@elabs-ai/components-ai 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -0
  3. package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
  4. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
  5. package/dist/_flow-boundary-D63PJ65S.js +186 -0
  6. package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
  7. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
  8. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
  9. package/dist/_persona-rive-RFR2EUWP.js +164 -0
  10. package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
  11. package/dist/index.d.ts +4079 -0
  12. package/dist/index.js +11770 -0
  13. package/dist/index.js.map +1 -0
  14. package/package.json +87 -0
  15. package/src/_audio-player-media-chrome.tsx +146 -0
  16. package/src/_chat-shell-rail.tsx +18 -0
  17. package/src/_code-block-theme.test.ts +134 -0
  18. package/src/_code-block-theme.ts +184 -0
  19. package/src/_flow-boundary.tsx +237 -0
  20. package/src/_flow-lazy.ts +34 -0
  21. package/src/_interactive-terminal-xterm.ts +32 -0
  22. package/src/_lazy-mermaid.test.ts +101 -0
  23. package/src/_lazy-mermaid.ts +117 -0
  24. package/src/_persona-rive.tsx +252 -0
  25. package/src/_streamdown-i18n.ts +119 -0
  26. package/src/agent-timeline.stories.tsx +66 -0
  27. package/src/agent-timeline.test.tsx +74 -0
  28. package/src/agent-timeline.tsx +106 -0
  29. package/src/agent.tsx +113 -0
  30. package/src/agentic-workspace.stories.tsx +1099 -0
  31. package/src/artifact.stories.tsx +62 -0
  32. package/src/artifact.test.tsx +112 -0
  33. package/src/artifact.tsx +167 -0
  34. package/src/asset-preview.stories.tsx +100 -0
  35. package/src/asset-preview.test.tsx +126 -0
  36. package/src/asset-preview.tsx +218 -0
  37. package/src/attachments.tsx +393 -0
  38. package/src/audio-player.stories.tsx +99 -0
  39. package/src/audio-player.tsx +170 -0
  40. package/src/blocks-ai-composer.stories.tsx +83 -0
  41. package/src/canvas.stories.tsx +142 -0
  42. package/src/canvas.tsx +29 -0
  43. package/src/chain-of-thought.tsx +193 -0
  44. package/src/chat-greeting.stories.tsx +96 -0
  45. package/src/chat-greeting.test.tsx +91 -0
  46. package/src/chat-greeting.tsx +91 -0
  47. package/src/chat-shell.stories.tsx +123 -0
  48. package/src/chat-shell.test.tsx +113 -0
  49. package/src/chat-shell.tsx +97 -0
  50. package/src/chat.stories.tsx +99 -0
  51. package/src/checkpoint.tsx +54 -0
  52. package/src/code-block.stories.tsx +81 -0
  53. package/src/code-block.test.tsx +191 -0
  54. package/src/code-block.tsx +673 -0
  55. package/src/commit.tsx +343 -0
  56. package/src/composer.stories.tsx +188 -0
  57. package/src/composer.test.tsx +182 -0
  58. package/src/composer.tsx +202 -0
  59. package/src/confirmation.stories.tsx +61 -0
  60. package/src/confirmation.test.tsx +136 -0
  61. package/src/confirmation.tsx +273 -0
  62. package/src/connection.tsx +23 -0
  63. package/src/context-panel.stories.tsx +137 -0
  64. package/src/context-panel.test.tsx +198 -0
  65. package/src/context-panel.tsx +609 -0
  66. package/src/context.stories.tsx +16 -0
  67. package/src/context.tsx +363 -0
  68. package/src/controls.tsx +20 -0
  69. package/src/conversation.stories.tsx +26 -0
  70. package/src/conversation.test.tsx +123 -0
  71. package/src/conversation.tsx +143 -0
  72. package/src/dark-theme-variant.test.ts +47 -0
  73. package/src/edge.tsx +33 -0
  74. package/src/environment-variables.tsx +304 -0
  75. package/src/file-tree.stories.tsx +55 -0
  76. package/src/file-tree.test.tsx +101 -0
  77. package/src/file-tree.tsx +390 -0
  78. package/src/gallery.stories.tsx +182 -0
  79. package/src/gallery.test.tsx +150 -0
  80. package/src/gallery.tsx +737 -0
  81. package/src/grouped-parts.stories.tsx +104 -0
  82. package/src/grouped-parts.test.tsx +167 -0
  83. package/src/grouped-parts.tsx +247 -0
  84. package/src/image.stories.tsx +67 -0
  85. package/src/image.test.tsx +105 -0
  86. package/src/image.tsx +115 -0
  87. package/src/index.ts +93 -0
  88. package/src/inline-citation.stories.tsx +91 -0
  89. package/src/inline-citation.test.tsx +96 -0
  90. package/src/inline-citation.tsx +314 -0
  91. package/src/interactive-terminal.stories.tsx +165 -0
  92. package/src/interactive-terminal.test.tsx +448 -0
  93. package/src/interactive-terminal.tsx +444 -0
  94. package/src/jsx-preview.stories.tsx +265 -0
  95. package/src/jsx-preview.test.tsx +75 -0
  96. package/src/jsx-preview.tsx +409 -0
  97. package/src/markdown-view.stories.tsx +57 -0
  98. package/src/markdown-view.test.tsx +36 -0
  99. package/src/markdown-view.tsx +113 -0
  100. package/src/message-edit.stories.tsx +150 -0
  101. package/src/message-edit.test.tsx +115 -0
  102. package/src/message-edit.tsx +336 -0
  103. package/src/message-feedback.stories.tsx +53 -0
  104. package/src/message-feedback.test.tsx +62 -0
  105. package/src/message-feedback.tsx +117 -0
  106. package/src/message-form-spec.ts +349 -0
  107. package/src/message-form.stories.tsx +182 -0
  108. package/src/message-form.test.tsx +227 -0
  109. package/src/message-form.tsx +845 -0
  110. package/src/message-table-spec.ts +281 -0
  111. package/src/message-table.stories.tsx +176 -0
  112. package/src/message-table.test.tsx +144 -0
  113. package/src/message-table.tsx +366 -0
  114. package/src/message.stories.tsx +262 -0
  115. package/src/message.test.tsx +508 -0
  116. package/src/message.tsx +619 -0
  117. package/src/mic-selector.tsx +339 -0
  118. package/src/microcopy.test.tsx +123 -0
  119. package/src/model-selector.stories.tsx +73 -0
  120. package/src/model-selector.test.tsx +55 -0
  121. package/src/model-selector.tsx +225 -0
  122. package/src/motion-config.tsx +49 -0
  123. package/src/node.tsx +71 -0
  124. package/src/open-in-chat.tsx +340 -0
  125. package/src/package-info.tsx +205 -0
  126. package/src/panel.tsx +20 -0
  127. package/src/part-groups.ts +258 -0
  128. package/src/persona-sources.ts +59 -0
  129. package/src/persona.stories.tsx +111 -0
  130. package/src/persona.test.tsx +67 -0
  131. package/src/persona.tsx +148 -0
  132. package/src/plan.tsx +135 -0
  133. package/src/prompt-input.stories.tsx +261 -0
  134. package/src/prompt-input.test.tsx +392 -0
  135. package/src/prompt-input.tsx +1668 -0
  136. package/src/queue.tsx +237 -0
  137. package/src/reasoning.stories.tsx +81 -0
  138. package/src/reasoning.test.tsx +50 -0
  139. package/src/reasoning.tsx +229 -0
  140. package/src/sandbox.stories.tsx +62 -0
  141. package/src/sandbox.test.tsx +42 -0
  142. package/src/sandbox.tsx +149 -0
  143. package/src/schema-display.tsx +412 -0
  144. package/src/selection-toolbar.stories.tsx +86 -0
  145. package/src/selection-toolbar.test.tsx +89 -0
  146. package/src/selection-toolbar.tsx +192 -0
  147. package/src/shimmer.stories.tsx +10 -0
  148. package/src/shimmer.tsx +81 -0
  149. package/src/snippet.stories.tsx +19 -0
  150. package/src/snippet.test.tsx +116 -0
  151. package/src/snippet.tsx +138 -0
  152. package/src/sources.stories.tsx +34 -0
  153. package/src/sources.test.tsx +129 -0
  154. package/src/sources.tsx +93 -0
  155. package/src/speech-input.tsx +312 -0
  156. package/src/stack-trace.tsx +479 -0
  157. package/src/streamdown-i18n.test.tsx +163 -0
  158. package/src/suggestion.stories.tsx +75 -0
  159. package/src/suggestion.test.tsx +72 -0
  160. package/src/suggestion.tsx +120 -0
  161. package/src/task.stories.tsx +22 -0
  162. package/src/task.test.tsx +36 -0
  163. package/src/task.tsx +89 -0
  164. package/src/templates-ai-assistant.stories.tsx +142 -0
  165. package/src/terminal.tsx +244 -0
  166. package/src/test-results.stories.tsx +177 -0
  167. package/src/test-results.test.tsx +241 -0
  168. package/src/test-results.tsx +408 -0
  169. package/src/tool-result-card.stories.tsx +143 -0
  170. package/src/tool-result-card.test.tsx +72 -0
  171. package/src/tool-result-card.tsx +86 -0
  172. package/src/tool.stories.tsx +60 -0
  173. package/src/tool.test.tsx +63 -0
  174. package/src/tool.tsx +235 -0
  175. package/src/toolbar.tsx +44 -0
  176. package/src/transcription.tsx +118 -0
  177. package/src/voice-selector.tsx +469 -0
  178. package/src/web-preview.stories.tsx +67 -0
  179. package/src/web-preview.test.tsx +117 -0
  180. package/src/web-preview.tsx +283 -0
