Composer and pickers
The desktop app's prompt box, model pickers and question cards as React components.
Composer is the desktop app's prompt box: a plain-text editor, staged attachments, a row for pickers and one button that sends or stops the run. HostComposer binds it to a ChatHost. HostInteractions shows the questions and plans that the agent waits on. The demo runs all three on a fixture host. Your first prompt gets a question back.
Mount it#
Stack the three components as the desktop app's chat column does (hosts).
import type { ChatHost } from 'zidane-ui/chat/headless'
import 'zidane-ui/styles.css'
import { ChatProvider, HostComposer, HostInteractions, HostTranscript } from 'zidane-ui/chat'
export function Chat({ host, id }: { host: ChatHost, id: string }) {
return (
<ChatProvider host={host}>
<HostTranscript sessionId={id} />
<HostInteractions sessionId={id} autoFocus />
<HostComposer sessionId={id} attachments />
</ChatProvider>
)
}Composer alone needs no host:
import { Composer } from 'zidane-ui/chat'
<Composer
running={running}
onSend={({ prompt, attachments }) => agent.run(prompt, attachments)}
onStop={() => agent.abort()}
delivery="queue"
/>The editor has no Markdown shortcuts. Paragraphs join with one \n and the prompt is trimmed.
The editor grows with its text up to 40% of the window, then scrolls inside the card. Set --zg-composer-max-h on an ancestor to change that cap. In a panel of fixed height, make the panel a size container and cap the editor at a share of it, so a long prompt never grows the panel:
.chat-panel {
container-type: size;
--zg-composer-max-h: 30cqh;
}Composer props#
| Prop | Type | Description |
|---|---|---|
onSend | ({ prompt, attachments, delivery? }) => void | boolean | Promise<…> | Required. Enter or the send button. While running, delivery is steer or queue |
running | boolean | A run is in flight: Enter steers or queues, Escape and the button stop |
delivery | 'steer' | 'queue' | What Enter does while running. AltEnter does the other. Pass the user's "Messages during a run" setting. Default queue |
onStop | () => void | Makes the button Stop while running and binds Escape |
defaultValue, value, onChange | string | Uncontrolled by default. Setting value turns badges back into text |
placeholder | string | Default: composerPlaceholder label (Message the agent…) |
ariaLabel | string | Textbox name. Default: composerLabel label (Message) |
disabled | boolean | Not editable, nothing sends |
attachments | boolean | Paste, drop and pick images, PDFs and text files |
defaultAttachments | ComposerAttachment[] | Staged on mount (a restored draft) |
footer | ReactNode | Controls after the attach button, such as pickers |
trailing | ReactNode | Controls at the end of the controls row, before Send (a context meter) |
below | ReactNode | A band under the card, inside the tray ring |
badges | ComposerBadge[] | The badge strip at the top of the tray ring, in display order, see badges |
onBadgesReorder | (ids) => void | Makes the badges draggable. Gets every badge id in the new order |
badgesKey | string | What the badges belong to (a session id). A new key shows its badges without animating them. HostComposer passes sessionId |
mentions, commands | SuggestionSource | @ and / completions, see below |
history | string[] | Earlier prompts for ↑ and ↓, oldest first. Default: what this composer sent while mounted |
autoFocus | boolean | Focus on mount |
onError | (error) => void | Called when a handler throws. Default: the composer shows the message under the editor |
className | string | Extra classes for the root |
A handler that returns false or throws puts the draft back, text and attachments included.
A ref gives a ComposerHandle: focus(), clear(), insertText(text) at the caret, getText() (the draft as the composer sends it).
Badges#
badges puts a strip of pills at the top of the composer's tray, above the card: what waits around the conversation, such as a todo list, queued messages or an extension's status. A badge with onSelect is a button, and the active one is the raised chip. The desktop app uses the strip for its Inbox, Todos and Queue tabs and for extension badges (composer badges). Click a badge below, drag one, or add and clear them with the buttons.
import { Composer, mergeBadgeOrder, orderComposerBadges } from 'zidane-ui/chat/composer'
const [order, setOrder] = useState<readonly string[]>([])
const badges = orderComposerBadges([
{ id: 'todos', label: 'Todos', icon: 'i-ph-list-checks', count: '2/5', order: 10, onSelect: openTodos },
{ id: 'inbox', label: 'Plan to review', icon: 'i-ph-tray', highlight: true, order: 0, onSelect: openInbox },
], order)
<Composer
onSend={send}
badges={badges}
badgesKey={sessionId}
onBadgesReorder={ids => setOrder(saved => mergeBadgeOrder(saved, ids))}
/>ComposerBadge field | Type | Description |
|---|---|---|
id | string | Required. The key, and what a saved order names |
label | string | Required. The badge's name |
count | string | A count or short value after the label, drawn as a count badge |
countTone | 'info' | 'neutral' | 'attention' | Look of the count. Default info, attention on a highlighted badge |
icon | string | Leading icon class, such as i-ph-list-checks |
tone | 'muted' | 'subtle' | 'accent' | 'success' | 'warn' | 'danger' | Tints the icon |
loader | boolean | A running arc in place of the icon |
highlight | boolean | Something waits on the user: raised in the warn tone, breathing, scrolled into view |
title | string | Tooltip, read with the badge by assistive tech |
active | boolean | What the badge opens is on screen: the raised chip, aria-pressed |
order | number | Default slot for orderComposerBadges, lower first (default 100) |
onSelect | () => void | Click, Enter or Space. Without it the badge is a plain pill |
The strip is a group of buttons. It scrolls sideways when the badges overflow, edges faded, and a vertical wheel scrolls it while it can move. The highlighted badge, else the active one, stays in view.
With onBadgesReorder, the user drags a badge, or moves the focused one with Alt Shift ← →. The new order comes once per move. orderComposerBadges(badges, saved) sorts by order and applies a saved order over the badges it names, so a badge that is new since the user last moved one keeps its default slot. mergeBadgeOrder(saved, ids) is the order to save after a move: the badges shown in their new order, with saved ids not shown now kept where they were.
Badges enter on the kit's settle motion with a fade and leave on exit, and the others slide to their slots. The band opens and closes with the first and last badge. A new badgesKey shows the next conversation's badges as they are, with nothing animating in. Under reduced motion only the fades remain, and with motion off nothing moves. ComposerBadges is the strip alone, for a composer of your own: put it first in the tray.
Attachments#
With attachments on, each pasted, dropped or picked file becomes a ComposerAttachment: { id, name, mediaType, size, dataUrl }. The rules come from planAttachment in zidane/chat/pure, so the desktop app uses the same rules.
| File | Rule |
|---|---|
| PNG, JPEG, GIF or WebP image up to 3.5 MiB | Attached as it is |
| Larger image, or another image format (AVIF, BMP, SVG, HEIC) | Encoded again as PNG (a JPEG stays JPEG) and scaled down until it fits 3.5 MiB. If the browser cannot decode it, the composer refuses it |
| PDF or text file | Attached up to 20 MiB. A larger file is refused |
| Office document | Refused, with a request to export it as PDF |
| Other binary file | Refused |
| Pasted text | Becomes a .txt attachment at more than 10 lines, or at 4,092 characters or more |
| Text preview in the lightbox | Files up to 1 MiB. A larger file shows its name and size only |
For a refused file, the composer shows the reason under the editor.
Click a tile to open it in the lightbox. The terminal app has its own limits.
Keyboard#
| Key | Action |
|---|---|
| Enter | Send. While running: with delivery (steer or queue) |
| Alt Enter | While running: send with the other delivery |
| Shift Enter | New line |
| Esc | Stop the run, when running and onStop is set |
| ↑ | On the first line: previous prompt (the draft is kept) |
| ↓ | On the last line, while recalling: next prompt, then the draft |
| ↑ ↓ Enter Tab Esc | In a suggestion popup: move, pick, pick, close |
A popup with no matches leaves Enter, Tab and the arrows to the draft. A command typed out in full opens no popup, so Enter sends it.
The editor is a textbox named by ariaLabel. The attach, send and stop buttons have labels. The popup is a listbox with an aria-selected option.
HostComposer#
HostComposer takes every Composer prop except the handlers and running, plus the props below. It reads running from host.transcript(sessionId). A missing host capability hides its control.
| Control | Needs | Calls |
|---|---|---|
| Composer | host.send | send({ sessionId, prompt, attachments, model, thinking }). Without send, nothing renders |
| Stop, Escape | host.interrupt | interrupt(sessionId) |
| Enter while running | host.send | send({ …, delivery }): steer into the run or queue for its end, attachments included. A run that ended first gets the prompt as a new send |
| Model picker | non-empty host.models | Sent as model (provider/model) |
| Reasoning picker | picked model has thinking: true | Sent as thinking |
| Prop | Description |
|---|---|
sessionId | Required |
defaultModel | provider/model or a bare id. Default: the session's modelId, else the first offered |
defaultThinking | off, minimal, low, medium (default), high |
footer | Controls after the pickers |
onModelChange | Called with the provider/model key when the user picks a model |
badges, onBadgesReorder | The badge strip, as on Composer. badgesKey defaults to sessionId |
SendInput.thinking takes those five levels only (THINKING_LEVELS). zidane has three more: xhigh, max and adaptive (see thinking). ModelOptionsPicker used alone lists all eight (EFFORT_LEVELS) unless you pass levels.
The desktop app's pickers are exported for your own Composer. Put them in one zg-segment track in footer.
| Picker | Props | Picks |
|---|---|---|
AgentPicker | profiles, value, onChange(id), optional onOpenChange(open). A profile's hint shows dim at the end of its row | An agent profile |
ModelPicker | providers: ChatModelProvider[], value, onChange(choice), optional onOpenChange(open) | One model |
ModelChainPopover | a ModelCatalogRow[] catalog, chain, onCommit(chain), showLegacy, onShowLegacyChange, optional onOpenChange(open), forNextChat and shortcut. A row's details show beside its name. Hovering the pill shows the chain (ModelChainTooltip). images ({ rows, value, onPick }) adds a Chat / Image switch under the search field, and kind: 'chat' | 'image' shows one list only | A model with fallbacks (the app's model pill). catalogFromProviders turns host.models into a catalog |
ImageModelList | rows: ImageModelRow[] | null, value, onPick(key), showLegacy, onShowLegacyChange, optional toolbar, query and onQueryChange | The image models in the chat models' list (ModelChainList, one model): the same header, pills, empty states and footer, the default pinned as In use. effectiveImageModel resolves the stored default |
ModelOptionsPicker | supportsReasoning, effort, onEffort(level), options, enabled, onOptions(next), optional levels, effortHints (a note per level) and onOpenChange(open) | Effort and per-model options |
onOpenChange fires on every open and close, so you can load what the menu shows each time it opens. The desktop app uses it to fetch the cache hints (re-reads ~452k tokens).
Mentions and commands#
@ anywhere, or / at the start of the prompt, opens a popup at the caret.
interface MentionItem {
id: string // what the host resolves: a path, a session id, a command
label: string // what the popup and the badge show
description?: string
icon?: string // an icon class, e.g. 'i-ph-file'
}
interface SuggestionSource {
items: (query: string) => MentionItem[] | Promise<MentionItem[]>
serialize?: (item: { id: string, label: string }) => string
}items runs on every keystroke with the text after the trigger and returns items ranked (the first is selected). A picked item shows as a badge and is sent as @label or /label unless serialize returns something else. Most hosts want the id:
<HostComposer
sessionId={id}
mentions={{
items: query => files.filter(f => f.path.includes(query)).slice(0, 20).map(f => ({ id: f.path, label: f.name })),
serialize: item => `@${item.id}`,
}}
commands={{ items: query => commands.filter(c => c.name.startsWith(query)).map(c => ({ id: c.name, label: c.name })) }}
/>Interactions#
When the agent calls ask_user or present_plan, the run waits. HostInteractions lists pending requests from host.interactions.pending(sessionId), oldest first, answers with host.interactions.answer(requestId, response) and renders nothing when none are pending.
| Request | Card |
|---|---|
| Question | A form (free text, single or multiple choice, yes/no) plus "Additional instructions". Required questions block Send. Dismiss (continue without an answer) shows when the host has interactions.cancel |
| Plan | Text and steps. Approve answers at once. Revise and Reject take an optional note. Reject stops the run |
| Unknown kind | Its tool and payload, read-only |
InteractionCard renders one card: interaction (a ChatInteraction), onAnswer(response), optional onDismiss and autoFocus. Both components draw Markdown through the transcript's Markdown slot, or a Markdown prop.
Sending from a desktop-style composer#
sendPrompt(request, deps) (zidane-ui/chat/headless) is what the desktop app's Send does, React-free. It checks a provider is connected (ChatHost.providerReady) when the app last knew none, hands /side to the side panel, creates the session from the new-chat draft (deps.createSession, built with newSessionRequest(draft, text, defaults)), runs a ! command with ChatHost.shell, queues a prompt sent mid-run (deps.enqueue, the session host's queue), and otherwise sends it with its chips (promptRefs) and previews. A known / command shows its own header, so it is sent with echo: false. It rejects, so the composer keeps the text, when no provider is connected, /side is refused or the session could not be created.
| Host member | What it adds | Absent |
|---|---|---|
send({ …, activatedSkills, refs, echo, keepDraft }) | Skills activated before the run, the echo's chips, no echo for a / command, the shared draft kept | Nothing is sent |
shell(sessionId, command) | ! runs outside any agent run, streamed into the transcript | A ! prompt goes to the model as text |
providerReady(cwd?) | A send with no provider connected is refused | Never checked |
useProviderReadyPoll(store, paused) keeps a Writable<boolean | null> on that check, at mount and each time paused turns off (the desktop app pauses it while a modal is open, as Settings › Accounts and the setup wizard write the credentials): pass the same store as useSendPrompt's providerReady, and read it for a sign-in pill. A failed check stores null (unknown), never "none".
useSendPrompt(options) (zidane-ui/chat/composer) binds it to the providers: the ChatHost, the session host's queue, the composer host's / commands (completions.commands), the git host's machine folders and the prompt history. The app passes what only it holds: createSession(request), newSessionDefaults() (the picks a new session starts with), onSessionCreated, reveal, side(sessionId, question) and the provider flag (providerReady, a Writable<boolean | null>). It returns send, stop and needsProvider, which stays up until the flag says a provider is ready.
useDraftTarget({ session, defaultFolder, setDefaultFolder, sessions, servers, onLocalFolder }) keeps the draft's picks: the folder (or none), a saved server and its folder, an environment kind (useEnvironmentChoices, zidane-ui/git) and a machine's folder. Every pick resets when the conversation moves to a session. onLocalFolder hears the draft's folder on this computer while the draft shows.
The draft's rules are there too: recentLocalFolders, draftFolderPath and draftLocalFolder say which folders the folder menu offers, which folder the draft is in and when it makes the window's draft scope. createDraftScopeStore({ projectRoot, release, forget }) holds that scope: the draft id, renewed after each send and when the draft moves to another project, so the terminals opened in one draft never follow the next.
Fixture hosts#
createFixtureHost runs all of this without an agent. reply(prompt) returns a script: transcript events, streamed in, and { interaction, then } steps that raise a request and continue with then(answer) (null when dismissed). The fixture steers, queues follow-ups until the run ends and stops on interrupt. Its interactions field seeds pending requests.
const host = createFixtureHost({ sessions, models }, {
reply: prompt => [
{ kind: 'tool', tool: 'read_file', callId: 'r1', text: 'read_file(a.ts)', input: { path: 'a.ts' } },
{ kind: 'tool-result', tool: 'read_file', callId: 'r1', text: 'export {}' },
{
interaction: question,
then: answer => [{ kind: 'markdown', text: answer ? 'Done.' : 'Skipped.' }],
},
],
})