Skip to main content

AssistantDock

A spark button in a corner and, when open, a small panel: the current context as a header badge, three to five suggestions, the tasks it has run (with whatever each one streamed, then a result and an Apply button) and an optional free-text input. Every suggestion is a function you supply that resolves to a RunSummary or streams AssistantEvents; the dock never talks to a model itself.

import AssistantDock, {type AssistantSuggestion, type AssistantEvent, type RunSummary} from '@zuilib/ai/assistant-dock'

Basic

open / onOpenChange are controlled; omit them and the dock keeps its own state, starting at defaultOpen. header names where the user is. Each suggestion's run returns (or resolves to) {summary, apply?}; the dock shows it as a running task, then the summary, then the apply button that puts the result back into your UI. summary is a ReactNode, so it can carry a link or a figure.

Loading example

Streaming

A run may return an async iterable of AssistantEvents instead of a result. The task card renders each event with the matching card as it arrives: text deltas through StreamingMarkdown, tool-call / tool-result through ToolCallCard, approval through ApprovalCard, diff through DiffReview, citation through CitationList. An approval event pauses the run until the human decides on the card; an async generator receives the decision as the value of its yield. A done event carries the final summary.

Loading example

An adapter turns a provider stream into these events, so the same suggestion can be run: ({signal}) => fromSSE(fetch('/api/outreach', {signal})). See Adapters.

Containment

By default the dock is fixed to the viewport. strategy="absolute" positions it inside the nearest relative ancestor instead, which keeps it inside that subtree's theme (a .dark wrapper, a token override) and inside a demo frame, the same trade-off as an in-place Headless UI panel. The example above uses it; the home-page console does too.

footer renders under the input: a tool-use switch, a model picker, a privacy note.

Loading example

Placement and size

position takes any corner: bottom-right (default), bottom-left, top-right, top-left; the classes are logical, so the corners mirror under dir="rtl". width and height size the panel (a number is pixels); with a height the body scrolls inside it. resizable adds a drag handle on the panel's free corner; the arrow keys resize it by 16px with the handle focused, down to 240 x 200.

Below sm (640px) the panel is a full-width bottom sheet (a top sheet for the top-* positions), capped at 85dvh and padded past the safe-area inset, with a close button in its header (data-slot="assistant-dock-close"); width, height and resizable apply from sm up.

Loading example

Dismissal and focus

  • Focus moves into the panel on open: the input, else the first suggestion, else the panel itself. Escape inside the panel closes it and returns focus to the trigger. A text field inside a task keeps Escape for itself (an approval's type-to-confirm input, a field in a preview); only the dock's own ask input lets Escape close the panel.
  • dismissOnOutsideClick closes the panel on a pointer press outside the panel and its trigger. A press inside a popup opened from the dock (a select, menu or popover panel that Headless UI portals to <body>) counts as inside: the popup is found through its button's aria-controls.
  • modal makes the panel a role="dialog": Tab and Shift+Tab cycle inside it, and Escape closes it from anywhere on the page. It is not aria-modal: the panel renders in place (not in a portal) and the page behind it is neither inert nor hidden from assistive technology, so the attribute would promise more than the DOM delivers. For a true modal, put the dock's parts in a Dialog from @zuilib/primitives.
  • shortcutKey="j" binds ⌘J / Ctrl+J on the document to toggle the dock; shortcut="⌘J" is the label shown in the trigger's tooltip.
  • Enter in the input submits the ask.
  • id="dock" names the root dock, the panel dock-panel and the trigger dock-trigger (aria-controls points at the panel while it is open).

Scrollback

Tasks stack newest first in the panel's body, which scrolls. maxTasks (20 by default) is the size of that history: past it the oldest finished task is dropped; a running task is never dropped.

Stop and retry

Each run receives {signal}: an AbortSignal the dock aborts when the human presses Stop on the running task or the dock unmounts (closing the panel leaves a task running). Pass it to your fetch or SDK call; once aborted, whatever the run rejects with is ignored, a streaming iterator is returned, the parts that already arrived stay on the card and the task shows as Stopped. A failed or stopped task keeps a Retry button that calls the same run again with a fresh signal. An error event in a stream fails the task with its message.

run: async ({signal}) => {
const draft = await api.draftOutreach(selectedIds, {signal})
return {summary: draft.summary}
}

Parts

AssistantDock.Trigger and AssistantDock.Panel are the pieces the root renders; they read the root's context and throw when rendered outside <AssistantDock>, so they are not standalone. AssistantDock.Task is a plain presentational card (task: {id, label, status, parts, result?, error?} plus optional onStop / onRetry that add the Stop and Retry buttons and onApprovalDecision / onDecisionsChange for the streamed cards) with no context dependency, which you can reuse in your own view. status is 'running' | 'done' | 'error' | 'stopped'; parts is the MessagePart[] the run streamed.

Props

PropTypeDefaultDescription
idstringBase for the ids: root id, `${id}-panel`, `${id}-trigger`
openbooleanControlled; omit it and the dock keeps its own state
defaultOpenbooleanfalseUncontrolled initial state
onOpenChange(open: boolean) => void
headerrequired{title: string; subtitle?: string}Shown as the badge in the header
suggestionsrequiredAssistantSuggestion[]{id, label, description?, run: (options: {signal: AbortSignal}) => RunResult}. Three to five.
onAsk(text: string, options: {signal: AbortSignal}) => RunResultFree-text path; without it there is no input
renderersPartRenderersPart renderers for every task’s thread: override a built-in card, or render custom parts by their kind
shortcutstringLabel in the trigger tooltip, e.g. "⌘J"
shortcutKeystringKey that, with ⌘ / Ctrl, toggles the dock
position'bottom-right' | 'bottom-left' | 'top-right' | 'top-left''bottom-right'
strategy'fixed' | 'absolute''fixed'absolute: positioned inside the nearest relative ancestor
width / heightnumber | stringPanel size; a number is pixels. Default 20rem wide, content-high with the body capped at 26rem
resizablebooleanfalseDrag handle on the free corner; arrow keys resize
dismissOnOutsideClickbooleanfalse
modalbooleanfalserole="dialog", Tab cycles inside the panel, Escape closes from anywhere (not aria-modal)
labelstring'Assistant'
suggestionsLabelstring'Suggested here'
placeholderstring'Ask about what’s on screen…'
maxTasksnumber20Tasks kept in the scrollback, newest first; a running task is never dropped
footerReactNodeUnder the input
onTasksChange(tasks: AssistantTask[]) => voidAfter every change; status is running / done / error / stopped
className / panelClassName / triggerClassNamestringPanel layer / panel / trigger button

RunSummary is {summary: ReactNode; apply?: {label: string; run: () => void}} and RunResult is RunSummary | AsyncIterable<AssistantEvent>, or a promise of either. A rejected run becomes a failed task showing the error message and a Retry button; a stopped one shows "Stopped." and the same Retry.