@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,187 @@
1
+ "use client";
2
+
3
+ /**
4
+ * AgentEvent — the lifecycle/hook event line (#109).
5
+ *
6
+ * Nothing in the trace vocabulary (`Message` → `Tool` → `Task`/`AgentStep`)
7
+ * has a place for something the *runtime* did to the agent: a lifecycle hook
8
+ * (`user_prompt_submit`, `stop`) or a policy/guard check
9
+ * (`pre_tool_use`/`post_tool_use`) that fired around a tool call. `AgentEvent`
10
+ * fills that gap as an `AgentStep` VARIANT on the existing `AgentTimeline`
11
+ * rail — not a second spine. It sits in the same `<ol>` as ordinary steps,
12
+ * distinguished by glyph and label rather than by a different layout
13
+ * (docs/decisions/2026-09-01-brainless-adoption-architecture.md § 6, and the
14
+ * `Task`/`TaskItem` precedent this file follows in `./agent-timeline`).
15
+ *
16
+ * `outcome` maps onto the existing closed 7-state `Status` (`@elabs-ai/components-ui`) —
17
+ * no eighth status is introduced. `checks` renders a gate's verdicts: an
18
+ * array renders each check as icon + accessible pass/fail TEXT (never colour
19
+ * alone — the greyscale test in `.claude/rules/accessibility.md`); a count
20
+ * summary renders `passed/ran` as plain text.
21
+ */
22
+ import { Webhook, type LucideIcon } from "lucide-react";
23
+ import { forwardRef, type ReactNode } from "react";
24
+ import {
25
+ StatusIcon,
26
+ agentEventOutcomeStatus,
27
+ formatElapsed,
28
+ useLocale,
29
+ type AgentEventOutcome,
30
+ type AgentEventPhase,
31
+ type CheckResult,
32
+ type CheckSummary,
33
+ } from "@elabs-ai/components-ui";
34
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
35
+ import { AgentStep, type AgentStepProps } from "./agent-timeline";
36
+
37
+ // `AgentEventPhase`, `AgentEventOutcome` and the outcome→`Status` mapping
38
+ // (`agentEventOutcomeStatus`, formerly this file's private `OUTCOME_STATUS`)
39
+ // moved to `@elabs-ai/components-ui` (`lib/agent-event-model.ts`) — the
40
+ // terminal CLI look-alike family's own event line (issue #117) reuses the
41
+ // same model, and `@elabs-ai/components-ai`/`@elabs-ai/components-terminal`
42
+ // are layer-2 DAG siblings that may not import each other (T0; see
43
+ // docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4). Imported
44
+ // above; NOT re-exported from here — a consumer imports `AgentEventPhase`/
45
+ // `AgentEventOutcome` from `@elabs-ai/components-ui`.
46
+
47
+ export interface AgentEventProps extends Omit<
48
+ AgentStepProps,
49
+ "name" | "status" | "children" | "icon" | "timestamp"
50
+ > {
51
+ /** The runtime event name, verbatim as the runtime emits it — e.g. `"user_prompt_submit"`, `"pre_tool_use"`, `"stop"`. */
52
+ label: string;
53
+ /** When this event fired relative to the action it gates. See `AgentEventPhase`. */
54
+ phase?: AgentEventPhase;
55
+ /** The event's verdict. @default "ok" */
56
+ outcome?: AgentEventOutcome;
57
+ /** Wall-clock duration of the event, in milliseconds. Rendered with the shared `formatElapsed`. */
58
+ durationMs?: number;
59
+ /** Gate checks the event ran: individual verdicts, or a count summary. */
60
+ checks?: CheckSummary | CheckResult[];
61
+ /** Optional leading glyph on the title row (Lucide). @default Webhook */
62
+ icon?: LucideIcon;
63
+ }
64
+
65
+ function isCheckResultList(checks: CheckSummary | CheckResult[]): checks is CheckResult[] {
66
+ return Array.isArray(checks);
67
+ }
68
+
69
+ /**
70
+ * A single gate-check verdict row: icon + label + accessible status word +
71
+ * optional duration + optional detail. Mirrors the anatomy of `ChangeReview`'s
72
+ * check row (`packages/ui/src/components/change-review/change-review.tsx`) so
73
+ * the two `CheckResult` surfaces read as one convention — reimplemented here
74
+ * (rather than imported) because that row is a private, unexported function.
75
+ *
76
+ * The pass/fail signal is carried by TWO non-colour cues — `StatusIcon`'s
77
+ * distinct glyph (decorative, `aria-hidden`) and the visible status word
78
+ * (what reaches assistive tech) — so it survives in greyscale (WCAG 1.4.1).
79
+ */
80
+ function AgentEventCheckRow({ check }: { check: CheckResult }) {
81
+ const { t } = useLocale();
82
+ const { label, ok, detail, durationMs } = check;
83
+ const statusWord = t(ok ? "ai.agentEvent.checkPassed" : "ai.agentEvent.checkFailed");
84
+
85
+ return (
86
+ <div data-slot="agent-event-check" data-ok={ok} className="flex flex-col gap-1">
87
+ <div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 min-w-0">
88
+ <StatusIcon status={ok ? "complete" : "failed"} className="size-3.5 shrink-0" />
89
+ <span className="text-caption font-medium text-foreground truncate">{label}</span>
90
+ <span
91
+ data-slot="agent-event-check-status"
92
+ className={cn("text-caption", ok ? "text-success-text" : "text-destructive-text")}
93
+ >
94
+ {statusWord}
95
+ </span>
96
+ {durationMs !== undefined && (
97
+ <span className="text-caption text-muted-foreground tabular-nums shrink-0">
98
+ {formatElapsed(durationMs)}
99
+ </span>
100
+ )}
101
+ </div>
102
+ {detail !== undefined && detail !== "" && (
103
+ <p
104
+ data-slot="agent-event-check-detail"
105
+ className="pl-5 text-caption text-muted-foreground break-words"
106
+ >
107
+ {detail}
108
+ </p>
109
+ )}
110
+ </div>
111
+ );
112
+ }
113
+
114
+ /** The checks section: a flat list of verdict rows, or a `passed/ran` count line. */
115
+ function AgentEventChecks({ checks }: { checks: CheckSummary | CheckResult[] }) {
116
+ const { t } = useLocale();
117
+
118
+ if (isCheckResultList(checks)) {
119
+ if (checks.length === 0) return null;
120
+ return (
121
+ <div data-slot="agent-event-checks" className="flex flex-col gap-1.5">
122
+ {checks.map((check, index) => (
123
+ <AgentEventCheckRow key={`${check.label}-${index}`} check={check} />
124
+ ))}
125
+ </div>
126
+ );
127
+ }
128
+
129
+ const { ran, passed } = checks;
130
+ return (
131
+ <p
132
+ data-slot="agent-event-checks-summary"
133
+ className="text-meta text-muted-foreground tabular-nums"
134
+ >
135
+ {t("ai.agentEvent.checksSummary", { passed, ran })}
136
+ </p>
137
+ );
138
+ }
139
+
140
+ const PHASE_KEY: Record<AgentEventPhase, string> = {
141
+ before: "ai.agentEvent.phaseBefore",
142
+ after: "ai.agentEvent.phaseAfter",
143
+ lifecycle: "ai.agentEvent.phaseLifecycle",
144
+ };
145
+
146
+ /**
147
+ * One lifecycle/hook event line on the `AgentTimeline` rail — an `AgentStep`
148
+ * variant, never a second spine. Composes `AgentStep` exactly as `TaskItem`
149
+ * does in `./agent-timeline`, so it rides the same `TimelineRoot`/`TimelineItem`
150
+ * primitives every other trace entry uses.
151
+ */
152
+ export const AgentEvent = forwardRef<HTMLLIElement, AgentEventProps>(function AgentEvent(
153
+ { label, phase, outcome = "ok", durationMs, checks, icon: Icon = Webhook, summary, ...props },
154
+ ref,
155
+ ) {
156
+ const { t } = useLocale();
157
+ const status = agentEventOutcomeStatus(outcome);
158
+ const phaseLabel = phase === undefined ? undefined : t(PHASE_KEY[phase]);
159
+
160
+ const name: ReactNode = phaseLabel ? (
161
+ <>
162
+ {label}{" "}
163
+ <span data-slot="agent-event-phase" className="text-meta font-normal text-muted-foreground">
164
+ · {phaseLabel}
165
+ </span>
166
+ </>
167
+ ) : (
168
+ label
169
+ );
170
+
171
+ return (
172
+ <AgentStep
173
+ ref={ref}
174
+ icon={Icon}
175
+ name={name}
176
+ status={status}
177
+ summary={summary}
178
+ timestamp={durationMs === undefined ? undefined : formatElapsed(durationMs)}
179
+ data-slot="agent-event"
180
+ {...props}
181
+ >
182
+ {checks ? <AgentEventChecks checks={checks} /> : null}
183
+ </AgentStep>
184
+ );
185
+ });
186
+
187
+ AgentEvent.displayName = "AgentEvent";
@@ -6,7 +6,15 @@ import { ToolDetails, ToolInput, ToolOutput } from "./tool";
6
6
  const meta = {
7
7
  title: "AI/AgentTimeline",
8
8
  component: AgentTimeline,
9
- parameters: { layout: "padded" },
9
+ parameters: {
10
+ layout: "padded",
11
+ docs: {
12
+ description: {
13
+ component:
14
+ "The agent EXECUTION trace on the canonical rail. A generic ordered-steps rail is `Core/Timeline`, and git history is `Data/RevisionTimeline` — see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). `AgentTimeline` is the convergence compound: sequenced, status-bearing steps on ONE spine (the `@elabs-ai/components-ui` Timeline rail) speaking ONE vocabulary (the closed seven-state `Status` enum). `AI/Task` and `AI/AgentEvent` compose it rather than hand-rolling rails.",
15
+ },
16
+ },
17
+ },
10
18
  tags: ["autodocs"],
11
19
  } satisfies Meta<typeof AgentTimeline>;
