@filigran/chatbot 3.7.4 → 3.9.1

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
@@ -6,11 +6,18 @@ Filigran chat panel — a standalone React + Tailwind chatbot component with SSE
6
6
 
7
7
  - 🔄 **SSE Message Streaming** — Real-time response streaming with status indicators
8
8
  - ⚡ **Mid-Run Steering** — Send messages while the agent is generating; they are injected into the running agentic loop instead of waiting for the turn to finish
9
- - 🗂️ **Conversation History** — Switch between (and delete) past conversations from a history menu in the header
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
+ - 🗂️ **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)
10
11
  - 🤖 **Multi-Agent Support** — Switch between different AI agents
11
12
  - 📎 **File Attachments** — Upload and paste files (PDF, TXT, images)
12
13
  - 📥 **Agent-Generated Files** — Renders downloadable file cards from agent output and strips the `[[FILE:id]]` markers from the prose
13
- - 📝 **Full Markdown** — Tables, code blocks with copy button, lists, blockquotes
14
+ - 📝 **Full Markdown** — Tables (mis-delimited ones repaired), code blocks with copy button, lists, blockquotes, soft line breaks, inline images with a lightbox
15
+ - 🖼️ **Image Previews** — `data:image/*` charts and image attachments render inline, click to expand
16
+ - 📋 **Copy & Rate** — Copy any answer; optional 👍/👎 feedback wired to the host
17
+ - 🧰 **Composer Toolbar** — Prompt library and quota indicator, both driven by whether the host serves the route; plus a slot for the host's own controls
18
+ - 🧠 **Context Gauge** — Ring + percentage showing how full the model's context window is, so a long chat's silent summarising is visible before it happens
19
+ - 🎙️ **Dictation** — Speech-to-text via the browser's own Web Speech API; no endpoint, no key, hidden where unsupported
20
+ - ✍️ **Draft Recovery** — Unsent composer text is kept per conversation and restored when the panel reopens
14
21
  - 🎨 **Customizable Theme** — Accent color and logo customization
15
22
  - 📱 **3 Display Modes** — Floating, sidebar (resizable), and fullscreen
16
23
  - 💾 **Persistence** — Conversation and sidebar width saved to localStorage
@@ -74,6 +81,10 @@ import { ChatPanel } from '@filigran/chatbot';
74
81
  | `onWidthChange` | `(width: number) => void` | — | Called when sidebar width changes during resize |
75
82
  | `onResizeStart` | `() => void` | — | Called when resize drag starts |
76
83
  | `onResizeEnd` | `() => void` | — | Called when resize drag ends |
84
+ | `onMessageFeedback` | `(id, feedback, message) => void` | — | Enables 👍/👎 on completed assistant answers and receives each rating (`null` clears it). Omit to hide the affordance — the panel stores nothing itself. |
85
+ | `disableImagePreviews` | `boolean` | `false` | Render image attachments as download cards instead of inline previews |
86
+ | `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). |
87
+ | `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. |
77
88
 
78
89
  #### Resizable Sidebar Example
79
90
 
@@ -110,13 +121,14 @@ import { ChatToggleButton } from '@filigran/chatbot';
110
121
 
111
122
  #### Props
112
123
 
113
- | Prop | Type | Default | Description |
114
- | ------------- | ----------------- | ------------ | ------------------------------ |
115
- | `isOpen` | `boolean` | **required** | Whether the chat panel is open |
116
- | `onToggle` | `() => void` | **required** | Called when button is clicked |
117
- | `label` | `string` | `'Chat'` | Tooltip/aria label |
118
- | `accentColor` | `string` | `'#7b5cff'` | Button background color |
119
- | `icon` | `React.ReactNode` | default icon | Custom icon |
124
+ | Prop | Type | Default | Description |
125
+ | ------------- | ------------------------- | ------------ | -------------------------------------------------------------------------- |
126
+ | `isOpen` | `boolean` | **required** | Whether the chat panel is open |
127
+ | `onToggle` | `() => void` | **required** | Called when button is clicked |
128
+ | `label` | `string` | built-in | Button text, already translated by the host; omit for `t('Ask Assistant')` |
129
+ | `t` | `(key: string) => string` | identity | Translation function, used for the default label |
130
+ | `accentColor` | `string` | `'#7b5cff'` | Button background color |
131
+ | `icon` | `React.ReactNode` | default icon | Custom icon |
120
132
 
