@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.
- package/LICENSE +21 -0
- package/README.md +133 -0
- package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
- package/dist/_flow-boundary-D63PJ65S.js +186 -0
- package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
- package/dist/_persona-rive-RFR2EUWP.js +164 -0
- package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
- package/dist/index.d.ts +4079 -0
- package/dist/index.js +11770 -0
- package/dist/index.js.map +1 -0
- package/package.json +87 -0
- package/src/_audio-player-media-chrome.tsx +146 -0
- package/src/_chat-shell-rail.tsx +18 -0
- package/src/_code-block-theme.test.ts +134 -0
- package/src/_code-block-theme.ts +184 -0
- package/src/_flow-boundary.tsx +237 -0
- package/src/_flow-lazy.ts +34 -0
- package/src/_interactive-terminal-xterm.ts +32 -0
- package/src/_lazy-mermaid.test.ts +101 -0
- package/src/_lazy-mermaid.ts +117 -0
- package/src/_persona-rive.tsx +252 -0
- package/src/_streamdown-i18n.ts +119 -0
- package/src/agent-timeline.stories.tsx +66 -0
- package/src/agent-timeline.test.tsx +74 -0
- package/src/agent-timeline.tsx +106 -0
- package/src/agent.tsx +113 -0
- package/src/agentic-workspace.stories.tsx +1099 -0
- package/src/artifact.stories.tsx +62 -0
- package/src/artifact.test.tsx +112 -0
- package/src/artifact.tsx +167 -0
- package/src/asset-preview.stories.tsx +100 -0
- package/src/asset-preview.test.tsx +126 -0
- package/src/asset-preview.tsx +218 -0
- package/src/attachments.tsx +393 -0
- package/src/audio-player.stories.tsx +99 -0
- package/src/audio-player.tsx +170 -0
- package/src/blocks-ai-composer.stories.tsx +83 -0
- package/src/canvas.stories.tsx +142 -0
- package/src/canvas.tsx +29 -0
- package/src/chain-of-thought.tsx +193 -0
- package/src/chat-greeting.stories.tsx +96 -0
- package/src/chat-greeting.test.tsx +91 -0
- package/src/chat-greeting.tsx +91 -0
- package/src/chat-shell.stories.tsx +123 -0
- package/src/chat-shell.test.tsx +113 -0
- package/src/chat-shell.tsx +97 -0
- package/src/chat.stories.tsx +99 -0
- package/src/checkpoint.tsx +54 -0
- package/src/code-block.stories.tsx +81 -0
- package/src/code-block.test.tsx +191 -0
- package/src/code-block.tsx +673 -0
- package/src/commit.tsx +343 -0
- package/src/composer.stories.tsx +188 -0
- package/src/composer.test.tsx +182 -0
- package/src/composer.tsx +202 -0
- package/src/confirmation.stories.tsx +61 -0
- package/src/confirmation.test.tsx +136 -0
- package/src/confirmation.tsx +273 -0
- package/src/connection.tsx +23 -0
- package/src/context-panel.stories.tsx +137 -0
- package/src/context-panel.test.tsx +198 -0
- package/src/context-panel.tsx +609 -0
- package/src/context.stories.tsx +16 -0
- package/src/context.tsx +363 -0
- package/src/controls.tsx +20 -0
- package/src/conversation.stories.tsx +26 -0
- package/src/conversation.test.tsx +123 -0
- package/src/conversation.tsx +143 -0
- package/src/dark-theme-variant.test.ts +47 -0
- package/src/edge.tsx +33 -0
- package/src/environment-variables.tsx +304 -0
- package/src/file-tree.stories.tsx +55 -0
- package/src/file-tree.test.tsx +101 -0
- package/src/file-tree.tsx +390 -0
- package/src/gallery.stories.tsx +182 -0
- package/src/gallery.test.tsx +150 -0
- package/src/gallery.tsx +737 -0
- package/src/grouped-parts.stories.tsx +104 -0
- package/src/grouped-parts.test.tsx +167 -0
- package/src/grouped-parts.tsx +247 -0
- package/src/image.stories.tsx +67 -0
- package/src/image.test.tsx +105 -0
- package/src/image.tsx +115 -0
- package/src/index.ts +93 -0
- package/src/inline-citation.stories.tsx +91 -0
- package/src/inline-citation.test.tsx +96 -0
- package/src/inline-citation.tsx +314 -0
- package/src/interactive-terminal.stories.tsx +165 -0
- package/src/interactive-terminal.test.tsx +448 -0
- package/src/interactive-terminal.tsx +444 -0
- package/src/jsx-preview.stories.tsx +265 -0
- package/src/jsx-preview.test.tsx +75 -0
- package/src/jsx-preview.tsx +409 -0
- package/src/markdown-view.stories.tsx +57 -0
- package/src/markdown-view.test.tsx +36 -0
- package/src/markdown-view.tsx +113 -0
- package/src/message-edit.stories.tsx +150 -0
- package/src/message-edit.test.tsx +115 -0
- package/src/message-edit.tsx +336 -0
- package/src/message-feedback.stories.tsx +53 -0
- package/src/message-feedback.test.tsx +62 -0
- package/src/message-feedback.tsx +117 -0
- package/src/message-form-spec.ts +349 -0
- package/src/message-form.stories.tsx +182 -0
- package/src/message-form.test.tsx +227 -0
- package/src/message-form.tsx +845 -0
- package/src/message-table-spec.ts +281 -0
- package/src/message-table.stories.tsx +176 -0
- package/src/message-table.test.tsx +144 -0
- package/src/message-table.tsx +366 -0
- package/src/message.stories.tsx +262 -0
- package/src/message.test.tsx +508 -0
- package/src/message.tsx +619 -0
- package/src/mic-selector.tsx +339 -0
- package/src/microcopy.test.tsx +123 -0
- package/src/model-selector.stories.tsx +73 -0
- package/src/model-selector.test.tsx +55 -0
- package/src/model-selector.tsx +225 -0
- package/src/motion-config.tsx +49 -0
- package/src/node.tsx +71 -0
- package/src/open-in-chat.tsx +340 -0
- package/src/package-info.tsx +205 -0
- package/src/panel.tsx +20 -0
- package/src/part-groups.ts +258 -0
- package/src/persona-sources.ts +59 -0
- package/src/persona.stories.tsx +111 -0
- package/src/persona.test.tsx +67 -0
- package/src/persona.tsx +148 -0
- package/src/plan.tsx +135 -0
- package/src/prompt-input.stories.tsx +261 -0
- package/src/prompt-input.test.tsx +392 -0
- package/src/prompt-input.tsx +1668 -0
- package/src/queue.tsx +237 -0
- package/src/reasoning.stories.tsx +81 -0
- package/src/reasoning.test.tsx +50 -0
- package/src/reasoning.tsx +229 -0
- package/src/sandbox.stories.tsx +62 -0
- package/src/sandbox.test.tsx +42 -0
- package/src/sandbox.tsx +149 -0
- package/src/schema-display.tsx +412 -0
- package/src/selection-toolbar.stories.tsx +86 -0
- package/src/selection-toolbar.test.tsx +89 -0
- package/src/selection-toolbar.tsx +192 -0
- package/src/shimmer.stories.tsx +10 -0
- package/src/shimmer.tsx +81 -0
- package/src/snippet.stories.tsx +19 -0
- package/src/snippet.test.tsx +116 -0
- package/src/snippet.tsx +138 -0
- package/src/sources.stories.tsx +34 -0
- package/src/sources.test.tsx +129 -0
- package/src/sources.tsx +93 -0
- package/src/speech-input.tsx +312 -0
- package/src/stack-trace.tsx +479 -0
- package/src/streamdown-i18n.test.tsx +163 -0
- package/src/suggestion.stories.tsx +75 -0
- package/src/suggestion.test.tsx +72 -0
- package/src/suggestion.tsx +120 -0
- package/src/task.stories.tsx +22 -0
- package/src/task.test.tsx +36 -0
- package/src/task.tsx +89 -0
- package/src/templates-ai-assistant.stories.tsx +142 -0
- package/src/terminal.tsx +244 -0
- package/src/test-results.stories.tsx +177 -0
- package/src/test-results.test.tsx +241 -0
- package/src/test-results.tsx +408 -0
- package/src/tool-result-card.stories.tsx +143 -0
- package/src/tool-result-card.test.tsx +72 -0
- package/src/tool-result-card.tsx +86 -0
- package/src/tool.stories.tsx +60 -0
- package/src/tool.test.tsx +63 -0
- package/src/tool.tsx +235 -0
- package/src/toolbar.tsx +44 -0
- package/src/transcription.tsx +118 -0
- package/src/voice-selector.tsx +469 -0
- package/src/web-preview.stories.tsx +67 -0
- package/src/web-preview.test.tsx +117 -0
- 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
|
+
};
|