Repository navigation
Chat Surface Layout
Canonical source:
docs/05-frontend/chat-surface-layout.md— this page is rendered from it on every publish; edit the source.
How
/chat/[threadId]is built, file by file. The product decisions behind it live indocs/02-business-product/chat-thread-page-spec.md; the enforceable constraints inrules/40-chat-surface-layout-and-composer.md.
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.
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-nonebeats itsresize-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-0strips 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.
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".
-
useComposerAttachmentsnow keeps a per-file ledger (useComposerUploadEntries:uploads+dismissUpload) instead of only a pending count. A failed / unsupported (HTTP 415, or refused byuploadFileSchema) 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.
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).
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; -
mainkeepsp-3; - the attachment tray is one row that scrolls sideways (
flex-nowrap overflow-x-auto), capped at22dvh; - the textarea is capped at
30dvhwith.composer-textarea-short-cap, and scrolls inside past that cap. The class forcesoverflow-y: auto, because the autosize hook sets an inlinehiddenbelow 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 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 (
smwidth, 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.
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).
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.
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 fromsmup (flex-col sm:flex-row sm:items-baseline). Belowsmit 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-basefor the header went from1.25rem/1.75remto0.375rem/0.5rem. The old value put 28px of empty band directly under the portal's ownsm:p-6; breathing room the ancestor already supplies is not breathing room, it is a second copy of it. -
The title dropped
sm:text-lgfor a flattext-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 keepstext-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 whencollapsePrimaryActionsis true, which is exactly belowsm. One bag, built once inuseThreadDetailPageand handed to both hosts: the header belowsm, the rail above it. Radix owns the keyboard contract — arrows move, Escape closes and restores focus to the trigger.
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-maxon 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-columnwhile 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 intitle. It carries bothtruncateandclamp-title:globals.cssdeliberately neutralises.truncateunder 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 fromuseEditableTitlerather than a seconduseTranslationcall 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.
.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.
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 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).
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.
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.*).
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.
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.
Owned by Virtuoso and hooks/chat/use-virtualized-messages-controller.ts:
-
followOutputreturns'smooth'only when the user is already at the bottom, so a user reading history is never yanked forward. -
atBottomThresholdisSTICKY_BOTTOM_THRESHOLD_PX. -
JumpToLatestButtonappears whenever the user is away from the bottom, with an unread count of assistant render-groups accumulated since they scrolled up. -
useFollowStreamingTokenskeeps 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.
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.
- No fixed pixel heights anywhere on the surface.
- No
position: fixedcomposer, therefore no clearance custom property to keep in step (see rule 36). - No second autosize implementation.
- No duplicated mobile/desktop control rows.
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.tsxplususe-answer-export.ts.-
.mdand.txtare saved in the browser, with no request. -
.html,.docx,.pdf,.xlsx,.pptxand.zip(ADR-108) callPOST /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 isskills/add-an-answer-export-format.md.
-
- The filename comes from
answerTitleandsafeFileBaseinutilities/answer-export.utility.ts. - Test ids:
answer-expand-trigger,answer-rendered,answer-raw,answer-export-trigger, andanswer-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.
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.allprefix, 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 callPOST :id/use(best effort). - Delete confirms inside the row; no second dialog is stacked.
- Strings:
promptLibrary.*in all 13 locales.
-
skills/verify-responsive-layout-in-browser.md— how to check the above in a real browser docs/05-frontend/component-architecture.mddocs/05-frontend/styling-guide.md
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).
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 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.tsxowns the state hook and the prompt-library dialog;composer-plus-menu-context-host.tsxruns the context hooks only when a thread exists and owns the three context dialogs;composer-plus-menu-view.tsxis the pure popover.composer-research-panel.tsxis shared with the toolbar chip (composer-research-chip.tsx), so both change the same state.preview-context-dialog.tsxreplaced the standalone Preview button. -
Tours.
composer-attachis the+. The tour steps for context, prompts, preview and provider now point atcomposer-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.*incomposer-menu-translations.ts(13 locales); labels reusechat.composerContext.*,preview.openLabel,promptLibrary.openLabelandresearch.*. -
Not yet moved (follow-up). Compare (
chat/compare/page.tsx), in-thread Compare andOrchestrationPageShellstill render the loose Context, research selects and Prompt library buttons: they build their own attachment and research state rather thanComposerToolbarProps, so moving them is a separate, tested change (rule 59).
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).
ClawAI Wiki · source-controlled under wiki/ · generated/mirrored from the current repository knowledge layer.
ClawAI Wiki
Product
- Product-Vision
- Flagship-Features
- Business-Overview
- Requirements-Register
- Drift-Log
- Runtime-Managed-Connector-Providers
- Threads
- ADR-Index
Architecture
- System-at-a-Glance
- Architecture-Map
- System-Architecture
- Message-Flow
- End-to-End-Data-Flow
- Routing-Engine
- Event-Bus
- Security-Architecture
Platform
- Backend-Services
- Frontend-Architecture
- Shared-Packages
- Database-Reference
- API-Reference
- Provider-Catalog
- Memory-and-Context-Architecture
- File-Support-Architecture
- Research-and-Web-Crawling
Runtime & Ops
- Docker-Guide
- Nginx-Reference
- CI-CD-Pipeline
- Logging-and-Observability
- Operational-Runbooks
- Troubleshooting
Quality
Engineering OS
- AI-Native-Engineering-OS
- Generated-Manifests
- Rules-Catalog
- Skills-Catalog
- Context-Layer
- Engineering-Memory
- Tooling-and-Automation
- Scripts-and-Operations
- ESLint-and-Architecture-Enforcement
- Extended-Work-Skills
Agents
Reference