feat(chat): transcript polish, message queue, approval modes, shared composer
Replaces #8146. That branch is rebased onto the transcript polish (deny-first approvals, diff and terminal cards, step summaries, the working line, selectable text, document-style markdown), so both land together.
Rebase notes
Both branches rebuilt the transcript. The polish branch kept turn groups and scrolled with MessageScroller. This branch replaced the groups with one flat keyed row list and its own follow-and-pin scrolling. The flat list and its scrolling stay. The last commit puts the polish back on top of them:
ToolRunRowsays what the run did ("ran 6 commands, edited 3 files") throughstepCounts, which moved fromTurnGroupSectiontoToolRunRow.- A running turn whose newest row is not live ends with a "Working…" line.
- Transcript text is selectable, and the scroll gutter is reserved on both sides.
ReasoningRowandAgentMessageRowkeep the polish branch's behaviour (closed thoughts with a duration,useCopyToClipboard), and taketextinstead of the snapshot.- The unused
chat-uiMessageScrollerre-export is removed. The catalogs are regenerated.
After the rebase: bun test passes in apps/desktop v2-workspace (422), packages/chat (42) and packages/chat-runtime (179). Desktop typecheck shows no errors in chat code. The 28 better-auth errors described below are still there.
Summary
- Sending a message while the agent is working puts it in one queue on the host, whether it comes from the desktop UI or the host's chat API (
/chat-v3/trpc, e.g. from Roster). The queue sends messages in order as each turn ends. - The desktop shows that queue as a stack above the composer. Each row has Steer (stop the turn and send this message next), Delete, and … > Edit.
- Escape in the composer stops the turn. If messages are queued, the queue pauses, and the stack shows "Queue paused because you interrupted" with Resume.
getQueuelets API callers read the queue and the pause state directly.
Why / Context
PromptInputignored Enter while the agent was streaming, so you could not type ahead.LiveSessionalready queued prompts that arrived during a turn, but the only sign was a "Queued" badge in the transcript. There was no way to delete, steer, or see the queue from the API.
How It Works
Host (chat-runtime, LiveSession), new procedures
| Procedure | Behavior |
|---|---|
removeQueuedPrompt({ sessionId, itemId }) | Takes the prompt out of the queue and stores the item again with discarded: true. |
steerQueuedPrompt({ sessionId, itemId }) | Moves the prompt to the front and clears any pause. Then: if a turn is running, it is interrupted. If a turn is about to start, that turn is interrupted when it starts. If the session is idle, the prompt is sent at once. The rest of the queue follows. |
resumeQueue({ sessionId }) | Clears the pause and sends the next prompt if the session is idle. |
getQueue({ sessionId }) | Returns { live, paused, prompts: [{ itemId, clientId, content, queuedAtMs }] }. |
- Pause:
cancelTurnwith prompts queued setssession.queuePaused. The pause clears itself when the queue empties. - Errors: a prompt that is not queued returns
NOT_FOUND.
Client (chat)
- A discarded prompt stays in the snapshot as a tombstone, so loading an older page cannot bring it back.
deriveTimelineleaves out queued and discarded prompts.deriveQueuedPromptslists the queue.SessionClientanduseChatSessionexpose the new commands.
Composer (chat-ui PromptInput)
submitWhileStreaming: Enter, and the button, submit while a turn runs. The button shows Send instead of Stop when there is a draft.- Escape stops the turn.
- An
appendTexthandle lets Edit add text without remounting, so the draft keeps its attachments. - A stored draft is seeded only into an empty editor. Before this, a remount (for example a hot reload) inserted the draft a second time.
Desktop
QueuedPromptsrendersderiveQueuedPrompts, the paused row, and Resume. If an action fails, a toast shows the error.
API callers (Roster)
- Call
promptas usual. If a turn is running, the message is queued and appears in the desktop stack. - Read the queue with
getQueue, then follow the session stream for changes:queued: truewhen a message is queued, the same item withoutqueuedwhen it is delivered,discarded: truewhen it is deleted, andsession.queuePausedfor the pause.
Transcript and composer fixes (measured in the dev app)
Draft doubling. Composer passed the stored draft as defaultValue on every render. ComposerBody only marked the draft as seeded once it had inserted one. So: open an empty chat, type past the 300 ms draft save, and any re-render (a new message, a turn starting) inserted the saved draft again. Reproduced in the dev app with an API prompt as the re-render: the old code gave "draft checkdraft check", this branch gives "draft check".
Jump after each send. I recorded scrollHeight and scrollTop every frame. Before this change:
- The "Sending" bubble and the echoed message showed together for a frame.
- The message row was removed and re-mounted when its turn started.
- Each new row first laid out as a 240px
content-visibilityplaceholder and then shrank (scrollHeight 1325 → 1431 → 1589 → 1625 → 1429 within 100 ms).
After this change:
- The transcript is one flat keyed list. A user message is keyed by
clientId, so it is one DOM node from Sending to sent. content-visibilityapplies only to rows older than the last 30. Every row records its size.- scrollHeight now only grows (1671 → 1739 → 1775 → 1817 → 1840), with no remount and no placeholder spikes.
Streaming cost. Rows are memoized on the item and its live text, so only the streaming row re-renders. Before, every agent message re-rendered its Markdown on every frame, because the rows took the whole snapshot. SessionView callbacks are stable, Composer is memoized, and the rail and queue lists keep their identity while unchanged. A 45 s streamed reply rendered 5,366 frames, with 2 frames over 50 ms and 1 long task of 67 ms.
Scrolling. A sent message moves to the top of the transcript, with a spacer below it, and stays there while its turn header lands above it. Once the reply outgrows the screen, the view follows the stream. A long reply used to stream in below the composer, out of sight. If the reader scrolls (wheel, touch, keys, pointer), the view stops following until they scroll back to the bottom. Measured: the message pinned at 24 px for about 5 s, then the view stayed within 28 px of the end for the rest of a 45 s reply.
Approval modes
The composer has an approval picker built from the session's own modes, so each agent shows what it supports:
- Claude: Ask for approval, Approve edits, Plan, Approve for me, Full access.
- Codex: Read only, Auto, Full access.
- ACP agents: whatever modes the agent advertises.
Known mode ids get a localized title and description. Unknown ids show the agent's own label.
The native Claude adapter now advertises its permission modes, starts in the requested one, and switches the live session through the SDK's setPermissionMode. allowDangerouslySkipPermissions is set at launch, so "Full access" can take effect when picked.
Verified in the dev app: a Claude ACP session listed its five modes. Picking "Approve edits" changed the trigger, through the host's session state, and picking "Approve for me" restored it.
Shared composer
renderer/components/AgentComposer is the chat PromptInput with the plugin @-mention provider built in. The chat composer and the new-workspace screen both use it. The new-workspace screen no longer uses the ai-elements PromptInput or the Tiptap MarkdownEditor:
- Header slot: the linked PR/issue pills and attachment cards.
- Toolbar: the agent, model and effort selects.
- Toolbar end: the link commands, the paperclip (it opens the composer's file picker) and the submit button.
- Files: paste, drop and picker all go through
onAddFilesinto the page's attachment store, with the same 5-file and 10 MB limits. Drops outside the card go to the page-level drop handler. - Submit: Enter submits with an empty prompt. A blocked submit keeps the prompt (
clearOnSubmit={false}).
New PromptInput options: header, onAddFiles, allowEmptySubmit, clearOnSubmit, hideSubmit, autoFocus, and openFileDialog/focus handles. The editor text now uses the transcript's line height (1.625), and spell check is off.
Verified in the dev app:
- The screen renders with its toolbar and pickers, and the editor is focused.
- Typing and then Enter with no project shows "Select a project" and keeps the text.
- A pasted file appears as an attachment card in the header, and its remove button works.
Known difference: the prompt is plain text now. Plugin mentions restored from a saved draft come back as their text form, not as chips.
Design Decisions
- No new event type. A removal is an item update with an optional
discardedfield, and the pause is an optional session-state field. The journal parses events withdurableEventSchema, so a new event type would make an older host fail to read a newer journal. - Steer interrupts the turn. It does not inject the message into the running turn. The protocol's
steerInputSchemais still not implemented by any harness.
Compatibility (trpc-compat)
- Only additions: four new procedures, the optional
user_message.discardedfield, and the optionalsession.queuePausedfield. No existing input or output changed. - New desktop with an older host: queued messages still show, because older hosts already set
queued: true. Steer, Delete and Resume fail with a toast. - Older desktop with a newer host: a message deleted by another client shows as a sent message, because the old client doesn't know
discarded.
Known Limitations
- The queue lives in
LiveSessionmemory. If the host restarts with messages queued, they stay in the journal asqueued, and Steer and Delete then returnNOT_FOUND. This was already true of the old "Queued" badge. - Edit is disabled for queued messages that have attachments.
Manual QA
Done in the dev app (bun run dev:desktop, local stack, chat flags on):
- An interrupted turn with a message queued shows the "Queue paused because you interrupted" row, Resume, and the queued row with Steer, Delete and … (screenshot taken during verification)
-
getQueueover/chat-v3/trpcfrom outside the UI returns 200 with{ live: true, paused: false, prompts: [] }for the live session - Two messages typed during a reply show in the stack, and
getQueuereturns both - Delete (trash) removes a row;
getQueuedrops it from the host queue - Escape in the composer interrupts the turn ("Interrupted");
getQueuereportspaused: true; the paused row shows - Resume sends the next queued message as a new turn; the queue empties and the paused row goes away
- Steer during a reply interrupts it; the steered message runs next and gets its answer; the queue is empty afterwards
Testing
packages/chat-runtime:bun test(172 pass). NewLiveSessiontests cover remove, steer order, steer between turns (fails without the fix), pause on interrupt, resume, steer-while-paused resuming the queue,getQueue, and rejecting an unqueued id.packages/chat:bun test(41 pass), including thederiveQueuedPromptsand tombstone-merge tests.apps/desktop:bun testonChatSessionandpersisted-keys(20 pass).bun run typecheckpasses inchat,chat-runtimeandchat-ui. Inhost-serviceandapps/desktop, typecheck locally reports 28 errors after the rebase, all in better-auth inferred types inpackages/auth,packages/trpcand auth routes. None are in files this PR touches.main's CI typecheck passes, so these look like a local install mismatch.biome checkandbun run check:i18n. Locally, Lingui 6.8.0 also extracts thegreetingid from an excluded marketing test file, probably because this worktree sits under a dot directory. Those entries are not committed.
Summary by cubic
Queues messages sent while the agent is working — from the desktop composer or the chat API — on the host, with a stack above the composer to Steer, Delete, Edit, or Resume. It also polishes the transcript (step summaries, diff and terminal cards, selectable text, a stable flat row list), adds an approval-mode picker per agent, and shares the composer editor with the new workspace screen.
Message queue
- Sending during a turn queues the prompt on the host; the stack above the composer offers Steer, Delete, Edit, and a Resume row after Escape pauses it. The paused banner adds Clear all, treating prompts that already left the queue as cleared.
- New procedures
removeQueuedPrompt,steerQueuedPrompt,resumeQueue, andgetQueueback the UI and let API callers read the queue; discarding stays in the snapshot as a tombstone so an older page cannot bring it back. - All additions are optional, so compatibility holds both ways: a new desktop on an older host shows the queue but Steer/Delete/Resume fail; an older desktop misses tombstones and shows deleted messages as sent.
- The queue lives in
LiveSessionmemory — a host restart with queued prompts drops them (same as the old "Queued" badge).
Transcript, modes, and shared composer
- The transcript is one flat keyed list on the shared
MessageScroller: only the latest message sent from this view is pinned, a queued prompt isn't pinned until it starts, and dragging the scrollbar or pressing a scroll key releases the pin. A user message stays one DOM node from Sending to sent, and rows re-render only when their own text streams. - Step summaries name what a run did ("ran 6 commands, edited 3 files"), diff and terminal content render as cards that follow their own stream, a running turn ends with "Working…", and text is selectable. A tool run is keyed by its first tool so it keeps its expanded state, and a fenced multi-file diff falls back to the plain code view.
- The composer picks from the session's own approval modes; the native Claude adapter advertises and switches them through the SDK.
AgentComposerreplaces the separate editors: chat and the new workspace screen share onePromptInput, and a stored draft is seeded only into an empty editor, fixing draft doubling. The draft flushes when the pane closes and a send cancels any pending write.- Enter while the session is still loading keeps the draft instead of clearing it and sending nothing.
- The chat UI is split along the repo's folder rules: the queue stack, composer hooks, and row components each get their own folder, diff cards open files through a shared
ChatPaneActionsProvider, and the draft key registration now points at its new writer.
Summary by CodeRabbit
- New Features
- Manage queued prompts: pause, resume, prioritize, edit, remove, or clear them, with clear status indicators.
- Choose an agent’s operating mode and respond to approval requests with option-specific actions.
- View formatted chat code with copy controls and expanded diffs for single-file patches.
- Explore tool activity and file changes with summaries and change statistics; open referenced files directly from chat.
- Improvements
- Chat history and tool output have smoother scrolling, with clearer working, interrupted, and failed-turn indicators.
- Composers offer more flexible attachment handling and submission controls. Failed message sends include retry and discard actions.
- Updated interface translations across supported languages.