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

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/cli/ai/tests/ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ describe( 'AiChatUI.clearTranscript', () => {
ui.currentMarkdown = { someMarkdown: true };
ui.currentResponseText = 'in-progress';
ui.hideLoader = vi.fn();
ui.queuedPrompts = [];

ui.clearTranscript();

Expand Down
73 changes: 71 additions & 2 deletions apps/cli/ai/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ function formatToolOutputLines( lines: string[] ): string {
.join( '\n' );
}

// Faint variant of the user bubble used by `addUserMessage`, for prompts that
// were staged during an active turn and haven't been dispatched yet.
function formatQueuedPrompt( text: string ): string {
const lines = text.split( '\n' );
return lines
.map( ( line, i ) => {
const body = i === 0 ? '↳ ' + line + ' ' : ' ' + line + ' ';
return ' ' + chalk.bgHex( '#e8eef5' ).hex( '#5a6b7d' )( body );
} )
.join( '\n' );
}

class PromptEditor implements Component, Focusable {
private editor: Editor;
private borderColorFn: ( text: string ) => string;
Expand Down Expand Up @@ -365,6 +377,8 @@ export class AiChatUI implements AiOutputAdapter {
private editor: PromptEditor;
private loader: Loader;
private messages: Container;
private queuedContainer: Container;
private queuedPrompts: string[] = [];
private currentResponseText = '';
private currentMarkdown: Markdown | null = null;
private submitResolve: ( ( text: string ) => void ) | null = null;
Expand Down Expand Up @@ -476,6 +490,10 @@ export class AiChatUI implements AiOutputAdapter {
this.currentMarkdown = null;
this.currentResponseText = '';
this.messages.clear();
if ( this.queuedPrompts.length > 0 ) {
this.queuedPrompts = [];
this.renderQueuedContainer();
}
this.tui.requestRender();
}

Expand All @@ -497,6 +515,12 @@ export class AiChatUI implements AiOutputAdapter {
this.messages = new Container();
this.tui.addChild( this.messages );

// Always mounted just after `messages` and (once shown) just before the
// editor, so staged follow-up prompts render in that gap regardless of
// whether the loader is currently visible.
this.queuedContainer = new Container();
this.tui.addChild( this.queuedContainer );

this.loader = new Loader(
this.tui,
( str ) => chalk.yellow( str ),
Expand Down Expand Up @@ -537,10 +561,21 @@ export class AiChatUI implements AiOutputAdapter {

this.editor.onSubmit = ( text ) => {
const trimmed = text.trim();
if ( trimmed && this.submitResolve ) {
if ( ! trimmed ) {
return;
}
if ( this.submitResolve ) {
const resolve = this.submitResolve;
this.submitResolve = null;
resolve( trimmed );
return;
}
// No waiter → we're mid-turn. Stage the prompt so it fires after
// the current run ends; `waitForInput` drains the head.
if ( this._inAgentTurn ) {
this.queuedPrompts.push( trimmed );
this.editor.setText( '' );
this.renderQueuedContainer();
}
};
// Ctrl+C to exit, Escape to interrupt/close picker, arrow keys for picker
Expand Down Expand Up @@ -650,6 +685,19 @@ export class AiChatUI implements AiOutputAdapter {
this.renderSitePicker();
return { consume: true };
}
// Backspace on an empty editor pops the most recent queued prompt.
// Mirrors the × discard affordance in the GUI — lightweight undo
// for staged follow-ups without adding a new keybinding.
if (
matchesKey( data, 'backspace' ) &&
this.editorVisible &&
this.queuedPrompts.length > 0 &&
this.editor.getText() === ''
) {
this.queuedPrompts.pop();
this.renderQueuedContainer();
return { consume: true };
}
if ( matchesKey( data, 'escape' ) && this.interruptCallback ) {
this.wasInterrupted = true;
this.interruptCallback();
Expand Down Expand Up @@ -1203,6 +1251,11 @@ export class AiChatUI implements AiOutputAdapter {
this.editor.setText( '' );
this.hideLoader();
this.showEditor();
if ( this.queuedPrompts.length > 0 ) {
const next = this.queuedPrompts.shift()!;
this.renderQueuedContainer();
return Promise.resolve( next );
}
return new Promise( ( resolve ) => {
this.submitResolve = resolve;
} );
Expand All @@ -1222,6 +1275,15 @@ export class AiChatUI implements AiOutputAdapter {
this.tui.requestRender();
}

private renderQueuedContainer(): void {
this.queuedContainer.clear();
for ( const prompt of this.queuedPrompts ) {
this.queuedContainer.addChild( new Text( '\n' + formatQueuedPrompt( prompt ), 0, 0 ) );
}
this.updateHints();
this.tui.requestRender();
}

private lastProgressText: Text | null = null;

setLoaderMessage( message: string, update?: boolean ): void {
Expand All @@ -1240,12 +1302,16 @@ export class AiChatUI implements AiOutputAdapter {

private showLoader( message?: string ): void {
if ( ! this.loaderVisible ) {
// Ensure editor is removed first so loader appears above it
// Re-attach trailing children in order so the final stack is
// [..., loader, queuedContainer, editor?] — loader just above the
// staged follow-ups, which sit just above the editor.
const wasEditorVisible = this.editorVisible;
if ( wasEditorVisible ) {
this.tui.removeChild( this.editor );
}
this.tui.removeChild( this.queuedContainer );
this.tui.addChild( this.loader );
this.tui.addChild( this.queuedContainer );
if ( wasEditorVisible ) {
this.tui.addChild( this.editor );
}
Expand Down Expand Up @@ -1278,6 +1344,9 @@ export class AiChatUI implements AiOutputAdapter {
this.activeExpandablePreview.isExpanded ? __( 'ctrl+o collapse' ) : __( 'ctrl+o expand' )
);
}
if ( this.queuedPrompts.length > 0 ) {
hints.push( __( 'backspace to unqueue' ) );
}
hints.push( __( 'esc to interrupt' ) );
this.editor.hints = hints;
}
Expand Down
41 changes: 23 additions & 18 deletions apps/ui/src/components/session-view/composer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,42 @@ import { useCallback, useState } from 'react';
import styles from './style.module.css';

interface ComposerProps {
disabled: boolean;
busy: boolean;
error: string | null;
onSend: ( prompt: string ) => Promise< void >;
onInterrupt: () => Promise< void >;
}

export function Composer( { disabled, error, onSend, onInterrupt }: ComposerProps ) {
export function Composer( { busy, error, onSend, onInterrupt }: ComposerProps ) {
const [ value, setValue ] = useState( '' );

const send = useCallback( async () => {
const trimmed = value.trim();
if ( ! trimmed || disabled ) {
if ( ! trimmed ) {
return;
}
setValue( '' );
try {
await onSend( trimmed );
} catch {
// Restore the draft so the user can retry; the parent surfaces the
// error message via `error`.
// error message via `error`. Queued sends never throw from onSend
// (the parent swallows the failure and clears the queue instead),
// so this path only trips for direct sends from the idle state.
setValue( trimmed );
}
}, [ value, disabled, onSend ] );
}, [ value, onSend ] );

const placeholder = busy
? __( 'Queue a follow-up instruction…' )
: __( 'Set your next instruction…' );
const sendLabel = busy ? __( 'Queue' ) : __( 'Send' );

return (
<div className={ styles.root }>
<textarea
className={ styles.input }
placeholder={ __( 'Set your next instruction…' ) }
placeholder={ placeholder }
value={ value }
onChange={ ( event ) => setValue( event.target.value ) }
onKeyDown={ ( event ) => {
Expand All @@ -40,26 +47,24 @@ export function Composer( { disabled, error, onSend, onInterrupt }: ComposerProp
void send();
}
} }
disabled={ disabled }
rows={ 3 }
/>
<div className={ styles.footer }>
{ error ? <span className={ styles.error }>{ error }</span> : null }
<div className={ styles.actions }>
{ disabled ? (
{ busy ? (
<button type="button" className={ styles.button } onClick={ () => void onInterrupt() }>
{ __( 'Stop' ) }
</button>
) : (
<button
type="button"
className={ styles.button }
onClick={ () => void send() }
disabled={ ! value.trim() }
>
{ __( 'Send' ) }
</button>
) }
) : null }
<button
type="button"
className={ styles.button }
onClick={ () => void send() }
disabled={ ! value.trim() }
>
{ sendLabel }
</button>
</div>
</div>
</div>
Expand Down
8 changes: 6 additions & 2 deletions apps/ui/src/components/session-view/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { clsx } from 'clsx';
import { useLayoutEffect, useMemo, useRef } from 'react';
import { Composer } from '@/components/session-view/composer';
import { Conversation } from '@/components/session-view/conversation';
import { QueuedPrompts } from '@/components/session-view/queued-prompts';
import { SiteDropdown } from '@/components/site-dropdown';
import { useAgentRun } from '@/data/queries/use-agent-run';
import { useSession } from '@/data/queries/use-sessions';
Expand Down Expand Up @@ -53,9 +54,11 @@ export function SessionView( { sessionId }: { sessionId: string } ) {
error: runError,
pendingQuestions,
pendingAnswers,
queuedPrompts,
sendMessage,
interrupt,
answerQuestion,
removeQueuedPrompt,
} = useAgentRun( sessionId );
const pendingQuestionTexts = useMemo(
() => new Set( pendingQuestions.map( ( q ) => q.question ) ),
Expand All @@ -74,7 +77,7 @@ export function SessionView( { sessionId }: { sessionId: string } ) {
node.scrollTop = node.scrollHeight;
} );
return () => cancelAnimationFrame( id );
}, [ sessionId, data, isRunning ] );
}, [ sessionId, data, isRunning, queuedPrompts.length ] );

if ( isLoading ) {
return <div className={ styles.state }>{ __( 'Loading session…' ) }</div>;
Expand Down Expand Up @@ -106,8 +109,9 @@ export function SessionView( { sessionId }: { sessionId: string } ) {
</div>
<div className={ styles.composerOuter }>
<div className={ styles.column }>
<QueuedPrompts prompts={ queuedPrompts } onRemove={ removeQueuedPrompt } />
<Composer
disabled={ composerBusy }
busy={ composerBusy }
error={ runError }
onSend={ sendMessage }
onInterrupt={ interrupt }
Expand Down
32 changes: 32 additions & 0 deletions apps/ui/src/components/session-view/queued-prompts/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { __ } from '@wordpress/i18n';
import styles from './style.module.css';
import type { QueuedPrompt } from '@/data/queries/use-agent-run';

interface QueuedPromptsProps {
prompts: QueuedPrompt[];
onRemove: ( id: string ) => void;
}

export function QueuedPrompts( { prompts, onRemove }: QueuedPromptsProps ) {
if ( prompts.length === 0 ) {
return null;
}
return (
<div className={ styles.root } aria-label={ __( 'Queued follow-ups' ) }>
{ prompts.map( ( item ) => (
<div key={ item.id } className={ styles.item }>
<span className={ styles.text }>{ item.prompt }</span>
<button
type="button"
className={ styles.remove }
onClick={ () => onRemove( item.id ) }
aria-label={ __( 'Discard queued follow-up' ) }
title={ __( 'Discard queued follow-up' ) }
>
×
</button>
</div>
) ) }
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
.root {
display: flex;
flex-direction: column;
gap: var(--wpds-dimension-padding-sm);
margin-bottom: var(--wpds-dimension-padding-md);
}

.item {
position: relative;
align-self: flex-end;
display: flex;
align-items: flex-start;
gap: var(--wpds-dimension-padding-sm);
padding: var(--wpds-dimension-padding-md) var(--wpds-dimension-padding-lg);
/* Match the user bubble shape but lighter so staged follow-ups read as
"drafted, not sent". Mirrors `.userText` (right-aligned, bottom-right
tight corner). */
background-color: color-mix(in srgb, var(--wpds-color-fg-content-accent, #2563eb) 4%, transparent);
border: 1px dashed color-mix(in srgb, var(--wpds-color-fg-content-accent, #2563eb) 30%, transparent);
border-radius: 18px 18px var(--wpds-border-radius-md, 8px) 18px;
max-width: 80%;
font-size: var(--wpds-font-size-md);
line-height: 1.55;
color: var(--wpds-color-fg-content-neutral-weak);
}

.text {
flex: 1;
min-width: 0;
white-space: pre-wrap;
word-wrap: break-word;
}

.remove {
appearance: none;
flex-shrink: 0;
width: 22px;
height: 22px;
margin: -2px -6px -2px 0;
border: 0;
border-radius: 50%;
padding: 0;
background: transparent;
color: var(--wpds-color-fg-content-neutral-weak);
font: inherit;
font-size: 16px;
line-height: 1;
cursor: pointer;
opacity: 0.6;
}

.item:hover .remove,
.remove:focus-visible {
opacity: 1;
}

.remove:hover,
.remove:focus-visible {
background-color: color-mix(in srgb, var(--wpds-color-fg-content-accent, #2563eb) 12%, transparent);
color: var(--wpds-color-fg-content-neutral);
}
Loading