12
20
  export default meta;
@@ -0,0 +1,47 @@
1
+ import type { Tool } from "ai";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { describe, expect, it } from "vitest";
4
+ import { AgentTool, AgentTools } from "./agent";
5
+
6
+ const staticTool: Tool = {
7
+ description: "Search the web for a query.",
8
+ inputSchema: { type: "object" } as never,
9
+ };
10
+
11
+ // ai@7 widened `Tool.description` to `string | ((options) => string)` (a
12
+ // per-call dynamic description). `AgentTool` renders a static list with no
13
+ // call context to invoke that function with.
14
+ const dynamicTool: Tool = {
15
+ description: () => "computed at call time",
16
+ inputSchema: { type: "object" } as never,
17
+ };
18
+
19
+ describe("AgentTool description (#30, ai@7 Tool.description can be a function)", () => {
20
+ it("renders a string description", () => {
21
+ render(
22
+ <AgentTools type="single" collapsible>
23
+ <AgentTool tool={staticTool} value="search" />
24
+ </AgentTools>,
25
+ );
26
+ expect(screen.getByText("Search the web for a query.")).toBeInTheDocument();
27
+ });
28
+
29
+ it("falls back to the empty-description copy instead of rendering a function as a node", () => {
30
+ render(
31
+ <AgentTools type="single" collapsible>
32
+ <AgentTool tool={dynamicTool} value="dynamic" />
33
+ </AgentTools>,
34
+ );
35
+ expect(screen.getByText("No description")).toBeInTheDocument();
36
+ });
37
+
38
+ it("falls back to the empty-description copy when there is no description at all", () => {
39
+ const bareTool: Tool = { inputSchema: { type: "object" } as never };
40
+ render(
41
+ <AgentTools type="single" collapsible>
42
+ <AgentTool tool={bareTool} value="bare" />
43
+ </AgentTools>,
44
+ );
45
+ expect(screen.getByText("No description")).toBeInTheDocument();
46
+ });
47
+ });
package/src/agent.tsx CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  AccordionTrigger,
8
8
  } from "@elabs-ai/components-ui";
