Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 36 additions & 13 deletions apps/studio/src/components/studio-code-session/composer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ export function ComposerSkeleton() {

interface ComposerProps {
busy: boolean;
// The agent is blocked on `ask_user`. Sending cancels the questions and
// delivers the message as a new turn, so this is a send, not a queue.
awaitingAnswer?: boolean;
// The user armed a question's "Something else" option.
freeFormActive?: boolean;
// Bump to move focus into the textarea without touching its content.
focusRequestId?: number;
isInterrupting?: boolean;
error: string | null;
usageCapMessage?: string | null;
Expand All @@ -91,6 +98,15 @@ interface ComposerProps {
previewPrompt?: string | null;
}

function focusAtEnd( node: HTMLTextAreaElement | null ) {
if ( ! node ) {
return;
}
node.focus();
const length = node.value.length;
node.setSelectionRange( length, length );
}

const isMacPlatform =
typeof navigator !== 'undefined' && /mac/i.test( navigator.platform || navigator.userAgent );

Expand Down Expand Up @@ -227,6 +243,9 @@ function getSessionPlaceholder( sessionId: string | undefined ): string {

export function Composer( {
busy,
awaitingAnswer = false,
freeFormActive = false,
focusRequestId = 0,
isInterrupting = false,
error,
usageCapMessage,
Expand Down Expand Up @@ -281,17 +300,16 @@ export function Composer( {
}
appliedDraftPromptIdRef.current = draftPrompt.id;
setDraftValue( draftPrompt.prompt );
queueMicrotask( () => {
const node = textareaRef.current;
if ( ! node ) {
return;
}
node.focus();
const length = node.value.length;
node.setSelectionRange( length, length );
} );
queueMicrotask( () => focusAtEnd( textareaRef.current ) );
}, [ draftPrompt, setDraftValue ] );

useEffect( () => {
if ( focusRequestId === 0 ) {
return;
}
focusAtEnd( textareaRef.current );
}, [ focusRequestId ] );

useEffect( () => {
setValue( loadDraft( draftStorageKey ) );
}, [ draftStorageKey ] );
Expand Down Expand Up @@ -449,10 +467,15 @@ export function Composer( {
}, [ onSwitchSession, ownerSiteId, pendingFamilyChange, queryClient ] );

const canSend = value.trim().length > 0 || attachments.length > 0;
const placeholder = busy
? __( 'Queue a follow-up instruction…' )
: getSessionPlaceholder( sessionId );
const sendAriaLabel = busy ? __( 'Queue' ) : __( 'Send' );
let placeholder = getSessionPlaceholder( sessionId );
if ( freeFormActive ) {
placeholder = __( 'Type your own answer…' );
} else if ( awaitingAnswer ) {
placeholder = __( 'Reply instead of choosing an option…' );
} else if ( busy ) {
placeholder = __( 'Queue a follow-up instruction…' );
}
const sendAriaLabel = busy && ! awaitingAnswer ? __( 'Queue' ) : __( 'Send' );
const modKey = isMacPlatform ? '⌘' : 'Ctrl';
const hoveredAttachment = hoverPreview
? attachments.find( ( attachment ) => attachment.id === hoverPreview.id )
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,63 @@ describe( 'entriesToRenderItems – persisted picked answers', () => {
} );
} );

describe( 'AgentQuestion – free-form escape hatch', () => {
function renderQuestion(
entries: SessionEntry[],
props: {
pendingQuestions?: Set< string >;
freeFormQuestion?: string | null;
onChooseFreeForm?: ( question: string ) => void;
} = {}
) {
render(
<Conversation
data={ { entries } as unknown as LoadedAiSession }
isRunning={ false }
startedAt={ null }
pendingQuestions={ props.pendingQuestions ?? new Set( [ 'Q1' ] ) }
pendingAnswers={ {} }
answeredQuestions={ {} }
freeFormQuestion={ props.freeFormQuestion ?? null }
onAnswerQuestion={ () => {} }
onChooseFreeForm={ props.onChooseFreeForm ?? ( () => {} ) }
/>
);
}

it( 'offers the free-form option the AskUserQuestion tool promises the model', () => {
const onChooseFreeForm = vi.fn();
renderQuestion( [ question( 'Q1', [ 'A', 'B' ] ) ], { onChooseFreeForm } );

const button = screen.getByRole( 'button', { name: 'Something else' } );
expect( button ).toHaveAttribute( 'aria-pressed', 'false' );

fireEvent.click( button );
expect( onChooseFreeForm ).toHaveBeenCalledWith( 'Q1' );
} );

it( 'marks the option as armed once chosen', () => {
renderQuestion( [ question( 'Q1', [ 'A', 'B' ] ) ], { freeFormQuestion: 'Q1' } );

expect( screen.getByRole( 'button', { name: 'Something else' } ) ).toHaveAttribute(
'aria-pressed',
'true'
);
} );

it( 'hides the option once the batch is no longer interactive', () => {
renderQuestion( [ question( 'Q1', [ 'A', 'B' ] ) ], { pendingQuestions: new Set() } );

expect( screen.queryByRole( 'button', { name: 'Something else' } ) ).not.toBeInTheDocument();
} );

it( 'does not duplicate an option an off-contract model wrote itself', () => {
renderQuestion( [ question( 'Q1', [ 'A', 'Something else' ] ) ] );

expect( screen.getAllByRole( 'button', { name: 'Something else' } ) ).toHaveLength( 1 );
} );
} );

describe( 'wasLastTurnInterrupted', () => {
it( 'is false for an in-flight or completed turn', () => {
expect( wasLastTurnInterrupted( [ prompt( 'Build me a blog' ) ] ) ).toBe( false );
Expand Down Expand Up @@ -259,7 +316,9 @@ describe( 'Conversation – inline media artifacts', () => {
pendingQuestions={ new Set() }
pendingAnswers={ {} }
answeredQuestions={ {} }
freeFormQuestion={ null }
onAnswerQuestion={ () => {} }
onChooseFreeForm={ () => {} }
/>
);
}
Expand Down Expand Up @@ -378,7 +437,9 @@ describe( 'Conversation – assistant message copy button', () => {
pendingQuestions={ new Set() }
pendingAnswers={ {} }
answeredQuestions={ {} }
freeFormQuestion={ null }
onAnswerQuestion={ () => {} }
onChooseFreeForm={ () => {} }
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ import {
type StudioCustomEntry,
} from '@studio/common/ai/sessions/entry-types';
import {
getFreeFormOptionDescription,
getFreeFormOptionLabel,
getToolDetail,
getToolDisplayName,
getToolResultDiff,
hasOwnFreeFormOption,
type NormalizedToolResult,
} from '@studio/common/ai/tools';
import { formatUsageCapNotice } from '@studio/common/lib/studio-assistant-quota';
Expand Down Expand Up @@ -617,14 +620,21 @@ function AgentQuestion( {
options,
isInteractive,
pickedLabel,
freeFormActive,
onAnswer,
onChooseFreeForm,
}: {
question: string;
options: Array< { label: string; description: string } >;
isInteractive: boolean;
pickedLabel: string | undefined;
freeFormActive: boolean;
onAnswer: ( label: string ) => void;
onChooseFreeForm: () => void;
} ) {
const freeFormLabel = getFreeFormOptionLabel();
const showFreeForm = isInteractive && ! hasOwnFreeFormOption( options );

return (
<div className={ styles.question }>
<p className={ styles.questionText }>{ question }</p>
Expand All @@ -646,6 +656,23 @@ function AgentQuestion( {
</li>
);
} ) }
{ showFreeForm ? (
<li>
<button
type="button"
className={ cx(
styles.questionOption,
styles.questionOptionFreeForm,
freeFormActive && styles.questionOptionPicked
) }
onClick={ onChooseFreeForm }
aria-pressed={ freeFormActive }
title={ getFreeFormOptionDescription() }
>
{ freeFormLabel }
</button>
</li>
) : null }
</ul>
) : null }
</div>
Expand Down Expand Up @@ -693,7 +720,9 @@ export function Conversation( {
pendingQuestions,
pendingAnswers,
answeredQuestions,
freeFormQuestion,
onAnswerQuestion,
onChooseFreeForm,
canEditLastUserMessage = false,
onEditUserMessage,
}: {
Expand All @@ -703,7 +732,11 @@ export function Conversation( {
pendingQuestions: Set< string >;
pendingAnswers: Record< string, string >;
answeredQuestions: Record< string, string >;
// Question whose "Something else" option is armed; its answer arrives from
// the composer rather than from an option click.
freeFormQuestion: string | null;
onAnswerQuestion: ( question: string, label: string ) => void;
onChooseFreeForm: ( question: string ) => void;
canEditLastUserMessage?: boolean;
onEditUserMessage?: ( entryId: string, text: string ) => void;
} ) {
Expand Down Expand Up @@ -795,7 +828,9 @@ export function Conversation( {
answeredQuestions[ item.question ] ??
item.answer
}
freeFormActive={ freeFormQuestion === item.question }
onAnswer={ ( label ) => onAnswerQuestion( item.question, label ) }
onChooseFreeForm={ () => onChooseFreeForm( item.question ) }
/>
);
case 'chat-artifact':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,17 @@
cursor: default;
}

/* Dashed border sets the appended free-form escape hatch apart from the
options the agent actually offered. */
.questionOptionFreeForm {
border-style: dashed;
color: var(--color-frame-text-secondary);
}

.questionOptionFreeForm.questionOptionPicked {
border-style: solid;
}

/* Keep the picked option fully legible even when disabled, so the user
can still see their choice once the batch is finalized. */
.questionOptionPicked,
Expand Down
29 changes: 28 additions & 1 deletion apps/studio/src/components/studio-code-session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,28 @@ function SessionContent( { selectedSite }: { selectedSite: SiteDetails } ) {
[ pendingQuestions ]
);
const composerBusy = hasActiveRun || pendingQuestions.length > 0;
// Which question the user chose to answer in their own words. Derived, so a
// stale prompt can't outlive the batch it belongs to.
const [ armedFreeFormQuestion, setArmedFreeFormQuestion ] = useState< string | null >( null );
const freeFormQuestion =
armedFreeFormQuestion && pendingQuestionTexts.has( armedFreeFormQuestion )
? armedFreeFormQuestion
: null;
const [ composerFocusRequestId, setComposerFocusRequestId ] = useState( 0 );
const chooseFreeFormAnswer = useCallback( ( question: string ) => {
setArmedFreeFormQuestion( question );
setComposerFocusRequestId( ( id ) => id + 1 );
}, [] );
// Picking a listed option supersedes an armed free-form reply for that same
// question. Answering a *different* one leaves the arming alone, and
// arming again after picking still works, so a pick stays changeable.
const answerQuestionFromOption = useCallback(
( question: string, label: string ) => {
setArmedFreeFormQuestion( ( armed ) => ( armed === question ? null : armed ) );
answerQuestion( question, label );
},
[ answerQuestion ]
);
const canEditLastUserMessage = useMemo(
() => ! composerBusy && ! isRunning && wasLastTurnInterrupted( data?.entries ?? [] ),
[ composerBusy, isRunning, data?.entries ]
Expand Down Expand Up @@ -427,6 +449,9 @@ function SessionContent( { selectedSite }: { selectedSite: SiteDetails } ) {
) : (
<Composer
busy={ composerBusy }
awaitingAnswer={ pendingQuestions.length > 0 }
freeFormActive={ freeFormQuestion !== null }
focusRequestId={ composerFocusRequestId }
isInterrupting={ isInterrupting }
error={ usageCapReached ? null : runError }
usageCapMessage={ usageCapReached ? runError : null }
Expand Down Expand Up @@ -459,7 +484,9 @@ function SessionContent( { selectedSite }: { selectedSite: SiteDetails } ) {
pendingQuestions={ pendingQuestionTexts }
pendingAnswers={ pendingAnswers }
answeredQuestions={ answeredQuestions }
onAnswerQuestion={ answerQuestion }
freeFormQuestion={ freeFormQuestion }
onAnswerQuestion={ answerQuestionFromOption }
onChooseFreeForm={ chooseFreeFormAnswer }
canEditLastUserMessage={ canEditLastUserMessage }
onEditUserMessage={ editAndResendMessage }
/>
Expand Down
Loading