# Composer

Package: @commenda/pathfinder@0.1.0
Page: /docs/components/composer
Figma: https://www.figma.com/design/dn7BkGYEfIyP0o2zfesfVh/The-System?node-id=580-28168

```tsx
import { Composer } from '@commenda/pathfinder/composer';

<Composer
  label="Message"
  value={draft}
  onValueChange={setDraft}
  onSubmit={submit}
  requestConfig={{ status, onStop: stop }}
/>;
```


## Usage [#usage]

```tsx
import { Composer } from '@commenda/pathfinder/composer';

<Composer
  label="Message"
  value={draft}
  onValueChange={setDraft}
  onSubmit={({ text, attachments, command }) => {
    // Set request status synchronously; catch failures in your request handler.
    sendDraft({ text, attachments, command });
  }}
  requestConfig={{ status, onStop: stop }}
/>;
```

The host owns the draft, uploads, selected skill, and request lifecycle. Composer never starts a network request or clears data. Clear the submitted draft only after acceptance, and avoid clearing text the user has typed since submitting. Handle rejected requests in the application and preserve the draft for retry.

## Standard and hero [#standard-and-hero]

Standard starts with one 20px text line. Hero reserves a 53px editing area. Both grow with content, cap the composer surface at 40dvh, and keep Send reachable while the editor scrolls. The maximum width is 736px; smaller containers fill available width.


```tsx
<Composer
  label="Message"
  size="hero"
  value={draft}
  onValueChange={setDraft}
  onSubmit={submit}
/>;
```


## Skills and API search [#skills-and-api-search]

Type `/` at a word boundary to open skills. Text after the slash filters local labels and descriptions. Arrow keys move through enabled results; Enter selects without sending. Selection removes the query and displays the selected skill as a blue Tag beside the editable text. The submit payload preserves the command separately from plain text; no rich-text document or Vercel message object is required.

```tsx
<Composer
  label="Message"
  value={draft}
  onValueChange={setDraft}
  onSubmit={submit}
  commandConfig={{
    items: results,
    selected: selectedSkill,
    onSelect: setSelectedSkill,
    filterMode: 'manual',
    onSearchChange: setQuery,
    isLoading,
  }}
/>
```

Manual mode preserves API ranking and does not filter results again. `onSearchChange` receives the query while open and `null` when closed. Debounce, cancel outdated requests, and supply results only for the current query. Set `isLoading` while results are stale: selection is blocked and previous options are hidden. Empty results cannot accidentally send the query. The following example simulates an API; search for `tax` to receive semantically related results.


```tsx
<Composer
  label="Message"
  value={draft}
  onValueChange={setDraft}
  onSubmit={submit}
  commandConfig={{
    items: results,
    selected: skill,
    onSelect: setSkill,
    filterMode: 'manual',
    onSearchChange: setQuery,
    isLoading,
  }}
/>;
```


The selected skill is one token preceding the plain-text draft. This is not an arbitrary inline rich-text mention editor. Shared context comes from the host through `contextConfig`; no undocumented `@` picker is introduced.

## Attachments, mode, and dictation [#attachments-mode-and-dictation]

`attachmentConfig` composes AttachmentGroup above the editor. `onAdd` opens your existing file picker or upload menu; validate with AttachmentValidation before uploading. Pending or failed uploads disable Send until resolved or removed. Removal, retry, previews, cancellation, and URLs remain application-owned.

`toolbar.leading` accepts Mode/Model Select or Dropdown controls. `toolbar.trailing` accepts a dictation IconButton. Render only actions your application supports and pass their disabled states explicitly. Composer does not request microphone permission or connect to a speech service.


```tsx
<Composer
  label="Message"
  value={draft}
  onValueChange={setDraft}
  onSubmit={submit}
  attachmentConfig={{
    items: attachments,
    onAdd: openFilePicker,
    onRemove: removeAttachment,
  }}
  toolbar={{
    leading: (
      <Select
        label="Mode"
        hideLabel
        options={modes}
        value={mode}
        onValueChange={setMode}
      />
    ),
    trailing: (
      <IconButton
        label="Dictate message"
        size="sm"
        onClick={startDictation}
      >
        <Microphone01Icon />
      </IconButton>
    ),
  }}
/>;
```


