Design system
Docs
Surfaces

Composer

The input surface: Lexical editor, reference chips, @ and / suggestions, attachments, lanes, and the queued-message card

HostImplementationNote
Primitive@nyte-ai/ui/popover, /preview-card, /button
Desktopconversation/composer.tsxComposerFrame (presentational) and Composer (session-bound); editor in composer-editor.tsx
Terminalcomposer.ts, slash-autocomplete.tslater pass

From composer.tsx:

The input surface has two layouts: the new-chat field stacks above its controls while the follow-up field is one compact row. Admission is open (invariant 5): sending while a run is live is not an error. Enter sends to the lane that lands at the next response boundary (it steers), Cmd/Ctrl+Enter to the lane that waits for an idle head (it queues a follow-up), both read from the landing policy. The toolbar card shows still-pending queue items with edit, cancel, and 'send now' (redeliver), and Esc requests a durable abort.

Parts

ComponentFilePure?
ComposerFramecomposer.tsxmostly (platform + landing policy)
Composercomposer.tsxno (session, outbox, plugins)
ComposerEditorcomposer-editor.tsxyes (Lexical)
ComposerChipViewcomposer-chip.tsxyes
composer-keys.tsyes
composer-document.tsyes

ComposerFrame props

PropTypeDefaultDescription
surface"new-chat" | "follow-up""Placement is caller intent; the follow-up surface derives its own geometry."
documentComposerDocumentState"The frame reports every edit back; the parent owns the value."
onDocumentChange(document) => void
onSubmit(submission, lane) => boolean | Promise<boolean>"Resolves once the send is accepted or refused. The parent clears the document itself."
placeholderstring
autoFocusbooleanfalse
disabledbooleanfalse
busybooleanfalse"A run is live: empty-input Esc and the idle button both request an abort."
onAbort() => void
onDismissTray() => boolean"An open composer tray handles Escape before the empty-input abort shortcut."
modelReactNode"The model chip slot, left side of the controls row."
inputRef(handle: ComposerEditorHandle | null) => void
onFocusChange(focused) => void
suggestionCatalogComposerSource<PluginCatalog>loading / error / ready.
mentionFilesComposerSource<readonly MentionFile[]>"Callers without an open workspace pass an empty ready list."
hasConversationContextbooleanfalseAdds the "Current conversation" @-mention.
attachmentsreadonly ComposerImageAttachment[][]
attachmentBusybooleanfalseBlocks submit.
attachmentErrorstringrole="alert".
onFilesSelected(files) => voidPresence enables drag-drop, paste, and the hidden file input (gif, jpeg, png, webp).
onAttachmentRemove(id) => void
editing{ lane: Lane; onCancel: () => void }Editing a queued item; Enter keeps its lane, modifier swaps.

Keyboard

Resolved by composerEnterAction(event) in composer-keys.ts and the frame's onKeyDown.

KeySuggestions closedSuggestions open
EnterSubmit to the steer lane (IME composition: ignored)Pick the active suggestion
⌘Enter / Ctrl+EnterSubmit to the queue lane (or swap lane when editing)Still submits
Shift+EnterNewline
Tab (no shift)Pick
EscapeCancel editing → else onDismissTray() → else if busy and the draft, attachments, and references are empty, onAbort()Close
ArrowDown, Ctrl+N, Ctrl+JNext
ArrowUp, Ctrl+P, Ctrl+KPrevious
Home / EndFirst / last
PageDown / PageUp±9

@ opens mention suggestions; / opens commands and skills. Both are detected by composer-document.ts. File suggestions are capped at 20 and ranked label-prefix > label-includes > description-includes; "Up to thousands of files rank per keystroke; keep that off the render path."

The editor as combobox

ComposerEditor is uncontrolled Lexical: "Lexical owns the document, the selection, the IME, the clipboard, and history; React owns the chips it portals into Lexical's decorator hosts and the caret it draws over the native one." A document prop is applied only when it differs from the editor's last report, so "the parent's echoes never reseed."

PropTypeDescription
ref / inputRefComposerEditorHandleelement, focus(opts), read(), readDocument(), replaceText(start, end, text), insertReference(ref, start?, end?).
labelstringaria-label, default "Message".
document, onDocumentChange(document, completion)completion is the detected @// token, undefined on external restores.
onReferencesChange(references) => void
filesreadonly MentionFile[]
disabled, autoFocus, placeholderplaceholder becomes data-placeholder.
combobox{ expanded, popupId, activeOption }Switches role from textbox to combobox with aria-autocomplete="list", aria-expanded, aria-controls, aria-activedescendant.
onKeyDown(event: KeyboardEvent) => voidKEY_DOWN_COMMAND at high priority; skipped while composing.
onFocusChange, onFilesSelectedThe latter enables PASTE_COMMAND file interception.

The suggestion popup is @nyte-ai/ui/popover with modal={false}, initialFocus/finalFocus off, role="listbox", and onMouseDown preventDefault so focus never leaves the editor. "The editor owns the combobox. PreviewCard associates its popup with a result trigger, but that result must never become the typing target." Scrolling the active option uses the popup's own list, not scrollIntoView, which "can drag the thread or the window with it."

Data attributes: data-placeholder, data-empty, data-custom-caret, data-composer-caret.

Chips

ComposerChipView({ reference, onRemove }): "One chip, the same in the editor, the queue strip, the transcript, and a message edit." onRemove is "present only inside an editor; read-only surfaces draw the chip without controls." Sets data-composer-chip={kind} and data-has-remove-button; the remove button prevents mousedown default to keep editor focus.

Session composer

Composer binds the frame to a session: sessionId, working (→ busy), pending ("durable queue items waiting behind a live run"), unsent (outbox rows), viewState / onViewStateChange (pane store), fileDropRoot (binds drop to the transcript scroller), backgroundWork (the TasksPanel slot and its Escape handler). It renders the SessionModelChip ("shows the selected inputs for the next message, even while an older run is still executing"), the queued card (aria-label="Queued messages", rows role="status" with Edit / Send now / Cancel), and a feedback bar with "Restore draft". Region: role="region" aria-label="Conversation input".

Lanes

laneRoles(landing){ steer, queue }; "a policy with one lane fills both roles." submissionLane(action, roles, current?) picks the lane for an action, and modifierKeyLabel(mac) gives "⌘" or "Ctrl+" for hints.