inline-chat-kit 0.55.2 → 0.56.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 CHANGED
@@ -8,6 +8,53 @@ The versions before 1.0 follow the pre-release convention: **a breaking change
8
8
  or new public API bumps the minor**, and the patch is for fixes. Anything that would break an
9
9
  existing install is called out under **Breaking**, with what to do about it.
10
10
 
11
+ ## 0.56.0 — 2026-09-22
12
+
13
+ Four things that kept the kit out of a real host app: an answer could only
14
+ hold the kit's own parts, an approval always offered "forever", an assembled
15
+ chat could not be translated, and it drew controls a host had no use for.
16
+ Nothing here changes what an existing install draws.
17
+
18
+ ### Added
19
+
20
+ - **`{ kind: "custom", id, type, data }` — the host's own card in an answer.**
21
+ Every other part is a component in this package; this one is drawn by the
22
+ new `renderPart(part, { turnId })` prop on `ChatTurnRow` and
23
+ `ChatExperience`, in its place among the other parts and inside the answer's
24
+ entrance. Merged by id like the rest, so `{ kind: "custom", id, data }`
25
+ replaces `data` and keeps `type`. Without a renderer, or when it returns
26
+ `null`, the part draws nothing. `CustomPart` is exported.
27
+ - **`chat` on `ChatExperience`.** It called `useChatTurns` itself, so a host
28
+ had no `updatePart` and a card could not change after the answer finished.
29
+ Pass your own hook's result and it is used instead; left out, nothing
30
+ changes. `onSend` is optional when `chat` is given.
31
+ - **`choices` on `<Approval>` and on the approval part.** `["once", "deny"]`
32
+ leaves "Always allow" out. Allow once stays the primary and Deny stays first.
33
+ `ApprovalChoice` is exported.
34
+ - **`labels` on `ChatExperience` and `ChatTurnRow`, and `LabelsProvider`.** One
35
+ `ChatLabels` object, grouped by component and partial, reaches every piece
36
+ through context — the header, the composer and its menu, the microphone and
37
+ what it announces, the highlighter, the thread, the code block's copy, the
38
+ pane, the jump button, the option letters, and every part. The strings that
39
+ were hard-coded English with no way in now all go through it.
40
+ `defaultLabels` is the English set. The individual `labels` props keep
41
+ working and win over the provider.
42
+ - **`headerActions` and `composerMenu` on `ChatExperience`.**
43
+ `headerActions={false}` (or `["theme"]`, `["share"]`) drops the built-in
44
+ header actions; `composerMenu={false}` removes the "+", and a list of
45
+ `{ id, label, icon?, onSelect? }` replaces its three entries. `ChatInput`
46
+ takes the same list as `menu`. `ComposerMenuItem` is exported.
47
+
48
+ ### Fixed
49
+
50
+ - **Every row in `ChatExperience` re-rendered on every frame of an answer.**
51
+ `onOpenArtifact` was an arrow written inline in the rows' props — a new
52
+ function each render — so `ChatTurnRow`'s memo never held inside the
53
+ assembled experience. Hoisted; the custom-part test fails without it.
54
+ - **The composer's menu opened the file picker by matching the word "Add".**
55
+ Translated, it would have stopped attaching anything. It matches the entry's
56
+ id now.
57
+
11
58
  ## 0.55.2 — 2026-09-16
12
59
 
13
60
  ### Fixed
package/README.md CHANGED
@@ -153,6 +153,14 @@ Which is why the callbacks take the turn's id rather than being closed over per
153
153
  row. Pass the hook's own functions straight through — they are stable. An arrow
154
154
  created during render is not, and hands the memo a new prop every time.
155
155
 
156
+ `renderPart` is a callback like the others and follows the same rule: define
157
+ it outside the component, or wrap it in `useCallback` / `useMemo`. A renderer
158
+ written inline is a new function every render, which is a changed prop on every
159
+ row — so every finished answer re-renders on every frame of the one arriving,
160
+ exactly the cost the memo exists to remove. What a card needs to change (it was
161
+ applied, it was sent) belongs in the part's `data`, written back with
162
+ `updatePart`; then only the row that owns it re-renders.
163
+
156
164
  If you write your own row instead, wrap it in `React.memo` and do the same.
