@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,67 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
|
|
4
|
+
import { PERSONA_SOURCES, Persona } from "./persona";
|
|
5
|
+
|
|
6
|
+
describe("Persona — remote artwork escape hatches", () => {
|
|
7
|
+
it("renders a fallback rather than an empty box before the Rive chunk loads", () => {
|
|
8
|
+
// The Rive runtime is behind `lazy()`, so the first frame is always the
|
|
9
|
+
// Suspense fallback. Previously a blocked `.riv` left a bare 64px void.
|
|
10
|
+
const { container } = render(<Persona state="idle" src="about:blank#offline" />);
|
|
11
|
+
expect(container.firstElementChild).not.toBeNull();
|
|
12
|
+
// The visually-hidden status announcement is the only text content now.
|
|
13
|
+
expect(container.textContent).toBe("Assistant idle");
|
|
14
|
+
expect(container.querySelector('[aria-hidden="true"]')).not.toBeNull();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("renders a caller-supplied fallback", () => {
|
|
18
|
+
render(<Persona state="idle" src="about:blank#offline" fallback={<span>resting</span>} />);
|
|
19
|
+
expect(screen.getByText("resting")).toBeInTheDocument();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("throws on an unknown variant, as before", () => {
|
|
23
|
+
expect(() =>
|
|
24
|
+
// @ts-expect-error — deliberately invalid variant
|
|
25
|
+
render(<Persona state="idle" variant="nope" />),
|
|
26
|
+
).toThrow(/Invalid variant/);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("Persona — state announced to assistive tech", () => {
|
|
31
|
+
it("announces a localized status for the current state", () => {
|
|
32
|
+
render(<Persona state="thinking" src="about:blank#offline" />);
|
|
33
|
+
expect(screen.getByRole("status")).toHaveTextContent("Assistant thinking…");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("updates the SAME live region rather than remounting it when state changes", () => {
|
|
37
|
+
const { rerender } = render(<Persona state="thinking" src="about:blank#offline" />);
|
|
38
|
+
const region = screen.getByRole("status");
|
|
39
|
+
expect(region).toHaveTextContent("Assistant thinking…");
|
|
40
|
+
|
|
41
|
+
rerender(<Persona state="speaking" src="about:blank#offline" />);
|
|
42
|
+
expect(screen.getByRole("status")).toBe(region);
|
|
43
|
+
expect(region).toHaveTextContent("Assistant speaking");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("renders no status region when statusLabel is explicitly null", () => {
|
|
47
|
+
render(<Persona state="thinking" src="about:blank#offline" statusLabel={null} />);
|
|
48
|
+
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("renders a caller-supplied statusLabel instead of the default", () => {
|
|
52
|
+
render(
|
|
53
|
+
<Persona state="thinking" src="about:blank#offline" statusLabel={<span>Working</span>} />,
|
|
54
|
+
);
|
|
55
|
+
expect(screen.getByRole("status")).toHaveTextContent("Working");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("PERSONA_SOURCES", () => {
|
|
60
|
+
it("is exported so consumers can self-host every artwork file", () => {
|
|
61
|
+
const variants = Object.keys(PERSONA_SOURCES);
|
|
62
|
+
expect(variants).toHaveLength(6);
|
|
63
|
+
for (const source of Object.values(PERSONA_SOURCES)) {
|
|
64
|
+
expect(source.source).toMatch(/^https:\/\/.*\.riv$/);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|
package/src/persona.tsx
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
4
|
+
import { useLocale } from "@elabs-ai/components-ui";
|
|
5
|
+
import type { RiveParameters } from "@rive-app/react-webgl2";
|
|
6
|
+
import type { FC, ReactNode } from "react";
|
|
7
|
+
import { lazy, memo, Suspense, useCallback, useState } from "react";
|
|
8
|
+
|
|
9
|
+
import { PERSONA_SOURCES, type PersonaState, type PersonaVariant } from "./persona-sources";
|
|
10
|
+
|
|
11
|
+
export { PERSONA_SOURCES };
|
|
12
|
+
export type { PersonaState, PersonaVariant };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The Rive runtime lives behind a dynamic import — `@rive-app/react-webgl2`
|
|
16
|
+
* declares no `sideEffects`, so a static import would put the whole WebGL2
|
|
17
|
+
* runtime and its `.wasm` in every consumer's entry chunk, `Persona` rendered or
|
|
18
|
+
* not. `RiveParameters` above is a TYPE import and erases. See ADR 0019.
|
|
19
|
+
*/
|
|
20
|
+
const PersonaRive = lazy(() => import("./_persona-rive"));
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Localized status announced when `state` changes, one key per `PersonaState`.
|
|
24
|
+
* Typed as a `Record` (not a bare template string) so a `PersonaState` value
|
|
25
|
+
* added later without a matching key here fails to typecheck.
|
|
26
|
+
*/
|
|
27
|
+
const PERSONA_STATE_KEYS: Record<PersonaState, string> = {
|
|
28
|
+
idle: "ai.persona.idle",
|
|
29
|
+
listening: "ai.persona.listening",
|
|
30
|
+
thinking: "ai.persona.thinking",
|
|
31
|
+
speaking: "ai.persona.speaking",
|
|
32
|
+
asleep: "ai.persona.asleep",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export interface PersonaProps {
|
|
36
|
+
className?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Rendered while the artwork loads, and permanently if it fails (blocked by a
|
|
39
|
+
* `connect-src` CSP, offline, or a bad `src`). Defaults to a token-driven orb,
|
|
40
|
+
* so a blocked fetch never leaves an empty box.
|
|
41
|
+
*/
|
|
42
|
+
fallback?: ReactNode;
|
|
43
|
+
onLoad?: RiveParameters["onLoad"];
|
|
44
|
+
onLoadError?: RiveParameters["onLoadError"];
|
|
45
|
+
onPause?: RiveParameters["onPause"];
|
|
46
|
+
onPlay?: RiveParameters["onPlay"];
|
|
47
|
+
onReady?: () => void;
|
|
48
|
+
onStop?: RiveParameters["onStop"];
|
|
49
|
+
/**
|
|
50
|
+
* Override the `.riv` artwork URL — point at a self-hosted copy when a CSP
|
|
51
|
+
* blocks the default origin. Defaults to `PERSONA_SOURCES[variant].source`.
|
|
52
|
+
* Mirrors the `mapStyle` override on `MapCanvas`.
|
|
53
|
+
*/
|
|
54
|
+
src?: string;
|
|
55
|
+
state: PersonaState;
|
|
56
|
+
/**
|
|
57
|
+
* Visually-hidden text announced when `state` changes. Defaults to a
|
|
58
|
+
* localized label for the current `state`; pass `null` when the consuming
|
|
59
|
+
* surface already renders its own `role="status"` line, so AT is not told
|
|
60
|
+
* twice.
|
|
61
|
+
*/
|
|
62
|
+
statusLabel?: ReactNode | null;
|
|
63
|
+
variant?: PersonaVariant;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The offline/blocked stand-in: a token-driven orb.
|
|
68
|
+
*
|
|
69
|
+
* Opacity-only pulse (no transform), neutralised under reduced motion.
|
|
70
|
+
*/
|
|
71
|
+
const PersonaFallback = ({ className }: { className?: string }) => (
|
|
72
|
+
<div
|
|
73
|
+
aria-hidden="true"
|
|
74
|
+
className={cn("grid size-16 shrink-0 place-items-center rounded-full bg-primary/10", className)}
|
|
75
|
+
>
|
|
76
|
+
<span className="size-6 animate-pulse rounded-full bg-primary/40 motion-reduce:animate-none" />
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
export const Persona: FC<PersonaProps> = memo(
|
|
81
|
+
({
|
|
82
|
+
className,
|
|
83
|
+
fallback,
|
|
84
|
+
onLoad,
|
|
85
|
+
onLoadError,
|
|
86
|
+
onPause,
|
|
87
|
+
onPlay,
|
|
88
|
+
onReady,
|
|
89
|
+
onStop,
|
|
90
|
+
src,
|
|
91
|
+
state = "idle",
|
|
92
|
+
statusLabel,
|
|
93
|
+
variant = "obsidian",
|
|
94
|
+
}) => {
|
|
95
|
+
const source = PERSONA_SOURCES[variant];
|
|
96
|
+
|
|
97
|
+
if (!source) {
|
|
98
|
+
throw new Error(`Invalid variant: ${variant}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { t } = useLocale();
|
|
102
|
+
const [failed, setFailed] = useState(false);
|
|
103
|
+
|
|
104
|
+
const handleLoadError = useCallback<NonNullable<RiveParameters["onLoadError"]>>(
|
|
105
|
+
(event) => {
|
|
106
|
+
setFailed(true);
|
|
107
|
+
onLoadError?.(event);
|
|
108
|
+
},
|
|
109
|
+
[onLoadError],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const placeholder = fallback ?? <PersonaFallback className={className} />;
|
|
113
|
+
|
|
114
|
+
const label = statusLabel === null ? null : (statusLabel ?? t(PERSONA_STATE_KEYS[state]));
|
|
115
|
+
|
|
116
|
+
const visual = failed ? (
|
|
117
|
+
placeholder
|
|
118
|
+
) : (
|
|
119
|
+
<Suspense fallback={placeholder}>
|
|
120
|
+
<PersonaRive
|
|
121
|
+
className={className}
|
|
122
|
+
onLoad={onLoad}
|
|
123
|
+
onLoadError={handleLoadError}
|
|
124
|
+
onPause={onPause}
|
|
125
|
+
onPlay={onPlay}
|
|
126
|
+
onReady={onReady}
|
|
127
|
+
onStop={onStop}
|
|
128
|
+
source={source}
|
|
129
|
+
src={src ?? source.source}
|
|
130
|
+
state={state}
|
|
131
|
+
/>
|
|
132
|
+
</Suspense>
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<>
|
|
137
|
+
{visual}
|
|
138
|
+
{label !== null && (
|
|
139
|
+
<span className="sr-only" role="status" aria-live="polite">
|
|
140
|
+
{label}
|
|
141
|
+
</span>
|
|
142
|
+
)}
|
|
143
|
+
</>
|
|
144
|
+
);
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
Persona.displayName = "Persona";
|
package/src/plan.tsx
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@elabs-ai/components-ui";
|
|
4
|
+
import {
|
|
5
|
+
Card,
|
|
6
|
+
CardAction,
|
|
7
|
+
CardContent,
|
|
8
|
+
CardDescription,
|
|
9
|
+
CardFooter,
|
|
10
|
+
CardHeader,
|
|
11
|
+
CardTitle,
|
|
12
|
+
} from "@elabs-ai/components-ui";
|
|
13
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@elabs-ai/components-ui";
|
|
14
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
15
|
+
import { ChevronsUpDownIcon } from "lucide-react";
|
|
16
|
+
import type { ComponentProps } from "react";
|
|
17
|
+
import { createContext, useContext, useMemo } from "react";
|
|
18
|
+
|
|
19
|
+
import { Shimmer } from "./shimmer";
|
|
20
|
+
|
|
21
|
+
interface PlanContextValue {
|
|
22
|
+
isStreaming: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const PlanContext = createContext<PlanContextValue | null>(null);
|
|
26
|
+
|
|
27
|
+
const usePlan = () => {
|
|
28
|
+
const context = useContext(PlanContext);
|
|
29
|
+
if (!context) {
|
|
30
|
+
throw new Error("Plan components must be used within Plan");
|
|
31
|
+
}
|
|
32
|
+
return context;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type PlanProps = ComponentProps<typeof Collapsible> & {
|
|
36
|
+
isStreaming?: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const Plan = ({ className, isStreaming = false, children, ...props }: PlanProps) => {
|
|
40
|
+
const contextValue = useMemo(() => ({ isStreaming }), [isStreaming]);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<PlanContext.Provider value={contextValue}>
|
|
44
|
+
<Collapsible asChild data-slot="plan" {...props}>
|
|
45
|
+
<Card className={cn("shadow-none", className)}>{children}</Card>
|
|
46
|
+
</Collapsible>
|
|
47
|
+
</PlanContext.Provider>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type PlanHeaderProps = ComponentProps<typeof CardHeader>;
|
|
52
|
+
|
|
53
|
+
export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
|
|
54
|
+
<CardHeader
|
|
55
|
+
className={cn("flex items-start justify-between", className)}
|
|
56
|
+
data-slot="plan-header"
|
|
57
|
+
{...props}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
export type PlanTitleProps = Omit<ComponentProps<typeof CardTitle>, "children"> & {
|
|
62
|
+
children: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
|
|
66
|
+
const { isStreaming } = usePlan();
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<CardTitle data-slot="plan-title" {...props}>
|
|
70
|
+
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
|
71
|
+
</CardTitle>
|
|
72
|
+
);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type PlanDescriptionProps = Omit<ComponentProps<typeof CardDescription>, "children"> & {
|
|
76
|
+
children: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const PlanDescription = ({ className, children, ...props }: PlanDescriptionProps) => {
|
|
80
|
+
const { isStreaming } = usePlan();
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<CardDescription
|
|
84
|
+
className={cn("text-balance", className)}
|
|
85
|
+
data-slot="plan-description"
|
|
86
|
+
{...props}
|
|
87
|
+
>
|
|
88
|
+
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
|
89
|
+
</CardDescription>
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export type PlanActionProps = ComponentProps<typeof CardAction>;
|
|
94
|
+
|
|
95
|
+
export const PlanAction = (props: PlanActionProps) => (
|
|
96
|
+
<CardAction data-slot="plan-action" {...props} />
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
export type PlanContentProps = ComponentProps<typeof CardContent>;
|
|
100
|
+
|
|
101
|
+
export const PlanContent = ({ className, ...props }: PlanContentProps) => (
|
|
102
|
+
<CollapsibleContent asChild>
|
|
103
|
+
<CardContent
|
|
104
|
+
className={cn(
|
|
105
|
+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:slide-in-from-top-2 data-[state=closed]:slide-out-to-top-2 data-[state=closed]:fade-out-0 data-[state=open]:[--tw-ease:var(--ease-entrance)] data-[state=closed]:[--tw-ease:var(--ease-exit)] outline-none",
|
|
106
|
+
className,
|
|
107
|
+
)}
|
|
108
|
+
data-slot="plan-content"
|
|
109
|
+
{...props}
|
|
110
|
+
/>
|
|
111
|
+
</CollapsibleContent>
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
export type PlanFooterProps = ComponentProps<"div">;
|
|
115
|
+
|
|
116
|
+
export const PlanFooter = (props: PlanFooterProps) => (
|
|
117
|
+
<CardFooter data-slot="plan-footer" {...props} />
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
|
|
121
|
+
|
|
122
|
+
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
|
|
123
|
+
<CollapsibleTrigger asChild>
|
|
124
|
+
<Button
|
|
125
|
+
className={cn("size-8", className)}
|
|
126
|
+
data-slot="plan-trigger"
|
|
127
|
+
size="icon"
|
|
128
|
+
variant="ghost"
|
|
129
|
+
{...props}
|
|
130
|
+
>
|
|
131
|
+
<ChevronsUpDownIcon className="size-4" />
|
|
132
|
+
<span className="sr-only">Toggle plan</span>
|
|
133
|
+
</Button>
|
|
134
|
+
</CollapsibleTrigger>
|
|
135
|
+
);
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PromptInput — the raw composer FORM primitive `Composer` is built on.
|
|
3
|
+
*
|
|
4
|
+
* Compose `PromptInputBody` / `PromptInputTextarea` / `PromptInputFooter` /
|
|
5
|
+
* `PromptInputTools` / `PromptInputSubmit` yourself when you need a bespoke
|
|
6
|
+
* shell; reach for `<Composer />` for the standard chat input.
|
|
7
|
+
*
|
|
8
|
+
* `tone` picks the inner well's fill: `"surface"` (default, muted — the look
|
|
9
|
+
* every existing usage keeps) or `"card"`, letting the well nest inside an
|
|
10
|
+
* already-tinted outer frame — the "double card" (#254) that `PromptInput`
|
|
11
|
+
* could not express before this prop existed. The `card` well's fill is
|
|
12
|
+
* theme-driven, not universally "white" — raised (lighter than the outer
|
|
13
|
+
* frame) on light themes, recessed (darker) on dark; see the
|
|
14
|
+
* `tone` prop doc.
|
|
15
|
+
*/
|
|
16
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
17
|
+
import { expect, fn, waitFor } from "storybook/test";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
PromptInput,
|
|
21
|
+
PromptInputBody,
|
|
22
|
+
PromptInputFooter,
|
|
23
|
+
PromptInputProvider,
|
|
24
|
+
PromptInputStop,
|
|
25
|
+
PromptInputSubmit,
|
|
26
|
+
PromptInputTextarea,
|
|
27
|
+
PromptInputTools,
|
|
28
|
+
} from "./prompt-input";
|
|
29
|
+
|
|
30
|
+
const meta = {
|
|
31
|
+
title: "AI/PromptInput",
|
|
32
|
+
component: PromptInput,
|
|
33
|
+
parameters: {
|
|
34
|
+
layout: "padded",
|
|
35
|
+
docs: {
|
|
36
|
+
description: {
|
|
37
|
+
component:
|
|
38
|
+
"The raw composer FORM primitive Composer is built on. `tone` picks the inner well's fill — `surface` (default, muted) or `card` — so it can nest inside an already-tinted outer frame (the double card, #254). The `card` fill is theme-driven, not universally white: raised on light themes, recessed on dark — still a distinct, legible tone against the outer frame in every theme.",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
args: {
|
|
43
|
+
onSubmit: () => undefined,
|
|
44
|
+
},
|
|
45
|
+
tags: ["autodocs"],
|
|
46
|
+
} satisfies Meta<typeof PromptInput>;
|
|
47
|
+
|
|
48
|
+
export default meta;
|
|
49
|
+
type Story = StoryObj<typeof meta>;
|
|
50
|
+
|
|
51
|
+
/** The default `surface` (muted) inner well — the look every existing usage keeps. */
|
|
52
|
+
export const Default: Story = {
|
|
53
|
+
render: (args) => (
|
|
54
|
+
<div className="mx-auto max-w-2xl">
|
|
55
|
+
<PromptInput {...args}>
|
|
56
|
+
<PromptInputBody>
|
|
57
|
+
<PromptInputTextarea />
|
|
58
|
+
</PromptInputBody>
|
|
59
|
+
<PromptInputFooter>
|
|
60
|
+
<PromptInputTools />
|
|
61
|
+
<PromptInputSubmit />
|
|
62
|
+
</PromptInputFooter>
|
|
63
|
+
</PromptInput>
|
|
64
|
+
</div>
|
|
65
|
+
),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The "double card" (#254): a tinted `bg-surface-muted` outer frame around a
|
|
70
|
+
* `tone="card"` well. Raised (lighter than the frame) on light themes,
|
|
71
|
+
* recessed (darker) on dark — check both themes here, not
|
|
72
|
+
* just the light default.
|
|
73
|
+
*/
|
|
74
|
+
export const DoubleCardToned: Story = {
|
|
75
|
+
render: (args) => (
|
|
76
|
+
<div className="mx-auto max-w-2xl rounded-xl bg-surface-muted p-1.5">
|
|
77
|
+
<PromptInput {...args} tone="card">
|
|
78
|
+
<PromptInputBody>
|
|
79
|
+
<PromptInputTextarea />
|
|
80
|
+
</PromptInputBody>
|
|
81
|
+
<PromptInputFooter>
|
|
82
|
+
<PromptInputTools />
|
|
83
|
+
<PromptInputSubmit />
|
|
84
|
+
</PromptInputFooter>
|
|
85
|
+
</PromptInput>
|
|
86
|
+
</div>
|
|
87
|
+
),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/* -------------------------------------------------------------------------- */
|
|
91
|
+
/* PromptInputSubmit — the merged primary-action contract (#351) */
|
|
92
|
+
/* */
|
|
93
|
+
/* 1. ready/undefined/error -> Send (unchanged) */
|
|
94
|
+
/* 2. running + composer EMPTY -> Stop (unchanged) */
|
|
95
|
+
/* 3. running + composer NON-EMPTY -> Send (the fix — a follow-up can */
|
|
96
|
+
/* always be composed and submitted mid-turn; brand-ui never asserts */
|
|
97
|
+
/* what the app's onSubmit does with it — that's the app's call, D5) */
|
|
98
|
+
/* 4. a PromptInputStop mounted alongside -> this control is ALWAYS Send */
|
|
99
|
+
/* -------------------------------------------------------------------------- */
|
|
100
|
+
|
|
101
|
+
/** At rest: Send, disabled until there is text or an attachment. */
|
|
102
|
+
export const Idle: Story = {
|
|
103
|
+
args: { onSubmit: fn() },
|
|
104
|
+
render: (args) => (
|
|
105
|
+
<div className="mx-auto max-w-2xl">
|
|
106
|
+
<PromptInput {...args}>
|
|
107
|
+
<PromptInputBody>
|
|
108
|
+
<PromptInputTextarea />
|
|
109
|
+
</PromptInputBody>
|
|
110
|
+
<PromptInputFooter>
|
|
111
|
+
<PromptInputTools />
|
|
112
|
+
<PromptInputSubmit />
|
|
113
|
+
</PromptInputFooter>
|
|
114
|
+
</PromptInput>
|
|
115
|
+
</div>
|
|
116
|
+
),
|
|
117
|
+
play: async ({ canvas }) => {
|
|
118
|
+
const send = await canvas.findByRole("button", { name: "Submit" });
|
|
119
|
+
// `aria-disabled`, not the native `disabled` attribute — see the comment
|
|
120
|
+
// on PromptInputSubmit (prompt-input.tsx) and the "Nothing to submit"
|
|
121
|
+
// exception in .claude/rules/interaction-guidelines.md: a natively
|
|
122
|
+
// disabled control is dropped from the focus order and strands keyboard
|
|
123
|
+
// users right after a keyboard-initiated send.
|
|
124
|
+
await expect(send).toHaveAttribute("aria-disabled", "true");
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// `onStop` isn't a `PromptInput` prop, so it can't live in typed `args` —
|
|
129
|
+
// each story below keeps its own module-scoped mock, set in `render` and
|
|
130
|
+
// read back in `play`.
|
|
131
|
+
let runningEmptyOnStop: ReturnType<typeof fn>;
|
|
132
|
+
|
|
133
|
+
/** Running, composer empty — the control IS the Stop affordance (unchanged). */
|
|
134
|
+
export const RunningEmpty: Story = {
|
|
135
|
+
args: { onSubmit: fn() },
|
|
136
|
+
render: (args) => {
|
|
137
|
+
runningEmptyOnStop = fn();
|
|
138
|
+
return (
|
|
139
|
+
<div className="mx-auto max-w-2xl">
|
|
140
|
+
<PromptInput {...args}>
|
|
141
|
+
<PromptInputBody>
|
|
142
|
+
<PromptInputTextarea />
|
|
143
|
+
</PromptInputBody>
|
|
144
|
+
<PromptInputFooter>
|
|
145
|
+
<PromptInputTools />
|
|
146
|
+
<PromptInputSubmit status="streaming" onStop={runningEmptyOnStop} />
|
|
147
|
+
</PromptInputFooter>
|
|
148
|
+
</PromptInput>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
},
|
|
152
|
+
play: async ({ canvas, args }) => {
|
|
153
|
+
const stop = await canvas.findByRole("button", { name: "Stop" });
|
|
154
|
+
await expect(stop).toHaveAttribute("data-action", "stop");
|
|
155
|
+
await stop.click();
|
|
156
|
+
await waitFor(() => expect(runningEmptyOnStop).toHaveBeenCalledTimes(1));
|
|
157
|
+
await expect(args.onSubmit).not.toHaveBeenCalled();
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
let runningWithDraftOnStop: ReturnType<typeof fn>;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Running, but the user has typed a follow-up — the P0 fix. The control flips
|
|
165
|
+
* back to Send so the draft can always be submitted mid-turn; brand-ui never
|
|
166
|
+
* asserts what `onSubmit` does with it (queue, interleave — the app decides).
|
|
167
|
+
*/
|
|
168
|
+
export const RunningWithDraft: Story = {
|
|
169
|
+
args: { onSubmit: fn() },
|
|
170
|
+
render: (args) => {
|
|
171
|
+
runningWithDraftOnStop = fn();
|
|
172
|
+
return (
|
|
173
|
+
<div className="mx-auto max-w-2xl">
|
|
174
|
+
<PromptInputProvider initialInput="What about the edge cases?">
|
|
175
|
+
<PromptInput {...args}>
|
|
176
|
+
<PromptInputBody>
|
|
177
|
+
<PromptInputTextarea />
|
|
178
|
+
</PromptInputBody>
|
|
179
|
+
<PromptInputFooter>
|
|
180
|
+
<PromptInputTools />
|
|
181
|
+
<PromptInputSubmit status="streaming" onStop={runningWithDraftOnStop} />
|
|
182
|
+
</PromptInputFooter>
|
|
183
|
+
</PromptInput>
|
|
184
|
+
</PromptInputProvider>
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
},
|
|
188
|
+
play: async ({ canvas, args }) => {
|
|
189
|
+
const send = await canvas.findByRole("button", { name: "Submit" });
|
|
190
|
+
await expect(send).toHaveAttribute("data-action", "send");
|
|
191
|
+
await expect(send).not.toHaveAttribute("data-generating");
|
|
192
|
+
await send.click();
|
|
193
|
+
await waitFor(() => expect(args.onSubmit).toHaveBeenCalledTimes(1));
|
|
194
|
+
await expect(runningWithDraftOnStop).not.toHaveBeenCalled();
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/** Failed — an X that stays clickable so the user can retry. */
|
|
199
|
+
export const ErrorState: Story = {
|
|
200
|
+
args: { onSubmit: fn() },
|
|
201
|
+
render: (args) => (
|
|
202
|
+
<div className="mx-auto max-w-2xl">
|
|
203
|
+
<PromptInput {...args}>
|
|
204
|
+
<PromptInputBody>
|
|
205
|
+
<PromptInputTextarea />
|
|
206
|
+
</PromptInputBody>
|
|
207
|
+
<PromptInputFooter>
|
|
208
|
+
<PromptInputTools />
|
|
209
|
+
<PromptInputSubmit status="error" />
|
|
210
|
+
</PromptInputFooter>
|
|
211
|
+
</PromptInput>
|
|
212
|
+
</div>
|
|
213
|
+
),
|
|
214
|
+
play: async ({ canvas }) => {
|
|
215
|
+
const send = await canvas.findByRole("button", { name: "Submit" });
|
|
216
|
+
await expect(send).toBeEnabled();
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
let separateStopControlOnStop: ReturnType<typeof fn>;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The composed "separate" arrangement: a dedicated `PromptInputStop` beside
|
|
224
|
+
* `PromptInputSubmit`. Never a `mode` prop (component-api.md bans behavioural
|
|
225
|
+
* modes) — compose the two controls instead. While `PromptInputStop` is
|
|
226
|
+
* mounted, `PromptInputSubmit` stays Send in every state; the dedicated
|
|
227
|
+
* control owns stopping.
|
|
228
|
+
*/
|
|
229
|
+
export const SeparateStopControl: Story = {
|
|
230
|
+
args: { onSubmit: fn() },
|
|
231
|
+
render: (args) => {
|
|
232
|
+
separateStopControlOnStop = fn();
|
|
233
|
+
return (
|
|
234
|
+
<div className="mx-auto max-w-2xl">
|
|
235
|
+
<PromptInput {...args}>
|
|
236
|
+
<PromptInputBody>
|
|
237
|
+
<PromptInputTextarea />
|
|
238
|
+
</PromptInputBody>
|
|
239
|
+
<PromptInputFooter>
|
|
240
|
+
<PromptInputTools />
|
|
241
|
+
<div className="flex items-center gap-1">
|
|
242
|
+
<PromptInputStop status="streaming" onStop={separateStopControlOnStop} />
|
|
243
|
+
<PromptInputSubmit status="streaming" onStop={separateStopControlOnStop} />
|
|
244
|
+
</div>
|
|
245
|
+
</PromptInputFooter>
|
|
246
|
+
</PromptInput>
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
},
|
|
250
|
+
play: async ({ canvas }) => {
|
|
251
|
+
const submit = await canvas.findByRole("button", { name: "Submit" });
|
|
252
|
+
await expect(submit).toHaveAttribute("data-action", "send");
|
|
253
|
+
// `aria-disabled`, not the native `disabled` attribute — see the Idle
|
|
254
|
+
// story above and prompt-input.tsx's PromptInputSubmit comment.
|
|
255
|
+
await expect(submit).toHaveAttribute("aria-disabled", "true");
|
|
256
|
+
|
|
257
|
+
const stop = await canvas.findByRole("button", { name: "Stop" });
|
|
258
|
+
await stop.click();
|
|
259
|
+
await waitFor(() => expect(separateStopControlOnStop).toHaveBeenCalledTimes(1));
|
|
260
|
+
},
|
|
261
|
+
};
|