@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.
Files changed (180) hide show
  1. package/README.md +62 -12
  2. package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
  3. package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
  4. package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
  5. package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
  6. package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
  7. package/dist/_persona-rive-JEG44YHX.js.map +1 -0
  8. package/dist/index.d.ts +1152 -218
  9. package/dist/index.js +5601 -3241
  10. package/dist/index.js.map +1 -1
  11. package/package.json +30 -15
  12. package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
  13. package/src/__contract__/chat-shell.contract.test.tsx +49 -0
  14. package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
  15. package/src/__contract__/image.contract.test.tsx +49 -0
  16. package/src/__contract__/markdown-view.contract.test.tsx +49 -0
  17. package/src/__contract__/message-feedback.contract.test.tsx +49 -0
  18. package/src/__contract__/message-form.contract.test.tsx +49 -0
  19. package/src/__contract__/message-table.contract.test.tsx +49 -0
  20. package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
  21. package/src/__contract__/persona.contract.test.tsx +49 -0
  22. package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
  23. package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
  24. package/src/_audio-player-media-chrome.tsx +102 -15
  25. package/src/_chat-shell-rail.tsx +2 -2
  26. package/src/_flow-boundary.tsx +68 -49
  27. package/src/_lazy-boundary-conformance.ts +38 -0
  28. package/src/_lazy-cjk.test.ts +43 -0
  29. package/src/_lazy-cjk.ts +73 -0
  30. package/src/_lazy-engine-boundary.tsx +61 -0
  31. package/src/_lazy-math.test.ts +63 -0
  32. package/src/_lazy-math.ts +90 -0
  33. package/src/_lazy-mermaid-absent.test.ts +53 -0
  34. package/src/_lazy-mermaid.test.ts +15 -0
  35. package/src/_lazy-mermaid.ts +24 -1
  36. package/src/_mermaid-error-panel.test.tsx +50 -0
  37. package/src/_mermaid-error-panel.tsx +66 -0
  38. package/src/_persona-rive.tsx +62 -10
  39. package/src/_streamdown-i18n.ts +94 -22
  40. package/src/_streamdown-safety.ts +170 -0
  41. package/src/_theme-scope-store.test.ts +83 -0
  42. package/src/_theme-scope-store.ts +103 -0
  43. package/src/agent-event.stories.tsx +97 -0
  44. package/src/agent-event.test.tsx +145 -0
  45. package/src/agent-event.tsx +187 -0
  46. package/src/agent-timeline.stories.tsx +9 -1
  47. package/src/agent.test.tsx +47 -0
  48. package/src/agent.tsx +43 -24
  49. package/src/agentic-workspace.stories.tsx +27 -20
  50. package/src/artifact.tsx +17 -14
  51. package/src/asset-preview.test.tsx +40 -0
  52. package/src/asset-preview.tsx +83 -12
  53. package/src/attachments.tsx +7 -4
  54. package/src/audio-player.test.tsx +78 -0
  55. package/src/audio-player.tsx +243 -56
  56. package/src/audio-visualizer.stories.tsx +126 -0
  57. package/src/audio-visualizer.test.tsx +438 -0
  58. package/src/audio-visualizer.tsx +367 -0
  59. package/src/canvas.stories.tsx +150 -1
  60. package/src/chat-shell.stories.tsx +18 -3
  61. package/src/chat.stories.tsx +16 -2
  62. package/src/code-block.stories.tsx +9 -1
  63. package/src/code-block.test.tsx +100 -1
  64. package/src/code-block.tsx +166 -103
  65. package/src/commit.tsx +30 -41
  66. package/src/composer.stories.tsx +372 -9
  67. package/src/composer.test.tsx +357 -6
  68. package/src/composer.tsx +217 -35
  69. package/src/confirmation.stories.tsx +72 -1
  70. package/src/confirmation.test.tsx +216 -2
  71. package/src/confirmation.tsx +264 -4
  72. package/src/context-panel.stories.tsx +9 -1
  73. package/src/context-panel.tsx +3 -2
  74. package/src/conversation.stories.tsx +86 -2
  75. package/src/conversation.test.tsx +66 -0
  76. package/src/conversation.tsx +52 -9
  77. package/src/diff-view.stories.tsx +196 -0
  78. package/src/diff-view.test.tsx +238 -0
  79. package/src/diff-view.tsx +697 -0
  80. package/src/environment-variables.tsx +20 -37
  81. package/src/file-tree.test.tsx +21 -0
  82. package/src/file-tree.tsx +12 -2
  83. package/src/gallery.tsx +4 -2
  84. package/src/index.ts +14 -4
  85. package/src/inline-citation.tsx +5 -5
  86. package/src/jsx-preview.stories.tsx +2 -2
  87. package/src/jsx-preview.tsx +151 -43
  88. package/src/markdown-view.stories.tsx +92 -1
  89. package/src/markdown-view.test.tsx +237 -2
  90. package/src/markdown-view.tsx +157 -6
  91. package/src/message-compare.stories.tsx +175 -0
  92. package/src/message-compare.test.tsx +207 -0
  93. package/src/message-compare.tsx +453 -0
  94. package/src/message-form.stories.tsx +65 -4
  95. package/src/message-form.test.tsx +8 -2
  96. package/src/message-form.tsx +15 -7
  97. package/src/message-table.stories.tsx +2 -2
  98. package/src/message-table.test.tsx +7 -0
  99. package/src/message-table.tsx +8 -4
  100. package/src/message.stories.tsx +9 -1
  101. package/src/message.test.tsx +247 -1
  102. package/src/message.tsx +125 -15
  103. package/src/microcopy.test.tsx +40 -0
  104. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  105. package/src/model-provider-logo.test.tsx +109 -0
  106. package/src/model-provider-logo.tsx +183 -0
  107. package/src/open-in-chat.tsx +50 -29
  108. package/src/package-info.tsx +12 -12
  109. package/src/permission-mode-select.stories.tsx +82 -0
  110. package/src/permission-mode-select.test.tsx +100 -0
  111. package/src/permission-mode-select.tsx +137 -0
  112. package/src/persona-missing-peer.test.tsx +54 -0
  113. package/src/persona.tsx +68 -22
  114. package/src/plan.stories.tsx +166 -0
  115. package/src/plan.test.tsx +267 -0
  116. package/src/plan.tsx +182 -20
  117. package/src/prompt-input-effort.stories.tsx +123 -0
  118. package/src/prompt-input-effort.test.tsx +83 -0
  119. package/src/prompt-input-effort.tsx +136 -0
  120. package/src/prompt-input-mode.stories.tsx +108 -0
  121. package/src/prompt-input-mode.test.tsx +99 -0
  122. package/src/prompt-input-mode.tsx +169 -0
  123. package/src/prompt-input-slash.stories.tsx +211 -0
  124. package/src/prompt-input-slash.test.tsx +262 -0
  125. package/src/prompt-input-slash.tsx +541 -0
  126. package/src/prompt-input.stories.tsx +2 -2
  127. package/src/prompt-input.test.tsx +67 -1
  128. package/src/prompt-input.tsx +42 -4
  129. package/src/queue.tsx +4 -4
  130. package/src/reasoning.tsx +42 -17
  131. package/src/sandbox.stories.tsx +9 -1
  132. package/src/sandbox.tsx +3 -3
  133. package/src/schema-display.test.tsx +56 -0
  134. package/src/schema-display.tsx +71 -37
  135. package/src/selection-toolbar.stories.tsx +9 -1
  136. package/src/session-header.stories.tsx +128 -0
  137. package/src/session-header.test.tsx +138 -0
  138. package/src/session-header.tsx +243 -0
  139. package/src/session-status-bar.stories.tsx +73 -0
  140. package/src/session-status-bar.test.tsx +94 -0
  141. package/src/session-status-bar.tsx +165 -0
  142. package/src/snippet.stories.tsx +9 -1
  143. package/src/snippet.test.tsx +6 -2
  144. package/src/snippet.tsx +14 -34
  145. package/src/speech-input.test.tsx +109 -0
  146. package/src/speech-input.tsx +31 -3
  147. package/src/stack-trace.tsx +24 -36
  148. package/src/streamdown-i18n.test.tsx +24 -1
  149. package/src/task.stories.tsx +13 -3
  150. package/src/templates-ai-assistant.stories.tsx +21 -1
  151. package/src/test-results.tsx +47 -27
  152. package/src/token-usage.stories.tsx +24 -0
  153. package/src/token-usage.test.tsx +92 -0
  154. package/src/{context.tsx → token-usage.tsx} +73 -59
  155. package/src/tool-result-card.stories.tsx +9 -1
  156. package/src/tool.stories.tsx +13 -3
  157. package/src/tool.test.tsx +65 -0
  158. package/src/tool.tsx +73 -30
  159. package/src/transcription.tsx +1 -1
  160. package/src/turn-status.stories.tsx +124 -0
  161. package/src/turn-status.test.tsx +74 -0
  162. package/src/turn-status.tsx +174 -0
  163. package/src/use-audio-level.ts +104 -0
  164. package/src/voice-selector.tsx +5 -5
  165. package/src/web-preview.test.tsx +51 -1
  166. package/src/web-preview.tsx +46 -10
  167. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  168. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  169. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  170. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  171. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  172. package/src/_interactive-terminal-xterm.ts +0 -32
  173. package/src/blocks-ai-composer.stories.tsx +0 -83
  174. package/src/context.stories.tsx +0 -16
  175. package/src/interactive-terminal.stories.tsx +0 -165
  176. package/src/interactive-terminal.test.tsx +0 -448
  177. package/src/interactive-terminal.tsx +0 -444
  178. package/src/model-selector.test.tsx +0 -55
  179. package/src/model-selector.tsx +0 -225
  180. 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 = "Show technical details",