121
133
  ## API Contract
122
134
 
@@ -219,6 +231,12 @@ the chat. Set `apiEndpoints.history` to `null` to hide the history menu
219
231
  entirely, or point it at a dedicated path if your proxy can't route `GET` on
220
232
  the sessions path.
221
233
 
234
+ ### `PATCH {apiBaseUrl}/chat/sessions/{conversation_id}`
235
+
236
+ Renames a conversation. Body: `{ "title": "..." }`. Only reached from the
237
+ fullscreen sidebar; a backend without the route simply fails the request and
238
+ the row reverts to its previous title.
239
+
222
240
  ### `DELETE {apiBaseUrl}/chat/sessions/{conversation_id}`
223
241
 
224
242
  Deletes a conversation from the history menu. Any 2xx response counts as
@@ -378,6 +396,134 @@ data: {"type": "stream", "content": "Follow-up answer to the steering message"}
378
396
  data: {"type": "done", "content": "Follow-up answer to the steering message", "conversation_id": "uuid"}
379
397
  ```
380
398
 
399
+ ### `POST {apiBaseUrl}{apiEndpoints.approve}`
400
+
401
+ Answers a turn that paused because the agent proposed a tool call requiring a
402
+ human's consent. **Opt-in: there is no default path.** The widget advertises
403
+ approval support to the backend only when `apiEndpoints.approve` is set, and
404
+ that flag is a promise — a backend that pauses a turn waits indefinitely for a
405
+ decision, with no timeout. A host that names a path it cannot route would
406
+ receive the pause, POST the decision into a 404, and hang the turn with the
407
+ user watching a spinner. Leave it unset and the backend degrades to an ordinary
408
+ assistant message explaining what it could not run, so an un-updated host keeps
409
+ working untouched.
410
+
411
+ When set, `supports_tool_approval: true` is sent on every `rest` message body.
412
+
413
+ **Server → client**, on the existing SSE stream, alongside `stream` / `status` /
414
+ `done`:
415
+
416
+ ```
417
+ data: {"type": "approval_required", "conversation_id": "uuid-here", "proposals": [
418
+ {
419
+ "tool_call_id": "call_abc123",
420
+ "tool_name": "opencti_delete_entity",
421
+ "tool_description": "Permanently delete an entity from the platform.",
422
+ "arguments": {"entity_id": "e-123", "cascade": true},
423
+ "input_schema": {"type": "object", "properties": {
424
+ "entity_id": {"type": "string", "description": "Entity to delete"},
425
+ "cascade": {"type": "boolean", "description": "Also delete linked entities"}
426
+ }},
427
+ "source": "integration:opencti"
428
+ }
429
+ ]}
430
+ ```
431
+
432
+ The turn is **not** over: no `done` arrives, the stream stays open and silent
433
+ (kept alive by SSE `: keepalive` comment lines, which the reader drops), and the
434
+ rest of the turn continues on it once a decision is sent. The progress bubble is
435
+ replaced by the prompt, which renders each argument next to its description from
436
+ `input_schema` — `cascade: true` is unjudgeable on its own, so a prompt showing
437
+ only names and values would be a rubber stamp.
438
+
439
+ **Client → server**, one decision per proposed call:
440
+
441
+ ```json
442
+ {
443
+ "conversation_id": "uuid-here",
444
+ "decisions": [
445
+ { "tool_call_id": "call_abc123", "decision": "approve" },
446
+ { "tool_call_id": "call_ghi789", "decision": "reject", "rejection_reason": "Wrong target environment." },
447
+ { "tool_call_id": "call_jkl012", "decision": "approve_always" }
448
+ ]
449
+ }
450
+ ```
451
+
452
+ | `decision` | Effect |
453
+ | ---------------- | ------------------------------------------------------------------- |
454
+ | `approve` | Runs with the arguments exactly as proposed |
455
+ | `reject` | Does not run; the agent receives `rejection_reason` and can adapt |
456
+ | `approve_always` | Runs, **and** saves a standing approval for this user |
457
+
458
+ Every proposed `tool_call_id` must appear exactly once — the backend refuses a
459
+ partial set, because resuming with an undecided call leaves a `tool_use` block
460
+ without its `tool_result`, which the model providers reject outright. The prompt
461
+ therefore submits itself once the last card is decided. A set containing
462
+ `approve_always` waits for an explicit Confirm instead: it is the only verdict
463
+ whose reach outlives the turn (it applies to the user's unattended scheduled
464
+ runs too), so the warning has to be read before it is committed.
465
+
466
+ A decision carries no arguments. Correcting a wrong proposal is what
467
+ `reject` with a reason is for — rewriting a call under the agent's name would
468
+ leave a transcript crediting it with arguments it never chose.
469
+
470
+ On a non-2xx the prompt stays on screen with the failure noted and the controls
471
+ re-armed: the turn is still paused either way, so clearing the prompt would
472
+ strand it with nothing able to answer. A `409` means nothing is waiting any more
473
+ (the turn finished, was cancelled, or was answered elsewhere); the stream ending
474
+ then clears the prompt on its own. Stopping the turn also dismisses it — the
475
+ backend waits indefinitely by design, so abandoning the stream is the reviewer's
476
+ only other way out.
477
+
478
+ **Proxied hosts:** the decision goes through the same fetch path as every other
479
+ endpoint — relative to `apiBaseUrl` and honouring `requestHeaders` — so CSRF
480
+ wrappers and per-request context headers keep working. A proxy in front of the
481
+ chat must forward the request body whole (a proxy rebuilding it from a fixed
482
+ field list silently drops `supports_tool_approval`), never time out the
483
+ streaming turn, and pass SSE keepalives through untouched.
484
+
485
+ #### Recovering a prompt after a page reload
486
+
487
+ `approval_required` is a single event on a stream, so a reload loses it —
488
+ including the `tool_call_id`s a decision has to name — while the turn goes on
489
+ waiting for an answer that can no longer be given. To the user that is a chat
490
+ which simply stopped replying.
491
+
492
+ Set `apiEndpoints.pendingApprovals` (XTM One: `/chat/conversations`) and the
493
+ panel asks once per conversation, on mount and on every conversation switch:
494
+
495
+ ```
496
+ GET {apiBaseUrl}{apiEndpoints.pendingApprovals}/{conversation_id}/pending-approvals
497
+ → {
498
+ "conversation_id": "uuid-here",
499
+ "proposals": [ /* as the event carried */ ],
500
+ "turn": "running" | "idle"
501
+ }
502
+ ```
503
+
504
+ An empty `proposals` is the ordinary answer. A non-empty one re-renders the same
505
+ prompt, and the decision is POSTed exactly as before. Like `approve` this has no
506
+ default and is skipped when unset, leaving the live flow untouched.
507
+
508
+ The recovered turn resumes with the reasoning and tool results it had already
509
+ produced — but **not on a stream**: the one it would have reported on died with
510
+ the old page, so the backend persists the answer and suppresses the live `done`
511
+ frame. So after a decision on a recovered prompt the panel shows its ordinary
512
+ working indicator and polls this route every 5s, using `turn` as the stop
513
+ condition: while it reads `running` it keeps waiting, and the moment it reads
514
+ `idle` it re-reads the conversation once — the answer is there. A resumed turn
515
+ that pauses *again* on a second gated call is picked up by the same poll, which
516
+ is the only way that prompt could reach the user with no stream open.
517
+
518
+ One bound applies server-side: after 30 minutes with **no sign of a client** and
519
+ no decision, the turn stops waiting and the pause is discarded. Any request about
520
+ the conversation counts as a sign, so while a recovered prompt is displayed the
521
+ panel re-reads this route every 10 minutes purely to say someone is still there —
522
+ a tab left open makes no requests of its own, and the bound is meant to limit
523
+ abandonment, never the person deciding.
524
+
525
+ REST backend only: `legacy` and `ag-ui` never meet this gate.
526
+
381
527
  ## Customization
382
528
 
383
529
  ### Custom Logo
@@ -426,47 +572,230 @@ function App() {
426
572
  }
427
573
  ```
