Skip to content

Chat Surface Layout

github-actions[bot] edited this page Oct 14, 2026 · 3 revisions

Canonical source: docs/05-frontend/chat-surface-layout.md — this page is rendered from it on every publish; edit the source.

Chat surface layout — the implementation contract

How /chat/[threadId] is built, file by file. The product decisions behind it live in docs/02-business-product/chat-thread-page-spec.md; the enforceable constraints in rules/40-chat-surface-layout-and-composer.md.

The height chain

Percentage and flex-1 heights only work if every ancestor has a definite one. The chain, top to bottom, is:

Element File What it contributes
div.h-dvh components/layout/portal-shell.tsx The definite height everything else divides up. dvh, not vh, so a mobile keyboard shrinks it.
div.flex-1.flex-col same The content column beside the sidebar. Carries the bottom-nav margin on phones.
main.flex-1.overflow-y-auto same Definite height from flex. Its padding is what insets the chat page.
div.flex.h-full.min-h-0.flex-col components/chat/chat-thread-shell.tsx The chat page column.
header shrink-0 · body row flex-1 min-h-0 same The split. Only the body row grows.
div.chat-thread-row (flex row) same Reading column + action rail. The rail is self-start, so it never stretches.
div.min-h-0.flex-1.overflow-hidden same The transcript frame.
Virtuoso style={{height:'100%'}} components/chat/virtualized-messages.tsx The scroller.
Virtuoso style={{height: min(rows x 36px, 320px, available - 7rem)}} components/chat/model-picker-list.tsx The model picker list; windows ~900 models to ~36 DOM rows (ADR-158).

Break any min-h-0 in that chain and the symptom is always the same: the transcript refuses to shrink below its content and pushes the composer off the bottom of the screen instead of scrolling.

The composer

components/chat/message-composer.tsx is pure render over exactly one hook, hooks/chat/use-message-composer.ts. That hook composes three others (useTranslation, usePlanFeatures, useMessageComposerState) which the component used to call itself, and resolves the viewport once through useMediaQuery(MEDIA_QUERY_LG_UP).

Height comes from RichPromptTextarea — the same component the in-thread compare panel uses — bounded by COMPOSER_MIN_ROWS and COMPOSER_MAX_ROWS. There is no autosize code in the composer itself and there must not be; see ADR-088.

Two overrides are passed deliberately in className, and cn() merges them over the shared component's defaults:

  • resize-none beats its resize-y. The native handle is what let the old composer be left in a state the user could not undo.
  • min-h-0 border-0 bg-transparent shadow-none focus-visible:ring-0 strips the shadcn field frame, because the card around the whole composer is the frame.

