From 1a646e6519af48d80f81651f617629410ca762d3 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 19 Aug 2026 13:42:44 -0700 Subject: [PATCH 1/2] Let somebody talk to a Bot that is already working Typing into a channel while the Bot had the turn did nothing. The composer took the keystrokes and refused the send, so a person watching their coworker head off in the wrong direction had two ways out: stop the turn and lose whatever it had done, or wait for it to finish being wrong. Neither is what they wanted, which was to say "no, the other one" while it was working and have that land. A message typed mid-turn is now parked instead of dropped. It appears in the transcript straight away as their own bubble, faded, saying Queued underneath, with a Remove next to it; and when the turn ends everything parked runs as one follow-up turn with the lines joined by newlines. A burst of three corrections costs one turn, not three. The drain is keyed on the turn being over and never asks how it ended, which is what makes Stop a way of steering rather than a way of giving up: park a correction, press Stop, and the correction is what runs next. There is no stop path in the code to forget about. The rule is one pure reducer in composer/queue.ts and is tested as one. The state is held by ConversationView, which is the nearest thing that owns both the composer that parks a message and the transcript that has to show it. Its docblock says plainly what the state is worth: memory in one tab, gone on reload, not an outbox. The affordance is drawn only while a turn is in flight, so a reload finds no queue and shows none rather than promising to send words it will never send. The compose screen does not get this. It creates the channel on send and then navigates away, so anything parked there would go down with the unmount, which is worse than a send button that visibly will not go. Queueing is therefore an opt-in prop and only the channel view asks for it. --- app/src/components/channels/channel-chat.tsx | 7 + .../components/channels/chat-transcript.tsx | 118 ++++++++++- .../components/channels/composer/composer.tsx | 84 ++++++-- app/src/components/channels/composer/index.ts | 6 + .../channels/composer/queue.test.ts | 185 ++++++++++++++++++ app/src/components/channels/composer/queue.ts | 137 +++++++++++++ .../components/channels/conversation-view.tsx | 144 +++++++++++++- 7 files changed, 664 insertions(+), 17 deletions(-) create mode 100644 app/src/components/channels/composer/queue.test.ts create mode 100644 app/src/components/channels/composer/queue.ts diff --git a/app/src/components/channels/channel-chat.tsx b/app/src/components/channels/channel-chat.tsx index 8f4546a22..f4134c436 100644 --- a/app/src/components/channels/channel-chat.tsx +++ b/app/src/components/channels/channel-chat.tsx @@ -336,6 +336,13 @@ export function ChannelChat({ copilotkit.stopAgent({ agent }); }} pending={agent.isRunning} + /* + * A channel outlives its turns, so it is the screen where waiting is worth offering. A + * correction typed mid-answer is held here, in this tab, and runs as one follow-up turn the + * moment this one is over — including when it is over because somebody pressed the button + * above. + */ + queueWhileBusy /> ); diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index 20d4c0c7a..52e487379 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -7,7 +7,11 @@ import { Streamdown } from "streamdown"; import { markdownComponents } from "@/lib/markdown"; import { EASE_OUT, ENTRANCE_SECONDS } from "@/lib/motion"; import { Bubble, BubbleContent } from "@/components/ui/bubble"; -import { MessageContent, Message as MessageRow } from "@/components/ui/message"; +import { + MessageContent, + MessageFooter, + Message as MessageRow, +} from "@/components/ui/message"; import { MessageScroller, MessageScrollerButton, @@ -15,8 +19,10 @@ import { MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport, + useMessageScroller, } from "@/components/ui/message-scroller"; import { toVisibleChatItems } from "./chat-messages"; +import type { QueuedMessage } from "./composer"; import { ToolLine } from "./tool-line"; import { ToolRenderBoundary } from "./tool-boundary"; @@ -25,8 +31,18 @@ type ChatTranscriptProps = { /** Comma-separated `/` command names, used to tell a skill chip from a leading slash. */ commandNames?: string; messages: ReadonlyArray>; + /** + * Typed while the Bot had the turn, and waiting for it to finish. Empty on a screen that does not + * offer queueing at all. + */ + queued?: readonly QueuedMessage[]; + /** Take one back before it runs. Without it a queued line is shown but cannot be undone. */ + onRemoveQueued?: (id: string) => void; }; +/** One shared empty array, so a screen without a queue does not hand down a new one per render. */ +const EMPTY_QUEUE: readonly QueuedMessage[] = []; + /** * Split a person's message into the skill they invoked and the rest of what they typed. * @@ -75,6 +91,87 @@ function Thinking() { ); } +/** + * Something the person said while the Bot was working, waiting its turn. + * + * IT IS DRAWN AS THEIR MESSAGE, NOT AS A NOTICE ABOUT ONE. The whole point of letting somebody type + * mid-turn is that they can see their words landed, and a status line saying "1 message queued" + * does not do that — they would still be wondering whether the sentence they typed is the sentence + * that will run. So it is the same bubble, in the same column, with the same wrapping, and only two + * things say it has not run yet: it is faded, and it says so underneath. + * + * The footer carries the taking-back too, because that is where the reader's eye already is once + * they have decided this was a mistake, and because a control on the bubble itself would have to + * hover over the words it is offering to delete. + */ +function Queued({ + text, + onRemove, +}: { + text: string; + onRemove?: (() => void) | undefined; +}) { + return ( + + + + + {/* Shown exactly as typed, for the same reason a sent message is. */} + {text} + + + + {/* + * `status` rather than `alert`, matching the thinking line: a person who has just chosen + * to queue something is not being interrupted by the news that it is queued. + */} + Queued + {onRemove ? ( + + ) : null} + + + + ); +} + +/** + * Put the newest queued message where the person who just typed it can see it. + * + * WITHOUT THIS THE AFFORDANCE IS INVISIBLE EXACTLY WHEN IT MATTERS. The scroller holds its anchor on + * the turn being answered rather than following the bottom, so during a long streamed answer the + * transcript sits a screen or so above the end — and a line appended below it lands off screen. + * Measured at the point somebody would actually use this: eighty-odd pixels under the fold, with + * the composer emptying at the same moment. They would have watched their correction vanish. + * + * Keyed on the newest queued id rather than on the list, so it does not fire again for every chunk + * of the answer still streaming above it. It does fire when the bottom-most queued line is taken + * back, which is a scroll nobody asked for and which lands on the end of the conversation anyway, + * and it stays quiet on a drain, when the id goes to null. + * + * Rendering nothing and living inside the provider is what buys access to the scroller at all; the + * alternative is threading a ref out through three components with no other reason to know a + * scroller exists. + */ +function ScrollNewestQueuedIntoView({ newest }: { newest: string | null }) { + const { scrollToEnd } = useMessageScroller(); + + useEffect(() => { + if (newest === null) { + return; + } + scrollToEnd(); + }, [newest, scrollToEnd]); + + return null; +} + /** * How many of the newest turns cascade when a channel is opened, and how far apart. * @@ -341,6 +438,8 @@ export function ChatTranscript({ busy = false, commandNames = "", messages, + onRemoveQueued, + queued = EMPTY_QUEUE, }: ChatTranscriptProps) { /* * NOT MEMOISED, AND THAT IS DELIBERATE. `useMemo` keyed on `messages` looks obviously right and @@ -436,9 +535,26 @@ export function ChatTranscript({ * the scroller to measure and anchor something that exists for a second and a half. */} {waitingOnFirstToken ? : null} + {/* + * Below the thinking line, and outside the item list for the same reason it is: these + * are not yet turns. They have ids of their own, but they are this tab's ids and not the + * thread's, so handing them to the scroller would ask it to anchor on something that is + * about to be replaced by a message with a different id — and the replacement is the + * one worth scrolling to. + */} + {queued.map((message) => ( + onRemoveQueued(message.id) : undefined + } + text={message.text} + /> + ))} + ); diff --git a/app/src/components/channels/composer/composer.tsx b/app/src/components/channels/composer/composer.tsx index 7ce029b1c..f1478a11f 100644 --- a/app/src/components/channels/composer/composer.tsx +++ b/app/src/components/channels/composer/composer.tsx @@ -43,6 +43,20 @@ export type ComposerProps = { * structured data instead of something it would have to re-parse out of the text. */ onSubmit?: (draft: ComposerDraft) => void | Promise; + /** + * Park this message until the turn in flight is over, instead of refusing the keystroke. + * + * Its presence is what lets a person type at a Bot that is already working. Without it the + * composer goes on refusing mid-turn sends, which is still the right answer for a screen that has + * nowhere to put a parked message — the compose screen creates the channel on send and then + * navigates away, so anything parked there would be dropped on unmount, and a message that + * silently disappears is worse than a send button that visibly will not go. + * + * Called instead of `onSubmit`, not as well as it, and it does not return a promise: parking is + * a state change, and awaiting one would hold the composer's send lock for the length of somebody + * else's turn and block the next correction. + */ + onQueue?: (draft: ComposerDraft) => void; /** Stop the Bot mid-answer; while pending, the send button becomes a stop button. */ onStop?: () => void; /** @@ -64,6 +78,7 @@ export function Composer({ agents = [], commands = PLACEHOLDER_COMMANDS, onSubmit, + onQueue, onStop, disabled = false, pending = false, @@ -107,13 +122,32 @@ export function Composer({ const submitDraft = useCallback( async (segments: Segment[]) => { const submitted = toDraft(segments); - if ( - submitted.isEmpty || - disabled || - isBusy || - submitInFlight.current || - !onSubmit - ) { + if (submitted.isEmpty || disabled) { + return; + } + + /* + * A TURN IS IN FLIGHT, AND THIS IS THE FORK THE WHOLE AFFORDANCE HANGS ON. + * + * With somewhere to park it the message goes there and the box empties, so the person sees + * their words land. Without, we are back to refusing, which is what every caller that does + * not queue still gets. + * + * It returns before `submitInFlight` and `isSubmitting` are touched on purpose. Those guard + * one send from starting twice; a send here is held open for the length of the whole run, so + * borrowing them for a parked message would let the first turn lock out every correction + * typed while it worked — the exact thing this exists to allow. + */ + if (isBusy) { + if (!onQueue) { + return; + } + setValue([]); + onQueue(submitted); + return; + } + + if (submitInFlight.current || !onSubmit) { return; } @@ -134,7 +168,7 @@ export function Composer({ wantsFocus.current = true; } }, - [disabled, isBusy, onSubmit], + [disabled, isBusy, onQueue, onSubmit], ); /** @@ -157,9 +191,33 @@ export function Composer({ void submitDraft(value); }; - const canSend = !disabled && !isBusy && !draft.isEmpty; - /** Stop is available only once the agent run is actually pending. */ - const canStop = Boolean(onStop) && pending; + /** + * There is a turn in flight and somewhere to park what is being typed. + * + * Not the same question as "is anything typed" — an empty composer mid-turn can queue nothing, + * and the button it wants is Stop. + */ + const canQueue = Boolean(onQueue) && isBusy && !disabled; + /** Something is typed, mid-turn, with a queue to put it in. */ + const parking = canQueue && !draft.isEmpty; + const canSend = !disabled && !draft.isEmpty && (!isBusy || canQueue); + /** + * Stop is available only once the agent run is actually pending, and it gives way to Send the + * moment there is something typed to park. + * + * One button, so one of the two has to yield. Send wins because the correction is the thing that + * cannot wait: park it and the box empties, which brings Stop straight back — so stopping is + * never more than one press away, and the press before it is the one that saves the sentence. + * Showing both would be honest and would also put two round buttons in a row on a compact + * composer that has room for one. + */ + const canStop = Boolean(onStop) && pending && !parking; + /** + * The same arrow either way, because it is the same gesture, but a screen reader is told which of + * the two it is about to do. "Send" on a button that will not send for another minute is a small + * lie told to exactly the people who cannot see the queue it lands in. + */ + const sendLabel = parking ? "Queue message" : "Send message"; if (compact) { return ( @@ -217,7 +275,7 @@ export function Composer({ ) : (