428
574
 
429
- **Translation keys used:**
575
+ `t` is only ever asked for a lookup — key in, translated string out — so any
576
+ i18n library works and the package never carries a dictionary of its own.
577
+ Untranslated hosts can omit it: the default is the identity function, so every
578
+ key is its own English text.
430
579
 
431
- - `'Thinking...'`
432
- - `'Using tools…'`
433
- - `'Analyzing results…'`
434
- - `'Composing answer…'`
435
- - `'Incorporating your message…'`
436
- - `'Ask a question...'`
437
- - `'Stop generating'`
438
- - `'Send now'`
439
- - `'Enter to send now · Esc to stop'`
440
- - `'Attachments wait for the current response'`
441
- - `'New chat'`
580
+ #### Values inside sentences
581
+
582
+ A key is always a **whole sentence**, with `{placeholder}` slots for the values.
583
+ The panel fills the slots in _after_ the lookup, so a locale is free to move the
584
+ value, and a translator sees the sentence rather than a fragment of one:
585
+
586
+ | Key | Renders as |
587
+ | --- | --- |
588
+ | `'Waiting for {count} background tasks…'` | Waiting for 3 background tasks… |
589
+ | `'Transferred from {agent}'` | Transferred from Threat Analyst |
590
+ | `'{percent}% full'` | 84% full |
591
+ | `'How can {agent} help you, {name}?'` | How can **Threat Analyst** help you, John? |
592
+
593
+ Two consequences worth knowing when writing the locale files:
594
+
595
+ - **Plurals are separate keys** — `'1 tool call'` and `'{count} tool calls'`,
596
+ `'Delegating one task…'` and `'Delegating {count} tasks…'`. A lookup cannot
597
+ select a plural form, so the panel picks the sentence and the locale
598
+ translates it whole; a language with more plural forms than English can route
599
+ the plural key through its own rules.
600
+ - **A missing slot is not fatal** — a translation that drops a `{placeholder}`
601
+ renders the rest of the sentence as it is, and the value is simply absent.
602
+ That includes the greeting, where the agent's name is markup: a locale that
603
+ rewords the sentence without `{agent}` gets the sentence, not a name dangling
604
+ off the end of it.
605
+
606
+ `promptSuggestions` (and any suggestions the backend serves) also go through
607
+ `t`, so a host may pass either keys or final text.
608
+
609
+ #### Translation keys used
610
+
611
+ Every key the package can ask for, grouped by where it appears:
612
+
613
+ **Toggle button**
614
+
615
+ - `'Ask Assistant'`
616
+
617
+ **Header, agent menu and history**
618
+
619
+ - `'Agent switching is not available here'`
620
+ - `'Browse agents'`
621
+ - `'Close'`
442
622
  - `'Conversation history'`
