@elabs-ai/components-ai 4.0.0 → 4.1.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.
Files changed (128) hide show
  1. package/README.md +61 -11
  2. package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
  3. package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
  4. package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
  5. package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
  6. package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
  7. package/dist/_persona-rive-JEG44YHX.js.map +1 -0
  8. package/dist/index.d.ts +1109 -202
  9. package/dist/index.js +4867 -2878
  10. package/dist/index.js.map +1 -1
  11. package/package.json +31 -16
  12. package/src/_audio-player-media-chrome.tsx +102 -15
  13. package/src/_flow-boundary.tsx +68 -49
  14. package/src/_lazy-boundary-conformance.ts +38 -0
  15. package/src/_lazy-engine-boundary.tsx +61 -0
  16. package/src/_lazy-mermaid-absent.test.ts +53 -0
  17. package/src/_lazy-mermaid.test.ts +15 -0
  18. package/src/_lazy-mermaid.ts +24 -1
  19. package/src/_mermaid-error-panel.test.tsx +50 -0
  20. package/src/_mermaid-error-panel.tsx +66 -0
  21. package/src/_persona-rive.tsx +62 -10
  22. package/src/_streamdown-i18n.ts +21 -1
  23. package/src/_streamdown-safety.ts +170 -0
  24. package/src/agent-event.stories.tsx +97 -0
  25. package/src/agent-event.test.tsx +145 -0
  26. package/src/agent-event.tsx +187 -0
  27. package/src/agent-timeline.stories.tsx +9 -1
  28. package/src/agent.test.tsx +47 -0
  29. package/src/agent.tsx +28 -14
  30. package/src/agentic-workspace.stories.tsx +27 -20
  31. package/src/artifact.tsx +15 -12
  32. package/src/audio-player.test.tsx +78 -0
  33. package/src/audio-player.tsx +243 -56
  34. package/src/audio-visualizer.stories.tsx +126 -0
  35. package/src/audio-visualizer.test.tsx +438 -0
  36. package/src/audio-visualizer.tsx +367 -0
  37. package/src/canvas.stories.tsx +150 -1
  38. package/src/chat-shell.stories.tsx +18 -3
  39. package/src/chat.stories.tsx +16 -2
  40. package/src/code-block.stories.tsx +9 -1
  41. package/src/composer.stories.tsx +372 -9
  42. package/src/composer.test.tsx +357 -6
  43. package/src/composer.tsx +217 -35
  44. package/src/confirmation.stories.tsx +72 -1
  45. package/src/confirmation.test.tsx +216 -2
  46. package/src/confirmation.tsx +263 -3
  47. package/src/context-panel.stories.tsx +9 -1
  48. package/src/context-panel.tsx +2 -1
  49. package/src/conversation.stories.tsx +63 -2
  50. package/src/conversation.test.tsx +13 -0
  51. package/src/conversation.tsx +20 -2
  52. package/src/diff-view.stories.tsx +196 -0
  53. package/src/diff-view.test.tsx +188 -0
  54. package/src/diff-view.tsx +642 -0
  55. package/src/gallery.tsx +4 -2
  56. package/src/index.ts +14 -4
  57. package/src/jsx-preview.stories.tsx +2 -2
  58. package/src/markdown-view.stories.tsx +92 -1
  59. package/src/markdown-view.test.tsx +232 -1
  60. package/src/markdown-view.tsx +150 -6
  61. package/src/message-compare.stories.tsx +175 -0
  62. package/src/message-compare.test.tsx +207 -0
  63. package/src/message-compare.tsx +453 -0
  64. package/src/message-form.stories.tsx +29 -1
  65. package/src/message.stories.tsx +9 -1
  66. package/src/message.test.tsx +176 -0
  67. package/src/message.tsx +90 -4
  68. package/src/microcopy.test.tsx +40 -0
  69. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  70. package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
  71. package/src/model-provider-logo.tsx +149 -0
  72. package/src/permission-mode-select.stories.tsx +82 -0
  73. package/src/permission-mode-select.test.tsx +100 -0
  74. package/src/permission-mode-select.tsx +137 -0
  75. package/src/persona-missing-peer.test.tsx +54 -0
  76. package/src/persona.tsx +68 -22
  77. package/src/plan.stories.tsx +166 -0
  78. package/src/plan.test.tsx +267 -0
  79. package/src/plan.tsx +182 -20
  80. package/src/prompt-input-effort.stories.tsx +123 -0
  81. package/src/prompt-input-effort.test.tsx +83 -0
  82. package/src/prompt-input-effort.tsx +136 -0
  83. package/src/prompt-input-mode.stories.tsx +108 -0
  84. package/src/prompt-input-mode.test.tsx +99 -0
  85. package/src/prompt-input-mode.tsx +169 -0
  86. package/src/prompt-input-slash.stories.tsx +211 -0
  87. package/src/prompt-input-slash.test.tsx +262 -0
  88. package/src/prompt-input-slash.tsx +541 -0
  89. package/src/prompt-input.stories.tsx +2 -2
  90. package/src/reasoning.tsx +27 -13
  91. package/src/sandbox.stories.tsx +9 -1
  92. package/src/schema-display.tsx +5 -2
  93. package/src/selection-toolbar.stories.tsx +9 -1
  94. package/src/session-header.stories.tsx +128 -0
  95. package/src/session-header.test.tsx +138 -0
  96. package/src/session-header.tsx +243 -0
  97. package/src/session-status-bar.stories.tsx +73 -0
  98. package/src/session-status-bar.test.tsx +94 -0
  99. package/src/session-status-bar.tsx +165 -0
  100. package/src/snippet.stories.tsx +9 -1
  101. package/src/stack-trace.tsx +6 -2
  102. package/src/streamdown-i18n.test.tsx +1 -1
  103. package/src/task.stories.tsx +13 -3
  104. package/src/templates-ai-assistant.stories.tsx +21 -1
  105. package/src/token-usage.stories.tsx +24 -0
  106. package/src/token-usage.test.tsx +92 -0
  107. package/src/{context.tsx → token-usage.tsx} +67 -53
  108. package/src/tool-result-card.stories.tsx +9 -1
  109. package/src/tool.stories.tsx +13 -3
  110. package/src/tool.tsx +15 -8
  111. package/src/turn-status.stories.tsx +124 -0
  112. package/src/turn-status.test.tsx +74 -0
  113. package/src/turn-status.tsx +174 -0
  114. package/src/use-audio-level.ts +104 -0
  115. package/src/web-preview.tsx +8 -3
  116. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  117. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  118. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  119. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  120. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  121. package/src/_interactive-terminal-xterm.ts +0 -32
  122. package/src/blocks-ai-composer.stories.tsx +0 -83
  123. package/src/context.stories.tsx +0 -16
  124. package/src/interactive-terminal.stories.tsx +0 -165
  125. package/src/interactive-terminal.test.tsx +0 -448
  126. package/src/interactive-terminal.tsx +0 -444
  127. package/src/model-selector.tsx +0 -225
  128. package/src/terminal.tsx +0 -244
