Skip to content

feat(studio): port the prototype's chat composer, pills, and queued strip - #2253

Merged
kantord merged 15 commits into
mainfrom
feat/studio-chat-composer-2207b
Oct 14, 2026
Merged

kantord merged 15 commits into
mainfrom
feat/studio-chat-composer-2207b

Conversation

@peppescg

@peppescg peppescg commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This is the Composer step of the chat alignment (#2207, part of #2210): step 2 of 4, after the approvals (#2251) and before the transcript and delegation, and the session dialogs. It's stacked on #2252 (Settings), so review that first.

The chat composer now comes from the prototype (stack-08): its frame and toolbar, the Mode, Model, and Effort pills under the box, the phone options sheet, the queued-message strip, text-file attachments, a per-chat unsent draft, and the permission-mode badge in the header.

The prototype's UI runs on Studio's chat logic. These are unchanged:

  • the Enter, queue, and steer semantics: the EnterSendBehavior preference and resolveComposerAction
  • chat-queue.ts, the steer flow, its stale-run fallback, and the steer trace
  • voice input (use-voice-input.ts)
  • the image count, size, and total limits, the capability check, and the upload path
  • the model inventory and disabled models (lib/model-preferences.ts); a live chat changes its mode in place and forks to change its model or effort
  • the seed confirmation and chat-seed.ts sanitisation
  • chat-escape.ts: Escape's order, and the double Escape that clears a draft

What changed

Frame and toolbar (chat-composer.tsx, composer-frame.ts, clear-draft-button.tsx)

  • The prototype's box: attachment pills on top, the text, then a toolbar with Attach files (+), dictation, Clear draft (×, only while there is a draft), and Send.
  • The frame's border follows the prototype's precedence: a dragged file, then typed text during a run (amber), then a draft's mode (Plan blue, Accept edits green).
  • Files can be dropped on the box.
  • On a phone (under 500px) the composer runs edge to edge and keeps the safe-area inset.
  • The textbox keeps its name, "Message Mecatl", so the approval keys' typing check and its pinned test still apply.

Pills (composer-option-menu.tsx, model-effort-menu.tsx, effort-picker.tsx, chat-session-controls.tsx)

  • One pill row under the box, on a draft and on a live chat: Mode and Model with its Effort submenu. The triggers are the prototype's ghost pills; the menus are the shadcn DropdownMenu.
  • Execution has its own line under the pills, in the same tray. On a draft it is main's Execution picker from feat: add execution templates with platform-managed identity #2219, kept verbatim. On a live chat the line shows whether the session has files and the built-in Shell.
  • A live chat's pills reuse the controls card's handlers and disabled rules: Mode changes in place (PUT …/mode), and a model or effort pick forks. The card is gone; its context meter, Compact, and its fork and mode notices move to the end of the pill row.
  • The Model submenu takes the prototype's look: a filter field, provider groups, and the check at the right. A live chat no longer lists a "Default" row, which did nothing there.
  • The Effort submenu notes when the selected model reports no reasoning support.
  • The Safety pill is dropped, as in the prototype. The posture is still under Settings > Permissions.

Phone options sheet (composer-options-sheet.tsx)

  • Composer options (the sliders button) opens a bottom sheet with Add a file and a row per setting. Each row drills into its own view (Mode; Model with Effort chips and, on a draft, Reset to default), and a pick closes the sheet. A draft's Execution picker, main's native select, sits inline in the sheet's main view.
  • It replaces the old "Chat options" sheet and its native selects, and it now also serves a live chat, with its context meter and Compact at the foot.
  • The composer button is renamed "Composer options" because the header's menu is also "Chat options".

Queued messages (queued-message-strip.tsx)

  • The prototype's strip replaces the queued list: a header with the count, then one row per message with an actions menu.
  • Edit keeps Studio's in-place editing, so the message keeps its place. Delete removes it.
  • Steer (new, while a run can be steered) sends the message into the live run through the same route as a steered prompt. It leaves the queue only once the steer is accepted; a stale run keeps it queued.

Attachments (lib/attachment-inline.ts, lib/file-meta.ts)

  • Attach files takes images and text files. Images keep Studio's path and limits.
  • A text file (up to 256 KiB, 512 KiB together) is inlined into the prompt between --- attached file: <name> --- and --- end of <name> --- lines; the name is sanitised so it can't close its block. Binary and audio files are refused with a reason.
  • Like images, text files wait for the active run to finish. Selecting a text pill previews it in the side panel.

Drafts (lib/draft-store.ts)

  • Each chat's unsent text persists in session storage under studio.chat.draft.<chat> (new for a new chat), so it survives a reload and a switch to another chat and back. Attachments are not kept.
  • The keys use Studio's account-scoped storage, so a sign-out or account change clears them before the next person's chat renders.
  • The draft is written on each change, not on a timer, and never on unmount: the auth gate remounts the workspace only after storage is reconciled, so nothing can write the old account's text into the new account's storage.
  • A new chat's first send carries its text into the chat it opens, and a refused send leaves it in place.

Header (permission-mode-badge.tsx)

  • The chat header shows Accept edits or Plan as a badge. Manual shows nothing.

Docs

  • user-docs/operating/studio.md describes the pills, the phone sheet, text attachments, drafts, and steering a queued message.

Differences from the prototype

  • Execution: kept on its own line under the pills. A draft's execution choice is sent to createSession, and a live chat's line shows whether it has files and the built-in Shell. The prototype has neither.
  • Known follow-up: the Execution picker is a native <select>, kept verbatim from feat: add execution templates with platform-managed identity #2219. The prototype has no template catalog, and turning the picker into a pill is a pending design decision with the designer.
  • Context meter and Compact: kept at the end of the pill row (and in the phone sheet). The prototype moves Compact into the header menu, which is step 4's surface.
  • Stop: stays in the header (fix(studio): keep the running chat's Stop inside the phone header #2233). The prototype swaps Send for Stop while a run is live and the composer is empty.
  • Enter: Studio's semantics. The prototype's ⌘/Ctrl+Enter newline, ⌘⇧U clear, ⇧Tab mode cycle, and the empty-composer queue keys (Enter resumes, ↑ edits, Escape clears) are not ported: Studio's shortcut registry has none of them, and its queue drains on its own.
  • Escape: stays with useChatEscape. The hints ("Esc twice to clear draft", "Press Escape again…") keep their text and conditions but now sit above the composer, as in the prototype.
  • Queued strip: no "↑ edit" or Clear all, and Edit edits in place instead of pulling the message back into the composer, which would move it to the end of the queue.
  • Model picker: no star for a default model, because Studio has no default-model preference. The submenu stays a filtered menu list, not a cmdk palette, which keeps Studio's keyboard behaviour.
  • Dictation: keeps Studio's labels ("Start dictation", "Stop dictation"), its typing fallback when the browser can't dictate, and its "Hearing:" line. The prototype hides the button when speech isn't supported.
  • Insert from MCP: not here; the MCP inventory is Studio chat: browse MCP inventory and insert into a draft #1851.
  • Text preview: opens in Studio's side panel rather than a new tab.

Visible change

  • The composer's frame, toolbar, and edge-to-edge phone layout.
  • The pill row under the box, on drafts and live chats; the chat controls card and the Safety pill are gone.
  • The phone Composer options sheet, on live chats too.
  • Attachment pills, text attachments, drag and drop, and Clear draft.
  • The queued strip with its actions menu.
  • The header's mode badge.
  • The escape hints above the composer.

No regression

Pinning tests

0c5f65ef7 was committed first, alone, and passes on the base (7f5301a89) and on this branch, unchanged:

  • chat-composer.pinning.test.tsx (16 tests):
    • the Enter, queue, and steer matrix through the textarea, for both preferences, idle and running, with and without Shift
    • the submit button's name and the placeholder for each state
    • the trimmed prompt, and no blank sends
    • a refused send keeps the text and images; an accepted one clears both
    • the escape clear signal empties text, images, and the attachment error
    • the 10 MB image limit, the 20 MB total, and preview and remove by name
  • Two tests in chat-workspace.integration.test.tsx:
    • a draft sends the model, effort, and mode picked from the desktop pills, and the execution picked on the line under them, to createSession
    • a live chat whose effort alone changes forks with its current model and the new effort, with no mode request

New tests

  • chat-composer.alignment.test.tsx (26 tests): drafts per chat, across remounts and chat switches; attachments dropped on a switch; the draft cleared after an accepted send and kept after a refused one; a new chat's text carried into the chat its send opens; Clear draft and the escape signal; a draft that survives a remount after an earlier escape clear; one account's draft never reaching the next account; text inlining, a file-only send, the binary and audio refusals, files held during a run, drag and drop; the live pills (mode in place, model fork, no Default or Reset, disabled while busy or without model selection); no Model pill without an inventory; the reasoning note; the sheet's Default and Reset; the frame tint; the badge; and the queued strip's Steer, Edit, and Delete.
  • lib/draft-store.test.ts, lib/attachment-inline.test.ts, and lib/file-meta.test.ts (33 tests).
  • Four workspace tests: steering a queued message (accepted, then stale), no Steer while idle, the header badge with Compact from the pill row, and a draft kept per chat across a switch.

Four mutations were checked by hand:

  • an unscoped draft key fails the account tests
  • swapping the draft during an in-flight send fails the carry-over test
  • keeping the old key's copy fails the same test
  • the old mount-time clear fails the remount test, which is the bug 7ef2c69af fixes

Modified tests

No assertion was removed or weakened. These tests drove controls that the prototype replaced, so their steps changed:

  • chat-composer.test.tsx, "keeps composer controls reachable at narrow widths": Attach files and Composer options instead of Attach images and the composer's "Chat options"; the sheet's Mode and Model rows and its Effort group instead of the labelled Mode, Model, and Effort selects, while the Execution picker is still found by its label; focus still returns to the button.
  • chat-composer.test.tsx, "limits image sending to the selected model capability": the Attach button now stays for text files, so instead of asserting it is absent, the test attaches an image while the model can't take one and expects the refusal. The other assertions are unchanged.
  • chat-composer.configuration.test.tsx and the integration test "creates a draft with its selected controls and excludes a disabled model": they pick the model, effort, and mode through the sheet's views instead of selectOptions, and keep selectOptions for the Execution picker inline in the sheet. The final configuration, the four changes, the createSession body, and the hidden model's absence are asserted as before.
  • The integration test "creates one session and drains one queued prompt only after its run settles": Edit and Delete are chosen from each row's actions menu. The queue contents and run bodies are asserted as before.
  • Four tests: the file input is labelled "Choose files to attach" (it was "Choose images to attach").

These pass with their assertions unchanged: chat-escape.test.tsx, approval-shortcuts.test.tsx (including "never answers an ask from keys typed in the composer"), approval-interactions.test.tsx, chat-queue.test.ts, steer-trace.test.tsx, use-voice-input.test.tsx, local-file-preview.test.ts, delegation-workspace.test.tsx, and the e2e chat journeys, including auth-recovery.spec.ts's exact account-storage key checks.

Gate counts

Both columns ran through the shared gate lock on the same machine. Base is 7f5301a89 (#2252's head), and this branch is 7ef2c69af. An earlier head run (3f66ce813) stopped at Biome's semantic-element rule on the sheet's Effort group; 7ef2c69af fixes it.

Gate Base (7f5301a89) This branch (7ef2c69af)
Biome 438 files 453 files
Typecheck and generated-artifact check clean clean
Server 24 files, 195 tests 24 files, 195 tests
Web 139 files, 1,039 tests 144 files, 1,120 tests
Browser (task studio:browser) 66 66

The 81 new web tests are the 18 pins, the 26 composer tests, the 33 helper tests, and the 4 workspace tests. The browser run includes the chat approval, header Stop, and auth-recovery journeys on desktop and mobile Chromium.

task docs passed: matlatl --strict reported 185 documents and 0 broken links. It also rewraps one line of user-docs/reference/server-cli.md, because the default workspace path depends on the checkout's path length; that change isn't part of this PR.

Screenshots

Captured with a throwaway Playwright journey (Chromium, not committed), with offline BFF routes, a model inventory of three models, and a loopback activity stream that stays open for the running chat. The clock is fixed at 2026-09-25 12:30 UTC, the time zone is Europe/Rome, and the locale is en-US. Every surface was shot at desktop 1280×800 and mobile 390×844, in light and dark.

Before is the base (7f5301a89) and after is this branch's head (7ef2c69af). Each tree was captured twice. The after runs matched pixel for pixel apart from 6 px in one shot; the before runs differed in 7 shots: two by the transcript's scroll position on a phone (19,604 and 23,648 px), and five by 6–142 px of anti-aliasing. Every pair was pixel-compared, and red boxes mark the regions that differ. Desktop composites are downscaled to 1600px wide.

The Prototype column comes from the prototype harness (stack-08): the gallery where it has the surface, and new captures for the image attachment, the Effort menu, a running chat, and a queued message. Its chats differ, so it's a visual reference, not the same content.

Composer

Idle composer, live chat

Intended change: the prototype's frame and toolbar (+, microphone, send), with the pill row under the box. Mode and Model move out of the chat controls card into the pill row; the context meter and Compact sit at its end. The Safety pill and the Enter hint are gone. On a phone the composer runs edge to edge and the pills fold into Composer options. Matches the prototype, except that Studio keeps its context meter and Compact in the row, and has no MCP button yet.

Idle composer, live chat, desktop light

Dark and mobile

Desktop, dark:

Idle composer, live chat, desktop dark

Mobile, light:

Idle composer, live chat, mobile light

Mobile, dark:

Idle composer, live chat, mobile dark

Idle composer, new chat

Intended change: the same frame on a draft, with Mode and Model pills and Execution on its own line under them. Matches the prototype, except the Execution line, which Studio keeps because a draft's execution choice is a real setting; the picker is main's, unchanged. The greeting and starters are unchanged; they move only because the composer is shorter.

These composites were re-captured after the rebase onto #2219, with the same journey. Before is main (7852ac855) and after is this branch's head (4c07463fa), so they also show changes from the PRs below this one in the stack, such as the top nav from #2228. Each tree was captured twice: the before runs matched pixel for pixel, and the after runs differed by 21 px in one shot. On a phone, the greeting runs past the screen edge in the Before and After columns; that overflow is on main, and #2256 fixes it.

Idle composer, new chat, desktop light

Dark and mobile

Desktop, dark:

Idle composer, new chat, desktop dark

Mobile, light:

Idle composer, new chat, mobile light

Mobile, dark:

Idle composer, new chat, mobile dark

Image attachment

Intended change: the image becomes a round pill with a thumbnail above the text, and Clear draft (×) appears beside Send. The escape hint now sits above the composer. Matches the prototype, except the mode tint: the prototype's chat is in Accept edits.

Image attachment, desktop light

Dark and mobile

Desktop, dark:

Image attachment, desktop dark

Mobile, light:

Image attachment, mobile light

Mobile, dark:

Image attachment, mobile dark

Text attachment

New: a Markdown file attaches as a pill and is inlined into the prompt on send. The header shows the Accept edits badge. Matches the prototype. Before has no text attachments; the before column shows the same chat with only the typed text.

Text attachment, desktop light

Dark and mobile

Desktop, dark:

Text attachment, desktop dark

Mobile, light:

Text attachment, mobile light

Mobile, dark:

Text attachment, mobile dark

Menus

Model menu

Intended change: the prototype's ghost pill and menu, with the model list in a 26rem submenu (filter field, provider groups, check at the right). On a phone, the sheet's Model view with the Effort chips. Matches the prototype, except that Studio has no star for a default model (it has no default-model preference) and its filter is a menu list, not a command palette.

Model menu, desktop light

Dark and mobile

Desktop, dark:

Model menu, desktop dark

Mobile, light:

Model menu, mobile light

Mobile, dark:

Model menu, mobile dark

Effort menu

Intended change: the Effort submenu, unchanged in content; on a phone, the Effort chips under the model list. Matches the prototype.

Effort menu, desktop light

Dark and mobile

Desktop, dark:

Effort menu, desktop dark

Mobile, light:

Effort menu, mobile light

Mobile, dark:

Effort menu, mobile dark

Mode menu

Intended change: the Mode menu gains the posture dots (grey, green, blue); on a phone it is the sheet's Mode view. Matches the prototype.

Mode menu, desktop light

Dark and mobile

Desktop, dark:

Mode menu, desktop dark

Mobile, light:

Mode menu, mobile light

Mobile, dark:

Mode menu, mobile dark

Phone options sheet

Intended change: Composer options opens a bottom sheet with Add a file, Mode, and Model rows, then the context meter and Compact for a live chat. Before had no composer sheet on a live chat; its controls card stood above the composer. Matches the prototype, except for the usage footer (Studio's) and the missing Insert from MCP row (#1851).

Phone options sheet, mobile light

Dark

Mobile, dark:

Phone options sheet, mobile dark

During a run

Queued message

Intended change: the prototype's strip: an hourglass header with the count, one row per message, and an actions menu (Steer while a run is live, Edit, Delete) in place of the inline pencil and bin. Matches the prototype, except the header reads "1 queued message" with no "↑ edit" or Clear all: Studio's queue drains on its own and has no paused state.

Queued message, desktop light

Dark and mobile

Desktop, dark:

Queued message, desktop dark

Mobile, light:

Queued message, mobile light

Mobile, dark:

Queued message, mobile dark

Composer during a run (steer)

Intended change: the same frame while a run is live, with the Steer preference; the pills are disabled. Matches the prototype, except that Stop stays in the header (#2233) rather than replacing Send.

Composer during a run (steer), desktop light

Dark and mobile

Desktop, dark:

Composer during a run (steer), desktop dark

Mobile, light:

Composer during a run (steer), mobile light

Mobile, dark:

Composer during a run (steer), mobile dark

The prototype reference code is on stack-08, in apps/web/src/features/chat/: chat-composer.tsx, composer-frame.ts, clear-draft-button.tsx, composer-option-menu.tsx, model-picker.tsx, effort-picker.tsx, permission-mode-badge.tsx, and queued-message-strip.tsx, and in apps/web/src/lib/: attachment-inline.ts, file-meta.ts, draft-store.ts, permission-mode.ts, and reasoning-effort.ts.

Review fixes

Fixes from the final regression and security review of the stack (stack #2227). Each one comes with a test that fails without the fix.

Fixes for the final review's #2253 findings. Each fix has a test that fails on the pre-fix head (7ef2c69af) and passes now.

Finding Fix Tests
Major: switching chats while a send is in flight wrote chat A's text into chat B's draft, and Enter then sent A's message into B The composer swaps in the next chat's own draft on every switch, even mid-send. Only a draft's first send carries its text, and only into the chat that send created: the workspace passes that chat as createdChatKey. A send that settles after a switch leaves its text stored under the chat it was typed in. Accepted, that copy is spent; refused, it stays. A carry also empties the draft view's copy straight away. chat-composer.alignment.test.tsx: "swaps in the next chat's own draft when another chat opens during a send", "clears only the sent chat's draft when a send is accepted after a switch", "carries a draft's text only into the chat its send created". chat-workspace.integration.test.tsx: "keeps the open chat's draft when the user switches chats during a send", "carries a new chat's first prompt into the chat its send creates"
A seed waiting for confirmation was saved as the draft, and a reload sent it without the confirmation Nothing is saved while a seed waits for confirmation. A confirmed seed's send brings back the user's own draft. The seed becomes the draft only after Edit prompt. Switching chats closes the pending confirmation. "never stores a seed that awaits confirmation, so a reload cannot send it unconfirmed", "brings back the user's draft once a confirmed seed is sent", "stores the seed as the draft once the user chooses to edit it"
A queued Steer raced the queue drain, so the message could be delivered twice The drain skips the message a steer is delivering. That message still leaves the queue only once the steer is accepted. "never drains a queued message while it is being steered" (integration)
The phone sheet's Mode view ignored disabled while open The Mode view now disables its choices whenever its pill is disabled, as the Model view already did and as the prototype's sheet does. The Execution choice, inline in the sheet, follows the same disabled state "blocks the phone sheet's open Mode view and Execution picker once their controls are disabled", "blocks a live chat's open Mode view once its controls are busy"
File content could forge the attachment delimiter, and the whole selection was read before the total was checked Every block's fence is one dash longer than the longest run of dashes in any attached text, and never shorter than three. Files without such a run keep the old lines. The composer checks the 512 KiB total before it reads each file. attachment-inline.test.ts: "fences every block longer than any dash run in the content, so content cannot close one", "outgrows a long run of dashes in any file". Alignment: "refuses a file that would pass the text total before reading it"

The Enter, queue, and steer matrix and the step-2 pinning tests are unchanged: chat-composer.pinning.test.tsx and the pinned integration tests have no modified lines. One existing test changed: "carries a draft's text into the chat its first send opens" now passes createdChatKey, as the workspace does, and its assertions are unchanged. user-docs/operating/studio.md documents the seed, steer, and fence behaviour.

Gates on 9e43ec012: studio:check passes with 1134 web tests (1120 before, so 14 new) and 195 server tests. studio:browser passes 66 of 66, the same as before.

The only visible change is the phone sheet's Mode view while a compaction is pending: the choices now dim and can't be selected, which matches the prototype's sheet (disabled={!canEditMode}).

Screenshots

Phone Mode view while a compaction is pending: the choices are dimmed and disabled. The prototype disables the same rows (disabled={!canEditMode}, disabled:opacity-50). Its gallery shot shows the enabled view, because the prototype harness has no busy capture.

Mode view while busy, light

Dark, and the unchanged enabled Mode view

Mode view while busy, dark
Mode view, light (unchanged, matches the prototype)
Mode view, dark (unchanged, matches the prototype)

The idle composer is pixel-identical before and after: desktop and mobile, light and dark, 0 changed pixels.

Re-verification (2026-10-11)

  • A stale steer stranded its queued message (minor). The drain skips the message a steer is delivering, so when the run settled during the steer and the steer then answered stale_run_control, nothing drained the queue until another run ended. A steer that fails with no run active now drains the queue. (3f4e4b0c8) Test: integration "sends a queued message once its steer finds the run already ended".
  • A refused send could write its text into the next account's drafts (minor). After a chat switch, a refused send wrote its text back whenever the home chat's stored draft was empty: after the user cleared it, or after an account change cleared storage. The text never leaves its home key, so nothing is written back now. (c7df1c7e1) Tests: "never writes a refused send back into the next account's drafts", "keeps a draft the user cleared when its refused send settles elsewhere".
  • Edit prompt during a confirmed seed's send overwrote the user's draft (minor). Closing the confirmation mid-send stored the seed over the draft, and the accepted send restored the seed. Edit prompt is disabled and the dialog ignores close requests while the send is in flight. (f81d27d0b) Test: "keeps the confirmation open and the user's draft while a confirmed seed is sending".
  • App shortcuts fired behind the phone composer sheet (minor). ? and mod+shift+o acted on the chat behind the modal sheet; it now suppresses app shortcuts while open. (62e2b442a) Test: "blocks app shortcuts while the phone sheet is open".

Each test fails without its fix. No visible change, except that Edit prompt is disabled for the moment a confirmed seed is sending.

Rebased onto #2219 (execution templates)

Main's #2219 replaced the composer's Tools choice (toolAccess) with an Execution template picker, a native <select>, and added a live chat's execution status. This branch keeps both as main has them, on their own line under the prototype's pill row:

  • Tools → Execution. A draft's Execution picker replaces the Tools pill. It takes its own line under the Mode and Model pills, and in the phone sheet it sits inline in the main view, in place of the Tools row and its drill-in view. createSession receives execution instead of toolAccess.
  • Live chat. The status line (files and built-in Shell) takes the same line under the pills, and the phone sheet shows it in its footer.
  • 598a40a15 moves both to that line. When they sat in the single pill row, the picker pushed Model out of view at 500 to 640px, and the live status pushed Compact out at 1280px. The new browser test apps/web/e2e/composer-execution-line.spec.ts checks a draft and a live chat at 500, 640, and 1280px, on desktop and mobile Chromium. It fails 4 of 4 without the fix and passes 4 of 4 with it.
  • dd5e31ac8 updates user-docs/operating/studio.md: the send=1 confirmation shows an Execution row, which replaces the stale mention of tool access.
  • Tests. The pinned integration test picks Execution and asserts execution in the createSession body, where it picked Tools and asserted toolAccess before the rebase.

The new-chat composites under Screenshots were re-captured for this rebase; the other screenshots predate it.

Gates on the restacked head dd5e31ac8: studio:check passes (web 1,252, server 210 tests). At the stack top (#2256): studio:browser 118 passed, 30 skipped; the mecated --mock integration suite 8 passed. The browser suite takes 5.3 minutes on a CI runner.

Commit hashes cited earlier in this body predate the restacks; the current commits keep the same subjects.

Refs #2207

🤖 Generated with Claude Code

@peppescg
peppescg added this pull request to stack #2227 October 10, 2026 11:44
@peppescg
peppescg force-pushed the feat/studio-chat-composer-2207b branch from 7ef2c69 to 6e6ebe2 Compare October 11, 2026 14:57
@peppescg
peppescg force-pushed the feat/studio-chat-composer-2207b branch from 6e6ebe2 to ee6bbb9 Compare October 11, 2026 15:28
@peppescg
peppescg force-pushed the feat/studio-chat-composer-2207b branch from ee6bbb9 to 3c9ed80 Compare October 11, 2026 15:48
@peppescg
peppescg force-pushed the feat/studio-chat-composer-2207b branch from 3c9ed80 to dc1856e Compare October 11, 2026 16:13
@peppescg
peppescg force-pushed the feat/studio-chat-composer-2207b branch from dc1856e to dd5ebf6 Compare October 13, 2026 13:29
peppescg added a commit that referenced this pull request Oct 13, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@peppescg
peppescg force-pushed the feat/studio-chat-composer-2207b branch from 4c07463 to dd5e31a Compare October 14, 2026 08:45
@kantord
kantord force-pushed the feat/studio-chat-composer-2207b branch from dd5e31a to e06f4f9 Compare October 14, 2026 08:59
@kantord
kantord force-pushed the feat/studio-chat-composer-2207b branch from e06f4f9 to 21fb80c Compare October 14, 2026 08:59
@kantord
kantord force-pushed the feat/studio-chat-composer-2207b branch from 21fb80c to e2e2bb9 Compare October 14, 2026 09:00
@kantord
kantord force-pushed the feat/studio-chat-composer-2207b branch from e2e2bb9 to 8d4b799 Compare October 14, 2026 09:41
@kantord
kantord force-pushed the feat/studio-chat-composer-2207b branch from 8d4b799 to 0a5d104 Compare October 14, 2026 09:41
@kantord
kantord force-pushed the feat/studio-chat-composer-2207b branch from 0a5d104 to d86f0c4 Compare October 14, 2026 09:47
@kantord
kantord force-pushed the feat/studio-chat-composer-2207b branch from d86f0c4 to 4cf9ce7 Compare October 14, 2026 09:48
Base automatically changed from feat/studio-settings-2206b to main October 14, 2026 10:02
peppescg and others added 15 commits October 14, 2026 13:02
…l choices

Pin the composer behaviour the prototype alignment must keep, before
the composer changes: the Enter, queue, and steer matrix through the
textarea, the submit labels and placeholders, the send outcome, the
escape draft clear, the image size and total limits, the draft's model,
effort, mode, and execution from the desktop pills, and the live fork when
only the effort changes.

Refs #2207

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… helpers

Port the prototype's composer helpers onto Studio's foundations:

- attachment-inline: classify a picked file as an image, inlined text,
  or refused (audio, binary, over 256 KiB, or over 512 KiB together),
  and inline text files as delimited blocks in the prompt
- file-meta: a file chip's icon and label, on Studio's localFileKind
- draft-store: one unsent draft per chat in session storage, under the
  account-scoped studio. prefix, so an account change or sign-out
  clears it before the next person's chat renders
- permission-mode and reasoning-effort: the Mode and Effort option
  tables the pill row and the phone sheet share

Refs #2207

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Re-skin the chat composer to the prototype (stack-08) on Studio's chat
logic:

- The frame: the input box with attachment pills, a toolbar (attach,
  dictation, clear draft, send), and the pill row under it. A draft's
  mode tints the frame, typed text during a run turns it amber, and a
  dragged file highlights it as a drop target.
- The pills: Mode, Execution (a draft only), and Model with its Effort
  submenu, on both a draft and a live chat. A live chat's mode changes in
  place, and a model or effort pick forks it, as before. The live chat's
  context usage and Compact move from the controls card to the end of
  the pill row, with the session's execution capabilities.
- On a phone, the pill row folds into the Composer options sheet, with
  a drill-in view per setting instead of native selects.
- The prototype has no template catalog, so a draft's Execution picker
  keeps its own form: in the pill row, and inline in the phone sheet.
- Attach files: text files are inlined into the prompt; images keep
  Studio's count, size, and total limits and the upload path.
- The draft persists per chat in the account-scoped draft store. A
  draft's first send carries its text into the chat it opens.
- The queued strip replaces the queued list. Each row's actions menu
  edits in place, deletes, or steers the message into the live run; it
  leaves the queue only once the steer is accepted.
- The header shows the permission mode when it is not Manual.

The Enter, queue, and steer semantics, the seed confirmation, voice
input, and the Escape draft clear are unchanged. The escape hints now
sit above the composer.

Tests that drove the replaced controls now drive the new ones, with
their assertions intact: the phone sheet's rows instead of native
selects, the queued row's actions menu instead of inline buttons, and
"Choose files to attach" instead of "Choose images to attach". The
capability test checks that an image is refused, now that text files
keep the Attach button visible.

Refs #2207

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… queue

Update the Studio operating guide for the composer: the Mode, Model,
and Tools pills and the phone's Composer options, text-file attachments
and their limits, per-chat unsent drafts that an account change clears,
and steering a queued message from its actions menu.

Refs #2207

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The composer cleared itself on mount whenever the workspace had cleared
a draft with a double Escape earlier, because the clear signal was
already non-zero. With drafts now restored from the draft store, coming
back to a chat through a remount would wipe its stored draft. Only a
signal that changes while the composer is mounted clears it now.

The phone sheet's Effort choices also use a fieldset again, as Biome's
semantic-element rule asks, with the legend floated off the border.

Refs #2207

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…he total first

A text file's content could close its own block early: a line such as
"--- end of notes.md ---" inside the file read to the model as the end of
the attachment, and anything after it as text outside the file.

- Every block's fence now has one more dash than the longest run of
  dashes in any attached text, and at least three, so no file content can
  hold a line that closes its block or opens a new one. Files without
  such a run keep the same three-dash lines.
- The composer checks the 512 KiB total before it reads each file, one at
  a time, instead of reading the whole selection first.

Refs #2207

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… disabled

The phone options sheet's Mode view ignored its control's disabled
state once open, so a run, a compaction, or a mode change that started
while the view was open could still take a pick. The view now disables
its choices with the setting's pill, as the Model view already did, and
the test checks that the inline Execution picker follows the same state.

Refs #2207

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A queued message stays in the queue until its steer is accepted. If the
run settled while that steer was in flight, the queue drain took the
same message and started a new run with it, so it was delivered twice.

The drain now passes over the message a steer is delivering. It still
leaves the queue only once the steer is accepted, and a stale steer
keeps it queued as before.

Refs #2207

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n unconfirmed seed

Switching chats while a send was in flight kept the sent text in the
composer and stored it under the chat the user opened, losing that
chat's own draft. If the send was refused, Enter then sent the first
chat's message into the second.

- The composer now swaps in the next chat's own draft on every switch,
  even during a send. Only a draft's first send carries its text, into
  the chat that send created: the workspace names that chat
  (`createdChatKey`), and the composer carries only for a chat named
  after the send began.
- A send that settles after a switch leaves its text stored under the
  chat it was typed in: an accepted send spends that copy, and a refused
  one keeps it there.
- A carry empties the draft view's copy straight away, so going back to
  it mid-send no longer shows the text a second time.

A seed awaiting confirmation was also stored as the draft, overwriting
the user's own draft, and a reload or remount restored it as plain text
that Enter sent without the confirmation. The composer no longer stores
the draft while a seed awaits confirmation, brings back the user's draft
once a confirmed seed is sent, and stores the seed only after Edit
prompt. A chat switch closes the pending confirmation.

The test "carries a draft's text into the chat its first send opens" now
names the created chat with `createdChatKey`, as the workspace does; its
assertions are unchanged.

Refs #2207

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The queue drain skips the message a steer is delivering, so a run that
settles while that steer is in flight leaves it queued. When the steer
then answers stale_run_control, nothing drained the queue again until
another run ended: the message sat in the queue and went out late, after
the user's next prompt.

A steer that fails with no run active now drains the queue, so the
message is sent once, as it would have been without the steer. A steer
refused while the run is still live keeps the message queued, as before.

Refs #2207

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A send that settled refused after the composer had swapped to another
chat wrote its text back under the chat it was typed in whenever that
chat's stored draft was empty. The draft is empty in two cases, and the
write was wrong in both: the user cleared it after the switch, or an
account change cleared storage, in which case the previous account's
text landed in the next account's drafts.

The text never leaves its home key during a send, so nothing needs
writing back: a refused send's copy is still stored, unless the user or
an account change removed it.

Refs #2207

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Edit prompt, and Escape or an outside click, stayed live during a
confirmed seed's send. Closing the confirmation then stored the seed
over the user's own draft, and the accepted send restored that seed to
the composer, where one Enter sent it again.

While the send is in flight, Edit prompt is disabled and the dialog
ignores close requests. It closes when the send is accepted, as before.

Refs #2207

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The phone sheet is modal, but it registered no shortcut scope, so ?
opened the shortcuts page and mod+shift+o a new chat from behind it.
It now suppresses app shortcuts while open, like Studio's other modal
chat surfaces.

Refs #2207

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Restacking onto main brought #2219's Execution picker and a live chat's
execution status into the prototype's single-line pill row. The picker
pushed the Model pill out of view at 500 to 640px, and the status pushed
Compact out at 1280px. Both now take a line under the pills, inside the
same tray, so the pill row keeps Mode, Model, usage, and Compact as the
port laid them out. A browser journey checks a draft and a live chat at
500, 640, and 1280px; it fails with the execution UI back in the pill
row.

Refs #2207

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The send=1 confirmation shows an Execution row, so the page no longer
says it shows tool access for a new chat. For a new chat the row is the
selected execution; for an existing chat it is whether files and the
built-in Shell are available.

Refs #2207

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@kantord
kantord force-pushed the feat/studio-chat-composer-2207b branch from 4cf9ce7 to 3297c80 Compare October 14, 2026 10:02
@kantord
kantord merged commit 0f472e6 into main Oct 14, 2026
31 checks passed
@kantord
kantord deleted the feat/studio-chat-composer-2207b branch October 14, 2026 10:23
@kantord kantord mentioned this pull request Oct 14, 2026
41 tasks done
@kantord

kantord commented Oct 14, 2026

Copy link
Copy Markdown
Member

Post-merge review follow-ups for this PR. No blockers or regressions found. Items worth tracking were filed here:

Other candidate findings were checked and dropped as not worth tracking.

Fully or partially written by an AI agent.

This branch was successfully deployed

1 active deployment
Preview — 3297c801 Deployed Oct 14, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants