@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
package/src/tool.test.tsx
CHANGED
|
@@ -61,3 +61,68 @@ describe("ToolOutput isStreaming (#269, loading-states.md)", () => {
|
|
|
61
61
|
expect(container).toBeEmptyDOMElement();
|
|
62
62
|
});
|
|
63
63
|
});
|
|
64
|
+
|
|
65
|
+
// A tool that returns a count of 0, a boolean check of false, or an empty
|
|
66
|
+
// string is a REAL, defined result — only `undefined` means "no output yet".
|
|
67
|
+
// The old `!(output || errorText || isStreaming)` guard treated all three as
|
|
68
|
+
// "nothing to show" and rendered null outright.
|
|
69
|
+
describe("ToolOutput — defined-but-falsy output renders (review #1)", () => {
|
|
70
|
+
it("renders a numeric 0 result instead of returning null", () => {
|
|
71
|
+
render(<ToolOutput output={0} errorText={undefined} />);
|
|
72
|
+
expect(screen.getByText("Result")).toBeInTheDocument();
|
|
73
|
+
expect(screen.getByText("0")).toBeInTheDocument();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("renders a boolean false result instead of returning null", () => {
|
|
77
|
+
const { container } = render(<ToolOutput output={false} errorText={undefined} />);
|
|
78
|
+
expect(screen.getByText("Result")).toBeInTheDocument();
|
|
79
|
+
expect(container.querySelector('[data-language="json"]')).not.toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("renders an empty-string result (not the pending skeleton, not null)", () => {
|
|
83
|
+
const { container } = render(<ToolOutput output="" errorText={undefined} />);
|
|
84
|
+
expect(screen.getByText("Result")).toBeInTheDocument();
|
|
85
|
+
expect(container.querySelector('[role="status"]')).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("ToolOutput — string output renders as plain text, not forced JSON (review #1)", () => {
|
|
90
|
+
it("renders a plain-text (non-JSON) string result without the JSON highlighter", () => {
|
|
91
|
+
const { container } = render(
|
|
92
|
+
<ToolOutput output="just a plain sentence, not json" errorText={undefined} />,
|
|
93
|
+
);
|
|
94
|
+
expect(screen.getByText("just a plain sentence, not json")).toBeInTheDocument();
|
|
95
|
+
expect(container.querySelector('[data-language="text"]')).not.toBeNull();
|
|
96
|
+
expect(container.querySelector('[data-language="json"]')).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("still renders an object result through the JSON highlighter", () => {
|
|
100
|
+
const { container } = render(<ToolOutput output={{ ok: true }} errorText={undefined} />);
|
|
101
|
+
expect(container.querySelector('[data-language="json"]')).not.toBeNull();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("ToolOutput/ToolInput — JSON.stringify never crashes the render (review #1)", () => {
|
|
106
|
+
it("falls back gracefully for a circular output object instead of throwing", () => {
|
|
107
|
+
const circular: Record<string, unknown> = { name: "circular" };
|
|
108
|
+
circular.self = circular;
|
|
109
|
+
|
|
110
|
+
expect(() => render(<ToolOutput output={circular} errorText={undefined} />)).not.toThrow();
|
|
111
|
+
expect(screen.getByText("Result")).toBeInTheDocument();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("renders a BigInt field in the output instead of throwing", () => {
|
|
115
|
+
expect(() =>
|
|
116
|
+
render(<ToolOutput output={{ total: 10n }} errorText={undefined} />),
|
|
117
|
+
).not.toThrow();
|
|
118
|
+
expect(screen.getByText("Result")).toBeInTheDocument();
|
|
119
|
+
expect(screen.getByText(/10n/)).toBeInTheDocument();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("falls back gracefully for circular tool input instead of throwing", () => {
|
|
123
|
+
const circular: Record<string, unknown> = { query: "x" };
|
|
124
|
+
circular.self = circular;
|
|
125
|
+
|
|
126
|
+
expect(() => render(<ToolInput input={circular} />)).not.toThrow();
|
|
127
|
+
});
|
|
128
|
+
});
|
package/src/tool.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import type { DynamicToolUIPart, ToolUIPart } from "ai";
|
|
|
7
7
|
import { ChevronDownIcon, WrenchIcon } from "lucide-react";
|
|
8
8
|
import type { ComponentProps, ReactNode } from "react";
|
|
9
9
|
import { isValidElement } from "react";
|
|
10
|
+
import type { BundledLanguage } from "shiki";
|
|
10
11
|
|
|
11
12
|
import { CodeBlock } from "./code-block";
|
|
12
13
|
|
|
@@ -21,6 +22,19 @@ export const Tool = ({ className, ...props }: ToolProps) => (
|
|
|
21
22
|
|
|
22
23
|
export type ToolPart = ToolUIPart | DynamicToolUIPart;
|
|
23
24
|
|
|
25
|
+
/**
|
|
26
|
+
* `JSON.stringify` throws on a circular reference or a `BigInt` — both
|
|
27
|
+
* realistic shapes for tool input/output payloads a model produced. Falls
|
|
28
|
+
* back to a readable placeholder instead of crashing the message render.
|
|
29
|
+
*/
|
|
30
|
+
function safeJsonStringify(value: unknown): string {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.stringify(value, (_key, v) => (typeof v === "bigint" ? `${v.toString()}n` : v), 2);
|
|
33
|
+
} catch {
|
|
34
|
+
return String(value);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
24
38
|
export type ToolHeaderProps = {
|
|
25
39
|
title?: string;
|
|
26
40
|
/**
|
|
@@ -123,36 +137,44 @@ export type ToolDetailsProps = ComponentProps<typeof Collapsible> & {
|
|
|
123
137
|
*/
|
|
124
138
|
export const ToolDetails = ({
|
|
125
139
|
className,
|
|
126
|
-
label
|
|
140
|
+
label,
|
|
127
141
|
defaultOpen = false,
|
|
128
142
|
children,
|
|
129
143
|
...props
|
|
130
|
-
}: ToolDetailsProps) =>
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
144
|
+
}: ToolDetailsProps) => {
|
|
145
|
+
const { t } = useLocale();
|
|
146
|
+
return (
|
|
147
|
+
<Collapsible
|
|
148
|
+
className={cn("group/tool-details not-prose", className)}
|
|
149
|
+
defaultOpen={defaultOpen}
|
|
150
|
+
{...props}
|
|
151
|
+
>
|
|
152
|
+
<CollapsibleTrigger className="flex items-center gap-1 rounded-sm text-meta text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
153
|
+
<ChevronDownIcon className="size-3.5 transition-transform group-data-[state=open]/tool-details:rotate-180" />
|
|
154
|
+
{label ?? t("ai.tool.showTechnicalDetails")}
|
|
155
|
+
</CollapsibleTrigger>
|
|
156
|
+
<CollapsibleContent className="mt-3 space-y-4">{children}</CollapsibleContent>
|
|
157
|
+
</Collapsible>
|
|
158
|
+
);
|
|
159
|
+
};
|
|
143
160
|
|
|
144
161
|
export type ToolInputProps = ComponentProps<"div"> & {
|
|
145
162
|
input: ToolPart["input"];
|
|
146
163
|
};
|
|
147
164
|
|
|
148
|
-
export const ToolInput = ({ className, input, ...props }: ToolInputProps) =>
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
<div className="
|
|
152
|
-
<
|
|
165
|
+
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => {
|
|
166
|
+
const { t } = useLocale();
|
|
167
|
+
return (
|
|
168
|
+
<div className={cn("space-y-2 overflow-hidden", className)} {...props}>
|
|
169
|
+
<h4 className="text-meta uppercase text-muted-foreground">
|
|
170
|
+
{t("ai.schemaDisplay.parameters")}
|
|
171
|
+
</h4>
|
|
172
|
+
<div className="rounded-md bg-muted/50">
|
|
173
|
+
<CodeBlock code={safeJsonStringify(input)} language="json" />
|
|
174
|
+
</div>
|
|
153
175
|
</div>
|
|
154
|
-
|
|
155
|
-
|
|
176
|
+
);
|
|
177
|
+
};
|
|
156
178
|
|
|
157
179
|
export type ToolOutputProps = ComponentProps<"div"> & {
|
|
158
180
|
/**
|
|
@@ -192,25 +214,44 @@ export const ToolOutput = ({
|
|
|
192
214
|
}: ToolOutputProps) => {
|
|
193
215
|
const { t } = useLocale();
|
|
194
216
|
|
|
195
|
-
|
|
217
|
+
// `output` is a defined-but-falsy result (`0`, `false`, `""`) for plenty of
|
|
218
|
+
// real tools (a count, a boolean check, an empty-string field) — only
|
|
219
|
+
// `undefined` means "no output (yet)".
|
|
220
|
+
const hasOutput = output !== undefined;
|
|
221
|
+
|
|
222
|
+
if (!(hasOutput || errorText || isStreaming)) {
|
|
196
223
|
return null;
|
|
197
224
|
}
|
|
198
225
|
|
|
199
226
|
const showError = !isStreaming && Boolean(errorText);
|
|
200
|
-
const pending = isStreaming && !
|
|
227
|
+
const pending = isStreaming && !hasOutput && !errorText;
|
|
201
228
|
|
|
202
|
-
let Output =
|
|
229
|
+
let Output: ReactNode = null;
|
|
203
230
|
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
231
|
+
if (hasOutput) {
|
|
232
|
+
if (isValidElement(output)) {
|
|
233
|
+
Output = output;
|
|
234
|
+
} else if (typeof output === "string") {
|
|
235
|
+
// A tool result is arbitrary text, not guaranteed JSON — forcing the
|
|
236
|
+
// JSON highlighter on it mis-colours ordinary strings. Shiki's own
|
|
237
|
+
// `BundledLanguage` union (grammar-backed languages) omits its
|
|
238
|
+
// hard-coded plain-text pseudo-languages (`isPlainLang`:
|
|
239
|
+
// "plaintext" | "txt" | "text" | "plain") — `createHighlighter`/
|
|
240
|
+
// `codeToTokens` accept and special-case them with no grammar load
|
|
241
|
+
// (verified: `getLoadedLanguages()` stays empty, no throw), so this
|
|
242
|
+
// is a type-only gap, not a runtime one.
|
|
243
|
+
Output = <CodeBlock code={output} language={"text" as BundledLanguage} />;
|
|
244
|
+
} else {
|
|
245
|
+
// Objects, arrays, numbers, booleans, null, bigint — all safe to
|
|
246
|
+
// stringify for display.
|
|
247
|
+
Output = <CodeBlock code={safeJsonStringify(output)} language="json" />;
|
|
248
|
+
}
|
|
208
249
|
}
|
|
209
250
|
|
|
210
251
|
return (
|
|
211
252
|
<div className={cn("space-y-2", className)} {...props}>
|
|
212
253
|
<h4 className="text-meta uppercase text-muted-foreground">
|
|
213
|
-
{showError ? "
|
|
254
|
+
{showError ? t("ai.tool.error") : t("ai.tool.result")}
|
|
214
255
|
</h4>
|
|
215
256
|
{pending ? (
|
|
216
257
|
<div className="space-y-2 rounded-md bg-muted/50 p-3" role="status" aria-live="polite">
|
|
@@ -223,7 +264,9 @@ export const ToolOutput = ({
|
|
|
223
264
|
<div
|
|
224
265
|
className={cn(
|
|
225
266
|
"overflow-x-auto rounded-md text-caption [&_table]:w-full",
|
|
226
|
-
|
|
267
|
+
// #124: this colours the ambient text of the error/result body —
|
|
268
|
+
// running text, so the error branch takes the ink rung.
|
|
269
|
+
showError ? "bg-destructive/10 text-destructive-text" : "bg-muted/50 text-foreground",
|
|
227
270
|
)}
|
|
228
271
|
>
|
|
229
272
|
{showError && <div>{errorText}</div>}
|
package/src/transcription.tsx
CHANGED
|
@@ -54,7 +54,7 @@ export const Transcription = ({
|
|
|
54
54
|
return (
|
|
55
55
|
<TranscriptionContext.Provider value={contextValue}>
|
|
56
56
|
<div
|
|
57
|
-
className={cn("flex flex-wrap gap-1 text-
|
|
57
|
+
className={cn("flex flex-wrap gap-1 text-body leading-relaxed", className)}
|
|
58
58
|
data-slot="transcription"
|
|
59
59
|
{...props}
|
|
60
60
|
>
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { TurnStatus } from "./turn-status";
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: "AI/TurnStatus",
|
|
7
|
+
component: TurnStatus,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: "padded",
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
"The CHAT in-turn footer; the console skin is `Terminal/TerminalWorking` — see " +
|
|
14
|
+
"[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
|
|
15
|
+
"The in-turn footer: label, elapsed time, token counts, turn progress and a stop affordance in one place (#105). Exactly one role=status live region announces the label and the settled state — the ticking elapsed/tokens/turn stats are plain visible text so they never flood assistive tech.",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
args: {
|
|
20
|
+
elapsedMs: 8000,
|
|
21
|
+
label: "Working…",
|
|
22
|
+
status: "working",
|
|
23
|
+
tokens: { input: 1200, output: 340 },
|
|
24
|
+
turn: 2,
|
|
25
|
+
turnTotal: 5,
|
|
26
|
+
},
|
|
27
|
+
tags: ["autodocs"],
|
|
28
|
+
} satisfies Meta<typeof TurnStatus>;
|
|
29
|
+
|
|
30
|
+
export default meta;
|
|
31
|
+
type Story = StoryObj<typeof meta>;
|
|
32
|
+
|
|
33
|
+
/** A running turn: label, elapsed, tokens, turn progress and a stop button. */
|
|
34
|
+
export const Default: Story = {
|
|
35
|
+
args: {
|
|
36
|
+
onStop: () => undefined,
|
|
37
|
+
},
|
|
38
|
+
render: (args) => (
|
|
39
|
+
<div className="max-w-lg rounded-md border bg-card p-2">
|
|
40
|
+
<TurnStatus {...args} />
|
|
41
|
+
</div>
|
|
42
|
+
),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** No stop control — just the label and live stats. */
|
|
46
|
+
export const WithoutStop: Story = {
|
|
47
|
+
render: (args) => (
|
|
48
|
+
<div className="max-w-lg rounded-md border bg-card p-2">
|
|
49
|
+
<TurnStatus {...args} />
|
|
50
|
+
</div>
|
|
51
|
+
),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** A minimal working state — only a label, no stats yet. */
|
|
55
|
+
export const LabelOnly: Story = {
|
|
56
|
+
args: {
|
|
57
|
+
elapsedMs: undefined,
|
|
58
|
+
tokens: undefined,
|
|
59
|
+
turn: undefined,
|
|
60
|
+
turnTotal: undefined,
|
|
61
|
+
},
|
|
62
|
+
render: (args) => (
|
|
63
|
+
<div className="max-w-lg rounded-md border bg-card p-2">
|
|
64
|
+
<TurnStatus {...args} />
|
|
65
|
+
</div>
|
|
66
|
+
),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** The completed-turn line — stats and the stop control disappear; the live region stops announcing further ticks. */
|
|
70
|
+
export const Settled: Story = {
|
|
71
|
+
args: {
|
|
72
|
+
status: "settled",
|
|
73
|
+
},
|
|
74
|
+
render: (args) => (
|
|
75
|
+
<div className="max-w-lg rounded-md border bg-card p-2">
|
|
76
|
+
<TurnStatus {...args} />
|
|
77
|
+
</div>
|
|
78
|
+
),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Adds a "scroll to bottom" affordance beside the stop control. */
|
|
82
|
+
export const WithScrollToBottom: Story = {
|
|
83
|
+
args: {
|
|
84
|
+
onScrollToBottom: () => undefined,
|
|
85
|
+
onStop: () => undefined,
|
|
86
|
+
showScrollToBottom: true,
|
|
87
|
+
},
|
|
88
|
+
render: (args) => (
|
|
89
|
+
<div className="max-w-lg rounded-md border bg-card p-2">
|
|
90
|
+
<TurnStatus {...args} />
|
|
91
|
+
</div>
|
|
92
|
+
),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** A live-ticking demo — elapsed time advances every second until Stop settles the turn. */
|
|
96
|
+
export const Live: Story = {
|
|
97
|
+
render: () => {
|
|
98
|
+
const LiveDemo = () => {
|
|
99
|
+
const [elapsedMs, setElapsedMs] = useState(0);
|
|
100
|
+
const [status, setStatus] = useState<"working" | "settled">("working");
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (status !== "working") return;
|
|
104
|
+
const id = setInterval(() => setElapsedMs((ms) => ms + 1000), 1000);
|
|
105
|
+
return () => clearInterval(id);
|
|
106
|
+
}, [status]);
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className="max-w-lg rounded-md border bg-card p-2">
|
|
110
|
+
<TurnStatus
|
|
111
|
+
elapsedMs={elapsedMs}
|
|
112
|
+
label="Editing files…"
|
|
113
|
+
onStop={() => setStatus("settled")}
|
|
114
|
+
status={status}
|
|
115
|
+
tokens={{ input: 800 + elapsedMs, output: 120 + elapsedMs / 2 }}
|
|
116
|
+
turn={1}
|
|
117
|
+
turnTotal={3}
|
|
118
|
+
/>
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
return <LiveDemo />;
|
|
123
|
+
},
|
|
124
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { formatElapsed } from "@elabs-ai/components-ui";
|
|
4
|
+
import { TurnStatus } from "./turn-status";
|
|
5
|
+
|
|
6
|
+
// `formatElapsed` now lives in `@elabs-ai/components-ui` (docs/decisions/
|
|
7
|
+
// 2026-09-01-brainless-adoption-architecture.md § 6.1) — `TurnStatus` imports
|
|
8
|
+
// it rather than declaring it. These tests stay here because `TurnStatus` is
|
|
9
|
+
// the surface whose behaviour they were written to lock.
|
|
10
|
+
describe("formatElapsed", () => {
|
|
11
|
+
it.each([
|
|
12
|
+
// [elapsedMs, expected]
|
|
13
|
+
[0, "0ms"],
|
|
14
|
+
[420, "420ms"],
|
|
15
|
+
[999, "999ms"],
|
|
16
|
+
[1000, "1.0s"],
|
|
17
|
+
[8000, "8.0s"],
|
|
18
|
+
[9949, "9.9s"],
|
|
19
|
+
[10000, "10s"],
|
|
20
|
+
[42000, "42s"],
|
|
21
|
+
[59000, "59s"],
|
|
22
|
+
[60000, "1m00s"],
|
|
23
|
+
[77000, "1m17s"],
|
|
24
|
+
[605000, "10m05s"],
|
|
25
|
+
] as const)("formats %dms as %s", (elapsedMs, expected) => {
|
|
26
|
+
expect(formatElapsed(elapsedMs)).toBe(expected);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("TurnStatus", () => {
|
|
31
|
+
it("renders exactly one live region when label, elapsed, tokens and turn are all supplied", () => {
|
|
32
|
+
render(
|
|
33
|
+
<TurnStatus
|
|
34
|
+
elapsedMs={8000}
|
|
35
|
+
label="Working…"
|
|
36
|
+
tokens={{ input: 120, output: 340 }}
|
|
37
|
+
turn={2}
|
|
38
|
+
turnTotal={5}
|
|
39
|
+
/>,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const liveRegions = screen.getAllByRole("status");
|
|
43
|
+
expect(liveRegions).toHaveLength(1);
|
|
44
|
+
expect(liveRegions[0]).toHaveTextContent("Working…");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("renders the elapsed time and does not put it inside the live region", () => {
|
|
48
|
+
render(<TurnStatus elapsedMs={8000} label="Working…" />);
|
|
49
|
+
|
|
50
|
+
expect(screen.getByText("8.0s")).toBeInTheDocument();
|
|
51
|
+
const [liveRegion] = screen.getAllByRole("status");
|
|
52
|
+
expect(liveRegion).not.toHaveTextContent("8.0s");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("renders a focusable stop button while working, reachable via onStop", () => {
|
|
56
|
+
const onStop = () => undefined;
|
|
57
|
+
render(<TurnStatus label="Working…" onStop={onStop} status="working" />);
|
|
58
|
+
|
|
59
|
+
const stop = screen.getByRole("button", { name: "Stop" });
|
|
60
|
+
expect(stop).toBeInTheDocument();
|
|
61
|
+
expect(stop.tagName).toBe("BUTTON");
|
|
62
|
+
stop.focus();
|
|
63
|
+
expect(stop).toHaveFocus();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("renders the completed-turn line and stops announcing when settled", () => {
|
|
67
|
+
render(<TurnStatus elapsedMs={8000} status="settled" />);
|
|
68
|
+
|
|
69
|
+
expect(screen.queryByRole("button", { name: "Stop" })).not.toBeInTheDocument();
|
|
70
|
+
const liveRegions = screen.getAllByRole("status");
|
|
71
|
+
expect(liveRegions).toHaveLength(1);
|
|
72
|
+
expect(liveRegions[0]).toHaveTextContent(/8\.0s/);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button, cn, formatElapsed, useLocale } from "@elabs-ai/components-ui";
|
|
4
|
+
import { ArrowDownIcon, SquareIcon } from "lucide-react";
|
|
5
|
+
import type { ComponentProps } from "react";
|
|
6
|
+
import { forwardRef } from "react";
|
|
7
|
+
|
|
8
|
+
export interface TurnStatusTokens {
|
|
9
|
+
input?: number;
|
|
10
|
+
output?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TurnStatusProps extends Omit<ComponentProps<"div">, "children"> {
|
|
14
|
+
/**
|
|
15
|
+
* What the turn is doing right now, e.g. `"Working…"`, `"Editing files…"`.
|
|
16
|
+
* Caller-supplied and rendered verbatim — `TurnStatus` never invents this
|
|
17
|
+
* text, so it carries no default and needs no translation key of its own.
|
|
18
|
+
*/
|
|
19
|
+
label?: string;
|
|
20
|
+
/** Elapsed time in the current turn, in milliseconds. Rendered via `formatElapsed`. */
|
|
21
|
+
elapsedMs?: number;
|
|
22
|
+
/** Token counts for the turn so far. */
|
|
23
|
+
tokens?: TurnStatusTokens;
|
|
24
|
+
/** 1-based index of the current turn. */
|
|
25
|
+
turn?: number;
|
|
26
|
+
/** Total number of turns, when known. */
|
|
27
|
+
turnTotal?: number;
|
|
28
|
+
/**
|
|
29
|
+
* `"working"` (default) renders the live stats + stop control and keeps
|
|
30
|
+
* announcing `label` changes. `"settled"` renders the completed-turn line
|
|
31
|
+
* instead and stops announcing (the elapsed/tokens/turn stats and the stop
|
|
32
|
+
* control are hidden — the turn is over).
|
|
33
|
+
*/
|
|
34
|
+
status?: "working" | "settled";
|
|
35
|
+
/** Renders a focusable stop button, reachable by keyboard, while `status="working"`. */
|
|
36
|
+
onStop?: () => void;
|
|
37
|
+
/** Renders a "scroll to bottom" affordance. */
|
|
38
|
+
showScrollToBottom?: boolean;
|
|
39
|
+
onScrollToBottom?: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* TurnStatus — the in-turn footer: a working/settled label, elapsed time,
|
|
44
|
+
* token counts, turn progress, and a stop affordance, all in one place (#105).
|
|
45
|
+
*
|
|
46
|
+
* Exactly ONE `role="status" aria-live="polite"` region is rendered — a
|
|
47
|
+
* visually-hidden node that announces `label` while working and the
|
|
48
|
+
* completed-turn line once `status="settled"`. The ticking elapsed time and
|
|
49
|
+
* token/turn counts are plain (non-live) visible text: they update every
|
|
50
|
+
* render without re-announcing on every tick, which would flood assistive
|
|
51
|
+
* tech (see `.claude/rules/loading-states.md`). This mirrors `Persona`'s
|
|
52
|
+
* visible-content/`sr-only`-announcement split.
|
|
53
|
+
*/
|
|
54
|
+
export const TurnStatus = forwardRef<HTMLDivElement, TurnStatusProps>(function TurnStatus(
|
|
55
|
+
{
|
|
56
|
+
label,
|
|
57
|
+
elapsedMs,
|
|
58
|
+
tokens,
|
|
59
|
+
turn,
|
|
60
|
+
turnTotal,
|
|
61
|
+
status = "working",
|
|
62
|
+
onStop,
|
|
63
|
+
showScrollToBottom,
|
|
64
|
+
onScrollToBottom,
|
|
65
|
+
className,
|
|
66
|
+
...props
|
|
67
|
+
},
|
|
68
|
+
ref,
|
|
69
|
+
) {
|
|
70
|
+
const { t, formatNumber } = useLocale();
|
|
71
|
+
const isSettled = status === "settled";
|
|
72
|
+
const formattedElapsed = elapsedMs === undefined ? undefined : formatElapsed(elapsedMs);
|
|
73
|
+
|
|
74
|
+
const settledText = isSettled
|
|
75
|
+
? (label ??
|
|
76
|
+
(formattedElapsed
|
|
77
|
+
? t("ai.turnStatus.completedIn", { elapsed: formattedElapsed })
|
|
78
|
+
: t("ai.turnStatus.completed")))
|
|
79
|
+
: undefined;
|
|
80
|
+
|
|
81
|
+
/** The one thing this component ever announces: `label`, or the settled line. */
|
|
82
|
+
const announced = isSettled ? (settledText ?? "") : (label ?? "");
|
|
83
|
+
|
|
84
|
+
const hasTokens = tokens?.input !== undefined || tokens?.output !== undefined;
|
|
85
|
+
const hasTurn = turn !== undefined;
|
|
86
|
+
const visibleLabel = isSettled ? settledText : label;
|
|
87
|
+
const hasMeta = !isSettled && (formattedElapsed !== undefined || hasTokens || hasTurn);
|
|
88
|
+
const showStop = !isSettled && Boolean(onStop);
|
|
89
|
+
const hasTrailing = hasMeta || showScrollToBottom || showStop;
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div
|
|
93
|
+
ref={ref}
|
|
94
|
+
className={cn("flex w-full items-center gap-3 text-body text-foreground", className)}
|
|
95
|
+
data-slot="turn-status"
|
|
96
|
+
{...props}
|
|
97
|
+
>
|
|
98
|
+
<span
|
|
99
|
+
aria-hidden="true"
|
|
100
|
+
className={cn(
|
|
101
|
+
"size-2 shrink-0 rounded-full",
|
|
102
|
+
isSettled ? "bg-success" : "animate-pulse bg-primary motion-reduce:animate-none",
|
|
103
|
+
)}
|
|
104
|
+
data-slot="turn-status-indicator"
|
|
105
|
+
/>
|
|
106
|
+
|
|
107
|
+
{visibleLabel ? (
|
|
108
|
+
<span className="truncate" data-slot="turn-status-label">
|
|
109
|
+
{visibleLabel}
|
|
110
|
+
</span>
|
|
111
|
+
) : null}
|
|
112
|
+
|
|
113
|
+
{/* The single live region — see the component doc comment above. */}
|
|
114
|
+
<span aria-live="polite" className="sr-only" data-slot="turn-status-live" role="status">
|
|
115
|
+
{announced}
|
|
116
|
+
</span>
|
|
117
|
+
|
|
118
|
+
{hasTrailing ? (
|
|
119
|
+
<div className="ms-auto flex shrink-0 items-center gap-3" data-slot="turn-status-trailing">
|
|
120
|
+
{hasMeta ? (
|
|
121
|
+
<div
|
|
122
|
+
className="flex items-center gap-3 text-meta text-muted-foreground tabular-nums"
|
|
123
|
+
data-slot="turn-status-meta"
|
|
124
|
+
>
|
|
125
|
+
{formattedElapsed ? (
|
|
126
|
+
<span data-slot="turn-status-elapsed">{formattedElapsed}</span>
|
|
127
|
+
) : null}
|
|
128
|
+
{hasTokens ? (
|
|
129
|
+
<span data-slot="turn-status-tokens">
|
|
130
|
+
{formatNumber(tokens?.input ?? 0, { notation: "compact" })}
|
|
131
|
+
{"↑ "}
|
|
132
|
+
{formatNumber(tokens?.output ?? 0, { notation: "compact" })}
|
|
133
|
+
{"↓"}
|
|
134
|
+
</span>
|
|
135
|
+
) : null}
|
|
136
|
+
{hasTurn ? (
|
|
137
|
+
<span data-slot="turn-status-turn">
|
|
138
|
+
{turn}
|
|
139
|
+
{turnTotal === undefined ? "" : `/${turnTotal}`}
|
|
140
|
+
</span>
|
|
141
|
+
) : null}
|
|
142
|
+
</div>
|
|
143
|
+
) : null}
|
|
144
|
+
|
|
145
|
+
{showScrollToBottom ? (
|
|
146
|
+
<Button
|
|
147
|
+
aria-label={t("ai.turnStatus.scrollToBottom")}
|
|
148
|
+
data-slot="turn-status-scroll-to-bottom"
|
|
149
|
+
onClick={onScrollToBottom}
|
|
150
|
+
size="icon-sm"
|
|
151
|
+
type="button"
|
|
152
|
+
variant="ghost"
|
|
153
|
+
>
|
|
154
|
+
<ArrowDownIcon className="size-4" />
|
|
155
|
+
</Button>
|
|
156
|
+
) : null}
|
|
157
|
+
|
|
158
|
+
{showStop ? (
|
|
159
|
+
<Button
|
|
160
|
+
aria-label={t("ai.promptInput.stop")}
|
|
161
|
+
data-slot="turn-status-stop"
|
|
162
|
+
onClick={onStop}
|
|
163
|
+
size="icon-sm"
|
|
164
|
+
type="button"
|
|
165
|
+
variant="outline"
|
|
166
|
+
>
|
|
167
|
+
<SquareIcon className="size-4" />
|
|
168
|
+
</Button>
|
|
169
|
+
) : null}
|
|
170
|
+
</div>
|
|
171
|
+
) : null}
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
});
|