Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
9 changes: 5 additions & 4 deletions apps/cli/ai/tools/generate-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import fs from 'fs/promises';
import path from 'path';
import { Type } from 'typebox';
import { STUDIO_SITES_ROOT } from 'cli/lib/site-paths';
import { emitProgress } from 'cli/logger';
import {
composeImagePrompt,
generateImages,
Expand Down Expand Up @@ -84,7 +83,7 @@ export const generateImagesTool = defineTool(
} )
),
},
async ( args ) => {
async ( args, context ) => {
if ( ! ( await isImageGenerationAvailable() ) ) {
throw new Error(
'Image generation is not available in this session. Build the site without generated imagery.'
Expand All @@ -96,7 +95,9 @@ export const generateImagesTool = defineTool(
resolvedPath: resolveImageFilePath( image.path ),
} ) );

emitProgress( `Generating ${ targets.length } image${ targets.length === 1 ? '' : 's' }…` );
context.onProgress(
`Generating ${ targets.length } image${ targets.length === 1 ? '' : 's' }…`
);

const requests = targets.map( ( image ) => ( {
prompt: composeImagePrompt( image, {
Expand All @@ -111,7 +112,7 @@ export const generateImagesTool = defineTool(
const results = await generateImages( requests, ( _index, result ) => {
if ( result.ok ) {
generated++;
emitProgress( `Generated ${ generated }/${ targets.length } images` );
context.onProgress( `Generated ${ generated }/${ targets.length } images` );
}
} );

Expand Down
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
17 changes: 17 additions & 0 deletions apps/studio/src/components/studio-code-session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,18 @@ 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 );
}, [] );
const canEditLastUserMessage = useMemo(
() => ! composerBusy && ! isRunning && wasLastTurnInterrupted( data?.entries ?? [] ),
[ composerBusy, isRunning, data?.entries ]
Expand Down Expand Up @@ -427,6 +439,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 +474,9 @@ function SessionContent( { selectedSite }: { selectedSite: SiteDetails } ) {
pendingQuestions={ pendingQuestionTexts }
pendingAnswers={ pendingAnswers }
answeredQuestions={ answeredQuestions }
freeFormQuestion={ freeFormQuestion }
onAnswerQuestion={ answerQuestion }
onChooseFreeForm={ chooseFreeFormAnswer }
canEditLastUserMessage={ canEditLastUserMessage }
Comment thread
gcsecsey marked this conversation as resolved.
onEditUserMessage={ editAndResendMessage }
/>
Expand Down
Loading