@elabs-ai/components-ai 4.0.0 → 4.2.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 +62 -12
- 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 +1152 -218
- package/dist/index.js +5601 -3241
- package/dist/index.js.map +1 -1
- package/package.json +30 -15
- package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
- package/src/__contract__/chat-shell.contract.test.tsx +49 -0
- package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
- package/src/__contract__/image.contract.test.tsx +49 -0
- package/src/__contract__/markdown-view.contract.test.tsx +49 -0
- package/src/__contract__/message-feedback.contract.test.tsx +49 -0
- package/src/__contract__/message-form.contract.test.tsx +49 -0
- package/src/__contract__/message-table.contract.test.tsx +49 -0
- package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
- package/src/__contract__/persona.contract.test.tsx +49 -0
- package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
- package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
- package/src/_audio-player-media-chrome.tsx +102 -15
- package/src/_chat-shell-rail.tsx +2 -2
- package/src/_flow-boundary.tsx +68 -49
- package/src/_lazy-boundary-conformance.ts +38 -0
- package/src/_lazy-cjk.test.ts +43 -0
- package/src/_lazy-cjk.ts +73 -0
- package/src/_lazy-engine-boundary.tsx +61 -0
- package/src/_lazy-math.test.ts +63 -0
- package/src/_lazy-math.ts +90 -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 +94 -22
- package/src/_streamdown-safety.ts +170 -0
- package/src/_theme-scope-store.test.ts +83 -0
- package/src/_theme-scope-store.ts +103 -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 +43 -24
- package/src/agentic-workspace.stories.tsx +27 -20
- package/src/artifact.tsx +17 -14
- package/src/asset-preview.test.tsx +40 -0
- package/src/asset-preview.tsx +83 -12
- package/src/attachments.tsx +7 -4
- 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/code-block.test.tsx +100 -1
- package/src/code-block.tsx +166 -103
- package/src/commit.tsx +30 -41
- 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 +264 -4
- package/src/context-panel.stories.tsx +9 -1
- package/src/context-panel.tsx +3 -2
- package/src/conversation.stories.tsx +86 -2
- package/src/conversation.test.tsx +66 -0
- package/src/conversation.tsx +52 -9
- package/src/diff-view.stories.tsx +196 -0
- package/src/diff-view.test.tsx +238 -0
- package/src/diff-view.tsx +697 -0
- package/src/environment-variables.tsx +20 -37
- package/src/file-tree.test.tsx +21 -0
- package/src/file-tree.tsx +12 -2
- package/src/gallery.tsx +4 -2
- package/src/index.ts +14 -4
- package/src/inline-citation.tsx +5 -5
- package/src/jsx-preview.stories.tsx +2 -2
- package/src/jsx-preview.tsx +151 -43
- package/src/markdown-view.stories.tsx +92 -1
- package/src/markdown-view.test.tsx +237 -2
- package/src/markdown-view.tsx +157 -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 +65 -4
- package/src/message-form.test.tsx +8 -2
- package/src/message-form.tsx +15 -7
- package/src/message-table.stories.tsx +2 -2
- package/src/message-table.test.tsx +7 -0
- package/src/message-table.tsx +8 -4
- package/src/message.stories.tsx +9 -1
- package/src/message.test.tsx +247 -1
- package/src/message.tsx +125 -15
- package/src/microcopy.test.tsx +40 -0
- package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
- package/src/model-provider-logo.test.tsx +109 -0
- package/src/model-provider-logo.tsx +183 -0
- package/src/open-in-chat.tsx +50 -29
- package/src/package-info.tsx +12 -12
- 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/prompt-input.test.tsx +67 -1
- package/src/prompt-input.tsx +42 -4
- package/src/queue.tsx +4 -4
- package/src/reasoning.tsx +42 -17
- package/src/sandbox.stories.tsx +9 -1
- package/src/sandbox.tsx +3 -3
- package/src/schema-display.test.tsx +56 -0
- package/src/schema-display.tsx +71 -37
- 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/snippet.test.tsx +6 -2
- package/src/snippet.tsx +14 -34
- package/src/speech-input.test.tsx +109 -0
- package/src/speech-input.tsx +31 -3
- package/src/stack-trace.tsx +24 -36
- package/src/streamdown-i18n.test.tsx +24 -1
- package/src/task.stories.tsx +13 -3
- package/src/templates-ai-assistant.stories.tsx +21 -1
- package/src/test-results.tsx +47 -27
- package/src/token-usage.stories.tsx +24 -0
- package/src/token-usage.test.tsx +92 -0
- package/src/{context.tsx → token-usage.tsx} +73 -59
- package/src/tool-result-card.stories.tsx +9 -1
- package/src/tool.stories.tsx +13 -3
- package/src/tool.test.tsx +65 -0
- package/src/tool.tsx +73 -30
- package/src/transcription.tsx +1 -1
- 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/voice-selector.tsx +5 -5
- package/src/web-preview.test.tsx +51 -1
- package/src/web-preview.tsx +46 -10
- 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.test.tsx +0 -55
- package/src/model-selector.tsx +0 -225
- package/src/terminal.tsx +0 -244
|
@@ -0,0 +1,83 @@
|
|
|
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 { type EffortLevel } from "@elabs-ai/components-ui";
|
|
5
|
+
import { PromptInputEffort } from "./prompt-input-effort";
|
|
6
|
+
|
|
7
|
+
const levels: EffortLevel[] = [
|
|
8
|
+
{ id: "low", label: "Low" },
|
|
9
|
+
{ id: "medium", label: "Medium" },
|
|
10
|
+
{ id: "high", label: "High" },
|
|
11
|
+
{ id: "max", label: "Max" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
describe("PromptInputEffort", () => {
|
|
15
|
+
it("defaults to the first (lowest) level", () => {
|
|
16
|
+
render(<PromptInputEffort levels={levels} aria-label="Reasoning effort" />);
|
|
17
|
+
expect(screen.getByRole("radio", { name: "Low", checked: true })).toBeInTheDocument();
|
|
18
|
+
expect(screen.getByText("Low")).toBeInTheDocument();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("is uncontrolled: clicking a level fires onValueChange and moves the checked radio", async () => {
|
|
22
|
+
const onValueChange = vi.fn();
|
|
23
|
+
render(
|
|
24
|
+
<PromptInputEffort
|
|
25
|
+
levels={levels}
|
|
26
|
+
aria-label="Reasoning effort"
|
|
27
|
+
onValueChange={onValueChange}
|
|
28
|
+
/>,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
await userEvent.click(screen.getByRole("radio", { name: "High" }));
|
|
32
|
+
expect(onValueChange).toHaveBeenCalledWith("high");
|
|
33
|
+
expect(screen.getByRole("radio", { name: "High", checked: true })).toBeInTheDocument();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("is controlled via `value`", () => {
|
|
37
|
+
render(<PromptInputEffort levels={levels} aria-label="Reasoning effort" value="max" />);
|
|
38
|
+
expect(screen.getByRole("radio", { name: "Max", checked: true })).toBeInTheDocument();
|
|
39
|
+
expect(screen.getByText("Max")).toBeInTheDocument();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The test the issue asks for: two ADJACENT levels must differ in their
|
|
44
|
+
* ACCESSIBLE TEXT (not merely a class string, which would pass on
|
|
45
|
+
* colour-only code) and in their RENDERED INDICATOR — a structural count of
|
|
46
|
+
* how many rungs are marked filled, not a comparison of two class strings.
|
|
47
|
+
*/
|
|
48
|
+
it("two adjacent levels differ in accessible text AND in the rendered fill indicator", () => {
|
|
49
|
+
const { unmount } = render(
|
|
50
|
+
<PromptInputEffort levels={levels} aria-label="Reasoning effort" value="medium" />,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Accessible text: the checked radio's accessible name, and the visible
|
|
54
|
+
// level-name text, both reflect "Medium".
|
|
55
|
+
const mediumChecked = screen.getByRole("radio", { checked: true });
|
|
56
|
+
expect(mediumChecked).toHaveAccessibleName("Medium");
|
|
57
|
+
expect(screen.getByText("Medium")).toBeInTheDocument();
|
|
58
|
+
|
|
59
|
+
// Rendered indicator: a structural count of filled rungs (`data-filled`),
|
|
60
|
+
// not a class-string diff — "Medium" is the 2nd of 4 rungs, so exactly 2
|
|
61
|
+
// are filled.
|
|
62
|
+
const mediumFilledCount = document.querySelectorAll('[data-filled="true"]').length;
|
|
63
|
+
expect(mediumFilledCount).toBe(2);
|
|
64
|
+
|
|
65
|
+
unmount();
|
|
66
|
+
|
|
67
|
+
render(<PromptInputEffort levels={levels} aria-label="Reasoning effort" value="high" />);
|
|
68
|
+
|
|
69
|
+
const highChecked = screen.getByRole("radio", { checked: true });
|
|
70
|
+
expect(highChecked).toHaveAccessibleName("High");
|
|
71
|
+
expect(screen.getByText("High")).toBeInTheDocument();
|
|
72
|
+
|
|
73
|
+
// "High" is the 3rd of 4 (adjacent) rungs, so exactly 3 are filled — one
|
|
74
|
+
// more than "Medium", proving the indicator actually moved.
|
|
75
|
+
const highFilledCount = document.querySelectorAll('[data-filled="true"]').length;
|
|
76
|
+
expect(highFilledCount).toBe(3);
|
|
77
|
+
|
|
78
|
+
// The two accessible names differ, and the two filled counts differ —
|
|
79
|
+
// the assertion this test exists to make.
|
|
80
|
+
expect(mediumChecked).not.toBe(highChecked);
|
|
81
|
+
expect(mediumFilledCount).not.toBe(highFilledCount);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, type HTMLAttributes } from "react";
|
|
4
|
+
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
|
5
|
+
import {
|
|
6
|
+
RadioGroup,
|
|
7
|
+
RadioGroupItem,
|
|
8
|
+
cn,
|
|
9
|
+
effortRungForIndex,
|
|
10
|
+
type EffortLevel,
|
|
11
|
+
} from "@elabs-ai/components-ui";
|
|
12
|
+
|
|
13
|
+
// `EffortLevel` and the growing-square size ramp (`effortRungForIndex`,
|
|
14
|
+
// formerly this file's private `SIZE_RUNGS`/`rungForIndex`) moved to
|
|
15
|
+
// `@elabs-ai/components-ui` (`lib/operating-mode.ts`) — the terminal CLI
|
|
16
|
+
// look-alike family's own effort chips (issue #117) reuse the same
|
|
17
|
+
// vocabulary shape, and `@elabs-ai/components-ai`/
|
|
18
|
+
// `@elabs-ai/components-terminal` are layer-2 DAG siblings that may not
|
|
19
|
+
// import each other (T0; see
|
|
20
|
+
// docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4). Imported
|
|
21
|
+
// above; NOT re-exported from here — a consumer imports `EffortLevel` from
|
|
22
|
+
// `@elabs-ai/components-ui`.
|
|
23
|
+
|
|
24
|
+
export type PromptInputEffortProps = Omit<HTMLAttributes<HTMLDivElement>, "onChange"> & {
|
|
25
|
+
/** Ordered low → high. The order IS the semantics — render in this order. */
|
|
26
|
+
levels: EffortLevel[];
|
|
27
|
+
/** Controlled selected level id. Omit for the uncontrolled default (`levels[0]`). */
|
|
28
|
+
value?: string;
|
|
29
|
+
/** Fires with the chosen level's id. */
|
|
30
|
+
onValueChange?: (id: string) => void;
|
|
31
|
+
/**
|
|
32
|
+
* Accessible name for the whole scale (e.g. "Reasoning effort", "Thinking
|
|
33
|
+
* budget") — required because the NAME of the scale is as much the
|
|
34
|
+
* consumer's vocabulary as its levels are; this component has no default
|
|
35
|
+
* to fall back to.
|
|
36
|
+
*/
|
|
37
|
+
"aria-label": string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* PromptInputEffort — an ORDERED reasoning-effort/budget control.
|
|
42
|
+
*
|
|
43
|
+
* Renders `levels` as a row of same-shape squares that GROW in size, low to
|
|
44
|
+
* high — the size ramp encodes the order on its own, before any selection is
|
|
45
|
+
* made. Selecting a level fills every square up to and including it (solid
|
|
46
|
+
* fill + coloured border) and leaves the rest hollow (outline only): the
|
|
47
|
+
* indicator's FILL is the second, non-colour channel required by a
|
|
48
|
+
* greyscale-legibility read — the size ramp and the solid/hollow shape both
|
|
49
|
+
* survive a greyscale render, so the level is recoverable without colour.
|
|
50
|
+
* The current level's name is additionally rendered as text.
|
|
51
|
+
*
|
|
52
|
+
* Built on Radix (`RadioGroup`/`RadioGroupItem` from `@elabs-ai/components-ui`,
|
|
53
|
+
* `role="radiogroup"` of `role="radio"`s) for arrow-key navigation, roving
|
|
54
|
+
* focus and an accessible-name announcement on the checked item — no
|
|
55
|
+
* hand-rolled key handling.
|
|
56
|
+
*/
|
|
57
|
+
export const PromptInputEffort = forwardRef<HTMLDivElement, PromptInputEffortProps>(
|
|
58
|
+
function PromptInputEffort(
|
|
59
|
+
{ levels, value, onValueChange, className, "aria-label": ariaLabel, ...props },
|
|
60
|
+
ref,
|
|
61
|
+
) {
|
|
62
|
+
const [selected, setSelected] = useControllableState<string>({
|
|
63
|
+
defaultProp: levels[0]?.id ?? "",
|
|
64
|
+
onChange: onValueChange,
|
|
65
|
+
prop: value,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const handleChange = (next: string) => {
|
|
69
|
+
if (!next) return;
|
|
70
|
+
setSelected(next);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const currentIndex = levels.findIndex((level) => level.id === selected);
|
|
74
|
+
const current = currentIndex >= 0 ? levels[currentIndex] : levels[0];
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div
|
|
78
|
+
ref={ref}
|
|
79
|
+
data-slot="prompt-input-effort"
|
|
80
|
+
className={cn("inline-flex items-center gap-2", className)}
|
|
81
|
+
{...props}
|
|
82
|
+
>
|
|
83
|
+
<RadioGroup
|
|
84
|
+
value={selected}
|
|
85
|
+
onValueChange={handleChange}
|
|
86
|
+
orientation="horizontal"
|
|
87
|
+
aria-label={ariaLabel}
|
|
88
|
+
data-slot="prompt-input-effort-scale"
|
|
89
|
+
className="flex items-end gap-1"
|
|
90
|
+
>
|
|
91
|
+
{levels.map((level, index) => {
|
|
92
|
+
const filled = currentIndex >= 0 && index <= currentIndex;
|
|
93
|
+
return (
|
|
94
|
+
// The visible bar (`-visual`) and the click/tap/focus target
|
|
95
|
+
// (the radio itself) are decoupled (WCAG 2.5.8, #161): the
|
|
96
|
+
// radio's authored size IS the ramp's meaning, so it can't
|
|
97
|
+
// simply grow to 24px without flattening the ramp. Instead the
|
|
98
|
+
// radio is stretched to fill this `size-6` (24px) frame —
|
|
99
|
+
// invisible, but a real >=24x24 hit box — while a separate,
|
|
100
|
+
// `aria-hidden` sibling paints the small bar at its authored
|
|
101
|
+
// `effortRungForIndex` size. Both are bottom-flush + centered
|
|
102
|
+
// so the radio's own checked-state indicator still lands on
|
|
103
|
+
// (and stays invisible against) the visible bar, exactly as
|
|
104
|
+
// when the radio painted the bar directly.
|
|
105
|
+
<span
|
|
106
|
+
key={level.id}
|
|
107
|
+
data-slot="prompt-input-effort-item-frame"
|
|
108
|
+
className="relative inline-flex size-6 shrink-0 items-end justify-center"
|
|
109
|
+
>
|
|
110
|
+
<span
|
|
111
|
+
aria-hidden="true"
|
|
112
|
+
data-slot="prompt-input-effort-item-visual"
|
|
113
|
+
className={cn(
|
|
114
|
+
effortRungForIndex(index, levels.length),
|
|
115
|
+
"rounded-sm border",
|
|
116
|
+
filled ? "border-primary bg-primary" : "border-border-strong bg-transparent",
|
|
117
|
+
)}
|
|
118
|
+
/>
|
|
119
|
+
<RadioGroupItem
|
|
120
|
+
value={level.id}
|
|
121
|
+
aria-label={level.label}
|
|
122
|
+
data-slot="prompt-input-effort-item"
|
|
123
|
+
data-filled={filled ? "true" : "false"}
|
|
124
|
+
className="absolute inset-0 flex size-full items-end justify-center rounded-sm border-0 bg-transparent p-0 shadow-none"
|
|
125
|
+
/>
|
|
126
|
+
</span>
|
|
127
|
+
);
|
|
128
|
+
})}
|
|
129
|
+
</RadioGroup>
|
|
130
|
+
<span className="text-body" data-slot="prompt-input-effort-label">
|
|
131
|
+
{current?.label}
|
|
132
|
+
</span>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
},
|
|
136
|
+
);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PromptInputMode — a composer control for an app-defined operating mode
|
|
3
|
+
* (#107). Sits beside the model pill / tools cluster in a `PromptInput`
|
|
4
|
+
* footer: a compact trigger showing the current mode, opening a menu of
|
|
5
|
+
* every mode the app supports. Ships no mode vocabulary of its own — the
|
|
6
|
+
* example modes below are demo data, not a canonical set.
|
|
7
|
+
*
|
|
8
|
+
* Distinct from `PermissionModeSelect` (#104), which renders the SAME kind
|
|
9
|
+
* of app-defined policy as an always-expanded settings-panel card list with
|
|
10
|
+
* a required consequence sentence per mode. This is the composer-toolbar
|
|
11
|
+
* altitude: a collapsed trigger, an optional one-line description, and an
|
|
12
|
+
* optional shortcut hint — the two are siblings, not a duplicate.
|
|
13
|
+
*/
|
|
14
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
15
|
+
import { expect, fn, within } from "storybook/test";
|
|
16
|
+
import { Bot, PencilLine, ShieldCheck } from "lucide-react";
|
|
17
|
+
import { type OperatingMode } from "@elabs-ai/components-ui";
|
|
18
|
+
|
|
19
|
+
import { PromptInputMode } from "./prompt-input-mode";
|
|
20
|
+
|
|
21
|
+
const MODES: OperatingMode[] = [
|
|
22
|
+
{
|
|
23
|
+
id: "auto",
|
|
24
|
+
label: "Auto",
|
|
25
|
+
description: "Acts without asking, within its usual limits.",
|
|
26
|
+
icon: <Bot className="size-4" aria-hidden="true" />,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "plan",
|
|
30
|
+
label: "Plan first",
|
|
31
|
+
description: "Proposes a plan and waits for approval before acting.",
|
|
32
|
+
keyHint: "⇧ Tab",
|
|
33
|
+
icon: <PencilLine className="size-4" aria-hidden="true" />,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "review",
|
|
37
|
+
label: "Review edits",
|
|
38
|
+
description: "Acts, but pauses on every file edit for a quick review.",
|
|
39
|
+
icon: <ShieldCheck className="size-4" aria-hidden="true" />,
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const meta = {
|
|
44
|
+
title: "AI/Composer/PromptInputMode",
|
|
45
|
+
component: PromptInputMode,
|
|
46
|
+
parameters: {
|
|
47
|
+
layout: "padded",
|
|
48
|
+
docs: {
|
|
49
|
+
description: {
|
|
50
|
+
component:
|
|
51
|
+
"A part of the [Composer](?path=/docs/ai-composer--docs) family — what the `mode` slot renders. A composer control for an app-defined operating mode — how autonomously the agent may act. The trigger shows the current mode's icon + label; opening it lists every mode with its optional description and shortcut hint. `modes` is entirely prop-driven — no mode vocabulary is hardcoded.",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
args: {
|
|
56
|
+
modes: MODES,
|
|
57
|
+
onValueChange: fn(),
|
|
58
|
+
},
|
|
59
|
+
tags: ["autodocs"],
|
|
60
|
+
} satisfies Meta<typeof PromptInputMode>;
|
|
61
|
+
|
|
62
|
+
export default meta;
|
|
63
|
+
type Story = StoryObj<typeof meta>;
|
|
64
|
+
|
|
65
|
+
/** Uncontrolled — defaults to the first mode ("Auto"). */
|
|
66
|
+
export const Default: Story = {};
|
|
67
|
+
|
|
68
|
+
/** Opening the trigger lists every mode with its description and key hint. */
|
|
69
|
+
export const OpenMenu: Story = {
|
|
70
|
+
play: async ({ canvas, userEvent }) => {
|
|
71
|
+
const trigger = canvas.getByRole("button", { name: "Auto" });
|
|
72
|
+
await userEvent.click(trigger);
|
|
73
|
+
|
|
74
|
+
// Radix portals the menu to document.body, so it is OUTSIDE the story
|
|
75
|
+
// canvas. jsdom is forgiving about this; a real browser is not.
|
|
76
|
+
const menu = within(document.body);
|
|
77
|
+
const planOption = await menu.findByRole("menuitemradio", { name: "Plan first" });
|
|
78
|
+
await expect(planOption).toBeInTheDocument();
|
|
79
|
+
// The description and shortcut are visible but excluded from the
|
|
80
|
+
// accessible name (#153) — never let this drift back to a regex match,
|
|
81
|
+
// which would pass even with the extra text folded back in.
|
|
82
|
+
await expect(planOption).toHaveAccessibleName("Plan first");
|
|
83
|
+
await expect(
|
|
84
|
+
menu.getByText("Proposes a plan and waits for approval before acting."),
|
|
85
|
+
).toBeInTheDocument();
|
|
86
|
+
await expect(menu.getByText("⇧ Tab")).toBeInTheDocument();
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Selecting a mode updates the trigger and fires `onValueChange` once. */
|
|
91
|
+
export const SelectAMode: Story = {
|
|
92
|
+
play: async ({ canvas, args, userEvent }) => {
|
|
93
|
+
await userEvent.click(canvas.getByRole("button", { name: "Auto" }));
|
|
94
|
+
const reviewOption = await within(document.body).findByRole("menuitemradio", {
|
|
95
|
+
name: "Review edits",
|
|
96
|
+
});
|
|
97
|
+
await userEvent.click(reviewOption);
|
|
98
|
+
|
|
99
|
+
await expect(args.onValueChange).toHaveBeenCalledWith("review");
|
|
100
|
+
const trigger = await canvas.findByRole("button");
|
|
101
|
+
await expect(trigger).toHaveAccessibleName("Review edits");
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Controlled via `value` — the app owns which mode is shown. */
|
|
106
|
+
export const Controlled: Story = {
|
|
107
|
+
args: { value: "plan" },
|
|
108
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
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 { type OperatingMode } from "@elabs-ai/components-ui";
|
|
5
|
+
import { PromptInputMode } from "./prompt-input-mode";
|
|
6
|
+
|
|
7
|
+
const modes: OperatingMode[] = [
|
|
8
|
+
{ id: "auto", label: "Auto", description: "Acts without asking" },
|
|
9
|
+
{
|
|
10
|
+
id: "plan",
|
|
11
|
+
label: "Plan first",
|
|
12
|
+
description: "Proposes a plan before acting",
|
|
13
|
+
keyHint: "⇧ Tab",
|
|
14
|
+
},
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
describe("PromptInputMode", () => {
|
|
18
|
+
it("defaults to the first mode and shows it on the trigger", () => {
|
|
19
|
+
render(<PromptInputMode modes={modes} />);
|
|
20
|
+
const trigger = screen.getByRole("button");
|
|
21
|
+
expect(trigger).toHaveAccessibleName("Auto");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("is uncontrolled: opens the menu, lists every mode, and selects one on click", async () => {
|
|
25
|
+
const onValueChange = vi.fn();
|
|
26
|
+
render(<PromptInputMode modes={modes} onValueChange={onValueChange} />);
|
|
27
|
+
|
|
28
|
+
await userEvent.click(screen.getByRole("button", { name: "Auto" }));
|
|
29
|
+
const planOption = await screen.findByRole("menuitemradio", { name: "Plan first" });
|
|
30
|
+
expect(planOption).toHaveAccessibleName("Plan first");
|
|
31
|
+
expect(planOption).toHaveAttribute("aria-checked", "false");
|
|
32
|
+
|
|
33
|
+
await userEvent.click(planOption);
|
|
34
|
+
expect(onValueChange).toHaveBeenCalledWith("plan");
|
|
35
|
+
// The trigger's visible label updates to reflect the new selection.
|
|
36
|
+
const trigger = await screen.findByRole("button");
|
|
37
|
+
expect(trigger).toHaveAccessibleName("Plan first");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("is controlled via `value`: the app owns the selection, not the component", async () => {
|
|
41
|
+
const onValueChange = vi.fn();
|
|
42
|
+
const { rerender } = render(
|
|
43
|
+
<PromptInputMode modes={modes} value="plan" onValueChange={onValueChange} />,
|
|
44
|
+
);
|
|
45
|
+
expect(screen.getByRole("button")).toHaveAccessibleName("Plan first");
|
|
46
|
+
|
|
47
|
+
await userEvent.click(screen.getByRole("button", { name: "Plan first" }));
|
|
48
|
+
const autoOption = await screen.findByRole("menuitemradio", { name: "Auto" });
|
|
49
|
+
expect(autoOption).toHaveAccessibleName("Auto");
|
|
50
|
+
await userEvent.click(autoOption);
|
|
51
|
+
|
|
52
|
+
expect(onValueChange).toHaveBeenCalledWith("auto");
|
|
53
|
+
// A controlled component does not move on its own — the trigger only
|
|
54
|
+
// updates once the app feeds the new value back in.
|
|
55
|
+
expect(screen.getByRole("button")).toHaveAccessibleName("Plan first");
|
|
56
|
+
|
|
57
|
+
rerender(<PromptInputMode modes={modes} value="auto" onValueChange={onValueChange} />);
|
|
58
|
+
expect(screen.getByRole("button")).toHaveAccessibleName("Auto");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("renders a mode's description and key hint inside the menu", async () => {
|
|
62
|
+
render(<PromptInputMode modes={modes} />);
|
|
63
|
+
await userEvent.click(screen.getByRole("button", { name: "Auto" }));
|
|
64
|
+
|
|
65
|
+
expect(await screen.findByText("Proposes a plan before acting")).toBeInTheDocument();
|
|
66
|
+
expect(screen.getByText("⇧ Tab")).toBeInTheDocument();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("gives a menu item an accessible name that is exactly its label — the description and key hint never join it (#153)", async () => {
|
|
70
|
+
render(<PromptInputMode modes={modes} />);
|
|
71
|
+
await userEvent.click(screen.getByRole("button", { name: "Auto" }));
|
|
72
|
+
|
|
73
|
+
const planOption = await screen.findByRole("menuitemradio", { name: "Plan first" });
|
|
74
|
+
expect(planOption).toHaveAccessibleName("Plan first");
|
|
75
|
+
|
|
76
|
+
// The description is still associated for assistive tech — as a
|
|
77
|
+
// description, not folded into the name.
|
|
78
|
+
expect(planOption).toHaveAccessibleDescription("Proposes a plan before acting");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("resolves the accessible description even when a consumer-supplied mode id contains whitespace (#153)", async () => {
|
|
82
|
+
const modesWithSpaceId: OperatingMode[] = [
|
|
83
|
+
{ id: "auto", label: "Auto", description: "Acts without asking" },
|
|
84
|
+
{
|
|
85
|
+
id: "plan first",
|
|
86
|
+
label: "Plan first",
|
|
87
|
+
description: "Proposes a plan before acting",
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
render(<PromptInputMode modes={modesWithSpaceId} />);
|
|
91
|
+
await userEvent.click(screen.getByRole("button", { name: "Auto" }));
|
|
92
|
+
|
|
93
|
+
const planOption = await screen.findByRole("menuitemradio", { name: "Plan first" });
|
|
94
|
+
// `aria-describedby` is a space-separated token list of IDs — an id
|
|
95
|
+
// built from a raw mode value containing whitespace (e.g. "plan first")
|
|
96
|
+
// splits into two references, breaking the association.
|
|
97
|
+
expect(planOption).toHaveAccessibleDescription("Proposes a plan before acting");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, useId, type HTMLAttributes } from "react";
|
|
4
|
+
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
|
5
|
+
import { ChevronDown } from "lucide-react";
|
|
6
|
+
import {
|
|
7
|
+
DropdownMenu,
|
|
8
|
+
DropdownMenuContent,
|
|
9
|
+
DropdownMenuRadioGroup,
|
|
10
|
+
DropdownMenuRadioItem,
|
|
11
|
+
DropdownMenuTrigger,
|
|
12
|
+
Kbd,
|
|
13
|
+
cn,
|
|
14
|
+
type OperatingMode,
|
|
15
|
+
} from "@elabs-ai/components-ui";
|
|
16
|
+
|
|
17
|
+
import { PromptInputButton } from "./prompt-input";
|
|
18
|
+
|
|
19
|
+
// `OperatingMode` moved to `@elabs-ai/components-ui` (`lib/operating-mode.ts`)
|
|
20
|
+
// — the terminal CLI look-alike family's own composer chips (issue #117) reuse
|
|
21
|
+
// the same vocabulary shape, and `@elabs-ai/components-ai`/
|
|
22
|
+
// `@elabs-ai/components-terminal` are layer-2 DAG siblings that may not import
|
|
23
|
+
// each other (T0; see
|
|
24
|
+
// docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4). Imported
|
|
25
|
+
// above; NOT re-exported from here — `@elabs-ai/components-ai` never
|
|
26
|
+
// re-exports from `@elabs-ai/components-ui`. A consumer imports
|
|
27
|
+
// `OperatingMode` from `@elabs-ai/components-ui`.
|
|
28
|
+
|
|
29
|
+
export type PromptInputModeProps = Omit<HTMLAttributes<HTMLDivElement>, "onChange"> & {
|
|
30
|
+
/** The app's own operating modes, low to high ceremony or however it orders them. */
|
|
31
|
+
modes: OperatingMode[];
|
|
32
|
+
/** Controlled selected mode id. Omit for the uncontrolled default (`modes[0]`). */
|
|
33
|
+
value?: string;
|
|
34
|
+
/** Fires with the chosen mode's id. */
|
|
35
|
+
onValueChange?: (id: string) => void;
|
|
36
|
+
/**
|
|
37
|
+
* Accessible name for the trigger control, read alongside the current
|
|
38
|
+
* mode's label. Optional — the trigger already carries the current mode's
|
|
39
|
+
* visible text as its name, so this only adds context (e.g. "Operating
|
|
40
|
+
* mode: Auto" instead of just "Auto").
|
|
41
|
+
*/
|
|
42
|
+
"aria-label"?: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* PromptInputMode — a composer control for an app-defined operating mode
|
|
47
|
+
* (how autonomously the agent may act on the next turn).
|
|
48
|
+
*
|
|
49
|
+
* A `DropdownMenu` of `modes`, radio-selected so exactly one is active at a
|
|
50
|
+
* time; the trigger shows the current mode's icon + label. Ships no mode
|
|
51
|
+
* vocabulary — `modes` is entirely prop-driven, so `auto`/`plan`/whatever a
|
|
52
|
+
* given agent calls its modes lives in the consuming app, not here.
|
|
53
|
+
*
|
|
54
|
+
* Built on Radix (`DropdownMenu` + `DropdownMenuRadioGroup`/`RadioItem` from
|
|
55
|
+
* `@elabs-ai/components-ui`) for keyboard navigation, roving focus and a
|
|
56
|
+
* `menuitemradio` role whose `aria-checked` announces the current selection —
|
|
57
|
+
* no hand-rolled key handling.
|
|
58
|
+
*/
|
|
59
|
+
export const PromptInputMode = forwardRef<HTMLDivElement, PromptInputModeProps>(
|
|
60
|
+
function PromptInputMode(
|
|
61
|
+
{ modes, value, onValueChange, className, "aria-label": ariaLabel, ...props },
|
|
62
|
+
ref,
|
|
63
|
+
) {
|
|
64
|
+
const [selected, setSelected] = useControllableState<string>({
|
|
65
|
+
defaultProp: modes[0]?.id ?? "",
|
|
66
|
+
onChange: onValueChange,
|
|
67
|
+
prop: value,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const handleChange = (next: string) => {
|
|
71
|
+
// Radix would otherwise allow an empty-string commit if a caller wires
|
|
72
|
+
// a deselectable radio group elsewhere — a mode picker always has an
|
|
73
|
+
// active mode, so an empty commit is ignored.
|
|
74
|
+
if (!next) return;
|
|
75
|
+
setSelected(next);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const current = modes.find((mode) => mode.id === selected) ?? modes[0];
|
|
79
|
+
|
|
80
|
+
// `OperatingMode.id` is an unconstrained, consumer-owned string — it may
|
|
81
|
+
// contain whitespace or other characters that are invalid inside a
|
|
82
|
+
// single `aria-describedby` token (a space-separated ID list splits on
|
|
83
|
+
// whitespace). Derive a per-instance, DOM-safe id from `useId()` and the
|
|
84
|
+
// mode's index instead of embedding the raw value.
|
|
85
|
+
const descriptionIdBase = useId();
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div
|
|
89
|
+
ref={ref}
|
|
90
|
+
data-slot="prompt-input-mode"
|
|
91
|
+
className={cn("inline-flex", className)}
|
|
92
|
+
{...props}
|
|
93
|
+
>
|
|
94
|
+
{/*
|
|
95
|
+
* `modal={false}` is load-bearing, not a preference. Radix's modal
|
|
96
|
+
* dropdown marks the rest of the document `aria-hidden` while the menu
|
|
97
|
+
* is open — including this component's own trigger — which axe flags as
|
|
98
|
+
* `aria-hidden-focus` ("ARIA hidden element must not be focusable")
|
|
99
|
+
* and which drops the trigger out of the accessibility tree entirely.
|
|
100
|
+
* A composer toolbar chip has no business hiding the page or locking
|
|
101
|
+
* its scroll, so the non-modal layer is both the accessible answer and
|
|
102
|
+
* the semantically correct one.
|
|
103
|
+
*/}
|
|
104
|
+
<DropdownMenu modal={false}>
|
|
105
|
+
<DropdownMenuTrigger asChild>
|
|
106
|
+
<PromptInputButton
|
|
107
|
+
variant="ghost"
|
|
108
|
+
className="gap-1.5 rounded-full"
|
|
109
|
+
disabled={modes.length === 0}
|
|
110
|
+
aria-label={
|
|
111
|
+
ariaLabel && current ? `${ariaLabel}: ${current.label}` : (ariaLabel ?? undefined)
|
|
112
|
+
}
|
|
113
|
+
data-slot="prompt-input-mode-trigger"
|
|
114
|
+
>
|
|
115
|
+
{current?.icon}
|
|
116
|
+
<span>{current?.label}</span>
|
|
117
|
+
<ChevronDown className="size-3.5 opacity-60" aria-hidden="true" />
|
|
118
|
+
</PromptInputButton>
|
|
119
|
+
</DropdownMenuTrigger>
|
|
120
|
+
|
|
121
|
+
<DropdownMenuContent align="start" data-slot="prompt-input-mode-content" className="w-72">
|
|
122
|
+
<DropdownMenuRadioGroup value={selected} onValueChange={handleChange}>
|
|
123
|
+
{modes.map((mode, index) => {
|
|
124
|
+
const descriptionId =
|
|
125
|
+
mode.description === undefined
|
|
126
|
+
? undefined
|
|
127
|
+
: `${descriptionIdBase}-description-${index}`;
|
|
128
|
+
return (
|
|
129
|
+
<DropdownMenuRadioItem
|
|
130
|
+
key={mode.id}
|
|
131
|
+
value={mode.id}
|
|
132
|
+
data-slot="prompt-input-mode-item"
|
|
133
|
+
className="gap-2"
|
|
134
|
+
aria-describedby={descriptionId}
|
|
135
|
+
>
|
|
136
|
+
{mode.icon}
|
|
137
|
+
<span className="flex min-w-0 flex-1 flex-col">
|
|
138
|
+
<span className="truncate text-body">{mode.label}</span>
|
|
139
|
+
{mode.description === undefined ? null : (
|
|
140
|
+
// Visible, but excluded from the item's accessible
|
|
141
|
+
// name (aria-hidden) so the name stays exactly the
|
|
142
|
+
// label; still exposed as the accessible DESCRIPTION
|
|
143
|
+
// via aria-describedby above — a hidden node an
|
|
144
|
+
// aria-describedby reference points to is still read
|
|
145
|
+
// for the description computation.
|
|
146
|
+
<span
|
|
147
|
+
id={descriptionId}
|
|
148
|
+
aria-hidden="true"
|
|
149
|
+
className="truncate text-meta text-muted-foreground"
|
|
150
|
+
>
|
|
151
|
+
{mode.description}
|
|
152
|
+
</span>
|
|
153
|
+
)}
|
|
154
|
+
</span>
|
|
155
|
+
{mode.keyHint === undefined ? null : (
|
|
156
|
+
<Kbd className="shrink-0" aria-hidden="true">
|
|
157
|
+
{mode.keyHint}
|
|
158
|
+
</Kbd>
|
|
159
|
+
)}
|
|
160
|
+
</DropdownMenuRadioItem>
|
|
161
|
+
);
|
|
162
|
+
})}
|
|
163
|
+
</DropdownMenuRadioGroup>
|
|
164
|
+
</DropdownMenuContent>
|
|
165
|
+
</DropdownMenu>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
},
|
|
169
|
+
);
|