@@ -0,0 +1,609 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ContextPanel — the chat workspace's right context rail (#193, research 09).
5
+ *
6
+ * A compound, lifted-state component (doc-13): `ContextPanelProvider` lifts ALL
7
+ * state ({ open, view, selectedAsset }) so an external trigger (e.g. an
8
+ * app-header `ContextPanelTrigger`) can drive the panel from anywhere inside
9
+ * the provider. The panel itself is ALWAYS MOUNTED and collapses via the
10
+ * canonical `useCollapsiblePanel` width tween (one collapse implementation —
11
+ * never a conditional mount, which can't animate). Root ↔ detail drill-in is a
12
+ * v1 CSS two-pane translateX track behind the future `useViewTransition` seam
13
+ * (research view-transitions/01-design.md §6.1): `openDetail`/`back` are
14
+ * already shaped as `mutate`-callbacks and the panes carry stable
15
+ * `data-vt-pane` identities, so the v2 swap is internal-only.
16
+ *
17
+ * ChatShell stays generic: compose `<ContextPanel>` as a SIBLING of
18
+ * `<ChatShell>` under a workspace-wrapping `<ContextPanelProvider>` (the
19
+ * Sidebar / SidebarInset relationship). Below the mobile breakpoint the panel
20
+ * degrades to a `@elabs-ai/components-ui` `<Sheet side="right">` driven by the same state.
21
+ */
22
+ import {
23
+ Button,
24
+ Sheet,
25
+ SheetContent,
26
+ SheetDescription,
27
+ SheetHeader,
28
+ SheetTitle,
29
+ useCollapsiblePanel,
30
+ useIsMobile,
31
+ useLocale,
32
+ type FileSource,
33
+ } from "@elabs-ai/components-ui";
34
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
35
+ import { ChevronLeftIcon, PanelRightIcon } from "lucide-react";
36
+ import type { ComponentProps, CSSProperties, HTMLAttributes, ReactNode, RefObject } from "react";
37
+ import {
38
+ createContext,
39
+ forwardRef,
40
+ use,
41
+ useCallback,
42
+ useEffect,
43
+ useId,
44
+ useMemo,
45
+ useRef,
46
+ useState,
47
+ } from "react";
48
+
49
+ const CONTEXT_PANEL_COOKIE_NAME = "context_panel_state";
50
+ const CONTEXT_PANEL_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
51
+ const CONTEXT_PANEL_WIDTH = "20rem";
52
+ const CONTEXT_PANEL_WIDTH_MOBILE = "20rem";
53
+
54
+ /** Closed asset-type enum shared by the tree (`ProducedAssetTree`) and the preview (`AssetPreview`). */
55
+ export type ContextAssetType = "markdown" | "code" | "sql" | "csv" | "image";
56
+
57
+ /** One produced asset — the unit the drill-in focuses (research 09 §B.1). */
58
+ export interface ContextAsset {
59
+ id: string;
60
+ name: string;
61
+ path?: string;
62
+ type: ContextAssetType;
63
+ /** Inline content: source text — or, for `image`, the image src URL. */
64
+ content?: string;
65
+ /** Optional Shiki language for `code` assets (defaults to "typescript"). */
66
+ language?: string;
67
+ /**
68
+ * The file itself, when the asset IS one — a `File`, a `Blob`, a URL, a
69
+ * buffer. Carried so a `renderPreview` injection (below) can hand it to
70
+ * something that opens files, such as `@elabs-ai/components-viewer`'s
71
+ * `FileViewer`, without the asset first being turned into a string.
72
+ *
73
+ * `ContextAssetType` is deliberately NOT widened to cover PDFs, Office files
74
+ * or video: this union describes what THIS package can draw, and every format
75
+ * beyond it arrives through the injection seam rather than by growing a case
76
+ * per format here (ADR 0024 §6).
77
+ */
78
+ source?: FileSource;
79
+ /** MIME type, when known — helps a renderer that detects by media type. */
80
+ mediaType?: string;
81
+ }
82
+
83
+ /**
84
+ * Draw the body of an asset preview.
85
+ *
86
+ * Return `null` to decline and let `AssetPreview` fall through to its own
87
+ * type-keyed rendering, which is what makes this additive: an injection that
88
+ * handles PDFs only leaves markdown, code and CSV exactly as they were.
89
+ */
90
+ export type AssetPreviewRenderer = (asset: ContextAsset) => ReactNode | null;
91
+
92
+ export type ContextPanelView = "root" | "detail";
93
+
94
+ export interface ContextPanelContextValue {
95
+ /** Lifted state (doc-13 `state` / `actions` / `meta` interface). */
96
+ state: {
97
+ open: boolean;
98
+ view: ContextPanelView;
99
+ selectedAsset: ContextAsset | null;
100
+ };
101
+ actions: {
102
+ /** Toggle the panel (the inline collapse on desktop, the Sheet on mobile). */
103
+ toggle: () => void;
104
+ setOpen: (open: boolean | ((open: boolean) => boolean)) => void;
105
+ /** Drill into one asset (root → detail). Focus moves to the BACK control. */
106
+ openDetail: (asset: ContextAsset) => void;
107
+ /** Return to the root view; focus is restored to the originating row. */
108
+ back: () => void;
109
+ };
110
+ meta: {
111
+ isMobile: boolean;
112
+ openMobile: boolean;
113
+ setOpenMobile: (open: boolean) => void;
114
+ /**
115
+ * The injected preview renderer, if the provider was given one.
116
+ * `AssetPreview` reads it from here so a page can teach the whole rail a
117
+ * new format in one place instead of at every call site.
118
+ */
119
+ renderPreview?: AssetPreviewRenderer;
120
+ };
121
+ }
122
+
123
+ const ContextPanelContext = createContext<ContextPanelContextValue | null>(null);
124
+
125
+ interface ContextPanelInternalValue {
126
+ panelId: string;
127
+ titleId: string;
128
+ /** The element focus moves to on `openDetail` (the BACK control). */
129
+ detailFocusRef: RefObject<HTMLElement | null>;
130
+ }
131
+
132
+ const ContextPanelInternalContext = createContext<ContextPanelInternalValue | null>(null);
133
+
134
+ export function useContextPanel(): ContextPanelContextValue {
135
+ const context = use(ContextPanelContext);
136
+ if (!context) throw new Error("useContextPanel must be used within a ContextPanelProvider.");
137
+ return context;
138
+ }
139
+
140
+ /**
141
+ * The renderer injected into the surrounding `ContextPanelProvider`, if any.
142
+ *
143
+ * Unlike `useContextPanel()` this does NOT throw outside a provider:
144
+ * `AssetPreview` is usable on its own (a preview pane in a page, a story), and
145
+ * "no provider" simply means nobody injected anything.
146
+ */
147
+ export function useAssetPreviewRenderer(): AssetPreviewRenderer | undefined {
148
+ return use(ContextPanelContext)?.meta.renderPreview;
149
+ }
150
+
151
+ function useContextPanelInternal(): ContextPanelInternalValue {
152
+ const context = use(ContextPanelInternalContext);
153
+ if (!context) {
154
+ throw new Error("ContextPanel components must be used within a ContextPanelProvider.");
155
+ }
156
+ return context;
157
+ }
158
+
159
+ export interface ContextPanelProviderProps {
160
+ children: ReactNode;
161
+ /** Uncontrolled initial open state. Default `true`. */
162
+ defaultOpen?: boolean;
163
+ /** Controlled open state (`isControlled = open !== undefined`, never flips). */
164
+ open?: boolean;
165
+ onOpenChange?: (open: boolean) => void;
166
+ /** Controlled view (root ↔ detail). */
167
+ view?: ContextPanelView;
168
+ onViewChange?: (view: ContextPanelView) => void;
169
+ /** Uncontrolled initial view. Default `"root"`. */
170
+ defaultView?: ContextPanelView;
171
+ /** Controlled selected asset. */
172
+ selectedAsset?: ContextAsset | null;
173
+ onSelectedAssetChange?: (asset: ContextAsset | null) => void;
174
+ /** Uncontrolled initial selected asset. Default `null`. */
175
+ defaultSelectedAsset?: ContextAsset | null;
176
+ /**
177
+ * Draw an asset's preview body yourself.
178
+ *
179
+ * The seam that lets this package show formats it cannot parse — a PDF, a
180
+ * spreadsheet, a video — without importing a domain sibling
181
+ * (`@elabs-ai/components-ai` and
182
+ * `@elabs-ai/components-viewer` are peers in the layer graph and
183
+ * may never import each other). The consuming app owns the edge:
184
+ *
185
+ * ```tsx
186
+ * <ContextPanelProvider
187
+ * renderPreview={(asset) =>
188
+ * asset.source ? <FileViewer source={asset.source} /> : null
189
+ * }
190
+ * >
191
+ * ```
192
+ *
193
+ * Return `null` to decline, and the built-in type-keyed rendering runs
194
+ * exactly as before — so an injection is additive, never a replacement.
195
+ */
196
+ renderPreview?: AssetPreviewRenderer;
197
+ }
198
+
199
+ /**
200
+ * Lifts the panel state above the workspace so siblings outside the panel's
201
+ * frame (the app-header trigger) can read/drive it — no prop-drilling, no
202
+ * page-level `useState`, no conditional mount (research 09 §B.1/§C.2).
203
+ */
204
+ export function ContextPanelProvider({
205
+ children,
206
+ defaultOpen = true,
207
+ open: openProp,
208
+ onOpenChange,
209
+ view: viewProp,
210
+ onViewChange,
211
+ defaultView = "root",
212
+ selectedAsset: selectedAssetProp,
213
+ onSelectedAssetChange,
214
+ defaultSelectedAsset = null,
215
+ renderPreview,
216
+ }: ContextPanelProviderProps) {
217
+ const isMobile = useIsMobile();
218
+ const [openMobile, setOpenMobile] = useState(false);
219
+
220
+ // Controlled/uncontrolled per component-api.md: controlled iff the prop is
221
+ // provided; the mode never flips mid-life.
222
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
223
+ const openControlled = openProp !== undefined;
224
+ const open = openControlled ? openProp : uncontrolledOpen;
225
+
226
+ const [uncontrolledView, setUncontrolledView] = useState<ContextPanelView>(defaultView);
227
+ const viewControlled = viewProp !== undefined;
228
+ const view = viewControlled ? viewProp : uncontrolledView;
229
+
230
+ const [uncontrolledAsset, setUncontrolledAsset] = useState<ContextAsset | null>(
231
+ defaultSelectedAsset,
232
+ );
233
+ const assetControlled = selectedAssetProp !== undefined;
234
+ const selectedAsset = assetControlled ? selectedAssetProp : uncontrolledAsset;
235
+
236
+ const panelId = useId();
237
+ const titleId = useId();
238
+ /** Where focus returns on `back()` — the originating tree row (09 §B.5). */
239
+ const returnFocusRef = useRef<HTMLElement | null>(null);
240
+ /** Where focus lands on `openDetail()` — the BACK control in the header. */
241
+ const detailFocusRef = useRef<HTMLElement | null>(null);
242
+
243
+ const setOpen = useCallback(
244
+ (value: boolean | ((open: boolean) => boolean)) => {
245
+ const next = typeof value === "function" ? value(open) : value;
246
+ if (!openControlled) setUncontrolledOpen(next);
247
+ onOpenChange?.(next);
248
+ // Persistence: the SidebarProvider cookie pattern (research 09 §B.1).
249
+ if (typeof document !== "undefined") {
250
+ document.cookie = `${CONTEXT_PANEL_COOKIE_NAME}=${next}; path=/; max-age=${CONTEXT_PANEL_COOKIE_MAX_AGE}`;
251
+ }
252
+ },
253
+ [onOpenChange, open, openControlled],
254
+ );
255
+
256
+ const toggle = useCallback(() => {
257
+ if (isMobile) setOpenMobile((o) => !o);
258
+ else setOpen((o) => !o);
259
+ }, [isMobile, setOpen]);
260
+
261
+ const setView = useCallback(
262
+ (next: ContextPanelView) => {
263
+ if (!viewControlled) setUncontrolledView(next);
264
+ onViewChange?.(next);
265
+ },
266
+ [onViewChange, viewControlled],
267
+ );
268
+
269
+ const setSelectedAsset = useCallback(
270
+ (next: ContextAsset | null) => {
271
+ if (!assetControlled) setUncontrolledAsset(next);
272
+ onSelectedAssetChange?.(next);
273
+ },
274
+ [assetControlled, onSelectedAssetChange],
275
+ );
276
+
277
+ const openDetail = useCallback(
278
+ (asset: ContextAsset) => {
279
+ // Drilling while collapsed auto-opens first (research 09 §G.1).
280
+ if (isMobile) setOpenMobile(true);
281
+ else if (!open) setOpen(true);
282
+ // Stash the originating row so back() can restore focus (09 §B.5).
283
+ if (typeof document !== "undefined" && document.activeElement instanceof HTMLElement) {
284
+ returnFocusRef.current = document.activeElement;
285
+ }
286
+ // The v2 `useViewTransition` seam: this state change is the `mutate`
287
+ // callback a future vt.run({ recipe: "nav-forward" }) will wrap (09 §B.3).
288
+ setView("detail");
289
+ setSelectedAsset(asset);
290
+ // Focus management is independent of the animation (VT guardrail §8):
291
+ // land on the BACK control after the swap, never on transition end.
292
+ requestAnimationFrame(() => detailFocusRef.current?.focus());
293
+ },
294
+ [isMobile, open, setOpen, setSelectedAsset, setView],
295
+ );
296
+
297
+ const back = useCallback(() => {
298
+ // The v2 seam: the `mutate` a future vt.run({ recipe: "nav-back" }) wraps.
299
+ setView("root");
300
+ requestAnimationFrame(() => {
301
+ const target = returnFocusRef.current;
302
+ if (target?.isConnected) target.focus();
303
+ });
304
+ }, [setView]);
305
+
306
+ const contextValue = useMemo<ContextPanelContextValue>(
307
+ () => ({
308
+ state: { open, view, selectedAsset },
309
+ actions: { toggle, setOpen, openDetail, back },
310
+ meta: { isMobile, openMobile, setOpenMobile, renderPreview },
311
+ }),
312
+ [
313
+ back,
314
+ isMobile,
315
+ open,
316
+ openDetail,
317
+ openMobile,
318
+ renderPreview,
319
+ selectedAsset,
320
+ setOpen,
321
+ toggle,
322
+ view,
323
+ ],
324
+ );
325
+
326
+ const internalValue = useMemo<ContextPanelInternalValue>(
327
+ () => ({ panelId, titleId, detailFocusRef }),
328
+ [panelId, titleId],
329
+ );
330
+
331
+ return (
332
+ <ContextPanelContext.Provider value={contextValue}>
333
+ <ContextPanelInternalContext.Provider value={internalValue}>
334
+ {children}
335
+ </ContextPanelInternalContext.Provider>
336
+ </ContextPanelContext.Provider>
337
+ );
338
+ }
339
+
340
+ export interface ContextPanelProps extends ComponentProps<"div"> {
341
+ /** Expanded panel width (any CSS length). Default `"20rem"`. */
342
+ width?: string;
343
+ }
344
+
345
+ /**
346
+ * The always-mounted shell. On desktop it animates its OWN width via the
347
+ * canonical `useCollapsiblePanel` two-element tween — bounded to the SHELL
348
+ * (a relative group + absolute container), not the viewport (09 §B.2). Below
349
+ * the mobile breakpoint it renders a `Sheet side="right"` with the same
350
+ * children, driven by the same provider (09 §B.4).
351
+ */
352
+ export const ContextPanel = forwardRef<HTMLDivElement, ContextPanelProps>(function ContextPanel(
353
+ { width = CONTEXT_PANEL_WIDTH, className, style, children, ...props },
354
+ ref,
355
+ ) {
356
+ const { state, actions, meta } = useContextPanel();
357
+ const { panelId } = useContextPanelInternal();
358
+
359
+ const panel = useCollapsiblePanel({
360
+ side: "right",
361
+ open: state.open,
362
+ onOpenChange: actions.setOpen,
363
+ widthClassName: "w-(--context-panel-width)",
364
+ spacerCollapsedClassName: "group-data-[state=collapsed]:w-0",
365
+ containerSlideClassNames: {
366
+ left: "left-0 group-data-[state=collapsed]:left-[calc(var(--context-panel-width)*-1)]",
367
+ right: "right-0 group-data-[state=collapsed]:right-[calc(var(--context-panel-width)*-1)]",
368
+ },
369
+ });
370
+
371
+ if (meta.isMobile) {
372
+ return (
373
+ <Sheet open={meta.openMobile} onOpenChange={meta.setOpenMobile}>
374
+ <SheetContent
375
+ id={panelId}
376
+ side="right"
377
+ data-slot="context-panel"
378
+ data-mobile="true"
379
+ className="w-(--context-panel-width) gap-0 p-0"
380
+ style={{ "--context-panel-width": CONTEXT_PANEL_WIDTH_MOBILE } as CSSProperties}
381
+ >
382
+ <SheetHeader className="sr-only">
383
+ <SheetTitle>Context panel</SheetTitle>
384
+ <SheetDescription>Displays the chat context panel.</SheetDescription>
385
+ </SheetHeader>
386
+ <div className="flex h-full w-full flex-col">{children}</div>
387
+ </SheetContent>
388
+ </Sheet>
389
+ );
390
+ }
391
+
392
+ return (
393
+ <div
394
+ ref={ref}
395
+ id={panelId}
396
+ data-slot="context-panel"
397
+ // Collapsed container is inert (09 §B.5 — Sidebar gets this free via
398
+ // `hidden`; the right rail must do it explicitly).
399
+ inert={state.open ? undefined : true}
400
+ className={cn("group relative hidden overflow-hidden md:block", className)}
401
+ style={{ "--context-panel-width": width, ...style } as CSSProperties}
402
+ {...panel.attrs}
403
+ {...props}
404
+ >
405
+ <div data-slot="context-panel-gap" className={panel.spacerClassName} />
406
+ <div
407
+ data-slot="context-panel-container"
408
+ // The deliberate Sidebar divergence (09 §B.2): bound to the shell, not
409
+ // the viewport — `absolute` within the relative group, full height.
410
+ className={cn(panel.containerClassName, "absolute inset-y-0 flex h-full border-s")}
411
+ >
412
+ {/* Detail-tier surface: raised `card`, robust across themes (#193). */}
413
+ <div data-slot="context-panel-inner" className="flex h-full w-full flex-col bg-card">
414
+ {children}
415
+ </div>
416
+ </div>
417
+ </div>
418
+ );
419
+ });
420
+
421
+ export type ContextPanelTriggerProps = ComponentProps<typeof Button>;
422
+
423
+ /** Toggle button — placeable ANYWHERE inside the provider (e.g. the app header). */
424
+ export const ContextPanelTrigger = forwardRef<HTMLButtonElement, ContextPanelTriggerProps>(
425
+ function ContextPanelTrigger({ className, onClick, children, ...props }, ref) {
426
+ const { t } = useLocale();
427
+ const { state, actions, meta } = useContextPanel();
428
+ const { panelId } = useContextPanelInternal();
429
+ const expanded = meta.isMobile ? meta.openMobile : state.open;
430
+ return (
431
+ <Button
432
+ ref={ref}
433
+ data-slot="context-panel-trigger"
434
+ variant="ghost"
435
+ size="icon"
436
+ aria-expanded={expanded}
437
+ aria-controls={panelId}
438
+ aria-label={t("ai.contextPanel.toggle")}
439
+ className={cn("size-7", className)}
440
+ onClick={(event) => {
441
+ onClick?.(event);
442
+ actions.toggle();
443
+ }}
444
+ {...props}
445
+ >
446
+ {children ?? <PanelRightIcon aria-hidden="true" />}
447
+ </Button>
448
+ );
449
+ },
450
+ );
451
+
452
+ export interface ContextPanelHeaderProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
453
+ /** Root-view title. Default `"Context"`. */
454
+ title?: ReactNode;
455
+ }
456
+
457
+ /**
458
+ * Title row. In the detail view it hosts the BACK control (a real button —
459
+ * "tab-panel swap", not a breadcrumb) and shows the focused asset's name.
460
+ */
461
+ export const ContextPanelHeader = forwardRef<HTMLDivElement, ContextPanelHeaderProps>(
462
+ function ContextPanelHeader({ title = "Context", className, children, ...props }, ref) {
463
+ const { t } = useLocale();
464
+ const { state, actions } = useContextPanel();
465
+ const { titleId, detailFocusRef } = useContextPanelInternal();
466
+ const isDetail = state.view === "detail";
467
+ return (
468
+ <div
469
+ ref={ref}
470
+ data-slot="context-panel-header"
471
+ className={cn("flex h-12 shrink-0 items-center gap-2 border-b px-3", className)}
472
+ {...props}
473
+ >
474
+ {isDetail ? (
475
+ <Button
476
+ ref={(node) => {
477
+ detailFocusRef.current = node;
478
+ }}
479
+ type="button"
480
+ variant="ghost"
481
+ size="sm"
482
+ data-slot="context-panel-back"
483
+ aria-label={t("ai.contextPanel.back")}
484
+ className="gap-1 px-2 text-muted-foreground"
485
+ onClick={() => actions.back()}
486
+ >
487
+ <ChevronLeftIcon className="size-4" aria-hidden="true" data-rtl-flip />
488
+ Back
489
+ </Button>
490
+ ) : null}
491
+ <h2 id={titleId} className="min-w-0 truncate text-title">
492
+ {isDetail ? (state.selectedAsset?.name ?? title) : title}
493
+ </h2>
494
+ {children ? <div className="ms-auto flex items-center gap-1">{children}</div> : null}
495
+ </div>
496
+ );
497
+ },
498
+ );
499
+
500
+ export interface ContextPanelBodyProps extends HTMLAttributes<HTMLDivElement> {
501
+ /** Root view — the labelled sections (status / grounding / produced assets). */
502
+ root: ReactNode;
503
+ /** Detail view — typically `<ContextPanelDetail><AssetPreview …/></ContextPanelDetail>`. */
504
+ detail: ReactNode;
505
+ /** Accessible name of the region. Default `"Context"`. */
506
+ label?: string;
507
+ }
508
+
509
+ /**
510
+ * The two-view container that owns the drill-in. v1: a CSS-gated two-pane
511
+ * translateX track (both panes mounted, transform-only, `duration-base` +
512
+ * explicit `motion-reduce:` neutralizer). The off-screen pane is `aria-hidden`
513
+ * AND `inert` so Tab can't land in it mid-slide (09 §B.3/§B.5). The panes'
514
+ * `data-vt-pane` identities are the v2 `useViewTransition` seam.
515
+ */
516
+ export const ContextPanelBody = forwardRef<HTMLDivElement, ContextPanelBodyProps>(
517
+ function ContextPanelBody({ root, detail, label = "Context", className, ...props }, ref) {
518
+ const { state } = useContextPanel();
519
+ const isDetail = state.view === "detail";
520
+
521
+ // Announce, don't steal focus mid-animation (09 §B.5). Skip the initial view.
522
+ const [announcement, setAnnouncement] = useState("");
523
+ const previousView = useRef(state.view);
524
+ useEffect(() => {
525
+ if (previousView.current === state.view) return;
526
+ previousView.current = state.view;
527
+ setAnnouncement(
528
+ state.view === "detail"
529
+ ? `Showing ${state.selectedAsset?.name ?? "asset detail"}`
530
+ : "Back to context",
531
+ );
532
+ }, [state.selectedAsset, state.view]);
533
+
534
+ return (
535
+ <div
536
+ ref={ref}
537
+ role="region"
538
+ aria-label={label}
539
+ data-slot="context-panel-body"
540
+ className={cn("relative min-h-0 flex-1 overflow-hidden", className)}
541
+ {...props}
542
+ >
543
+ <div aria-live="polite" className="sr-only">
544
+ {announcement}
545
+ </div>
546
+ <div
547
+ data-slot="context-panel-track"
548
+ className="flex h-full w-[200%] transition-transform duration-base ease-standard motion-reduce:transition-none"
549
+ style={{ transform: isDetail ? "translateX(-50%)" : "translateX(0)" }}
550
+ >
551
+ <div
552
+ data-vt-pane="root"
553
+ aria-hidden={isDetail}
554
+ inert={isDetail ? true : undefined}
555
+ className="flex h-full w-1/2 flex-col gap-5 overflow-y-auto p-4"
556
+ >
557
+ {root}
558
+ </div>
559
+ <div
560
+ data-vt-pane="detail"
561
+ aria-hidden={!isDetail}
562
+ inert={isDetail ? undefined : true}
563
+ className="h-full w-1/2 overflow-y-auto"
564
+ >
565
+ {detail}
566
+ </div>
567
+ </div>
568
+ </div>
569
+ );
570
+ },
571
+ );
572
+
573
+ export interface ContextPanelSectionProps extends HTMLAttributes<HTMLElement> {
574
+ /** Section label — a quiet `text-meta` heading, not a box (research 08). */
575
+ label?: ReactNode;
576
+ }
577
+
578
+ /** A labelled root-view section (status / grounding / produced assets). */
579
+ export const ContextPanelSection = forwardRef<HTMLElement, ContextPanelSectionProps>(
580
+ function ContextPanelSection({ label, className, children, ...props }, ref) {
581
+ return (
582
+ <section
583
+ ref={ref}
584
+ data-slot="context-panel-section"
585
+ className={cn("flex shrink-0 flex-col gap-2", className)}
586
+ {...props}
587
+ >
588
+ {label ? <h3 className="text-meta text-muted-foreground">{label}</h3> : null}
589
+ {children}
590
+ </section>
591
+ );
592
+ },
593
+ );
594
+
595
+ export type ContextPanelDetailProps = HTMLAttributes<HTMLDivElement>;
596
+
597
+ /** The focused single-asset view — hosts `<AssetPreview>` (research 04 §5). */
598
+ export const ContextPanelDetail = forwardRef<HTMLDivElement, ContextPanelDetailProps>(
599
+ function ContextPanelDetail({ className, ...props }, ref) {
600
+ return (
601
+ <div
602
+ ref={ref}
603
+ data-slot="context-panel-detail"
604
+ className={cn("flex min-h-full flex-col p-4", className)}
605
+ {...props}
606
+ />
607
+ );
608
+ },
609
+ );
@@ -0,0 +1,16 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { Context, ContextTrigger } from "./context";
3
+ const meta = {
4
+ title: "AI/Context",
5
+ component: Context,
6
+ parameters: { layout: "padded" },
7
+ } satisfies Meta<typeof Context>;
8
+ export default meta;
9
+ type Story = StoryObj<typeof meta>;
10
+ export const Default: Story = {
11
+ render: () => (
12
+ <Context usedTokens={2400} maxTokens={8000}>
13
+ <ContextTrigger />
14
+ </Context>
15
+ ),
16
+ };