@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,273 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Alert, AlertDescription, AlertTitle, StatusBadge } from "@elabs-ai/components-ui";
|
|
4
|
+
import { Button } from "@elabs-ai/components-ui";
|
|
5
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
6
|
+
import type { ToolUIPart } from "ai";
|
|
7
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
8
|
+
import { createContext, useContext, useId, useMemo } from "react";
|
|
9
|
+
|
|
10
|
+
type ToolUIPartApproval =
|
|
11
|
+
| {
|
|
12
|
+
id: string;
|
|
13
|
+
approved?: never;
|
|
14
|
+
reason?: never;
|
|
15
|
+
}
|
|
16
|
+
| {
|
|
17
|
+
id: string;
|
|
18
|
+
approved: boolean;
|
|
19
|
+
reason?: string;
|
|
20
|
+
}
|
|
21
|
+
| {
|
|
22
|
+
id: string;
|
|
23
|
+
approved: true;
|
|
24
|
+
reason?: string;
|
|
25
|
+
}
|
|
26
|
+
| {
|
|
27
|
+
id: string;
|
|
28
|
+
approved: false;
|
|
29
|
+
reason?: string;
|
|
30
|
+
}
|
|
31
|
+
| undefined;
|
|
32
|
+
|
|
33
|
+
interface ConfirmationContextValue {
|
|
34
|
+
approval: ToolUIPartApproval;
|
|
35
|
+
state: ToolUIPart["state"];
|
|
36
|
+
/** Default id wired from the card's `aria-labelledby` to `ConfirmationTitle`. */
|
|
37
|
+
titleId: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ConfirmationContext = createContext<ConfirmationContextValue | null>(null);
|
|
41
|
+
|
|
42
|
+
const useConfirmation = () => {
|
|
43
|
+
const context = useContext(ConfirmationContext);
|
|
44
|
+
|
|
45
|
+
if (!context) {
|
|
46
|
+
throw new Error("Confirmation components must be used within Confirmation");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return context;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type ConfirmationProps = ComponentProps<typeof Alert> & {
|
|
53
|
+
approval?: ToolUIPartApproval;
|
|
54
|
+
state: ToolUIPart["state"];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const Confirmation = ({
|
|
58
|
+
className,
|
|
59
|
+
approval,
|
|
60
|
+
state,
|
|
61
|
+
variant,
|
|
62
|
+
role,
|
|
63
|
+
children,
|
|
64
|
+
...props
|
|
65
|
+
}: ConfirmationProps) => {
|
|
66
|
+
const titleId = useId();
|
|
67
|
+
const contextValue = useMemo(() => ({ approval, state, titleId }), [approval, state, titleId]);
|
|
68
|
+
|
|
69
|
+
if (!approval || state === "input-streaming" || state === "input-available") {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const isResolved =
|
|
74
|
+
state === "approval-responded" || state === "output-denied" || state === "output-available";
|
|
75
|
+
const isPending = state === "approval-requested";
|
|
76
|
+
const approved = isResolved ? approval.approved : undefined;
|
|
77
|
+
|
|
78
|
+
// QUIET-RAIL treatment (supersedes research-11's warning/status WASH — a
|
|
79
|
+
// deliberate design-authority decision to drop the filled "AI card" look):
|
|
80
|
+
// a neutral card ground (Alert variant="default") + ONE accent rail encoding
|
|
81
|
+
// the outcome, plus the StatusBadge (icon + label, never color alone). The
|
|
82
|
+
// rails are hue-independent enough to stay legible at high decoration / high-contrast.
|
|
83
|
+
// A caller-supplied `variant` still wins (escape hatch).
|
|
84
|
+
const railClass =
|
|
85
|
+
approved === true
|
|
86
|
+
? "border-s-success"
|
|
87
|
+
: approved === false
|
|
88
|
+
? "border-s-destructive"
|
|
89
|
+
: isPending
|
|
90
|
+
? "border-s-border-strong"
|
|
91
|
+
: "border-s-border";
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<ConfirmationContext.Provider value={contextValue}>
|
|
95
|
+
<Alert
|
|
96
|
+
variant={variant ?? "default"}
|
|
97
|
+
// A pending decision containing focusable controls is a labelled
|
|
98
|
+
// region, NOT an assertive live region (research 11 §B.3): pending =
|
|
99
|
+
// role="group" + aria-labelledby; resolved keeps role="alert".
|
|
100
|
+
role={role ?? (isPending ? "group" : "alert")}
|
|
101
|
+
aria-labelledby={isPending ? titleId : undefined}
|
|
102
|
+
className={cn(
|
|
103
|
+
"flex flex-col gap-2 border-s-4 transition-colors duration-fast",
|
|
104
|
+
railClass,
|
|
105
|
+
isPending && "shadow-sm",
|
|
106
|
+
className,
|
|
107
|
+
)}
|
|
108
|
+
{...props}
|
|
109
|
+
>
|
|
110
|
+
{/* The status line is the canonical StatusBadge (#189, research 10 §B.1). */}
|
|
111
|
+
{approved === true ? (
|
|
112
|
+
<StatusBadge
|
|
113
|
+
className="animate-in fade-in-0 self-start duration-base ease-entrance"
|
|
114
|
+
status="complete"
|
|
115
|
+
>
|
|
116
|
+
Approved
|
|
117
|
+
</StatusBadge>
|
|
118
|
+
) : null}
|
|
119
|
+
{approved === false ? (
|
|
120
|
+
<StatusBadge
|
|
121
|
+
className="animate-in fade-in-0 self-start duration-base ease-entrance"
|
|
122
|
+
status="denied"
|
|
123
|
+
>
|
|
124
|
+
Denied
|
|
125
|
+
</StatusBadge>
|
|
126
|
+
) : null}
|
|
127
|
+
{children}
|
|
128
|
+
</Alert>
|
|
129
|
+
</ConfirmationContext.Provider>
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export type ConfirmationTitleProps = ComponentProps<typeof AlertTitle>;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The question zone (research 11 §B.3 APPROVE-3) — out-ranks the surrounding
|
|
137
|
+
* `text-body` and names the pending region via the card's `aria-labelledby`.
|
|
138
|
+
*/
|
|
139
|
+
export const ConfirmationTitle = ({ className, id, ...props }: ConfirmationTitleProps) => {
|
|
140
|
+
const { titleId } = useConfirmation();
|
|
141
|
+
return <AlertTitle id={id ?? titleId} className={cn("text-subtitle", className)} {...props} />;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export type ConfirmationDescriptionProps = ComponentProps<typeof AlertDescription>;
|
|
145
|
+
|
|
146
|
+
/** The consequence zone — what approving will actually do (research 11 §B.3). */
|
|
147
|
+
export const ConfirmationDescription = ({ className, ...props }: ConfirmationDescriptionProps) => (
|
|
148
|
+
<AlertDescription className={cn("text-body text-muted-foreground", className)} {...props} />
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
export interface ConfirmationRequestProps {
|
|
152
|
+
children?: ReactNode;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => {
|
|
156
|
+
const { state } = useConfirmation();
|
|
157
|
+
|
|
158
|
+
// Only show when approval is requested
|
|
159
|
+
if (state !== "approval-requested") {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return children;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export interface ConfirmationAcceptedProps {
|
|
167
|
+
children?: ReactNode;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const ConfirmationAccepted = ({ children }: ConfirmationAcceptedProps) => {
|
|
171
|
+
const { approval, state } = useConfirmation();
|
|
172
|
+
|
|
173
|
+
// Only show when approved and in response states
|
|
174
|
+
if (
|
|
175
|
+
!approval?.approved ||
|
|
176
|
+
(state !== "approval-responded" && state !== "output-denied" && state !== "output-available")
|
|
177
|
+
) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return children;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export interface ConfirmationRejectedProps {
|
|
185
|
+
children?: ReactNode;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export const ConfirmationRejected = ({ children }: ConfirmationRejectedProps) => {
|
|
189
|
+
const { approval, state } = useConfirmation();
|
|
190
|
+
|
|
191
|
+
// Only show when rejected and in response states
|
|
192
|
+
if (
|
|
193
|
+
approval?.approved !== false ||
|
|
194
|
+
(state !== "approval-responded" && state !== "output-denied" && state !== "output-available")
|
|
195
|
+
) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return children;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export type ConfirmationActionsProps = ComponentProps<"div">;
|
|
203
|
+
|
|
204
|
+
export const ConfirmationActions = ({ className, ...props }: ConfirmationActionsProps) => {
|
|
205
|
+
const { state } = useConfirmation();
|
|
206
|
+
|
|
207
|
+
// Only show when approval is requested
|
|
208
|
+
if (state !== "approval-requested") {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<div
|
|
214
|
+
className={cn(
|
|
215
|
+
// The action band (research 11 §B.3 APPROVE-3): the divider is the SOLE
|
|
216
|
+
// cue between consequence and buttons, so `border-strong` is correct.
|
|
217
|
+
"flex w-full items-center justify-end gap-2 border-t border-border-strong pt-3",
|
|
218
|
+
className,
|
|
219
|
+
)}
|
|
220
|
+
{...props}
|
|
221
|
+
/>
|
|
222
|
+
);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
export type ConfirmationActionProps = ComponentProps<typeof Button>;
|
|
226
|
+
|
|
227
|
+
export const ConfirmationAction = ({ className, ...props }: ConfirmationActionProps) => (
|
|
228
|
+
<Button className={cn("h-8 px-3 text-sm", className)} type="button" {...props} />
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
export type ConfirmationApproveProps = ConfirmationActionProps;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The proceed action — presets the filled primary variant (research 11 §B.3
|
|
235
|
+
* APPROVE-2). Role-named so the primary path cannot drift to `outline`.
|
|
236
|
+
*/
|
|
237
|
+
export const ConfirmationApprove = (props: ConfirmationApproveProps) => (
|
|
238
|
+
<ConfirmationAction variant="default" {...props} />
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
export type ConfirmationDenyProps = ConfirmationActionProps;
|
|
242
|
+
|
|
243
|
+
/** The decline action — presets the quiet `ghost` variant, never `outline`. */
|
|
244
|
+
export const ConfirmationDeny = (props: ConfirmationDenyProps) => (
|
|
245
|
+
<ConfirmationAction variant="ghost" {...props} />
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
/*
|
|
249
|
+
* ApprovalCard — the promoted, semantically-named front door for the approval
|
|
250
|
+
* interaction (research 11 §B.3). Same components, clearer name; the
|
|
251
|
+
* `Confirmation*` exports stay for the AI-Elements-shaped API (non-breaking).
|
|
252
|
+
* New code reaches for `ApprovalCard`.
|
|
253
|
+
*/
|
|
254
|
+
export const ApprovalCard = Confirmation;
|
|
255
|
+
export type ApprovalCardProps = ConfirmationProps;
|
|
256
|
+
export const ApprovalCardTitle = ConfirmationTitle;
|
|
257
|
+
export type ApprovalCardTitleProps = ConfirmationTitleProps;
|
|
258
|
+
export const ApprovalCardDescription = ConfirmationDescription;
|
|
259
|
+
export type ApprovalCardDescriptionProps = ConfirmationDescriptionProps;
|
|
260
|
+
export const ApprovalCardRequest = ConfirmationRequest;
|
|
261
|
+
export type ApprovalCardRequestProps = ConfirmationRequestProps;
|
|
262
|
+
export const ApprovalCardAccepted = ConfirmationAccepted;
|
|
263
|
+
export type ApprovalCardAcceptedProps = ConfirmationAcceptedProps;
|
|
264
|
+
export const ApprovalCardRejected = ConfirmationRejected;
|
|
265
|
+
export type ApprovalCardRejectedProps = ConfirmationRejectedProps;
|
|
266
|
+
export const ApprovalCardActions = ConfirmationActions;
|
|
267
|
+
export type ApprovalCardActionsProps = ConfirmationActionsProps;
|
|
268
|
+
export const ApprovalCardAction = ConfirmationAction;
|
|
269
|
+
export type ApprovalCardActionProps = ConfirmationActionProps;
|
|
270
|
+
export const ApprovalCardApprove = ConfirmationApprove;
|
|
271
|
+
export type ApprovalCardApproveProps = ConfirmationApproveProps;
|
|
272
|
+
export const ApprovalCardDeny = ConfirmationDeny;
|
|
273
|
+
export type ApprovalCardDenyProps = ConfirmationDenyProps;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ConnectionLineComponent } from "@xyflow/react";
|
|
2
|
+
|
|
3
|
+
const HALF = 0.5;
|
|
4
|
+
|
|
5
|
+
export const Connection: ConnectionLineComponent = ({ fromX, fromY, toX, toY }) => (
|
|
6
|
+
<g>
|
|
7
|
+
<path
|
|
8
|
+
className="animated"
|
|
9
|
+
d={`M${fromX},${fromY} C ${fromX + (toX - fromX) * HALF},${fromY} ${fromX + (toX - fromX) * HALF},${toY} ${toX},${toY}`}
|
|
10
|
+
fill="none"
|
|
11
|
+
stroke="var(--color-ring)"
|
|
12
|
+
strokeWidth={1}
|
|
13
|
+
/>
|
|
14
|
+
<circle
|
|
15
|
+
cx={toX}
|
|
16
|
+
cy={toY}
|
|
17
|
+
fill="var(--color-background)"
|
|
18
|
+
r={3}
|
|
19
|
+
stroke="var(--color-ring)"
|
|
20
|
+
strokeWidth={1}
|
|
21
|
+
/>
|
|
22
|
+
</g>
|
|
23
|
+
);
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { AssetPreview } from "./asset-preview";
|
|
3
|
+
import {
|
|
4
|
+
ContextPanel,
|
|
5
|
+
ContextPanelBody,
|
|
6
|
+
ContextPanelDetail,
|
|
7
|
+
ContextPanelHeader,
|
|
8
|
+
ContextPanelProvider,
|
|
9
|
+
ContextPanelSection,
|
|
10
|
+
ContextPanelTrigger,
|
|
11
|
+
useContextPanel,
|
|
12
|
+
type ContextAsset,
|
|
13
|
+
type ContextPanelProviderProps,
|
|
14
|
+
} from "./context-panel";
|
|
15
|
+
import { ProducedAssetTree } from "./file-tree";
|
|
16
|
+
|
|
17
|
+
const meta = {
|
|
18
|
+
title: "AI/ContextPanel",
|
|
19
|
+
component: ContextPanel,
|
|
20
|
+
tags: ["autodocs"],
|
|
21
|
+
parameters: { layout: "padded" },
|
|
22
|
+
} satisfies Meta<typeof ContextPanel>;
|
|
23
|
+
export default meta;
|
|
24
|
+
type Story = StoryObj<typeof meta>;
|
|
25
|
+
|
|
26
|
+
const BOARD_NOTE = `# Board note — Q3
|
|
27
|
+
|
|
28
|
+
Revenue grew **12.4% QoQ**, led by EMEA.
|
|
29
|
+
|
|
30
|
+
## Highlights
|
|
31
|
+
|
|
32
|
+
- EMEA $18.4M (+14%)
|
|
33
|
+
- Americas $21.2M (+8%)
|
|
34
|
+
|
|
35
|
+
> Watch APAC churn — flagged by the variance check.
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
const ASSETS: ContextAsset[] = [
|
|
39
|
+
{ id: "board-note", name: "board-note.md", type: "markdown", content: BOARD_NOTE },
|
|
40
|
+
{
|
|
41
|
+
id: "variance-query",
|
|
42
|
+
name: "variance-check.sql",
|
|
43
|
+
type: "sql",
|
|
44
|
+
content: "SELECT region, total\nFROM finance.revenue\nWHERE quarter = 'Q3';",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "revenue",
|
|
48
|
+
name: "revenue.csv",
|
|
49
|
+
type: "csv",
|
|
50
|
+
content: "region,q2,q3\nEMEA,16.1,18.4\nAmericas,19.6,21.2\nAPAC,7.2,8.6",
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/** Root view — the three labelled sections (research 09 §C.2). */
|
|
55
|
+
function RailRoot() {
|
|
56
|
+
const { state, actions } = useContextPanel();
|
|
57
|
+
return (
|
|
58
|
+
<>
|
|
59
|
+
<ContextPanelSection label="Status">
|
|
60
|
+
<p className="text-body text-muted-foreground">Context 21% · model atlas-fast</p>
|
|
61
|
+
</ContextPanelSection>
|
|
62
|
+
<ContextPanelSection label="Produced assets">
|
|
63
|
+
<ProducedAssetTree
|
|
64
|
+
assets={ASSETS}
|
|
65
|
+
selectedId={state.selectedAsset?.id}
|
|
66
|
+
onSelect={actions.openDetail}
|
|
67
|
+
/>
|
|
68
|
+
</ContextPanelSection>
|
|
69
|
+
</>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Detail view — the focused asset, drill-in target (research 04 §5). */
|
|
74
|
+
function RailDetail() {
|
|
75
|
+
const { state } = useContextPanel();
|
|
76
|
+
return (
|
|
77
|
+
<ContextPanelDetail>
|
|
78
|
+
{state.selectedAsset ? (
|
|
79
|
+
<AssetPreview asset={state.selectedAsset} />
|
|
80
|
+
) : (
|
|
81
|
+
<p className="text-body text-muted-foreground">Select an asset to preview it…</p>
|
|
82
|
+
)}
|
|
83
|
+
</ContextPanelDetail>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The provider wraps the WORKSPACE; the trigger lives in the app header and the
|
|
89
|
+
* panel is a sibling of the chat (the Sidebar / SidebarInset relationship) —
|
|
90
|
+
* ChatShell stays generic (research 09 §C.2/§C.3).
|
|
91
|
+
*/
|
|
92
|
+
function WorkspaceDemo(providerProps: Omit<ContextPanelProviderProps, "children">) {
|
|
93
|
+
return (
|
|
94
|
+
<ContextPanelProvider {...providerProps}>
|
|
95
|
+
<div className="flex h-[520px] w-full overflow-hidden rounded-xl border bg-background">
|
|
96
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
97
|
+
<header className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
|
98
|
+
<span className="text-subtitle">Agentic workspace</span>
|
|
99
|
+
<ContextPanelTrigger />
|
|
100
|
+
</header>
|
|
101
|
+
<div className="flex-1 overflow-y-auto p-4">
|
|
102
|
+
<p className="max-w-prose text-body text-muted-foreground">
|
|
103
|
+
The conversation renders here. The context panel is an always-mounted sibling: the
|
|
104
|
+
header trigger collapses it (width tween, no conditional mount), and selecting a
|
|
105
|
+
produced asset drills into the detail view inside the same rail.
|
|
106
|
+
</p>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
<ContextPanel>
|
|
110
|
+
<ContextPanelHeader title="Context" />
|
|
111
|
+
<ContextPanelBody root={<RailRoot />} detail={<RailDetail />} />
|
|
112
|
+
</ContextPanel>
|
|
113
|
+
</div>
|
|
114
|
+
</ContextPanelProvider>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Root view, expanded — toggle via the header trigger, click a row to drill in. */
|
|
119
|
+
export const Default: Story = {
|
|
120
|
+
render: () => <WorkspaceDemo defaultOpen />,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** Detail view — the drill-in target with the BACK control in the header. */
|
|
124
|
+
export const DetailView: Story = {
|
|
125
|
+
render: () => <WorkspaceDemo defaultOpen defaultView="detail" defaultSelectedAsset={ASSETS[0]} />,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** Collapsed — still mounted (data-state="collapsed"), inert, ready to tween open. */
|
|
129
|
+
export const Collapsed: Story = {
|
|
130
|
+
render: () => <WorkspaceDemo defaultOpen={false} />,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Below the mobile breakpoint the same state drives a Sheet (research 09 §B.4). */
|
|
134
|
+
export const Mobile: Story = {
|
|
135
|
+
globals: { viewport: { value: "mobile1", isRotated: false } },
|
|
136
|
+
render: () => <WorkspaceDemo />,
|
|
137
|
+
};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
ContextPanel,
|
|
6
|
+
ContextPanelBody,
|
|
7
|
+
ContextPanelDetail,
|
|
8
|
+
ContextPanelHeader,
|
|
9
|
+
ContextPanelProvider,
|
|
10
|
+
ContextPanelSection,
|
|
11
|
+
ContextPanelTrigger,
|
|
12
|
+
useContextPanel,
|
|
13
|
+
type ContextAsset,
|
|
14
|
+
type ContextPanelProviderProps,
|
|
15
|
+
} from "./context-panel";
|
|
16
|
+
import { ProducedAssetTree } from "./file-tree";
|
|
17
|
+
|
|
18
|
+
beforeAll(() => {
|
|
19
|
+
// jsdom has no matchMedia (useIsMobile drives the mobile Sheet fallback).
|
|
20
|
+
window.matchMedia = ((query: string) => ({
|
|
21
|
+
matches: false,
|
|
22
|
+
media: query,
|
|
23
|
+
onchange: null,
|
|
24
|
+
addListener: () => undefined,
|
|
25
|
+
removeListener: () => undefined,
|
|
26
|
+
addEventListener: () => undefined,
|
|
27
|
+
removeEventListener: () => undefined,
|
|
28
|
+
dispatchEvent: () => false,
|
|
29
|
+
})) as unknown as typeof window.matchMedia;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(cleanup);
|
|
33
|
+
|
|
34
|
+
const ASSETS: ContextAsset[] = [
|
|
35
|
+
{ id: "board-note", name: "board-note.md", type: "markdown", content: "# Note" },
|
|
36
|
+
{ id: "revenue", name: "revenue.csv", type: "csv", content: "region,total\nEMEA,1" },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function RootSections() {
|
|
40
|
+
const { state, actions } = useContextPanel();
|
|
41
|
+
return (
|
|
42
|
+
<ContextPanelSection label="Produced assets">
|
|
43
|
+
<ProducedAssetTree
|
|
44
|
+
assets={ASSETS}
|
|
45
|
+
selectedId={state.selectedAsset?.id}
|
|
46
|
+
onSelect={actions.openDetail}
|
|
47
|
+
/>
|
|
48
|
+
</ContextPanelSection>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function DetailHost() {
|
|
53
|
+
const { state } = useContextPanel();
|
|
54
|
+
return <div data-testid="detail-content">{state.selectedAsset?.name ?? "No asset"}</div>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function Workspace(providerProps: Omit<ContextPanelProviderProps, "children"> = {}) {
|
|
58
|
+
return (
|
|
59
|
+
<ContextPanelProvider {...providerProps}>
|
|
60
|
+
<header>
|
|
61
|
+
<ContextPanelTrigger />
|
|
62
|
+
</header>
|
|
63
|
+
<ContextPanel data-testid="panel">
|
|
64
|
+
<ContextPanelHeader title="Context" />
|
|
65
|
+
<ContextPanelBody
|
|
66
|
+
root={<RootSections />}
|
|
67
|
+
detail={
|
|
68
|
+
<ContextPanelDetail>
|
|
69
|
+
<DetailHost />
|
|
70
|
+
</ContextPanelDetail>
|
|
71
|
+
}
|
|
72
|
+
/>
|
|
73
|
+
</ContextPanel>
|
|
74
|
+
</ContextPanelProvider>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("ContextPanel collapse (#193, research 09 §B.2)", () => {
|
|
79
|
+
it("is always mounted and collapses via data-state, never a conditional mount", async () => {
|
|
80
|
+
const user = userEvent.setup();
|
|
81
|
+
render(<Workspace />);
|
|
82
|
+
|
|
83
|
+
const panel = screen.getByTestId("panel");
|
|
84
|
+
const trigger = screen.getByRole("button", { name: "Toggle context panel" });
|
|
85
|
+
|
|
86
|
+
expect(panel).toHaveAttribute("data-state", "expanded");
|
|
87
|
+
expect(panel).toHaveAttribute("data-side", "right");
|
|
88
|
+
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
|
89
|
+
expect(trigger).toHaveAttribute("aria-controls", panel.id);
|
|
90
|
+
|
|
91
|
+
await user.click(trigger);
|
|
92
|
+
|
|
93
|
+
// Still in the DOM (the whole point — width tweens need a mounted node)…
|
|
94
|
+
expect(screen.getByTestId("panel")).toBe(panel);
|
|
95
|
+
// …but collapsed and inert (research 09 §B.5).
|
|
96
|
+
expect(panel).toHaveAttribute("data-state", "collapsed");
|
|
97
|
+
expect(panel).toHaveAttribute("inert");
|
|
98
|
+
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("respects controlled open (mode never flips)", async () => {
|
|
102
|
+
const onOpenChange = vi.fn();
|
|
103
|
+
const user = userEvent.setup();
|
|
104
|
+
render(<Workspace open={false} onOpenChange={onOpenChange} />);
|
|
105
|
+
|
|
106
|
+
const panel = screen.getByTestId("panel");
|
|
107
|
+
expect(panel).toHaveAttribute("data-state", "collapsed");
|
|
108
|
+
|
|
109
|
+
await user.click(screen.getByRole("button", { name: "Toggle context panel" }));
|
|
110
|
+
expect(onOpenChange).toHaveBeenCalledWith(true);
|
|
111
|
+
// Controlled: the prop still says closed.
|
|
112
|
+
expect(panel).toHaveAttribute("data-state", "collapsed");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("throws when useContextPanel is used outside the provider", () => {
|
|
116
|
+
const Naked = () => {
|
|
117
|
+
useContextPanel();
|
|
118
|
+
return null;
|
|
119
|
+
};
|
|
120
|
+
expect(() => render(<Naked />)).toThrow(/within a ContextPanelProvider/);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("ContextPanel drill-in a11y (#193, research 09 §B.5 tab-panel swap)", () => {
|
|
125
|
+
it("moves focus to the BACK control, inerts the off-screen pane and announces", async () => {
|
|
126
|
+
const user = userEvent.setup();
|
|
127
|
+
const { container } = render(<Workspace />);
|
|
128
|
+
|
|
129
|
+
const rootPane = container.querySelector('[data-vt-pane="root"]') as HTMLElement;
|
|
130
|
+
const detailPane = container.querySelector('[data-vt-pane="detail"]') as HTMLElement;
|
|
131
|
+
expect(rootPane).not.toHaveAttribute("inert");
|
|
132
|
+
expect(detailPane).toHaveAttribute("inert");
|
|
133
|
+
expect(detailPane).toHaveAttribute("aria-hidden", "true");
|
|
134
|
+
|
|
135
|
+
await user.click(screen.getByText("board-note.md"));
|
|
136
|
+
|
|
137
|
+
// The detail pane is now the live one; the root pane is off-screen.
|
|
138
|
+
expect(detailPane).not.toHaveAttribute("inert");
|
|
139
|
+
expect(rootPane).toHaveAttribute("inert");
|
|
140
|
+
expect(rootPane).toHaveAttribute("aria-hidden", "true");
|
|
141
|
+
expect(screen.getByTestId("detail-content")).toHaveTextContent("board-note.md");
|
|
142
|
+
|
|
143
|
+
// Focus lands on the BACK control after the swap (rAF, animation-independent).
|
|
144
|
+
const back = screen.getByRole("button", { name: "Back to context" });
|
|
145
|
+
await waitFor(() => expect(back).toHaveFocus());
|
|
146
|
+
|
|
147
|
+
// The polite live region announced the target.
|
|
148
|
+
expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent(
|
|
149
|
+
"Showing board-note.md",
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("returns focus to the originating row on back", async () => {
|
|
154
|
+
const user = userEvent.setup();
|
|
155
|
+
const { container } = render(<Workspace />);
|
|
156
|
+
|
|
157
|
+
const row = screen.getByText("board-note.md").closest('[role="treeitem"]') as HTMLElement;
|
|
158
|
+
await user.click(screen.getByText("board-note.md"));
|
|
159
|
+
|
|
160
|
+
const back = screen.getByRole("button", { name: "Back to context" });
|
|
161
|
+
await waitFor(() => expect(back).toHaveFocus());
|
|
162
|
+
await user.click(back);
|
|
163
|
+
|
|
164
|
+
const rootPane = container.querySelector('[data-vt-pane="root"]') as HTMLElement;
|
|
165
|
+
expect(rootPane).not.toHaveAttribute("inert");
|
|
166
|
+
await waitFor(() => expect(row).toHaveFocus());
|
|
167
|
+
expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent("Back to context");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("auto-opens a collapsed panel when drilling in (research 09 §G.1)", async () => {
|
|
171
|
+
const user = userEvent.setup();
|
|
172
|
+
|
|
173
|
+
// External drive: a row is unreachable while the panel is inert, so use a
|
|
174
|
+
// consumer control (the lifted-state story — actions work from anywhere).
|
|
175
|
+
function ExternalOpen() {
|
|
176
|
+
const { actions } = useContextPanel();
|
|
177
|
+
return (
|
|
178
|
+
<button type="button" onClick={() => actions.openDetail(ASSETS[1] as ContextAsset)}>
|
|
179
|
+
Open revenue
|
|
180
|
+
</button>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
render(
|
|
184
|
+
<ContextPanelProvider defaultOpen={false}>
|
|
185
|
+
<ExternalOpen />
|
|
186
|
+
<ContextPanel data-testid="panel">
|
|
187
|
+
<ContextPanelHeader />
|
|
188
|
+
<ContextPanelBody root={<RootSections />} detail={<DetailHost />} />
|
|
189
|
+
</ContextPanel>
|
|
190
|
+
</ContextPanelProvider>,
|
|
191
|
+
);
|
|
192
|
+
expect(screen.getByTestId("panel")).toHaveAttribute("data-state", "collapsed");
|
|
193
|
+
|
|
194
|
+
await user.click(screen.getByRole("button", { name: "Open revenue" }));
|
|
195
|
+
expect(screen.getByTestId("panel")).toHaveAttribute("data-state", "expanded");
|
|
196
|
+
expect(screen.getByRole("button", { name: "Back to context" })).toBeInTheDocument();
|
|
197
|
+
});
|
|
198
|
+
});
|