443
- - `'No conversations yet'`
444
- - `'Untitled conversation'`
445
- - `'New conversation'`
623
+ - `'Could not reach the assistant service. Check the connection and try again.'`
624
+ - `'Create agent'`
446
625
  - `'Delete conversation'`
447
- - `'just now'` / `'m ago'` / `'h ago'` / `'d ago'`
448
- - `'Switch view'`
449
- - `'Close'`
626
+ - `'Floating'`
627
+ - `'Full screen'`
628
+ - `'New chat'`
629
+ - `'New conversation'`
630
+ - `'No agent matches'`
631
+ - `'No conversations yet'`
632
+ - `'Search agents...'`
633
+ - `'Sidebar'`
634
+ - `'Switch to'`
450
635
  - `'Switch to another agent'`
451
- - `'Browse agents'`
452
- - `'Create agent'`
636
+ - `'Switch view'`
637
+ - `'Transferred from {agent}'`
638
+ - `'Untitled conversation'`
639
+
640
+ **Conversation sidebar**
641
+
642
+ - `'Conversation title'`
643
+ - `'Hide conversations'`
644
+ - `'No conversation matches'`
645
+ - `'Rename conversation'`
646
+ - `'Search conversations...'`
647
+ - `'Show conversations'`
648
+
649
+ **Welcome screen**
650
+
651
+ - `'Assistant'`
652
+ - `'Help me create a new simulation scenario'`
653
+ - `'How can I help you, {name}?'`
654
+ - `'How can {agent} help you, {name}?'`
655
+ - `'How do I configure detection rules?'`
656
+ - `'Suggestions'`
657
+ - `'Summarize my recent findings'`
658
+ - `'What are the latest attack patterns?'`
659
+
660
+ **Composer**
661
+
662
+ - `'Ask a question...'`
663
+ - `'Attachments wait for the current response'`
664
+ - `'Dictate a message'`
665
+ - `'Enter to send now · Esc to stop'`
666
+ - `'Files uploading...'`
667
+ - `'Insert prompt template'`
668
+ - `'No prompt matches'`
669
+ - `'Search prompts...'`
670
+ - `'Send now'`
671
+ - `'Stop dictation'`
672
+ - `'Stop generating'`
673
+ - `'Uses AI. Verify results.'`
674
+
675
+ **Messages, markdown and files**
676
+
677
+ - `'Bad response'`
678
+ - `'Copied'`
679
+ - `'Copied!'`
680
+ - `'Copy code'`
681
+ - `'Copy response'`
682
+ - `'Download'`
683
+ - `'Expand image'`
684
+ - `'Good response'`
685
+ - `'Image could not be loaded'`
686
+ - `'Image preview'`
687
+ - `'Load earlier messages'`
688
+ - `'Loading image…'`
453
689
  - `'Reasoning details'`
