@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.
- package/README.md +61 -11
- package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
- package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
- package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
- package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
- package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
- package/dist/_persona-rive-JEG44YHX.js.map +1 -0
- package/dist/index.d.ts +1109 -202
- package/dist/index.js +4867 -2878
- package/dist/index.js.map +1 -1
- package/package.json +31 -16
- package/src/_audio-player-media-chrome.tsx +102 -15
- package/src/_flow-boundary.tsx +68 -49
- package/src/_lazy-boundary-conformance.ts +38 -0
- package/src/_lazy-engine-boundary.tsx +61 -0
- package/src/_lazy-mermaid-absent.test.ts +53 -0
- package/src/_lazy-mermaid.test.ts +15 -0
- package/src/_lazy-mermaid.ts +24 -1
- package/src/_mermaid-error-panel.test.tsx +50 -0
- package/src/_mermaid-error-panel.tsx +66 -0
- package/src/_persona-rive.tsx +62 -10
- package/src/_streamdown-i18n.ts +21 -1
- package/src/_streamdown-safety.ts +170 -0
- package/src/agent-event.stories.tsx +97 -0
- package/src/agent-event.test.tsx +145 -0
- package/src/agent-event.tsx +187 -0
- package/src/agent-timeline.stories.tsx +9 -1
- package/src/agent.test.tsx +47 -0
- package/src/agent.tsx +28 -14
- package/src/agentic-workspace.stories.tsx +27 -20
- package/src/artifact.tsx +15 -12
- package/src/audio-player.test.tsx +78 -0
- package/src/audio-player.tsx +243 -56
- package/src/audio-visualizer.stories.tsx +126 -0
- package/src/audio-visualizer.test.tsx +438 -0
- package/src/audio-visualizer.tsx +367 -0
- package/src/canvas.stories.tsx +150 -1
- package/src/chat-shell.stories.tsx +18 -3
- package/src/chat.stories.tsx +16 -2
- package/src/code-block.stories.tsx +9 -1
- package/src/composer.stories.tsx +372 -9
- package/src/composer.test.tsx +357 -6
- package/src/composer.tsx +217 -35
- package/src/confirmation.stories.tsx +72 -1
- package/src/confirmation.test.tsx +216 -2
- package/src/confirmation.tsx +263 -3
- package/src/context-panel.stories.tsx +9 -1
- package/src/context-panel.tsx +2 -1
- package/src/conversation.stories.tsx +63 -2
- package/src/conversation.test.tsx +13 -0
- package/src/conversation.tsx +20 -2
- package/src/diff-view.stories.tsx +196 -0
- package/src/diff-view.test.tsx +188 -0
- package/src/diff-view.tsx +642 -0
- package/src/gallery.tsx +4 -2
- package/src/index.ts +14 -4
- package/src/jsx-preview.stories.tsx +2 -2
- package/src/markdown-view.stories.tsx +92 -1
- package/src/markdown-view.test.tsx +232 -1
- package/src/markdown-view.tsx +150 -6
- package/src/message-compare.stories.tsx +175 -0
- package/src/message-compare.test.tsx +207 -0
- package/src/message-compare.tsx +453 -0
- package/src/message-form.stories.tsx +29 -1
- package/src/message.stories.tsx +9 -1
- package/src/message.test.tsx +176 -0
- package/src/message.tsx +90 -4
- package/src/microcopy.test.tsx +40 -0
- package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
- package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
- package/src/model-provider-logo.tsx +149 -0
- package/src/permission-mode-select.stories.tsx +82 -0
- package/src/permission-mode-select.test.tsx +100 -0
- package/src/permission-mode-select.tsx +137 -0
- package/src/persona-missing-peer.test.tsx +54 -0
- package/src/persona.tsx +68 -22
- package/src/plan.stories.tsx +166 -0
- package/src/plan.test.tsx +267 -0
- package/src/plan.tsx +182 -20
- package/src/prompt-input-effort.stories.tsx +123 -0
- package/src/prompt-input-effort.test.tsx +83 -0
- package/src/prompt-input-effort.tsx +136 -0
- package/src/prompt-input-mode.stories.tsx +108 -0
- package/src/prompt-input-mode.test.tsx +99 -0
- package/src/prompt-input-mode.tsx +169 -0
- package/src/prompt-input-slash.stories.tsx +211 -0
- package/src/prompt-input-slash.test.tsx +262 -0
- package/src/prompt-input-slash.tsx +541 -0
- package/src/prompt-input.stories.tsx +2 -2
- package/src/reasoning.tsx +27 -13
- package/src/sandbox.stories.tsx +9 -1
- package/src/schema-display.tsx +5 -2
- package/src/selection-toolbar.stories.tsx +9 -1
- package/src/session-header.stories.tsx +128 -0
- package/src/session-header.test.tsx +138 -0
- package/src/session-header.tsx +243 -0
- package/src/session-status-bar.stories.tsx +73 -0
- package/src/session-status-bar.test.tsx +94 -0
- package/src/session-status-bar.tsx +165 -0
- package/src/snippet.stories.tsx +9 -1
- package/src/stack-trace.tsx +6 -2
- package/src/streamdown-i18n.test.tsx +1 -1
- package/src/task.stories.tsx +13 -3
- package/src/templates-ai-assistant.stories.tsx +21 -1
- package/src/token-usage.stories.tsx +24 -0
- package/src/token-usage.test.tsx +92 -0
- package/src/{context.tsx → token-usage.tsx} +67 -53
- package/src/tool-result-card.stories.tsx +9 -1
- package/src/tool.stories.tsx +13 -3
- package/src/tool.tsx +15 -8
- package/src/turn-status.stories.tsx +124 -0
- package/src/turn-status.test.tsx +74 -0
- package/src/turn-status.tsx +174 -0
- package/src/use-audio-level.ts +104 -0
- package/src/web-preview.tsx +8 -3
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
- package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
- package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
- package/src/_interactive-terminal-xterm.ts +0 -32
- package/src/blocks-ai-composer.stories.tsx +0 -83
- package/src/context.stories.tsx +0 -16
- package/src/interactive-terminal.stories.tsx +0 -165
- package/src/interactive-terminal.test.tsx +0 -448
- package/src/interactive-terminal.tsx +0 -444
- package/src/model-selector.tsx +0 -225
- 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.
|
|
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?:
|
|
44
|
-
onLoadError?:
|
|
45
|
-
onPause?:
|
|
46
|
-
onPlay?:
|
|
79
|
+
onLoad?: PersonaRiveEventCallback;
|
|
80
|
+
onLoadError?: PersonaRiveEventCallback;
|
|
81
|
+
onPause?: PersonaRiveEventCallback;
|
|
82
|
+
onPlay?: PersonaRiveEventCallback;
|
|
47
83
|
onReady?: () => void;
|
|
48
|
-
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<
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
+
};
|