@tribepad/themis 1.10.1 → 1.11.2
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/CHANGELOG.md +15 -0
- package/dist/elements/Carousel/index.js +1 -1
- package/dist/elements/Carousel/index.js.map +1 -1
- package/dist/elements/Carousel/index.mjs +1 -1
- package/dist/elements/Carousel/index.mjs.map +1 -1
- package/dist/elements/Filters/index.js +1 -1
- package/dist/elements/Filters/index.js.map +1 -1
- package/dist/elements/Filters/index.mjs +1 -1
- package/dist/elements/Filters/index.mjs.map +1 -1
- package/dist/elements/PageTitle/index.js +1 -1
- package/dist/elements/PageTitle/index.js.map +1 -1
- package/dist/elements/PageTitle/index.mjs +1 -1
- package/dist/elements/PageTitle/index.mjs.map +1 -1
- package/dist/elements/Select/Select.d.ts.map +1 -1
- package/dist/elements/Select/Select.styles.d.ts +11 -0
- package/dist/elements/Select/Select.styles.d.ts.map +1 -1
- package/dist/elements/Select/index.js +1 -1
- package/dist/elements/Select/index.js.map +1 -1
- package/dist/elements/Select/index.mjs +1 -1
- package/dist/elements/Select/index.mjs.map +1 -1
- package/dist/elements/Sidebar/Sidebar.types.d.ts +20 -0
- package/dist/elements/Sidebar/Sidebar.types.d.ts.map +1 -1
- package/dist/elements/Sidebar/SidebarRail.d.ts.map +1 -1
- package/dist/elements/Sidebar/index.d.ts +2 -2
- package/dist/elements/Sidebar/index.d.ts.map +1 -1
- package/dist/elements/Sidebar/index.js +1 -1
- package/dist/elements/Sidebar/index.js.map +1 -1
- package/dist/elements/Sidebar/index.mjs +1 -1
- package/dist/elements/Sidebar/index.mjs.map +1 -1
- package/dist/elements/Table/index.js +1 -1
- package/dist/elements/Table/index.js.map +1 -1
- package/dist/elements/Table/index.mjs +1 -1
- package/dist/elements/Table/index.mjs.map +1 -1
- package/dist/elements/index.js +1 -1
- package/dist/elements/index.js.map +1 -1
- package/dist/elements/index.mjs +1 -1
- package/dist/elements/index.mjs.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +21 -20
- package/src/elements/Sidebar/Sidebar.stories.tsx +25 -0
- package/src/elements/ChatInterface/README.md +0 -408
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tribepad/themis",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.2",
|
|
4
4
|
"description": "Accessible React component library built on React Aria primitives",
|
|
5
5
|
"author": "Tribepad <mbasford@tribepad.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -152,45 +152,45 @@
|
|
|
152
152
|
"@internationalized/date": "^3.12.2",
|
|
153
153
|
"class-variance-authority": "^0.7.1",
|
|
154
154
|
"clsx": "^2.1.1",
|
|
155
|
-
"react-aria": "^3.
|
|
156
|
-
"react-aria-components": "^1.
|
|
157
|
-
"react-stately": "^3.
|
|
155
|
+
"react-aria": "^3.50.0",
|
|
156
|
+
"react-aria-components": "^1.19.0",
|
|
157
|
+
"react-stately": "^3.48.0",
|
|
158
158
|
"tailwind-merge": "^3.6.0"
|
|
159
159
|
},
|
|
160
160
|
"devDependencies": {
|
|
161
161
|
"@eslint/js": "^10.0.1",
|
|
162
162
|
"@size-limit/preset-small-lib": "^12.1.0",
|
|
163
|
-
"@storybook/addon-a11y": "^10.
|
|
164
|
-
"@storybook/addon-mcp": "^0.
|
|
165
|
-
"@storybook/react-vite": "^10.
|
|
163
|
+
"@storybook/addon-a11y": "^10.5.0",
|
|
164
|
+
"@storybook/addon-mcp": "^0.7.0",
|
|
165
|
+
"@storybook/react-vite": "^10.5.0",
|
|
166
166
|
"@tailwindcss/vite": "^4.3.2",
|
|
167
167
|
"@testing-library/jest-dom": "^6.9.1",
|
|
168
168
|
"@testing-library/react": "^16.3.2",
|
|
169
169
|
"@testing-library/user-event": "^14.6.1",
|
|
170
|
-
"@types/node": "^26.1.
|
|
170
|
+
"@types/node": "^26.1.1",
|
|
171
171
|
"@types/react": "^19.2.17",
|
|
172
172
|
"@types/react-dom": "^19.2.3",
|
|
173
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
174
|
-
"@typescript-eslint/parser": "^8.
|
|
175
|
-
"@vitejs/plugin-react": "^6.0.
|
|
173
|
+
"@typescript-eslint/eslint-plugin": "^8.63.0",
|
|
174
|
+
"@typescript-eslint/parser": "^8.63.0",
|
|
175
|
+
"@vitejs/plugin-react": "^6.0.3",
|
|
176
176
|
"@vitest/coverage-v8": "^4.1.10",
|
|
177
|
-
"eslint": "^10.
|
|
177
|
+
"eslint": "^10.7.0",
|
|
178
178
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
|
179
|
-
"eslint-plugin-storybook": "10.
|
|
180
|
-
"globals": "^17.
|
|
181
|
-
"happy-dom": "^20.10.
|
|
179
|
+
"eslint-plugin-storybook": "10.5.0",
|
|
180
|
+
"globals": "^17.7.0",
|
|
181
|
+
"happy-dom": "^20.10.6",
|
|
182
182
|
"jest-axe": "^10.0.0",
|
|
183
183
|
"jsdom": "^29.1.1",
|
|
184
|
-
"lucide-react": "^1.
|
|
184
|
+
"lucide-react": "^1.24.0",
|
|
185
185
|
"motion": "^12.42.2",
|
|
186
186
|
"react": "^19.2.7",
|
|
187
187
|
"react-dom": "^19.2.7",
|
|
188
188
|
"size-limit": "^12.1.0",
|
|
189
|
-
"storybook": "^10.
|
|
189
|
+
"storybook": "^10.5.0",
|
|
190
190
|
"tsup": "^8.5.1",
|
|
191
|
-
"tsx": "^4.23.
|
|
191
|
+
"tsx": "^4.23.1",
|
|
192
192
|
"typescript": "^6.0.3",
|
|
193
|
-
"vite": "^8.1.
|
|
193
|
+
"vite": "^8.1.4",
|
|
194
194
|
"vitest": "^4.1.10",
|
|
195
195
|
"zod": "^4.4.3"
|
|
196
196
|
},
|
|
@@ -201,7 +201,8 @@
|
|
|
201
201
|
"overrides": {
|
|
202
202
|
"postcss@<8.5.10": "^8.5.12",
|
|
203
203
|
"undici@>=7.0.0 <7.28.0": "^7.28.0",
|
|
204
|
-
"@babel/core@<=7.29.0": "^7.29.6"
|
|
204
|
+
"@babel/core@<=7.29.0": "^7.29.6",
|
|
205
|
+
"esbuild@<0.28.1": "^0.28.1"
|
|
205
206
|
}
|
|
206
207
|
},
|
|
207
208
|
"engines": {
|
|
@@ -321,6 +321,31 @@ export const LinkItems: Story = {
|
|
|
321
321
|
),
|
|
322
322
|
};
|
|
323
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Rail items that navigate via a plain `<a href>` (`routing="native"`), bypassing
|
|
326
|
+
* React Aria's router. Use for cross-app/zone links whose href must be literal,
|
|
327
|
+
* and so an app-level capture handler (e.g. an unsaved-changes guard) can
|
|
328
|
+
* intercept the click — a real anchor is required for that.
|
|
329
|
+
*/
|
|
330
|
+
export const NativeRoutingLinkItems: Story = {
|
|
331
|
+
render: () => (
|
|
332
|
+
<div className="flex h-screen bg-[var(--page-background)]">
|
|
333
|
+
<Sidebar>
|
|
334
|
+
<Sidebar.Rail aria-label="Main navigation">
|
|
335
|
+
<Sidebar.RailItem id="home" icon={<Home />} label="Home" href="/" routing="native" isActive />
|
|
336
|
+
<Sidebar.RailItem id="docs" icon={<FileText />} label="Docs" href="/docs" routing="native" />
|
|
337
|
+
<Sidebar.RailItem id="help" icon={<HelpCircle />} label="Help" href="/help" routing="native" />
|
|
338
|
+
</Sidebar.Rail>
|
|
339
|
+
</Sidebar>
|
|
340
|
+
<main className="flex-1 p-6">
|
|
341
|
+
<p className="text-[var(--page-foreground)]">
|
|
342
|
+
These rail items render as plain anchors the browser navigates directly.
|
|
343
|
+
</p>
|
|
344
|
+
</main>
|
|
345
|
+
</div>
|
|
346
|
+
),
|
|
347
|
+
};
|
|
348
|
+
|
|
324
349
|
/** Default rail variant */
|
|
325
350
|
export const DefaultRail: Story = {
|
|
326
351
|
render: () => <SidebarDemo showPopovers railVariant="default" />,
|
|
@@ -1,408 +0,0 @@
|
|
|
1
|
-
# ChatInterface
|
|
2
|
-
|
|
3
|
-
Accessible chatbot UI element for Themis. WCAG 2.2 AAA compliant. Designed to host AI support chatbots — the consumer owns the messages, status, and websocket; ChatInterface renders the transcript, manages keyboard/screen-reader concerns, and emits events.
|
|
4
|
-
|
|
5
|
-
## Quick start
|
|
6
|
-
|
|
7
|
-
```tsx
|
|
8
|
-
import { useState } from 'react';
|
|
9
|
-
import { ChatInterface, type ChatMessage } from '@tribepad/themis/elements/ChatInterface';
|
|
10
|
-
|
|
11
|
-
function SupportChat() {
|
|
12
|
-
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
13
|
-
const [status, setStatus] = useState<'idle' | 'streaming' | 'error'>('idle');
|
|
14
|
-
|
|
15
|
-
const handleSend = (text: string) => {
|
|
16
|
-
setMessages((m) => [
|
|
17
|
-
...m,
|
|
18
|
-
{ id: crypto.randomUUID(), role: 'user', status: 'complete', content: text },
|
|
19
|
-
]);
|
|
20
|
-
// Wire up your AI call / websocket here. As tokens arrive, push a NEW assistant
|
|
21
|
-
// message object — never mutate in place. See "Performance rules" below.
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
return (
|
|
25
|
-
<div style={{ height: 600 }}>
|
|
26
|
-
<ChatInterface
|
|
27
|
-
messages={messages}
|
|
28
|
-
status={status}
|
|
29
|
-
onSendMessage={handleSend}
|
|
30
|
-
/>
|
|
31
|
-
</div>
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
The component fills its container — give it a fixed-height parent (page, panel, modal).
|
|
37
|
-
|
|
38
|
-
## Mental model
|
|
39
|
-
|
|
40
|
-
ChatInterface is **fully controlled**. Every piece of conversation state lives in the consumer; ChatInterface is a presentation layer.
|
|
41
|
-
|
|
42
|
-
| Responsibility | Owner |
|
|
43
|
-
|---|---|
|
|
44
|
-
| `messages[]` array | Consumer |
|
|
45
|
-
| Streaming over websocket | Consumer |
|
|
46
|
-
| Persistence / history | Consumer |
|
|
47
|
-
| Markdown parsing & sanitisation | Consumer (via render prop) |
|
|
48
|
-
| Talking-to-a-human form | Consumer (fires `onEscalate`) |
|
|
49
|
-
| Visual rendering | ChatInterface |
|
|
50
|
-
| Keyboard handling | ChatInterface |
|
|
51
|
-
| Live-region announcements to screen readers | ChatInterface |
|
|
52
|
-
| 44×44 touch targets for buttons | ChatInterface |
|
|
53
|
-
| Auto-scroll, auto-resize input, IME handling | ChatInterface |
|
|
54
|
-
|
|
55
|
-
## Message types
|
|
56
|
-
|
|
57
|
-
`messages` is a discriminated union over `role`. Each entry has `id`, `role`, `status`, optional `createdAt`, and role-specific fields.
|
|
58
|
-
|
|
59
|
-
```ts
|
|
60
|
-
type ChatRole = 'user' | 'assistant' | 'system';
|
|
61
|
-
type ChatMessageStatus = 'complete' | 'streaming' | 'thinking' | 'error';
|
|
62
|
-
|
|
63
|
-
interface ChatUserMessage {
|
|
64
|
-
id: string;
|
|
65
|
-
role: 'user';
|
|
66
|
-
status: 'complete' | 'error'; // user messages never stream/think
|
|
67
|
-
content: string;
|
|
68
|
-
createdAt?: number;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
interface ChatAssistantMessage {
|
|
72
|
-
id: string;
|
|
73
|
-
role: 'assistant';
|
|
74
|
-
status: ChatMessageStatus;
|
|
75
|
-
content: string; // markdown source (consumer renders)
|
|
76
|
-
parts?: ThinkingPart[]; // visible thinking / tool calls
|
|
77
|
-
errorMessage?: string; // free-form error detail
|
|
78
|
-
createdAt?: number;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
interface ChatSystemMessage {
|
|
82
|
-
id: string;
|
|
83
|
-
role: 'system';
|
|
84
|
-
status: 'complete';
|
|
85
|
-
content: ReactNode; // free-form (already-rendered React node)
|
|
86
|
-
announce?: boolean; // default false (visual-only)
|
|
87
|
-
createdAt?: number;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
type ThinkingPart =
|
|
91
|
-
| { kind: 'text'; text: string }
|
|
92
|
-
| { kind: 'tool_call'; name: string; status: 'running' | 'done' | 'error'; summary?: string };
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
### Status states
|
|
96
|
-
|
|
97
|
-
The top-level `status` prop drives the **input area** (Send vs Stop button, disabled state). Each message also has a `status` field describing its own lifecycle.
|
|
98
|
-
|
|
99
|
-
| Top-level `status` | Send button | Stop button | Input |
|
|
100
|
-
|---|---|---|---|
|
|
101
|
-
| `idle` | enabled | hidden | enabled |
|
|
102
|
-
| `streaming` | hidden | enabled | disabled |
|
|
103
|
-
| `error` | enabled | hidden | disabled |
|
|
104
|
-
|
|
105
|
-
Per-message status drives bubble rendering:
|
|
106
|
-
|
|
107
|
-
| `message.status` | Visual |
|
|
108
|
-
|---|---|
|
|
109
|
-
| `complete` | normal bubble |
|
|
110
|
-
| `thinking` | "Thinking…" pill + tool-call rows visible |
|
|
111
|
-
| `streaming` | typing dots indicator |
|
|
112
|
-
| `error` | destructive border + `errorMessage` + Try again / Submit a support request buttons |
|
|
113
|
-
|
|
114
|
-
At most one assistant message can be `streaming` or `thinking` at a time. Dev mode warns if more than one is found.
|
|
115
|
-
|
|
116
|
-
## Markdown rendering — read this carefully
|
|
117
|
-
|
|
118
|
-
ChatInterface treats `message.content` as **opaque** — it does NOT parse or sanitise markdown. Consumers wire up their own renderer via two render props:
|
|
119
|
-
|
|
120
|
-
```tsx
|
|
121
|
-
<ChatInterface
|
|
122
|
-
...
|
|
123
|
-
renderAssistantContent={(message) => /* return ReactNode */}
|
|
124
|
-
renderUserContent={(message) => /* return ReactNode */}
|
|
125
|
-
/>
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
If you don't pass these, content renders as plain text in `<div className="whitespace-pre-wrap">{content}</div>`. Markdown source will be visible verbatim — which is ugly but unambiguous.
|
|
129
|
-
|
|
130
|
-
### Recommended setup with `react-markdown`
|
|
131
|
-
|
|
132
|
-
This is the **minimum safe configuration**. Removing any of these allows XSS via assistant content (which is attacker-influenced if your LLM is exposed to user input):
|
|
133
|
-
|
|
134
|
-
```tsx
|
|
135
|
-
import ReactMarkdown from 'react-markdown';
|
|
136
|
-
import rehypeSanitize from 'rehype-sanitize';
|
|
137
|
-
|
|
138
|
-
const MarkdownRenderer = ({ content }: { content: string }) => (
|
|
139
|
-
<ReactMarkdown
|
|
140
|
-
rehypePlugins={[rehypeSanitize]}
|
|
141
|
-
urlTransform={(url) => {
|
|
142
|
-
// Strip dangerous schemes from links and images.
|
|
143
|
-
const lower = url.toLowerCase().trim();
|
|
144
|
-
if (lower.startsWith('javascript:') || lower.startsWith('data:') || lower.startsWith('vbscript:') || lower.startsWith('file:')) {
|
|
145
|
-
return '';
|
|
146
|
-
}
|
|
147
|
-
return url;
|
|
148
|
-
}}
|
|
149
|
-
components={{
|
|
150
|
-
a: ({ href, children, ...props }) => (
|
|
151
|
-
<a href={href} target="_blank" rel="noopener noreferrer nofollow" {...props}>
|
|
152
|
-
{children}
|
|
153
|
-
</a>
|
|
154
|
-
),
|
|
155
|
-
// Disable images by default — assistant-controlled image URLs are a tracking-pixel /
|
|
156
|
-
// SSRF / CSP risk. Opt back in only if you've configured CSP `img-src` and you
|
|
157
|
-
// trust the assistant's content origin.
|
|
158
|
-
img: () => null,
|
|
159
|
-
}}
|
|
160
|
-
>
|
|
161
|
-
{content}
|
|
162
|
-
</ReactMarkdown>
|
|
163
|
-
);
|
|
164
|
-
|
|
165
|
-
<ChatInterface
|
|
166
|
-
...
|
|
167
|
-
renderAssistantContent={(m) => <MarkdownRenderer content={m.content} />}
|
|
168
|
-
/>
|
|
169
|
-
```
|
|
170
|
-
|
|
171
|
-
### Things you must NOT do inside renderers
|
|
172
|
-
|
|
173
|
-
- **Do not** include `aria-live`, `aria-busy`, or `role="log"` attributes anywhere in your rendered output. ChatInterface owns the live-region semantics; nesting another live region inside the log breaks the AT announcement strategy. (Dev mode warns if it detects this.)
|
|
174
|
-
- **Do not** run side effects (focus management, fetches, state updates). Renderers must be pure.
|
|
175
|
-
- **Do not** emit `data-action="retry"` or `data-action="stop"` / `data-action="send"` / `data-action="jump-to-latest"` / `data-action="escalate"` — these are reserved attributes ChatInterface uses to find its own controls.
|
|
176
|
-
|
|
177
|
-
## Custom input
|
|
178
|
-
|
|
179
|
-
The default input is a Themis-styled multi-line textarea (Enter sends, Shift+Enter newlines). To swap in your own — typically a rich-text editor that outputs markdown — pass `inputComponent`:
|
|
180
|
-
|
|
181
|
-
```tsx
|
|
182
|
-
import { forwardRef, useImperativeHandle, useRef } from 'react';
|
|
183
|
-
import type { ChatInputComponent, ChatInputComponentHandle } from '@tribepad/themis/elements/ChatInterface';
|
|
184
|
-
|
|
185
|
-
const RichInput: ChatInputComponent = forwardRef(function RichInput(
|
|
186
|
-
{ isDisabled, placeholder, onSubmit, onChange, onFocus, onBlur, 'aria-label': ariaLabel },
|
|
187
|
-
ref
|
|
188
|
-
) {
|
|
189
|
-
const editorRef = useRef<HTMLDivElement>(null);
|
|
190
|
-
|
|
191
|
-
useImperativeHandle(ref, (): ChatInputComponentHandle => ({
|
|
192
|
-
focus: () => editorRef.current?.focus(),
|
|
193
|
-
clear: () => { /* reset your editor's value */ },
|
|
194
|
-
submit: () => {
|
|
195
|
-
// Extract the editor's current value AS MARKDOWN and call onSubmit.
|
|
196
|
-
// No-op if empty/whitespace — never call onSubmit with empty string.
|
|
197
|
-
const md = readMarkdownFromEditor();
|
|
198
|
-
if (md.trim()) onSubmit(md);
|
|
199
|
-
},
|
|
200
|
-
setValue: (md) => { /* parse markdown back into your editor's state */ },
|
|
201
|
-
}));
|
|
202
|
-
|
|
203
|
-
return <div ref={editorRef} contentEditable={!isDisabled} aria-label={ariaLabel} ... />;
|
|
204
|
-
});
|
|
205
|
-
```
|
|
206
|
-
|
|
207
|
-
### Custom input contract
|
|
208
|
-
|
|
209
|
-
ChatInterface gives the input these props:
|
|
210
|
-
|
|
211
|
-
- `isDisabled: boolean` — true while top-level status is `streaming` / `error` or consumer-disabled. **Must** be honoured (check synchronously in your submit handler).
|
|
212
|
-
- `placeholder?: string`
|
|
213
|
-
- `maxLength?: number` — best-effort. ChatInterface enforces a defence-in-depth backstop on receive.
|
|
214
|
-
- `'aria-label'?: string`
|
|
215
|
-
- `onSubmit: (markdown: string) => void` — call when user confirms.
|
|
216
|
-
- `onChange?: (markdown: string) => void` — optional; fire on changes for char counts. **Not** used by ChatInterface to drive Send button state.
|
|
217
|
-
- `onFocus?: () => void` / `onBlur?: () => void`
|
|
218
|
-
|
|
219
|
-
The input MUST expose this imperative handle:
|
|
220
|
-
|
|
221
|
-
- `focus(): void`
|
|
222
|
-
- `clear(): void` — called after a successful send.
|
|
223
|
-
- `submit(): void` — called when Send button is pressed; extract value and invoke `onSubmit`. **Must no-op on empty/whitespace.**
|
|
224
|
-
- `setValue(markdown: string): void` — programmatic prefill (suggested-question UX). Should not change focus; cursor should land at end.
|
|
225
|
-
|
|
226
|
-
### Custom input rules
|
|
227
|
-
|
|
228
|
-
- **Do NOT** render your own submit button. Send/Stop is owned by ChatInterface. Use ChatInterface's `inputActions` slot for additional buttons (attach, model picker, etc.).
|
|
229
|
-
- IME composition handling is your responsibility. The default input checks `event.isComposing || event.keyCode === 229` on keydown.
|
|
230
|
-
- If you provide `inputComponent`, you should also provide `renderUserContent` — otherwise user messages render as raw markdown source. Dev mode warns.
|
|
231
|
-
|
|
232
|
-
## Performance rules
|
|
233
|
-
|
|
234
|
-
ChatInterface is heavily memoised. **Following these two rules is the difference between smooth streaming and dropped frames at 30–100Hz token rates:**
|
|
235
|
-
|
|
236
|
-
### 1. Pass new message objects on update
|
|
237
|
-
|
|
238
|
-
When a message's `content`, `parts`, `status`, `errorMessage`, or `createdAt` changes, pass a **new object**:
|
|
239
|
-
|
|
240
|
-
```tsx
|
|
241
|
-
// CORRECT
|
|
242
|
-
setMessages((m) =>
|
|
243
|
-
m.map((msg) =>
|
|
244
|
-
msg.id === streamingId ? { ...msg, content: msg.content + token } : msg
|
|
245
|
-
)
|
|
246
|
-
);
|
|
247
|
-
|
|
248
|
-
// WRONG — mutates in place; bubble will not re-render
|
|
249
|
-
const target = messages.find((m) => m.id === streamingId);
|
|
250
|
-
target.content += token;
|
|
251
|
-
setMessages([...messages]);
|
|
252
|
-
```
|
|
253
|
-
|
|
254
|
-
### 2. Memoize render-prop functions and avatar nodes
|
|
255
|
-
|
|
256
|
-
```tsx
|
|
257
|
-
// CORRECT — stable references; bubble memo holds.
|
|
258
|
-
const renderAssistantContent = useCallback(
|
|
259
|
-
(m: ChatAssistantMessage) => <MarkdownRenderer content={m.content} />,
|
|
260
|
-
[]
|
|
261
|
-
);
|
|
262
|
-
const assistantAvatar = useMemo(() => <Avatar src={...} />, []);
|
|
263
|
-
|
|
264
|
-
<ChatInterface
|
|
265
|
-
renderAssistantContent={renderAssistantContent}
|
|
266
|
-
assistantAvatar={assistantAvatar}
|
|
267
|
-
...
|
|
268
|
-
/>
|
|
269
|
-
|
|
270
|
-
// WRONG — fresh references every render bust the bubble memo,
|
|
271
|
-
// re-running your markdown parser on every completed message every token tick.
|
|
272
|
-
<ChatInterface
|
|
273
|
-
renderAssistantContent={(m) => <MarkdownRenderer content={m.content} />}
|
|
274
|
-
assistantAvatar={<Avatar src={...} />}
|
|
275
|
-
...
|
|
276
|
-
/>
|
|
277
|
-
```
|
|
278
|
-
|
|
279
|
-
When `renderAssistantContent` is stable, ChatInterface caches the rendered ReactNode for each completed message in a bounded LRU (max 200 entries) keyed on `(id, content)` — your parser only runs once per message, not per render.
|
|
280
|
-
|
|
281
|
-
### 3. Optional: wrap completion-flip in `startTransition`
|
|
282
|
-
|
|
283
|
-
For best perceived perf when transitioning a streaming message to `complete`, wrap the final state update in a transition:
|
|
284
|
-
|
|
285
|
-
```tsx
|
|
286
|
-
import { startTransition } from 'react';
|
|
287
|
-
|
|
288
|
-
// Completion arrives:
|
|
289
|
-
startTransition(() => {
|
|
290
|
-
setMessages((m) =>
|
|
291
|
-
m.map((msg) => msg.id === streamingId ? { ...msg, status: 'complete', content: final } : msg)
|
|
292
|
-
);
|
|
293
|
-
setStatus('idle');
|
|
294
|
-
});
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
This lets React keep painting incoming tokens with priority over the heavier completion render.
|
|
298
|
-
|
|
299
|
-
## Accessibility behaviour
|
|
300
|
-
|
|
301
|
-
### Live regions
|
|
302
|
-
|
|
303
|
-
ChatInterface uses a two-region pattern:
|
|
304
|
-
|
|
305
|
-
- **Log region** (`role="log"` + `aria-label` + `tabIndex={0}`) — visual + navigable. **No `aria-live`.** Screen reader users locate it via landmarks/rotor and navigate with arrow keys. Token mutations during streaming do NOT announce.
|
|
306
|
-
- **Status region** (sr-only `role="status"` + `aria-live="polite"`) — single canonical AT announcement channel. FIFO queue with 500ms minimum interval and 1s dedup window.
|
|
307
|
-
|
|
308
|
-
### What gets announced
|
|
309
|
-
|
|
310
|
-
- **Response complete** — `"Assistant replied: <first ~200 chars of rendered text>"` (truncated with "…full response in transcript" if longer). Built from the bubble's rendered DOM `innerText` after commit; Unicode-normalised (control chars / zero-width / bidi overrides stripped) before announcement.
|
|
311
|
-
- **Generation stopped** — `"Generation stopped"` exactly, when top-level status flips `streaming → idle`.
|
|
312
|
-
- **Response failed** — `"Response failed"` exactly, when an assistant message transitions to `status: 'error'`. Never interpolates `errorMessage` (errored bubble carries the full text visibly + via AT navigation). On error transition, focus moves from the now-vanished Stop button to "Try again".
|
|
313
|
-
- **Bounded "still working"** — `"Assistant is still working"` after 3s, 15s, 60s of token silence during streaming. Capped at 3 announcements per stream. Gated on `document.visibilityState === 'visible'`. Suppressible via `progressAnnouncements={false}`.
|
|
314
|
-
|
|
315
|
-
### What does NOT get announced
|
|
316
|
-
|
|
317
|
-
- Per-token streaming text mutations.
|
|
318
|
-
- Thinking-state entry. (No proactive ping; the bounded "still working" timer at 3s+ covers slow responses.)
|
|
319
|
-
- Tool-call transitions (running → done → error).
|
|
320
|
-
- New user messages.
|
|
321
|
-
- Send/Stop/Clear button presses (the underlying actions are user-initiated; AT users already know they pressed the button).
|
|
322
|
-
|
|
323
|
-
### Reduced motion
|
|
324
|
-
|
|
325
|
-
When the OS preference `prefers-reduced-motion: reduce` is on, ChatInterface suppresses motion in three ways:
|
|
326
|
-
|
|
327
|
-
- **Streaming text is not revealed token-by-token.** Mid-stream, the bubble shows a static "Generating response…" placeholder and non-animated typing dots; the full message snaps into place when status flips to `complete`. This avoids layout-shift / scroll-anchor churn that some users find disorienting.
|
|
328
|
-
- **Animations** (typing dots, "Thinking…" pill pulse, running tool-call spinner) are static.
|
|
329
|
-
- **Auto-scroll** ignores the `smooth: true` option — scroll is always instant.
|
|
330
|
-
|
|
331
|
-
Thinking phase is unaffected — it already renders static parts and a (gated) pill, with no tokens streaming.
|
|
332
|
-
|
|
333
|
-
### Keyboard
|
|
334
|
-
|
|
335
|
-
- **Tab** order: log region → input → Send / Stop → input actions slot → escalation buttons.
|
|
336
|
-
- **Enter** sends; **Shift+Enter** inserts a newline.
|
|
337
|
-
- **`sendShortcut: 'mod-enter'`** opt-in: requires Cmd/Ctrl+Enter to send (Enter alone inserts newline).
|
|
338
|
-
- IME composition is respected — Enter while composing does not send.
|
|
339
|
-
- The log region is keyboard-focusable so users can arrow-scroll the transcript.
|
|
340
|
-
- The "Thoughts" disclosure under thinking-equipped messages is a native `<details>`/`<summary>` — keyboard-toggleable, AT-friendly without ARIA.
|
|
341
|
-
|
|
342
|
-
## Imperative handle
|
|
343
|
-
|
|
344
|
-
```tsx
|
|
345
|
-
import { useRef } from 'react';
|
|
346
|
-
import type { ChatInterfaceHandle } from '@tribepad/themis/elements/ChatInterface';
|
|
347
|
-
|
|
348
|
-
const ref = useRef<ChatInterfaceHandle>(null);
|
|
349
|
-
|
|
350
|
-
ref.current?.focusInput(); // moves focus into the textarea / custom input
|
|
351
|
-
ref.current?.scrollToBottom({ smooth: true }); // jumps the log to latest (smooth respects reduced-motion)
|
|
352
|
-
```
|
|
353
|
-
|
|
354
|
-
`scrollToMessage` is intentionally not exposed in v1 — there's no concrete consumer use case yet. Open a PR if you need it.
|
|
355
|
-
|
|
356
|
-
## Props reference
|
|
357
|
-
|
|
358
|
-
| Prop | Type | Default | Notes |
|
|
359
|
-
|---|---|---|---|
|
|
360
|
-
| `messages` | `ChatMessage[]` | required | Pass new objects on update — see Performance rules. |
|
|
361
|
-
| `status` | `'idle' \| 'streaming' \| 'error'` | required | |
|
|
362
|
-
| `onSendMessage` | `(text: string) => void` | required | Synchronous. ChatInterface enforces `maxInputLength` truncation before calling. |
|
|
363
|
-
| `onStopGeneration` | `() => void` | — | Required when `status` can be `'streaming'`. |
|
|
364
|
-
| `onRetry` | `(messageId: string) => void` | — | Renders Try again button on errored bubbles. |
|
|
365
|
-
| `onEscalate` | `() => void` | — | Renders Submit-a-support-request button on errored bubbles. |
|
|
366
|
-
| `renderAssistantContent` | `(m: ChatAssistantMessage) => ReactNode` | plain text | Pure render function. Memoize for cache to work. |
|
|
367
|
-
| `renderUserContent` | `(m: ChatUserMessage) => ReactNode` | plain text | |
|
|
368
|
-
| `inputComponent` | `ChatInputComponent` | built-in textarea | Custom input. See Custom input section. |
|
|
369
|
-
| `assistantAvatar` | `ReactNode` | — | Memoize to avoid memo-busting. |
|
|
370
|
-
| `userAvatar` | `ReactNode` | — | Memoize to avoid memo-busting. |
|
|
371
|
-
| `header` | `ReactNode` | — | Slot above the log. |
|
|
372
|
-
| `emptyState` | `ReactNode` | — | Rendered inside the log when `messages.length === 0`. |
|
|
373
|
-
| `inputActions` | `ReactNode` | — | Buttons beside Send/Stop (attach, model picker, etc.). |
|
|
374
|
-
| `placeholder` | `string` | `'Type a message…'` | |
|
|
375
|
-
| `isDisabled` | `boolean` | `false` | Forces input + Send disabled. |
|
|
376
|
-
| `maxInputLength` | `number` | — | Defence-in-depth truncation at the ChatInterface boundary. |
|
|
377
|
-
| `sendShortcut` | `'enter' \| 'mod-enter'` | `'enter'` | Default-input only. Custom inputs choose their own keybindings. |
|
|
378
|
-
| `progressAnnouncements` | `boolean` | `true` | When false, suppresses bounded "still working" pings. |
|
|
379
|
-
| `completionAnnouncementMaxChars` | `number` | `200` | Clamped to [50, 1000]; out-of-range values dev-warn. |
|
|
380
|
-
| `aria-label` | `string` | `'Chat'` | Region label. |
|
|
381
|
-
| `logLabel` | `string` | `'Conversation transcript'` | |
|
|
382
|
-
| `thinkingDisclosureLabel` | `string` | `'Thoughts'` | i18n hook. |
|
|
383
|
-
|
|
384
|
-
## Limitations (v1)
|
|
385
|
-
|
|
386
|
-
- **Long conversations** — `content-visibility: auto` skips layout for offscreen bubbles, which keeps things smooth up to ~1000–2000 messages on a mid-range device. Beyond that, consider paginating in your consumer state. Full virtualisation is a v2 candidate when concrete demand arrives. Dev mode warns when `messages.length > 1000`.
|
|
387
|
-
- **Multi-part assistant messages** — assistant `content` is a single string. Interleaved `[text, tool_call, text]` content (common in agent-style assistants) is not modelled in v1; tool calls are visible separately via `parts[]` but inline-with-text is not. Parts-first model is a v2 candidate, non-breaking when added.
|
|
388
|
-
- **Attachments** — user messages are text-only. File/image attachments need to be added via the `inputActions` slot for the upload UI and sent through your own `onSendMessage` channel; the rendered version goes through `renderUserContent`. Native attachment data on user messages is a v2 candidate.
|
|
389
|
-
- **System message renderers** — system messages bypass `renderAssistantContent` / `renderUserContent`. Their `content` is `ReactNode`, so render whatever you want — but they're not run through the markdown render pipeline.
|
|
390
|
-
- **Citations** — RAG-style `[1][2]` source markers should be embedded in the assistant's markdown content as standard markdown links. There's no first-class citation type in v1.
|
|
391
|
-
- **Multi-AI persona avatars** — `assistantAvatar` is a single prop, not per-message. If your conversation has multiple assistants, you'll need to encode persona via `renderAssistantContent` styling.
|
|
392
|
-
- **Message editing / deletion UX** — not modelled. The data model supports it (consumer can omit a deleted message from `messages`), but the bubble itself doesn't render edit/delete controls. Add via custom render prop if needed.
|
|
393
|
-
- **Copy / regenerate buttons** — not provided. Add via your `renderAssistantContent` render prop.
|
|
394
|
-
- **Reactions** — not modelled.
|
|
395
|
-
|
|
396
|
-
## Trusted Types compatibility
|
|
397
|
-
|
|
398
|
-
ChatInterface is Trusted Types compatible. It does not assign user-controlled strings to `innerHTML`, `outerHTML`, `insertAdjacentHTML`, `setHTMLUnsafe`, `document.write`, `Range.createContextualFragment`, or `DOMParser.parseFromString`-with-HTML. If you adopt `react-markdown` for rendering, verify your specific configuration is TT-compatible (`rehype-raw` is not).
|
|
399
|
-
|
|
400
|
-
## Storybook examples
|
|
401
|
-
|
|
402
|
-
`pnpm storybook`, then look at `Elements/ChatInterface`. Stories cover:
|
|
403
|
-
|
|
404
|
-
- Empty / Plain / Streaming / Thinking / ErrorState
|
|
405
|
-
- BYO Markdown (with the recommended secure configuration)
|
|
406
|
-
- Custom input (contenteditable RTE)
|
|
407
|
-
- Long conversation (200 turns)
|
|
408
|
-
- Reduced motion
|