@@ -0,0 +1,82 @@
1
+ /**
2
+ * PermissionModeSelect — the standing-permission-policy chooser (#104).
3
+ *
4
+ * Distinct from `ApprovalCard`/`Confirmation` (#103): that renders ONE
5
+ * per-call decision; this renders the standing POLICY that decides how many
6
+ * of those decisions ever get asked. The mode vocabulary below is example
7
+ * data only — the component hardcodes no agent's mode names.
8
+ */
9
+ import type { Meta, StoryObj } from "@storybook/react-vite";
10
+ import { expect, fn } from "storybook/test";
11
+
12
+ import { PermissionModeSelect, type PermissionMode } from "./permission-mode-select";
13
+
14
+ const MODES: PermissionMode[] = [
15
+ {
16
+ id: "ask",
17
+ label: "Ask each time",
18
+ consequence: "Every command and file edit waits for your approval before it runs.",
19
+ },
20
+ {
21
+ id: "auto-safe",
22
+ label: "Auto-approve safe actions",
23
+ consequence:
24
+ "Reads and edits inside this project run without asking; a shell command, or anything outside the project, still asks.",
25
+ keyHint: "⌥⇧A",
26
+ },
27
+ {
28
+ id: "unrestricted",
29
+ label: "Unrestricted",
30
+ consequence:
31
+ "Every action runs immediately, including shell commands and edits outside this project — nothing is held back for approval.",
32
+ keyHint: "⌥⇧U",
33
+ },
34
+ ];
35
+
36
+ const meta = {
37
+ title: "AI/PermissionModeSelect",
38
+ component: PermissionModeSelect,
39
+ parameters: {
40
+ layout: "padded",
41
+ docs: {
42
+ description: {
43
+ component:
44
+ "A standing-permission-policy chooser: each mode carries the sentence describing what it actually permits, and the mode currently in force is marked in text (not colour alone). Built on the RadioGroup primitive for keyboard/announcement behaviour; the mode vocabulary is entirely app-supplied via the `modes` prop.",
45
+ },
46
+ },
47
+ },
48
+ args: {
49
+ modes: MODES,
50
+ currentId: "ask",
51
+ onValueChange: fn(),
52
+ },
53
+ tags: ["autodocs"],
54
+ } satisfies Meta<typeof PermissionModeSelect>;
55
+
56
+ export default meta;
57
+ type Story = StoryObj<typeof meta>;
58
+
59
+ /** Three modes, each with its consequence sentence; "Ask each time" is in force. */
60
+ export const Default: Story = {
61
+ render: (args) => <PermissionModeSelect {...args} className="max-w-md" />,
62
+ };
63
+
64
+ /** Selecting a different mode reports the new id without moving the "Current" marker. */
65
+ export const SelectingADifferentMode: Story = {
66
+ render: (args) => <PermissionModeSelect {...args} className="max-w-md" />,
67
+ play: async ({ canvas, args, userEvent }) => {
68
+ const current = canvas.getByRole("radio", { name: /ask each time.*current/i });
69
+ await expect(current).toBeChecked();
70
+
71
+ const unrestricted = canvas.getByRole("radio", { name: "Unrestricted" });
72
+ await userEvent.click(unrestricted);
73
+
74
+ await expect(unrestricted).toBeChecked();
75
+ await expect(args.onValueChange).toHaveBeenCalledWith("unrestricted");
76
+ // The mode in force is app-controlled — selecting a new highlight does not
77
+ // relabel a different mode "current" on its own.
78
+ await expect(
79
+ canvas.getByRole("radio", { name: /ask each time.*current/i }),
80
+ ).toBeInTheDocument();
81
+ },
82
+ };
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { PermissionModeSelect, type PermissionMode } from "./permission-mode-select";
5
+
6
+ const modes: PermissionMode[] = [
7
+ {
8
+ id: "ask",
9
+ label: "Ask each time",
10
+ consequence: "Waits for your approval before every command and file edit.",
11
+ },
12
+ {
13
+ id: "auto",
14
+ label: "Auto-approve safe actions",
15
+ consequence:
16
+ "Approves reads and edits in this project automatically; shell commands still ask.",
17
+ },
18
+ {
19
+ id: "full",
20
+ label: "Unrestricted",
21
+ consequence: "Runs every action immediately — nothing is held back for approval.",
22
+ },
23
+ ];
24
+
25
+ describe("PermissionModeSelect", () => {
26
+ it("renders every mode's consequence sentence", () => {
27
+ render(<PermissionModeSelect currentId="ask" modes={modes} />);
28
+
29
+ for (const mode of modes) {
30
+ expect(screen.getByText(mode.consequence)).toBeInTheDocument();
31
+ }
32
+ });
33
+
34
+ it("links each consequence sentence to its radio as an accessible DESCRIPTION", () => {
35
+ // Rendering the sentence is not the same as delivering it. A screen-reader
36
+ // user arrowing through the modes hears the radio's name and its
37
+ // description — nothing else in the option's box. Without the
38
+ // `aria-describedby` link they hear "Unrestricted" and never hear what
39
+ // unrestricted costs them, which is the exact failure this component's
40
+ // required `consequence` field exists to prevent. Asserting the text is
41
+ // merely present would pass on that broken markup, so assert the LINK.
42
+ render(<PermissionModeSelect currentId="ask" modes={modes} />);
43
+
44
+ for (const mode of modes) {
45
+ const radio = screen.getByRole("radio", { name: new RegExp(mode.label) });
46
+ const describedBy = radio.getAttribute("aria-describedby");
47
+
48
+ expect(describedBy).toBeTruthy();
49
+ expect(document.getElementById(describedBy as string)).toHaveTextContent(mode.consequence);
50
+ }
51
+ });
52
+
53
+ it("marks the in-force mode in the ACCESSIBLE NAME, not only via a class or data-* attribute", () => {
54
+ render(<PermissionModeSelect currentId="auto" modes={modes} />);
55
+
56
+ // The load-bearing assertion: querying by accessible NAME is the only way
57
+ // to prove the marker reaches a screen reader. Two class strings differing,
58
+ // or a bare `data-current` attribute, would pass a naive test while an AT
59
+ // user hears nothing — that is exactly the gap this test closes.
60
+ expect(
61
+ screen.getByRole("radio", { name: /auto-approve safe actions.*current/i }),
62
+ ).toBeInTheDocument();
63
+
64
+ // The other modes' accessible names carry no such marker.
65
+ expect(screen.getByRole("radio", { name: "Ask each time" })).toBeInTheDocument();
66
+ expect(screen.getByRole("radio", { name: "Unrestricted" })).toBeInTheDocument();
67
+ });
68
+
69
+ it("selecting a different mode reports its id without mutating currentId", async () => {
70
+ const onValueChange = vi.fn();
71
+ render(<PermissionModeSelect currentId="ask" modes={modes} onValueChange={onValueChange} />);
72
+
73
+ await userEvent.click(screen.getByRole("radio", { name: "Unrestricted" }));
74
+
75
+ expect(onValueChange).toHaveBeenCalledTimes(1);
76
+ expect(onValueChange).toHaveBeenCalledWith("full");
77
+
78
+ // The newly-highlighted radio is checked…
79
+ expect(screen.getByRole("radio", { name: "Unrestricted" })).toBeChecked();
80
+ // …but `currentId` — an app-controlled prop the component never writes to
81
+ // — still names "Ask each time": the marker has not moved to the
82
+ // selection, and "Unrestricted" carries no "current" marker of its own.
83
+ expect(screen.getByRole("radio", { name: /ask each time.*current/i })).toBeInTheDocument();
84
+ expect(screen.queryByRole("radio", { name: /unrestricted.*current/i })).not.toBeInTheDocument();
85
+ });
86
+
87
+ it("does not hardcode any mode vocabulary — every label/consequence comes from props", () => {
88
+ const customModes: PermissionMode[] = [
89
+ {
90
+ id: "x",
91
+ label: "Read-only sandbox",
92
+ consequence: "Nothing outside memory is ever touched.",
93
+ },
94
+ ];
95
+ render(<PermissionModeSelect currentId="x" modes={customModes} />);
96
+
97
+ expect(screen.getByRole("radio", { name: /read-only sandbox.*current/i })).toBeInTheDocument();
98
+ expect(screen.getByText("Nothing outside memory is ever touched.")).toBeInTheDocument();
99
+ });
100
+ });
@@ -0,0 +1,137 @@
1
+ "use client";
2
+
3
+ import { forwardRef, useId, type ComponentProps } from "react";
4
+ import { Badge, Kbd, Label, RadioGroup, RadioGroupItem, useLocale } from "@elabs-ai/components-ui";
5
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
6
+
7
+ /**
8
+ * PermissionModeSelect — the standing-permission-policy chooser (#104).
9
+ *
10
+ * Distinct from `Confirmation`/`ApprovalCard` (#103): those render ONE
11
+ * per-call decision ("may I run this command?"). This renders the POLICY
12
+ * that governs how many of those per-call decisions ever occur ("how much
13
+ * may you do without asking?"). The two stay separate components with a
14
+ * documented relationship rather than one growing a boolean "policy mode"
15
+ * prop — see `.claude/rules/component-api.md` ("Avoid boolean-prop
16
+ * proliferation").
17
+ *
18
+ * The mode vocabulary is entirely app-defined: no agent's mode names are
19
+ * hardcoded in this file or in `PermissionMode`/`PermissionModeSelectProps`.
20
+ * `consequence` is a REQUIRED field — a mode whose effect is unstated is
21
+ * exactly the failure this component exists to prevent (three unlabelled
22
+ * radio buttons).
23
+ *
24
+ * Built on `@elabs-ai/components-ui`'s `RadioGroup` (Radix) for roving
25
+ * focus / arrow-key navigation / Space-Enter selection / announcement — this
26
+ * component does not hand-roll any of that. The in-force mode is marked in
27
+ * TEXT: a "Current" badge lives inside the `<Label>` associated with that
28
+ * mode's radio input, so it is part of the control's accessible name and
29
+ * recoverable in greyscale — never colour alone (WCAG 1.4.1).
30
+ */
31
+ export interface PermissionMode {
32
+ id: string;
33
+ label: string;
34
+ /** What this mode actually permits — required, not optional. */
35
+ consequence: string;
36
+ keyHint?: string;
37
+ }
38
+
39
+ export interface PermissionModeSelectProps extends ComponentProps<"div"> {
40
+ modes: PermissionMode[];
41
+ value?: string;
42
+ defaultValue?: string;
43
+ onValueChange?: (id: string) => void;
44
+ /** The mode currently in force, marked distinctly from the highlighted one. */
45
+ currentId?: string;
46
+ }
47
+
48
+ export const PermissionModeSelect = forwardRef<HTMLDivElement, PermissionModeSelectProps>(
49
+ function PermissionModeSelect(
50
+ { modes, value, defaultValue, onValueChange, currentId, className, dir, ...props },
51
+ ref,
52
+ ) {
53
+ const { t } = useLocale();
54
+ const baseId = useId();
55
+
56
+ return (
57
+ <RadioGroup
58
+ ref={ref}
59
+ // `dir` on a plain <div> is a free-form HTML string; Radix's RadioGroup
60
+ // narrows it to "ltr" | "rtl". Cast at this single call site rather
61
+ // than widening (and so weakening) the public prop type.
62
+ dir={dir as "ltr" | "rtl" | undefined}
63
+ // The RadioGroup's own highlighted value is independent of `currentId`
64
+ // (the mode already in force) — they answer different questions, so
65
+ // neither prop ever derives the other except as an uncontrolled
66
+ // starting point: with nothing else specified, pre-select the mode
67
+ // that is currently in force.
68
+ defaultValue={defaultValue ?? currentId}
69
+ value={value}
70
+ onValueChange={onValueChange}
71
+ data-slot="permission-mode-select"
72
+ className={cn("grid gap-3", className)}
73
+ {...props}
74
+ >
75
+ {modes.map((mode) => {
76
+ const isCurrent = mode.id === currentId;
77
+ const itemId = `${baseId}-${mode.id}`;
78
+ // The consequence sentence is the whole point of this component (see
79
+ // the `consequence` prop doc above), so it has to reach assistive
80
+ // tech as a DESCRIPTION of the radio — not merely sit next to it in
81
+ // the DOM. Without this link a screen-reader user arrowing through
82
+ // the modes hears "Unrestricted" and never hears what unrestricted
83
+ // costs them. Same wiring as `ApprovalCardOptions` in
84
+ // `confirmation.tsx`, deliberately: one convention, not two.
85
+ const consequenceId = `${itemId}-consequence`;
86
+
87
+ return (
88
+ <div
89
+ key={mode.id}
90
+ data-slot="permission-mode-select-option"
91
+ className={cn(
92
+ "flex items-start gap-3 rounded-lg border border-border bg-card p-4",
93
+ "has-[[data-state=checked]]:border-primary",
94
+ )}
95
+ >
96
+ <RadioGroupItem
97
+ aria-describedby={consequenceId}
98
+ className="mt-1"
99
+ data-slot="permission-mode-select-input"
100
+ id={itemId}
101
+ value={mode.id}
102
+ />
103
+ <div className="grid flex-1 gap-1">
104
+ <div className="flex items-center justify-between gap-2">
105
+ <Label
106
+ className="text-body flex items-center gap-2 font-medium"
107
+ data-slot="permission-mode-select-label"
108
+ htmlFor={itemId}
109
+ >
110
+ {mode.label}
111
+ {isCurrent ? (
112
+ <Badge data-slot="permission-mode-select-current" variant="secondary">
113
+ {t("ai.permissionModeSelect.current")}
114
+ </Badge>
115
+ ) : null}
116
+ </Label>
117
+ {mode.keyHint ? (
118
+ <Kbd data-slot="permission-mode-select-key-hint">{mode.keyHint}</Kbd>
119
+ ) : null}
120
+ </div>
121
+ <p
122
+ className="text-meta text-muted-foreground"
123
+ data-slot="permission-mode-select-consequence"
124
+ id={consequenceId}
125
+ >
126
+ {mode.consequence}
127
+ </p>
128
+ </div>
129
+ </div>
130
+ );
131
+ })}
132
+ </RadioGroup>
133
+ );
134
+ },
135
+ );
136
+
137
+ PermissionModeSelect.displayName = "PermissionModeSelect";
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Issue #33 — `@rive-app/react-webgl2` is an OPTIONAL peer of
3
+ * `@elabs-ai/components-ai`, reached only through `lazy(() =>
4
+ * import("./_persona-rive"))` inside `Persona` (ADR 0019). A REJECTED `lazy()`
5
+ * import throws during render — `Suspense` alone does not catch that (it only
6
+ * covers the pending state) — so `Persona` wraps it in `LazyEngineBoundary`
7
+ * (`_lazy-engine-boundary.tsx`). This locks that the boundary actually falls
8
+ * back to the placeholder instead of taking the whole tree down.
9
+ *
10
+ * Separate file from `persona.test.tsx`: that file exercises the real
11
+ * (unmocked) `./_persona-rive` module and never awaits its resolution; this
12
+ * file mocks it to simulate the optional peer being absent, without touching
13
+ * the sibling file's assumptions.
14
+ *
15
+ * `vi.mock` factories are hoisted, so the mock itself must resolve
16
+ * successfully (a factory that throws or returns a rejected promise gets
17
+ * intercepted by Vitest's OWN "error when mocking a module" diagnostic
18
+ * instead of the message this test needs — see
19
+ * `interactive-terminal-missing-peer.test.tsx` for the same finding). So the
20
+ * module resolves fine and the simulated failure instead comes from the
21
+ * default export THROWING when React renders it — a real render-phase error,
22
+ * which is exactly what `LazyEngineBoundary` exists to catch.
23
+ */
24
+ import { act, cleanup, render, screen } from "@testing-library/react";
25
+ import { afterEach, describe, expect, it, vi } from "vitest";
26
+
27
+ vi.mock("./_persona-rive", () => ({
28
+ default: () => {
29
+ throw new Error("Cannot find module '@rive-app/react-webgl2'");
30
+ },
31
+ }));
32
+
33
+ import { Persona } from "./persona";
34
+
35
+ afterEach(cleanup);
36
+
37
+ const flush = () => act(async () => void (await new Promise((resolve) => setTimeout(resolve, 0))));
38
+
39
+ describe("Persona — missing optional peer (#33)", () => {
40
+ it("falls back to the placeholder instead of crashing when the Rive engine fails to load", async () => {
41
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
42
+
43
+ render(<Persona state="idle" fallback={<span>resting</span>} />);
44
+ await flush();
45
+
46
+ // The tree survived — the caller-supplied fallback rendered — rather than
47
+ // the whole component (or its ancestors) unmounting.
48
+ expect(screen.getByText("resting")).toBeInTheDocument();
49
+ // The failure is still observable to a developer, not silently eaten.
50
+ expect(errorSpy).toHaveBeenCalled();
51
+
52
+ errorSpy.mockRestore();
53
+ });
54
+ });
package/src/persona.tsx CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  import { cn } from "@elabs-ai/components-ui/lib/cn";
4
4
  import { useLocale } from "@elabs-ai/components-ui";
