Skip to content

Commit 46cfda8

Browse files
youknowriadclaude
andauthored
apps/ui, apps/cli: queue follow-up prompts during an active agent turn (#3146)
Let users stage new prompts while the agent is running instead of having to wait for a turn to end. Staged prompts render as faint user-style bubbles and auto-dispatch FIFO when the current run ends. Users can drop them before they fire (× in the GUI, backspace on an empty editor in the CLI). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent aa2d36c commit 46cfda8

7 files changed

Lines changed: 282 additions & 26 deletions

File tree

‎apps/cli/ai/tests/ui.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ describe( 'AiChatUI.clearTranscript', () => {
194194
ui.currentMarkdown = { someMarkdown: true };
195195
ui.currentResponseText = 'in-progress';
196196
ui.hideLoader = vi.fn();
197+
ui.queuedPrompts = [];
197198

198199
ui.clearTranscript();
199200

‎apps/cli/ai/ui.ts‎

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,18 @@ function formatToolOutputLines( lines: string[] ): string {
7777
.join( '\n' );
7878
}
7979

80+
// Faint variant of the user bubble used by `addUserMessage`, for prompts that
81+
// were staged during an active turn and haven't been dispatched yet.
82+
function formatQueuedPrompt( text: string ): string {
83+
const lines = text.split( '\n' );
84+
return lines
85+
.map( ( line, i ) => {
86+
const body = i === 0 ? '↳ ' + line + ' ' : ' ' + line + ' ';
87+
return ' ' + chalk.bgHex( '#e8eef5' ).hex( '#5a6b7d' )( body );
88+
} )
89+
.join( '\n' );
90+
}
91+
8092
class PromptEditor implements Component, Focusable {
8193
private editor: Editor;
8294
private borderColorFn: ( text: string ) => string;
@@ -365,6 +377,8 @@ export class AiChatUI implements AiOutputAdapter {
365377
private editor: PromptEditor;
366378
private loader: Loader;
367379
private messages: Container;
380+
private queuedContainer: Container;
381+
private queuedPrompts: string[] = [];
368382
private currentResponseText = '';
369383
private currentMarkdown: Markdown | null = null;
370384
private submitResolve: ( ( text: string ) => void ) | null = null;
@@ -476,6 +490,10 @@ export class AiChatUI implements AiOutputAdapter {
476490
this.currentMarkdown = null;
477491
this.currentResponseText = '';
478492
this.messages.clear();
493+
if ( this.queuedPrompts.length > 0 ) {
494+
this.queuedPrompts = [];
495+
this.renderQueuedContainer();
496+
}
479497
this.tui.requestRender();
480498
}
481499

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

518+
// Always mounted just after `messages` and (once shown) just before the
519+
// editor, so staged follow-up prompts render in that gap regardless of
520+
// whether the loader is currently visible.
521+
this.queuedContainer = new Container();
522+
this.tui.addChild( this.queuedContainer );
523+
500524
this.loader = new Loader(
501525
this.tui,
502526
( str ) => chalk.yellow( str ),
@@ -537,10 +561,21 @@ export class AiChatUI implements AiOutputAdapter {
537561

538562
this.editor.onSubmit = ( text ) => {
539563
const trimmed = text.trim();
540-
if ( trimmed && this.submitResolve ) {
564+
if ( ! trimmed ) {
565+
return;
566+
}
567+
if ( this.submitResolve ) {
541568
const resolve = this.submitResolve;
542569
this.submitResolve = null;
543570
resolve( trimmed );
571+
return;
572+
}
573+
// No waiter → we're mid-turn. Stage the prompt so it fires after
574+
// the current run ends; `waitForInput` drains the head.
575+
if ( this._inAgentTurn ) {
576+
this.queuedPrompts.push( trimmed );
577+
this.editor.setText( '' );
578+
this.renderQueuedContainer();
544579
}
545580
};
546581
// Ctrl+C to exit, Escape to interrupt/close picker, arrow keys for picker
@@ -650,6 +685,19 @@ export class AiChatUI implements AiOutputAdapter {
650685
this.renderSitePicker();
651686
return { consume: true };
652687
}
688+
// Backspace on an empty editor pops the most recent queued prompt.
689+
// Mirrors the × discard affordance in the GUI — lightweight undo
690+
// for staged follow-ups without adding a new keybinding.
691+
if (
692+
matchesKey( data, 'backspace' ) &&
693+
this.editorVisible &&
694+
this.queuedPrompts.length > 0 &&
695+
this.editor.getText() === ''
696+
) {
697+
this.queuedPrompts.pop();
698+
this.renderQueuedContainer();
699+
return { consume: true };
700+
}
653701
if ( matchesKey( data, 'escape' ) && this.interruptCallback ) {
654702
this.wasInterrupted = true;
655703
this.interruptCallback();
@@ -1203,6 +1251,11 @@ export class AiChatUI implements AiOutputAdapter {
12031251
this.editor.setText( '' );
12041252
this.hideLoader();
12051253
this.showEditor();
1254+
if ( this.queuedPrompts.length > 0 ) {
1255+
const next = this.queuedPrompts.shift()!;
1256+
this.renderQueuedContainer();
1257+
return Promise.resolve( next );
1258+
}
12061259
return new Promise( ( resolve ) => {
12071260
this.submitResolve = resolve;
12081261
} );
@@ -1222,6 +1275,15 @@ export class AiChatUI implements AiOutputAdapter {
12221275
this.tui.requestRender();
12231276
}
12241277

1278+
private renderQueuedContainer(): void {
1279+
this.queuedContainer.clear();
1280+
for ( const prompt of this.queuedPrompts ) {
1281+
this.queuedContainer.addChild( new Text( '\n' + formatQueuedPrompt( prompt ), 0, 0 ) );
1282+
}
1283+
this.updateHints();
1284+
this.tui.requestRender();
1285+
}
1286+
12251287
private lastProgressText: Text | null = null;
12261288

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

12411303
private showLoader( message?: string ): void {
12421304
if ( ! this.loaderVisible ) {
1243-
// Ensure editor is removed first so loader appears above it
1305+
// Re-attach trailing children in order so the final stack is
1306+
// [..., loader, queuedContainer, editor?] — loader just above the
1307+
// staged follow-ups, which sit just above the editor.
12441308
const wasEditorVisible = this.editorVisible;
12451309
if ( wasEditorVisible ) {
12461310
this.tui.removeChild( this.editor );
12471311
}
1312+
this.tui.removeChild( this.queuedContainer );
12481313
this.tui.addChild( this.loader );
1314+
this.tui.addChild( this.queuedContainer );
12491315
if ( wasEditorVisible ) {
12501316
this.tui.addChild( this.editor );
12511317
}
@@ -1278,6 +1344,9 @@ export class AiChatUI implements AiOutputAdapter {
12781344
this.activeExpandablePreview.isExpanded ? __( 'ctrl+o collapse' ) : __( 'ctrl+o expand' )
12791345
);
12801346
}
1347+
if ( this.queuedPrompts.length > 0 ) {
1348+
hints.push( __( 'backspace to unqueue' ) );
1349+
}
12811350
hints.push( __( 'esc to interrupt' ) );
12821351
this.editor.hints = hints;
12831352
}

‎apps/ui/src/components/session-view/composer/index.tsx‎

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,35 +3,42 @@ import { useCallback, useState } from 'react';
33
import styles from './style.module.css';
44

55
interface ComposerProps {
6-
disabled: boolean;
6+
busy: boolean;
77
error: string | null;
88
onSend: ( prompt: string ) => Promise< void >;
99
onInterrupt: () => Promise< void >;
1010
}
1111

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

1515
const send = useCallback( async () => {
1616
const trimmed = value.trim();
17-
if ( ! trimmed || disabled ) {
17+
if ( ! trimmed ) {
1818
return;
1919
}
2020
setValue( '' );
2121
try {
2222
await onSend( trimmed );
2323
} catch {
2424
// Restore the draft so the user can retry; the parent surfaces the
25-
// error message via `error`.
25+
// error message via `error`. Queued sends never throw from onSend
26+
// (the parent swallows the failure and clears the queue instead),
27+
// so this path only trips for direct sends from the idle state.
2628
setValue( trimmed );
2729
}
28-
}, [ value, disabled, onSend ] );
30+
}, [ value, onSend ] );
31+
32+
const placeholder = busy
33+
? __( 'Queue a follow-up instruction…' )
34+
: __( 'Set your next instruction…' );
35+
const sendLabel = busy ? __( 'Queue' ) : __( 'Send' );
2936

3037
return (
3138
<div className={ styles.root }>
3239
<textarea
3340
className={ styles.input }
34-
placeholder={ __( 'Set your next instruction…' ) }
41+
placeholder={ placeholder }
3542
value={ value }
3643
onChange={ ( event ) => setValue( event.target.value ) }
3744
onKeyDown={ ( event ) => {
@@ -40,26 +47,24 @@ export function Composer( { disabled, error, onSend, onInterrupt }: ComposerProp
4047
void send();
4148
}
4249
} }
43-
disabled={ disabled }
4450
rows={ 3 }
4551
/>
4652
<div className={ styles.footer }>
4753
{ error ? <span className={ styles.error }>{ error }</span> : null }
4854
<div className={ styles.actions }>
49-
{ disabled ? (
55+
{ busy ? (
5056
<button type="button" className={ styles.button } onClick={ () => void onInterrupt() }>
5157
{ __( 'Stop' ) }
5258
</button>
53-
) : (
54-
<button
55-
type="button"
56-
className={ styles.button }
57-
onClick={ () => void send() }
58-
disabled={ ! value.trim() }
59-
>
60-
{ __( 'Send' ) }
61-
</button>
62-
) }
59+
) : null }
60+
<button
61+
type="button"
62+
className={ styles.button }
63+
onClick={ () => void send() }
64+
disabled={ ! value.trim() }
65+
>
66+
{ sendLabel }
67+
</button>
6368
</div>
6469
</div>
6570
</div>

‎apps/ui/src/components/session-view/index.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { clsx } from 'clsx';
33
import { useLayoutEffect, useMemo, useRef } from 'react';
44
import { Composer } from '@/components/session-view/composer';
55
import { Conversation } from '@/components/session-view/conversation';
6+
import { QueuedPrompts } from '@/components/session-view/queued-prompts';
67
import { SiteDropdown } from '@/components/site-dropdown';
78
import { useAgentRun } from '@/data/queries/use-agent-run';
89
import { useSession } from '@/data/queries/use-sessions';
@@ -53,9 +54,11 @@ export function SessionView( { sessionId }: { sessionId: string } ) {
5354
error: runError,
5455
pendingQuestions,
5556
pendingAnswers,
57+
queuedPrompts,
5658
sendMessage,
5759
interrupt,
5860
answerQuestion,
61+
removeQueuedPrompt,
5962
} = useAgentRun( sessionId );
6063
const pendingQuestionTexts = useMemo(
6164
() => new Set( pendingQuestions.map( ( q ) => q.question ) ),
@@ -74,7 +77,7 @@ export function SessionView( { sessionId }: { sessionId: string } ) {
7477
node.scrollTop = node.scrollHeight;
7578
} );
7679
return () => cancelAnimationFrame( id );
77-
}, [ sessionId, data, isRunning ] );
80+
}, [ sessionId, data, isRunning, queuedPrompts.length ] );
7881

7982
if ( isLoading ) {
8083
return <div className={ styles.state }>{ __( 'Loading session…' ) }</div>;
@@ -106,8 +109,9 @@ export function SessionView( { sessionId }: { sessionId: string } ) {
106109
</div>
107110
<div className={ styles.composerOuter }>
108111
<div className={ styles.column }>
112+
<QueuedPrompts prompts={ queuedPrompts } onRemove={ removeQueuedPrompt } />
109113
<Composer
110-
disabled={ composerBusy }
114+
busy={ composerBusy }
111115
error={ runError }
112116
onSend={ sendMessage }
113117
onInterrupt={ interrupt }
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { __ } from '@wordpress/i18n';
2+
import styles from './style.module.css';
3+
import type { QueuedPrompt } from '@/data/queries/use-agent-run';
4+
5+
interface QueuedPromptsProps {
6+
prompts: QueuedPrompt[];
7+
onRemove: ( id: string ) => void;
8+
}
9+
10+
export function QueuedPrompts( { prompts, onRemove }: QueuedPromptsProps ) {
11+
if ( prompts.length === 0 ) {
12+
return null;
13+
}
14+
return (
15+
<div className={ styles.root } aria-label={ __( 'Queued follow-ups' ) }>
16+
{ prompts.map( ( item ) => (
17+
<div key={ item.id } className={ styles.item }>
18+
<span className={ styles.text }>{ item.prompt }</span>
19+
<button
20+
type="button"
21+
className={ styles.remove }
22+
onClick={ () => onRemove( item.id ) }
23+
aria-label={ __( 'Discard queued follow-up' ) }
24+
title={ __( 'Discard queued follow-up' ) }
25+
>
26+
×
27+
</button>
28+
</div>
29+
) ) }
30+
</div>
31+
);
32+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
.root {
2+
display: flex;
3+
flex-direction: column;
4+
gap: var(--wpds-dimension-padding-sm);
5+
margin-bottom: var(--wpds-dimension-padding-md);
6+
}
7+
8+
.item {
9+
position: relative;
10+
align-self: flex-end;
11+
display: flex;
12+
align-items: flex-start;
13+
gap: var(--wpds-dimension-padding-sm);
14+
padding: var(--wpds-dimension-padding-md) var(--wpds-dimension-padding-lg);
15+
/* Match the user bubble shape but lighter so staged follow-ups read as
16+
"drafted, not sent". Mirrors `.userText` (right-aligned, bottom-right
17+
tight corner). */
18+
background-color: color-mix(in srgb, var(--wpds-color-fg-content-accent, #2563eb) 4%, transparent);
19+
border: 1px dashed color-mix(in srgb, var(--wpds-color-fg-content-accent, #2563eb) 30%, transparent);
20+
border-radius: 18px 18px var(--wpds-border-radius-md, 8px) 18px;
21+
max-width: 80%;
22+
font-size: var(--wpds-font-size-md);
23+
line-height: 1.55;
24+
color: var(--wpds-color-fg-content-neutral-weak);
25+
}
26+
27+
.text {
28+
flex: 1;
29+
min-width: 0;
30+
white-space: pre-wrap;
31+
word-wrap: break-word;
32+
}
33+
34+
.remove {
35+
appearance: none;
36+
flex-shrink: 0;
37+
width: 22px;
38+
height: 22px;
39+
margin: -2px -6px -2px 0;
40+
border: 0;
41+
border-radius: 50%;
42+
padding: 0;
43+
background: transparent;
44+
color: var(--wpds-color-fg-content-neutral-weak);
45+
font: inherit;
46+
font-size: 16px;
47+
line-height: 1;
48+
cursor: pointer;
49+
opacity: 0.6;
50+
}
51+
52+
.item:hover .remove,
53+
.remove:focus-visible {
54+
opacity: 1;
55+
}
56+
57+
.remove:hover,
58+
.remove:focus-visible {
59+
background-color: color-mix(in srgb, var(--wpds-color-fg-content-accent, #2563eb) 12%, transparent);
60+
color: var(--wpds-color-fg-content-neutral);
61+
}

0 commit comments

Comments
 (0)