@elabs-ai/components-ai 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +61 -11
  2. package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
  3. package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
  4. package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
  5. package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
  6. package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
  7. package/dist/_persona-rive-JEG44YHX.js.map +1 -0
  8. package/dist/index.d.ts +1109 -202
  9. package/dist/index.js +4867 -2878
  10. package/dist/index.js.map +1 -1
  11. package/package.json +31 -16
  12. package/src/_audio-player-media-chrome.tsx +102 -15
  13. package/src/_flow-boundary.tsx +68 -49
  14. package/src/_lazy-boundary-conformance.ts +38 -0
  15. package/src/_lazy-engine-boundary.tsx +61 -0
  16. package/src/_lazy-mermaid-absent.test.ts +53 -0
  17. package/src/_lazy-mermaid.test.ts +15 -0
  18. package/src/_lazy-mermaid.ts +24 -1
  19. package/src/_mermaid-error-panel.test.tsx +50 -0
  20. package/src/_mermaid-error-panel.tsx +66 -0
  21. package/src/_persona-rive.tsx +62 -10
  22. package/src/_streamdown-i18n.ts +21 -1
  23. package/src/_streamdown-safety.ts +170 -0
  24. package/src/agent-event.stories.tsx +97 -0
  25. package/src/agent-event.test.tsx +145 -0
  26. package/src/agent-event.tsx +187 -0
  27. package/src/agent-timeline.stories.tsx +9 -1
  28. package/src/agent.test.tsx +47 -0
  29. package/src/agent.tsx +28 -14
  30. package/src/agentic-workspace.stories.tsx +27 -20
  31. package/src/artifact.tsx +15 -12
  32. package/src/audio-player.test.tsx +78 -0
  33. package/src/audio-player.tsx +243 -56
  34. package/src/audio-visualizer.stories.tsx +126 -0
  35. package/src/audio-visualizer.test.tsx +438 -0
  36. package/src/audio-visualizer.tsx +367 -0
  37. package/src/canvas.stories.tsx +150 -1
  38. package/src/chat-shell.stories.tsx +18 -3
  39. package/src/chat.stories.tsx +16 -2
  40. package/src/code-block.stories.tsx +9 -1
  41. package/src/composer.stories.tsx +372 -9
  42. package/src/composer.test.tsx +357 -6
  43. package/src/composer.tsx +217 -35
  44. package/src/confirmation.stories.tsx +72 -1
  45. package/src/confirmation.test.tsx +216 -2
  46. package/src/confirmation.tsx +263 -3
  47. package/src/context-panel.stories.tsx +9 -1
  48. package/src/context-panel.tsx +2 -1
  49. package/src/conversation.stories.tsx +63 -2
  50. package/src/conversation.test.tsx +13 -0
  51. package/src/conversation.tsx +20 -2
  52. package/src/diff-view.stories.tsx +196 -0
  53. package/src/diff-view.test.tsx +188 -0
  54. package/src/diff-view.tsx +642 -0
  55. package/src/gallery.tsx +4 -2
  56. package/src/index.ts +14 -4
  57. package/src/jsx-preview.stories.tsx +2 -2
  58. package/src/markdown-view.stories.tsx +92 -1
  59. package/src/markdown-view.test.tsx +232 -1
  60. package/src/markdown-view.tsx +150 -6
  61. package/src/message-compare.stories.tsx +175 -0
  62. package/src/message-compare.test.tsx +207 -0
  63. package/src/message-compare.tsx +453 -0
  64. package/src/message-form.stories.tsx +29 -1
  65. package/src/message.stories.tsx +9 -1
  66. package/src/message.test.tsx +176 -0
  67. package/src/message.tsx +90 -4
  68. package/src/microcopy.test.tsx +40 -0
  69. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  70. package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
  71. package/src/model-provider-logo.tsx +149 -0
  72. package/src/permission-mode-select.stories.tsx +82 -0
  73. package/src/permission-mode-select.test.tsx +100 -0
  74. package/src/permission-mode-select.tsx +137 -0
  75. package/src/persona-missing-peer.test.tsx +54 -0
  76. package/src/persona.tsx +68 -22
  77. package/src/plan.stories.tsx +166 -0
  78. package/src/plan.test.tsx +267 -0
  79. package/src/plan.tsx +182 -20
  80. package/src/prompt-input-effort.stories.tsx +123 -0
  81. package/src/prompt-input-effort.test.tsx +83 -0
  82. package/src/prompt-input-effort.tsx +136 -0
  83. package/src/prompt-input-mode.stories.tsx +108 -0
  84. package/src/prompt-input-mode.test.tsx +99 -0
  85. package/src/prompt-input-mode.tsx +169 -0
  86. package/src/prompt-input-slash.stories.tsx +211 -0
  87. package/src/prompt-input-slash.test.tsx +262 -0
  88. package/src/prompt-input-slash.tsx +541 -0
  89. package/src/prompt-input.stories.tsx +2 -2
  90. package/src/reasoning.tsx +27 -13
  91. package/src/sandbox.stories.tsx +9 -1
  92. package/src/schema-display.tsx +5 -2
  93. package/src/selection-toolbar.stories.tsx +9 -1
  94. package/src/session-header.stories.tsx +128 -0
  95. package/src/session-header.test.tsx +138 -0
  96. package/src/session-header.tsx +243 -0
  97. package/src/session-status-bar.stories.tsx +73 -0
  98. package/src/session-status-bar.test.tsx +94 -0
  99. package/src/session-status-bar.tsx +165 -0
  100. package/src/snippet.stories.tsx +9 -1
  101. package/src/stack-trace.tsx +6 -2
  102. package/src/streamdown-i18n.test.tsx +1 -1
  103. package/src/task.stories.tsx +13 -3
  104. package/src/templates-ai-assistant.stories.tsx +21 -1
  105. package/src/token-usage.stories.tsx +24 -0
  106. package/src/token-usage.test.tsx +92 -0
  107. package/src/{context.tsx → token-usage.tsx} +67 -53
  108. package/src/tool-result-card.stories.tsx +9 -1
  109. package/src/tool.stories.tsx +13 -3
  110. package/src/tool.tsx +15 -8
  111. package/src/turn-status.stories.tsx +124 -0
  112. package/src/turn-status.test.tsx +74 -0
  113. package/src/turn-status.tsx +174 -0
  114. package/src/use-audio-level.ts +104 -0
  115. package/src/web-preview.tsx +8 -3
  116. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  117. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  118. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  119. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  120. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  121. package/src/_interactive-terminal-xterm.ts +0 -32
  122. package/src/blocks-ai-composer.stories.tsx +0 -83
  123. package/src/context.stories.tsx +0 -16
  124. package/src/interactive-terminal.stories.tsx +0 -165
  125. package/src/interactive-terminal.test.tsx +0 -448
  126. package/src/interactive-terminal.tsx +0 -444
  127. package/src/model-selector.tsx +0 -225
  128. package/src/terminal.tsx +0 -244
