Skip to content

Composer chrome

What sits around a composer, over a composer host - the agent, model and options pickers, the run chip and context orb, the badge strip, the dock and the picker dialogs.

zidane-ui/chat/composer also holds what the desktop app draws around its composer: the controls row (agent, model and options pickers), the status pills (the run chip and the context orb), the badge strip of a conversation, the dock above the prompt, and the picker dialogs its shortcuts open. They read the chrome members of a ComposerHost, and each one the host leaves out hides what needs it. The previews below run on the fixture host.

A session mid-run: the run chip, the context orb
A session at rest
A draft: the picks are the next chat's defaults

The host's chrome members#

Every member is optional (zidane-ui/chat/headless).

MemberWhat reads it
controlsThe pickers' writes (setModel, setEffort, setModelOptions, setAgent) and their cache estimates (cacheImpact).
modelsThe model catalog (catalog(cwd, hostId)), the Image tab (images), provider labels (providers). Each is a readable resource.
agentsThe agent profiles a folder offers (list), read again on onChanged.
prefsPicks remembered across apps: effort and options per model, the next chat's model (lastModelChain; lastProvider / lastModelByProvider / lastAccountByProvider for the new-session default).
pickerPrefsThe model picker's per-device preferences (createModelPickerPrefs).
contextA session's gauge (status), its breakdown (breakdown) and its settled cost (cost).
runsThe current run's counters per session (meter), kept by createRunMeter from the stream.
hudWhat extensions show with a session: statuses and badges (onState, get).

A run meter follows the stream signals you hand it, so keep one per window and feed it from your transport:

TypeScript
import { createRunMeter } from 'zidane-ui/chat/headless'

const runMeter = createRunMeter()
client.onSignal(signal => runMeter.handle(signal))

const host: ComposerHost = {
  runs: { meter: runMeter.session, noteSeen: runMeter.noteSeen, noteProgress: runMeter.noteProgress, settleCost: runMeter.settleCost },
  // …
}

createServerComposerHost (zidane-ui/adapters) does this over zidane-server: the agent profiles from agents.list, the remembered picks in the server's state.json (prefs.get / prefs.set), each offered when the handshake lists it. A session's settled cost and a joined run's progress come from its header (sessions.getHeader with usage), the strip's snapshot from hud.get.

Controls and status pills#

ComposerControls is the left cluster: the agent pill, a slot for a retry pill, the model chain picker and, when the model has a reasoning knob or feature toggles, the options pill. With a session the picks write to it; without one they are the next chat's defaults (defaults, onDefaultAgentChange, onDefaultModel). ComposerStatusPills is the right cluster: RunStatusChip while the session runs and HostContextOrb once its context status is known.

TSX
import { ComposerControls, ComposerStatusPills, useSessionMeters } from 'zidane-ui/chat/composer'

function Footer({ session, running, defaults, setDefaults, refresh }) {
  const meters = useSessionMeters(session, running)
  return (
    <>
      <ComposerControls
        session={session}
        defaults={defaults}
        onDefaultAgentChange={id => setDefaults({ agentId: id })}
        onDefaultModel={pick => setDefaults({ providerKey: pick.providerKey, modelId: pick.modelId })}
        onCommitted={refresh}
        modelId={meters.modelId}
        providerKey={session?.providerKey ?? null}
        activeSupportsReasoning={meters.contextStatus?.supportsReasoning ?? false}
        activeModelOptions={meters.contextStatus?.availableOptions ?? []}
        activeEnabledOptions={meters.contextStatus?.modelOptions ?? {}}
      />
      <ComposerStatusPills
        sessionId={session?.id ?? null}
        running={running}
        contextStatus={meters.contextStatus}
        fetchedCost={meters.fetchedCost}
      />
    </>
  )
}

The pickers are also exported one by one (HostAgentPicker, HostModelPicker, HostModelOptionsPicker), for a side chat that resolves its session on the first pick (commitTo) and remembers nothing (remember={false}). useCacheHints(sessionId, open) asks the host what each choice would cost the prompt cache, once per opening. useCycleAgent moves a session to its next profile, never mid-run or while it waits on an approval or a question.

The dock#

ComposerDock stacks what waits above the prompt, each in a DockCard of DockRows that line up with the composer's text. ApprovalDock docks the session's next approval (it reads the approvals host and opens a file edit's review on the modal layer). ArchivedBanner stands in for the composer of an archived session. A draft's ScopeToggle picks between a folderless chat and a folder. ServerScopeToggle is its counterpart on a saved server: the same pill and menu with the server's recent folders (draft.serverFolders), its default folder (draft.remoteRoot), the local project to clone there on send, Import repository on the server (draft.importRepoOnServer) and "Select directory…", which turns the menu into the server's RemoteFolderBrowser. Both are built on ProjectMenu, the folder pill and its project menu. Its trigger opens the same menu from another element: the project home's name uses it.

A docked card
Select a turn with ↑ / ↓
Esc
Waiting on you
An archived session
This session is archived. Unarchive it to continue.
A draft's scope: no folder, a folder
A draft on a server: its projects, the local project to clone, Select directory
A stalled stream, retried
retrying 2/5

The badge strip#

ConversationBadges draws a conversation's badges: what waits in its inbox (the chat host's interactions), its todos, its queue and running work (the session host), and what extensions show with it (hud). A badge opens its panel (onOpenPanel); the badge of the panel on screen (onScreen) closes it. Pass order and onOrderChange to let the user drag them.

Queued messages, todos, an extension's badge and status

Dialogs#

ModelPickerDialog, EffortPickerDialog and AgentPickerDialog open on the app's modal layer (useModal). The model dialog commits on close through commitModelPick: the session's chain, then the app's default when this computer can run it.

What the pickers read for a session outside its composer (a shortcut): useSessionContextStatus(session), the same keyed context.status read as useSessionMeters, so one request. effortPickerTarget(status, session.modelOptions) (zidane-ui/chat/headless) says what the effort dialog sets, null when the model has no effort knob and no options: open nothing.

TSX
const status = useSessionContextStatus(session)
const target = effortPickerTarget(status, session?.modelOptions)
const openEffort = target ? () => modal.open(<EffortPickerDialog session={session} {...target} onApplyEffort={apply} onToggleOption={toggle} />) : undefined

New-session defaults#

useNewSessionDefaults(settings, spaceId) (zidane-ui/chat/composer) is what a new session starts with: the folder last used (ComposerHost.folders: lastUsed, setLastUsed) and its project (completions.locationOf), the agent (this chat's pick, the space's, else build), the space preset's effort and options, and the model: the space's preset, else the pick the host remembers (prefs, its account kept only while models.catalog still lists it), else the built-in default. modelSource says which (preset, remembered, builtin). setDefaultModel(pick) remembers a pick through prefs ({ persist: false }: this chat only), setDefaultFolder and setDefaultAgentId set the others.

The desktop app implements folders over IPC; zidane-server has no last-used folder, so createServerComposerHost leaves it out and a web session starts in the server's first workspace root. The web app passes the agent, the knobs and a preset or remembered model to sessions.create, and leaves the model to the server when only the built-in default names one.

Reference#

zidane-ui/chat/composer, zidane-ui/chat/headless.