## Context [#context]

Pass ComposerContext's top or bottom configuration through `contextConfig`. Context stays separate from the outgoing text; your host adds any domain identifiers to its request. To dismiss a bottom invitation, retain its position and set `connectorConfig: null`.


```tsx
<Composer
  label="Message"
  value={draft}
  onValueChange={setDraft}
  onSubmit={submit}
  contextConfig={{ items: contextItems }}
/>;
```


## Sending and failure [#sending-and-failure]

`submitted` and `streaming` show Stop. Typing remains available, but Enter never queues a message. `ready` and `error` allow an explicit retry when text and attachments are valid. An absent `onStop` disables Stop. Errors belong in the appropriate Attachment, Message, or host notification, with no red composer border.


```tsx
<Composer
  label="Message"
  value={draft}
  onValueChange={setDraft}
  onSubmit={submitWithErrorHandling}
  requestConfig={{ status: 'error' }}
/>;
```


## Keyboard and accessibility [#keyboard-and-accessibility]

- Provide a meaningful `label`; the placeholder is not the accessible name.
- `enter` sends with Enter, while Shift+Enter inserts a newline. `mod-enter` sends with Command/Ctrl+Enter and leaves ordinary Enter for newlines.
- IME composition does not submit or choose a skill. Pasted line breaks are preserved.
- Skills use a listbox linked to the textarea, with an active descendant. Focus stays in the editor.
- Escape closes suggestions and stops propagation. It never cancels generation; use Stop. Tab leaves suggestions without choosing a result.
- Disabled state locks the editor and built-in actions. Its visible reason is associated with the editor. Host toolbar controls must also receive disabled state.
- The forwarded ref is the textarea, so selecting a starter prompt can focus it.


```tsx
<Composer
  label="Message"
  value={draft}
  onValueChange={setDraft}
  onSubmit={submit}
  disableConfig={{
    isDisabled: true,
    reason: 'This conversation is archived.',
  }}
/>;
```


## Mobile layout [#mobile-layout]

The host uses a flex column with scrollable conversation history and a footer that stays above the software keyboard. This example listens to `visualViewport.resize`, bounds its height, and includes the bottom safe area. Composer stays in normal document flow. Test the containing application on actual iOS/Android keyboards before release.


```tsx
// Host layout: viewport resize and safe-area padding belong to your chat shell.
<div className="chat-layout">
  <div className="scrollable-history">{messages}</div>
  <footer>
    <Composer
      label="Message"
      value={draft}
      onValueChange={setDraft}
      onSubmit={submit}
    />
  </footer>
</div>;
```


## API reference [#api-reference]

| Prop | Type | Requirement | Behavior |
| --- | --- | --- | --- |
| `label` | `string` | required | Required accessible editor name |
| `value` | `string` | required | Required controlled draft |
| `onValueChange` | `((value: string) => void)` | required | Required controlled draft |
| `onSubmit` | `((message: ComposerSubmission) => void)` | required | Required; host handles async failures |
| `size` | `"standard" \| "hero"` | optional | `standard` |
| `placeholder` | `string` | optional | `Ask Commenda anything`; mention `/` only when skills are configured |
| `requestConfig` | `{ status: "ready" \| "submitted" \| "streaming" \| "error"; onStop?: () => void; submitLabel?: string; stopLabel?: string; }` | optional | `status`: `ready`, `submitted`, `streaming`, or `error` |
| `attachmentConfig` | `Omit<AttachmentGroupProps, "className" \| "align"> & { onAdd?: () => void; addLabel?: string; }` | optional | Alignment/class controlled internally; default three rows |
| `commandConfig` | `ComposerCommandConfig` | optional | Optional local/manual skill list and controlled selection |
| `contextConfig` | `ComposerContextConfig` | optional | Top context or bottom connector invitation |
| `toolbar` | `{ leading?: ReactNode; trailing?: ReactNode; }` | optional | Application-owned controls |
| `submitShortcut` | `"enter" \| "mod-enter"` | optional | `enter` |
| `disableConfig` | `{ isDisabled: boolean; reason?: string; }` | optional | Optional disabled state and caption |
| `inputProps` | `Pick<TextareaHTMLAttributes<HTMLTextAreaElement>, "autoFocus" \| "id" \| "spellCheck" \| "aria-describedby" \| "onFocus" \| "onBlur" \| "name" \| "autoComplete" \| "maxLength">` | optional | `id`, `name`, `autoFocus`, `autoComplete`, `spellCheck`, `maxLength`, `aria-describedby`, `onFocus`, `onBlur` |
| `className` | `string` | optional | Outer layout and imperative focus |

### requestConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | `"ready" \| "submitted" \| "streaming" \| "error"` | Yes |  |
| `onStop` | `(() => void)` | No |  |
| `submitLabel` | `string` | No |  |
| `stopLabel` | `string` | No |  |

### commandConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `items` | `readonly ComposerCommand[]` | Yes |  |
| `selected` | `ComposerCommand \| null` | Yes |  |
| `onSelect` | `(command: ComposerCommand \| null) => void` | Yes |  |
| `filterMode` | `"local" \| "manual"` | No | Manual mode accepts results as supplied, without applying a second filter. |
| `onSearchChange` | `((query: string \| null) => void)` | No |  |
| `isLoading` | `boolean` | No |  |
| `label` | `string` | No |  |
| `emptyLabel` | `string` | No |  |

### commandConfig.selected fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | Yes |  |
| `label` | `string` | Yes |  |
| `description` | `string` | No |  |
| `disabled` | `boolean` | No |  |

### contextConfig fields

Choose one shape; excluded fields cannot be combined.

#### Option 1

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `items` | `readonly ComposerContextItem[]` | Yes |  |
| `onRemove` | `((id: string) => void)` | No |  |
| `position` | `"top"` | No |  |

Do not supply: `connectorConfig`.

#### Option 2

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `position` | `"bottom"` | Yes |  |
| `connectorConfig` | `{ label: string; logos?: readonly ReactNode[]; onConnect: () => void; onDismiss?: () => void; } \| null` | Yes |  |

Do not supply: `items`, `onRemove`.

### contextConfig.connectorConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | `string` | Yes |  |
| `logos` | `readonly ReactNode[]` | No |  |
| `onConnect` | `() => void` | Yes |  |
| `onDismiss` | `(() => void)` | No |  |

### toolbar fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `leading` | `ReactNode` | No |  |
| `trailing` | `ReactNode` | No |  |

### disableConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `isDisabled` | `boolean` | Yes |  |
| `reason` | `string` | No |  |

`ComposerSubmission` contains `{ text: string; attachments: readonly AttachmentItem[]; command: ComposerCommand | null }`. `ComposerCommand` contains `{ id, label, description?, disabled? }`. IDs must be unique and stable. `ComposerCommandConfig` requires `items`, `selected`, and `onSelect`, with optional `filterMode`, `onSearchChange`, `isLoading`, `label`, and `emptyLabel`.

## Design rules [#design-rules]

- **COMPOSER-01:** Figma 580:28873: 16px inset, 6px gap, 20px radius, pearl400 hairline, soft raised shadow, 736px maximum. Hover/focus border uses blue700.
- **COMPOSER-02:** Send requires nonblank text and ready attachments. Generating changes Send to Stop without locking the draft.
- **COMPOSER-03:** Never clear, silently queue, upload, or execute commands internally.
- **COMPOSER-04:** Respect IME and both keyboard modes. Escape is scoped to suggestions; explicit Stop avoids cancelling a request when dismissing a menu.
- **COMPOSER-05:** Do not render a red error composer. Disabled state explains why in a caption.
- **COMPOSER-06:** Skills use a searchable listbox and blue Tag; toolbar actions use existing Pathfinder controls. Application context is supplied by the host.
- **COMPOSER-07:** Mobile editor text is 16px. The host docks its chat layout above the keyboard and accounts for safe areas; Composer itself does not take over the viewport.

The existing TextArea's field shell is intentionally not reused: it adds a second border, field padding, and an 80px minimum. Composer keeps a private native textarea and reuses AttachmentGroup, ComposerContext, Tag, and IconButton.