454
690
  - `'Reasoning details — turn limit reached'`
691
+
692
+ **Agent status**
693
+
694
+ - `'Analyzing results…'`
695
+ - `'Collecting results from one task…'`
696
+ - `'Collecting results from {count} tasks…'`
697
+ - `'Composing answer…'`
698
+ - `'Consulting {agent}…'`
699
+ - `'Delegating one task…'`
700
+ - `'Delegating {count} tasks…'`
701
+ - `'Incorporating your message…'`
702
+ - `'Thinking...'`
703
+ - `'Transferring to {agent}…'`
704
+ - `'Using tools…'`
705
+ - `'Waiting for one background task…'`
706
+ - `'Waiting for your approval…'`
707
+ - `'Waiting for {count} background tasks…'`
708
+ - `'the agent'`
709
+ - `'{tool} (+{count} more)…'`
710
+
711
+ **Waiting mini-game**
712
+
713
+ - `'Almost there'`
714
+ - `'Analyzing the details'`
715
+ - `'Connecting the dots'`
716
+ - `'Consulting the sources'`
717
+ - `'Crunching the data'`
718
+ - `'Polishing the answer'`
719
+ - `'Putting it together'`
720
+ - `'Reticulating splines'`
721
+ - `'Thinking it through'`
722
+ - `'Turn off the waiting mini-game'`
723
+ - `'Turn on the waiting mini-game'`
724
+ - `'Wrapping things up'`
725
+
726
+ **Reasoning details**
727
+
728
+ - `'(no output)'`
729
+ - `'1 tool call'`
730
+ - `'1 transfer'`
731
+ - `'Input'`
455
732
  - `'Model reasoning'`
456
- - `'iterations'`
457
- - `'transfer'` / `'transfers'`
733
+ - `'Output'`
734
+ - `"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."`
458
735
  - `'Transfer chain'`
459
736
  - `'Turn limit reached.'`
460
- - `"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."`
461
- - `'Input'` / `'Output'` / `'(no output)'`
462
- - `'Download'`
463
- - `'tool call'` / `'tool calls'`
464
- - `'Uses AI. Verify results.'`
465
- - `'How can I help you, '`
466
- - `'Suggestions'`
467
- - `'Floating'`
468
- - `'Sidebar'`
469
- - `'Full screen'`
737
+ - `'{count} iterations'`
738
+ - `'{count} tool calls'`
739
+ - `'{count} transfers'`
740
+
741
+ **Tool approval**
742
+
743
+ - `'Also applies to your scheduled runs, until you revoke it'`
744
+ - `'Always allowed'`
745
+ - `'Approved'`
746
+ - `'Back'`
747
+ - `'Confirm'`
748
+ - `'Decline this call'`
749
+ - `'Declined'`
750
+ - `'No'`
751
+ - `'Sending…'`
752
+ - `'The agent needs your approval to run a tool:'`
753
+ - `'The agent needs your approval to run these tools:'`
754
+ - `'Why not? The agent sees this and can adapt (optional)'`
755
+ - `'Yes'`
756
+ - `'Yes, always'`
757
+ - `'e.g. wrong environment — use staging instead'`
758
+ - `'unknown tool'`
759
+ - `'{decided}/{total} decided'`
760
+ - `'“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.'`
761
+
762
+ **Context gauge**
763
+
764
+ - `'Context full — older turns are being dropped'`
765
+ - `'Context nearly full — older turns are being summarized'`
766
+ - `'Context used'`
767
+ - `'Conversation'`
768
+ - `'MCP & dynamic tools'`
769
+ - `'Summarized conversation'`
770
+ - `'System prompt'`
771
+ - `'Tool definitions'`
772
+ - `'Tool results'`
773
+ - `'{counts} tokens'`
774
+ - `'{percent}% full'`
775
+ - `'{summary} — click for details'`
776
+
777
+ **Quota**
778
+
779
+ - `'Quota'`
780
+ - `'Quota reached'`
781
+ - `'Usage'`
782
+ - `'Usage · {period}'`
783
+
784
+ **Notices, errors and timestamps**
785
+
786
+ - `'Could not send your decision. Please try again.'`
787
+ - `'No response.'`
788
+ - `'Response ready'`
789
+ - `'Sorry, an error occurred. Please try again.'`
790
+ - `'This decision could not be sent. Reload the chat and try again.'`
791
+ - `'This turn is no longer waiting for a decision.'`
792
+ - `'Unable to connect. Please check the configuration.'`
793
+ - `'Your answer is ready'`
794
+ - `'just now'`
795
+ - `'{agent} has finished'`
796
+ - `'{count}d ago'`
797
+ - `'{count}h ago'`
798
+ - `'{count}m ago'`
470
799
 
