@filigran/chatbot 3.13.0 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,6 +9,7 @@ Filigran chat panel — a standalone React + Tailwind chatbot component with SSE
9
9
  - ✋ **Tool Approval** — When the agent stops at a tool that needs a human's consent, the turn pauses mid-answer and the reviewer approves, declines with a reason, or approves always — opt-in per host, see [Tool approval](#post-apibaseurlapiendpointsapprove)
10
10
  - 🗂️ **Conversation History** — Switch between (and delete) past conversations from a header menu, or from a permanent sidebar in fullscreen mode (collapsible, searchable past 7 entries, rename in place)
11
11
  - 🤖 **Multi-Agent Support** — Switch between different AI agents
12
+ - 🔗 **Conversation References** — Type `@` in the composer to point the agent at another conversation; opt-in per host, see [Conversation references](#conversation-references)
12
13
  - 📎 **File Attachments** — Upload and paste files (PDF, TXT, images)
13
14
  - 📥 **Agent-Generated Files** — Renders downloadable file cards from agent output and strips the `[[FILE:id]]` markers from the prose
14
15
  - 📝 **Full Markdown** — Tables (mis-delimited ones repaired), code blocks with copy button, lists, blockquotes, soft line breaks, inline images with a lightbox
@@ -19,7 +20,7 @@ Filigran chat panel — a standalone React + Tailwind chatbot component with SSE
19
20
  - 🎙️ **Dictation** — Speech-to-text via the browser's own Web Speech API; no endpoint, no key, hidden where unsupported
20
21
  - ✍️ **Draft Recovery** — Unsent composer text is kept per conversation and restored when the panel reopens
21
22
  - 🎨 **Customizable Theme** — Accent color and logo customization
22
- - 📱 **3 Display Modes** — Floating, sidebar (resizable), and fullscreen
23
+ - 📱 **4 Display Modes** — Floating, sidebar (resizable), fullscreen and inline
23
24
  - 💾 **Persistence** — Conversation and sidebar width saved to localStorage
24
25
  - 🛠️ **Tool Tracking** — Visual indicators for AI tool usage
25
26
 
@@ -65,34 +66,317 @@ import { ChatPanel } from '@filigran/chatbot';
65
66
 
66
67
  | Prop | Type | Default | Description |
67
68
  | ------------------- | ----------------------------------------- | ------------ | ---------------------------------------------------------------- |
68
- | `mode` | `'floating' \| 'sidebar' \| 'fullscreen'` | **required** | Display mode |
69
- | `onClose` | `() => void` | **required** | Called when close button is clicked |
70
- | `onModeChange` | `(mode: ChatMode) => void` | **required** | Called when user switches display mode |
69
+ | `mode` | `'floating' \| 'sidebar' \| 'fullscreen' \| 'inline'` | **required** | Display mode; `inline` fills the host's container — see [Inline mode and the handle](#inline-mode-and-the-handle) |
70
+ | `onClose` | `() => void` | — | Called when the close button is clicked; the button shows only with it (never in inline mode) |
71
+ | `onModeChange` | `(mode: ChatMode) => void` | — | Called when the user switches display mode; the view switcher shows only with it (never in inline mode) |
71
72
  | `apiBaseUrl` | `string` | **required** | Base URL for chat API endpoints |
73
+ | `backendType` | `'rest' \| 'legacy' \| 'ag-ui'` | `'rest'` | The backend's protocol: XTM One's Chat API, a single POST endpoint with Flowise-style SSE events, or [AG-UI](https://github.com/ag-ui-protocol/ag-ui) |
72
74
  | `user` | `{ firstName: string }` | **required** | Current user info |
73
75
  | `topOffset` | `number` | `0` | Top offset in pixels (for sidebar/fullscreen with fixed headers) |
74
- | `agentDashboardUrl` | `string` | — | XTM One URL for the "Browse agents" / "Create agent" links and the waiting panel's "Play Space Invaders in XTM One" link (`{url}/chat?arcade=play`, http(s) only) |
76
+ | `agentDashboardUrl` | `string` | — | XTM One URL for the "Browse agents" / "Create agent" links and the waiting panel's "Play Space Invaders in XTM One" link (`{url}/chat?arcade=play`, http(s) only, unless `waitingGameUrl` says where) |
77
+ | `waitingGameUrl` | `string` | — | Where the waiting panel's invitation to play leads, over `{agentDashboardUrl}/chat?arcade=play`: an http(s) URL or a path of the host's origin (`/…`); anything else links nothing |
75
78
  | `miniGameEnabled` | `boolean` | `true` | Show the waiting panel during longer waits: rotating messages and the invitation to play XTM One's arcade (the package ships no game of its own) |
76
79
  | `onPlayWaitingGame` | `() => void` | — | Open the arcade in place instead of linking to XTM One, for a host that runs it itself (the XTM One floating assistant) |
77
- | `t` | `(key: string) => string` | identity | Translation function for i18n |
80
+ | `t` | `(key: string) => string` | — | The host's translations; a key it returns unchanged falls back to the panel's catalog in `locale` (none without `locale`) — see [Internationalization](#internationalization) |
78
81
  | `accentColor` | `string` | `'#7b5cff'` | Primary accent color (hex) |
79
82
  | `logoIcon` | `React.ReactNode` | default icon | Custom logo/icon for the assistant |
80
83
  | `promptSuggestions` | `string[]` | default list | Prompt suggestions shown on welcome screen |
84
+ | `draftBorderColor` | `string` | — | Color of the line above the composer (e.g. a draft context) |
85
+ | `draftLabel` | `string` | — | Accepted, and not shown |
81
86
  | `pageContext` | `Record<string, unknown>` | — | Arbitrary host page context (e.g. `{ url: '/dashboard/...' }`) sent as `context` on each `rest` message so the agent knows where the user is. Must be JSON-serializable (skipped if not). Read fresh at send time; omitted when empty. |
82
87
  | `resizable` | `boolean` | `false` | Enable drag-to-resize for sidebar mode |
83
88
  | `onWidthChange` | `(width: number) => void` | — | Called when sidebar width changes during resize |
84
89
  | `onResizeStart` | `() => void` | — | Called when resize drag starts |
85
90
  | `onResizeEnd` | `() => void` | — | Called when resize drag ends |
91
+ | `pushContentSelector` | `string` | — | CSS selector of the host's content, given a right padding of the sidebar's width while the panel is in sidebar mode (`--chatbot-sidebar-width` on `:root` holds it either way) |
92
+ | `disableFileManagement` | `boolean` | `false` | No attaching or pasting files in the composer |
93
+ | `maxFileCount` | `number` | `10` | Most files attached to one message |
94
+ | `maxTotalSize` | `number` | 50 MB | Most bytes the files attached to one message add up to |
86
95
  | `onMessageFeedback` | `(id, feedback, message) => void` | — | Receives each rating of an answer (`null` clears it); `id` is the message's persisted id when the backend reported one (`done.message_id`, a restored entry's `id`), else the panel's own. On its own it enables 👍/👎 and the panel stores nothing; with `apiEndpoints.feedback` set it is called once the backend has stored the rating. With neither, no thumbs. |
87
- | `locale` | `string` | browser's | BCP 47 language tag the message times are written in and answers are read aloud in (the platform's language, e.g. `'fr-FR'`) |
96
+ | `locale` | `string` | browser's | BCP 47 language tag of the platform's language (e.g. `'fr-FR'`): the language of the panel's catalog for what `t` does not translate, of its message times and of read-aloud. Unset, the browser's — except that a host passing `t` gets no catalog then |
88
97
  | `disableImagePreviews` | `boolean` | `false` | Render image attachments as download cards instead of inline previews |
89
98
  | `contextUsageEnabled` | `boolean` | `true` | Show how full the model's context window is for the current conversation (ring + percentage in the composer toolbar). Data-driven, so it stays absent until the backend reports occupancy — see [Context usage](#context-usage). |
99
+ | `notifyOnComplete` | `boolean` | `true` | Tell the user a long turn ended while they were not watching the chat: away (the tab hidden, another window), the page title flashes and a browser notification shows (when already granted); away or with the panel closed or hidden, `onTaskComplete` is called |
100
+ | `onTaskComplete` | `(title: string, body: string) => void` | — | The host's own toast for a long turn that ended while the user was not watching the chat, with the translated `title` and `body` |
90
101
  | `composerToolbar` | `React.ReactNode` | — | Extra controls appended to the composer toolbar. The escape hatch for host-specific affordances (XTM One's session-tool picker) — the package never learns what they are. Pass nothing and the toolbar simply has none. |
102
+ | `storageKeyPrefix` | `string` | — | Namespaces every key the panel keeps in browser storage (`<prefix>:filigranChatConversationId`, …), for a host mounting more than one panel |
103
+ | `conversationId` | `string \| null` | — | The conversation on screen when the host owns it (a `?c=` URL); `null` is a new chat. Left `undefined`, the panel remembers its own, and a host-owned one is never written to storage |
104
+ | `onConversationChange` | `(id: string \| null) => void` | — | Called whenever the conversation on screen changes, whoever changed it (a first answer, New chat, the history) |
105
+ | `initialAgent` | `string` | — | Agent selected once the list loads, by id or slug, over the remembered one |
106
+ | `onAgentChange` | `(agent: XtmAgent) => void` | — | Called when another agent is picked |
107
+ | `readOnly` | `boolean` | `false` | Hides the composer: a conversation the user may read but not write to |
108
+ | `readOnlyNotice` | `React.ReactNode` | — | Shown in place of the composer while `readOnly` |
109
+ | `getRequestExtras` | `() => Record<string, unknown>` | — | Fields merged into every message body, read at send time (the host's own per-message options). They never override the panel's fields |
110
+ | `onStreamEvent` | `(event, { relayed, end?, replayed? }) => void` | — | Every frame received, on the message stream or the [event stream](#turns-that-outlive-their-stream) (`relayed: true`), including those the panel does not render, with what the panel read in it (`end`: `'provisional'` or `'stored'`; `replayed`) — see [Host extensions](#host-extensions) |
111
+ | `onSessionEnded` | `(reason: string \| undefined) => void` | — | The user's session ended: a `session_expired` frame on either stream, or a `401` whose `detail.code` is `session_expired` on any request the panel makes; *reason* is the frame's or the `detail`'s `reason`. Called once per set of `requestHeaders` |
112
+ | `markerRenderers` | `Record<string, ChatMarkerRenderer>` | — | The host's cards for answer markers, by name — see [Host extensions](#host-extensions) |
113
+ | `codeBlockRenderers` | `Record<string, ChatCodeBlockRenderer>` | — | The host's rendering of fenced code blocks, by language: `(code, { streaming }) => React.ReactNode` — see [Host extensions](#host-extensions) |
114
+ | `transformMessageText` | `(text, message) => string` | — | Rewrites a message's text before it is shown, copied or read aloud; one that throws leaves the text as it came |
115
+ | `onArtifactOpen` | `(artifact: ChatArtifact) => void` | — | Opens a deck, report or video in the host, in place of the `Open in XTM One` link |
116
+ | `headerActions` / `banner` / `composerHeader` / `transcriptFooter` / `welcome` | `React.ReactNode` | — | The host's content in the header, above the thread, above the composer, after the last message, or in place of the welcome screen |
117
+ | `renderMessageExtras` / `messageFooterActions` | `(message, { streaming }) => React.ReactNode` | — | The host's content under a message, and controls in a finished answer's footer; `streaming` while the message is the answer still being written. `message.raw` holds the backend's own fields — see [Host extensions](#host-extensions) |
118
+ | `sidebarRowActions` | `(conversation) => React.ReactNode` | — | The host's controls on a row of the conversation list |
119
+ | `sidebarHeaderActions` / `sidebarSections` | `React.ReactNode` | — | The host's controls beside New conversation, and sections under the list |
120
+ | `statusLabel` | `(status: AgentStatusState) => string \| undefined` | — | The status line's text for a status, over the built-in one |
121
+ | `waitingMessages` | `string[]` | built-in | The waiting panel's rotating messages, already translated |
122
+ | `renderWaitingPanel` | `() => React.ReactNode` | — | Replaces the waiting panel's content (its messages and the invitation to play) |
123
+ | `fontFamily` | `string` | — | Font of the panel's text |
124
+ | `agentMentions` | `boolean` | `false` | `@` also mentions agents of the agent list; the message carries their ids as `mentioned_agent_ids` |
125
+ | `steerAttachments` | `boolean` | `false` | Uploaded files may steer the running turn (the steer route takes `file_ids`) |
126
+ | `onReady` | `() => void` | — | Called once, when the panel has chosen its agent and read back the conversation on screen; handle calls made earlier wait for it and then run in order |
127
+ | `onHeldSendSettled` | `(text, outcome) => void` | — | What became of a message held until the turn ended (`send` resolved `'held'`): `'sent'`, `'failed'`, `'dropped'`, `'returned'` (given back to the composer: held when Stop ended its turn, or taken over by `onBeforeSend` as it leaves; told once the composer shows it, so `getDraft()` reads it) or `'cancelled'`. A message sent after a stopped turn ended goes out once that turn winds down |
128
+ | `onTurnRunningChange` | `(running: boolean, info?: ChatTurnEnd) => void` | — | A turn started or stopped running on screen, own or relayed: the state of the Stop button and of `draft.turnRunning`; with `false` comes how it ended, `info.ended`: `'answered'`, `'stopped'` or `'failed'` — see [Host extensions](#host-extensions). After a provisional end (see [Turns that outlive their stream](#turns-that-outlive-their-stream)) `false` comes at once, while sends are still held as the turn winds down: until its stored end or its stream's close, at most 5 s |
129
+ | `onBeforeSend` | `(draft) => false \| { text?, displayText?, extras? } \| void` | — | Sees every non-empty send first and takes it over (`false`), rewords it, shows the user's bubble other words or adds body fields (a message with fields of its own is held with `holdSendsDuringTurn`) — see [Host extensions](#host-extensions) |
130
+ | `hideConversationList` | `boolean` | `false` | Hides the conversation list of fullscreen and inline modes, for a rail or a task pane (usable from 320 px wide); the header's history menu stands in for it — see [Host extensions](#host-extensions) |
131
+ | `holdSendsDuringTurn` | `boolean` | `false` | A message that cannot steer is held and sent when the turn ends, instead of waiting in the composer. A turn winding down after a provisional end (until its stored end or its stream's close, at most 5 s) holds sends with or without it |
91
132
 
92
133
  Plain-data props (`requestHeaders`, `apiEndpoints`, `pageContext`, `promptSuggestions`) are compared by content and callback props are called
93
134
  through a stable wrapper, so building them inline is fine. `t` and `logoIcon` are used as given: keep them stable (`useCallback` / `useMemo`),
94
135
  or every render of the host re-renders every message in the thread.
95
136
 
137
+ #### Inline mode and the handle
138
+
139
+ `mode="inline"` fills the host's container: the conversation list sits beside
140
+ the thread as in fullscreen, and there is no view switcher or close button.
141
+ With a `ref`, the host drives it:
142
+
143
+ ```tsx
144
+ const chat = useRef<ChatPanelHandle>(null);
145
+
146
+ <div style={{ height: '100%' }}>
147
+ <ChatPanel
148
+ ref={chat}
149
+ mode="inline"
150
+ conversationId={searchParams.get('c')}
151
+ onConversationChange={(id) => setSearchParams(id ? { c: id } : {})}
152
+ storageKeyPrefix="chat-page"
153
+ // ...
154
+ />
155
+ </div>;
156
+
157
+ chat.current?.newConversation({ agent: 'threat-hunter' });
158
+ chat.current?.send('Summarize this report', { extras: { mode: 'deep_investigation' } });
159
+ ```
160
+
161
+ | Method | What it does |
162
+ | --- | --- |
163
+ | `send(text, { extras, displayText })` | Sends *text* as the user, leaving the composer's draft alone; `extras` join that one message's body, and the user's bubble shows `displayText` in place of *text* (as `onBeforeSend`'s `displayText`, which wins when it gives one). Resolves to `'sent'` (once the backend takes it), `'steered'` (joined the running turn), `'held'` (sent when the turn ends - also, with or without `holdSendsDuringTurn`, while a turn ended provisionally winds down, after `onTurnRunningChange(false)`), `'dropped'` (empty, taken over by `onBeforeSend`, `readOnly`, or the panel unmounted first) or `'failed'` (the backend refused it) |
164
+ | `newConversation({ workspaceId, agent })` | Starts a new chat, in a workspace and with an agent (id or slug) if given |
165
+ | `selectConversation(id)` | Opens a conversation |
166
+ | `setDraft(text)` / `focusComposer()` | Fills / focuses the composer |
167
+ | `getDraft()` | The composer's text as it stands (typed by the user, or set by `setDraft`), e.g. for `onBeforeSend` taking over a held message (`draft.held`) to tell whether the composer still holds what was typed for it |
168
+ | `stop()` | Same as the Stop button; during a voice call it also drops the question awaited and stops speaking |
169
+ | `startVoiceCall()` | Starts a [voice call](#voice-call) over the panel; `false` when this browser has no speech recognition or the panel is `readOnly` |
170
+ | `reloadAgents()` | Reads the agent list again, keeping the agent picked unless it is gone |
171
+ | `scrollToMessage(id)` | Scrolls to a message by its server id and highlights it; `false` when it is not on screen |
172
+
173
+ #### Host extensions
174
+
175
+ A host adds what is its own without the package learning about it. An answer
176
+ marker `[[KIND:args]]` renders the host's card where it stands, once the answer
177
+ is done (`renderWhileStreaming` to show it earlier); a renderer named `FILE`,
178
+ `PRESENTATION`, `REPORT` or `VIDEO` replaces the built-in card, and a marker no
179
+ renderer names stays text:
180
+
181
+ ```tsx
182
+ const markers = useMemo(
183
+ () => ({
184
+ WORK_ITEM: { render: (id) => <WorkItemCard id={id} /> },
185
+ INVESTIGATION: { render: (id) => <InvestigationBlock id={id} />, fullWidth: true },
186
+ }),
187
+ [],
188
+ );
189
+
190
+ <ChatPanel
191
+ markerRenderers={markers}
192
+ transformMessageText={maskSecrets}
193
+ banner={<ChannelContinuityBanner />}
194
+ composerHeader={<DesktopMachineBar />}
195
+ renderMessageExtras={(m) => <RunnerActivity message={m} />}
196
+ // ...
197
+ />;
198
+ ```
199
+
200
+ A fenced code block whose language a host renderer names (`codeBlockRenderers`,
201
+ matched case-insensitively) renders that node in place of the code block -
202
+ a chart drawn from the block's data, for instance. The renderer gets the
203
+ block's text and `streaming`, true while the answer is still coming (the block
204
+ may then be incomplete); a language no renderer names, or a renderer that
205
+ returns `null` (a block it cannot read), stays a code block:
206
+
207
+ ```tsx
208
+ const codeBlocks = useMemo(
209
+ () => ({
210
+ chart: (code: string, { streaming }: { streaming: boolean }) => (streaming ? <ChartSkeleton /> : <Chart spec={code} />),
211
+ }),
212
+ [],
213
+ );
214
+
215
+ <ChatPanel codeBlockRenderers={codeBlocks} />;
216
+ ```
217
+
218
+ A card or a code block renderer that throws shows `This item could not be
219
+ loaded.` and leaves the rest of the answer on screen. A marker's arguments come from the model's answer:
220
+ treat them as untrusted input, never as markup or a URL to follow unchecked. Like `t`, the function props are used as given: keep
221
+ them stable, and a new one re-renders every message, which is also how to
222
+ refresh what they render.
223
+
224
+ `onBeforeSend(draft)` sees every non-empty send first, synchronously:
225
+ `draft.text`, `draft.source` (`'composer'`, `'host'` or `'voice'`) and
226
+ `draft.turnRunning` (a turn is running). It returns `false` to take the send
227
+ over (the composer keeps its draft), or an object: `text` rewords what the
228
+ backend receives, `displayText` is what the user's bubble shows, and `extras`
229
+ adds body fields. The panel's own fields cannot be set this way, and a message
230
+ with fields of its own never steers: it is held with `holdSendsDuringTurn`. A
231
+ message held while a turn ran is asked about again as it leaves as a new turn
232
+ (`turnRunning: false`, `held: true`), each send held with it on its own; taken
233
+ over then, what the user typed for it goes back to the composer, after what
234
+ was typed since. A held message every send of which is taken over is settled
235
+ `'returned'` (`'dropped'` when the user typed nothing for it).
236
+
237
+ A host that wraps what the user typed in context of its own (the open document,
238
+ the page) sends one text and shows another: `onBeforeSend` returns `text`, what
239
+ the backend receives, and `displayText`, what the user's bubble shows. A held
240
+ message keeps both, and a conversation read back shows what the backend stored:
241
+
242
+ ```tsx
243
+ <ChatPanel
244
+ onBeforeSend={({ text }) => ({ text: `${documentContext()}\n\n${text}`, displayText: text })}
245
+ // ...
246
+ />;
247
+ ```
248
+
249
+ A host's own send does the same through the handle (the send hook's
250
+ `displayText` wins when it gives one):
251
+
252
+ ```tsx
253
+ chat.current?.send(`${documentContext()}\n\n${typed}`, { displayText: typed });
254
+ ```
255
+
256
+ `onTurnRunningChange(running)` tells the host when a turn starts or stops
257
+ running - on screen, where it shows the Stop button, the status line and the
258
+ waiting panel, or a relayed turn let go of while the page was hidden; the
259
+ state `onBeforeSend` reports as `draft.turnRunning` - so it never re-derives
260
+ it from `onStreamEvent`. `true` comes with the panel's
261
+ own send and with a turn the [event stream](#turns-that-outlive-their-stream)
262
+ relays; `false` once that turn ends: its stream closes after the last `done`
263
+ (an intermediate `done` goes on), an `error`, `cancelled` or `session_expired`
264
+ frame comes, Stop lets go of it, a new conversation replaces it, or the panel
265
+ unmounts in the middle of it. A turn handed over to the event stream stays
266
+ running meanwhile, and so does a relayed turn let go of while the page was
267
+ hidden, until the reopened stream replays it or the conversation read back
268
+ says how it ended. It is called on changes only.
269
+
270
+ With `false` comes how the turn ended, `info.ended` (`ChatTurnOutcome`), so a
271
+ host running a sequence of turns goes on only while they are answered:
272
+
273
+ - `'answered'`: its last `done` came, with nothing after it saying otherwise;
274
+ for a relayed turn that ended out of sight (nothing replayed it, or the event
275
+ stream could not reopen), the conversation read back holds an answer stored
276
+ after what was shown (an `error` or a `cancelled` the backend stores as an
277
+ answer reads the same);
278
+ - `'stopped'`: a `cancelled` frame (even after the last `done`), a
279
+ `session_expired` frame, Stop, a new conversation, the panel unmounting, a
280
+ stream that closed before its last `done` with no event stream to carry the
281
+ turn to its end (a turn the event stream does carry ends as its replay does),
282
+ or a relayed turn that ended out of sight with no answer read back (none
283
+ stored, or the conversation could not be read back);
284
+ - `'failed'`: an `error` frame, or a send the backend refused or could not be
285
+ reached for.
286
+
287
+ ```tsx
288
+ <ChatPanel
289
+ onTurnRunningChange={(running, info) => {
290
+ setAgentWorking(running);
291
+ if (!running && info?.ended !== 'answered') cancelSequence();
292
+ }}
293
+ />
294
+ ```
295
+
296
+ Each answer says which agent wrote it, for a host's own rendering
297
+ (`renderMessageExtras`, `messageFooterActions`): `message.answeredBy`
298
+ (`{ agentId, agentName, agentIcon }`, from `answered_by`) and
299
+ `message.mentionFailed` (`mention_failed`: a note written in place of a
300
+ mentioned agent that could not answer), read from every `done` frame,
301
+ intermediate ones included, and from each restored entry.
302
+
303
+ Both slots get the message and `{ streaming }`, true while it is the answer
304
+ still being written (`messageFooterActions` shows on a finished answer only,
305
+ so it is always false there). `message.raw` is the backend's own object for
306
+ the message, a plain copy the panel reads nothing from: the restored entry
307
+ (paged or relayed ones too), or the `done` frame that closed a live answer
308
+ (intermediate ones included) and, after a provisional end, the stored end. A
309
+ host reads its backend's fields there (a trace id, a channel, a format)
310
+ without fetching the conversation again; their shape is the backend's to
311
+ define. A message no backend object describes yet (one being written, a user
312
+ message sent from this panel) has none.
313
+
314
+ ```tsx
315
+ <ChatPanel
316
+ renderMessageExtras={(m, { streaming }) => (streaming ? null : <TraceLink id={m.raw?.llm_trace_id as string | undefined} />)}
317
+ />
318
+ ```
319
+
320
+ `onStreamEvent(frame, info)` gives every frame as received, with what the
321
+ panel read in it, so a host never re-derives either: `info.relayed` (a turn
322
+ the event stream relays), `info.end` - `'provisional'` on an `error` or
323
+ `cancelled` marked `provisional: true` (its stored end follows),
324
+ `'stored'` on an end naming the stored answer's `message_id` (a `done` that is
325
+ not intermediate, a `cancelled`, an `error`) - and `info.replayed` on the
326
+ `thinking` frame (`resumed: true`) of a turn replayed from its start, whose
327
+ frames then restate it so far.
328
+
329
+ `onSessionEnded(reason)` says the user's session ended, as the backend tells
330
+ it: a `session_expired` frame on either stream, or a `401` whose body's
331
+ `detail.code` is `session_expired` (`{"detail": {"code": "session_expired",
332
+ "reason": "idle"}}`) on any request the panel makes. *reason* is the
333
+ frame's or the `detail`'s `reason`, `undefined` when it gives none. It comes
334
+ once, whichever request or stream said it first, and again only once
335
+ `requestHeaders` change. Signing the user out is the host's: the panel ends
336
+ the turn on screen, and a refused read-back offers Retry.
337
+
338
+ `hideConversationList` gives the thread the panel's whole width in inline (or
339
+ fullscreen) mode, for a rail beside the host's own content or an add-in's task
340
+ pane: the header, thread and composer hold from 320 px wide. The history is
341
+ still there, in the header's history menu (open, delete, Clear all), and a
342
+ referenced conversation's chip still opens it; the `sidebar*` slots render
343
+ nothing meanwhile. To drop the history altogether, set `apiEndpoints.history`
344
+ to `null` instead.
345
+
346
+ #### Voice call
347
+
348
+ `startVoiceCall()` on the handle opens a call over the panel, for a host's own
349
+ button (`voiceCallSupported()` says whether the browser can hold one): the
350
+ browser's speech recognition listens in `locale`, a phrase is sent after one
351
+ second of silence (as the user, `onBeforeSend` seeing `source: 'voice'`), and
352
+ the answer is said through `apiEndpoints.tts` in pieces of about 200
353
+ characters, played in order, the next one requested while one plays, each
354
+ piece the route fails or the browser cannot play (or every piece, without the
355
+ route) in the browser's voice. Then it listens again. The thread under the
356
+ call shows the conversation as usual; a `transformMessageText` that throws
357
+ leaves the answer said and shown as it came.
358
+
359
+ - Only the answer to the question is said: the final `done` of the panel's own
360
+ turn, or of the turn a `folded_into_active_turn` frame (or the steer
361
+ route's `turn_id`) names, never a turn of another tab and never a mid-turn
362
+ segment on its own. A steer stored that the backend says no turn took
363
+ (`"answered": false`) gets no answer, and the call listens again. Only the
364
+ addressed agent (the conversation's, or the one a `transfer_start` hands it
365
+ to) speaks; replies the turn's other agents give (`answered_by`) show by
366
+ name, `Did not answer` beside one that carries `mention_failed`.
367
+ - Speech over the answer (barge-in), a tap on the call or Skip stops it and
368
+ listens; a tap while the answer is awaited cancels the question and stops
369
+ its turn, and so does the handle's `stop()`. The microphone can be muted;
370
+ the call mutes it itself when the recogniser fails five times in a row with
371
+ nothing heard between (a refused or missing microphone, no network, a
372
+ language it lacks), silence (`no-speech`) and an aborted session aside.
373
+ - A question held while a turn runs (`holdSendsDuringTurn`) is awaited once
374
+ it goes out; one that never does (`onBeforeSend` takes it over as it
375
+ leaves, Stop, a failed send) gets no answer, and the call listens again.
376
+ - A turn silent for 120 s is checked through `apiEndpoints.pendingApprovals`
377
+ (its `turn: 'running'`): still running, the call waits on; over (or with no
378
+ such route), it gives up on that question and listens again.
379
+
96
380
  #### Resizable Sidebar Example
97
381
 
98
382
  ```tsx
@@ -116,7 +400,7 @@ function App() {
116
400
  }
117
401
  ```
118
402
 
119
- The sidebar width is persisted to `localStorage` under the key `filigranChatSidebarWidth`.
403
+ The sidebar width is persisted to `localStorage` under the key `filigranChatSidebarWidth` (`<storageKeyPrefix>:filigranChatSidebarWidth` with a prefix).
120
404
 
121
405
  ### `ChatToggleButton`
122
406
 
@@ -133,7 +417,8 @@ import { ChatToggleButton } from '@filigran/chatbot';
133
417
  | `isOpen` | `boolean` | **required** | Whether the chat panel is open |
134
418
  | `onToggle` | `() => void` | **required** | Called when button is clicked |
135
419
  | `label` | `string` | built-in | Button text, already translated by the host; omit for `t('Ask Assistant')` |
136
- | `t` | `(key: string) => string` | identity | Translation function, used for the default label |
420
+ | `t` | `(key: string) => string` | — | The host's translations; the panel's catalog in `locale` answers the rest |
421
+ | `locale` | `string` | browser's | The default label's language, by the panel's rule (see Internationalization) |
137
422
  | `accentColor` | `string` | `'#7b5cff'` | Button background color |
138
423
  | `icon` | `React.ReactNode` | default icon | Custom icon |
139
424
 
@@ -188,7 +473,11 @@ Restores conversation history (and, implicitly, resolves the session).
188
473
  }
189
474
  ```
190
475
 
191
- `id`, `created_at` and `feedback` are optional. `id` is the message's
476
+ `id`, `created_at` and `feedback` are optional, and so are `answered_by`
477
+ (`{ agent_id, agent_name, agent_icon }`, the agent that wrote an answer of a
478
+ turn several agents answered) and `mention_failed` (`true` on a note written
479
+ in place of an agent that could not answer), handed to the host as
480
+ `message.answeredBy` and `message.mentionFailed`. `id` is the message's
192
481
  persisted id, what its rating is stored against (see
193
482
  [Message feedback](#message-feedback));
194
483
  `created_at` (ISO 8601) is when it was sent, shown in its footer - a message
@@ -205,7 +494,12 @@ empty `messages` array. The component adopts whatever id the response returns
205
494
  against a dead conversation — which would otherwise fail with a
206
495
  "conversation does not exist" error and force the user to manually start a new
207
496
  conversation. If the endpoint responds with a non-2xx status, the component
208
- silently resets the stored id and starts fresh on the next message.
497
+ silently resets the stored id and starts fresh on the next message, except
498
+ when it says nothing of the conversation: a `502`, `503` or `504` (or no
499
+ answer at all) keeps the id and reads it back once the backend answers, and a
500
+ `401` or `403` (expired credentials) keeps it, shows the response's `detail`
501
+ (or "This conversation could not be loaded.") with a Retry button, and reads
502
+ it back as soon as `requestHeaders` change.
209
503
 
210
504
  Assistant history messages **should echo the same `attachments[]` array** that
211
505
  was sent on the original `done` event (see [Agent-generated file attachments](#agent-generated-file-attachments)),
@@ -258,6 +552,78 @@ Renames a conversation. Body: `{ "title": "..." }`. Only reached from the
258
552
  fullscreen sidebar; a backend without the route simply fails the request and
259
553
  the row reverts to its previous title.
260
554
 
555
+ With [workspaces](#workspaces) on, the same route files a conversation:
556
+ `{ "workspace_id": "uuid" }`, or `{ "workspace_id": null }` to take it out of
557
+ its workspace. A refusal's `detail` is shown under the list.
558
+
559
+ ### Workspaces
560
+
561
+ The fullscreen conversation list can be grouped by workspace, the way a
562
+ desktop assistant groups its projects: one collapsible group per workspace
563
+ (the collapsed ones are remembered per browser), a conversation started
564
+ inside a workspace with its group's compose button, a workspace created,
565
+ renamed or deleted from the list, and a conversation moved between groups
566
+ from its row menu or by dragging it onto a group.
567
+
568
+ The feature is off until the host names the route:
569
+ `apiEndpoints.workspaces` has **no default**, for the same reason as
570
+ `apiEndpoints.approve` - a proxied host must expose every route below before
571
+ the panel offers them. A backend that refuses the list (not licensed, an
572
+ older proxy) also leaves the list flat.
573
+
574
+ | Request | Purpose |
575
+ | --- | --- |
576
+ | `GET {apiBaseUrl}{workspaces}` | The caller's workspaces: a bare array or `{ "workspaces": [...] }` of `{ id, name, is_default, is_own, can_manage, is_archived }`. Archived ones are left out. |
577
+ | `POST {apiBaseUrl}{workspaces}` | Create one: `{ "name": "..." }`. |
578
+ | `PATCH {apiBaseUrl}{workspaces}/{id}` | Rename one: `{ "name": "..." }`. |
579
+ | `DELETE {apiBaseUrl}{workspaces}/{id}` | Delete one (never offered for the default). Its conversations are expected back in no workspace (`workspace_id: null`), never moved to a default; the history is reloaded after the delete. |
580
+ | `PATCH {apiBaseUrl}{history}/{conversation_id}` | File a conversation: `{ "workspace_id": "uuid" \| null }`. |
581
+ | `POST {apiBaseUrl}{sessions}` | Carries `workspace_id` when the conversation is started inside a workspace. |
582
+
583
+ `GET {history}` entries carry `workspace_id` (`null` for none). A conversation
584
+ in no workspace is a normal state, listed under "Not in a workspace": a new
585
+ conversation starts there unless it is started inside a workspace. A workspace the
586
+ caller may only read (`can_manage: false`) gets a group only while it holds
587
+ one of their conversations, and conversations filed in a workspace the list
588
+ does not return are grouped under "Other workspaces". XTM One serves all of it
589
+ at `/chat/workspaces` and `/chat/sessions`.
590
+
591
+ ### Conversation references
592
+
593
+ Typing `@` in the composer - at the start of the text or after a space, never
594
+ inside a word or an email (`a@b`) - opens a menu of the user's other
595
+ conversations, searched by title or key as they type. A pick inserts `@<key> `, and the
596
+ message goes out with the conversations it still references, so the agent reads
597
+ them; a `@word` typed by hand stays plain text, and so does a pick whose `@key`
598
+ is edited out before sending. The arrows move in the menu, Enter or Tab insert,
599
+ Escape closes it.
600
+
601
+ The feature is off until the host names the route:
602
+ `apiEndpoints.conversationReferences` has **no default**, for the same reason
603
+ as `apiEndpoints.approve` - a proxied host must expose the route, and forward
604
+ the new body field, before the composer offers the menu. Unset, `@` is plain
605
+ text and the message body is unchanged.
606
+
607
+ | Request | Purpose |
608
+ | --- | --- |
609
+ | `GET {apiBaseUrl}{conversationReferences}?q=<text>&limit=8&exclude=<conversation_id>` | The menu: `{ "conversations": [{ id, title, key, updated_at, is_own }] }` (or a bare array), the conversations the user can open, most recent first, that `q` matches (case-insensitive): every word of `q` (split on spaces, `-` and `_`) is in the title, or the key starts with `q` - so a key typed back (`@hi-bob` for "Hi Bob"), or a key that is not made of the title's words (an accent folded, a title without Latin letters), still finds its conversation. `q` is omitted for a bare `@`, `exclude` while the conversation is not created yet. `key` is what the pick inserts after `@`, always the backend's own. `is_own: false` marks a conversation shared with the user ("Shared with you"). |
610
+ | `POST {apiBaseUrl}{messages}` | Carries `referenced_conversation_ids: string[]`: the picks the text still holds, in the order they appear, each once, at most 5. Omitted when there is none. |
611
+ | `POST {apiBaseUrl}{sessions}` | A restored user message may carry `conversation_refs: [{ conversation_id, title, key }]`. |
612
+
613
+ The references of a sent message show as chips above it, also after a reload.
614
+ A chip opens its conversation in the panel when the conversation list holds it
615
+ (the list is read as soon as the thread shows a reference), and is a plain label
616
+ otherwise - a conversation shared with the user, say. While an answer streams,
617
+ a message with a reference waits for it instead of steering the running turn:
618
+ a steer reaches the agent as text alone, so it would never read what the
619
+ message references. Once the text references five conversations the menu says
620
+ so instead of offering more. The picks of an unsent draft are kept with its text, so a
621
+ draft restored when the panel reopens still references what it did.
622
+
623
+ XTM One serves it at `/chat/conversation-references`; a host behind its own
624
+ proxy names its route, relative to its `apiBaseUrl` (e.g.
625
+ `conversationReferences: '/conversation-references'`).
626
+
261
627
  ### `DELETE {apiBaseUrl}/chat/sessions/{conversation_id}`
262
628
 
263
629
  Deletes a conversation from the history menu. Any 2xx response counts as
@@ -279,6 +645,9 @@ Sends a message and streams the response via SSE.
279
645
  }
280
646
  ```
281
647
 
648
+ With [conversation references](#conversation-references) on, a message that
649
+ references another conversation also carries `referenced_conversation_ids`.
650
+
282
651
  The optional `context` object is forwarded verbatim from the `pageContext`
283
652
  prop (REST backend only) and is omitted entirely when empty. Use it to make
284
653
  the agent aware of the user's current location/page; the shape is up to the
@@ -286,6 +655,12 @@ host and can be extended later (page title, selected entity, user role, etc.).
286
655
  It must be JSON-serializable — a non-serializable value (circular reference,
287
656
  `BigInt`, …) is skipped rather than breaking the request.
288
657
 
658
+ A refusal before the stream (a non-2xx answer: a rate limit, a refused
659
+ option, an unknown conversation) shows the response's `detail` in place of the
660
+ answer - a string, or the `message` (or `detail`) of an object - and
661
+ "Unable to connect. Please check the configuration." when it has none; the
662
+ handle's `send` resolves `'failed'`.
663
+
289
664
  **Response:** Server-Sent Events stream with these event types:
290
665
 
291
666
  ```
@@ -298,6 +673,11 @@ data: {"type": "stream", "content": "today is sunny."}
298
673
  data: {"type": "done", "content": "The weather today is sunny.", "conversation_id": "new-uuid", "message_id": "msg-2", "tool_names": ["search_web"], "tool_call_count": 1, "iterations": 1, "reasoning": "Let me check the weather data first.", "tool_call_trace": [{"name": "search_web", "input": "{\"query\": \"weather\"}", "output": "Sunny, 24C", "success": true}], "transfer_chain": [{"agent_id": "uuid", "agent_name": "General"}], "is_truncated": false}
299
674
  ```
300
675
 
676
+ A `done` may carry `answered_by` and `mention_failed`, as a restored entry
677
+ does, and a `{"type": "session_expired", "reason": "idle"}` frame (the user's
678
+ session ended, the stream ends with it) ends the turn on screen there and
679
+ then, keeping what it wrote; it does the same on the event stream.
680
+
301
681
  The optional `message_id` on `done` is the answer's persisted id: without it
302
682
  (and without a restore naming the message) the answer cannot be rated through
303
683
  `apiEndpoints.feedback`.
@@ -365,6 +745,7 @@ Download failures (403/404/5xx/network) are reported through the optional `onDow
365
745
  - `thinking` — Agent is processing
366
746
  - `tool_start` — Agent is using tools (with `tools` array)
367
747
  - `tool_heartbeat` — Liveness signal during a long tool execution (with `tools` and `elapsed_s`). The widget keeps the current status label and renders a live elapsed-time indicator next to it once the execution exceeds ~15 s, so long operations (background tasks, agent consults) never look stuck
748
+ - `wait_start` — The agent pauses (with `seconds` and `reason`): the reason and a countdown show, with `Continue now` when the host names `skipWait`. The pause stays on screen through `wait_status` (what it watches changed state), `tool_heartbeat`, `tool_progress` and `consult_progress`; streamed text or any other status ends it
368
749
  - `analyzing` — Agent is analyzing tool results
369
750
  - `composing` — Agent is composing the response
370
751
  - `streaming` — Content is being streamed
@@ -376,6 +757,10 @@ Download failures (403/404/5xx/network) are reported through the optional `onDow
376
757
  data: {"type": "error", "content": "Something went wrong"}
377
758
  ```
378
759
 
760
+ An `error` ends the turn for good, and so does a `cancelled`, unless marked
761
+ `provisional: true` (see
762
+ [Turns that outlive their stream](#turns-that-outlive-their-stream)).
763
+
379
764
  ### `POST {apiBaseUrl}/chat/messages/steer`
380
765
 
381
766
  Steers the agent mid-run: while a response is still streaming, Enter / the
@@ -386,22 +771,44 @@ until the turn finishes. The widget POSTs:
386
771
  {
387
772
  "conversation_id": "uuid-here",
388
773
  "content": "Actually, filter by subregion instead",
389
- "agent_slug": "general"
774
+ "agent_slug": "general",
775
+ "file_ids": ["file-uuid"]
390
776
  }
391
777
  ```
392
778
 
779
+ `file_ids` is sent only with `steerAttachments` set, for files already
780
+ uploaded: by default a message with attachments does not steer.
781
+
393
782
  A 2xx response means the message was persisted and will be injected into the
394
- running agentic loop at the next iteration boundary. On a non-2xx response or
395
- a network error the optimistic user bubble is rolled back and the text is
396
- restored into the composer (prepended on its own line if the user already
397
- typed something new) — so a backend without steering support degrades
398
- gracefully and the message is never silently lost. Set `apiEndpoints.steer`
399
- to `null` to disable the steering affordances entirely.
400
-
401
- Steering only applies to text-only sends on the `rest` backend with a known
783
+ running agentic loop at the next iteration boundary (`turn_id`, when the body
784
+ names it, is the turn it joined), whatever else the body says: XTM One's
785
+ `{"status": "stored", "turn_id": null}` (stored in the thread, handed to no turn
786
+ it can name) keeps the bubble and resolves `'steered'` too. With
787
+ `"answered": false` the backend knows no turn took it: the bubble stays and a
788
+ notice above the composer says the message will be read with the next one,
789
+ until the next send. On a non-2xx
790
+ response or a network error nothing was stored: the optimistic user bubble is
791
+ rolled back, the text is restored into the composer (prepended on its own line
792
+ if the user already typed something new), the send resolves `'failed'` and the
793
+ refusal's `detail`, when there is one, shows above the composer — so a backend
794
+ without steering support degrades gracefully and the message is never silently
795
+ lost. A `409` means no turn is running any more, an ordinary race: no notice
796
+ shows, and with `holdSendsDuringTurn` the message is held and sent as the next
797
+ turn instead of going back to the composer. Set `apiEndpoints.steer` to `null`
798
+ to disable the steering affordances entirely.
799
+
800
+ Steering only applies to sends on the `rest` backend with a known
402
801
  `conversation_id` (the first turn of a fresh conversation only receives its id
403
- on `done`). Sends with attachments keep the legacy wait behavior. Esc stops
404
- generating.
802
+ on `done`). A message with attachments steers only with `steerAttachments`
803
+ (once every file is uploaded); one carrying conversation references, agent
804
+ mentions or body fields of its own (`send`'s `extras`, `onBeforeSend`'s
805
+ `extras`) never steers, since a steer reaches the agent as text alone: it waits
806
+ for the turn to end (`holdSendsDuringTurn`), and `onBeforeSend` is asked again,
807
+ with `turnRunning: false`, before it goes out. A file that failed to upload
808
+ neither steers nor waits: while a turn runs, winds down or is let go of with
809
+ the event stream, the composer offers no send and says why, and once no turn
810
+ holds the conversation the message goes without that file. `send` and a voice
811
+ call attach no files, so this never holds them. Esc stops generating.
405
812
 
406
813
  #### Multi-segment responses
407
814
 
@@ -496,9 +903,8 @@ On a non-2xx the prompt stays on screen with the failure noted and the controls
496
903
  re-armed: the turn is still paused either way, so clearing the prompt would
497
904
  strand it with nothing able to answer. A `409` means nothing is waiting any more
498
905
  (the turn finished, was cancelled, or was answered elsewhere); the stream ending
499
- then clears the prompt on its own. Stopping the turn also dismisses it — the
500
- backend waits indefinitely by design, so abandoning the stream is the reviewer's
501
- only other way out.
906
+ then clears the prompt on its own. Stopping the turn also dismisses it (and,
907
+ with `cancel`, asks the backend to end it).
502
908
 
503
909
  **Proxied hosts:** the decision goes through the same fetch path as every other
504
910
  endpoint — relative to `apiBaseUrl` and honouring `requestHeaders` — so CSRF
@@ -601,6 +1007,126 @@ abandonment, never the person deciding.
601
1007
 
602
1008
  REST backend only: `legacy` and `ag-ui` never meet this gate.
603
1009
 
1010
+ ### Turns that outlive their stream
1011
+
1012
+ A backend whose turns keep running when their stream drops (XTM One's own web
1013
+ chat) opts into six more routes, none with a default:
1014
+
1015
+ | `apiEndpoints` key | Request | What it does |
1016
+ | --- | --- | --- |
1017
+ | `cancel` | `POST {cancel}` with `{ conversation_id }` | Stop asks the backend to end the turn; the panel keeps reading until the stream's `cancelled` frame (or lets go after 10 s, or at once on a network error or an answer of `{ "turn": "none" }`, reading the conversation back for an end it had not read yet). A Stop pressed before the panel's own send started its turn (its stream delivered nothing yet) carries `"awaiting_send": true`, and its `none` says nothing of that turn: the Stop stays pending and is sent again, without the flag, as the turn starts (or as the answer comes, the turn started meanwhile); any other answer reached a turn, and nothing goes again. A backend may keep a Stop with that flag for the send's turn, and must then take a second cancel of one turn; a Stop without it says nothing of a later send. A refusal (non-2xx) keeps the turn running and shows its `detail` above the composer |
1018
+ | `skipWait` | `POST {skipWait}` with `{ conversation_id }` | `Continue now` on `status: wait_start` (`{ seconds, reason }`), for as long as the pause lasts (see the status values) |
1019
+ | `events` | `GET {events}/{conversation_id}/events?client_id=<id>` → SSE | Turns this panel did not start (another tab, a reload mid-turn), `user_message`, `conversation_title`, `approval_resolved`; every frame of a turn names it (`turn_id`, below) |
1020
+ | `messagesPage` | `GET {messagesPage}/{conversation_id}/messages?before=<message id>&limit=50&supports_artifacts=true` → `{ messages, has_more }` | "Load earlier messages" past the restored ones, offered when the restore answers `has_more: true` |
1021
+ | `clearAll` | `DELETE {clearAll}` | "Clear all conversations" in the conversation list and the header's history menu, confirmed first |
1022
+ | `tts` | `POST {tts}` with `{ text, format: 'mp3' }` → audio | Read aloud in the backend's voice; the browser's when it fails |
1023
+
1024
+ The event stream is opened once the conversation's history is read back (not
1025
+ while it could not be, for want of an agent or a reachable backend) and
1026
+ reconnected with backoff while the page is visible. Once the page has been
1027
+ hidden for 60 s it is closed (under HTTP/1.1 a background tab would otherwise
1028
+ hold one of the browser's six connections to the origin), and the relayed turn
1029
+ it fed is let go of: the segment it was writing goes, those it closed stay.
1030
+ Shown again, the stream reopens, the backend's snapshot replays a turn still
1031
+ running, and the conversation is read back for what ended meanwhile. The turn
1032
+ let go of may run on: sends wait for it (as while a turn winds down) and
1033
+ `onTurnRunningChange` says nothing until the reopened stream replays it or
1034
+ shows another turn. Not replayed within 5 s of the page showing again, or
1035
+ with the stream refused, it ended: the conversation is read back, and the
1036
+ turn ends as it says (`info.ended`), 10 s at most after. The backend may end
1037
+ the event stream at any time; a stream it ends, or that drops, is reopened,
1038
+ from the shortest delay again once it had delivered a frame or lived 5 s (one
1039
+ that closes at once backs off); a relayed turn on screen then runs on only if
1040
+ the snapshot replays it within 5 s, or ends as the conversation read back
1041
+ says.
1042
+ A `404` or `405` stops it, and a `401` or `403` stops it until
1043
+ `requestHeaders` change, when it is tried once more (a refreshed token picks
1044
+ it up).
1045
+
1046
+ With `events` named, every frame of a turn names it with the same `turn_id`
1047
+ wherever it goes: each turn frame of the event stream (`thinking`, `status`,
1048
+ `stream`, `done`, `error`, `cancelled`, `approval_required`, and the replay of
1049
+ a turn), and each frame of the message stream from its `turn_started` (or
1050
+ `folded_into_active_turn`) on. A turn frame of the event stream naming none is
1051
+ not read, and the console warns of it once per frame type. A backend without
1052
+ `events` need name no turn: each turn is then the one its own stream carries.
1053
+
1054
+ With `events` named, every message, steer, decision, page-action answer, Stop
1055
+ and "Continue now" carries the same id in `X-Chat-Client-Id` (the
1056
+ pending-approval reads and the history do not), which the backend uses to
1057
+ never relay a panel's own turn back to it; frames naming the `turn_id` of the
1058
+ panel's own turn are dropped too. When the panel's own stream drops mid-turn,
1059
+ or ends without the turn's last frame (`done`, `error`, `session_expired` or
1060
+ `cancelled`), it takes a new id and picks the turn up from the event stream,
1061
+ keeping the segments it closed (or reads the history back when nothing comes
1062
+ within 15 s). A read-back that waited for a turn to end joins the thread in
1063
+ place: each stored message missing from the screen goes after the one stored
1064
+ before it, so nothing lands above messages it follows.
1065
+
1066
+ A relayed turn uses the frames of `POST /chat/messages`, plus:
1067
+
1068
+ - `{"type": "thinking", "resumed": true, "elapsed_s": 42}` — a turn re-attached
1069
+ mid-way: the segment being written follows, whole (XTM One's snapshot holds
1070
+ that one only); the segments an intermediate `done` closed stay on screen.
1071
+ Replaying a turn it lost sight of (a relayed turn on a reopened event
1072
+ stream, its own turn after its stream dropped, a relayed turn let go of
1073
+ while the page was hidden), the panel reads the conversation back as the
1074
+ turn runs on: the segments closed meanwhile (stored by then) take their
1075
+ place before the one being written, matched to those on screen by
1076
+ `message_id`, else by text, so none shows twice; a bubble matched keeps
1077
+ what it shows (a user bubble's `displayText`, its files) and takes the
1078
+ stored id. A replay naming another turn means the one on screen ended
1079
+ meanwhile: the answer it was writing goes, its stored copy read back.
1080
+ - `{"type": "done", "intermediate": true, …}` — a segment closed, the turn goes on.
1081
+ - A provisional end — an `error` or `cancelled` marked `provisional: true` (a
1082
+ Stop's acknowledgement, a failed loop; any other is final, and nothing of
1083
+ its turn is awaited after it) — ends the turn on screen at once; the stored
1084
+ end of the same turn that follows it (a `done`, `cancelled` or `error`
1085
+ carrying `message_id`) only gives the answer its id, and a `done` its stored
1086
+ text and all a live `done` carries (attachments, reasoning, tool trace, the
1087
+ agent that answered, context usage, a transfer); also while the panel's own
1088
+ turn runs, whose context usage and agent then stay. The panel's own stream
1089
+ is read on for it. The backend still holds the conversation until then, so
1090
+ the panel holds sends meanwhile, as during a turn (with or without
1091
+ `holdSendsDuringTurn`): until that stored end or the stream's close (of
1092
+ the panel's own stream, for its own turn; another turn starting ends it
1093
+ too), at most 5 s. A relayed turn winds down across the event stream's
1094
+ reopening, whose replay of it holds sends too.
1095
+ - `{"type": "folded_into_active_turn", "turn_id": "…"}` (message stream) — the
1096
+ message joined a turn already running, whose frames follow.
1097
+
1098
+ ### Decks, reports and videos
1099
+
1100
+ An answer that made a deck, a report or a video carries a marker where it shows
1101
+ it: `[[PRESENTATION:<id>]]` (`[[PRESENTATION:<id>#/<slide id>]]` for one
1102
+ slide), `[[REPORT:<id>]]`, `[[VIDEO:<id>]]`. The panel sends
1103
+ `supports_artifacts: true` with every message and session restore of the REST
1104
+ backend, and XTM One then describes each one in the answer's `artifacts`; the
1105
+ panel draws a card where its marker stands. A marker is never shown as text, a
1106
+ descriptor whose marker is missing renders after the prose, and a marker with
1107
+ no descriptor renders nothing.
1108
+
1109
+ | Field | Meaning |
1110
+ | --- | --- |
1111
+ | `kind`, `id`, `title` | `presentation`, `report` or `video`, and what it is called. |
1112
+ | `url` | Its page in XTM One: the card's `Open in XTM One` (http(s) only, new tab). |
1113
+ | `slide_count`, `slide_ref` | A deck's slides, and the slide its marker named. |
1114
+ | `section_count` | A report's sections. |
1115
+ | `scene_count`, `duration_ms`, `status`, `progress` | A video's scenes and length, and its newest render (`pending`, `running`, `ready`, `failed`, `cancelled`; progress 0-100). |
1116
+ | `downloads` | The formats it downloads in: `pdf`, `pptx`, `pptx_native`, `mp4`. Empty until a video has a finished render. |
1117
+
1118
+ The downloads go through the host, like file downloads, once it names the
1119
+ route: `apiEndpoints.artifacts` has **no default**, for the same reason as
1120
+ `apiEndpoints.approve`. Unset, the cards show and link to XTM One, with no
1121
+ download buttons.
1122
+
1123
+ | Request | Purpose |
1124
+ | --- | --- |
1125
+ | `POST {apiBaseUrl}{artifacts}/{kind}/{id}/download` | `{ "format": "pdf" }`: the file, saved under its `Content-Disposition` name. A failure goes to `onDownloadError` with the name of the file asked for. |
1126
+ | `GET {apiBaseUrl}{artifacts}/{kind}/{id}` | The descriptor as of now: a card asks again 10 seconds after each answer, one request at a time, for 15 minutes at most, while its video renders. |
1127
+
1128
+ XTM One serves both at `/chat/artifacts`.
1129
+
604
1130
  ### Message feedback
605
1131
 
606
1132
  Every message ends with a footer. An assistant answer (once it has finished
@@ -647,8 +1173,8 @@ persisted id — the `done` event's `message_id`, or the restored entry's
647
1173
  REST backend only, and not in `singleEndpoint` mode (there is no per-path
648
1174
  routing to name a feedback route with).
649
1175
 
650
- **Read aloud** uses the browser's own speech synthesis — no endpoint, hidden
651
- where unsupported, and absent on an answer with nothing to say (code only). It
1176
+ **Read aloud** uses the browser's own speech synthesis — or the backend's
1177
+ voice when the host names `apiEndpoints.tts` — hidden where neither exists, and absent on an answer with nothing to say (code only). It
652
1178
  reads the answer's text without its markdown or file markers, in the `locale`
653
1179
  prop's language, one answer at a time; switching conversation, sending a
654
1180
  message or leaving the panel stops it.
@@ -686,25 +1212,39 @@ import { MyLogo } from './icons';
686
1212
 
687
1213
  ### Internationalization
688
1214
 
1215
+ The panel speaks English, French, Spanish and Japanese on its own: it ships a
1216
+ catalog for each and picks one from `locale` (`fr-FR` reads French; English, or
1217
+ a language it lacks, is its own keys). A host in another language translates
1218
+ through `t`. A host with nothing to add only passes `locale`:
1219
+
1220
+ ```tsx
1221
+ <ChatPanel locale={user.language} /* ... */ />
1222
+ ```
1223
+
1224
+ `t` is an optional override, asked first for every key — key in, translated
1225
+ string out, so any i18n library works. Where it returns something other than
1226
+ the key, its wording wins; where it returns the key (a host catalog that does
1227
+ not have it), the panel's own catalog answers, in `locale`'s language:
1228
+
689
1229
  ```tsx
690
1230
  import { useTranslation } from 'react-i18next';
691
1231
 
692
1232
  function App() {
693
- const { t } = useTranslation();
694
-
695
- return (
696
- <ChatPanel
697
- t={t}
698
- // ...
699
- />
700
- );
1233
+ const { t, i18n } = useTranslation();
1234
+ return <ChatPanel t={t} locale={i18n.language} /* ... */ />;
701
1235
  }
702
1236
  ```
703
1237
 
704
- `t` is only ever asked for a lookup — key in, translated string out — so any
705
- i18n library works and the package never carries a dictionary of its own.
706
- Untranslated hosts can omit it: the default is the identity function, so every
707
- key is its own English text.
1238
+ Which catalog answers, by what the host passes:
1239
+
1240
+ | `t` | `locale` | A key `t` does not translate shows in |
1241
+ | --- | --- | --- |
1242
+ | — | given | the panel's catalog for `locale` (English for a language it lacks) |
1243
+ | — | — | the panel's catalog for the browser's language |
1244
+ | given | given | the panel's catalog for `locale` |
1245
+ | given | — | English: the panel's catalog is not used |
1246
+
1247
+ `ChatToggleButton` takes the same `t` and `locale` for its default label, by the same rule.
708
1248
 
709
1249
  #### Values inside sentences
710
1250
 
@@ -719,7 +1259,7 @@ value, and a translator sees the sentence rather than a fragment of one:
719
1259
  | `'{percent}% full'` | 84% full |
720
1260
  | `'How can {agent} help you, {name}?'` | How can **Threat Analyst** help you, John? |
721
1261
 
722
- Two consequences worth knowing when writing the locale files:
1262
+ Two consequences worth knowing when writing a translation:
723
1263
 
724
1264
  - **Plurals are separate keys** — `'1 tool call'` and `'{count} tool calls'`,
725
1265
  `'Delegating one task…'` and `'Delegating {count} tasks…'`. A lookup cannot
@@ -735,212 +1275,12 @@ Two consequences worth knowing when writing the locale files:
735
1275
  `promptSuggestions` (and any suggestions the backend serves) also go through
736
1276
  `t`, so a host may pass either keys or final text.
737
1277
 
738
- #### Translation keys used
739
-
740
- Every key the package can ask for, grouped by where it appears:
741
-
742
- **Toggle button**
743
-
744
- - `'Ask Assistant'`
745
-
746
- **Header, agent menu and history**
747
-
748
- - `'Agent switching is not available here'`
749
- - `'Browse agents'`
750
- - `'Close'`
751
- - `'Conversation history'`
752
- - `'Could not reach the assistant service. Check the connection and try again.'`
753
- - `'Create agent'`
754
- - `'Delete conversation'`
755
- - `'Floating'`
756
- - `'Full screen'`
757
- - `'New chat'`
758
- - `'New conversation'`
759
- - `'No agent matches'`
760
- - `'No conversations yet'`
761
- - `'Search agents...'`
762
- - `'Sidebar'`
763
- - `'Switch to'`
764
- - `'Switch to another agent'`
765
- - `'Switch view'`
766
- - `'Transferred from {agent}'`
767
- - `'Untitled conversation'`
768
-
769
- **Conversation sidebar**
770
-
771
- - `'Conversation title'`
772
- - `'Hide conversations'`
773
- - `'No conversation matches'`
774
- - `'Rename conversation'`
775
- - `'Search conversations...'`
776
- - `'Show conversations'`
777
-
778
- **Welcome screen**
779
-
780
- - `'Assistant'`
781
- - `'Help me create a new simulation scenario'`
782
- - `'How can I help you, {name}?'`
783
- - `'How can {agent} help you, {name}?'`
784
- - `'How do I configure detection rules?'`
785
- - `'Suggestions'`
786
- - `'Summarize my recent findings'`
787
- - `'What are the latest attack patterns?'`
788
-
789
- **Composer**
790
-
791
- - `'Ask a question...'`
792
- - `'Attachments wait for the current response'`
793
- - `'Dictate a message'`
794
- - `'Enter to send now · Esc to stop'`
795
- - `'Files uploading...'`
796
- - `'Insert prompt template'`
797
- - `'Listening...'`
798
- - `'No prompt matches'`
799
- - `'Search prompts...'`
800
- - `'Send now'`
801
- - `'Stop dictation'`
802
- - `'Stop generating'`
803
- - `'Uses AI. Verify results.'`
804
-
805
- **Messages, markdown and files**
806
-
807
- - `'Bad response'`
808
- - `'Conversation transcript'`
809
- - `'Copied'`
810
- - `'Copied!'`
811
- - `'Copy code'`
812
- - `'Copy message'`
813
- - `'Copy response'`
814
- - `'Could not retract feedback.'`
815
- - `'Could not submit feedback.'`
816
- - `'Download'`
817
- - `'Expand image'`
818
- - `'Feedback already submitted'`
819
- - `'Good response'`
820
- - `'Image could not be loaded'`
821
- - `'Image preview'`
822
- - `'Load earlier messages'`
823
- - `'Loading image…'`
824
- - `'Read aloud'`
825
- - `'Reasoning details'`
826
- - `'Reasoning details — turn limit reached'`
827
- - `'Save'`
828
- - `'Skip'`
829
- - `'Stop reading'`
830
- - `'Tell us what went wrong (optional)'`
831
- - `'What could be improved?'`
832
-
833
- **Agent status**
834
-
835
- - `'Analyzing results…'`
836
- - `'Collecting results from one task…'`
837
- - `'Collecting results from {count} tasks…'`
838
- - `'Composing answer…'`
839
- - `'Consulting {agent}…'`
840
- - `'Delegating one task…'`
841
- - `'Delegating {count} tasks…'`
842
- - `'Incorporating your message…'`
843
- - `'Thinking...'`
844
- - `'Transferring to {agent}…'`
845
- - `'Using tools…'`
846
- - `'Waiting for one background task…'`
847
- - `'Waiting for your approval…'`
848
- - `'Waiting for {count} background tasks…'`
849
- - `'the agent'`
850
- - `'{tool} (+{count} more)…'`
851
-
852
- **Waiting panel**
853
-
854
- - `'Almost there'`
855
- - `'Analyzing the details'`
856
- - `'Connecting the dots'`
857
- - `'Consulting the sources'`
858
- - `'Crunching the data'`
859
- - `'Play Space Invaders'`
860
- - `'Play Space Invaders in XTM One'`
861
- - `'Polishing the answer'`
862
- - `'Putting it together'`
863
- - `'Reticulating splines'`
864
- - `'Thinking it through'`
865
- - `'Wrapping things up'`
866
-
867
- **Reasoning details**
868
-
869
- - `'(no output)'`
870
- - `'1 tool call'`
871
- - `'1 transfer'`
872
- - `'Input'`
873
- - `'Model reasoning'`
874
- - `'Output'`
875
- - `"The agent's iteration budget was exhausted - execution stopped before completing all planned steps. The final response is a best-effort summary of work done so far."`
876
- - `'Transfer chain'`
877
- - `'Turn limit reached.'`
878
- - `'{count} iterations'`
879
- - `'{count} tool calls'`
880
- - `'{count} transfers'`
881
-
882
- **Tool approval**
883
-
884
- - `'Also applies to your scheduled runs, until you revoke it'`
885
- - `'Always allowed'`
886
- - `'Approved'`
887
- - `'Back'`
888
- - `'Confirm'`
889
- - `'Decline this call'`
890
- - `'Declined'`
891
- - `'No'`
892
- - `'Sending…'`
893
- - `'The agent needs your approval to run a tool:'`
894
- - `'The agent needs your approval to run these tools:'`
895
- - `'Why not? The agent sees this and can adapt (optional)'`
896
- - `'Yes'`
897
- - `'Yes, always'`
898
- - `'e.g. wrong environment — use staging instead'`
899
- - `'unknown tool'`
900
- - `'{decided}/{total} decided'`
901
- - `'“Yes, always” saves a preference for you. That tool will then run without asking — including on scheduled runs nobody is watching — until you revoke it.'`
902
-
903
- **Context gauge**
904
-
905
- - `'Context full — older turns are being dropped'`
906
- - `'Context nearly full — older turns are being summarized'`
907
- - `'Context used'`
908
- - `'Conversation'`
909
- - `'MCP & dynamic tools'`
910
- - `'Summarized conversation'`
911
- - `'System prompt'`
912
- - `'Tool definitions'`
913
- - `'Tool results'`
914
- - `'{counts} tokens'`
915
- - `'{percent}% full'`
916
- - `'{summary} — click for details'`
917
-
918
- **Quota**
919
-
920
- - `'Quota'`
921
- - `'Quota reached'`
922
- - `'Usage'`
923
- - `'Usage · {period}'`
924
- - `'this month'`
925
- - `'this year'`
926
- - `'today'`
927
- - `'{quota} (shared across all users)'`
928
-
929
- **Notices, errors and timestamps**
930
-
931
- - `'Could not send your decision. Please try again.'`
932
- - `'No response.'`
933
- - `'Response ready'`
934
- - `'Sorry, an error occurred. Please try again.'`
935
- - `'This decision could not be sent. Reload the chat and try again.'`
936
- - `'This turn is no longer waiting for a decision.'`
937
- - `'Unable to connect. Please check the configuration.'`
938
- - `'Your answer is ready'`
939
- - `'just now'`
940
- - `'{agent} has finished'`
941
- - `'{count}d ago'`
942
- - `'{count}h ago'`
943
- - `'{count}m ago'`
1278
+ #### Translation keys
1279
+
1280
+ The catalogs are `src/i18n/<language>.ts`, one entry per key: the list of
1281
+ everything a host may override. `yarn test` fails when a key the source uses
1282
+ is missing from a catalog, or a translation drops one of its placeholders, so
1283
+ a new string ships translated with the feature that adds it.
944
1284
 
945
1285
  ## Styling
946
1286
 
@@ -955,11 +1295,15 @@ The component uses Tailwind CSS classes and CSS custom properties for theming. T
955
1295
  The stylesheet is isolated from the host page in both directions. Every utility
956
1296
  is scoped to the panel (`.filigran-chatbot` and its descendants) and declared
957
1297
  outside any cascade layer, and the theme variables (`--radius-*`, `--spacing`,
958
- ...) are declared on the panel root. The host's own utilities and `:root`
959
- tokens - a Tailwind build shipped without layers, such as the Filigran design
960
- system's - therefore never restyle the panel, and the panel's utilities never
961
- style the host page. Panel overlays (menus, dialogs, tooltips) render inside
962
- the panel root, so they keep its styles.
1298
+ `--color-*`, ...) are declared on the panel root only, never on `:root` or
1299
+ `:host`. The host's own utilities and `:root` tokens - a Tailwind build shipped
1300
+ without layers, such as the Filigran design system's - therefore never restyle
1301
+ the panel, and loading the stylesheet neither styles the host page nor
1302
+ redeclares the host's own variables. Panel overlays (menus, dialogs, tooltips,
1303
+ the image viewer) render inside the panel root, so they keep its styles; so
1304
+ does `ChatToggleButton`, which carries the `filigran-chatbot` class itself. A
1305
+ host element rendered outside the panel does not get the panel's variables:
1306
+ declare its own.
963
1307
 
964
1308
  ### Composer toolbar
965
1309
 
@@ -1069,31 +1413,28 @@ Anything host-specific goes through `composerToolbar`:
1069
1413
  A host that renders assistant prose with its **own** markdown component (its
1070
1414
  design tokens, its icon set) should still normalise the text the same way the
1071
1415
  panel does, rather than maintaining a divergent copy. These pure
1072
- `string → string` helpers ship from the dedicated **`@filigran/chatbot/markdown`**
1073
- entry point — ~2 kB, no React, no CSS. Import them from there and never from the
1416
+ `string → string` helpers ship, with the answer-marker helpers below, from the dedicated **`@filigran/chatbot/markdown`**
1417
+ entry point — ~5 kB, no React, no CSS. Import them from there and never from the
1074
1418
  package root, which is the full panel bundle:
1075
1419
 
1076
1420
  ```tsx
1077
- import {
1078
- hardenNestedCodeFences,
1079
- markdownUrlTransform,
1080
- normalizeImageMarkdown,
1081
- normalizeMarkdownTables,
1082
- wrapBareJson,
1083
- } from '@filigran/chatbot/markdown';
1084
-
1085
- const processed = hardenNestedCodeFences(
1086
- normalizeMarkdownTables(wrapBareJson(normalizeImageMarkdown(content))),
1087
- );
1421
+ import { markdownUrlTransform, prepareMarkdown } from '@filigran/chatbot/markdown';
1422
+
1423
+ // After the host's own rewriting, if any (its API's URLs).
1424
+ const processed = prepareMarkdown(rewriteOwnUrls(content));
1088
1425
 
1089
1426
  <ReactMarkdown urlTransform={markdownUrlTransform}>{processed}</ReactMarkdown>;
1090
1427
  ```
1091
1428
 
1429
+ `prepareMarkdown` is the panel's own pipeline, in its order:
1430
+ `hardenNestedCodeFences(normalizeMarkdownTables(wrapBareJson(normalizeImageMarkdown(content))))`.
1092
1431
  Order matters: alt-text is flattened before anything reads line structure, and
1093
- the JSON wrap must see the raw payload before fences are hardened.
1432
+ the JSON wrap must see the raw payload before fences are hardened. Each step
1433
+ is exported too.
1094
1434
 
1095
1435
  | Helper | Fixes |
1096
1436
  | ------------------------- | -------------------------------------------------------------------------------------------------------------- |
1437
+ | `prepareMarkdown` | All four below, in the panel's order |
1097
1438
  | `normalizeImageMarkdown` | Multi-line `![alt](url)` alt text, which breaks the image into literal paragraphs plus a stray link |
1098
1439
  | `wrapBareJson` | A whole message that is raw JSON — fenced as ```json so it stays readable and copyable |
1099
1440
  | `normalizeMarkdownTables` | A delimiter row whose column count doesn't match the header, including tables nested in blockquotes / list items |
@@ -1103,6 +1444,49 @@ the JSON wrap must see the raw payload before fences are hardened.
1103
1444
  None of them touch content they don't apply to — an already-valid document is
1104
1445
  returned byte-identical.
1105
1446
 
1447
+ The same entry point reads the `[[KIND:args]]` markers of an answer exactly as
1448
+ the panel does, for a host that renders answers, previews or notifications of
1449
+ its own:
1450
+
1451
+ ```ts
1452
+ import { markerKinds, splitAnswerMarkers, stripAnswerMarkers, type AnswerPart } from '@filigran/chatbot/markdown';
1453
+
1454
+ const kinds = markerKinds(['WORK_ITEM', 'INVESTIGATION']); // the host's own kinds
1455
+ const parts: AnswerPart[] = splitAnswerMarkers(content, isStreaming, kinds);
1456
+ const preview = stripAnswerMarkers(content, kinds);
1457
+ ```
1458
+
1459
+ | Helper | What it does |
1460
+ | --- | --- |
1461
+ | `markerKinds(names)` | The host's marker names upper-cased, deduplicated and sorted, invalid ones (anything but `A-Z`, `0-9`, `_`, starting with a letter) dropped: the `kinds` the two others take |
1462
+ | `splitAnswerMarkers(content, streaming?, kinds?)` | The answer as ordered parts: `{ type: 'text', value }`, `{ type: 'file', fileId }`, `{ type: 'artifact', kind, id, slideRef? }` (`PRESENTATION`, `REPORT`, `VIDEO`, the id lower-cased, a deck's `#/<slide>`), and `{ type: 'marker', kind, args }` for a kind in `kinds` (a built-in one named there included, its arguments as written). A marker cut off at the end is dropped, and so, while `streaming`, is a chunk that stopped inside a marker's name (`[[PRES`). A marker of a kind no one names stays text |
1463
+ | `stripAnswerMarkers(content, kinds?)` | The answer with its markers removed (copy, a preview, a notification); prose with none is returned untouched |
1464
+
1465
+ ## Workspace Helpers
1466
+
1467
+ A host listing conversations in a sidebar of its own groups them by workspace
1468
+ exactly as the panel's list does, from the dedicated **`@filigran/chatbot/workspaces`**
1469
+ entry point — pure functions, no React, no CSS:
1470
+
1471
+ ```ts
1472
+ import { ELSEWHERE_GROUP, groupConversations, groupIsOpen, parseWorkspaces } from '@filigran/chatbot/workspaces';
1473
+
1474
+ const workspaces = parseWorkspaces(workspaceList, { userId: me.id });
1475
+ const { groups, unfiled } = groupConversations(
1476
+ conversations.map((c) => ({ ...c, conversationId: c.id, workspaceId: c.workspace_id })),
1477
+ workspaces,
1478
+ { matches: search ? (c) => c.title.toLowerCase().includes(search) : undefined },
1479
+ );
1480
+ ```
1481
+
1482
+ | Helper | What it does |
1483
+ | --- | --- |
1484
+ | `parseWorkspaces(data, { userId? })` | The backend's list (a bare array or `{ workspaces }`) as `ChatWorkspace`s; entries without an id or a name, and archived ones, are left out. A workspace is the caller's own (`isOwn`) by its `is_own`, else, where the backend sends none, by its `user_id` being `userId` |
1485
+ | `sortWorkspaces(workspaces)` | The caller's own default first, then by name |
1486
+ | `groupConversations(conversations, workspaces, { matches? })` | `{ groups, unfiled }`: a group (`{ workspace, key, conversations }`) per workspace the caller may file into, empty or not, and per one they may only read while it holds one of their conversations; those filed in a workspace not listed go to one group keyed `ELSEWHERE_GROUP` (`workspace: null`). With `matches` only the matching conversations stay, and groups left empty go. Each conversation needs `conversationId` and `workspaceId`; the host's own type passes through whole |
1487
+ | `fileableWorkspaces(workspaces)` | The workspaces a conversation can be moved into (`canManage`), in list order |
1488
+ | `groupIsOpen(key, collapsed, group, activeConversationId, searching)` / `groupCanToggle(group, activeConversationId, searching)` | Whether a group shows its conversations (collapsed, unless it holds the conversation on screen; always while searching), and whether its toggle may fold it |
1489
+
1106
1490
  ## Peer Dependencies
1107
1491
 
1108
1492
  - `react` >= 18
@@ -1133,7 +1517,7 @@ To build:
1133
1517
  yarn build
1134
1518
  ```
1135
1519
 
1136
- To run the unit tests (Node's built-in test runner, which runs TypeScript natively from Node 22.18 / 23.6):
1520
+ To run the unit tests (Node's built-in test runner, with an esbuild loader for JSX and Node's own type stripping for TypeScript; Node 22.18 / 23.6 or later):
1137
1521
 
1138
1522
  ```bash
1139
1523
  yarn test