# Composer (/cloud/surfaces/composer)



<HostMatrix
  primitive="{
  label: &#x22;@nyte-ai/ui/popover, /preview-card, /button&#x22;,
  href: &#x22;/cloud/headless/popover&#x22;,
}"
  desktop="{
  label: &#x22;conversation/composer.tsx&#x22;,
  note: &#x22;ComposerFrame (presentational) and Composer (session-bound); editor in composer-editor.tsx&#x22;,
}"
  terminal="{ label: &#x22;composer.ts, slash-autocomplete.ts&#x22;, note: &#x22;later pass&#x22; }"
/>

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 [#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 [#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 [#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 [#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`](/cloud/headless/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 [#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 [#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](/cloud/surfaces/jobs) 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 [#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.
