inline-chat-kit 0.49.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/CHANGELOG.md +2111 -0
- package/LICENSE +21 -0
- package/README.md +1430 -0
- package/dist/AnswerActions/AnswerActions.d.ts +35 -0
- package/dist/Approval/Approval.d.ts +42 -0
- package/dist/Artifact/ArtifactCard.d.ts +45 -0
- package/dist/Artifact/ArtifactPane.d.ts +50 -0
- package/dist/Artifact/ChatLayout.d.ts +35 -0
- package/dist/Artifact/useArtifacts.d.ts +21 -0
- package/dist/Attachments/Attachments.d.ts +50 -0
- package/dist/Branch/Branch.d.ts +28 -0
- package/dist/Button/Button.d.ts +23 -0
- package/dist/ChainOfThought/ChainOfThought.d.ts +49 -0
- package/dist/ChatHeader/ChatHeader.d.ts +89 -0
- package/dist/ChatInput/AddCardsOverlay.d.ts +11 -0
- package/dist/ChatInput/ChatInput.d.ts +118 -0
- package/dist/ChatInput/HoverActionsRow.d.ts +13 -0
- package/dist/ChatInput/MorphGlyph.d.ts +15 -0
- package/dist/ChatTurnRow/ChatTurnRow.d.ts +115 -0
- package/dist/Chip/Chip.d.ts +7 -0
- package/dist/CodeBlock/CodeBlock.d.ts +24 -0
- package/dist/CodeBlock/grammars.d.ts +16 -0
- package/dist/CodeBlock/highlight.d.ts +38 -0
- package/dist/Context/Context.d.ts +36 -0
- package/dist/Conversation/Conversation.d.ts +64 -0
- package/dist/CustomCursor/CustomCursor.d.ts +1 -0
- package/dist/EmptyState/EmptyState.d.ts +24 -0
- package/dist/GlassButton/GlassButton.d.ts +19 -0
- package/dist/InlineCitation/InlineCitation.d.ts +30 -0
- package/dist/Loader/Loader.d.ts +23 -0
- package/dist/QuestionCard/QuestionCard.d.ts +32 -0
- package/dist/QuestionCard/parts.d.ts +84 -0
- package/dist/QuestionCard/types.d.ts +54 -0
- package/dist/QuestionGroup/QuestionGroup.d.ts +87 -0
- package/dist/Reasoning/Reasoning.d.ts +37 -0
- package/dist/ReplyThreadPopup/ReplyThreadPopup.d.ts +18 -0
- package/dist/Sources/Sources.d.ts +46 -0
- package/dist/SystemMessage/SystemMessage.d.ts +39 -0
- package/dist/TaskList/TaskList.d.ts +42 -0
- package/dist/TextHighlighter/TextHighlighter.d.ts +17 -0
- package/dist/Tool/Tool.d.ts +41 -0
- package/dist/announce/announce.d.ts +27 -0
- package/dist/disclosure/DisclosureBody.d.ts +22 -0
- package/dist/disclosure/DisclosureHeader.d.ts +42 -0
- package/dist/disclosure/useDisclosure.d.ts +30 -0
- package/dist/duration/formatDuration.d.ts +9 -0
- package/dist/grammars-B19jp7qm.js +3181 -0
- package/dist/grammars-B19jp7qm.js.map +1 -0
- package/dist/index.d.ts +80 -0
- package/dist/inline-chat-kit.css +2 -0
- package/dist/inline-chat-kit.js +5314 -0
- package/dist/inline-chat-kit.js.map +1 -0
- package/dist/markdown/parse.d.ts +105 -0
- package/dist/markdown/parseMarkdown.d.ts +47 -0
- package/dist/radiusCorrection/useCorrectedRadius.d.ts +24 -0
- package/dist/reducedMotion/reducedMotion.d.ts +3 -0
- package/dist/stateGlyph/StateGlyph.d.ts +23 -0
- package/dist/turnParts/turnParts.d.ts +156 -0
- package/dist/useChatTurns/useChatTurns.d.ts +127 -0
- package/dist/voice/useVoiceInput.d.ts +79 -0
- package/package.json +95 -0
- package/theming.md +234 -0
package/README.md
ADDED
|
@@ -0,0 +1,1430 @@
|
|
|
1
|
+
# inline-chat-kit
|
|
2
|
+
|
|
3
|
+
An inline AI chat experience for React. The input **is** the message: when you
|
|
4
|
+
send, the pill you typed into morphs into the bubble that holds your text, and
|
|
5
|
+
the answer streams in below it. No separate composer, no jump cut.
|
|
6
|
+
|
|
7
|
+
Ships the surrounding pieces too — a turn row, a header for the conversation,
|
|
8
|
+
hover actions on each bubble, copy and feedback under every answer, markdown
|
|
9
|
+
answers you can draw on with a marker,
|
|
10
|
+
syntax-highlighted code blocks, a scroll container that keeps up with an
|
|
11
|
+
answer, and reply-in-thread popups.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
**Not on npm yet.** Build the tarball out of the repo and install that:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
git clone git@github.com:bogstebole/chat-experience.git
|
|
19
|
+
cd chat-experience && npm install && npm run pack:kit
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install /path/to/chat-experience/packages/inline-chat-kit/inline-chat-kit.tgz motion lucide-react
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Once it is published the first command goes away and the second becomes
|
|
27
|
+
`npm install inline-chat-kit motion lucide-react`. Nothing else changes.
|
|
28
|
+
|
|
29
|
+
`react`, `react-dom`, `motion` and `lucide-react` are peer dependencies — the
|
|
30
|
+
kit uses whatever copy your app already has.
|
|
31
|
+
|
|
32
|
+
## Use
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
import { useState } from "react";
|
|
36
|
+
import { ChatInput, type ChatInputState } from "inline-chat-kit";
|
|
37
|
+
import "inline-chat-kit/styles.css";
|
|
38
|
+
|
|
39
|
+
function Composer() {
|
|
40
|
+
const [value, setValue] = useState("");
|
|
41
|
+
const [state, setState] = useState<ChatInputState>("idle");
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<ChatInput
|
|
45
|
+
state={state}
|
|
46
|
+
value={value}
|
|
47
|
+
onChange={(v) => {
|
|
48
|
+
setValue(v);
|
|
49
|
+
setState(v ? "typing" : "idle");
|
|
50
|
+
}}
|
|
51
|
+
onSubmit={async (v) => {
|
|
52
|
+
setState("responding");
|
|
53
|
+
await send(v);
|
|
54
|
+
setState("resting");
|
|
55
|
+
}}
|
|
56
|
+
onStop={() => setState("resting")}
|
|
57
|
+
placeholder="Ask me anything…"
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The stylesheet import is required — the components are CSS Modules and the
|
|
64
|
+
bundled sheet carries every class they reference.
|
|
65
|
+
|
|
66
|
+
## Wiring it to your model
|
|
67
|
+
|
|
68
|
+
`ChatInput` renders one turn. `useChatTurns` owns the conversation — the turn
|
|
69
|
+
list, the request in flight, and the reveal — and asks your app for the answers.
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
import { useChatTurns } from "inline-chat-kit";
|
|
73
|
+
|
|
74
|
+
const { turns, setDraft, submit, stop } = useChatTurns({
|
|
75
|
+
onSend: async function* (message, { signal }) {
|
|
76
|
+
const response = await fetch("/api/chat", {
|
|
77
|
+
method: "POST",
|
|
78
|
+
body: JSON.stringify({ message }),
|
|
79
|
+
signal,
|
|
80
|
+
});
|
|
81
|
+
for await (const chunk of response.body!.pipeThrough(new TextDecoderStream())) {
|
|
82
|
+
yield chunk;
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`onSend` may return a string, a promise of one, or an async iterable of deltas.
|
|
89
|
+
Return a string and the kit reveals it at a readable pace; return deltas and it
|
|
90
|
+
shows them as they land. `signal` aborts when the reader presses stop.
|
|
91
|
+
|
|
92
|
+
The kit never invents an answer. There is no canned fallback anywhere in the
|
|
93
|
+
package — if your handler returns nothing, nothing is what appears.
|
|
94
|
+
|
|
95
|
+
### Rendering the turns
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
{turns.map((turn, i) => {
|
|
99
|
+
const isActive = i === turns.length - 1 && (turn.state === "idle" || turn.state === "typing");
|
|
100
|
+
return (
|
|
101
|
+
<ChatTurnRow
|
|
102
|
+
key={turn.id}
|
|
103
|
+
turn={turn}
|
|
104
|
+
isActiveInput={isActive}
|
|
105
|
+
onDraft={setDraft}
|
|
106
|
+
onSubmit={submit}
|
|
107
|
+
onStop={stop}
|
|
108
|
+
onEdit={beginEdit}
|
|
109
|
+
onCancelEdit={cancelEdit}
|
|
110
|
+
/>
|
|
111
|
+
);
|
|
112
|
+
})}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`ChatTurnRow` is already memoised, and that memo is load-bearing. The hook
|
|
116
|
+
leaves untouched turns referentially identical when it rewrites one of them,
|
|
117
|
+
but that only pays off if the rows act on it — otherwise every turn re-renders
|
|
118
|
+
on every frame of every answer, and the cost grows with the conversation.
|
|
119
|
+
Measured before the memo existed: streaming one answer produced 366 DOM
|
|
120
|
+
mutations inside an unrelated, already-finished turn. With it, zero.
|
|
121
|
+
|
|
122
|
+
Which is why the callbacks take the turn's id rather than being closed over per
|
|
123
|
+
row. Pass the hook's own functions straight through — they are stable. An arrow
|
|
124
|
+
created during render is not, and hands the memo a new prop every time.
|
|
125
|
+
|
|
126
|
+
If you write your own row instead, wrap it in `React.memo` and do the same.
|
|
127
|
+
|
|
128
|
+
## The four states
|
|
129
|
+
|
|
130
|
+
`ChatInput` is fully controlled. You own `state` and drive the whole
|
|
131
|
+
choreography by moving between these values:
|
|
132
|
+
|
|
133
|
+
| `state` | What the input looks like |
|
|
134
|
+
| --- | --- |
|
|
135
|
+
| `idle` | Empty pill, placeholder showing |
|
|
136
|
+
| `typing` | Text in the pill, send glyph revealed |
|
|
137
|
+
| `responding` | Pill has become a glass bubble, glyph is a stop square |
|
|
138
|
+
| `resting` | Settled bubble, hover actions available |
|
|
139
|
+
|
|
140
|
+
## API
|
|
141
|
+
|
|
142
|
+
### `<ChatInput>`
|
|
143
|
+
|
|
144
|
+
| Prop | Type | Notes |
|
|
145
|
+
| --- | --- | --- |
|
|
146
|
+
| `state` | `ChatInputState` | Required. See table above |
|
|
147
|
+
| `value` | `string` | Required. Controlled value |
|
|
148
|
+
| `onChange` | `(value: string) => void` | Required |
|
|
149
|
+
| `onSubmit` | `(value: string) => void` | Required. Fires on Enter (Shift+Enter inserts a newline) |
|
|
150
|
+
| `onStop` | `() => void` | Stop button while `responding` |
|
|
151
|
+
| `onAdd` | `() => void` | The `+` button; opens the radial attachment fan |
|
|
152
|
+
| `onCopy` | `(value: string) => void` | Hover action on a resting bubble |
|
|
153
|
+
| `onEdit` | `(value: string) => void` | Hover action; put the turn back into `typing` |
|
|
154
|
+
| `onCancelEdit` | `() => void` | Paired with `isEditing` |
|
|
155
|
+
| `isEditing` | `boolean` | Shows save/cancel instead of send |
|
|
156
|
+
| `placeholder` | `string` | |
|
|
157
|
+
| `animationConfig` | `InlineAnimConfig` | Spring and stagger overrides — see below |
|
|
158
|
+
| `style` | `React.CSSProperties` | |
|
|
159
|
+
|
|
160
|
+
`ref` exposes `focus()`, `setValue(v)` and `getValue()` via `ChatInputHandle`.
|
|
161
|
+
|
|
162
|
+
### `<ChatTurnRow>`
|
|
163
|
+
|
|
164
|
+
One turn: the question as a composer that has become a bubble, and the answer
|
|
165
|
+
beneath it. Not `Message`, because it is not one — the user half is a live
|
|
166
|
+
input that morphs into its own bubble rather than a record of what was typed.
|
|
167
|
+
|
|
168
|
+
| Prop | Type | Default | Notes |
|
|
169
|
+
| --- | --- | --- | --- |
|
|
170
|
+
| `turn` | `ChatTurn` | | Required. Straight from `useChatTurns` |
|
|
171
|
+
| `isActiveInput` | `boolean` | `false` | This row owns the live composer |
|
|
172
|
+
| `inputRef` | `Ref<ChatInputHandle>` | `null` | For `focus()` |
|
|
173
|
+
| `placeholder` | `string` | | |
|
|
174
|
+
| `animationConfig` | `InlineAnimConfig` | | Passed to the input |
|
|
175
|
+
| `entranceDelay` | `number` | `0` | Stagger, in seconds |
|
|
176
|
+
| `selectionMode` | `"marker" \| "precise"` | `"marker"` | Passed to the highlighter |
|
|
177
|
+
| `questionAlign` | `"end" \| "stretch"` | `"end"` | Where the composer sits — see below |
|
|
178
|
+
| `onDraft` | `(id, value) => void` | | |
|
|
179
|
+
| `onSubmit` | `(id, value) => void` | | |
|
|
180
|
+
| `onStop` | `() => void` | | |
|
|
181
|
+
| `onEdit` | `(id) => void` | | |
|
|
182
|
+
| `onCancelEdit` | `(id) => void` | | |
|
|
183
|
+
| `onCopy` | `(value) => void` | writes to the clipboard | |
|
|
184
|
+
| `onHighlight` | `(turnId, text) => void` | | A passage was marked |
|
|
185
|
+
| `onReplyInThread` | `(text, rect) => void` | | Open a thread on the marked passage |
|
|
186
|
+
| `onRegenerate` | `(id) => void` | | Draws the regenerate button |
|
|
187
|
+
| `onFeedback` | `(id, verdict) => void` | | Draws the thumbs |
|
|
188
|
+
| `feedback` | `"up" \| "down" \| null` | `null` | Which one is lit |
|
|
189
|
+
| `answerActions` | `boolean` | `true` | Leave the row out |
|
|
190
|
+
|
|
191
|
+
The live row carries **`data-active-input`**, so a page can style the composer
|
|
192
|
+
without knowing which turn it is. The case it exists for: a gradient fading the
|
|
193
|
+
conversation off the bottom edge cannot tell the answer from the box you type
|
|
194
|
+
in, and a washed-out composer reads as one you are not allowed to use. Lift it
|
|
195
|
+
over the fade:
|
|
196
|
+
|
|
197
|
+
```css
|
|
198
|
+
.feed [data-active-input] { position: relative; z-index: 6; }
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
`questionAlign` is `end` by default, because the composer is about to become
|
|
203
|
+
the reader's own bubble and those sit right. `stretch` fills the row instead,
|
|
204
|
+
which is what an *opening* composer wants: on an empty conversation it is not a
|
|
205
|
+
message on its way, it is the box under the openers — and a pill floating at
|
|
206
|
+
the right edge of a centred block reads as unrelated to the block.
|
|
207
|
+
|
|
208
|
+
Every callback is optional; a row with none of them renders and can be marked.
|
|
209
|
+
The row carries `id="turn-<id>"` so a host can scroll to one, and `aria-busy`
|
|
210
|
+
while its answer is arriving.
|
|
211
|
+
|
|
212
|
+
### Two rules everything follows
|
|
213
|
+
|
|
214
|
+
**Surfaces nest in three steps.** A **ground** is what a group of things sits
|
|
215
|
+
on, a **card** is an opaque panel raised on it, and an **inset** is a row set
|
|
216
|
+
into the card. `--ick-ground`, `--ick-card`, `--ick-inset` — a question group,
|
|
217
|
+
a tool call and an approval all use the same three.
|
|
218
|
+
|
|
219
|
+
The card is paper and **opaque**, which matters more than it sounds: a
|
|
220
|
+
translucent panel picks up whatever it is sitting on, which is how a tool call
|
|
221
|
+
inside an approval came out pale green, back when the approval was tinted. In the dark the card is a lifted grey,
|
|
222
|
+
because `--ick-surface` there is the page itself and a card painted with it
|
|
223
|
+
would sink into the ground rather than sit on it — and that grey is **mixed,
|
|
224
|
+
not washed**. It was nine percent white for a while, which looks identical on
|
|
225
|
+
the page and olive over a tint. The rule held in the light,
|
|
226
|
+
where the card is `#fff`, and was quietly false in the dark. An inset is mixed
|
|
227
|
+
from the card for the same reason. A test now reads the values rather than the
|
|
228
|
+
wiring: an alpha channel on either is the fault, whatever the number.
|
|
229
|
+
|
|
230
|
+
The **ground** stays a wash, deliberately. It is the bottom of the stack, so
|
|
231
|
+
there is nothing under it to pick up but the page.
|
|
232
|
+
|
|
233
|
+
**Stacked boxes are separated by surface and gap, not by a rule.** A row is an
|
|
234
|
+
inset panel with space around it, and nothing is underlined. A tool call used
|
|
235
|
+
to draw a line under its header and a code block one under its label, which
|
|
236
|
+
with two sections open made three stacked rules in a component the size of a
|
|
237
|
+
paragraph — each saying again what the surface had already said.
|
|
238
|
+
|
|
239
|
+
**All three steps, every time.** A question is a group (ground) holding a card
|
|
240
|
+
holding rows; a tool call is a ground holding a card holding its input and
|
|
241
|
+
output; an approval is a ground holding a card holding what it is asking about. The tool used to be one box, and the box was the grey one — the
|
|
242
|
+
same three surfaces stacked backwards, which on its own reads fine and beside a
|
|
243
|
+
question card reads as a different system.
|
|
244
|
+
|
|
245
|
+
The approval had the same shape of fault from the other end: it was a tinted
|
|
246
|
+
box and the only thing on paper was the tool call, so the title and the buttons
|
|
247
|
+
sat straight on the ground. A question card puts its header *and* its Next
|
|
248
|
+
button on the card, and this now does the same.
|
|
249
|
+
|
|
250
|
+
**And the tint is gone**, which is worth its own paragraph. An approval *is* a
|
|
251
|
+
question — "may I run this" is the same species as "who else lives in the
|
|
252
|
+
household": the assistant asking, blocking, with an answer. It should look like
|
|
253
|
+
one. The tint was there on the reasoning that this is the one thing in an
|
|
254
|
+
answer allowed to look like a box that stops you; the reasoning is real and
|
|
255
|
+
colour was the wrong instrument. Everywhere else the marker is a small mark
|
|
256
|
+
pointed at one thing and means **this one** — the badge on the option you
|
|
257
|
+
picked, the stroke you drew, a citation, a source's number. A wash across a
|
|
258
|
+
whole box meaning "this kind of box" is a second job for the one accent, and at
|
|
259
|
+
8% it was too weak to be an alarm and too strong to be nothing. In the dark it
|
|
260
|
+
came out olive, which is not in the palette at all.
|
|
261
|
+
|
|
262
|
+
What says "this is unanswered" is that the box has **buttons in it**, and
|
|
263
|
+
nothing else in a turn does. They leave when it settles, the way a question
|
|
264
|
+
card's rows leave when it folds — and like a folded question, the approval
|
|
265
|
+
keeps its paper and its shadow rather than changing into something else.
|
|
266
|
+
|
|
267
|
+
A component that is *given* a surface takes the next step down rather than
|
|
268
|
+
repeating one. Inside an `<Approval>` the tool call is a **row on its card** —
|
|
269
|
+
inset, at the row corner, no ground of its own and no shadow. Two grounds is
|
|
270
|
+
one more than there is depth for, and so is two cards.
|
|
271
|
+
|
|
272
|
+
A rule down the **side** is a different device and stays: `<Reasoning>` uses one
|
|
273
|
+
to mark an aside, and `<ChainOfThought>`'s says each step follows from the one
|
|
274
|
+
above it.
|
|
275
|
+
|
|
276
|
+
**Corners are concentric, on one chain.** A box's corner is the corner of the
|
|
277
|
+
thing inside it plus the gap between them, and there are four steps for the
|
|
278
|
+
whole kit:
|
|
279
|
+
|
|
280
|
+
```
|
|
281
|
+
--ick-nest-inner 8 a badge, a chip, a fenced value
|
|
282
|
+
--ick-nest-row 16 an inset panel: an option, a field, a block of output
|
|
283
|
+
--ick-nest-card 24 paper, lifted off the ground
|
|
284
|
+
--ick-nest-ground 40 what the paper stands on
|
|
285
|
+
|
|
286
|
+
gaps: --ick-nest-pad 8 inside a card and a row, --ick-nest-ground-pad 16 around
|
|
287
|
+
a card on its ground
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Get it wrong and the two curves sit at different insets with the same radius,
|
|
291
|
+
leaving a crescent between them — the thing that reads as "not quite fitting"
|
|
292
|
+
without anybody being able to name it. It is written as `calc()` off a seed
|
|
293
|
+
rather than as a list of numbers, so changing a padding moves the corners with
|
|
294
|
+
it, and a test does the same sum for every nesting in the kit.
|
|
295
|
+
|
|
296
|
+
A component **picks a step** rather than deriving its own. Each used to derive:
|
|
297
|
+
a tool call was a 6px block in a 14px card on a 22px ground, beside a question's
|
|
298
|
+
8 / 16 / 24 / 40. Both were internally concentric, and the two were nothing like
|
|
299
|
+
each other — the same three surfaces at two scales, which reads as two systems
|
|
300
|
+
rather than as one object holding different things. So a tool call and a
|
|
301
|
+
question folded shut are now the same row, to the pixel: 40 tall, 16 in from the
|
|
302
|
+
left, the glyph riding in the box a badge sits in. There is a story that stacks
|
|
303
|
+
the two on one ground, and a test that compares every step of the two chains.
|
|
304
|
+
|
|
305
|
+
A short box takes the corner it can. A row folded shut is 40px tall and a 24px
|
|
306
|
+
corner on it clamps to a pill — both of them, equally, which is the point.
|
|
307
|
+
|
|
308
|
+
A `<CodeBlock>` reads `--ick-code-radius`, so a box that nests one repoints it
|
|
309
|
+
at the step its own corner implies — the way `<Tool>` hands it the row's. It
|
|
310
|
+
reads `--ick-code-pad` the same way, for its inner padding: `<Tool>` takes that
|
|
311
|
+
down to 8 so a panel's label lands on the column its header's glyph is in, and
|
|
312
|
+
every section of a tool call — code or text — starts on that one column.
|
|
313
|
+
|
|
314
|
+
### `<Tool>`
|
|
315
|
+
|
|
316
|
+
One tool call on the way to an answer: what was run, what with, what came
|
|
317
|
+
back.
|
|
318
|
+
|
|
319
|
+
```tsx
|
|
320
|
+
<Tool
|
|
321
|
+
name="search_web"
|
|
322
|
+
state="done"
|
|
323
|
+
summary="3 results"
|
|
324
|
+
duration={412}
|
|
325
|
+
input={{ query: "weather in Belgrade", limit: 3 }}
|
|
326
|
+
output={results}
|
|
327
|
+
/>
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
| Prop | Type | Notes |
|
|
331
|
+
| --- | --- | --- |
|
|
332
|
+
| `name` | `string` | Set in mono: it is an identifier, not prose |
|
|
333
|
+
| `state` | `"pending" \| "running" \| "done" \| "error"` | Default `"done"` |
|
|
334
|
+
| `summary` | `ReactNode` | A sentence for what it did |
|
|
335
|
+
| `input` / `output` | `unknown` | See below |
|
|
336
|
+
| `error` | `ReactNode` | Drawn instead of the output |
|
|
337
|
+
| `duration` | `number` | In ms. Shown once it has finished |
|
|
338
|
+
| `open` / `defaultOpen` / `onOpenChange` | | Controlled or not |
|
|
339
|
+
| `labels` | `Partial<Record<…, string>>` | `input`, `output`, `error`, and a word per state |
|
|
340
|
+
|
|
341
|
+
**Shut by default**, because most of the time nobody cares — and **open when it
|
|
342
|
+
failed**, because an error nobody can see has not been reported. That is
|
|
343
|
+
derived from the state rather than forced by an effect, so a call that fails
|
|
344
|
+
later opens itself, while one somebody deliberately shut stays shut.
|
|
345
|
+
|
|
346
|
+
**What you give it decides how it is drawn.** A string is text — wrapping
|
|
347
|
+
`"Belgrade, 24°C"` in a fence puts it in quotes with its newlines spelled out,
|
|
348
|
+
which is worse than reading it. An object is JSON, in a `CodeBlock` with its
|
|
349
|
+
copy button. An element is left alone, so anything you want drawn some other
|
|
350
|
+
way you draw yourself.
|
|
351
|
+
|
|
352
|
+
The state is never carried by colour alone: the glyph changes shape, and the
|
|
353
|
+
row says which state it is in in words only a screen reader hears.
|
|
354
|
+
|
|
355
|
+
### What a turn carries: `TurnPart`
|
|
356
|
+
|
|
357
|
+
An answer used to be one string, and everything the agent tier draws had
|
|
358
|
+
nowhere to live. A turn now carries `parts` alongside `ai`, and a `SendHandler`
|
|
359
|
+
streams them in among the prose:
|
|
360
|
+
|
|
361
|
+
```tsx
|
|
362
|
+
const send: SendHandler = async function* (message) {
|
|
363
|
+
yield { kind: "reasoning", id: "r", text: "Two numbers matter here.", state: "thinking" };
|
|
364
|
+
yield { kind: "tool", id: "t", name: "search_web", state: "running", input: { query } };
|
|
365
|
+
yield { kind: "tool", id: "t", name: "search_web", state: "done", output, duration: 412 };
|
|
366
|
+
yield { kind: "reasoning", id: "r", state: "done" };
|
|
367
|
+
yield "The Higgs weighs about 125 GeV."; // a delta of the answer's prose
|
|
368
|
+
};
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
A streamed item is either a **string** — appended to `ai`, as before — or a
|
|
372
|
+
**`TurnPart`**, merged into `turn.parts` **by its `id`**. The merge is shallow
|
|
373
|
+
and that is the point: send the state change on its own and the text that
|
|
374
|
+
arrived before it is still there. `<ChatTurnRow>` draws each kind with the
|
|
375
|
+
component that owns it.
|
|
376
|
+
|
|
377
|
+
| `kind` | Drawn as | Carries |
|
|
378
|
+
| --- | --- | --- |
|
|
379
|
+
| `reasoning` | `<Reasoning>` | `text`, `state`, `duration` |
|
|
380
|
+
| `tool` | `<Tool>` | `name`, `state`, `summary`, `input`, `output`, `error`, `duration` |
|
|
381
|
+
| `tasks` | `<TaskList>` | `title`, `tasks`, `collapsible` |
|
|
382
|
+
| `chain` | `<ChainOfThought>` | `steps`, `state`, `duration` |
|
|
383
|
+
| `sources` | `<Sources>` | `sources`, `title`, `collapsible` |
|
|
384
|
+
| `approval` | `<Approval>` | `title`, `description`, `tool`, `decision` |
|
|
385
|
+
| `question` | `<QuestionGroup>` | `title`, `questions`, `answers`, `activeIndex`, `collapsible` |
|
|
386
|
+
| `notice` | `<SystemMessage>` | `text`, `tone` |
|
|
387
|
+
| `artifact` | `<ArtifactCard>` | `title`, `meta`, `preview`, `lang`, `content`, `state` |
|
|
388
|
+
|
|
389
|
+
`reasoning` and `chain` are the same job at two grains — a block of prose, or
|
|
390
|
+
steps that follow from one another. Sending both for one stretch of thinking
|
|
391
|
+
says it twice.
|
|
392
|
+
|
|
393
|
+
#### Citing a source from the prose
|
|
394
|
+
|
|
395
|
+
`[^1]` in the answer is a **citation marker**, and the number is a position in
|
|
396
|
+
the turn's `sources` part:
|
|
397
|
+
|
|
398
|
+
```tsx
|
|
399
|
+
yield { kind: "sources", id: "s", sources: [atlas, pdg] };
|
|
400
|
+
yield "The combined figure is 125.25 GeV[^1], to a fifth of a percent[^2].";
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
It draws as an `<InlineCitation>` carrying the source's title, and the marker
|
|
404
|
+
is left out of the tokens — so a highlight drawn across the sentence, and the
|
|
405
|
+
text a thread quotes back, do not contain a stray `[1]`.
|
|
406
|
+
|
|
407
|
+
This is the kit's one extension to the markdown grammar. GFM spells footnotes
|
|
408
|
+
the same way but wants a `[^1]: …` definition in the document; a model streams
|
|
409
|
+
the marker and sends the list beside the text, never below it. Without it a
|
|
410
|
+
citation could only be written by hand in JSX — which a stream cannot do, so
|
|
411
|
+
the component existed and no real conversation could reach it.
|
|
412
|
+
|
|
413
|
+
Send the list **before** the prose that cites it. Sent after, the markers draw
|
|
414
|
+
bare and fill in later, which works and looks like a bug.
|
|
415
|
+
|
|
416
|
+
A question is answered by the person reading it, not by the stream — so
|
|
417
|
+
`useChatTurns` also returns **`updatePart(turnId, part)`**, and `ChatTurnRow`
|
|
418
|
+
reports through `onAnswerQuestion` / `onEditQuestion`. The row never keeps the
|
|
419
|
+
answer; the parts are yours.
|
|
420
|
+
|
|
421
|
+
Parts are cleared when a turn is answered again: the tool calls that produced
|
|
422
|
+
the old answer are not evidence for the new one.
|
|
423
|
+
|
|
424
|
+
### One header for everything that folds
|
|
425
|
+
|
|
426
|
+
`<Tool>`, `<Reasoning>`, `<ChainOfThought>`, `<TaskList>` and `<Sources>` all
|
|
427
|
+
open the same way, so they share **`DisclosureHeader`** and **`DisclosureBody`**
|
|
428
|
+
— internals, not exports. The header owns the button-or-heading switch, the
|
|
429
|
+
`aria-expanded` / `aria-controls` wiring, the chevron, the shimmer under a
|
|
430
|
+
pending label, and the guard that stops a click on the row from starting a
|
|
431
|
+
marker on the highlighter underneath. The body owns the always-present
|
|
432
|
+
container and the height reveal.
|
|
433
|
+
|
|
434
|
+
Two **fits**, and the difference is real rather than decorative:
|
|
435
|
+
|
|
436
|
+
| | Fit | Why |
|
|
437
|
+
| --- | --- | --- |
|
|
438
|
+
| `Tool`, `TaskList`, `Sources` | `band` | A full-width row. It has a right edge, so the meta and the chevron are pushed to it. |
|
|
439
|
+
| `Reasoning`, `ChainOfThought` | `inline` | A label that hugs its own words. These sit in the flow of an answer as asides; a chevron pushed to a right edge 500px away floats alone in white space. |
|
|
440
|
+
|
|
441
|
+
A component that means something different by its label **repoints a token**
|
|
442
|
+
from its own root rather than passing a class in — two `.label` rules from two
|
|
443
|
+
stylesheets have equal specificity, and which one wins is import order.
|
|
444
|
+
`--ick-disclosure-label`, `-font`, `-weight`, `-size`, `-glyph`, `-meta`.
|
|
445
|
+
|
|
446
|
+
`<QuestionGroup>` folds too and does not use this, for a reason worth stating:
|
|
447
|
+
its shut state is a *card*, and opening it is a FLIP morph between two layouts
|
|
448
|
+
rather than a box growing from zero height under a row. A test holds that
|
|
449
|
+
exemption, and the exemption stays honest — the day it stops being a morph, it
|
|
450
|
+
owes an answer.
|
|
451
|
+
|
|
452
|
+
### `<Reasoning>`
|
|
453
|
+
|
|
454
|
+
What the model worked through before it answered.
|
|
455
|
+
|
|
456
|
+
```tsx
|
|
457
|
+
<Reasoning state={thinking ? "thinking" : "done"}>{thoughts}</Reasoning>
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
| Prop | Type | Notes |
|
|
461
|
+
| --- | --- | --- |
|
|
462
|
+
| `children` | `ReactNode` | The thinking. Prose — see `Tool` for structure |
|
|
463
|
+
| `state` | `"thinking" \| "done"` | Default `"done"` |
|
|
464
|
+
| `duration` | `number` | In ms. Left out, the block times itself |
|
|
465
|
+
| `open` / `defaultOpen` / `onOpenChange` | | Controlled or not |
|
|
466
|
+
| `labels` | `Partial<Record<…, string>>` | `thinking`, `thought`, `thoughtFor` |
|
|
467
|
+
|
|
468
|
+
**Open while it thinks, folded away once the answer starts.** That is the one
|
|
469
|
+
detail every kit shipping this has converged on, and it is right: thinking is
|
|
470
|
+
worth watching while it happens and worth almost nothing afterwards — but it
|
|
471
|
+
has to stay reachable, because the times it matters are exactly the times the
|
|
472
|
+
answer looks wrong.
|
|
473
|
+
|
|
474
|
+
Folding is the block's *preference*, not something done to the reader. Open it
|
|
475
|
+
and it stays open, however many times the state changes underneath.
|
|
476
|
+
|
|
477
|
+
Without a `duration` it times itself, from the moment it starts thinking to the
|
|
478
|
+
moment it stops. Pass one when you already know — replaying a transcript, where
|
|
479
|
+
the thinking did not happen just now.
|
|
480
|
+
|
|
481
|
+
While it thinks the word shimmers, which is how this kit says "provisional"
|
|
482
|
+
everywhere. It is a real word in the button rather than a `<Loader>`: the loader
|
|
483
|
+
is decorative and marks itself `aria-hidden`, and hiding this one would leave a
|
|
484
|
+
control with nothing to call it.
|
|
485
|
+
|
|
486
|
+
### `<Context>`
|
|
487
|
+
|
|
488
|
+
How full the context window is.
|
|
489
|
+
|
|
490
|
+
```tsx
|
|
491
|
+
<ChatHeader title="Higgs boson" subtitle="14 turns">
|
|
492
|
+
<Context used={840_000} total={1_000_000} label={false} />
|
|
493
|
+
</ChatHeader>
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
| Prop | Type | Notes |
|
|
497
|
+
| --- | --- | --- |
|
|
498
|
+
| `used` / `total` | `number` | In tokens, or whatever unit you count in |
|
|
499
|
+
| `warnAt` | `number` | Fraction. Default `0.8` |
|
|
500
|
+
| `label` | `ReactNode \| false` | The percentage by default; `false` for the ring alone |
|
|
501
|
+
| `labels` | `Partial<Record<…, string>>` | `name`, `of`, `tokens`, `nearlyFull` |
|
|
502
|
+
|
|
503
|
+
Small on purpose. It is a gauge, not a feature, and it earns its place for one
|
|
504
|
+
reason: **it is the only honest way to explain why a long conversation starts
|
|
505
|
+
forgetting.** Without it the forgetting looks like the model being stupid
|
|
506
|
+
rather than the window being full.
|
|
507
|
+
|
|
508
|
+
Which is why the warning says *what happens next* rather than only that a
|
|
509
|
+
number is high — `"82%"` tells somebody nothing they can act on. The whole
|
|
510
|
+
sentence is the meter's accessible name and its `title`, so it reaches a
|
|
511
|
+
pointer and a screen reader alike.
|
|
512
|
+
|
|
513
|
+
Two colours, not three: quiet until `warnAt`, then the danger colour. A gauge
|
|
514
|
+
with an amber in the middle makes somebody learn a scale to read a number they
|
|
515
|
+
can already see.
|
|
516
|
+
|
|
517
|
+
### `<Approval>`
|
|
518
|
+
|
|
519
|
+
"It wants to do this. Is that all right?"
|
|
520
|
+
|
|
521
|
+
```tsx
|
|
522
|
+
<Approval
|
|
523
|
+
title="Run a command in your shell"
|
|
524
|
+
description="It removes the generated screenshots. Nothing else is touched."
|
|
525
|
+
decision={decision}
|
|
526
|
+
onDecide={setDecision}
|
|
527
|
+
>
|
|
528
|
+
<Tool name="bash" state="pending" input={{ command: "rm -rf Shots/" }} defaultOpen />
|
|
529
|
+
</Approval>
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
| Prop | Type | Notes |
|
|
533
|
+
| --- | --- | --- |
|
|
534
|
+
| `title` | `ReactNode` | What is being asked. Names the region |
|
|
535
|
+
| `description` | `ReactNode` | Why, or what it will touch |
|
|
536
|
+
| `children` | `ReactNode` | The thing itself — usually a `<Tool>` or a `<CodeBlock>` |
|
|
537
|
+
| `decision` | `"once" \| "always" \| "denied" \| null` | `null` while it is still asking |
|
|
538
|
+
| `onDecide` | `(decision) => void` | |
|
|
539
|
+
| `readOnly` | `boolean` | A record of a decision made elsewhere |
|
|
540
|
+
|
|
541
|
+
**Three answers, not two.** "Yes" and "yes forever" are not the same answer,
|
|
542
|
+
and a UI offering one button for both collects the wrong one. **Allow once is
|
|
543
|
+
the primary**: the narrow permission is the one that should be easiest to give,
|
|
544
|
+
and the standing one should cost a moment's thought. Deny sits alone on the left with
|
|
545
|
+
the two that say yes pushed right — a destructive choice flush against an
|
|
546
|
+
affirmative one is a mis-click waiting to happen — and it is first in the DOM
|
|
547
|
+
too, so a keyboard reaches the safe answer without tabbing past the other two.
|
|
548
|
+
It turns red only under the pointer, because a permanently red button is the
|
|
549
|
+
first thing the eye lands on.
|
|
550
|
+
|
|
551
|
+
Give it something to show. An approval with nothing under it is asking for a
|
|
552
|
+
signature on a blank page.
|
|
553
|
+
|
|
554
|
+
Decided, it stops being a set of buttons and becomes a record of what was
|
|
555
|
+
decided. Live controls under a decision already made invite a second one that
|
|
556
|
+
contradicts the first.
|
|
557
|
+
|
|
558
|
+
As a `TurnPart` it is `{ kind: "approval", id, title, description?, tool?,
|
|
559
|
+
decision? }` — data, like every part, so the tool it names is drawn for it
|
|
560
|
+
rather than passed in as an element. `<ChatTurnRow>` reports through
|
|
561
|
+
`onDecideApproval`.
|
|
562
|
+
|
|
563
|
+
### `<Sources>` and `<InlineCitation>`
|
|
564
|
+
|
|
565
|
+
A numbered marker in the text, and the list underneath.
|
|
566
|
+
|
|
567
|
+
```tsx
|
|
568
|
+
<p>
|
|
569
|
+
The Higgs is a point particle, so{" "}
|
|
570
|
+
<InlineCitation index={1} source={sources[0]} onSelect={(_, s) => setActive(s.id)}>
|
|
571
|
+
it has no measurable spatial extent
|
|
572
|
+
</InlineCitation>.
|
|
573
|
+
</p>
|
|
574
|
+
<Sources sources={sources} activeId={active} />
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
| `Sources` | Type | Notes |
|
|
578
|
+
| --- | --- | --- |
|
|
579
|
+
| `sources` | `Source[]` | `{ id, title, url?, origin?, quote? }` |
|
|
580
|
+
| `title` | `ReactNode` | Defaults to `labels.title` |
|
|
581
|
+
| `collapsible` | `boolean` | `false` |
|
|
582
|
+
| `activeId` | `string \| null` | The one arrived at from a marker |
|
|
583
|
+
| `onSelect` | `(source, index) => void` | |
|
|
584
|
+
|
|
585
|
+
| `InlineCitation` | Type | Notes |
|
|
586
|
+
| --- | --- | --- |
|
|
587
|
+
| `index` | `number` | 1-based, and it has to match the entry's place |
|
|
588
|
+
| `source` | `Source` | Named in the marker's accessible name |
|
|
589
|
+
| `children` | `ReactNode` | The passage this citation speaks for — marked, if given |
|
|
590
|
+
| `onSelect` | `(index, source?) => void` | Without one the marker is not a control |
|
|
591
|
+
|
|
592
|
+
**The citation marker and the highlight marker are the same interaction seen
|
|
593
|
+
twice.** The kit already had a way of saying *this run of words is picked out*
|
|
594
|
+
— the marker somebody draws over an answer to ask about it. A citation is that
|
|
595
|
+
same statement made by the answer rather than by the reader, so it is drawn the
|
|
596
|
+
same way rather than in a second visual language nobody has learned. Give
|
|
597
|
+
`InlineCitation` the passage as `children` and the passage is marked; give it
|
|
598
|
+
none and the marker stands on its own after whatever precedes it.
|
|
599
|
+
|
|
600
|
+
Pressing a marker **marks** the entry it points at rather than scrolling to it.
|
|
601
|
+
The list is already under the answer, and moving the page under somebody who
|
|
602
|
+
clicked a marker in a sentence they were reading loses them the sentence.
|
|
603
|
+
|
|
604
|
+
The list is open by default. Sources are the difference between an answer
|
|
605
|
+
somebody can check and one they have to trust, and folding that away by default
|
|
606
|
+
says the opposite of what a citation is for.
|
|
607
|
+
|
|
608
|
+
### `<ChainOfThought>`
|
|
609
|
+
|
|
610
|
+
How the answer was arrived at, step by step.
|
|
611
|
+
|
|
612
|
+
```tsx
|
|
613
|
+
<ChainOfThought
|
|
614
|
+
state={thinking ? "thinking" : "done"}
|
|
615
|
+
duration={4200}
|
|
616
|
+
steps={[
|
|
617
|
+
{ id: "a", label: "The question is about size, not mass", body: "A point particle has none." },
|
|
618
|
+
{ id: "b", label: "Checked the measured value", body: <Tool name="search_web" … /> },
|
|
619
|
+
]}
|
|
620
|
+
/>
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
| Prop | Type | Notes |
|
|
624
|
+
| --- | --- | --- |
|
|
625
|
+
| `steps` | `Thought[]` | `{ id, label, body?, state? }` |
|
|
626
|
+
| `state` | `"thinking" \| "done"` | `thinking` holds it open and narrates the running step |
|
|
627
|
+
| `duration` | `number` | In ms, for the whole chain |
|
|
628
|
+
| `open` / `defaultOpen` / `onOpenChange` | | Controlled or not |
|
|
629
|
+
| `labels` | `Partial<Record<…, string>>` | `through`, `step`, `steps`, `thinking` |
|
|
630
|
+
|
|
631
|
+
**Three components in this kit draw a sequence, and the line between them is
|
|
632
|
+
the only reason there are three:**
|
|
633
|
+
|
|
634
|
+
| | What it is | Shape |
|
|
635
|
+
| --- | --- | --- |
|
|
636
|
+
| `Reasoning` | The model talking to itself | Prose, unstructured |
|
|
637
|
+
| `TaskList` | A plan | Known up front, fixed order, items change state |
|
|
638
|
+
| `ChainOfThought` | A derivation | Grows; each step follows from the one above |
|
|
639
|
+
|
|
640
|
+
That last word is what the line down the glyph column draws. A task list has no
|
|
641
|
+
line between its items, because a plan's items do not follow from each other —
|
|
642
|
+
they are a set, in an order somebody chose.
|
|
643
|
+
|
|
644
|
+
A step's `body` is whatever you put there: prose, or a `<Tool>` if the step was
|
|
645
|
+
a tool call. While the chain is thinking its header carries the running step's
|
|
646
|
+
label rather than a count — that is the question somebody watching is asking,
|
|
647
|
+
and the reason to look at a folded chain at all.
|
|
648
|
+
|
|
649
|
+
### `<TaskList>`
|
|
650
|
+
|
|
651
|
+
What the agent means to do, what it is doing, and what it has finished.
|
|
652
|
+
|
|
653
|
+
```tsx
|
|
654
|
+
<TaskList
|
|
655
|
+
title="Plan"
|
|
656
|
+
collapsible
|
|
657
|
+
tasks={[
|
|
658
|
+
{ id: "read", label: "Read the care plan", state: "done", detail: "42 lines" },
|
|
659
|
+
{ id: "gaps", label: "Find the gaps in the weekly cover", state: "running" },
|
|
660
|
+
{ id: "draft", label: "Draft the questions for the family" },
|
|
661
|
+
]}
|
|
662
|
+
/>
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
| Prop | Type | Notes |
|
|
666
|
+
| --- | --- | --- |
|
|
667
|
+
| `tasks` | `Task[]` | `{ id, label, state?, detail? }` |
|
|
668
|
+
| `title` | `ReactNode` | Gives it a row to fold into. Without one there is no row |
|
|
669
|
+
| `collapsible` | `boolean` | `false` |
|
|
670
|
+
| `open` / `defaultOpen` / `onOpenChange` | | Controlled or not |
|
|
671
|
+
| `labels` | `Partial<Record<…, string>>` | The four states, and `progress` (`"{done} of {total}"`) |
|
|
672
|
+
|
|
673
|
+
A task is `pending`, `running`, `done` or `error` — the same four as a tool
|
|
674
|
+
call, and the same glyphs, because they are the same four states and naming
|
|
675
|
+
them differently in two places buys nothing.
|
|
676
|
+
|
|
677
|
+
**The order never changes.** A list that sorted itself as work progressed would
|
|
678
|
+
move the line somebody is reading out from under them, and the sequence is half
|
|
679
|
+
of what the list is saying — these steps, in this order. Only the glyphs change.
|
|
680
|
+
|
|
681
|
+
`collapsible` folds it away once every task is done: a plan is worth watching
|
|
682
|
+
while it runs and worth little afterwards. Anybody reading it can overrule
|
|
683
|
+
that, in either direction, for good.
|
|
684
|
+
|
|
685
|
+
The one being worked on carries `aria-current="step"`, so a screen reader can
|
|
686
|
+
jump to "where is it up to" rather than counting down the list.
|
|
687
|
+
|
|
688
|
+
### `<QuestionCard>` and `<QuestionGroup>`
|
|
689
|
+
|
|
690
|
+
A structured question inside a conversation: the assistant asks something with
|
|
691
|
+
a shape to it, and the answer is picked or typed rather than written out.
|
|
692
|
+
|
|
693
|
+
```tsx
|
|
694
|
+
<QuestionGroup
|
|
695
|
+
id="about-them"
|
|
696
|
+
title="Setting up the run"
|
|
697
|
+
questions={questions}
|
|
698
|
+
answers={answers}
|
|
699
|
+
activeIndex={activeIndex}
|
|
700
|
+
collapsible={done && questions.length >= FOLDABLE_FROM}
|
|
701
|
+
onCommit={(id, answer) => setAnswers((all) => ({ ...all, [id]: answer }))}
|
|
702
|
+
onEdit={setEditing}
|
|
703
|
+
/>
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
A `Question` is one of three shapes — `inputs` (type something), `single` (pick
|
|
707
|
+
one), `multi` (pick several, optionally with a "something else" field). Each
|
|
708
|
+
carries a `shortTitle`, which is what it is called once it folds into a row.
|
|
709
|
+
|
|
710
|
+
A card is in one of three states, and morphs between them:
|
|
711
|
+
|
|
712
|
+
| state | what it is |
|
|
713
|
+
| --- | --- |
|
|
714
|
+
| `upcoming` | one dim row, waiting its turn |
|
|
715
|
+
| `active` | the question, open, being answered |
|
|
716
|
+
| `collapsed` | one row: the short title, the answer as chips, and a way back in |
|
|
717
|
+
|
|
718
|
+
`QuestionGroup` holds a step's worth. **`title` names the step, at the top**,
|
|
719
|
+
and is also the fold control when there is one — which is the point of putting
|
|
720
|
+
it there. A control at the top does not move when the body under it changes, so
|
|
721
|
+
folding stops being two shapes swapping places and becomes one header with two
|
|
722
|
+
bodies. Left out on a foldable group, the count stands in so the control still
|
|
723
|
+
has a name; left out on one that cannot fold, there is no header at all.
|
|
724
|
+
|
|
725
|
+
Folded, the body is a single summary row — not a peek at the list, because a
|
|
726
|
+
peek costs more height than the answers it shows.
|
|
727
|
+
|
|
728
|
+
Two decisions carried over from the original, both worth keeping: `single`
|
|
729
|
+
waits a beat after a choice before committing, or the card is gone before
|
|
730
|
+
anyone sees what they picked; and the "something else" row is a `<label>`, not
|
|
731
|
+
a button, because an input inside a button is not reliably focusable.
|
|
732
|
+
|
|
733
|
+
Two changed. The collapsed row is a single `<button>` naming what it does
|
|
734
|
+
(`"Edit answer: Household"`) rather than a click handler on a `<div>` with
|
|
735
|
+
another button inside it — that could be clicked but not tabbed to. And the
|
|
736
|
+
letter badges are `aria-hidden`: the letter is a visual index, and left in the
|
|
737
|
+
tree it turns a field called "Their name" into one called "a Their name".
|
|
738
|
+
|
|
739
|
+
#### Composing a fourth kind of question
|
|
740
|
+
|
|
741
|
+
Three shapes are not the only three. The parts a card is built from are
|
|
742
|
+
exported, so a shape the kit does not ship is a composition rather than a fork
|
|
743
|
+
— and it arrives already wearing the same tokens, focus behaviour and ARIA as
|
|
744
|
+
the ones that do.
|
|
745
|
+
|
|
746
|
+
```tsx
|
|
747
|
+
<QuestionShell
|
|
748
|
+
number={3}
|
|
749
|
+
title="How soon do they need this?"
|
|
750
|
+
subtitle="Roughly is fine"
|
|
751
|
+
footer={<Button variant="secondary" size="m" onClick={commit}>Next</Button>}
|
|
752
|
+
>
|
|
753
|
+
{levels.map((level, i) => (
|
|
754
|
+
<QuestionOptionRow
|
|
755
|
+
key={level.id}
|
|
756
|
+
letter={"abc"[i]}
|
|
757
|
+
title={level.title}
|
|
758
|
+
description={level.description}
|
|
759
|
+
selected={picked === level.id}
|
|
760
|
+
onClick={() => setPicked(level.id)}
|
|
761
|
+
/>
|
|
762
|
+
))}
|
|
763
|
+
<QuestionFieldRow
|
|
764
|
+
letter="d"
|
|
765
|
+
label="Anything we should know"
|
|
766
|
+
value={note}
|
|
767
|
+
onChange={setNote}
|
|
768
|
+
/>
|
|
769
|
+
</QuestionShell>
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
| Part | What it is |
|
|
773
|
+
| --- | --- |
|
|
774
|
+
| `QuestionShell` | The card: header, the column the rows sit in, a right-aligned footer |
|
|
775
|
+
| `QuestionOptionRow` | A row that picks. A `<button>` with `aria-pressed` |
|
|
776
|
+
| `QuestionFieldRow` | A row that is typed into. A `<label>`, so the whole row focuses the input |
|
|
777
|
+
| `QuestionOtherRow` | The "something else" row: reads as an option, is a text field |
|
|
778
|
+
| `QuestionBadge` | The 24px square with the letter or the number |
|
|
779
|
+
|
|
780
|
+
Each takes a `className` that is **added** to its own rather than replacing it,
|
|
781
|
+
spreads the rest of its props onto the element it ends in, and forwards its ref
|
|
782
|
+
to the thing worth having one for — the input, in the two rows that have one.
|
|
783
|
+
On the two rows that take one, `className` styles the row and everything else
|
|
784
|
+
goes to the input.
|
|
785
|
+
|
|
786
|
+
`QuestionShell` paints the card — background, radius, shadow — unless you pass
|
|
787
|
+
`card={false}`, which is what `QuestionCard` does: the box that morphs between
|
|
788
|
+
the three states is its own, and two would nest.
|
|
789
|
+
|
|
790
|
+
`letter` is optional on all three rows. Left out, the row starts at its title.
|
|
791
|
+
`onEnter` fires on Enter unless your own `onKeyDown` called `preventDefault`
|
|
792
|
+
first, which is how a handler says it has dealt with the key.
|
|
793
|
+
|
|
794
|
+
### `<EmptyState>` and `<Loader>`
|
|
795
|
+
|
|
796
|
+
The two ends of a conversation that has not happened yet: what is on screen
|
|
797
|
+
before anybody asks, and the gap between sending and the first word.
|
|
798
|
+
|
|
799
|
+
| `EmptyState` | Type | Notes |
|
|
800
|
+
| --- | --- | --- |
|
|
801
|
+
| `icon` / `title` / `description` | `ReactNode` | Each optional; nothing is drawn in place of what you leave out |
|
|
802
|
+
| `suggestions` | `string[]` | Openers |
|
|
803
|
+
| `onSuggestion` | `(text: string) => void` | Without it, no openers are drawn |
|
|
804
|
+
| `suggestionsLabel` | `string` | Names the group. Default `"Suggestions"` |
|
|
805
|
+
|
|
806
|
+
`title` renders as text, not a heading. This sits inside a conversation the
|
|
807
|
+
host already owns, and claiming a level in their document is not ours to do —
|
|
808
|
+
pass `<h2>…</h2>` if it should be one.
|
|
809
|
+
|
|
810
|
+
| `Loader` | Type | Notes |
|
|
811
|
+
| --- | --- | --- |
|
|
812
|
+
| `variant` | `"dots" \| "shimmer"` | Default `"dots"` |
|
|
813
|
+
| `children` | `ReactNode` | The words the shimmer runs through |
|
|
814
|
+
| `label` | `string \| null` | Default `null` — see below |
|
|
815
|
+
|
|
816
|
+
The loader is **silent by default**. `useChatTurns` already announces that a
|
|
817
|
+
response is coming, and a second live region saying the same thing means
|
|
818
|
+
hearing it twice. Pass `label` only when nothing else is speaking for you.
|
|
819
|
+
|
|
820
|
+
`ChatTurnRow` shows it between the question being sent and the first word
|
|
821
|
+
landing, so a sent question is never a blank space.
|
|
822
|
+
|
|
823
|
+
### `<AnswerActions>`
|
|
824
|
+
|
|
825
|
+
Copy, regenerate and a verdict, under a settled answer. `ChatTurnRow` renders
|
|
826
|
+
it for you; it is exported for anyone composing their own row.
|
|
827
|
+
|
|
828
|
+
| Prop | Type | Default | Notes |
|
|
829
|
+
| --- | --- | --- | --- |
|
|
830
|
+
| `text` | `string` | | Required. What copy takes |
|
|
831
|
+
| `onCopy` | `(text: string) => void` | writes to the clipboard | |
|
|
832
|
+
| `onRegenerate` | `() => void` | | Omit and the button is not drawn |
|
|
833
|
+
| `onFeedback` | `(verdict: "up" \| "down" \| null) => void` | | Omit and the thumbs are not drawn |
|
|
834
|
+
| `feedback` | `"up" \| "down" \| null` | `null` | Controlled |
|
|
835
|
+
| `busy` | `boolean` | `false` | While regenerating |
|
|
836
|
+
| `reveal` | `boolean` | `false` | Invisible until hovered or focused |
|
|
837
|
+
| `labels` | `Partial<Record<…, string>>` | | |
|
|
838
|
+
| `children` | `ReactNode` | | Your own controls, after the built-in ones |
|
|
839
|
+
|
|
840
|
+
Only what has somewhere to report is drawn: no `onRegenerate`, no regenerate
|
|
841
|
+
button. A control that calls nothing looks like a feature and behaves like a
|
|
842
|
+
dead end.
|
|
843
|
+
|
|
844
|
+
Pressing the verdict already given reports `null` — that is how somebody takes
|
|
845
|
+
it back.
|
|
846
|
+
|
|
847
|
+
Inside a turn they appear when the answer **settles**. Offering to copy a
|
|
848
|
+
half-written answer, or to rate one, is offering the wrong thing.
|
|
849
|
+
|
|
850
|
+
### `<Conversation>`
|
|
851
|
+
|
|
852
|
+
The scroll container. It keeps up with an answer as it arrives and stops the
|
|
853
|
+
instant the reader scrolls away, with a button offering the way back.
|
|
854
|
+
|
|
855
|
+
```tsx
|
|
856
|
+
<Conversation>
|
|
857
|
+
{turns.map((turn) => <ChatTurnRow key={turn.id} turn={turn} … />)}
|
|
858
|
+
</Conversation>
|
|
859
|
+
```
|
|
860
|
+
|
|
861
|
+
| Prop | Type | Default | Notes |
|
|
862
|
+
| --- | --- | --- | --- |
|
|
863
|
+
| `anchorId` | `string` | | Hold this element at the top instead of following the end |
|
|
864
|
+
| `anchorOffset` | `number` | `0` | How far below the top edge it sits — leave room for a fixed header |
|
|
865
|
+
| `threshold` | `number` | `64` | How close still counts as following |
|
|
866
|
+
| `scrollButton` | `boolean` | `true` | The way back |
|
|
867
|
+
| `scrollButtonLabel` | `string` | `"Jump to the latest"` | |
|
|
868
|
+
| `follow` | `boolean` | `true` | `false` makes it a plain scroll container |
|
|
869
|
+
| `className` | `string` | | Goes on the root, which is the box you lay out |
|
|
870
|
+
| `viewportClassName` | `string` | | Goes on the element that scrolls — padding belongs here |
|
|
871
|
+
|
|
872
|
+
`ref` is forwarded to the **viewport**, not the root: anyone reaching for a ref
|
|
873
|
+
here wants to scroll something, and the root does not scroll.
|
|
874
|
+
|
|
875
|
+
Three things worth knowing.
|
|
876
|
+
|
|
877
|
+
**`anchorId` is the one that changes the feel.** Without it the view follows
|
|
878
|
+
the end of the content, which is what a chat that stacks downwards wants. With
|
|
879
|
+
it, the named element is brought to the top and *held* there while the answer
|
|
880
|
+
grows underneath — so a reader sees their question and its answer, and not the
|
|
881
|
+
whole conversation pushed up from below with the composer ending past the fold.
|
|
882
|
+
Point it at the turn that was just submitted:
|
|
883
|
+
|
|
884
|
+
```tsx
|
|
885
|
+
const [anchor, setAnchor] = useState<string | null>(null);
|
|
886
|
+
const send = (id: string, value: string) => { setAnchor(id); submit(id, value); };
|
|
887
|
+
|
|
888
|
+
<Conversation anchorId={anchor ? `turn-${anchor}` : undefined} anchorOffset={100}>
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
This needs room to scroll into — an element cannot be brought to the top of a
|
|
892
|
+
container that ends just below it. A large `padding-bottom` on the viewport is
|
|
893
|
+
what provides it.
|
|
894
|
+
|
|
895
|
+
Without an anchor it follows the **end of the content**, not the bottom of the
|
|
896
|
+
container, and those are only the same when nothing is padded below. A chat
|
|
897
|
+
with a screen-height pad beneath it would otherwise scroll the answer off the
|
|
898
|
+
screen to sit in front of a blank space.
|
|
899
|
+
|
|
900
|
+
And it reads the reader's intent from the **input**, not from the scroll event.
|
|
901
|
+
A component that watches scrolling cannot tell its own from theirs, and ends up
|
|
902
|
+
either dragging them back down mid-sentence or never following at all. A wheel
|
|
903
|
+
upwards, a page key, a drag away from the end: any of those and it lets go.
|
|
904
|
+
|
|
905
|
+
### `<ArtifactCard>`, `<ArtifactPane>`, `<ChatLayout>`, `useArtifacts`
|
|
906
|
+
|
|
907
|
+
What the answer produced, when it is bigger than the answer — a plan, a
|
|
908
|
+
document, a file. Three pieces and one decision.
|
|
909
|
+
|
|
910
|
+
```tsx
|
|
911
|
+
const artifacts = useArtifacts();
|
|
912
|
+
|
|
913
|
+
<ChatLayout
|
|
914
|
+
pane={({ narrow }) =>
|
|
915
|
+
artifacts.openId ? (
|
|
916
|
+
<ArtifactPane title="5k training plan" meta="8 weeks" modal={narrow} onClose={artifacts.close}>
|
|
917
|
+
<YourPlan />
|
|
918
|
+
</ArtifactPane>
|
|
919
|
+
) : null
|
|
920
|
+
}
|
|
921
|
+
>
|
|
922
|
+
<Conversation>
|
|
923
|
+
<ChatTurnRow turn={turn} openArtifactId={artifacts.openId} onOpenArtifact={(_, id) => artifacts.toggle(id)} />
|
|
924
|
+
</Conversation>
|
|
925
|
+
</ChatLayout>
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
**The kit decides where the pane goes.** On the right, with the conversation
|
|
929
|
+
making room; below `<ChatLayout>`'s width it covers the conversation instead. A
|
|
930
|
+
preview pane is one of the few patterns every AI chat now has, and the worth of
|
|
931
|
+
a pattern is that it is the same every time — ask for a plan, get a card, press
|
|
932
|
+
it, the plan opens on the right. A kit that let each host place it would be
|
|
933
|
+
shipping four chats that behave differently and calling it flexibility.
|
|
934
|
+
|
|
935
|
+
**The kit does not decide what is in it.** `<ArtifactPane>` takes children: a
|
|
936
|
+
plan, a table, a document, a diagram. That is the part that actually differs
|
|
937
|
+
between products, and the only part.
|
|
938
|
+
|
|
939
|
+
| Prop | Type | Notes |
|
|
940
|
+
| --- | --- | --- |
|
|
941
|
+
| `<ArtifactCard>` `id` | `string` | What the pane opens. The card and the pane share nothing else |
|
|
942
|
+
| `title`, `meta` | `ReactNode` | |
|
|
943
|
+
| `kind` | `"code" \| "text"` | How the preview is drawn |
|
|
944
|
+
| `content` | `string` | Absent while it is being written, which is how one usually arrives |
|
|
945
|
+
| `lines` | `number` | How much shows before the cut. Default 8 |
|
|
946
|
+
| `open` | `boolean` | True while its pane is the one on screen |
|
|
947
|
+
| `onOpen` | `(id: string) => void` | Without one the card is a record, not a control |
|
|
948
|
+
| `<ArtifactPane>` `modal` | `boolean` | See below |
|
|
949
|
+
| `<ChatLayout>` `pane` | `({ narrow }) => ReactNode` | |
|
|
950
|
+
|
|
951
|
+
**`modal` is the one prop that changes behaviour, and it is not about
|
|
952
|
+
position.** Covering the conversation changes what the pane *is*: focus has to
|
|
953
|
+
be held inside it and Escape has to close it, because there is nothing usable
|
|
954
|
+
behind it. Beside the conversation both would be wrong — trapping focus would
|
|
955
|
+
lock a reader out of the chat they are still reading. `<ChatLayout>` sets it
|
|
956
|
+
from its own width, so you only pass it yourself if you are placing the pane
|
|
957
|
+
without one.
|
|
958
|
+
|
|
959
|
+
That is the part worth having in a library rather than the box. On open, focus
|
|
960
|
+
moves to the pane's heading — not into its first control, which would skip what
|
|
961
|
+
the thing is — and it is **not** trapped unless the pane is covering the chat.
|
|
962
|
+
|
|
963
|
+
### `<SystemMessage>`
|
|
964
|
+
|
|
965
|
+
The conversation saying something about itself — not the reader, not the agent.
|
|
966
|
+
|
|
967
|
+
```tsx
|
|
968
|
+
<SystemMessage>The oldest messages are dropping out of the window.</SystemMessage>
|
|
969
|
+
<SystemMessage tone="danger">Lost the connection. Nothing since your last message was saved.</SystemMessage>
|
|
970
|
+
```
|
|
971
|
+
|
|
972
|
+
| Prop | Type | Notes |
|
|
973
|
+
| --- | --- | --- |
|
|
974
|
+
| `children` | `ReactNode` | The sentence. Say what happens next, not that a state changed |
|
|
975
|
+
| `tone` | `"notice" \| "danger"` | Two, because `<Context>` already settled that three is one too many |
|
|
976
|
+
|
|
977
|
+
Reachable from a stream as well, as a part of kind `notice`, which is the only
|
|
978
|
+
way anything gets into a conversation without the host placing it by hand.
|
|
979
|
+
|
|
980
|
+
**It has no icon**, and that is the component rather than an omission from it.
|
|
981
|
+
Every picture in this kit carries a state the words beside it also carry —
|
|
982
|
+
queued, running, failed, allowed — so a reader the picture does not reach loses
|
|
983
|
+
nothing. An icon here would say "something is being announced" beside a
|
|
984
|
+
sentence announcing it.
|
|
985
|
+
|
|
986
|
+
**No dismiss and no action.** It is a line of the transcript, not a toast:
|
|
987
|
+
dismissing one would be editing what happened. A host that wants a button under
|
|
988
|
+
it composes one, which costs them a line and costs this component an API.
|
|
989
|
+
|
|
990
|
+
**No live region.** The kit has one — see `announce` — written to on a later
|
|
991
|
+
tick. A second one says everything twice, which is the fault that region exists
|
|
992
|
+
to have fixed. A host streaming a `notice` should announce it.
|
|
993
|
+
|
|
994
|
+
### `<Branch>`
|
|
995
|
+
|
|
996
|
+
Which answer you are looking at, and how to reach the others.
|
|
997
|
+
|
|
998
|
+
```tsx
|
|
999
|
+
<Branch total={turn.versions?.length ?? 0} index={turn.versionIndex ?? 0} onSelect={show} />
|
|
1000
|
+
```
|
|
1001
|
+
|
|
1002
|
+
| Prop | Type | Notes |
|
|
1003
|
+
| --- | --- | --- |
|
|
1004
|
+
| `total` | `number` | How many answers this turn has had |
|
|
1005
|
+
| `index` | `number` | Which is on screen, from zero. Out of range is clamped |
|
|
1006
|
+
| `onSelect` | `(index: number) => void` | |
|
|
1007
|
+
| `labels` | `Partial<{ previous, next, position }>` | `position` fills in `{index}` and `{total}` |
|
|
1008
|
+
|
|
1009
|
+
**It draws nothing at all when `total` is under two.** A control reading "1 of
|
|
1010
|
+
1" offers to take you nowhere, so a turn answered once looks exactly as it did
|
|
1011
|
+
before there were versions.
|
|
1012
|
+
|
|
1013
|
+
**Regenerating keeps the old answer.** It used to overwrite `ai` and `parts`,
|
|
1014
|
+
which threw away the answer being compared against — and comparing is the only
|
|
1015
|
+
reason to press regenerate. A turn now carries `versions: TurnVersion[]` and
|
|
1016
|
+
`versionIndex`, `useChatTurns` returns **`showVersion(id, index)`**, and
|
|
1017
|
+
`<ChatTurnRow>` draws the control beside the answer actions when you give it
|
|
1018
|
+
`onShowVersion`.
|
|
1019
|
+
|
|
1020
|
+
`ai` and `parts` stay what they were — the answer on screen. They are kept
|
|
1021
|
+
equal to `versions[versionIndex]` by one function that both writers (the
|
|
1022
|
+
batched stream flush and the turn patcher) go through; two writers for one fact
|
|
1023
|
+
is how they drift, and a test compares them after a stream.
|
|
1024
|
+
|
|
1025
|
+
Both fields are optional, so a turn a host built by hand still renders — the
|
|
1026
|
+
same tolerance `parts` has.
|
|
1027
|
+
|
|
1028
|
+
### `<Attachments>`
|
|
1029
|
+
|
|
1030
|
+
What goes along with a message.
|
|
1031
|
+
|
|
1032
|
+
```tsx
|
|
1033
|
+
<Attachments attachments={files} onRemove={(id) => drop(id)} />
|
|
1034
|
+
```
|
|
1035
|
+
|
|
1036
|
+
| Prop | Type | Notes |
|
|
1037
|
+
| --- | --- | --- |
|
|
1038
|
+
| `attachments` | `Attachment[]` | Required. Nothing to show draws nothing at all |
|
|
1039
|
+
| `onRemove` | `(id: string) => void` | Leave it out and they are a record rather than a control |
|
|
1040
|
+
| `labels` | `Partial<{ remove }>` | |
|
|
1041
|
+
|
|
1042
|
+
An `Attachment` is `{ id, name, url?, type?, size? }`. An **image with a `url`
|
|
1043
|
+
shows itself**; everything else shows a glyph, its name and its size — a
|
|
1044
|
+
thumbnail of a PDF at 64px is a grey rectangle with a corner turned down and
|
|
1045
|
+
tells you less than the filename does.
|
|
1046
|
+
|
|
1047
|
+
The same row draws them in the composer and under the message once it has been
|
|
1048
|
+
sent. `onRemove` is the whole difference between a control and a record, and
|
|
1049
|
+
`<ChatInput>` stops passing one the moment the message goes.
|
|
1050
|
+
|
|
1051
|
+
**In the composer.** `<ChatInput>` opens the picker from its `+` and keeps what
|
|
1052
|
+
comes back, unless you take over:
|
|
1053
|
+
|
|
1054
|
+
| Prop | Type | Notes |
|
|
1055
|
+
| --- | --- | --- |
|
|
1056
|
+
| `attachments` | `Attachment[]` | Controlled. Left out, the composer keeps its own |
|
|
1057
|
+
| `onAttach` | `(files: File[]) => void` | Given, the composer hands the files over instead of attaching them — for uploading first and attaching the URL you get back |
|
|
1058
|
+
| `onRemoveAttachment` | `(id: string) => void` | |
|
|
1059
|
+
| `accept` | `string` | Passed to the picker. Defaults to `image/*` |
|
|
1060
|
+
| `multiple` | `boolean` | Off |
|
|
1061
|
+
|
|
1062
|
+
`onSubmit` is `(value, attachments)`. That second argument is the point: before
|
|
1063
|
+
it, a file could be picked, shown in the composer, and then quietly dropped on
|
|
1064
|
+
send — the message went and the picture did not, which is worse than not
|
|
1065
|
+
offering the button. A picture on its own is a message, so an empty box with
|
|
1066
|
+
something attached still sends.
|
|
1067
|
+
|
|
1068
|
+
The object URLs the composer makes are **revoked** — when one is removed, when
|
|
1069
|
+
one replaces another, and on unmount. `createObjectURL` pins the file in memory
|
|
1070
|
+
until it is; nothing was revoking, so attaching and removing ten times leaked
|
|
1071
|
+
ten of them.
|
|
1072
|
+
|
|
1073
|
+
They arrive at your `SendHandler` on the context: `(message, { signal, turnId,
|
|
1074
|
+
attachments })`. And they live on the turn as `turn.attachments`, which is what
|
|
1075
|
+
`<ChatTurnRow>` hands back to the composer so a sent message still shows what
|
|
1076
|
+
went with it.
|
|
1077
|
+
|
|
1078
|
+
### `<CodeBlock>`
|
|
1079
|
+
|
|
1080
|
+
A fenced block: the language, a copy button, and code that scrolls sideways
|
|
1081
|
+
rather than widening the answer. The markdown renderer uses it for every
|
|
1082
|
+
fence, and it is exported for use on its own.
|
|
1083
|
+
|
|
1084
|
+
| Prop | Type | Default | Notes |
|
|
1085
|
+
| --- | --- | --- | --- |
|
|
1086
|
+
| `code` | `string` | | Required |
|
|
1087
|
+
| `lang` | `string` | | The fence's language. Unknown ones render unhighlighted |
|
|
1088
|
+
| `label` | `string \| false` | the language | `false` drops the caption |
|
|
1089
|
+
| `copyable` | `boolean` | `true` | `false` with `label={false}` removes the bar entirely |
|
|
1090
|
+
| `onCopy` | `(code: string) => void` | writes to the clipboard | |
|
|
1091
|
+
| `copiedFor` | `number` | `1600` | How long the button stays confirmed, in ms |
|
|
1092
|
+
|
|
1093
|
+
**Eleven languages** are registered: TypeScript, JavaScript, HTML/XML, CSS,
|
|
1094
|
+
JSON, YAML, Bash, Python, SQL, Markdown and diff — plus the aliases people
|
|
1095
|
+
actually type (`ts`, `tsx`, `js`, `sh`, `py`, `yml`, …). `lowlight/common` is
|
|
1096
|
+
37 languages and 51.6 KB gzipped; these cost half of that and cover what a chat
|
|
1097
|
+
actually shows. A language outside the list renders unhighlighted rather than
|
|
1098
|
+
throwing.
|
|
1099
|
+
|
|
1100
|
+
**And they are not in your bundle until something needs them.** The grammars
|
|
1101
|
+
are a **25 kB gzip chunk behind a dynamic `import()`** — most conversations
|
|
1102
|
+
never show a fence, and 25 kB is 42% of what the package used to weigh. The
|
|
1103
|
+
first block on a page paints its code plain and colours in when the chunk
|
|
1104
|
+
lands; every block after that is coloured on its first paint, because the
|
|
1105
|
+
loaded highlighter is kept. There is no layout shift either way: the text is
|
|
1106
|
+
the same, only the colour arrives late.
|
|
1107
|
+
|
|
1108
|
+
If that trade is wrong for you — a docs tool where every answer is code —
|
|
1109
|
+
`import { loadHighlighter } from "inline-chat-kit"` and call it once at
|
|
1110
|
+
start-up. It is idempotent and concurrent callers share the one fetch.
|
|
1111
|
+
`canHighlight(lang)` answers whether a language is one of the eleven **without
|
|
1112
|
+
loading anything**, for a caller deciding what to draw.
|
|
1113
|
+
|
|
1114
|
+
The scheme is ink at four weights rather than a syntax palette — this kit is
|
|
1115
|
+
ink, paper and one acid yellow, and twelve colours dropped into it read as
|
|
1116
|
+
somebody else's component. Six tokens (`--ick-code-keyword`, `-string`,
|
|
1117
|
+
`-comment`, `-name`, `-number`, `-attr`) turn it into whatever palette you
|
|
1118
|
+
already own.
|
|
1119
|
+
|
|
1120
|
+
A block is **not markable**. Preformatted text split into word tokens stops
|
|
1121
|
+
being preformatted, so the highlighter skips it; copy is what people want from
|
|
1122
|
+
code anyway.
|
|
1123
|
+
|
|
1124
|
+
### `<ChatHeader>`
|
|
1125
|
+
|
|
1126
|
+
The chrome above the conversation: who you are talking to, what about, and the
|
|
1127
|
+
handful of things you can do to the whole thread.
|
|
1128
|
+
|
|
1129
|
+
```tsx
|
|
1130
|
+
<ChatHeader
|
|
1131
|
+
title={firstQuestion}
|
|
1132
|
+
subtitle="Claude Opus 5"
|
|
1133
|
+
backHref="/"
|
|
1134
|
+
actions={[
|
|
1135
|
+
{ id: "bookmarks", label: "Saved highlights", icon: <Bookmark size={16} />, count: 3, pinned: true },
|
|
1136
|
+
{ id: "share", label: "Share", icon: <Share2 size={16} />, onClick: share },
|
|
1137
|
+
]}
|
|
1138
|
+
>
|
|
1139
|
+
<YourModelPicker />
|
|
1140
|
+
</ChatHeader>
|
|
1141
|
+
```
|
|
1142
|
+
|
|
1143
|
+
| Prop | Type | Default | Notes |
|
|
1144
|
+
| --- | --- | --- | --- |
|
|
1145
|
+
| `title` | `ReactNode` | | What the conversation is about |
|
|
1146
|
+
| `subtitle` | `ReactNode` | | Second line — the model, a count, a state |
|
|
1147
|
+
| `avatar` | `ReactNode` | | Drawn before the title |
|
|
1148
|
+
| `headingLevel` | `1`–`6` \| `false` | `2` | The level belongs to your document |
|
|
1149
|
+
| `truncate` | `boolean` | `true` | Long titles get an ellipsis, not a second line |
|
|
1150
|
+
| `onBack` | `() => void` | | Renders a back button |
|
|
1151
|
+
| `backHref` | `string` | | Renders a back link instead |
|
|
1152
|
+
| `backLabel` | `string` | `"Back"` | |
|
|
1153
|
+
| `actions` | `ChatHeaderAction[]` | `[]` | The managed actions. These are what collapse |
|
|
1154
|
+
| `overflowLabel` | `string` | `"More actions"` | |
|
|
1155
|
+
| `variant` | `"plain" \| "glass" \| "bordered"` | `"plain"` | |
|
|
1156
|
+
| `size` | `"s" \| "m" \| "l"` | `"m"` | 40 / 48 / 56px |
|
|
1157
|
+
| `align` | `"start" \| "center"` | `"start"` | `center` is the native arrangement |
|
|
1158
|
+
| `sticky` | `boolean` | `false` | |
|
|
1159
|
+
| `elevateOnScroll` | `boolean` | `sticky` | Border and backdrop appear once content scrolls under |
|
|
1160
|
+
| `collapseActionsAt` | `number \| false` | `520` | Header width, not viewport width |
|
|
1161
|
+
| `landmark` | `boolean` | `true` | `false` inside a panel, where `banner` would be a lie |
|
|
1162
|
+
| `children` | `ReactNode` | | Anything the kit should not manage. Never collapses |
|
|
1163
|
+
|
|
1164
|
+
Each action is `{ id, label, icon, onClick?, href?, count?, active?, disabled?, pinned? }`.
|
|
1165
|
+
`label` is required because an icon has no name of its own, and `count` is
|
|
1166
|
+
folded into that name — the badge is decorative, so a reader who cannot see it
|
|
1167
|
+
still hears "Saved highlights, 3".
|
|
1168
|
+
|
|
1169
|
+
Actions are described rather than handed over as children for one reason:
|
|
1170
|
+
`collapseActionsAt` folds them into a menu when the header is narrow, and a
|
|
1171
|
+
header cannot summarise children it cannot read. Anything with no icon-and-label
|
|
1172
|
+
shape — a segmented control, a model picker — goes in as `children` instead and
|
|
1173
|
+
stays put.
|
|
1174
|
+
|
|
1175
|
+
### `<TextHighlighter>`
|
|
1176
|
+
|
|
1177
|
+
Wraps streamed text and lets the reader mark it up.
|
|
1178
|
+
|
|
1179
|
+
**The text is markdown.** Headings, emphasis, links, lists, blockquotes, code,
|
|
1180
|
+
tables and strikethrough (GFM) all render. Raw HTML in the input is dropped
|
|
1181
|
+
rather than rendered — model output is untrusted, and there is no version of
|
|
1182
|
+
injecting it into the host's page that is worth the surface it opens.
|
|
1183
|
+
|
|
1184
|
+
The marker does not care about any of it. Internally the words stay a **flat
|
|
1185
|
+
array of tokens addressed by index**, and markdown only decides which element
|
|
1186
|
+
each token is drawn inside — so a stroke that starts in plain text and ends
|
|
1187
|
+
inside `**bold**` is one run of indices like any other. Fenced code blocks are
|
|
1188
|
+
the exception: they are preformatted, so they are not tokenised and cannot be
|
|
1189
|
+
marked.
|
|
1190
|
+
|
|
1191
|
+
Parsing costs about 0.9 ms per 1000 characters, and runs once per frame while
|
|
1192
|
+
an answer streams. Fine for an ordinary answer; see the roadmap for where it
|
|
1193
|
+
stops being fine.
|
|
1194
|
+
|
|
1195
|
+
| Prop | Type | Notes |
|
|
1196
|
+
| --- | --- | --- |
|
|
1197
|
+
| `text` | `string` | The text to render |
|
|
1198
|
+
| `selectionMode` | `"marker" \| "precise"` | Freeform drawn marker, or native char-level selection |
|
|
1199
|
+
| `onHighlightComplete` | `(text: string) => void` | Fires when a highlight is drawn |
|
|
1200
|
+
| `onReplyInThread` | `(text: string, rect: DOMRect) => void` | Reader chose "reply in thread" |
|
|
1201
|
+
|
|
1202
|
+
It renders block-level elements for the marker overlay, so give it a `<div>`
|
|
1203
|
+
wrapper, not a `<p>` — a `<div>` inside a `<p>` is invalid HTML and trips a
|
|
1204
|
+
hydration mismatch under SSR.
|
|
1205
|
+
|
|
1206
|
+
### `<ReplyThreadPopup>`
|
|
1207
|
+
|
|
1208
|
+
A focused sub-conversation anchored to a highlighted passage.
|
|
1209
|
+
|
|
1210
|
+
| Prop | Type | Notes |
|
|
1211
|
+
| --- | --- | --- |
|
|
1212
|
+
| `activeReply` | `{ text: string; rect: DOMRect }` | Pass what `onReplyInThread` gave you |
|
|
1213
|
+
| `onClose` | `() => void` | |
|
|
1214
|
+
| `onSave` | `() => void` | |
|
|
1215
|
+
| `onSendMessage` | `(message, quotedText) => Promise<string> \| string` | **Provide this.** Without it the popup streams placeholder copy |
|
|
1216
|
+
|
|
1217
|
+
### `<CustomCursor>`
|
|
1218
|
+
|
|
1219
|
+
Optional. Swaps the pointer for a marker or text caret over elements carrying
|
|
1220
|
+
`data-cursor="marker"` / `data-cursor="text"`. Mount once, near the root, and
|
|
1221
|
+
hide the native cursor yourself:
|
|
1222
|
+
|
|
1223
|
+
```css
|
|
1224
|
+
* { cursor: none; }
|
|
1225
|
+
```
|
|
1226
|
+
|
|
1227
|
+
### Buttons
|
|
1228
|
+
|
|
1229
|
+
`Button` is one component in four materials — `primary`, `secondary`, `ghost`,
|
|
1230
|
+
`glass` — across five sizes (`xs` `s` `m` `l` `xl`, 24 through 48px). It takes
|
|
1231
|
+
`icon`, `iconRight` and `loading`. Icon-only needs an `aria-label`.
|
|
1232
|
+
|
|
1233
|
+
`GlassButton` is a deprecated wrapper around `<Button variant="glass">`, kept so
|
|
1234
|
+
existing call sites keep working. Its `s` / `m` / `l` map to `m` / `l` / `xl`.
|
|
1235
|
+
|
|
1236
|
+
Neither takes a dark-mode prop: the theme is a token swap on an ancestor. See
|
|
1237
|
+
[theming.md](./theming.md).
|
|
1238
|
+
|
|
1239
|
+
## Theming
|
|
1240
|
+
|
|
1241
|
+
The kit reads CSS custom properties, all prefixed `--ick-`. That is the whole
|
|
1242
|
+
interface: no provider, no build step. They sit in a `@layer inline-chat-kit`
|
|
1243
|
+
cascade layer, so **any unlayered rule in your app wins** regardless of import
|
|
1244
|
+
order.
|
|
1245
|
+
|
|
1246
|
+
Colours are built from channel triplets, so a handful of lines moves everything
|
|
1247
|
+
derived from them:
|
|
1248
|
+
|
|
1249
|
+
```css
|
|
1250
|
+
:root {
|
|
1251
|
+
--ick-ink-rgb: 20 20 24; /* text, hovers, borders */
|
|
1252
|
+
--ick-paper-rgb: 253 252 250; /* surfaces, and the light side of glass */
|
|
1253
|
+
--ick-marker-rgb: 120 200 255; /* the highlighter */
|
|
1254
|
+
--ick-font-sans: "Inter", system-ui, sans-serif;
|
|
1255
|
+
--ick-radius-xl: 12px;
|
|
1256
|
+
}
|
|
1257
|
+
```
|
|
1258
|
+
|
|
1259
|
+
Note the spaces rather than commas — they are used as
|
|
1260
|
+
`rgb(var(--ick-ink-rgb) / 0.6)`.
|
|
1261
|
+
|
|
1262
|
+
If a font loader hands you a CSS variable, point the kit at it:
|
|
1263
|
+
|
|
1264
|
+
```css
|
|
1265
|
+
:root {
|
|
1266
|
+
--ick-font-sans: var(--font-geist-sans);
|
|
1267
|
+
--ick-font-mono: var(--font-geist-mono);
|
|
1268
|
+
}
|
|
1269
|
+
```
|
|
1270
|
+
|
|
1271
|
+
**Dark** follows `prefers-color-scheme` on its own. Set `data-theme="light"` or
|
|
1272
|
+
`data-theme="dark"` on the root element to pin it; `.light` and `.dark` work
|
|
1273
|
+
too, for projects that already have them.
|
|
1274
|
+
|
|
1275
|
+
Overrides go on `:root` for the whole page, or on any element with
|
|
1276
|
+
`class="ick-theme"` for a subtree — the class is what makes the derived tokens
|
|
1277
|
+
recompute there.
|
|
1278
|
+
|
|
1279
|
+
[theming.md](./theming.md) has the rest — the per-component tokens, how to
|
|
1280
|
+
adjust dark without touching light, and the two values that deliberately do not
|
|
1281
|
+
follow the theme. The complete list, resolved live, is the first entry in
|
|
1282
|
+
Storybook.
|
|
1283
|
+
|
|
1284
|
+
## Tuning the motion
|
|
1285
|
+
|
|
1286
|
+
`animationConfig` takes the full `InlineAnimConfig` shape — bubble and button
|
|
1287
|
+
springs, ripple timing, the wrap thresholds that decide when a growing input
|
|
1288
|
+
breaks to a new line, action-row stagger, and the radial fan geometry for the
|
|
1289
|
+
`+` menu. Start from the defaults and override what you need:
|
|
1290
|
+
|
|
1291
|
+
```tsx
|
|
1292
|
+
import { ChatInput, defaultInlineAnimConfig } from "inline-chat-kit";
|
|
1293
|
+
|
|
1294
|
+
<ChatInput
|
|
1295
|
+
{...props}
|
|
1296
|
+
animationConfig={{
|
|
1297
|
+
...defaultInlineAnimConfig,
|
|
1298
|
+
bubble: { stiffness: 520, damping: 24, mass: 0.2 },
|
|
1299
|
+
}}
|
|
1300
|
+
/>;
|
|
1301
|
+
```
|
|
1302
|
+
|
|
1303
|
+
## Next.js
|
|
1304
|
+
|
|
1305
|
+
Works in the App Router as-is — the bundle carries a `"use client"` directive.
|
|
1306
|
+
Import the stylesheet from a client component or your root layout.
|
|
1307
|
+
|
|
1308
|
+
A route handler that streams, and the `onSend` that reads it:
|
|
1309
|
+
|
|
1310
|
+
```ts
|
|
1311
|
+
// app/api/chat/route.ts
|
|
1312
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
1313
|
+
|
|
1314
|
+
const anthropic = new Anthropic();
|
|
1315
|
+
|
|
1316
|
+
export async function POST(request: Request) {
|
|
1317
|
+
const { message, quotedText } = await request.json();
|
|
1318
|
+
|
|
1319
|
+
const stream = anthropic.messages.stream({
|
|
1320
|
+
model: "claude-sonnet-5",
|
|
1321
|
+
max_tokens: 1024,
|
|
1322
|
+
// The passage a thread hangs off, when there is one.
|
|
1323
|
+
system: quotedText
|
|
1324
|
+
? `The reader highlighted this passage and is asking about it:\n\n${quotedText}`
|
|
1325
|
+
: undefined,
|
|
1326
|
+
messages: [{ role: "user", content: message }],
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
return new Response(
|
|
1330
|
+
new ReadableStream({
|
|
1331
|
+
async start(controller) {
|
|
1332
|
+
for await (const event of stream) {
|
|
1333
|
+
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
|
|
1334
|
+
controller.enqueue(new TextEncoder().encode(event.delta.text));
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
controller.close();
|
|
1338
|
+
},
|
|
1339
|
+
}),
|
|
1340
|
+
{ headers: { "content-type": "text/plain; charset=utf-8" } }
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
```
|
|
1344
|
+
|
|
1345
|
+
```tsx
|
|
1346
|
+
"use client";
|
|
1347
|
+
|
|
1348
|
+
const { turns, setDraft, submit, stop } = useChatTurns({
|
|
1349
|
+
onSend: async function* (message, { signal }) {
|
|
1350
|
+
const response = await fetch("/api/chat", {
|
|
1351
|
+
method: "POST",
|
|
1352
|
+
headers: { "content-type": "application/json" },
|
|
1353
|
+
body: JSON.stringify({ message }),
|
|
1354
|
+
signal,
|
|
1355
|
+
});
|
|
1356
|
+
if (!response.ok) throw new Error(`chat failed: ${response.status}`);
|
|
1357
|
+
// `signal` aborts the request when the reader presses stop, which ends
|
|
1358
|
+
// this loop and settles the turn with whatever had already arrived.
|
|
1359
|
+
yield* response.body!.pipeThrough(new TextDecoderStream());
|
|
1360
|
+
},
|
|
1361
|
+
});
|
|
1362
|
+
```
|
|
1363
|
+
|
|
1364
|
+
`ReplyThreadPopup` takes the same shape with the quoted passage as a second
|
|
1365
|
+
argument:
|
|
1366
|
+
|
|
1367
|
+
```tsx
|
|
1368
|
+
<ReplyThreadPopup
|
|
1369
|
+
activeReply={activeReply}
|
|
1370
|
+
onClose={() => setActiveReply(null)}
|
|
1371
|
+
onSendMessage={async function* (message, quotedText, { signal }) {
|
|
1372
|
+
const response = await fetch("/api/chat", {
|
|
1373
|
+
method: "POST",
|
|
1374
|
+
headers: { "content-type": "application/json" },
|
|
1375
|
+
body: JSON.stringify({ message, quotedText }),
|
|
1376
|
+
signal,
|
|
1377
|
+
});
|
|
1378
|
+
yield* response.body!.pipeThrough(new TextDecoderStream());
|
|
1379
|
+
}}
|
|
1380
|
+
/>
|
|
1381
|
+
```
|
|
1382
|
+
|
|
1383
|
+
## Contributing
|
|
1384
|
+
|
|
1385
|
+
The dev environment, the tooling and the release live in
|
|
1386
|
+
[CONTRIBUTING.md](../../CONTRIBUTING.md) at the root of the repo. What follows
|
|
1387
|
+
is what is peculiar about *this package's* tests.
|
|
1388
|
+
|
|
1389
|
+
```bash
|
|
1390
|
+
npm install
|
|
1391
|
+
npm test
|
|
1392
|
+
npm run verify # everything CI runs
|
|
1393
|
+
```
|
|
1394
|
+
|
|
1395
|
+
**Storybook is the source of truth for what this looks like.** A component
|
|
1396
|
+
change is not finished until its story shows it — and that is enforced rather
|
|
1397
|
+
than remembered: one test fails when something is exported without a story, and
|
|
1398
|
+
CI builds Storybook so a story that has drifted out of step fails there.
|
|
1399
|
+
|
|
1400
|
+
A second guard fails when a literal colour appears anywhere outside
|
|
1401
|
+
`styles/tokens.css`. Its exception list carries a reason per entry, because a
|
|
1402
|
+
list of paths to ignore becomes a list of things nobody looks at.
|
|
1403
|
+
|
|
1404
|
+
The tests stop at the edge of what jsdom can honestly answer. It has no layout
|
|
1405
|
+
engine and does not implement contenteditable editing, so the wrap thresholds,
|
|
1406
|
+
the overflow fade and the marker's hit-testing are not asserted there — a
|
|
1407
|
+
passing tick for those would be a lie about untested code. They live in the
|
|
1408
|
+
playground, with a real pointer and a real display.
|
|
1409
|
+
|
|
1410
|
+
`TextHighlighter` carries one regression guard worth knowing about: token spans
|
|
1411
|
+
must have no inline styles at rest. Motion writes styles onto elements it
|
|
1412
|
+
drives, so if that test fails, per-word animation has come back — and it cost
|
|
1413
|
+
350 style writes per menu open the last time.
|
|
1414
|
+
|
|
1415
|
+
### Releasing
|
|
1416
|
+
|
|
1417
|
+
Versions before 1.0 follow the pre-release convention: a breaking change bumps
|
|
1418
|
+
the **minor**, a fix bumps the patch.
|
|
1419
|
+
|
|
1420
|
+
```bash
|
|
1421
|
+
npm version minor --workspace packages/inline-chat-kit
|
|
1422
|
+
npm run pack:kit
|
|
1423
|
+
```
|
|
1424
|
+
|
|
1425
|
+
Write the entry in [CHANGELOG.md](./CHANGELOG.md) first, and put anything that
|
|
1426
|
+
would break an existing install under **Breaking** with what to do about it.
|
|
1427
|
+
|
|
1428
|
+
## License
|
|
1429
|
+
|
|
1430
|
+
MIT
|