5
- import type { RiveParameters } from "@rive-app/react-webgl2";
6
5
  import type { FC, ReactNode } from "react";
7
6
  import { lazy, memo, Suspense, useCallback, useState } from "react";
8
7
 
8
+ import { LazyEngineBoundary } from "./_lazy-engine-boundary";
9
9
  import { PERSONA_SOURCES, type PersonaState, type PersonaVariant } from "./persona-sources";
10
10
 
11
11
  export { PERSONA_SOURCES };
@@ -15,10 +15,46 @@ export type { PersonaState, PersonaVariant };
15
15
  * The Rive runtime lives behind a dynamic import — `@rive-app/react-webgl2`
16
16
  * declares no `sideEffects`, so a static import would put the whole WebGL2
17
17
  * runtime and its `.wasm` in every consumer's entry chunk, `Persona` rendered or
18
- * not. `RiveParameters` above is a TYPE import and erases. See ADR 0019.
18
+ * not. See ADR 0019.
19
+ *
20
+ * Issue #101: `@rive-app/react-webgl2` (and, transitively, `@rive-app/webgl2`,
21
+ * which re-exports `RiveParameters`) is an OPTIONAL peer, so no PUBLIC export's
22
+ * type may structurally reference it — doing so would name the peer's module
23
+ * specifier in this package's generated root `.d.ts` and hand a
24
+ * `skipLibCheck: false` consumer who has not installed the peer a `TS2307` just
25
+ * for importing the barrel. `PersonaRiveEvent`/`PersonaRiveEventCallback` below
26
+ * are an OWNED mirror of the peer's `Event`/`EventCallback` (from
27
+ * `@rive-app/webgl2`'s `rive.d.ts`) — every one of `RiveParameters`'s
28
+ * `onLoad`/`onLoadError`/`onPause`/`onPlay`/`onStop` callbacks share that exact
29
+ * same `EventCallback` shape, so one owned type covers all five. The
30
+ * conformance assertion in `_persona-rive.tsx` (which still imports the real
31
+ * peer types — that module is reached only through `lazy()` and never sits in
32
+ * the barrel's declaration graph) proves this mirror stays assignable to the
33
+ * real peer type; a future Rive release that changes `Event`'s shape fails
34
+ * `pnpm --filter @elabs-ai/components-ai typecheck` locally instead of
35
+ * reaching a consumer as silent drift. Mirrors the identical pattern
36
+ * `packages/terminal/src/interactive-terminal.tsx` already established for
37
+ * `@xterm/xterm`'s `ITheme` (also issue #101).
19
38
  */
20
39
  const PersonaRive = lazy(() => import("./_persona-rive"));
21
40
 
41
+ /**
42
+ * Owned mirror of `@rive-app/webgl2`'s `Event` (the parameter type every
43
+ * `RiveParameters` callback receives) — see the module doc comment above.
44
+ * `data` is deliberately widened to `unknown` rather than replicating Rive's
45
+ * `RiveEventPayload`/`LoopEvent`/`RiveFile` union: no callback in this package
46
+ * (or, realistically, a consumer's) narrows it, and `unknown` remains a safe,
47
+ * structurally-compatible supertype of the real union for the conformance
48
+ * assertion in `_persona-rive.tsx`.
49
+ */
50
+ export interface PersonaRiveEvent {
51
+ type: string;
52
+ data?: unknown;
53
+ }
54
+
55
+ /** Owned mirror of `@rive-app/webgl2`'s `EventCallback`. */
56
+ export type PersonaRiveEventCallback = (event: PersonaRiveEvent) => void;
57
+
22
58
  /**
23
59
  * Localized status announced when `state` changes, one key per `PersonaState`.
24
60
  * Typed as a `Record` (not a bare template string) so a `PersonaState` value
@@ -40,12 +76,12 @@ export interface PersonaProps {
40
76
  * so a blocked fetch never leaves an empty box.
41
77
  */
42
78
  fallback?: ReactNode;
43
- onLoad?: RiveParameters["onLoad"];
44
- onLoadError?: RiveParameters["onLoadError"];
45
- onPause?: RiveParameters["onPause"];
46
- onPlay?: RiveParameters["onPlay"];
79
+ onLoad?: PersonaRiveEventCallback;
80
+ onLoadError?: PersonaRiveEventCallback;
81
+ onPause?: PersonaRiveEventCallback;
82
+ onPlay?: PersonaRiveEventCallback;
47
83
  onReady?: () => void;
48
- onStop?: RiveParameters["onStop"];
84
+ onStop?: PersonaRiveEventCallback;
49
85
  /**
50
86
  * Override the `.riv` artwork URL — point at a self-hosted copy when a CSP
51
87
  * blocks the default origin. Defaults to `PERSONA_SOURCES[variant].source`.
@@ -101,7 +137,7 @@ export const Persona: FC<PersonaProps> = memo(
101
137
  const { t } = useLocale();
102
138
  const [failed, setFailed] = useState(false);
103
139
 
104
- const handleLoadError = useCallback<NonNullable<RiveParameters["onLoadError"]>>(
140
+ const handleLoadError = useCallback<PersonaRiveEventCallback>(
105
141
  (event) => {
106
142
  setFailed(true);
107
143
  onLoadError?.(event);
@@ -116,20 +152,30 @@ export const Persona: FC<PersonaProps> = memo(
116
152
  const visual = failed ? (
117
153
  placeholder
118
154
  ) : (
119
- <Suspense fallback={placeholder}>
120
- <PersonaRive
121
- className={className}
122
- onLoad={onLoad}
123
- onLoadError={handleLoadError}
124
- onPause={onPause}
125
- onPlay={onPlay}
126
- onReady={onReady}
127
- onStop={onStop}
128
- source={source}
129
- src={src ?? source.source}
130
- state={state}
131
- />
132
- </Suspense>
155
+ // The Rive runtime is an optional peer (`@rive-app/react-webgl2`, issue
156
+ // #33) reached via `lazy()` — a REJECTED import throws during render,
157
+ // which `Suspense` alone cannot catch (it only covers the pending
158
+ // state). `LazyEngineBoundary` catches that throw and falls back to the
159
+ // same placeholder a load-time `onLoadError` already renders.
160
+ // `renderMissing` stays pure — `LazyEngineBoundary.componentDidCatch`
161
+ // already logs the error once, correctly; logging it again here would
162
+ // double-report on every render this fallback re-mounts under.
163
+ <LazyEngineBoundary renderMissing={() => placeholder}>
164
+ <Suspense fallback={placeholder}>
165
+ <PersonaRive
166
+ className={className}
167
+ onLoad={onLoad}
168
+ onLoadError={handleLoadError}
169
+ onPause={onPause}
170
+ onPlay={onPlay}
171
+ onReady={onReady}
172
+ onStop={onStop}
173
+ source={source}
174
+ src={src ?? source.source}
175
+ state={state}
176
+ />
177
+ </Suspense>
178
+ </LazyEngineBoundary>
133
179
  );
134
180
 
135
181
  return (
@@ -0,0 +1,166 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+
3
+ import {
4
+ Plan,
5
+ PlanAction,
6
+ PlanApprove,
7
+ PlanComment,
8
+ PlanContent,
9
+ PlanDescription,
10
+ PlanFooter,
11
+ PlanHeader,
12
+ PlanRequestChanges,
13
+ PlanStatusLine,
14
+ PlanTitle,
15
+ PlanTrigger,
16
+ } from "./plan";
17
+
18
+ const meta = {
19
+ title: "AI/Plan",
20
+ component: Plan,
21
+ tags: ["autodocs"],
22
+ parameters: {
23
+ layout: "padded",
24
+ docs: {
25
+ description: {
26
+ component:
27
+ "The CHAT plan card — what the agent intends to do, optionally with an accept/edit decision. The console counterpart of a three-state agent checklist is `Terminal/TerminalTodoList`; the summary of what already ran is `AI/Task`. See [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). `status` is additive: a `Plan` rendered without it has no ARIA region, no rail and no status line.",
28
+ },
29
+ },
30
+ },
31
+ } satisfies Meta<typeof Plan>;
32
+
33
+ export default meta;
34
+
35
+ type Story = StoryObj<typeof meta>;
36
+
37
+ const steps = (
38
+ <ol className="list-decimal space-y-1 ps-5 text-body text-muted-foreground">
39
+ <li>Pull the Q3 10-Q and Q2 board pack</li>
40
+ <li>Reconcile to the warehouse revenue table</li>
41
+ <li>Compute QoQ growth, ARR and gross margin</li>
42
+ <li>Draft a one-page note and export the figures</li>
43
+ </ol>
44
+ );
45
+
46
+ // The pre-#108 shape — no `status`, `isStreaming` defaults to `false`. Kept as
47
+ // the Default story so the legacy, display-only usage stays exercised exactly
48
+ // as before (#108 acceptance: existing usage renders unchanged).
49
+ export const Default: Story = {
50
+ render: () => (
51
+ <Plan>
52
+ <PlanHeader>
53
+ <div className="min-w-0">
54
+ <PlanTitle>Draft the Q3 board note</PlanTitle>
55
+ <PlanDescription>
56
+ Retrieve filings, reconcile to the warehouse, then summarize.
57
+ </PlanDescription>
58
+ </div>
59
+ <PlanAction>
60
+ <PlanTrigger />
61
+ </PlanAction>
62
+ </PlanHeader>
63
+ <PlanContent>{steps}</PlanContent>
64
+ <PlanFooter>
65
+ <span className="text-meta text-muted-foreground">
66
+ 4 steps · grounded in filings + warehouse
67
+ </span>
68
+ </PlanFooter>
69
+ </Plan>
70
+ ),
71
+ };
72
+
73
+ // The legacy display-only signal (pre-#108): the title/description shimmer
74
+ // while the plan is still arriving. No decision contract is attached.
75
+ export const Streaming: Story = {
76
+ render: () => (
77
+ <Plan isStreaming>
78
+ <PlanHeader>
79
+ <div className="min-w-0">
80
+ <PlanTitle>Draft the Q3 board note</PlanTitle>
81
+ <PlanDescription>Retrieving filings…</PlanDescription>
82
+ </div>
83
+ <PlanAction>
84
+ <PlanTrigger />
85
+ </PlanAction>
86
+ </PlanHeader>
87
+ <PlanContent>{steps}</PlanContent>
88
+ </Plan>
89
+ ),
90
+ };
91
+
92
+ // The decision contract (#108): a pending plan is a LABELLED GROUP (not
93
+ // `role="alert"`) containing the three named actions — Approve is the filled
94
+ // primary path, Request changes / Comment are quieter.
95
+ export const Awaiting: Story = {
96
+ render: () => (
97
+ <Plan status="awaiting">
98
+ <PlanHeader>
99
+ <div className="min-w-0">
100
+ <PlanTitle>Draft the Q3 board note</PlanTitle>
101
+ <PlanDescription>
102
+ Retrieve filings, reconcile to the warehouse, then summarize.
103
+ </PlanDescription>
104
+ </div>
105
+ <PlanAction>
106
+ <PlanTrigger />
107
+ </PlanAction>
108
+ </PlanHeader>
109
+ <PlanContent>{steps}</PlanContent>
110
+ <PlanFooter className="flex-col items-stretch gap-3">
111
+ <PlanStatusLine />
112
+ <div className="flex w-full items-center justify-end gap-2">
113
+ <PlanComment>Comment</PlanComment>
114
+ <PlanRequestChanges>Request changes</PlanRequestChanges>
115
+ <PlanApprove>Approve</PlanApprove>
116
+ </div>
117
+ </PlanFooter>
118
+ </Plan>
119
+ ),
120
+ };
121
+
122
+ // A settled outcome renders `role="alert"`, the actions are gone, and the
123
+ // outcome is carried in text + glyph (never rail colour alone).
124
+ export const Approved: Story = {
125
+ render: () => (
126
+ <Plan status="approved">
127
+ <PlanHeader>
128
+ <div className="min-w-0">
129
+ <PlanTitle>Draft the Q3 board note</PlanTitle>
130
+ <PlanDescription>
131
+ Retrieve filings, reconcile to the warehouse, then summarize.
132
+ </PlanDescription>
133
+ </div>
134
+ <PlanAction>
135
+ <PlanTrigger />
136
+ </PlanAction>
137
+ </PlanHeader>
138
+ <PlanContent>{steps}</PlanContent>
139
+ <PlanFooter>
140
+ <PlanStatusLine />
141
+ </PlanFooter>
142
+ </Plan>
143
+ ),
144
+ };
145
+
146
+ export const ChangesRequested: Story = {
147
+ render: () => (
148
+ <Plan status="changes-requested">
149
+ <PlanHeader>
150
+ <div className="min-w-0">
151
+ <PlanTitle>Draft the Q3 board note</PlanTitle>
152
+ <PlanDescription>
153
+ Retrieve filings, reconcile to the warehouse, then summarize.
154
+ </PlanDescription>
155
+ </div>
156
+ <PlanAction>
157
+ <PlanTrigger />
158
+ </PlanAction>
159
+ </PlanHeader>
160
+ <PlanContent>{steps}</PlanContent>
161
+ <PlanFooter>
162
+ <PlanStatusLine>Changes requested — reconcile against Q2 instead</PlanStatusLine>
163
+ </PlanFooter>
164
+ </Plan>
165
+ ),
166
+ };