Composer
The input surface: Lexical editor, reference chips, @ and / suggestions, attachments, lanes, and the queued-message card
| Host | Implementation | Note |
|---|---|---|
| Primitive | @nyte-ai/ui/popover, /preview-card, /button | |
| Desktop | conversation/composer.tsx | ComposerFrame (presentational) and Composer (session-bound); editor in composer-editor.tsx |
| Terminal | composer.ts, slash-autocomplete.ts | later 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
| Component | File | Pure? |
|---|---|---|
ComposerFrame | composer.tsx | mostly (platform + landing policy) |
Composer | composer.tsx | no (session, outbox, plugins) |
ComposerEditor | composer-editor.tsx | yes (Lexical) |
ComposerChipView | composer-chip.tsx | yes |
composer-keys.ts | yes | |
composer-document.ts | yes |
ComposerFrame props
| Prop | Type | Default | Description |
|---|---|---|---|
surface | "new-chat" | "follow-up" | "Placement is caller intent; the follow-up surface derives its own geometry." | |
document | ComposerDocumentState | "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." | |
placeholder | string | ||
autoFocus | boolean | false | |
disabled | boolean | false | |
busy | boolean | false | "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." | |
model | ReactNode | "The model chip slot, left side of the controls row." | |
inputRef | (handle: ComposerEditorHandle | null) => void | ||
onFocusChange | (focused) => void | ||
suggestionCatalog | ComposerSource<PluginCatalog> | loading / error / ready. | |
mentionFiles | ComposerSource<readonly MentionFile[]> | "Callers without an open workspace pass an empty ready list." | |
hasConversationContext | boolean | false | Adds the "Current conversation" @-mention. |
attachments | readonly ComposerImageAttachment[] | [] | |
attachmentBusy | boolean | false | Blocks submit. |
attachmentError | string | role="alert". | |
onFilesSelected | (files) => void | Presence 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.
| Key | Suggestions closed | Suggestions open |
|---|---|---|
| Enter | Submit to the steer lane (IME composition: ignored) | Pick the active suggestion |
| ⌘Enter / Ctrl+Enter | Submit to the queue lane (or swap lane when editing) | Still submits |
| Shift+Enter | Newline | |
| Tab (no shift) | Pick | |
| Escape | Cancel editing → else onDismissTray() → else if busy and the draft, attachments, and references are empty, onAbort() | Close |
| ArrowDown, Ctrl+N, Ctrl+J | Next | |
| ArrowUp, Ctrl+P, Ctrl+K | Previous | |
| Home / End | First / 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."
| Prop | Type | Description |
|---|---|---|
ref / inputRef | ComposerEditorHandle | element, focus(opts), read(), readDocument(), replaceText(start, end, text), insertReference(ref, start?, end?). |
label | string | aria-label, default "Message". |
document, onDocumentChange(document, completion) | completion is the detected @// token, undefined on external restores. | |
onReferencesChange | (references) => void | |
files | readonly MentionFile[] | |
disabled, autoFocus, placeholder | placeholder 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) => void | KEY_DOWN_COMMAND at high priority; skipped while composing. |
onFocusChange, onFilesSelected | The 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.