9
9
  import { Badge } from "@elabs-ai/components-ui";
10
+ import { useLocale } from "@elabs-ai/components-ui";
10
11
  import { cn } from "@elabs-ai/components-ui/lib/cn";
11
12
  import type { Tool } from "ai";
12
13
  import { BotIcon } from "lucide-react";
@@ -51,24 +52,32 @@ export type AgentInstructionsProps = ComponentProps<"div"> & {
51
52
  };
52
53
 
53
54
  export const AgentInstructions = memo(
54
- ({ className, children, ...props }: AgentInstructionsProps) => (
55
- <div className={cn("space-y-2", className)} {...props}>
56
- <span className="font-medium text-muted-foreground text-sm">Instructions</span>
57
- <div className="rounded-md bg-muted/50 p-3 text-muted-foreground text-sm">
58
- <p>{children}</p>
55
+ ({ className, children, ...props }: AgentInstructionsProps) => {
56
+ const { t } = useLocale();
57
+ return (
58
+ <div className={cn("space-y-2", className)} {...props}>
59
+ <span className="font-medium text-muted-foreground text-sm">
60
+ {t("ai.agent.instructions")}
61
+ </span>
62
+ <div className="rounded-md bg-muted/50 p-3 text-muted-foreground text-sm">
63
+ <p>{children}</p>
64
+ </div>
59
65
  </div>
60
- </div>
61
- ),
66
+ );
67
+ },
62
68
  );