157
165
 
158
166
  ## The four states
@@ -217,6 +225,9 @@ input that morphs into its own bubble rather than a record of what was typed.
217
225
  | `onFeedback` | `(id, verdict) => void` | | Draws the thumbs |
218
226
  | `feedback` | `"up" \| "down" \| null` | `null` | Which one is lit |
219
227
  | `answerActions` | `boolean` | `true` | Leave the row out |
228
+ | `renderPart` | `(part, { turnId }) => ReactNode` | | Draws `custom` parts — see below. Keep it stable |
229
+ | `composerMenu` | `ComposerMenuItem[] \| false` | built-in three | The composer's "+" menu; `false` removes the "+" |
230
+ | `labels` | `ChatLabels` | English | Every word the row says — see [Labels](#labels-every-word-the-kit-says) |
220
231
 
221
232
  The live row carries **`data-active-input`**, so a page can style the composer
222
233
  without knowing which turn it is. The case it exists for: a gradient fading the
@@ -411,15 +422,45 @@ component that owns it.
411
422
  | `tasks` | `<TaskList>` | `title`, `tasks`, `collapsible` |
412
423
  | `chain` | `<ChainOfThought>` | `steps`, `state`, `duration` |
413
424
  | `sources` | `<Sources>` | `sources`, `title`, `collapsible` |
414
- | `approval` | `<Approval>` | `title`, `description`, `tool`, `decision` |
425
+ | `approval` | `<Approval>` | `title`, `description`, `tool`, `decision`, `choices` |
415
426
  | `question` | `<QuestionGroup>` | `title`, `questions`, `answers`, `activeIndex`, `collapsible` |
416
427
  | `notice` | `<SystemMessage>` | `text`, `tone` |
417
428
  | `artifact` | `<ArtifactCard>` | `title`, `meta`, `preview`, `lang`, `content`, `state` |
429
+ | `custom` | whatever `renderPart` returns | `type`, `data` — the host's own card |
418
430
 
419
431
  `reasoning` and `chain` are the same job at two grains — a block of prose, or
420
432
  steps that follow from one another. Sending both for one stretch of thinking
421
433
  says it twice.
422
434
 
435
+ #### Your own cards: `custom`
436
+
437
+ Every other kind is a component in this package. `custom` is the host's: `type`
438
+ says which of your cards it is, `data` is whatever that card needs, and
439
+ `renderPart` draws it — in its place among the other parts, inside the answer
440
+ and its entrance.
441
+
442
+ ```tsx
443
+ import type { CustomPart } from "inline-chat-kit";
444
+
445
+ // Streamed like any part…
446
+ yield { kind: "custom", id: "plan", type: "plan-diff", data: { changes, status: "proposed" } };
447
+
448
+ // …drawn by the host. Outside the component, so it is stable.
449
+ const renderPart = (part: CustomPart, { turnId }: { turnId: string }) =>
450
+ part.type === "plan-diff" ? <PlanDiff turnId={turnId} id={part.id} {...(part.data as PlanDiffData)} /> : null;
451
+
452
+ // …and changed after the answer has finished, by id. Only `data` is replaced.
453
+ updatePart(turnId, { kind: "custom", id: "plan", data: { changes, status: "applied" } });
454
+ ```
455
+
456
+ Without `renderPart`, or when it returns `null`, the part draws nothing — no
457
+ placeholder, no error. A type the host does not recognise is a type it chose
458
+ not to draw. `data` is replaced whole on an update, not merged: it is yours,
459
+ and the kit does not guess its shape.
460
+
461
+ `ChatExperience` calls `useChatTurns` itself, so a host using it passes its own
462
+ instead — `chat={useChatTurns({ onSend })}` — to hold `updatePart`.
463
+
423
464
  #### Citing a source from the prose
424
465
 
425
466
  `[^1]` in the answer is a **citation marker**, and the number is a position in
@@ -567,6 +608,7 @@ can already see.
567
608
  | `decision` | `"once" \| "always" \| "denied" \| null` | `null` while it is still asking |
568
609
  | `onDecide` | `(decision) => void` | |
569
610
  | `readOnly` | `boolean` | A record of a decision made elsewhere |
611
+ | `choices` | `("once" \| "always" \| "deny")[]` | Which answers are offered. All three by default |
570
612
 
571
613
  **Three answers, not two.** "Yes" and "yes forever" are not the same answer,
572
614
  and a UI offering one button for both collects the wrong one. **Allow once is
@@ -578,6 +620,15 @@ too, so a keyboard reaches the safe answer without tabbing past the other two.
578
620
  It turns red only under the pointer, because a permanently red button is the
579
621
  first thing the eye lands on.
580
622
 
623
+ **Two answers, where "forever" means nothing.** Applying one change to a plan
624
+ happens once; `choices={["once", "deny"]}` leaves "Always allow" out. Nothing
625
+ else moves: Allow once is still the primary, Deny is still first in the DOM and
626
+ alone on the left, and the record it settles into says only what was decided.
627
+
628
+ ```tsx
629
+ <Approval title="Apply the change to your plan" choices={["once", "deny"]} onDecide={decide} />
630
+ ```
631
+
581
632
  Give it something to show. An approval with nothing under it is asking for a
582
633
  signature on a blank page.
583
634
 
@@ -586,7 +637,7 @@ decided. Live controls under a decision already made invite a second one that
586
637
  contradicts the first.
587
638
 
588
639
  As a `TurnPart` it is `{ kind: "approval", id, title, description?, tool?,
589
- decision? }` — data, like every part, so the tool it names is drawn for it
640
+ decision?, choices? }` — data, like every part, so the tool it names is drawn for it
590
641
  rather than passed in as an element. `<ChatTurnRow>` reports through
591
642
  `onDecideApproval`.
592
643
 
@@ -1266,6 +1317,63 @@ existing call sites keep working. Its `s` / `m` / `l` map to `m` / `l` / `xl`.
1266
1317
  Neither takes a dark-mode prop: the theme is a token swap on an ancestor. See
1267
1318
  [theming.md](./theming.md).
1268
1319
 
1320
+ ## Labels: every word the kit says
1321
+
1322
+ Every component takes a `labels` prop, and `ChatExperience` and `ChatTurnRow`
1323
+ take one object for all of them — grouped by the component that says it, and
1324
+ partial: anything left out stays English.
1325
+
1326
+ ```tsx
1327
+ import { ChatExperience, type ChatLabels } from "inline-chat-kit";
1328
+
1329
+ const sr: ChatLabels = {
1330
+ input: { placeholder: "Pitaj bilo šta…", send: "Pošalji poruku", copy: "Kopiraj", edit: "Izmeni" },
1331
+ approval: { once: "Dozvoli jednom", deny: "Odbij", allowedOnce: "Dozvoljeno jednom", wasDenied: "Odbijeno" },
1332
+ reasoning: { thinking: "Razmišljam", thoughtFor: "Razmišljao" },
1333
+ answerActions: { copy: "Kopiraj odgovor", regenerate: "Ponovo" },
1334
+ tasks: { progress: "{done} od {total}" },
1335
+ };
1336
+
1337
+ <ChatExperience onSend={send} labels={sr} />;
1338
+ ```
1339
+
1340
+ What counts as a word: text on screen, an `aria-label`, a `title`, a
1341
+ placeholder, text only a screen reader gets, and what is said through the live
1342
+ region. `{name}` in a value is filled in — keep it, and move it wherever the
1343
+ language wants it. `defaultLabels` is the full English set, which is also the
1344
+ list of what there is to translate.
1345
+
1346
+ It travels by context, not by a prop on every row, so the rows' memo is not
1347
+ disturbed. For pieces assembled by hand, `LabelsProvider` does what
1348
+ `ChatExperience` does:
1349
+
1350
+ ```tsx
1351
+ <LabelsProvider labels={sr}>
1352
+ {turns.map((turn) => <ChatTurnRow key={turn.id} turn={turn} /* … */ />)}
1353
+ </LabelsProvider>
1354
+ ```
1355
+
1356
+ The value is compared by content, so an object written inline costs nothing
1357
+ after the first render. A component's own `labels` prop wins over the provider
1358
+ for that one instance.
1359
+
1360
+ ### Turning off what you do not use
1361
+
1362
+ `ChatExperience` draws a theme toggle and Share in the header and a "+" menu in
1363
+ the composer. A host that has none of those says so:
1364
+
1365
+ ```tsx
1366
+ <ChatExperience
1367
+ onSend={send}
1368
+ headerActions={false} // or ["theme"], or ["share"]
1369
+ composerMenu={false} // or your own: [{ id, label, icon?, onSelect }]
1370
+ />
1371
+ ```
1372
+
1373
+ The saved highlights button is not one of the header's actions — it appears
1374
+ only once there is a highlight, and it is the only way back to them. An entry in
1375
+ your own menu with id `"attach"` and no `onSelect` opens the file picker.
1376
+
1269
1377
  ## Theming
1270
1378
 
1271
1379
  The kit reads CSS custom properties, all prefixed `--ick-`. That is the whole
@@ -1,7 +1,10 @@
1
1
  import { HTMLAttributes, ReactNode } from 'react';
2
+ import { ApprovalLabels } from '../labels/labels';
2
3
  /** Once, every time from now on, or not at all. */
3
4
  export type Decision = "once" | "always" | "denied";
4
- type Labels = Record<"once" | "always" | "deny", string> & Record<"allowedOnce" | "allowedAlways" | "wasDenied" | "pending", string>;
5
+ /** The buttons, by what they answer. `"deny"` is the button; `"denied"` the record. */
6
+ export type ApprovalChoice = "once" | "always" | "deny";
7
+ type Labels = ApprovalLabels;
5
8
  export interface ApprovalProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
6
9
  /** What is being asked. One line: "Run a command in your shell". */
7
10
  title: ReactNode;
@@ -20,6 +23,14 @@ export interface ApprovalProps extends Omit<HTMLAttributes<HTMLElement>, "title"
20
23
  onDecide?: (decision: Decision) => void;
21
24
  /** Nothing can be decided from here — a record of a decision already made. */
22
25
  readOnly?: boolean;
26
+ /**
27
+ * Which answers are offered. All three when left out.
28
+ *
29
+ * Leave out `"always"` where a standing permission means nothing — applying
30
+ * one edit to a plan is a thing that happens once. "Allow once" stays the
31
+ * primary and Deny stays first; only the button that is not offered goes.
32
+ */
33
+ choices?: ApprovalChoice[];
23
34
  labels?: Partial<Labels>;
24
35
  }
25
36
  /**
@@ -38,5 +49,5 @@ export interface ApprovalProps extends Omit<HTMLAttributes<HTMLElement>, "title"
38
49
  * decided. Leaving live controls under a decision already made invites a
39
50
  * second, contradictory one.
40
51
  */
41
- export declare function Approval({ title, description, children, decision, onDecide, readOnly, labels, className, ...rest }: ApprovalProps): import("react").JSX.Element;
52
+ export declare function Approval({ title, description, children, decision, onDecide, readOnly, choices, labels, className, ...rest }: ApprovalProps): import("react").JSX.Element;
42
53
  export {};
@@ -47,4 +47,4 @@ export interface ArtifactPaneProps extends Omit<HTMLAttributes<HTMLDivElement>,
47
47
  * conversation, and it must **not** be trapped unless the pane is covering
48
48
  * that conversation. Every host gets one of those two wrong.
49
49
  */
50
- export declare function ArtifactPane({ title, meta, children, onClose, modal, expanded, onToggleExpanded, closeLabel, expandLabel, collapseLabel, className, ...rest }: ArtifactPaneProps): import("react").JSX.Element;
50
+ export declare function ArtifactPane({ title, meta, children, onClose, modal, expanded, onToggleExpanded, closeLabel: closeProp, expandLabel: expandProp, collapseLabel: collapseProp, className, ...rest }: ArtifactPaneProps): import("react").JSX.Element;
@@ -1,4 +1,5 @@
1
1
  import { HTMLAttributes } from 'react';
2
+ import { LabelsOf } from '../labels/labels';
2
3
  /** Something sent along with a message. */
3
4
  export interface Attachment {
4
5
  id: string;
@@ -16,9 +17,7 @@ export interface Attachment {
16
17
  /** In bytes. Shown beside the name. */
17
18
  size?: number;
18
19
  }
19
- type Labels = {
20
- remove: string;
21
- };
20
+ type Labels = LabelsOf<"attachments">;
22
21
  export interface AttachmentsProps extends Omit<HTMLAttributes<HTMLUListElement>, "onRemove"> {
23
22
  attachments: Attachment[];
24
23
  /**
@@ -1,10 +1,6 @@
1
1
  import { HTMLAttributes } from 'react';
2
- type Labels = {
3
- previous: string;
4
- next: string;
5
- /** `{index}` and `{total}` are filled in. */
6
- position: string;
7
- };
2
+ import { LabelsOf } from '../labels/labels';
3
+ type Labels = LabelsOf<"branch">;
8
4
  export interface BranchProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
9
5
  /** How many answers this turn has had. */
10
6
  total: number;
@@ -1,5 +1,6 @@
1
1
  import { HTMLAttributes, ReactNode } from 'react';
2
2
  import { WorkState } from '../stateGlyph/StateGlyph';
3
+ import { LabelsOf } from '../labels/labels';
3
4
  /** One step of a derivation: what was worked out, and the working. */
4
5
  export interface Thought {
5
6
  id: string;
@@ -10,13 +11,7 @@ export interface Thought {
10
11
  /** Defaults to `done`: a step you can read is a step that happened. */
11
12
  state?: WorkState;
12
13
  }
13
- type Labels = {
14
- /** Followed by the count: "Thought through 4 steps". */
15
- through: string;
16
- step: string;
17
- steps: string;
18
- thinking: string;
19
- };
14
+ type Labels = LabelsOf<"chain">;
20
15
  export interface ChainOfThoughtProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
21
16
  steps: Thought[];
22
17
  /** The chain as a whole. `thinking` holds it open and narrates the last step. */
@@ -1,11 +1,13 @@
1
1
  import { ReactNode } from 'react';
2
2
  import { ChatHeaderAction } from '../ChatHeader/ChatHeader';
3
3
  import { ReplyThreadPopupProps } from '../ReplyThreadPopup/ReplyThreadPopup';
4
- import { UseChatTurnsOptions } from '../useChatTurns/useChatTurns';
4
+ import { UseChatTurnsOptions, UseChatTurnsResult } from '../useChatTurns/useChatTurns';
5
5
  import { FoldMotion } from '../QuestionGroup/QuestionGroup';
6
6
  import { InlineAnimConfig } from '../ChatInput/ChatInput';
7
7
  import { TranscribeHandler } from '../voice/useVoiceInput';
8
- import { TurnPartUpdate } from '../turnParts/turnParts';
8
+ import { CustomPart, TurnPartUpdate } from '../turnParts/turnParts';
9
+ import { ComposerMenuItem } from '../ChatInput/AddCardsOverlay';
10
+ import { ChatLabels } from '../labels/labels';
9
11
  import { Answer } from '../QuestionCard/types';
10
12
  import { Decision } from '../Approval/Approval';
11
13
  /**
@@ -60,10 +62,50 @@ export interface ChatExperienceArtifact {
60
62
  }
61
63
  /** A part the host drives, handed the writer for the turn it belongs to. */
62
64
  export type PartWriter = (turnId: string, part: TurnPartUpdate) => void;
65
+ /** The header's built-in actions, by id. */
66
+ export type ChatExperienceHeaderAction = "theme" | "share";
63
67
  export interface ChatExperienceProps {
64
68
  /** Where answers come from. Return a string, a promise of one, or an async
65
- iterable of deltas — see `useChatTurns`. */
66
- onSend: UseChatTurnsOptions["onSend"];
69
+ iterable of deltas — see `useChatTurns`. Required unless `chat` is
70
+ given, which brings its own. */
71
+ onSend?: UseChatTurnsOptions["onSend"];
72
+ /**
73
+ * The conversation, held by the host.
74
+ *
75
+ * Left out, this component calls `useChatTurns` itself and nothing outside
76
+ * can reach the turns. Pass what your own `useChatTurns` returned and the
77
+ * host has the same handle this does — `updatePart` in particular, which is
78
+ * how a card in an answer changes after the answer has finished ("Apply" →
79
+ * "Applied"). `onSend` is ignored then; the hook you called has one.
80
+ */
81
+ chat?: UseChatTurnsResult;
82
+ /**
83
+ * Draws the host's own `{ kind: "custom" }` parts. See `ChatTurnRow`. Keep
84
+ * it stable — outside the component or in `useCallback` — or every row
85
+ * re-renders on every frame of an answer arriving.
86
+ */
87
+ renderPart?: (part: CustomPart, context: {
88
+ turnId: string;
89
+ }) => ReactNode;
90
+ /**
91
+ * Every word the chat says, grouped by the component that says it. Partial:
92
+ * anything left out stays English. Reaches every piece through context, so
93
+ * an inline object costs nothing — it is compared by content.
94
+ */
95
+ labels?: ChatLabels;
96
+ /**
97
+ * The header's built-in actions. `true` (the default) is the theme toggle
98
+ * and Share; `false` is neither; a list keeps only those named. The saved
99
+ * highlights button is not one of these — it appears only once there is a
100
+ * highlight, and it is the only way back to them. `actions` still adds your
101
+ * own after whatever is left.
102
+ */
103
+ headerActions?: boolean | ChatExperienceHeaderAction[];
104
+ /**
105
+ * The composer's "+" menu. `false` takes the "+" away; a list replaces the
106
+ * built-in three (Add, Design, Connectors). Keep a list stable.
107
+ */
108
+ composerMenu?: ComposerMenuItem[] | false;
67
109
  /** Speech to text. The kit records; the host transcribes. Omit and the
68
110
  microphone does not appear. */
69
111
  onTranscribe?: TranscribeHandler;
@@ -112,4 +154,4 @@ export interface ChatExperienceProps {
112
154
  onDecideApproval?: (write: PartWriter, turnId: string, partId: string, decision: Decision) => void;
113
155
  className?: string;
114
156
  }
115
- export declare function ChatExperience({ onSend, onTranscribe, onThreadReply, title, backHref, backLabel, actions, placeholder, empty, contextTotal, contextBase, artifact, theme: themeProp, onThemeChange, cursor, selectionToggle, anchorOffset, endOffset, animationConfig, foldMotion, feedDelay, onAnswerQuestion, onEditQuestion, onDecideApproval, className, }: ChatExperienceProps): import("react").JSX.Element;
157
+ export declare function ChatExperience({ onSend, chat: hostChat, renderPart, labels, headerActions: builtInActions, composerMenu, onTranscribe, onThreadReply, title: titleProp, backHref, backLabel, actions, placeholder, empty, contextTotal, contextBase, artifact, theme: themeProp, onThemeChange, cursor, selectionToggle, anchorOffset, endOffset, animationConfig, foldMotion, feedDelay, onAnswerQuestion, onEditQuestion, onDecideApproval, className, }: ChatExperienceProps): import("react").JSX.Element;