@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
@@ -0,0 +1,94 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { SessionStatusBar } from "./session-status-bar";
4
+
5
+ describe("SessionStatusBar", () => {
6
+ it("renders nothing at all when every segment is empty", () => {
7
+ const { container } = render(<SessionStatusBar />);
8
+ expect(container).toBeEmptyDOMElement();
9
+ });
10
+
11
+ it("renders only the segments whose prop is supplied", () => {
12
+ render(<SessionStatusBar workspace="brand-ui" />);
13
+
14
+ expect(screen.getByText("brand-ui")).toBeInTheDocument();
15
+ expect(screen.queryByText(/main|feature\//)).not.toBeInTheDocument();
16
+ });
17
+
18
+ it("renders workspace, branch and model together when all are supplied", () => {
19
+ render(<SessionStatusBar branch="main" model="Claude Opus 4" workspace="brand-ui" />);
20
+
21
+ expect(screen.getByText("brand-ui")).toBeInTheDocument();
22
+ expect(screen.getByText("main")).toBeInTheDocument();
23
+ expect(screen.getByText("Claude Opus 4")).toBeInTheDocument();
24
+ });
25
+
26
+ it("docks children (e.g. Context) rather than reimplementing usage maths", () => {
27
+ render(
28
+ <SessionStatusBar workspace="brand-ui">
29
+ <span>Context slot</span>
30
+ </SessionStatusBar>,
31
+ );
32
+
33
+ expect(screen.getByText("Context slot")).toBeInTheDocument();
34
+ });
35
+
36
+ it("renders a connection count and a distinct connecting state", () => {
37
+ const { rerender } = render(
38
+ <SessionStatusBar connections={{ connected: 2, total: 4 }} workspace="brand-ui" />,
39
+ );
40
+ expect(screen.getByText("2/4")).toBeInTheDocument();
41
+
42
+ rerender(
43
+ <SessionStatusBar
44
+ connections={{ connected: 0, total: 4, connecting: true }}
45
+ workspace="brand-ui"
46
+ />,
47
+ );
48
+ expect(screen.getByText("0/4")).toBeInTheDocument();
49
+ });
50
+
51
+ // #155: the connections live region's only text content used to be
52
+ // aria-hidden, so a screen reader heard an empty string on every update.
53
+ // `screen.getByText` alone cannot catch this (it matches text regardless of
54
+ // aria-hidden) — this asserts what the region ACTUALLY announces: its text
55
+ // content with every aria-hidden subtree stripped out first.
56
+ const announceableText = (el: Element): string => {
57
+ const clone = el.cloneNode(true) as HTMLElement;
58
+ for (const hidden of Array.from(clone.querySelectorAll('[aria-hidden="true"]'))) {
59
+ hidden.remove();
60
+ }
61
+ return clone.textContent?.trim() ?? "";
62
+ };
63
+
64
+ it("announces the connection state via real text content, not just an aria-label (#155)", () => {
65
+ const { container, rerender } = render(
66
+ <SessionStatusBar connections={{ connected: 0, total: 4, connecting: true }} />,
67
+ );
68
+ const connecting = container.querySelector(
69
+ '[data-slot="session-status-bar-connections"]',
70
+ ) as HTMLElement;
71
+ expect(connecting).toHaveAttribute("role", "status");
72
+ expect(connecting).toHaveAttribute("aria-live", "polite");
73
+ // No separate `aria-label`: an explicit one would duplicate the
74
+ // announced content as the accessible name too, and some ATs speak a
75
+ // live region's name alongside its changed content ("Connecting…
76
+ // Connecting…"). Leaving it off does NOT relocate the name to the
77
+ // (sr-only) text — `role="status"` is `nameFrom: author`, so with no
78
+ // `aria-label`/`aria-labelledby` the region simply has no accessible
79
+ // name. That is fine: no acceptance criterion needs one here, only the
80
+ // announced content (asserted below) does.
81
+ expect(connecting).not.toHaveAttribute("aria-label");
82
+ // Before the fix this was "" — the only text node was aria-hidden.
83
+ expect(announceableText(connecting)).toBe("Connecting…");
84
+
85
+ rerender(<SessionStatusBar connections={{ connected: 2, total: 4 }} />);
86
+ const connected = container.querySelector(
87
+ '[data-slot="session-status-bar-connections"]',
88
+ ) as HTMLElement;
89
+ expect(announceableText(connected)).toBe("2 of 4 connections");
90
+ // The visible, aria-hidden counter is unchanged — the fix adds an
91
+ // announcement, it does not alter the rendered appearance (#155 AC).
92
+ expect(screen.getByText("2/4")).toHaveAttribute("aria-hidden", "true");
93
+ });
94
+ });
@@ -0,0 +1,165 @@
1
+ "use client";
2
+
3
+ import { cn, useLocale } from "@elabs-ai/components-ui";
4
+ import { Cpu, Folder, GitBranch, Loader2Icon, PlugZapIcon } from "lucide-react";
5
+ import type { ComponentProps, ComponentType, ReactNode, SVGProps } from "react";
6
+ import { forwardRef } from "react";
7
+
8
+ export interface SessionStatusBarConnections {
9
+ /** Number of integrations/MCP servers currently connected. */
10
+ connected: number;
11
+ /** Total number of configured integrations/MCP servers. */
12
+ total: number;
13
+ /** Renders a spinner in place of the connection icon while establishing. */
14
+ connecting?: boolean;
15
+ }
16
+
17
+ export interface SessionStatusBarProps extends ComponentProps<"div"> {
18
+ /** Current workspace/project name. */
19
+ workspace?: string;
20
+ /** Current git branch. */
21
+ branch?: string;
22
+ /** Active model id/label. */
23
+ model?: string;
24
+ /** Integration/MCP connection progress; renders a spinner while connecting. */
25
+ connections?: SessionStatusBarConnections;
26
+ /**
27
+ * Docks another component alongside the ambient segments — most often
28
+ * `<TokenUsage />` (`@elabs-ai/components-ai`), so token usage/cost keeps
29
+ * its one owner (`token-usage.tsx`, tokenlens) instead of being re-implemented
30
+ * here. Rendered flush to the end of the bar.
31
+ */
32
+ children?: ReactNode;
33
+ }
34
+
35
+ type SegmentIcon = ComponentType<SVGProps<SVGSVGElement>>;
36
+
37
+ const SessionStatusBarSegment = ({
38
+ icon: Icon,
39
+ slot,
40
+ value,
41
+ }: {
42
+ icon: SegmentIcon;
43
+ slot: string;
44
+ value: string;
45
+ }) => (
46
+ <span className="inline-flex min-w-0 items-center gap-1.5" data-slot={slot}>
47
+ <Icon aria-hidden="true" className="size-3.5 shrink-0" />
48
+ <span className="truncate">{value}</span>
49
+ </span>
50
+ );
51
+
52
+ /**
53
+ * SessionStatusBar — the ambient session row: workspace, branch, model, and
54
+ * integration-connection progress (#105). Every segment is independently
55
+ * optional and renders only when its prop is supplied; with nothing supplied
56
+ * the bar renders nothing at all, not an empty shell.
57
+ *
58
+ * Docks `Context` (or any other node) via `children` rather than
59
+ * re-implementing token-usage/cost maths — `context.tsx` stays the one owner
60
+ * of that calculation (tokenlens).
61
+ */
62
+ export const SessionStatusBar = forwardRef<HTMLDivElement, SessionStatusBarProps>(
63
+ function SessionStatusBar(
64
+ { workspace, branch, model, connections, children, className, ...props },
65
+ ref,
66
+ ) {
67
+ const { formatNumber, t } = useLocale();
68
+ const hasContent = Boolean(workspace ?? branch ?? model ?? connections ?? children);
69
+
70
+ if (!hasContent) {
71
+ return null;
72
+ }
73
+
74
+ // Computed once and reused for BOTH the connections region's name (for a
75
+ // user who navigates to it deliberately) and its real, non-hidden text
76
+ // content — what a polite announcement actually reads on change. An
77
+ // aria-label alone is NOT that (#155): a live region announces its
78
+ // content, not its own accessible name.
79
+ const connectionsAnnouncement = connections
80
+ ? connections.connecting
81
+ ? t("ai.sessionStatusBar.connecting")
82
+ : t("ai.sessionStatusBar.connections", {
83
+ connected: connections.connected,
84
+ total: connections.total,
85
+ })
86
+ : undefined;
87
+
88
+ return (
89
+ <div
90
+ ref={ref}
91
+ className={cn(
92
+ "flex w-full items-center gap-4 border-t bg-surface-muted px-3 py-1.5 text-meta text-muted-foreground",
93
+ className,
94
+ )}
95
+ data-slot="session-status-bar"
96
+ {...props}
97
+ >
98
+ {workspace ? (
99
+ <SessionStatusBarSegment
100
+ icon={Folder}
101
+ slot="session-status-bar-workspace"
102
+ value={workspace}
103
+ />
104
+ ) : null}
105
+ {branch ? (
106
+ <SessionStatusBarSegment
107
+ icon={GitBranch}
108
+ slot="session-status-bar-branch"
109
+ value={branch}
110
+ />
111
+ ) : null}
112
+ {model ? (
113
+ <SessionStatusBarSegment icon={Cpu} slot="session-status-bar-model" value={model} />
114
+ ) : null}
115
+ {connections ? (
116
+ <span
117
+ aria-live="polite"
118
+ className="inline-flex shrink-0 items-center gap-1.5"
119
+ data-slot="session-status-bar-connections"
120
+ role="status"
121
+ >
122
+ {connections.connecting ? (
123
+ <Loader2Icon
124
+ aria-hidden="true"
125
+ className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none"
126
+ />
127
+ ) : (
128
+ <PlugZapIcon aria-hidden="true" className="size-3.5 shrink-0" />
129
+ )}
130
+ <span aria-hidden="true" className="tabular-nums">
131
+ {formatNumber(connections.connected)}/{formatNumber(connections.total)}
132
+ </span>
133
+ {/* The live region's only NON-hidden text — mirrors
134
+ terminal-status-bar.tsx's aria-hidden-visual +
135
+ sr-only-sentence pair (#155). Without this, every polite
136
+ update announced an empty string: the counter above is
137
+ aria-hidden, so it was the region's ONLY text content.
138
+ Deliberately no `aria-label` here (unlike
139
+ terminal-status-bar.tsx, where the label lives on the OUTER
140
+ bar as a generic region name distinct from any segment's
141
+ content): an explicit `aria-label` here would give this
142
+ span an ACCESSIBLE NAME byte-identical to its announced
143
+ CONTENT, which risks a double announcement ("Connecting…
144
+ Connecting…") on ATs that speak a live region's name
145
+ alongside its changed content. Leaving `aria-label` off
146
+ does NOT relocate the name to this text — `role="status"`
147
+ is `nameFrom: author`, so it never falls back to content
148
+ the way e.g. a `button` does. The span simply has no
149
+ accessible name, which is fine: no acceptance criterion
150
+ needs one here, only the announced content does. */}
151
+ <span className="sr-only">{connectionsAnnouncement}</span>
152
+ </span>
153
+ ) : null}
154
+ {children ? (
155
+ <div
156
+ className="ms-auto flex shrink-0 items-center"
157
+ data-slot="session-status-bar-context"
158
+ >
159
+ {children}
160
+ </div>
161
+ ) : null}
162
+ </div>
163
+ );
164
+ },
165
+ );
@@ -3,7 +3,15 @@ import { Snippet, SnippetAddon, SnippetCopyButton, SnippetInput } from "./snippe
3
3
  const meta = {
4
4
  title: "AI/Snippet",
5
5
  component: Snippet,
6
- parameters: { layout: "padded" },
6
+ parameters: {
7
+ layout: "padded",
8
+ docs: {
9
+ description: {
10
+ component:
11
+ "A single-line copyable COMMAND — an `InputGroup` in a mono face wrapping a read-only value and a copy button (`npm i …`, a curl line, an id). Multi-line highlighted source with a language picker is `AI/CodeBlock`; see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). Nothing here highlights: `Snippet` never loads Shiki.",
12
+ },
13
+ },
14
+ },
7
15
  } satisfies Meta<typeof Snippet>;
8
16
  export default meta;
9
17
  type Story = StoryObj<typeof meta>;
@@ -3,6 +3,7 @@
3
3
  import { useControllableState } from "@radix-ui/react-use-controllable-state";
4
4
  import { Button } from "@elabs-ai/components-ui";
5
5
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@elabs-ai/components-ui";
6
+ import { useLocale } from "@elabs-ai/components-ui";
6
7
  import { cn } from "@elabs-ai/components-ui/lib/cn";
7
8
  import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, CopyIcon } from "lucide-react";
8
9
  import type { ComponentProps } from "react";
@@ -236,7 +237,9 @@ export const StackTraceErrorType = memo(
236
237
  const { trace } = useStackTrace();
237
238
 
238
239
  return (
239
- <span className={cn("shrink-0 font-semibold text-destructive", className)} {...props}>
240
+ // #124: the error type NAME is running text (e.g. "TypeError"), not a
241
+ // mark — ink rung, unlike the AlertTriangleIcon mark above.
242
+ <span className={cn("shrink-0 font-semibold text-destructive-text", className)} {...props}>
240
243
  {children ?? trace.errorType}
241
244
  </span>
242
245
  );
@@ -426,6 +429,7 @@ FilePathButton.displayName = "FilePathButton";
426
429
  export const StackTraceFrames = memo(
427
430
  ({ className, showInternalFrames = true, ...props }: StackTraceFramesProps) => {
428
431
  const { trace, onFilePathClick } = useStackTrace();
432
+ const { t } = useLocale();
429
433
 
430
434
  const framesToShow = showInternalFrames
431
435
  ? trace.frames
@@ -460,7 +464,7 @@ export const StackTraceFrames = memo(
460
464
  </div>
461
465
  ))}
462
466
  {framesToShow.length === 0 && (
463
- <div className="text-muted-foreground text-xs">No stack frames</div>
467
+ <div className="text-muted-foreground text-xs">{t("ai.stackTrace.empty")}</div>
464
468
  )}
465
469
  </div>
466
470
  );
@@ -129,7 +129,7 @@ describe("Streamdown code-block theme (#315 follow-up)", () => {
129
129
  // (github-light's keyword is #d73a49, github-dark's is #ff7b72).
130
130
  expect(span.getAttribute("style")).toContain("--sdm-c: #133796");
131
131
  // Both dual-theme slots are pinned to the SAME active-theme color (#315
132
- // follow-up design: brand-ui has three themes, not the two Shiki's
132
+ // follow-up design: brand-ui has more themes than the two Shiki's
133
133
  // light/dark mechanism expects), so `.dark` never resolves a different palette.
134
134
  expect(span.getAttribute("style")).toContain("--shiki-dark: #133796");
135
135
  });
@@ -1,8 +1,18 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { Task, TaskContent, TaskItem, TaskItemFile, TaskTrigger } from "./task";
3
- const meta = { title: "AI/Task", component: Task, parameters: { layout: "padded" } } satisfies Meta<
4
- typeof Task
5
- >;
3
+ const meta = {
4
+ title: "AI/Task",
5
+ component: Task,
6
+ parameters: {
7
+ layout: "padded",
8
+ docs: {
9
+ description: {
10
+ component:
11
+ "The CHAT run summary — what got done, collapsed by default so a settled transcript keeps only its focal surfaces open. The console counterpart of an agent checklist is `Terminal/TerminalTodoList`; see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). The body rides the canonical `AI/AgentTimeline` rail rather than a hand-rolled one, and what the agent intends to do BEFORE it runs is `AI/Plan`.",
12
+ },
13
+ },
14
+ },
15
+ } satisfies Meta<typeof Task>;
6
16
  export default meta;
7
17
  type Story = StoryObj<typeof meta>;
8
18
  // Collapsed by default since #192; `defaultOpen` here only so the docs render
@@ -9,6 +9,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
9
9
  import { useState } from "react";
10
10
  import { AppIcon } from "@elabs-ai/components-icons";
11
11
  import {
12
+ Button,
12
13
  Sidebar,
13
14
  SidebarContent,
14
15
  SidebarGroup,
@@ -31,7 +32,7 @@ import {
31
32
  MessageContent,
32
33
  MessageResponse,
33
34
  } from "./index";
34
- import { Bot, History, Home, Settings } from "lucide-react";
35
+ import { Bot, History, Home, MessageSquare, Settings } from "lucide-react";
35
36
 
36
37
  interface ChatMessage {
37
38
  id: string;
@@ -101,8 +102,27 @@ function AiAssistantTemplate() {
101
102
  <ConversationContent>
102
103
  {messages.length === 0 ? (
103
104
  <ConversationEmptyState
105
+ icon={<MessageSquare className="size-8" aria-hidden="true" />}
104
106
  title="Start the conversation"
105
107
  description="Ask anything to begin."
108
+ actions={
109
+ <>
110
+ <Button
111
+ onClick={() => send("What can you help me with?")}
112
+ size="sm"
113
+ variant="outline"
114
+ >
115
+ What can you help me with?
116
+ </Button>
117
+ <Button
118
+ onClick={() => send("Summarize my recent activity")}
119
+ size="sm"
120
+ variant="outline"
121
+ >
122
+ Summarize my recent activity
123
+ </Button>
124
+ </>
125
+ }
106
126
  />
107
127
  ) : (
108
128
  messages.map((m) => (
@@ -0,0 +1,24 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { TokenUsage, TokenUsageTrigger } from "./token-usage";
3
+ const meta = {
4
+ title: "AI/TokenUsage",
5
+ component: TokenUsage,
6
+ parameters: {
7
+ layout: "padded",
8
+ docs: {
9
+ description: {
10
+ component:
11
+ "The context-WINDOW usage ring: a compact `usedTokens` / `maxTokens` dial that opens a hover card with the input/output token split and, when `modelId` is known, an estimated cost. It was called `Context` until it was renamed for exactly this reason — the chat workspace’s right rail is `AI/ContextPanel`, a different component with a confusingly similar old name. See [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs).",
12
+ },
13
+ },
14
+ },
15
+ } satisfies Meta<typeof TokenUsage>;
16
+ export default meta;
17
+ type Story = StoryObj<typeof meta>;
18
+ export const Default: Story = {
19
+ render: () => (
20
+ <TokenUsage usedTokens={2400} maxTokens={8000}>
21
+ <TokenUsageTrigger />
22
+ </TokenUsage>
23
+ ),
24
+ };
@@ -0,0 +1,92 @@
1
+ import type { LanguageModelUsage } from "ai";
2
+ import { DEFAULT_MESSAGES, LocaleProvider } from "@elabs-ai/components-ui";
3
+ import { render, screen } from "@testing-library/react";
4
+ import { describe, expect, it } from "vitest";
5
+ import { TokenUsage, TokenUsageCache, TokenUsageReasoning } from "./token-usage";
6
+
7
+ // `LanguageModelUsage`'s reasoning/cache figures live under nested detail
8
+ // objects (`outputTokenDetails.reasoningTokens`, `inputTokenDetails.cacheReadTokens`).
9
+ // ai@6 also carried deprecated flat top-level aliases (`reasoningTokens`,
10
+ // `cachedInputTokens`); ai@7 removed them. This mock deliberately omits the
11
+ // flat aliases so the test only passes if the components read the nested path.
12
+ const usage: LanguageModelUsage = {
13
+ inputTokens: 1000,
14
+ inputTokenDetails: {
15
+ noCacheTokens: 600,
16
+ cacheReadTokens: 400,
17
+ cacheWriteTokens: 0,
18
+ },
19
+ outputTokens: 200,
20
+ outputTokenDetails: {
21
+ textTokens: 150,
22
+ reasoningTokens: 50,
23
+ },
24
+ totalTokens: 1200,
25
+ };
26
+
27
+ describe("TokenUsage usage readouts (#30, ai@7 LanguageModelUsage shape)", () => {
28
+ it("TokenUsageReasoning reads outputTokenDetails.reasoningTokens, not a flat field", () => {
29
+ render(
30
+ <TokenUsage maxTokens={8000} usage={usage} usedTokens={1200}>
31
+ <TokenUsageReasoning />
32
+ </TokenUsage>,
33
+ );
34
+ expect(screen.getByText("Reasoning")).toBeInTheDocument();
35
+ expect(screen.getByText("50")).toBeInTheDocument();
36
+ });
37
+
38
+ it("TokenUsageCache reads inputTokenDetails.cacheReadTokens, not a flat field", () => {
39
+ render(
40
+ <TokenUsage maxTokens={8000} usage={usage} usedTokens={1200}>
41
+ <TokenUsageCache />
42
+ </TokenUsage>,
43
+ );
44
+ expect(screen.getByText("Cache")).toBeInTheDocument();
45
+ expect(screen.getByText("400")).toBeInTheDocument();
46
+ });
47
+
48
+ it("renders nothing when the nested detail objects report zero", () => {
49
+ const zeroUsage: LanguageModelUsage = {
50
+ ...usage,
51
+ inputTokenDetails: { ...usage.inputTokenDetails, cacheReadTokens: 0 },
52
+ outputTokenDetails: { ...usage.outputTokenDetails, reasoningTokens: 0 },
53
+ };
54
+ const { container } = render(
55
+ <TokenUsage maxTokens={8000} usage={zeroUsage} usedTokens={1200}>
56
+ <TokenUsageReasoning />
57
+ <TokenUsageCache />
58
+ </TokenUsage>,
59
+ );
60
+ expect(container).toBeEmptyDOMElement();
61
+ });
62
+ });
63
+
64
+ describe("TokenUsage locale keys (#141, ai.context.* renamed to ai.tokenUsage.*)", () => {
65
+ it("the shipped dictionary defines no ai.context.* key", () => {
66
+ const staleKeys = Object.keys(DEFAULT_MESSAGES).filter((key) => key.startsWith("ai.context."));
67
+ expect(staleKeys).toEqual([]);
68
+ });
69
+
70
+ it("renders its labels from the ai.tokenUsage.* namespace, not a stale fallback", () => {
71
+ render(
72
+ <LocaleProvider
73
+ messages={{
74
+ "ai.tokenUsage.reasoning": "Custom reasoning label",
75
+ "ai.tokenUsage.cache": "Custom cache label",
76
+ }}
77
+ >
78
+ <TokenUsage maxTokens={8000} usage={usage} usedTokens={1200}>
79
+ <TokenUsageReasoning />
80
+ <TokenUsageCache />
81
+ </TokenUsage>
82
+ </LocaleProvider>,
83
+ );
84
+ // A component still reading the old `ai.context.*` namespace would ignore
85
+ // this override and keep rendering the untranslated English default
86
+ // ("Reasoning" / "Cache") instead.
87
+ expect(screen.getByText("Custom reasoning label")).toBeInTheDocument();
88
+ expect(screen.getByText("Custom cache label")).toBeInTheDocument();
89
+ expect(screen.queryByText("Reasoning")).not.toBeInTheDocument();
90
+ expect(screen.queryByText("Cache")).not.toBeInTheDocument();
91
+ });
92
+ });