63
69
 
64
70
  export type AgentToolsProps = ComponentProps<typeof Accordion>;
65
71
 
66
- export const AgentTools = memo(({ className, ...props }: AgentToolsProps) => (
67
- <div className={cn("space-y-2", className)}>
68
- <span className="font-medium text-muted-foreground text-sm">Tools</span>
69
- <Accordion className="rounded-md border" {...props} />
70
- </div>
71
- ));
72
+ export const AgentTools = memo(({ className, ...props }: AgentToolsProps) => {
73
+ const { t } = useLocale();
74
+ return (
75
+ <div className={cn("space-y-2", className)}>
76
+ <span className="font-medium text-muted-foreground text-sm">{t("ai.agent.tools")}</span>
77
+ <Accordion className="rounded-md border" {...props} />
78
+ </div>
79
+ );
80
+ });
72
81
 
73
82
  export type AgentToolProps = ComponentProps<typeof AccordionItem> & {
74
83
  tool: Tool;
@@ -76,11 +85,16 @@ export type AgentToolProps = ComponentProps<typeof AccordionItem> & {
76
85
 
77
86
  export const AgentTool = memo(({ className, tool, value, ...props }: AgentToolProps) => {
78
87
  const schema = "jsonSchema" in tool && tool.jsonSchema ? tool.jsonSchema : tool.inputSchema;
88
+ // `tool.description` may be a fixed string OR (ai@7+) a function of the live
89
+ // call context (`(options) => string`) for a per-call dynamic description.
90
+ // AgentTool renders a static list, so it has no call context to invoke that
91
+ // function with — show it only when it is already a plain string.
92
+ const description = typeof tool.description === "string" ? tool.description : undefined;
79
93
 
80
94
  return (
81
95
  <AccordionItem className={cn("border-b last:border-b-0", className)} value={value} {...props}>
82
96
  <AccordionTrigger className="px-3 py-2 text-sm hover:no-underline">
83
- {tool.description ?? "No description"}
97
+ {description ?? "No description"}
84
98
  </AccordionTrigger>
85
99
  <AccordionContent className="px-3 pb-3">
86
100
  <div className="rounded-md bg-muted/50">
@@ -86,12 +86,12 @@ import {
86
86
  ApprovalCardTitle,
87
87
  } from "./confirmation";
88
88
  import {
89
- Context,
90
- ContextContent,
91
- ContextContentFooter,
92
- ContextContentHeader,
93
- ContextTrigger,
94
- } from "./context";
89
+ TokenUsage,
90
+ TokenUsageContent,
91
+ TokenUsageContentFooter,
92
+ TokenUsageContentHeader,
93
+ TokenUsageTrigger,
94
+ } from "./token-usage";
95
95
  import {
96
96
  ContextPanel,
97
97
  ContextPanelBody,
@@ -321,13 +321,15 @@ function WorkspaceSidebar({
321
321
  <SidebarHeader>
322
322
  <SidebarMenu>
323
323
  <SidebarMenuItem>
324
- <SidebarMenuButton size="lg" tooltip="Atlas workspace">
324
+ <SidebarMenuButton size="lg" tooltip="Atlas workspace" className="group">
325
325
  <span className="flex aspect-square size-8 shrink-0 items-center justify-center group-data-[collapsible=icon]:size-4">
326
326
  <BrandLogo variant="mark" className="size-full" aria-hidden />
327
327
  </span>
328
328
  <span className="grid flex-1 text-start leading-tight">
329
329
  <span className="truncate text-body font-semibold">Atlas</span>
330
- <span className="truncate text-meta text-muted-foreground">Agentic workspace</span>
330
+ <span className="truncate text-meta text-sidebar-muted-foreground group-hover:text-sidebar-accent-foreground">
331
+ Agentic workspace
332
+ </span>
331
333
  </span>
332
334
  </SidebarMenuButton>
333
335
  </SidebarMenuItem>
@@ -378,13 +380,16 @@ function WorkspaceSidebar({
378
380
  <SidebarMenuSubButton asChild isActive={activeProject === p.id}>
379
381
  <a
380
382
  href={`#project-${p.id}`}
383
+ className="group"
381
384
  onClick={(e) => {
382
385
  e.preventDefault();
383
386
  onSelectProject(p.id);
384
387
  }}
385
388
  >
386
389
  <span className="flex-1 truncate">{p.name}</span>
387
- <span className="text-meta text-muted-foreground">{p.tag}</span>
390
+ <span className="text-meta text-sidebar-muted-foreground group-hover:text-sidebar-accent-foreground group-data-[active=true]:text-sidebar-accent-foreground">
391
+ {p.tag}
392
+ </span>
388
393
  </a>
389
394
  </SidebarMenuSubButton>
390
395
  </SidebarMenuSubItem>
@@ -455,9 +460,9 @@ function WorkspaceSidebar({
455
460
  {ASSET_CATEGORIES.map((c) => (
456
461
  <SidebarMenuSubItem key={c.id}>
457
462
  <SidebarMenuSubButton asChild>
458
- <a href={`#assets-${c.id}`}>
463
+ <a href={`#assets-${c.id}`} className="group">
459
464
  <span className="flex-1 truncate">{c.name}</span>
460
- <span className="text-meta tabular-nums text-muted-foreground">
465
+ <span className="text-meta tabular-nums text-sidebar-muted-foreground group-hover:text-sidebar-accent-foreground">
461
466
  {c.count}
462
467
  </span>
463
468
  </a>
@@ -478,13 +483,15 @@ function WorkspaceSidebar({
478
483
  <SidebarMenuItem>
479
484
  <DropdownMenu>
480
485
  <DropdownMenuTrigger asChild>
481
- <SidebarMenuButton size="lg" tooltip="Account">
486
+ <SidebarMenuButton size="lg" tooltip="Account" className="group">
482
487
  <Avatar className="size-7 rounded-md">
483
488
  <AvatarFallback className="rounded-md text-meta">AR</AvatarFallback>
484
489
  </Avatar>
485
490
  <span className="grid flex-1 text-start leading-tight">
486
491
  <span className="truncate text-body font-medium">Avery Rao</span>
487
- <span className="truncate text-meta text-muted-foreground">avery@acme.co</span>
492
+ <span className="truncate text-meta text-sidebar-muted-foreground group-hover:text-sidebar-accent-foreground">
493
+ avery@acme.co
494
+ </span>
488
495
  </span>
489
496
  <ChevronsUpDown className="ms-auto size-4" />
490
497
  </SidebarMenuButton>
@@ -557,13 +564,13 @@ function WorkspaceContextPanel() {
557
564
  return (
558
565
  <ContextPanel>
559
566
  <ContextPanelHeader title="Context">
560
- <Context usedTokens={42000} maxTokens={200000} modelId="anthropic/claude-opus-4">
561
- <ContextTrigger />
562
- <ContextContent>
563
- <ContextContentHeader />
564
- <ContextContentFooter />
565
- </ContextContent>
566
- </Context>
567
+ <TokenUsage usedTokens={42000} maxTokens={200000} modelId="anthropic/claude-opus-4">
568
+ <TokenUsageTrigger />
569
+ <TokenUsageContent>
570
+ <TokenUsageContentHeader />
571
+ <TokenUsageContentFooter />
572
+ </TokenUsageContent>
573
+ </TokenUsage>
567
574
  </ContextPanelHeader>
568
575
  <ContextPanelBody root={<ContextRailRoot />} detail={<ContextRailDetail />} />
569
576
  </ContextPanel>
package/src/artifact.tsx CHANGED
@@ -67,18 +67,21 @@ export const ArtifactClose = ({
67
67
  size = "sm",
68
68
  variant = "ghost",
69
69
  ...props
70
- }: ArtifactCloseProps) => (
71
- <Button
72
- className={cn("size-8 p-0 text-muted-foreground hover:text-foreground", className)}
73
- size={size}
74
- type="button"
75
- variant={variant}
76
- {...props}
77
- >
78
- {children ?? <XIcon className="size-4" />}
79
- <span className="sr-only">Close</span>
80
- </Button>
81
- );
70
+ }: ArtifactCloseProps) => {
71
+ const { t } = useLocale();
72
+ return (
73
+ <Button
74
+ className={cn("size-8 p-0 text-muted-foreground hover:text-foreground", className)}
75
+ size={size}
76
+ type="button"
77
+ variant={variant}
78
+ {...props}
79
+ >
80
+ {children ?? <XIcon className="size-4" />}
81
+ <span className="sr-only">{t("close")}</span>
82
+ </Button>
83
+ );
84
+ };
82
85
 
83
86
  export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>;
84
87
 
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Issue #33 — `media-chrome` is an OPTIONAL peer of `@elabs-ai/components-ai`,
3
+ * reached only through `lazy(() => import("./_audio-player-media-chrome"))`
4
+ * (ADR 0019, `lazyPart` in `audio-player.tsx`). A REJECTED `lazy()` import
5
+ * throws during render — `Suspense` alone does not catch that (it only covers
6
+ * the pending state) — so every exported part is wrapped in
7
+ * `LazyEngineBoundary` (`_lazy-engine-boundary.tsx`). This locks that the
8
+ * boundary falls back gracefully for the top-level `AudioPlayer` AND for a
9
+ * sub-control, instead of taking the tree down.
10
+ *
11
+ * `vi.mock` factories are hoisted, so the mock itself must resolve
12
+ * successfully (a factory that throws or rejects gets intercepted by
13
+ * Vitest's OWN "error when mocking a module" diagnostic instead of the
14
+ * message this test needs — see `interactive-terminal-missing-peer.test.tsx`
15
+ * for the same finding). So the module resolves fine and the simulated
16
+ * failure instead comes from each named export THROWING when React renders
17
+ * it — a real render-phase error, which is exactly what `LazyEngineBoundary`
18
+ * exists to catch.
19
+ *
20
+ * The top-level `AudioPlayer` case asserts a SETTLED, non-loading notice
21
+ * (never a permanent `Skeleton`) — a loading placeholder left in place after
22
+ * the load has definitively failed reads as "still loading, forever", which
23
+ * `.claude/rules/loading-states.md` treats as a defect, not a graceful
24
+ * fallback. See `AudioPlayerMissing` in `audio-player.tsx`.
25
+ */
26
+ import { act, cleanup, render, screen } from "@testing-library/react";
27
+ import { afterEach, describe, expect, it, vi } from "vitest";
28
+
29
+ vi.mock("./_audio-player-media-chrome", () => ({
30
+ AudioPlayer: () => {
31
+ throw new Error("Cannot find module 'media-chrome'");
32
+ },
33
+ AudioPlayerPlayButton: () => {
34
+ throw new Error("Cannot find module 'media-chrome'");
35
+ },
36
+ }));
37
+
38
+ import { AudioPlayer, AudioPlayerPlayButton } from "./audio-player";
39
+
40
+ afterEach(cleanup);
41
+
42
+ const flush = () => act(async () => void (await new Promise((resolve) => setTimeout(resolve, 0))));
43
+
44
+ describe("AudioPlayer — missing optional peer (#33)", () => {
45
+ it("falls back to a settled, actionable notice — never a permanent skeleton — when media-chrome fails to load", async () => {
46
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
47
+
48
+ render(<AudioPlayer />);
49
+ await flush();
50
+
51
+ // The tree survived, and it settled: no permanent loading placeholder
52
+ // (a `Skeleton` left in place after a definitive failure would read as
53
+ // "still loading, forever" — the bug this locks against), and the
54
+ // notice names the exact missing package via `role="status"` (a missing
55
+ // optional peer is a capability gap, not a broken component).
56
+ const notice = screen.getByRole("status");
57
+ expect(notice).toHaveTextContent(/media-chrome/i);
58
+ expect(document.querySelector('[class*="animate-pulse"]')).toBeNull();
59
+ expect(errorSpy).toHaveBeenCalled();
60
+
61
+ errorSpy.mockRestore();
62
+ });
63
+
64
+ it("degrades a sub-control to nothing rather than crashing", async () => {
65
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
66
+
67
+ const { container } = render(<AudioPlayerPlayButton />);
68
+ await flush();
69
+
70
+ // `renderMissing={() => null}` for sub-controls — an empty, but intact,
71
+ // tree beats a crash.
72
+ expect(container).toBeEmptyDOMElement();
73
+ expect(errorSpy).toHaveBeenCalled();
74
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
75
+
76
+ errorSpy.mockRestore();
77
+ });
78
+ });