471
800
  ## Styling
472
801
 
@@ -478,11 +807,142 @@ import '@filigran/chatbot/styles.css';
478
807
 
479
808
  The component uses Tailwind CSS classes and CSS custom properties for theming. The accent color is applied via `--chat-accent` CSS variable.
480
809
 
810
+ ### Composer toolbar
811
+
812
+ Two toolbar items are **data-driven rather than mode-driven**: they appear only
813
+ when the host serves the route, so the UI can never advertise something the
814
+ backend cannot answer, and there is no mode flag to keep in step.
815
+
816
+ | Endpoint | Default path | Response |
817
+ | --- | --- | --- |
818
+ | Prompt library | `GET {apiBaseUrl}/chat/prompts` | `[{ id, title, content, description? }]` (or `{ prompts: [...] }`) |
819
+ | Quota status | `GET {apiBaseUrl}/chat/quota` | `{ used: number, limit: number \| null, period: string }` |
820
+ | Agent suggestions | `GET {apiBaseUrl}/chat/suggestions?agent_slug=<slug>` | `["..."]` (or `{ suggestions: [...] }`, or objects with `prompt`/`label`/`text`) |
821
+
822
+ Set either to `null` in `apiEndpoints` to hide it. `limit: null` means no
823
+ ceiling — the indicator then shows consumption without a bar. The quota is
824
+ re-read whenever a turn finishes.
825
+
826
+ The welcome screen names the selected agent and shows its own suggestions —
827
+ which is also how switching agent is confirmed: the thread resets to that
828
+ screen, so without it nothing tells you who the next message will reach. When
829
+ the suggestions route is unavailable the host's `promptSuggestions` prop is used
830
+ instead, so the section is never empty.
831
+
832
+ Dictation needs no configuration at all: it uses the browser's own Web Speech
833
+ API, so the mic button appears wherever the API exists and is simply absent
834
+ elsewhere. Finalised phrases are appended to the composer (never replacing a
835
+ draft), interim words preview beside the button, and sending stops the mic so
836
+ the next words cannot land in a composer the user just emptied.
837
+
838
+ ### Context usage
839
+
840
+ The composer also carries a context gauge — a small ring plus percentage
841
+ showing how full the model's context window is for the current conversation,
842
+ the affordance Cursor popularised. It answers one question: *is this
843
+ conversation about to get shorter than I think?* Long chats do not fail at the
844
+ window, they get silently summarised, and a user who cannot see that coming
845
+ reads the summary's gaps as the assistant forgetting.
846
+
847
+ Clicking it opens a breakdown: one stacked bar over the window plus a colour
848
+ legend, so "why is this chat 84 % full" has an answer the user can act on —
849
+ usually "the tool results", sometimes "the MCP tools you wired up".
850
+
851
+ Unlike the items above it needs no endpoint of its own. The backend reports the
852
+ occupancy on the frames it already sends:
853
+
854
+ | Frame | Extra keys | When |
855
+ | --- | --- | --- |
856
+ | `status: "thinking"` | `context_tokens`, `context_window`, `context_breakdown?` | Each agent-loop iteration, so the gauge climbs during a long turn |
857
+ | `done` | same | Closing value for the turn — a turn whose last iteration compacted ends lower than it peaked |
858
+ | Restored message (`POST /chat/sessions`) | same | On the newest assistant message, so a reload or conversation switch restores the gauge |
859
+
860
+ `context_tokens` and `context_window` are required together and the window must
861
+ be positive: a token count with no window to measure it against is not a ratio.
862
+ Anything else is ignored, so a backend that reports nothing simply has no gauge —
863
+ and a host on an older backend needs no configuration change.
864
+
865
+ `context_breakdown` is optional and validated independently, so a malformed one
866
+ costs the gauge its detail but never its number. Keys, all optional, in tokens:
867
+
868
+ | Key | Legend row |
869
+ | --- | --- |
870
+ | `system` | System prompt |
871
+ | `tools` | Tool definitions |
872
+ | `dynamic_tools` | MCP & dynamic tools |
873
+ | `summary` | Summarized conversation |
874
+ | `tool_results` | Tool results |
875
+ | `conversation` | Conversation |
876
+
877
+ Only positive values are shown, so a chat with no digest yet simply has no
878
+ "Summarized conversation" row. **The values must sum to `context_tokens`** — the
879
+ popover shows the lines and the headline together, and a breakdown whose parts
880
+ do not add up reads as broken numbers rather than as rounding. A producer doing
881
+ per-bucket integer division has to distribute the remainder rather than drop it.
882
+
883
+ Colours track the backend's own thresholds, not a design choice: neutral below
884
+ 80 % (where XTM One's agent loop starts distilling older turns into a summary),
885
+ amber past it, red past 95 % (where it emergency-prunes). `used` is a
886
+ char-derived estimate of what the next call will carry — deliberately a forecast
887
+ rather than a receipt for the turn that just ended, which is why the figures are
888
+ prefixed with `~`.
889
+
890
+ Only the `rest` backend carries the figures today.
891
+
892
+ Anything host-specific goes through `composerToolbar`:
893
+
894
+ ```tsx
895
+ <ChatPanel
896
+ composerToolbar={<MySessionToolPicker />}
897
+ {...rest}
898
+ />
899
+ ```
900
+
901
+ ## Markdown Helpers
902
+
903
+ A host that renders assistant prose with its **own** markdown component (its
904
+ design tokens, its icon set) should still normalise the text the same way the
905
+ panel does, rather than maintaining a divergent copy. These pure
906
+ `string → string` helpers ship from the dedicated **`@filigran/chatbot/markdown`**
907
+ entry point — ~2 kB, no React, no CSS. Import them from there and never from the
908
+ package root, which is the full panel bundle:
909
+
910
+ ```tsx
911
+ import {
912
+ hardenNestedCodeFences,
913
+ markdownUrlTransform,
914
+ normalizeImageMarkdown,
915
+ normalizeMarkdownTables,
916
+ wrapBareJson,
917
+ } from '@filigran/chatbot/markdown';
918
+
919
+ const processed = hardenNestedCodeFences(
920
+ normalizeMarkdownTables(wrapBareJson(normalizeImageMarkdown(content))),
921
+ );
922
+
923
+ <ReactMarkdown urlTransform={markdownUrlTransform}>{processed}</ReactMarkdown>;
924
+ ```
925
+
926
+ Order matters: alt-text is flattened before anything reads line structure, and
927
+ the JSON wrap must see the raw payload before fences are hardened.
928
+
929
+ | Helper | Fixes |
930
+ | ------------------------- | -------------------------------------------------------------------------------------------------------------- |
931
+ | `normalizeImageMarkdown` | Multi-line `![alt](url)` alt text, which breaks the image into literal paragraphs plus a stray link |
932
+ | `wrapBareJson` | A whole message that is raw JSON — fenced as ```json so it stays readable and copyable |
933
+ | `normalizeMarkdownTables` | A delimiter row whose column count doesn't match the header, including tables nested in blockquotes / list items |
934
+ | `hardenNestedCodeFences` | A ```markdown block containing its own ``` fences, which shatters the snippet into alternating code and prose |
935
+ | `markdownUrlTransform` | react-markdown's default sanitiser stripping `data:image/*` URIs (code-interpreter charts). Still blocks `javascript:` and non-image `data:` |
936
+
937
+ None of them touch content they don't apply to — an already-valid document is
938
+ returned byte-identical.
939
+
481
940
  ## Peer Dependencies
482
941
 
483
942
  - `react` >= 18
484
943
  - `react-dom` >= 18
485
944
  - `react-markdown` >= 10
945
+ - `remark-breaks` >= 4
486
946
  - `remark-gfm` >= 4
487
947
 
488
948
  ---