@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
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { Badge } from "@elabs-ai/components-ui";
4
4
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@elabs-ai/components-ui";
5
+ import { useLocale } from "@elabs-ai/components-ui";
5
6
  import { cn } from "@elabs-ai/components-ui/lib/cn";
6
7
  import { ChevronRightIcon } from "lucide-react";
7
8
  import type { ComponentProps, HTMLAttributes } from "react";
@@ -172,12 +173,13 @@ export const SchemaDisplayParameters = ({
172
173
  ...props
173
174
  }: SchemaDisplayParametersProps) => {
174
175
  const { parameters } = useContext(SchemaDisplayContext);
176
+ const { t } = useLocale();
175
177
 
176
178
  return (
177
179
  <Collapsible className={cn(className)} defaultOpen {...props}>
178
180
  <CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-start transition-colors hover:bg-muted/50">
179
181
  <ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
180
- <span className="font-medium text-sm">Parameters</span>
182
+ <span className="font-medium text-sm">{t("ai.schemaDisplay.parameters")}</span>
181
183
  <Badge className="ms-auto text-xs" variant="secondary">
182
184
  {parameters?.length}
183
185
  </Badge>
@@ -313,12 +315,13 @@ export const SchemaDisplayResponse = ({
313
315
  ...props
314
316
  }: SchemaDisplayResponseProps) => {
315
317
  const { responseBody } = useContext(SchemaDisplayContext);
318
+ const { t } = useLocale();
316
319
 
317
320
  return (
318
321
  <Collapsible className={cn(className)} defaultOpen {...props}>
319
322
  <CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-start transition-colors hover:bg-muted/50">
320
323
  <ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
321
- <span className="font-medium text-sm">Response</span>
324
+ <span className="font-medium text-sm">{t("ai.schemaDisplay.response")}</span>
322
325
  </CollapsibleTrigger>
323
326
  <CollapsibleContent>
324
327
  <div className="border-t">
@@ -8,7 +8,15 @@ const meta = {
8
8
  title: "AI/SelectionToolbar",
9
9
  component: SelectionToolbar,
10
10
  tags: ["autodocs"],
11
- parameters: { layout: "padded" },
11
+ parameters: {
12
+ layout: "padded",
13
+ docs: {
14
+ description: {
15
+ component:
16
+ "The small toolbar that floats over a TEXT SELECTION in a transcript, offering Quote. A dense secondary control row is `Layout/Toolbar`, the row above a list or table is `Layout/ViewToolbar`, and formatting chrome for a markdown source pane is `Editor/MarkdownToolbar` — see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). Positioned with a Popover virtual anchor at the selection rect; presentational only.",
17
+ },
18
+ },
19
+ },
12
20
  } satisfies Meta<typeof SelectionToolbar>;
13
21
  export default meta;
14
22
  type Story = StoryObj<typeof meta>;
@@ -0,0 +1,128 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { fn } from "storybook/test";
3
+
4
+ import { ChatGreeting } from "./chat-greeting";
5
+ import { SessionHeader } from "./session-header";
6
+
7
+ const CAPABILITIES = [
8
+ { label: "Web search", description: "Look things up while we talk." },
9
+ { label: "Code execution", description: "Run and inspect snippets in a sandbox." },
10
+ { label: "File access", description: "Read files in the current workspace." },
11
+ ];
12
+
13
+ const WHATS_NEW = [
14
+ { label: "Faster file search", href: "#" },
15
+ { label: "Improved reasoning traces" },
16
+ ];
17
+
18
+ const QUICK_ACTIONS = [
19
+ { label: "New chat", keyHint: "⌘N", onSelect: fn() },
20
+ { label: "Open workspace", keyHint: "⌘O", onSelect: fn() },
21
+ { label: "Settings", onSelect: fn() },
22
+ ];
23
+
24
+ const meta = {
25
+ title: "AI/SessionHeader",
26
+ component: SessionHeader,
27
+ parameters: {
28
+ layout: "padded",
29
+ docs: {
30
+ description: {
31
+ component:
32
+ "The CHAT launch card; the console skin is `Terminal/TerminalBanner` — see " +
33
+ "[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
34
+ "The launch card for an empty agent session — model, workspace, version, capabilities, what's new, and quick actions with key hints. Every section is independently optional: with only `title` supplied it renders the identity row alone, with no empty section scaffolding and no stray separators. Pair it above `ChatGreeting` for the standard first-run agent session state; distinct from `ChatGreeting`, which is the centered display-scale greeting for a general assistant chat.",
35
+ },
36
+ },
37
+ },
38
+ args: {
39
+ title: "Codex",
40
+ model: "gpt-5.1-codex",
41
+ workspace: "~/dev/acme/api-gateway",
42
+ version: "v2.4.0",
43
+ capabilities: CAPABILITIES,
44
+ whatsNew: WHATS_NEW,
45
+ quickActions: QUICK_ACTIONS,
46
+ },
47
+ tags: ["autodocs"],
48
+ } satisfies Meta<typeof SessionHeader>;
49
+
50
+ export default meta;
51
+ type Story = StoryObj<typeof meta>;
52
+
53
+ /** The fully-populated launch card. */
54
+ export const Default: Story = {
55
+ render: (args) => (
56
+ <div className="mx-auto max-w-md bg-background">
57
+ <SessionHeader {...args} />
58
+ </div>
59
+ ),
60
+ };
61
+
62
+ /**
63
+ * `title` is the only prop supplied — every other section (meta, workspace,
64
+ * capabilities, what's new, quick actions) is absent. Renders the identity
65
+ * row alone with no empty scaffolding and no stray separators.
66
+ */
67
+ export const TitleOnly: Story = {
68
+ args: {
69
+ title: "Codex",
70
+ model: undefined,
71
+ workspace: undefined,
72
+ version: undefined,
73
+ capabilities: undefined,
74
+ whatsNew: undefined,
75
+ quickActions: undefined,
76
+ },
77
+ render: (args) => (
78
+ <div className="mx-auto max-w-md bg-background">
79
+ <SessionHeader {...args} />
80
+ </div>
81
+ ),
82
+ };
83
+
84
+ /** Every section populated — the full launch-card anatomy. */
85
+ export const FullyPopulated: Story = {
86
+ render: (args) => (
87
+ <div className="mx-auto max-w-md bg-background">
88
+ <SessionHeader {...args} />
89
+ </div>
90
+ ),
91
+ };
92
+
93
+ /** A long workspace path truncates instead of overflowing its container. */
94
+ export const LongWorkspacePath: Story = {
95
+ args: {
96
+ workspace: "~/dev/acme/monorepo/services/platform/billing/api-gateway/src/handlers/webhooks",
97
+ },
98
+ render: (args) => (
99
+ <div className="mx-auto max-w-md bg-background">
100
+ <SessionHeader {...args} />
101
+ </div>
102
+ ),
103
+ };
104
+
105
+ /** A route that already owns the page's `<h1>` — pass `level={3}` to keep a single, hierarchical outline. */
106
+ export const HeadingLevel3: Story = {
107
+ args: { level: 3 },
108
+ render: (args) => (
109
+ <div className="mx-auto max-w-md bg-background">
110
+ <h1 className="sr-only">Assistant</h1>
111
+ <h2 className="sr-only">Session</h2>
112
+ <SessionHeader {...args} />
113
+ </div>
114
+ ),
115
+ };
116
+
117
+ /** SessionHeader above ChatGreeting — the standard empty first-run agent session. */
118
+ export const WithChatGreeting: Story = {
119
+ parameters: { layout: "fullscreen" },
120
+ render: (args) => (
121
+ <div className="grid min-h-[32rem] place-items-center bg-background px-4 py-10 text-foreground">
122
+ <div className="w-full max-w-md space-y-8">
123
+ <SessionHeader {...args} />
124
+ <ChatGreeting title="Ready when you are" orb={false} level={3} />
125
+ </div>
126
+ </div>
127
+ ),
128
+ };
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { SessionHeader } from "./session-header";
5
+
6
+ const CAPABILITIES = [
7
+ { label: "Web search", description: "Look things up while we talk." },
8
+ { label: "Code execution" },
9
+ ];
10
+
11
+ const WHATS_NEW = [{ label: "Faster file search", href: "https://example.com/changelog" }];
12
+
13
+ const QUICK_ACTIONS = [
14
+ { label: "New chat", keyHint: "⌘N" },
15
+ { label: "Open workspace", keyHint: "⌘O" },
16
+ { label: "Settings" },
17
+ ];
18
+
19
+ describe("SessionHeader", () => {
20
+ it("renders only the title with no empty sections or stray separators (acceptance criterion)", () => {
21
+ const { container } = render(<SessionHeader title="Codex" />);
22
+
23
+ expect(screen.getByRole("heading")).toHaveTextContent("Codex");
24
+
25
+ // No section for a prop that was never supplied.
26
+ expect(container.querySelector('[data-slot="session-header-meta"]')).toBeNull();
27
+ expect(container.querySelector('[data-slot="session-header-workspace"]')).toBeNull();
28
+ expect(container.querySelector('[data-slot="session-header-capabilities"]')).toBeNull();
29
+ expect(container.querySelector('[data-slot="session-header-whats-new"]')).toBeNull();
30
+ expect(container.querySelector('[data-slot="session-header-quick-actions"]')).toBeNull();
31
+
32
+ // No separator is ever rendered by this component — nothing left "stray"
33
+ // when the sections around it are absent.
34
+ expect(container.querySelector('[role="separator"]')).toBeNull();
35
+ expect(screen.queryByRole("separator")).toBeNull();
36
+
37
+ // No list/group scaffolding renders without content behind it.
38
+ expect(container.querySelectorAll("ul")).toHaveLength(0);
39
+ expect(container.querySelector('[role="group"]')).toBeNull();
40
+ });
41
+
42
+ it("renders nothing at all — not even the identity row — with no props supplied", () => {
43
+ const { container } = render(<SessionHeader />);
44
+
45
+ const root = container.firstElementChild as HTMLElement;
46
+ expect(root).toHaveAttribute("data-slot", "session-header");
47
+ expect(root.querySelector('[data-slot="session-header-identity"]')).toBeNull();
48
+ expect(screen.queryByRole("heading")).not.toBeInTheDocument();
49
+ });
50
+
51
+ it("renders every quick action as a focusable button reachable by role and accessible name", async () => {
52
+ const user = userEvent.setup();
53
+ const onSelect = vi.fn();
54
+ const quickActions = [
55
+ { label: "New chat", keyHint: "⌘N", onSelect },
56
+ ...QUICK_ACTIONS.slice(1),
57
+ ];
58
+
59
+ render(
60
+ <SessionHeader
61
+ title="Codex"
62
+ model="gpt-5.1-codex"
63
+ workspace="~/dev/acme/api-gateway"
64
+ version="v2.4.0"
65
+ capabilities={CAPABILITIES}
66
+ whatsNew={WHATS_NEW}
67
+ quickActions={quickActions}
68
+ />,
69
+ );
70
+
71
+ for (const action of quickActions) {
72
+ const button = screen.getByRole("button", { name: new RegExp(action.label) });
73
+ expect(button.tagName).toBe("BUTTON");
74
+ }
75
+
76
+ await user.click(screen.getByRole("button", { name: /New chat/ }));
77
+ expect(onSelect).toHaveBeenCalledTimes(1);
78
+ });
79
+
80
+ it("renders shortcut hints via the Kbd primitive", () => {
81
+ render(<SessionHeader title="Codex" quickActions={QUICK_ACTIONS} />);
82
+
83
+ const button = screen.getByRole("button", { name: /New chat/ });
84
+ expect(button.querySelector("kbd")).toHaveTextContent("⌘N");
85
+ });
86
+
87
+ it("renders model and version together, separated, when both are supplied", () => {
88
+ render(<SessionHeader title="Codex" model="gpt-5.1-codex" version="v2.4.0" />);
89
+
90
+ expect(screen.getByText("gpt-5.1-codex · v2.4.0")).toBeInTheDocument();
91
+ });
92
+
93
+ it("truncates a long workspace path instead of overflowing", () => {
94
+ const workspace =
95
+ "~/dev/acme/monorepo/services/platform/billing/api-gateway/src/handlers/webhooks";
96
+ const { container } = render(<SessionHeader title="Codex" workspace={workspace} />);
97
+
98
+ const workspaceRow = container.querySelector('[data-slot="session-header-workspace"]');
99
+ const label = workspaceRow?.querySelector("span");
100
+ expect(label).toHaveTextContent(workspace);
101
+ expect(label?.className.split(" ")).toEqual(
102
+ expect.arrayContaining(["truncate", "min-w-0", "flex-1"]),
103
+ );
104
+ });
105
+
106
+ it("defaults to a level-2 heading and honors an explicit level override", () => {
107
+ const { rerender } = render(<SessionHeader title="Codex" />);
108
+ expect(screen.getByRole("heading", { level: 2 })).toBeInTheDocument();
109
+
110
+ rerender(<SessionHeader title="Codex" level={3} />);
111
+ expect(screen.getByRole("heading", { level: 3 })).toBeInTheDocument();
112
+ expect(screen.queryByRole("heading", { level: 2 })).not.toBeInTheDocument();
113
+ });
114
+
115
+ it("renders capability and what's-new list items only when their arrays are non-empty", () => {
116
+ render(<SessionHeader title="Codex" capabilities={CAPABILITIES} whatsNew={WHATS_NEW} />);
117
+
118
+ expect(screen.getByText("Web search")).toBeInTheDocument();
119
+ expect(screen.getByText("Look things up while we talk.")).toBeInTheDocument();
120
+ expect(screen.getByRole("link", { name: "Faster file search" })).toHaveAttribute(
121
+ "href",
122
+ "https://example.com/changelog",
123
+ );
124
+ });
125
+
126
+ it("merges a custom className onto the root", () => {
127
+ const { container } = render(<SessionHeader title="Codex" className="custom-class" />);
128
+
129
+ expect(container.firstElementChild).toHaveClass("custom-class");
130
+ });
131
+
132
+ it("forwards a ref to the root element", () => {
133
+ const ref = vi.fn();
134
+ render(<SessionHeader ref={ref} title="Codex" />);
135
+
136
+ expect(ref).toHaveBeenCalledWith(expect.any(HTMLDivElement));
137
+ });
138
+ });
@@ -0,0 +1,243 @@
1
+ "use client";
2
+
3
+ import type { ComponentProps } from "react";
4
+ import { forwardRef } from "react";
5
+ import { Folder, Sparkles } from "lucide-react";
6
+ import { BrandLogo } from "@elabs-ai/components-icons";
7
+ import {
8
+ Button,
9
+ cn,
10
+ Heading,
11
+ Kbd,
12
+ useLocale,
13
+ type HeadingLevel,
14
+ type SessionCapability,
15
+ type SessionQuickAction,
16
+ type SessionWhatsNewItem,
17
+ } from "@elabs-ai/components-ui";
18
+
19
+ // `SessionCapability`, `SessionWhatsNewItem` and `SessionQuickAction` moved
20
+ // to `@elabs-ai/components-ui` (`lib/session-launch.ts`) — the terminal CLI
21
+ // look-alike family's own session header (issue #117) reuses the same
22
+ // vocabulary shape, and `@elabs-ai/components-ai`/
23
+ // `@elabs-ai/components-terminal` are layer-2 DAG siblings that may not
24
+ // import each other (T0; see
25
+ // docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4). Imported
26
+ // above; NOT re-exported from here — a consumer imports these from
27
+ // `@elabs-ai/components-ui`.
28
+
29
+ export interface SessionHeaderProps extends ComponentProps<"div"> {
30
+ /**
31
+ * Session/agent name, e.g. "Codex". Anchors the card — every other section
32
+ * is independently optional and renders nothing when omitted.
33
+ */
34
+ title?: string;
35
+ /** Active model, e.g. "gpt-5.1-codex". */
36
+ model?: string;
37
+ /** Working directory / project path. Truncates instead of overflowing. */
38
+ workspace?: string;
39
+ /** Build/version string, e.g. "v2.4.0". */
40
+ version?: string;
41
+ /** What this session can reach. */
42
+ capabilities?: SessionCapability[];
43
+ /** Recent changes, shown as a short list. */
44
+ whatsNew?: SessionWhatsNewItem[];
45
+ /** Shortcut actions, rendered as real buttons with optional shortcut hints. */
46
+ quickActions?: SessionQuickAction[];
47
+ /**
48
+ * Semantic heading level for `title`. Default `2` (a session header
49
+ * typically sits above a `ChatGreeting`'s level-1 headline, or inside a
50
+ * page that already owns its own `<h1>`).
51
+ */
52
+ level?: HeadingLevel;
53
+ }
54
+
55
+ /**
56
+ * SessionHeader — the launch card for an empty agent session: model,
57
+ * workspace, version, capabilities, what's new, and quick actions with
58
+ * shortcut hints. Pair it above `ChatGreeting` (or inside `ChatShell`'s
59
+ * `header` slot) for the standard first-run agent session state.
60
+ *
61
+ * Distinct from `ChatGreeting`, which is the centered display-scale greeting
62
+ * for a general assistant chat — `SessionHeader` answers different questions
63
+ * (what workspace am I in, what model, what can this thing reach, what
64
+ * changed) that a greeting has no home for.
65
+ *
66
+ * **Every section is independently optional.** With only `title` supplied,
67
+ * the card renders the identity row alone — no empty section scaffolding,
68
+ * no stray separators.
69
+ */
70
+ export const SessionHeader = forwardRef<HTMLDivElement, SessionHeaderProps>(function SessionHeader(
71
+ {
72
+ title,
73
+ model,
74
+ workspace,
75
+ version,
76
+ capabilities,
77
+ whatsNew,
78
+ quickActions,
79
+ level = 2,
80
+ className,
81
+ ...props
82
+ },
83
+ ref,
84
+ ) {
85
+ const { t } = useLocale();
86
+ const hasMeta = model != null || version != null;
87
+ const hasIdentity = title != null || hasMeta || workspace != null;
88
+ const hasCapabilities = (capabilities?.length ?? 0) > 0;
89
+ const hasWhatsNew = (whatsNew?.length ?? 0) > 0;
90
+ const hasQuickActions = (quickActions?.length ?? 0) > 0;
91
+
92
+ return (
93
+ <div
94
+ ref={ref}
95
+ data-slot="session-header"
96
+ className={cn(
97
+ "min-w-0 rounded-xl border bg-card p-5 text-card-foreground shadow-xs",
98
+ className,
99
+ )}
100
+ {...props}
101
+ >
102
+ <div className="flex min-w-0 flex-col gap-4">
103
+ {hasIdentity ? (
104
+ <div data-slot="session-header-identity" className="flex min-w-0 flex-col gap-1">
105
+ {title != null ? (
106
+ <div className="flex min-w-0 items-center gap-2">
107
+ {/* Decorative — the visible Heading already carries the
108
+ accessible name, so the mark is hidden from AT rather than
109
+ announced a second time via BrandLogo's own `role="img"`. */}
110
+ <span aria-hidden="true" className="shrink-0">
111
+ <BrandLogo variant="mark" height={24} title={title} />
112
+ </span>
113
+ <Heading
114
+ data-slot="session-header-title"
115
+ level={level}
116
+ size="subtitle"
117
+ className="min-w-0 truncate"
118
+ >
119
+ {title}
120
+ </Heading>
121
+ </div>
122
+ ) : null}
123
+
124
+ {hasMeta ? (
125
+ <p
126
+ data-slot="session-header-meta"
127
+ className="min-w-0 truncate text-meta text-muted-foreground"
128
+ >
129
+ {model}
130
+ {model != null && version != null ? " · " : null}
131
+ {version}
132
+ </p>
133
+ ) : null}
134
+
135
+ {workspace != null ? (
136
+ <div
137
+ data-slot="session-header-workspace"
138
+ className="flex min-w-0 items-center gap-1.5 text-body text-muted-foreground"
139
+ >
140
+ <Folder aria-hidden="true" className="size-3.5 shrink-0" />
141
+ <span className="min-w-0 flex-1 truncate">{workspace}</span>
142
+ </div>
143
+ ) : null}
144
+ </div>
145
+ ) : null}
146
+
147
+ {hasCapabilities ? (
148
+ <div data-slot="session-header-capabilities" className="flex min-w-0 flex-col gap-2">
149
+ <p className="text-meta font-medium text-muted-foreground">
150
+ {t("ai.sessionHeader.capabilities")}
151
+ </p>
152
+ <ul className="flex min-w-0 flex-col gap-2">
153
+ {capabilities?.map((capability) => (
154
+ <li
155
+ key={capability.label}
156
+ data-slot="session-header-capability"
157
+ className="flex min-w-0 items-start gap-2"
158
+ >
159
+ {capability.icon != null ? (
160
+ <span
161
+ aria-hidden="true"
162
+ className="mt-0.5 shrink-0 text-muted-foreground [&_svg]:size-4"
163
+ >
164
+ {capability.icon}
165
+ </span>
166
+ ) : null}
167
+ <div className="min-w-0">
168
+ <p className="min-w-0 truncate text-body text-foreground">{capability.label}</p>
169
+ {capability.description != null ? (
170
+ <p className="min-w-0 truncate text-meta text-muted-foreground">
171
+ {capability.description}
172
+ </p>
173
+ ) : null}
174
+ </div>
175
+ </li>
176
+ ))}
177
+ </ul>
178
+ </div>
179
+ ) : null}
180
+
181
+ {hasWhatsNew ? (
182
+ <div data-slot="session-header-whats-new" className="flex min-w-0 flex-col gap-2">
183
+ <p className="flex items-center gap-1.5 text-meta font-medium text-muted-foreground">
184
+ <Sparkles aria-hidden="true" className="size-3.5 shrink-0" />
185
+ {t("ai.sessionHeader.whatsNew")}
186
+ </p>
187
+ <ul className="flex min-w-0 flex-col gap-1">
188
+ {whatsNew?.map((item) => (
189
+ <li
190
+ key={item.label}
191
+ data-slot="session-header-whats-new-item"
192
+ className="min-w-0 truncate text-body"
193
+ >
194
+ {item.href != null ? (
195
+ <a
196
+ href={item.href}
197
+ className="text-primary-text underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
198
+ >
199
+ {item.label}
200
+ </a>
201
+ ) : (
202
+ <span className="text-foreground">{item.label}</span>
203
+ )}
204
+ </li>
205
+ ))}
206
+ </ul>
207
+ </div>
208
+ ) : null}
209
+
210
+ {hasQuickActions ? (
211
+ <div
212
+ data-slot="session-header-quick-actions"
213
+ role="group"
214
+ aria-label={t("ai.sessionHeader.quickActions")}
215
+ className="flex min-w-0 flex-wrap gap-2"
216
+ >
217
+ {quickActions?.map((action) => (
218
+ <Button
219
+ key={action.label}
220
+ type="button"
221
+ data-slot="session-header-quick-action"
222
+ variant="outline"
223
+ size="sm"
224
+ onClick={action.onSelect}
225
+ >
226
+ <span>{action.label}</span>
227
+ {/* An explicit space text node (not just flex `gap-2`) keeps
228
+ "New chat"/"⌘N" apart in the button's computed accessible
229
+ name, not only its visual layout. */}
230
+ {action.keyHint != null ? (
231
+ <>
232
+ {" "}
233
+ <Kbd>{action.keyHint}</Kbd>
234
+ </>
235
+ ) : null}
236
+ </Button>
237
+ ))}
238
+ </div>
239
+ ) : null}
240
+ </div>
241
+ </div>
242
+ );
243
+ });
@@ -0,0 +1,73 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { TokenUsage, TokenUsageTrigger } from "./token-usage";
3
+ import { SessionStatusBar } from "./session-status-bar";
4
+
5
+ const meta = {
6
+ title: "AI/SessionStatusBar",
7
+ component: SessionStatusBar,
8
+ parameters: {
9
+ layout: "padded",
10
+ docs: {
11
+ description: {
12
+ component:
13
+ "The CHAT status row; the console skin is `Terminal/TerminalStatusBar`, with the same " +
14
+ "prop names — see " +
15
+ "[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
16
+ "The ambient session row: workspace, branch, model and integration-connection progress (#105). Every segment renders only when its prop is supplied — an all-empty bar renders nothing at all. Docks `TokenUsage` via `children` rather than re-implementing token-usage/cost maths.",
17
+ },
18
+ },
19
+ },
20
+ tags: ["autodocs"],
21
+ } satisfies Meta<typeof SessionStatusBar>;
22
+
23
+ export default meta;
24
+ type Story = StoryObj<typeof meta>;
25
+
26
+ /** Every ambient segment, with `TokenUsage` docked at the trailing edge. */
27
+ export const Default: Story = {
28
+ render: () => (
29
+ <SessionStatusBar branch="main" model="Claude Opus 4" workspace="brand-ui">
30
+ <TokenUsage maxTokens={8000} usedTokens={2400}>
31
+ <TokenUsageTrigger />
32
+ </TokenUsage>
33
+ </SessionStatusBar>
34
+ ),
35
+ };
36
+
37
+ /** Only a workspace name — every other segment is independently optional. */
38
+ export const WorkspaceOnly: Story = {
39
+ render: () => <SessionStatusBar workspace="brand-ui" />,
40
+ };
41
+
42
+ /** Integrations mid-handshake — a spinner replaces the connection icon. */
43
+ export const ConnectingIntegrations: Story = {
44
+ render: () => (
45
+ <SessionStatusBar
46
+ branch="feature/turn-status"
47
+ connections={{ connected: 1, total: 4, connecting: true }}
48
+ workspace="brand-ui"
49
+ />
50
+ ),
51
+ };
52
+
53
+ /** Integrations resolved — a static connected/total count. */
54
+ export const IntegrationsConnected: Story = {
55
+ render: () => (
56
+ <SessionStatusBar
57
+ branch="feature/turn-status"
58
+ connections={{ connected: 4, total: 4 }}
59
+ model="Claude Opus 4"
60
+ workspace="brand-ui"
61
+ />
62
+ ),
63
+ };
64
+
65
+ /** No segment supplied — the bar renders nothing at all, not an empty shell. */
66
+ export const Empty: Story = {
67
+ render: () => (
68
+ <div className="rounded-md border border-dashed p-4 text-center text-meta text-muted-foreground">
69
+ <SessionStatusBar />
70
+ Nothing rendered above this line.
71
+ </div>
72
+ ),
73
+ };