140
+ label,
127
141
  defaultOpen = false,
128
142
  children,
129
143
  ...props
130
- }: ToolDetailsProps) => (
131
- <Collapsible
132
- className={cn("group/tool-details not-prose", className)}
133
- defaultOpen={defaultOpen}
134
- {...props}
135
- >
136
- <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">
137
- <ChevronDownIcon className="size-3.5 transition-transform group-data-[state=open]/tool-details:rotate-180" />
138
- {label}
139
- </CollapsibleTrigger>
140
- <CollapsibleContent className="mt-3 space-y-4">{children}</CollapsibleContent>
141
- </Collapsible>
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
- <div className={cn("space-y-2 overflow-hidden", className)} {...props}>
150
- <h4 className="text-meta uppercase text-muted-foreground">Parameters</h4>
151
- <div className="rounded-md bg-muted/50">
152
- <CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
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
- </div>
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
- if (!(output || errorText || isStreaming)) {
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 && !output && !errorText;
227
+ const pending = isStreaming && !hasOutput && !errorText;
201
228
 
202
- let Output = <div>{output as ReactNode}</div>;
229
+ let Output: ReactNode = null;
203
230
 
204
- if (typeof output === "object" && !isValidElement(output)) {
205
- Output = <CodeBlock code={JSON.stringify(output, null, 2)} language="json" />;
206
- } else if (typeof output === "string") {
207
- Output = <CodeBlock code={output} language="json" />;
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 ? "Error" : "Result"}
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
- showError ? "bg-destructive/10 text-destructive" : "bg-muted/50 text-foreground",
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>}
@@ -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-sm leading-relaxed", className)}
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
+ });