One text size at every breakpoint (text-base, with the base Textarea's touch:text-[16px] handling iOS zoom). useRichPromptTextarea measures line-height once; a responsive font size would leave the row ceiling computed against a line height that no longer applies.

components/chat/composer-toolbar.tsx is the control row, rendered once. Which variant each control takes is a prop from the controller hook, not a Tailwind prefix — see rule 40 §9 for why. The row scrolls sideways rather than wrapping, so a long provider name cannot cost the conversation a line, and it carries scrollbar-none: the overflow is typically a couple of pixels and a classic scrollbar is 15px tall, so the bar cost more height than the overflow it reported.

Every control in that row is shrink-0 with a declared width, and none is flex-1. A flex-1 control shrinks below its own content when the row runs out of room: once the model trigger started showing a name, the research select collapsed to N… in about 90px on a 375px screen. A control narrower than one word of its own value is not a smaller control, it is a broken one. Overflow is the row's problem, and the row scrolls.

Hiding the scrollbar then owes the reader a signal, which .scroll-fade-inline-end supplies: the last 1.5rem fades, so a clipped control reads as "there is more this way". It costs no height, has an RTL counterpart, and uses mask-image rather than a gradient overlay because an overlay would need the card's own background colour and that changes with the theme.

Attachment chips (multimodal batch 10b)

ComposerAttachmentChips sits above the textarea and shows ONE chip per attachment: Uploading → Uploaded → Processing → Ready, or Failed / Not supported. Every chip spells its state out in words next to a distinct glyph (colour only repeats it), a failure shows its reason as visible text ("The file could not be processed: <backend detail>"), Processing says sending is still allowed, and the strip is aria-live="polite".

  • useComposerAttachments now keeps a per-file ledger (useComposerUploadEntries: uploads + dismissUpload) instead of only a pending count. A failed / unsupported (HTTP 415, or refused by uploadFileSchema) upload stays as a chip until dismissed.
  • Selected files take their state from useFiles() — the SAME query the paperclip picker mounts, polling only while a file is PENDING/PROCESSING. No second poller. An id not on the first page reads Uploaded, nothing more.
  • Sending while Processing is allowed and must stay allowed. chat-service waits (bounded) or tells the model the file is still processing; blocking the send here would recreate the voice-note race. The chip only reports it.
  • Pure join + copy: utilities/composer-attachment.utility.ts. The strip wraps (never scrolls) so a reason is readable at 360px; chips use logical padding (ps-/pe-) for RTL.
  • Only the main chat composer renders the strip today; labs and Compare still show the count badge + progress bar.

The decorative globe beside the research select is hidden below sm. It is aria-hidden and the select next to it says "No research" in words; it was worth 20px of a 375px row.

Attachments: the tray and the panel drop zone (2026-09-25)

Selected files used to be a number on the paperclip. ComposerAttachmentTray now sits above the textarea on every attachment surface: an image as a thumbnail, a voice or video note as a player (nothing downloads until Play), anything else as its type icon, name and size, and one dashed tile per upload still in flight with its percentage. Each tile has a remove (x) on the inline-end corner, so RTL needs no twin. The tray wraps; it never scrolls sideways, so every remove button stays reachable on a phone.

A file can be dropped anywhere on the reading column — the transcript as well as the composer. The column is ChatPanelDropzone; the composer registers its ingest function in useComposerDropTargetStore while mounted and keeps only paste for itself. Compare and the labs wrap the whole page, in-thread Compare the dialog body. One upload path (useComposerAttachments), capped at MAX_ATTACHMENTS_PER_MESSAGE = the server's 10.

Sending needs words or files (hasSendableInput). An attachment-only send is stored with empty text and renders as its attachments alone; chat-service tells the model to respond to the attachment (rule 42).

A short viewport: a phone in landscape (2026-09-25)

The live device matrix failed at 740×360. The height chain there was:

Element Height
Topbar 64 px
Bottom nav (shell margin) 64 px
main padding (sm:p-6, since 740 ≥ 640) 48 px
Thread header ~70 px
Left for transcript + composer ~114 px
Composer with one image attached (tray allowed up to sm:max-h-60 = 240 px) ~200 px

min-h-0 did its job: the transcript went to zero. The composer is shrink-0, so it overflowed, and send/record ended at y=373 under the nav. Width was fine, so a width breakpoint could not fix this. The fix is one height guard, short-viewport: (@media (max-height: 500px), globals.css). 500 px is above every phone in landscape and below every tablet, which starts at the nav-rail variant's 600 px. Under it:

  • the bottom nav is not rendered and --mobile-bottom-nav-height: 0rem. The shell margin and every floating offset read that variable, so they drop too. The topbar hamburger still opens the same drawer;
  • main keeps p-3;
  • the attachment tray is one row that scrolls sideways (flex-nowrap overflow-x-auto), capped at 22dvh;
  • the textarea is capped at 30dvh with .composer-textarea-short-cap, and scrolls inside past that cap. The class forces overflow-y: auto, because the autosize hook sets an inline hidden below its own row limit.

CSS only, no pixel heights: rule 40 §22, pinned by chat-surface-layout-contract.test.ts. Not re-run live yet. The dev stack serves the main checkout, so the 740×360 browser case must be run again once this code is deployed.

The floating rail's gutter on a phone (2026-09-25)

The device matrix found two collisions with the floating feedback launcher, which floats at the inline-end edge just above the composer (rule 36):

  • at 390×844 the launcher (− handle + button) and the labelled "Jump to latest" pill sat on the first words of the assistant's reply;
  • at 740×360 (sm width, so the action rail is rendered) the launcher's − sat on the action rail.

The fix is CSS only (globals.css, @layer utilities), keyed on the launcher's own state with :has() so no JS mirrors it:

Width While [data-feedback-launcher="expanded"] is on the page
< sm every .chat-message-row (VirtualizedMessageItem) gets padding-inline-end: var(--floating-rail-gutter)
sm … < md .chat-thread-body (the shell's body row) gets the same, so the action rail moves out of the launcher's column
≥ md nothing — the launcher's desktop offset and the bounded column already clear it

--floating-rail-gutter is the rail's own footprint from the viewport edge (1rem offset + one 44px target + 0.5rem air), not another component's height. The composer keeps its full width: the launcher's floor already sits above it. Collapsed to its edge tab the launcher covers nothing, so the gutter goes away. Below sm, "Jump to latest" is an icon-only circle (max-sm:sr-only label, full aria-label) at end-3, inside the same strip. Logical side only, so Arabic and Persian mirror with no rtl: twin. Pinned by chat-surface-layout-contract.test.ts; not re-run live yet.

Attachments on every composer (2026-09-25)

hooks/chat/use-composer-attachment-surface.ts is the one per-attachment UI: the tray (preview, state line, Stop processing for a video, cancel for a file still uploading) and the chip strip (uploads that never got an id — failed, not supported). The chat composer, Compare, the in-thread Compare dialog and OrchestrationPageShell (every lab) all render ComposerAttachmentTray + ComposerAttachmentChips from it. Do not build a second one. The pending tile's cancel calls dismissUpload, which aborts the upload (useChunkedUpload signal; no further chunk, one DELETE /files/upload/chunked/:uploadId).

The model picker

components/chat/model-picker.tsx is render-only over hooks/chat/use-model-picker.ts. The decisions below are recorded in ADR-090.

It is shared, so read this as seven screens and not one. Besides the composer's model-selector.tsx, the picker is used by advanced-module-model-selector.tsx, compare-critic-controls.tsx, compare-judge-controls.tsx, image-error-state.tsx, image-generation-bubble.tsx and orchestration/orchestration-single-model-select.tsx. All of them inherit the seeded highlight, the value→keywords swap and the bounded popover height. Only the trigger label differs, because useShortTriggerLabel is opt-in.

The hook exists for one behaviour that cannot live in the view.

cmdk's value is the HIGHLIGHT, not the selection. The picker never set it, so a list of ~180 models opened at scroll position zero every time and a model near the bottom had to be scrolled back to on every visit. The hook seeds the highlight from the current selection when the picker opens, and cmdk scrolls that row into view for us. It has to be state and not a fixed prop, because after opening the highlight belongs to the keyboard: arrow keys and typing move it, and pinning it to the selection would break both.

That forced a second change. Each item's cmdk value is now the option's own value rather than its label — the seed matches by identity, and two providers can ship the same display name. The label moved to keywords, which cmdk searches alongside the value, so typing a model name still finds it.

The trigger always says something. Compact mode used to render a 36px square whose only label was sr-only; on a phone, where the header does not repeat it, nothing on screen said which model would answer. It now shows ModelPickerOption.shortLabel (falling back to label) inside a bounded width, with the full name on title, on aria-label, and as the sheet's own title — which previously read the literal word "Auto" whatever was selected.

The list is flex-1 min-h-0 max-h-none, never a dvh fraction. The shadcn CommandList ships max-h-[min(300px,55dvh)], which cannot see the header, search box and footer around it: on a 500px-tall viewport it claimed its 275px and pushed the footer out through the bottom of the sheet, so the credit disclaimer read "Local models draw from" and stopped. The wrapper around the command box must also be a flex column — as a block it gave the box nothing to stretch against, so the box sized to its content and had its footer clipped.

Capability badges come from the row, never the provider (batch 10b). Each connector row carries capabilities: ModelCapabilityBadge[], derived by getConnectorModelCapabilityBadges from that row's own supportsVision, supportsAudio and supportsVideoInput (a flag must be literally true); the image-generation entries carry ImageOutput. ModelCapabilityBadges renders them icon-sized after the label — a distinct glyph each, a localized title tooltip and sr-only text (mediaUi.capability.*). A vision provider's text-only model shows no Vision badge. Local models carry none (no flags).

Delivery reasons and the video thumbnail. The compare delivery tooltip maps every chat-service file_delivery.reason.* key through FILE_DELIVERY_REASON_LABEL_KEYS (unknown → mediaUi.deliveryReason.unknown, never the raw key; a test reads chat-service's constants file so a new backend reason without copy fails CI). A processed video under a sent message shows extractionMetadata.media.thumbnailBase64 (already on the owner's GET /files/:id row, validated by toVideoPosterSrc: image MIME allow-list, base64 alphabet, ≤ 96 KB) with its length; otherwise the plain play card.

The header

One row, shrink-0, sticky, frosted — and, since the action rail, a title strip rather than a band. Inline-start to inline-end: back, thread drawer, title block, and the overflow menu only below sm.

It used to carry five more controls and a second line. Both were spending height, which is the one resource a conversation page cannot get back (rule 40 §1): a header pixel is taken at every scroll position, forever. What moved, and where:

  • Compare, Judge & Referee, Find, Share, … → chat-thread-action-rail.tsx, beside the conversation. See "The action rail" below.

  • The routing subtitle (AUTO · models/gemini-2.5-flash-lite) → the same line as the title from sm up (flex-col sm:flex-row sm:items-baseline). Below sm it stays stacked, because every control on that row is floored at 44px by the global touch rule and the second line fits inside the floor for free. On a mouse there is no floor, the row is exactly as tall as the title block, and the second line is ~14px of pure cost.

  • --safe-top-base for the header went from 1.25rem/1.75rem to 0.375rem/0.5rem. The old value put 28px of empty band directly under the portal's own sm:p-6; breathing room the ancestor already supplies is not breathing room, it is a second copy of it.

  • The title dropped sm:text-lg for a flat text-base. It is the tallest thing on a row of 32px buttons, so on a mouse it was setting the row height by itself. Mobile keeps text-base — the 44px floor sets the height there and shrinking the title would buy nothing.

  • Overflow (components/chat/chat-thread-header-menu.tsx): Export, Thread Settings, Delete — plus the four primary actions when collapsePrimaryActions is true, which is exactly below sm. One bag, built once in useThreadDetailPage and handed to both hosts: the header below sm, the rail above it. Radix owns the keyboard contract — arrows move, Escape closes and restores focus to the trigger.

The action rail

components/chat/chat-thread-action-rail.tsx, rendered by the shell as the last child of the body flex row when showInlineActions is true. Compare, Judge & Referee, Find, Share, a hairline, then the …. Icon-only at every width, with aria-label and title — a rail wide enough for "Judge & Referee" is a sidebar, and a sidebar is the horizontal cost the move exists to avoid.

RTL is document order, not a dir check. Last child of a flex row is the right in LTR and the left in RTL, for free. A right-* or ms-auto placement would need an rtl: twin, and the twin is what gets forgotten.

Its strip is reserved outside the reading column. .chat-thread-row is calc(var(--chat-content-max) + var(--chat-rail-reserve)), and both the header and the body row take it. Two consequences, both deliberate:

  • The transcript keeps its full --chat-content-max on any screen with the gutter to spare — moving controls sideways must not make the conversation narrower.
  • The title starts at the x the first message starts at. Binding the header to .chat-content-column while the body centred that column inside the row would have offset them by half a rail: invisible in review, obvious on a 2560px monitor.

Where the row binds before the reserve does — a 1280px laptop leaves about 976px, and the row wants 1196px — the rail and the column share what there is, and the transcript is about 40px narrower than before. That is the trade, taken knowingly: a conversation can scroll sideways past 40px of width, and cannot scroll back the ~30px of header height it gets in return at every scroll position.

Below sm the rail is not rendered at all and every action is in the header's …, exactly as before. A 360px screen has no gutter to put a rail in.

  • Title: components/chat/editable-title.tsx. One line on desktop, clamped to two on a phone, full text in title. It carries both truncate and clamp-title: globals.css deliberately neutralises .truncate under the touch query (a clipped string has no hover to reveal it), so without the clamp a long title wraps unbounded. Measured at 375px, it wrapped one character per line into a 926px header. Its three labels come from useEditableTitle rather than a second useTranslation call in the component.

The seven-controls-at-375px failure is why the header is driven by MEDIA_QUERY_SM_UP from useThreadDetailPage rather than by sm:hidden: the row buttons and the menu items are different components, and hiding one with a prefix would keep both mounted.

The reading column

.chat-content-column in app/globals.css reads --chat-content-max (72rem, raised from 68rem when the rail landed: the gutter it now sits in was otherwise doing nothing, and a 1920px monitor reads 1152px of transcript instead of 1088px). Both the transcript frame and the composer take it, so they stay aligned and cannot drift. It is wide enough not to bind on a 1366 px laptop, where the portal leaves roughly 1060 px between the sidebar and the page padding. .chat-thread-row wraps it with --chat-rail-reserve for the action rail — see "The action rail".

Message bubbles keep their own max-w-[88%]/sm:max-w-[85%] inside that column. Code blocks, tables and long URLs are handled in lib/markdown/markdown-components.tsx (overflow-x-auto on pre and on the table wrapper, break-words on prose, max-w-full on images) — the page body itself never scrolls sideways.

The branch lineage strip (ADR-130)

ThreadLineageBar sits at the top of the reading column, above the bordered transcript. It renders null for an ordinary thread — the common case — so it spends no height (rule 40 §1). For a branch it shows one line: "Branched from " (or "Branched from a chat that was deleted"), and when branches were cut from this thread a Branches (n) menu of links. Data: useThreadLineageBar → useThreadLineage → GET /chat-threads/:id/lineage; a failed read degrades to no strip, never to a blocked conversation. The thread list marks a branch with a GitBranch icon (thread-list-item.tsx).

Try again with another model, and edit in a branch (ADR-132)

Beside Regenerate, Try again with… is the composer's ModelPicker (RegenerateWithModel → useRegenerateWithModel, same groups and badges) with an AUTO option; a pick calls onRegenerate(messageId, choice). The edit dialog has Edit in a new branch (useEditInBranch): it branches BEFORE the message, writes the edited text into the branch's composer draft and opens the branch. It never sends — the person presses Send.

The saved card (ADR-134)

An answer with metadata.contextSave renders ContextSaveCard under its text: the saved memory (type badge, preview, Open memory → /memory?memoryId=, which opens it in the editor) and/or the pack (Open pack → /context?packId=, which opens that pack). While a pack choice is pending the card lists the user's packs plus New pack: ; a click calls useContextSaveCard → POST /chat-messages/:id/context-save, then refetches the thread so the card re-renders as saved from the stored record. Failures show the translated reason (chat.contextSave.reasons.*).

Inline citations (ADR-133)

An answer with metadata.citations passes them to MarkdownRenderer. The remarkCitations plugin rewrites text-node [n] (never in code or links) into a #cite-n link only when a stored entry has that index; the markdown Anchor reads CitationsContext and renders CitationLink — a superscript chip named "title — host" that opens the source in a new tab, or a non-clickable chip when the URL is not http(s). Answers without citations render exactly as before.

Quoting a selection (ADR-131)

Every message bubble carries data-quote-source-id. Selecting text inside ONE bubble floats a small Quote button above the selection (SelectionQuoteButton, useSelectionQuote: selectionchange + scroll, listeners removed on unmount). A selection across two bubbles is not offered. Quotes wait per thread in quote-draft.store.ts, render as removable chips above the textarea (ComposerQuoteChips), make an empty prompt sendable, and clear only after a successful send. A sent user turn shows what it replied to above the coloured bubble (MessageQuotes), outside it so the text keeps contrast.

Scrolling

Owned by Virtuoso and hooks/chat/use-virtualized-messages-controller.ts:

  • followOutput returns 'smooth' only when the user is already at the bottom, so a user reading history is never yanked forward.
  • atBottomThreshold is STICKY_BOTTOM_THRESHOLD_PX.
  • JumpToLatestButton appears whenever the user is away from the bottom, with an unread count of assistant render-groups accumulated since they scrolled up.
  • useFollowStreamingTokens keeps the view pinned during a stream, again only while the user is at the bottom.

None of that changed in the layout revamp, and none of it needs to know the composer's height — the transcript shrinks by exactly that height through ordinary flex layout.

The title clamp is bounded by width, deliberately

clamp-title wraps a thread title to two lines instead of truncating it, because a clipped title on a 375px header cannot be revealed. That rule lives inside the touch media query, which catches tablets too — so an iPad with a 900px-wide header rendered two lines of oversized bold text where one line and an ellipsis would have said the same thing in half the height. From 640px up the clamp reverts to a single-line ellipsis.

That is a width guard inside a pointer query, which rules/03 §7 normally forbids: a width test misreads a phone in landscape (915x412). The deviation is deliberate. The clamp trades width for height, and in landscape height is the scarce resource — two lines of oversized bold title over a 412px-tall viewport is the failure this avoids, not one it causes. The accepted cost is that a landscape phone gets an ellipsis with no hover; the full title stays reachable through the thread drawer and the rename control. Recorded in rules/40 §15 and ADR-090.

touch-layer.test.ts guards "mobile guards are pointer-based, not width-based" by scanning TSX for max-md:. It does not read CSS, so it would not catch a future width guard added here by accident.

What is deliberately absent

  • No fixed pixel heights anywhere on the surface.
  • No position: fixed composer, therefore no clearance custom property to keep in step (see rule 36).
  • No second autosize implementation.
  • No duplicated mobile/desktop control rows.

Answer actions: expand and "Download as" (ADR-105)

Every assistant answer that has visible text shows two more icon buttons in message-bubble.tsx. Neither appears on a file-generation or image-generation bubble, which already have their own card.

  • Expand: answer-expand-dialog.tsx. A dialog with Rendered (MarkdownRenderer) and Raw markdown tabs, each with a copy button.
  • Download as: answer-export-menu.tsx plus use-answer-export.ts.
    • .md and .txt are saved in the browser, with no request.
    • .html, .docx, .pdf, .xlsx, .pptx and .zip (ADR-108) call POST /file-generations/export, poll (1 s × 30), then download through the owner-only link.
    • While an export runs the icon spins; on failure it turns red and its label says so.
    • The options live in ANSWER_EXPORT_OPTIONS; adding one is skills/add-an-answer-export-format.md.
  • The filename comes from answerTitle and safeFileBase in utilities/answer-export.utility.ts.
  • Test ids: answer-expand-trigger, answer-rendered, answer-raw, answer-export-trigger, and answer-export-<ext> for each option.
  • E2E pitfall: the menu takes about 150 ms to close. A click on the trigger inside that window is ignored, so wait for the menu to close before reopening it.

Prompt library (composer)

A "Prompt library" button sits in ComposerToolbar and opens a dialog over the chat-service prompt templates (ADR-138).

  • Files: components/chat/prompt-library/* (pure render), hooks/prompt-library/* (controller, list query, mutations, form state), repositories/prompt-templates/, utilities/prompt-template.utility.ts (fillTemplate, parsePromptTags, appendToDraft).
  • The list is fetched only while the dialog is open. Every mutation invalidates the queryKeys.promptTemplates.all prefix, because the list key embeds the filters.
  • Choosing a template with no variables inserts its body at once. One with {{name}} variables opens a fill step first; blank values keep Insert disabled. Both paths append to the draft on a new line and call POST :id/use (best effort).
  • Delete confirms inside the row; no second dialog is stacked.
  • Strings: promptLibrary.* in all 13 locales.

See also

Context packs and memory in the composer (2026-10-03)

composer-context-controls.tsx is ONE always-visible "Context" button (first in the toolbar; useComposerContextMenu owns menu + dialog state, one shared preview) whose popover holds ComposerContextPackPicker (useComposerContextPacks: saves thread.contextPackIds through useUpdateThread, optimistic, capped at COMPOSER_CONTEXT_PACKS_MAX = 10), and two rows opening ComposerContextPacksView and ComposerMemoryView dialogs (fed by useComposerContextPreview → usePreviewContext, the server dry-run). MessageSaveAction under each answer calls POST /chat-messages/:id/save-to-context. i18n: chat.composerContext.*, chat.saveMessage.* (13 locales).

Labs and Compare use the chat composer's controls (2026-10-03)

OrchestrationPageShell (labs), compare/page.tsx and in-thread-compare-panel.tsx render the same Context button, Prompt library, attach, voice/video and research controls as the chat composer, and the same Enter-to-send prompt box (RichPromptTextarea). A lab has no thread until it runs, so OrchestrationContextButton holds the pack choice (useDraftContextPacks) and useOrchestrationComposer().sharedPayload sends it as contextPackIds. New composer controls go to all three places in the same commit (rule 59).

The composer "+" menu (2026-10-13)

The toolbar under the textarea is [+] [model] [research chip] [mic/video] … [credit], then send. Everything that used to sit loose beside them is a row of the round + menu (ComposerPlusMenu):

Section Rows
Attach FileLibraryMenu (recent files, search, Browse all; files lane)
Context (threads only) Context packs (sub-panel), What will be sent, Memory, Preview context
Tools Web research (sub-panel: mode + provider; hidden when the plan has no research), Prompt library
  • Shape. composer-plus-menu.tsx owns the state hook and the prompt-library dialog; composer-plus-menu-context-host.tsx runs the context hooks only when a thread exists and owns the three context dialogs; composer-plus-menu-view.tsx is the pure popover. composer-research-panel.tsx is shared with the toolbar chip (composer-research-chip.tsx), so both change the same state. preview-context-dialog.tsx replaced the standalone Preview button.
  • Tours. composer-attach is the +. The tour steps for context, prompts, preview and provider now point at composer-attach / composer-research (the chip); the old anchors also sit on the rows, so they resolve while the menu is open. A step whose element is absent still renders as a centred card.
  • Short viewports and touch. The popover scrolls inside (dvh + available height); rows are 44px under the touch variant; the chip and the + are 36px on a fine pointer and 44px on touch.
  • Keyboard. Tab reaches every row; arrow keys, Home and End move between rows and wrap; Esc closes. Opening a sub-panel moves focus into it; Back returns focus to the row that opened it.
  • i18n. chat.composerMenu.* in composer-menu-translations.ts (13 locales); labels reuse chat.composerContext.*, preview.openLabel, promptLibrary.openLabel and research.*.
  • Not yet moved (follow-up). Compare (chat/compare/page.tsx), in-thread Compare and OrchestrationPageShell still render the loose Context, research selects and Prompt library buttons: they build their own attachment and research state rather than ComposerToolbarProps, so moving them is a separate, tested change (rule 59).

The same "+" menu on every composer (2026-10-14)

Compare, in-thread Compare and the seven labs render the chat composer's ComposerPlusMenu (Attach / Context / Tools) and, while research is on, ComposerResearchChip; the loose Attach, Prompt library, Context and research controls are gone from them (chat-surface-layout-contract.test.ts enforces it). A surface with no thread yet passes draftContextPacks to get the pack picker; the thread-only rows (what is sent, memory, preview) are absent there. The @ plugin picker is part of RichPromptTextarea (pluginMentions).

Clone this wiki locally