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.
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.
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 slot
footer renders under the input: a tool-use switch, a model picker,
a privacy note.
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.
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.
dismissOnOutsideClickcloses 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'saria-controls.modalmakes the panel arole="dialog": Tab and Shift+Tab cycle inside it, and Escape closes it from anywhere on the page. It is notaria-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 aDialogfrom@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 rootdock, the paneldock-paneland the triggerdock-trigger(aria-controlspoints 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
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Base for the ids: root id, `${id}-panel`, `${id}-trigger` |
open | boolean | — | Controlled; omit it and the dock keeps its own state |
defaultOpen | boolean | false | Uncontrolled initial state |
onOpenChange | (open: boolean) => void | — | |
headerrequired | {title: string; subtitle?: string} | — | Shown as the badge in the header |
suggestionsrequired | AssistantSuggestion[] | — | {id, label, description?, run: (options: {signal: AbortSignal}) => RunResult}. Three to five. |
onAsk | (text: string, options: {signal: AbortSignal}) => RunResult | — | Free-text path; without it there is no input |
renderers | PartRenderers | — | Part renderers for every task’s thread: override a built-in card, or render custom parts by their kind |
shortcut | string | — | Label in the trigger tooltip, e.g. "⌘J" |
shortcutKey | string | — | Key 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 / height | number | string | — | Panel size; a number is pixels. Default 20rem wide, content-high with the body capped at 26rem |
resizable | boolean | false | Drag handle on the free corner; arrow keys resize |
dismissOnOutsideClick | boolean | false | |
modal | boolean | false | role="dialog", Tab cycles inside the panel, Escape closes from anywhere (not aria-modal) |
label | string | 'Assistant' | |
suggestionsLabel | string | 'Suggested here' | |
placeholder | string | 'Ask about what’s on screen…' | |
maxTasks | number | 20 | Tasks kept in the scrollback, newest first; a running task is never dropped |
footer | ReactNode | — | Under the input |
onTasksChange | (tasks: AssistantTask[]) => void | — | After every change; status is running / done / error / stopped |
className / panelClassName / triggerClassName | string | — | Panel 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.