@@ -18,50 +18,56 @@ const ICON_STROKE_WIDTH = 2;
18
18
 
19
19
  type ModelId = string;
20
20
 
21
- interface ContextSchema {
21
+ interface TokenUsageSchema {
22
22
  usedTokens: number;
23
23
  maxTokens: number;
24
24
  usage?: LanguageModelUsage;
25
25
  modelId?: ModelId;
26
26
  }
27
27
 
28
- const ContextContext = createContext<ContextSchema | null>(null);
28
+ const TokenUsageContext = createContext<TokenUsageSchema | null>(null);
29
29
 
30
- const useContextValue = () => {
31
- const context = useContext(ContextContext);
30
+ const useTokenUsageValue = () => {
31
+ const context = useContext(TokenUsageContext);
32
32
 
33
33
  if (!context) {
34
- throw new Error("Context components must be used within Context");
34
+ throw new Error("TokenUsage components must be used within TokenUsage");
35
35
  }
36
36
 
37
37
  return context;
38
38
  };
39
39
 
40
- export type ContextProps = ComponentProps<typeof HoverCard> & ContextSchema;
40
+ export type TokenUsageProps = ComponentProps<typeof HoverCard> & TokenUsageSchema;
41
41
 
42
- export const Context = ({ usedTokens, maxTokens, usage, modelId, ...props }: ContextProps) => {
42
+ export const TokenUsage = ({
43
+ usedTokens,
44
+ maxTokens,
45
+ usage,
46
+ modelId,
47
+ ...props
48
+ }: TokenUsageProps) => {
43
49
  const contextValue = useMemo(
44
50
  () => ({ maxTokens, modelId, usage, usedTokens }),
45
51
  [maxTokens, modelId, usage, usedTokens],
46
52
  );
47
53
 
48
54
  return (
49
- <ContextContext.Provider value={contextValue}>
55
+ <TokenUsageContext.Provider value={contextValue}>
50
56
  <HoverCard closeDelay={0} openDelay={0} {...props} />
51
- </ContextContext.Provider>
57
+ </TokenUsageContext.Provider>
52
58
  );
53
59
  };
54
60
 
55
- const ContextIcon = () => {
61
+ const TokenUsageIcon = () => {
56
62
  const { t } = useLocale();
57
- const { usedTokens, maxTokens } = useContextValue();
63
+ const { usedTokens, maxTokens } = useTokenUsageValue();
58
64
  const circumference = 2 * Math.PI * ICON_RADIUS;
59
65
  const usedPercent = usedTokens / maxTokens;
60
66
  const dashOffset = circumference * (1 - usedPercent);
61
67
 
62
68
  return (
63
69
  <svg
64
- aria-label={t("ai.context.usage")}
70
+ aria-label={t("ai.tokenUsage.usage")}
65
71
  height="20"
66
72
  role="img"
67
73
  style={{ color: "currentcolor" }}
@@ -94,11 +100,11 @@ const ContextIcon = () => {
94
100
  );
95
101
  };
96
102
 
97
- export type ContextTriggerProps = ComponentProps<typeof Button>;
103
+ export type TokenUsageTriggerProps = ComponentProps<typeof Button>;
98
104
 
99
- export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
105
+ export const TokenUsageTrigger = ({ children, ...props }: TokenUsageTriggerProps) => {
100
106
  const { formatNumber } = useLocale();
101
- const { usedTokens, maxTokens } = useContextValue();
107
+ const { usedTokens, maxTokens } = useTokenUsageValue();
102
108
  const usedPercent = usedTokens / maxTokens;
103
109
  const renderedPercent = formatNumber(usedPercent, {
104
110
  maximumFractionDigits: 1,
@@ -110,28 +116,28 @@ export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
110
116
  {children ?? (
111
117
  <Button type="button" variant="ghost" {...props}>
112
118
  <span className="font-medium text-muted-foreground">{renderedPercent}</span>
113
- <ContextIcon />
119
+ <TokenUsageIcon />
114
120
  </Button>
115
121
  )}
116
122
  </HoverCardTrigger>
117
123
  );
118
124
  };
119
125
 
120
- export type ContextContentProps = ComponentProps<typeof HoverCardContent>;
126
+ export type TokenUsageContentProps = ComponentProps<typeof HoverCardContent>;
121
127
 
122
- export const ContextContent = ({ className, ...props }: ContextContentProps) => (
128
+ export const TokenUsageContent = ({ className, ...props }: TokenUsageContentProps) => (
123
129
  <HoverCardContent className={cn("min-w-60 divide-y overflow-hidden p-0", className)} {...props} />
124
130
  );
125
131
 
126
- export type ContextContentHeaderProps = ComponentProps<"div">;
132
+ export type TokenUsageContentHeaderProps = ComponentProps<"div">;
127
133
 
128
- export const ContextContentHeader = ({
134
+ export const TokenUsageContentHeader = ({
129
135
  children,
130
136
  className,
131
137
  ...props
132
- }: ContextContentHeaderProps) => {
138
+ }: TokenUsageContentHeaderProps) => {
133
139
  const { formatNumber } = useLocale();
134
- const { usedTokens, maxTokens } = useContextValue();
140
+ const { usedTokens, maxTokens } = useTokenUsageValue();
135
141
  const usedPercent = usedTokens / maxTokens;
136
142
  const displayPct = formatNumber(usedPercent, {
137
143
  maximumFractionDigits: 1,
@@ -163,23 +169,27 @@ export const ContextContentHeader = ({
163
169
  );
164
170
  };
165
171
 
166
- export type ContextContentBodyProps = ComponentProps<"div">;
172
+ export type TokenUsageContentBodyProps = ComponentProps<"div">;
167
173
 
168
- export const ContextContentBody = ({ children, className, ...props }: ContextContentBodyProps) => (
174
+ export const TokenUsageContentBody = ({
175
+ children,
176
+ className,
177
+ ...props
178
+ }: TokenUsageContentBodyProps) => (
169
179
  <div className={cn("w-full p-3", className)} {...props}>
170
180
  {children}
171
181
  </div>
172
182
  );
173
183
 
174
- export type ContextContentFooterProps = ComponentProps<"div">;
184
+ export type TokenUsageContentFooterProps = ComponentProps<"div">;
175
185
 
176
- export const ContextContentFooter = ({
186
+ export const TokenUsageContentFooter = ({
177
187
  children,
178
188
  className,
179
189
  ...props
180
- }: ContextContentFooterProps) => {
181
- const { formatNumber } = useLocale();
182
- const { modelId, usage } = useContextValue();
190
+ }: TokenUsageContentFooterProps) => {
191
+ const { formatNumber, t } = useLocale();
192
+ const { modelId, usage } = useTokenUsageValue();
183
193
  const costUSD = modelId
184
194
  ? getUsage({
185
195
  modelId,
@@ -204,7 +214,7 @@ export const ContextContentFooter = ({
204
214
  >
205
215
  {children ?? (
206
216
  <>
207
- <span className="text-muted-foreground">Total cost</span>
217
+ <span className="text-muted-foreground">{t("ai.tokenUsage.totalCost")}</span>
208
218
  <span>{totalCost}</span>
209
219
  </>
210
220
  )}
@@ -222,11 +232,11 @@ const TokensWithCost = ({ tokens, costText }: { tokens?: number; costText?: stri
222
232
  );
223
233
  };
224
234
 
225
- export type ContextInputUsageProps = ComponentProps<"div">;
235
+ export type TokenUsageInputProps = ComponentProps<"div">;
226
236
 
227
- export const ContextInputUsage = ({ className, children, ...props }: ContextInputUsageProps) => {
228
- const { formatNumber } = useLocale();
229
- const { usage, modelId } = useContextValue();
237
+ export const TokenUsageInput = ({ className, children, ...props }: TokenUsageInputProps) => {
238
+ const { formatNumber, t } = useLocale();
239
+ const { usage, modelId } = useTokenUsageValue();
230
240
  const inputTokens = usage?.inputTokens ?? 0;
231
241
 
232
242
  if (children) {
@@ -250,17 +260,17 @@ export const ContextInputUsage = ({ className, children, ...props }: ContextInpu
250
260
 
251
261
  return (
252
262
  <div className={cn("flex items-center justify-between text-xs", className)} {...props}>
253
- <span className="text-muted-foreground">Input</span>
263
+ <span className="text-muted-foreground">{t("ai.tokenUsage.input")}</span>
254
264
  <TokensWithCost costText={inputCostText} tokens={inputTokens} />
255
265
  </div>
256
266
  );
257
267
  };
258
268
 
259
- export type ContextOutputUsageProps = ComponentProps<"div">;
269
+ export type TokenUsageOutputProps = ComponentProps<"div">;
260
270
 
261
- export const ContextOutputUsage = ({ className, children, ...props }: ContextOutputUsageProps) => {
262
- const { formatNumber } = useLocale();
263
- const { usage, modelId } = useContextValue();
271
+ export const TokenUsageOutput = ({ className, children, ...props }: TokenUsageOutputProps) => {
272
+ const { formatNumber, t } = useLocale();
273
+ const { usage, modelId } = useTokenUsageValue();
264
274
  const outputTokens = usage?.outputTokens ?? 0;
265
275
 
266
276
  if (children) {
@@ -284,22 +294,24 @@ export const ContextOutputUsage = ({ className, children, ...props }: ContextOut
284
294
 
285
295
  return (
286
296
  <div className={cn("flex items-center justify-between text-xs", className)} {...props}>
287
- <span className="text-muted-foreground">Output</span>
297
+ <span className="text-muted-foreground">{t("ai.tokenUsage.output")}</span>
288
298
  <TokensWithCost costText={outputCostText} tokens={outputTokens} />
289
299
  </div>
290
300
  );
291
301
  };
292
302
 
293
- export type ContextReasoningUsageProps = ComponentProps<"div">;
303
+ export type TokenUsageReasoningProps = ComponentProps<"div">;
294
304
 
295
- export const ContextReasoningUsage = ({
305
+ export const TokenUsageReasoning = ({
296
306
  className,
297
307
  children,
298
308
  ...props
299
- }: ContextReasoningUsageProps) => {
300
- const { formatNumber } = useLocale();
301
- const { usage, modelId } = useContextValue();
302
- const reasoningTokens = usage?.reasoningTokens ?? 0;
309
+ }: TokenUsageReasoningProps) => {
310
+ const { formatNumber, t } = useLocale();
311
+ const { usage, modelId } = useTokenUsageValue();
312
+ // `reasoningTokens` moved under `outputTokenDetails` in ai@7 (the flat,
313
+ // deprecated top-level alias from ai@6 was removed).
314
+ const reasoningTokens = usage?.outputTokenDetails?.reasoningTokens ?? 0;
303
315
 
304
316
  if (children) {
305
317
  return children;
@@ -322,18 +334,20 @@ export const ContextReasoningUsage = ({
322
334
 
323
335
  return (
324
336
  <div className={cn("flex items-center justify-between text-xs", className)} {...props}>
325
- <span className="text-muted-foreground">Reasoning</span>
337
+ <span className="text-muted-foreground">{t("ai.tokenUsage.reasoning")}</span>
326
338
  <TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} />
327
339
  </div>
328
340
  );
329
341
  };
330
342
 
331
- export type ContextCacheUsageProps = ComponentProps<"div">;
343
+ export type TokenUsageCacheProps = ComponentProps<"div">;
332
344
 
333
- export const ContextCacheUsage = ({ className, children, ...props }: ContextCacheUsageProps) => {
334
- const { formatNumber } = useLocale();
335
- const { usage, modelId } = useContextValue();
336
- const cacheTokens = usage?.cachedInputTokens ?? 0;
345
+ export const TokenUsageCache = ({ className, children, ...props }: TokenUsageCacheProps) => {
346
+ const { formatNumber, t } = useLocale();
347
+ const { usage, modelId } = useTokenUsageValue();
348
+ // `cachedInputTokens` moved under `inputTokenDetails.cacheReadTokens` in
349
+ // ai@7 (the flat, deprecated top-level alias from ai@6 was removed).
350
+ const cacheTokens = usage?.inputTokenDetails?.cacheReadTokens ?? 0;
337
351
 
338
352
  if (children) {
339
353
  return children;
@@ -356,7 +370,7 @@ export const ContextCacheUsage = ({ className, children, ...props }: ContextCach
356
370
 
357
371
  return (
358
372
  <div className={cn("flex items-center justify-between text-xs", className)} {...props}>
359
- <span className="text-muted-foreground">Cache</span>
373
+ <span className="text-muted-foreground">{t("ai.tokenUsage.cache")}</span>
360
374
  <TokensWithCost costText={cacheCostText} tokens={cacheTokens} />
361
375
  </div>
362
376
  );
@@ -9,7 +9,15 @@ import { ToolResultCard } from "./tool-result-card";
9
9
  const meta = {
10
10
  title: "AI/ToolResultCard",
11
11
  component: ToolResultCard,
12
- parameters: { layout: "padded" },
12
+ parameters: {
13
+ layout: "padded",
14
+ docs: {
15
+ description: {
16
+ component:
17
+ "The CHAT produced-artifact card; a console transcript renders the same moment through `Terminal/TerminalToolCall` — see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). A separate component from `AI/Tool` by design: `Tool` is the step that RAN (JSON behind disclosure), `ToolResultCard` hosts what it produced — a chart, a table, a file preview — as `children`, on the elevation channel (raised fill, no border).",
18
+ },
19
+ },
20
+ },
13
21
  tags: ["autodocs"],
14
22
  } satisfies Meta<typeof ToolResultCard>;
15
23
  export default meta;
@@ -8,9 +8,19 @@ import {
8
8
  ToolOutput,
9
9
  statusFromToolState,
10
10
  } from "./tool";
11
- const meta = { title: "AI/Tool", component: Tool, parameters: { layout: "padded" } } satisfies Meta<
12
- typeof Tool
13
- >;
11
+ const meta = {
12
+ title: "AI/Tool",
13
+ component: Tool,
14
+ parameters: {
15
+ layout: "padded",
16
+ docs: {
17
+ description: {
18
+ component:
19
+ "The CHAT tool-call row; the console skin is `Terminal/TerminalToolCall`, and both speak the same success / error / pending vocabulary — see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). `Tool` owns the rail-and-inspect idiom: a header carrying the derived tool name, a `StatusBadge` and a business summary, with the raw payload behind a default-collapsed `ToolDetails`. The artifact a call PRODUCED is `AI/ToolResultCard` instead.",
20
+ },
21
+ },
22
+ },
23
+ } satisfies Meta<typeof Tool>;
14
24
  export default meta;
15
25
  type Story = StoryObj<typeof meta>;
16
26
  // JSON-behind-disclosure is the package default (#192, research 10 §B.5): the
package/src/tool.tsx CHANGED
@@ -145,14 +145,19 @@ export type ToolInputProps = ComponentProps<"div"> & {
145
145
  input: ToolPart["input"];
146
146
  };
147
147
 
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" />
148
+ export const ToolInput = ({ className, input, ...props }: ToolInputProps) => {
149
+ const { t } = useLocale();
150
+ return (
151
+ <div className={cn("space-y-2 overflow-hidden", className)} {...props}>
152
+ <h4 className="text-meta uppercase text-muted-foreground">
153
+ {t("ai.schemaDisplay.parameters")}
154
+ </h4>
155
+ <div className="rounded-md bg-muted/50">
156
+ <CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
157
+ </div>
153
158
  </div>
154
- </div>
155
- );
159
+ );
160
+ };
156
161
 
157
162
  export type ToolOutputProps = ComponentProps<"div"> & {
158
163
  /**
@@ -223,7 +228,9 @@ export const ToolOutput = ({
223
228
  <div
224
229
  className={cn(
225
230
  "overflow-x-auto rounded-md text-caption [&_table]:w-full",
226
- showError ? "bg-destructive/10 text-destructive" : "bg-muted/50 text-foreground",
231
+ // #124: this colours the ambient text of the error/result body —
232
+ // running text, so the error branch takes the ink rung.
233
+ showError ? "bg-destructive/10 text-destructive-text" : "bg-muted/50 text-foreground",
227
234
  )}
228
235
  >
229
236
  {showError && <div>{errorText}</div>}
@@ -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
+ });