@elabs-ai/components-ai 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -0
  3. package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
  4. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
  5. package/dist/_flow-boundary-D63PJ65S.js +186 -0
  6. package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
  7. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
  8. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
  9. package/dist/_persona-rive-RFR2EUWP.js +164 -0
  10. package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
  11. package/dist/index.d.ts +4079 -0
  12. package/dist/index.js +11770 -0
  13. package/dist/index.js.map +1 -0
  14. package/package.json +87 -0
  15. package/src/_audio-player-media-chrome.tsx +146 -0
  16. package/src/_chat-shell-rail.tsx +18 -0
  17. package/src/_code-block-theme.test.ts +134 -0
  18. package/src/_code-block-theme.ts +184 -0
  19. package/src/_flow-boundary.tsx +237 -0
  20. package/src/_flow-lazy.ts +34 -0
  21. package/src/_interactive-terminal-xterm.ts +32 -0
  22. package/src/_lazy-mermaid.test.ts +101 -0
  23. package/src/_lazy-mermaid.ts +117 -0
  24. package/src/_persona-rive.tsx +252 -0
  25. package/src/_streamdown-i18n.ts +119 -0
  26. package/src/agent-timeline.stories.tsx +66 -0
  27. package/src/agent-timeline.test.tsx +74 -0
  28. package/src/agent-timeline.tsx +106 -0
  29. package/src/agent.tsx +113 -0
  30. package/src/agentic-workspace.stories.tsx +1099 -0
  31. package/src/artifact.stories.tsx +62 -0
  32. package/src/artifact.test.tsx +112 -0
  33. package/src/artifact.tsx +167 -0
  34. package/src/asset-preview.stories.tsx +100 -0
  35. package/src/asset-preview.test.tsx +126 -0
  36. package/src/asset-preview.tsx +218 -0
  37. package/src/attachments.tsx +393 -0
  38. package/src/audio-player.stories.tsx +99 -0
  39. package/src/audio-player.tsx +170 -0
  40. package/src/blocks-ai-composer.stories.tsx +83 -0
  41. package/src/canvas.stories.tsx +142 -0
  42. package/src/canvas.tsx +29 -0
  43. package/src/chain-of-thought.tsx +193 -0
  44. package/src/chat-greeting.stories.tsx +96 -0
  45. package/src/chat-greeting.test.tsx +91 -0
  46. package/src/chat-greeting.tsx +91 -0
  47. package/src/chat-shell.stories.tsx +123 -0
  48. package/src/chat-shell.test.tsx +113 -0
  49. package/src/chat-shell.tsx +97 -0
  50. package/src/chat.stories.tsx +99 -0
  51. package/src/checkpoint.tsx +54 -0
  52. package/src/code-block.stories.tsx +81 -0
  53. package/src/code-block.test.tsx +191 -0
  54. package/src/code-block.tsx +673 -0
  55. package/src/commit.tsx +343 -0
  56. package/src/composer.stories.tsx +188 -0
  57. package/src/composer.test.tsx +182 -0
  58. package/src/composer.tsx +202 -0
  59. package/src/confirmation.stories.tsx +61 -0
  60. package/src/confirmation.test.tsx +136 -0
  61. package/src/confirmation.tsx +273 -0
  62. package/src/connection.tsx +23 -0
  63. package/src/context-panel.stories.tsx +137 -0
  64. package/src/context-panel.test.tsx +198 -0
  65. package/src/context-panel.tsx +609 -0
  66. package/src/context.stories.tsx +16 -0
  67. package/src/context.tsx +363 -0
  68. package/src/controls.tsx +20 -0
  69. package/src/conversation.stories.tsx +26 -0
  70. package/src/conversation.test.tsx +123 -0
  71. package/src/conversation.tsx +143 -0
  72. package/src/dark-theme-variant.test.ts +47 -0
  73. package/src/edge.tsx +33 -0
  74. package/src/environment-variables.tsx +304 -0
  75. package/src/file-tree.stories.tsx +55 -0
  76. package/src/file-tree.test.tsx +101 -0
  77. package/src/file-tree.tsx +390 -0
  78. package/src/gallery.stories.tsx +182 -0
  79. package/src/gallery.test.tsx +150 -0
  80. package/src/gallery.tsx +737 -0
  81. package/src/grouped-parts.stories.tsx +104 -0
  82. package/src/grouped-parts.test.tsx +167 -0
  83. package/src/grouped-parts.tsx +247 -0
  84. package/src/image.stories.tsx +67 -0
  85. package/src/image.test.tsx +105 -0
  86. package/src/image.tsx +115 -0
  87. package/src/index.ts +93 -0
  88. package/src/inline-citation.stories.tsx +91 -0
  89. package/src/inline-citation.test.tsx +96 -0
  90. package/src/inline-citation.tsx +314 -0
  91. package/src/interactive-terminal.stories.tsx +165 -0
  92. package/src/interactive-terminal.test.tsx +448 -0
  93. package/src/interactive-terminal.tsx +444 -0
  94. package/src/jsx-preview.stories.tsx +265 -0
  95. package/src/jsx-preview.test.tsx +75 -0
  96. package/src/jsx-preview.tsx +409 -0
  97. package/src/markdown-view.stories.tsx +57 -0
  98. package/src/markdown-view.test.tsx +36 -0
  99. package/src/markdown-view.tsx +113 -0
  100. package/src/message-edit.stories.tsx +150 -0
  101. package/src/message-edit.test.tsx +115 -0
  102. package/src/message-edit.tsx +336 -0
  103. package/src/message-feedback.stories.tsx +53 -0
  104. package/src/message-feedback.test.tsx +62 -0
  105. package/src/message-feedback.tsx +117 -0
  106. package/src/message-form-spec.ts +349 -0
  107. package/src/message-form.stories.tsx +182 -0
  108. package/src/message-form.test.tsx +227 -0
  109. package/src/message-form.tsx +845 -0
  110. package/src/message-table-spec.ts +281 -0
  111. package/src/message-table.stories.tsx +176 -0
  112. package/src/message-table.test.tsx +144 -0
  113. package/src/message-table.tsx +366 -0
  114. package/src/message.stories.tsx +262 -0
  115. package/src/message.test.tsx +508 -0
  116. package/src/message.tsx +619 -0
  117. package/src/mic-selector.tsx +339 -0
  118. package/src/microcopy.test.tsx +123 -0
  119. package/src/model-selector.stories.tsx +73 -0
  120. package/src/model-selector.test.tsx +55 -0
  121. package/src/model-selector.tsx +225 -0
  122. package/src/motion-config.tsx +49 -0
  123. package/src/node.tsx +71 -0
  124. package/src/open-in-chat.tsx +340 -0
  125. package/src/package-info.tsx +205 -0
  126. package/src/panel.tsx +20 -0
  127. package/src/part-groups.ts +258 -0
  128. package/src/persona-sources.ts +59 -0
  129. package/src/persona.stories.tsx +111 -0
  130. package/src/persona.test.tsx +67 -0
  131. package/src/persona.tsx +148 -0
  132. package/src/plan.tsx +135 -0
  133. package/src/prompt-input.stories.tsx +261 -0
  134. package/src/prompt-input.test.tsx +392 -0
  135. package/src/prompt-input.tsx +1668 -0
  136. package/src/queue.tsx +237 -0
  137. package/src/reasoning.stories.tsx +81 -0
  138. package/src/reasoning.test.tsx +50 -0
  139. package/src/reasoning.tsx +229 -0
  140. package/src/sandbox.stories.tsx +62 -0
  141. package/src/sandbox.test.tsx +42 -0
  142. package/src/sandbox.tsx +149 -0
  143. package/src/schema-display.tsx +412 -0
  144. package/src/selection-toolbar.stories.tsx +86 -0
  145. package/src/selection-toolbar.test.tsx +89 -0
  146. package/src/selection-toolbar.tsx +192 -0
  147. package/src/shimmer.stories.tsx +10 -0
  148. package/src/shimmer.tsx +81 -0
  149. package/src/snippet.stories.tsx +19 -0
  150. package/src/snippet.test.tsx +116 -0
  151. package/src/snippet.tsx +138 -0
  152. package/src/sources.stories.tsx +34 -0
  153. package/src/sources.test.tsx +129 -0
  154. package/src/sources.tsx +93 -0
  155. package/src/speech-input.tsx +312 -0
  156. package/src/stack-trace.tsx +479 -0
  157. package/src/streamdown-i18n.test.tsx +163 -0
  158. package/src/suggestion.stories.tsx +75 -0
  159. package/src/suggestion.test.tsx +72 -0
  160. package/src/suggestion.tsx +120 -0
  161. package/src/task.stories.tsx +22 -0
  162. package/src/task.test.tsx +36 -0
  163. package/src/task.tsx +89 -0
  164. package/src/templates-ai-assistant.stories.tsx +142 -0
  165. package/src/terminal.tsx +244 -0
  166. package/src/test-results.stories.tsx +177 -0
  167. package/src/test-results.test.tsx +241 -0
  168. package/src/test-results.tsx +408 -0
  169. package/src/tool-result-card.stories.tsx +143 -0
  170. package/src/tool-result-card.test.tsx +72 -0
  171. package/src/tool-result-card.tsx +86 -0
  172. package/src/tool.stories.tsx +60 -0
  173. package/src/tool.test.tsx +63 -0
  174. package/src/tool.tsx +235 -0
  175. package/src/toolbar.tsx +44 -0
  176. package/src/transcription.tsx +118 -0
  177. package/src/voice-selector.tsx +469 -0
  178. package/src/web-preview.stories.tsx +67 -0
  179. package/src/web-preview.test.tsx +117 -0
  180. package/src/web-preview.tsx +283 -0
@@ -0,0 +1,150 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+ import { Button } from "@elabs-ai/components-ui";
4
+ import { ChevronLeft, ChevronRight } from "lucide-react";
5
+ import {
6
+ MessageEdit,
7
+ MessageEditContent,
8
+ MessageEditProvider,
9
+ MessageEditTrigger,
10
+ } from "./message-edit";
11
+ import { Message, MessageContent } from "./message";
12
+
13
+ const meta = {
14
+ title: "AI/MessageEdit",
15
+ component: MessageEdit,
16
+ tags: ["autodocs"],
17
+ parameters: { layout: "padded" },
18
+ } satisfies Meta<typeof MessageEdit>;
19
+ export default meta;
20
+ type Story = StoryObj<typeof meta>;
21
+
22
+ /** Default — a user message with an edit affordance (hover to reveal). */
23
+ export const Default: Story = {
24
+ render: function Default() {
25
+ const [text, setText] = useState("Summarize the Q3 revenue report by region.");
26
+ return (
27
+ <Message from="user">
28
+ <MessageContent>
29
+ <MessageEdit value={text} onEditSubmit={setText}>
30
+ {text}
31
+ </MessageEdit>
32
+ </MessageContent>
33
+ </Message>
34
+ );
35
+ },
36
+ };
37
+
38
+ /** Editing — starts in edit mode (Enter saves · Shift+Enter newline · Esc cancels). */
39
+ export const Editing: Story = {
40
+ render: function Editing() {
41
+ const [text, setText] = useState("Make the intro shorter and add a chart.");
42
+ return (
43
+ <Message from="user">
44
+ <MessageContent>
45
+ <MessageEdit value={text} onEditSubmit={setText} defaultEditing>
46
+ {text}
47
+ </MessageEdit>
48
+ </MessageContent>
49
+ </Message>
50
+ );
51
+ },
52
+ };
53
+
54
+ /** Trigger in a toolbar — compose the parts so the edit control lives elsewhere. */
55
+ export const TriggerInToolbar: Story = {
56
+ render: function TriggerInToolbar() {
57
+ const [text, setText] = useState("Rewrite this as a bulleted list.");
58
+ return (
59
+ <MessageEditProvider value={text} onEditSubmit={setText}>
60
+ <div className="flex items-start justify-between gap-4">
61
+ <Message from="user" className="flex-1">
62
+ <MessageContent>
63
+ <MessageEditContent>{text}</MessageEditContent>
64
+ </MessageContent>
65
+ </Message>
66
+ <MessageEditTrigger />
67
+ </div>
68
+ </MessageEditProvider>
69
+ );
70
+ },
71
+ };
72
+
73
+ /**
74
+ * Branch on edit — editing creates a new version. The consumer owns branch
75
+ * state; here each save appends a version and jumps to it (compose with
76
+ * `MessageBranch*` in production).
77
+ */
78
+ export const BranchOnEdit: Story = {
79
+ render: function BranchOnEdit() {
80
+ const [versions, setVersions] = useState<string[]>(["What's our churn rate this quarter?"]);
81
+ const [active, setActive] = useState(0);
82
+ const current = versions[active] ?? "";
83
+ return (
84
+ <div className="flex flex-col gap-2">
85
+ <Message from="user">
86
+ <MessageContent>
87
+ <MessageEdit
88
+ key={active}
89
+ value={current}
90
+ onEditSubmit={(next) => {
91
+ setVersions((v) => [...v, next]);
92
+ setActive(versions.length);
93
+ }}
94
+ >
95
+ {current}
96
+ </MessageEdit>
97
+ </MessageContent>
98
+ </Message>
99
+ {versions.length > 1 && (
100
+ <div className="flex items-center gap-2 self-end text-caption text-muted-foreground">
101
+ <Button
102
+ size="icon-sm"
103
+ variant="ghost"
104
+ aria-label="Previous version"
105
+ disabled={active === 0}
106
+ onClick={() => setActive((a) => Math.max(0, a - 1))}
107
+ >
108
+ <ChevronLeft aria-hidden="true" className="size-4" />
109
+ </Button>
110
+ <span>
111
+ {active + 1} / {versions.length}
112
+ </span>
113
+ <Button
114
+ size="icon-sm"
115
+ variant="ghost"
116
+ aria-label="Next version"
117
+ disabled={active === versions.length - 1}
118
+ onClick={() => setActive((a) => Math.min(versions.length - 1, a + 1))}
119
+ >
120
+ <ChevronRight aria-hidden="true" className="size-4" />
121
+ </Button>
122
+ </div>
123
+ )}
124
+ </div>
125
+ );
126
+ },
127
+ };
128
+
129
+ /** Dark theme — inline editor + trigger in dark. */
130
+ export const DarkTheme: Story = {
131
+ render: function DarkTheme() {
132
+ const [text, setText] = useState("Draft a follow-up email to the finance team.");
133
+ return (
134
+ <Message from="user">
135
+ <MessageContent>
136
+ <MessageEdit value={text} onEditSubmit={setText} defaultEditing>
137
+ {text}
138
+ </MessageEdit>
139
+ </MessageContent>
140
+ </Message>
141
+ );
142
+ },
143
+ decorators: [
144
+ (Story) => (
145
+ <div data-theme="dark" className="rounded-lg bg-background p-6 text-foreground">
146
+ <Story />
147
+ </div>
148
+ ),
149
+ ],
150
+ };
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import {
5
+ MessageEdit,
6
+ MessageEditContent,
7
+ MessageEditProvider,
8
+ MessageEditTrigger,
9
+ } from "./message-edit";
10
+
11
+ describe("MessageEdit", () => {
12
+ it("shows the display content and an edit trigger", () => {
13
+ render(<MessageEdit value="Original text">Original text</MessageEdit>);
14
+ expect(screen.getByText("Original text")).toBeInTheDocument();
15
+ expect(screen.getByRole("button", { name: /Edit/ })).toBeInTheDocument();
16
+ });
17
+
18
+ it("enters edit mode seeded with the original text", async () => {
19
+ const user = userEvent.setup();
20
+ render(<MessageEdit value="Original text">Original text</MessageEdit>);
21
+ await user.click(screen.getByRole("button", { name: /Edit/ }));
22
+ expect(screen.getByLabelText("Edit message")).toHaveValue("Original text");
23
+ });
24
+
25
+ it("submits the new text and leaves edit mode", async () => {
26
+ const user = userEvent.setup();
27
+ const onEditSubmit = vi.fn();
28
+ render(
29
+ <MessageEdit value="Original" onEditSubmit={onEditSubmit}>
30
+ Original
31
+ </MessageEdit>,
32
+ );
33
+ await user.click(screen.getByRole("button", { name: /Edit/ }));
34
+ const textarea = screen.getByLabelText("Edit message");
35
+ await user.clear(textarea);
36
+ await user.type(textarea, "Updated");
37
+ await user.click(screen.getByRole("button", { name: "Save" }));
38
+ expect(onEditSubmit).toHaveBeenCalledWith("Updated");
39
+ expect(screen.queryByLabelText("Edit message")).not.toBeInTheDocument();
40
+ });
41
+
42
+ it("submits on Enter (Shift+Enter inserts a newline)", async () => {
43
+ const user = userEvent.setup();
44
+ const onEditSubmit = vi.fn();
45
+ render(
46
+ <MessageEdit value="Hi" onEditSubmit={onEditSubmit}>
47
+ Hi
48
+ </MessageEdit>,
49
+ );
50
+ await user.click(screen.getByRole("button", { name: /Edit/ }));
51
+ const textarea = screen.getByLabelText("Edit message");
52
+ await user.clear(textarea);
53
+ await user.type(textarea, "Done{Enter}");
54
+ expect(onEditSubmit).toHaveBeenCalledWith("Done");
55
+ });
56
+
57
+ it("cancels on Escape without calling onEditSubmit and restores the original", async () => {
58
+ const user = userEvent.setup();
59
+ const onEditSubmit = vi.fn();
60
+ render(
61
+ <MessageEdit value="Original" onEditSubmit={onEditSubmit}>
62
+ Original
63
+ </MessageEdit>,
64
+ );
65
+ await user.click(screen.getByRole("button", { name: /Edit/ }));
66
+ const textarea = screen.getByLabelText("Edit message");
67
+ await user.clear(textarea);
68
+ await user.type(textarea, "changed{Escape}");
69
+ expect(onEditSubmit).not.toHaveBeenCalled();
70
+ // Back to display; re-entering shows the original again (draft was discarded).
71
+ await user.click(screen.getByRole("button", { name: /Edit/ }));
72
+ expect(screen.getByLabelText("Edit message")).toHaveValue("Original");
73
+ });
74
+
75
+ it("restores focus to the trigger after cancel", async () => {
76
+ const user = userEvent.setup();
77
+ render(<MessageEdit value="Original">Original</MessageEdit>);
78
+ await user.click(screen.getByRole("button", { name: /Edit/ }));
79
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
80
+ expect(screen.getByRole("button", { name: /Edit/ })).toHaveFocus();
81
+ });
82
+
83
+ it("supports controlled editing via the provider parts", async () => {
84
+ const user = userEvent.setup();
85
+ const onEditingChange = vi.fn();
86
+ render(
87
+ <MessageEditProvider value="Original" editing onEditingChange={onEditingChange}>
88
+ <MessageEditContent>Original</MessageEditContent>
89
+ <MessageEditTrigger />
90
+ </MessageEditProvider>,
91
+ );
92
+ // Controlled `editing` → editor is shown; the trigger is hidden while editing.
93
+ expect(screen.getByLabelText("Edit message")).toBeInTheDocument();
94
+ expect(screen.queryByRole("button", { name: /Edit/ })).not.toBeInTheDocument();
95
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
96
+ expect(onEditingChange).toHaveBeenCalledWith(false);
97
+ });
98
+
99
+ it("does NOT trap Tab in the inline editor (non-modal — focus flows out)", async () => {
100
+ const user = userEvent.setup();
101
+ render(
102
+ <>
103
+ <MessageEdit value="Original" defaultEditing>
104
+ Original
105
+ </MessageEdit>
106
+ <button type="button">after</button>
107
+ </>,
108
+ );
109
+ // Move to the Save button, then Tab once more — a trap would send focus back
110
+ // to the textarea; instead it must reach the element after the editor.
111
+ screen.getByRole("button", { name: "Save" }).focus();
112
+ await user.tab();
113
+ expect(screen.getByRole("button", { name: "after" })).toHaveFocus();
114
+ });
115
+ });
@@ -0,0 +1,336 @@
1
+ "use client";
2
+
3
+ /**
4
+ * MessageEdit — edit-in-place for a user message.
5
+ *
6
+ * Swaps a message bubble between its display content and an inline editor
7
+ * (the composer's `Textarea` primitive) seeded with the original text.
8
+ * `onEditSubmit(newText)` fires on save — the consumer then creates a branch
9
+ * (compose with `MessageBranch*`). Presentational only: it never mutates the
10
+ * message; the original is simply restored on cancel because the source text is
11
+ * untouched until `onEditSubmit`.
12
+ *
13
+ * Behavior: Enter submits, Shift+Enter inserts a newline, Esc cancels. Focus
14
+ * moves into the editor on begin, is trapped within it while editing, and is
15
+ * restored to the trigger on cancel/submit.
16
+ *
17
+ * Compound structure (the ChangeReview convention — named parts, lifted state):
18
+ * <MessageEditProvider> — lifted edit state (editing + draft + actions)
19
+ * <MessageEditContent> — display children, or the inline editor
20
+ * <MessageEditTrigger> — begins editing (focus is restored here on exit)
21
+ */
22
+
23
+ import {
24
+ createContext,
25
+ forwardRef,
26
+ use,
27
+ useCallback,
28
+ useEffect,
29
+ useId,
30
+ useMemo,
31
+ useRef,
32
+ useState,
33
+ type FormEvent,
34
+ type HTMLAttributes,
35
+ type KeyboardEvent,
36
+ type ReactNode,
37
+ } from "react";
38
+ import { Check, Pencil, X } from "lucide-react";
39
+ import { Button, Textarea } from "@elabs-ai/components-ui";
40
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
41
+ import { useLocale } from "@elabs-ai/components-ui";
42
+
43
+ // ─── Context ────────────────────────────────────────────────────────────────
44
+
45
+ interface MessageEditContextValue {
46
+ editing: boolean;
47
+ draft: string;
48
+ setDraft: (value: string) => void;
49
+ beginEdit: () => void;
50
+ cancel: () => void;
51
+ submit: () => void;
52
+ /** Ref for the trigger element so focus can be restored to it on exit. */
53
+ triggerRef: React.MutableRefObject<HTMLElement | null>;
54
+ editorId: string;
55
+ }
56
+
57
+ const MessageEditContext = createContext<MessageEditContextValue | null>(null);
58
+
59
+ function useMessageEditContext(): MessageEditContextValue {
60
+ const ctx = use(MessageEditContext);
61
+ if (!ctx) {
62
+ throw new Error("MessageEdit sub-components must be rendered inside <MessageEditProvider>.");
63
+ }
64
+ return ctx;
65
+ }
66
+
67
+ /** Read-only access to the edit state (for a custom trigger/toolbar). */
68
+ export function useMessageEdit(): Pick<
69
+ MessageEditContextValue,
70
+ "editing" | "draft" | "beginEdit" | "cancel" | "submit"
71
+ > {
72
+ const { editing, draft, beginEdit, cancel, submit } = useMessageEditContext();
73
+ return { editing, draft, beginEdit, cancel, submit };
74
+ }
75
+
76
+ // ─── Provider ───────────────────────────────────────────────────────────────
77
+
78
+ export interface MessageEditProviderProps {
79
+ /** The editable source text (seeds the editor on begin). */
80
+ value: string;
81
+ /** Called with the new text on save. The consumer creates the branch. */
82
+ onEditSubmit?: (newText: string) => void;
83
+ /** Controlled editing state. When set, `onEditingChange` drives transitions. */
84
+ editing?: boolean;
85
+ /** Uncontrolled initial editing state. @default false */
86
+ defaultEditing?: boolean;
87
+ /** Called when editing begins/ends. */
88
+ onEditingChange?: (editing: boolean) => void;
89
+ children: ReactNode;
90
+ }
91
+
92
+ /** Lifts the edit state (editing + draft). Controlled or uncontrolled. */
93
+ export function MessageEditProvider({
94
+ value,
95
+ onEditSubmit,
96
+ editing: editingProp,
97
+ defaultEditing = false,
98
+ onEditingChange,
99
+ children,
100
+ }: MessageEditProviderProps) {
101
+ const editorId = useId();
102
+ const isControlled = editingProp !== undefined;
103
+ const [internalEditing, setInternalEditing] = useState(defaultEditing);
104
+ const editing = isControlled ? (editingProp as boolean) : internalEditing;
105
+
106
+ const [draft, setDraft] = useState(value);
107
+ const triggerRef = useRef<HTMLElement | null>(null);
108
+ const restoreFocus = useRef(false);
109
+
110
+ const setEditing = useCallback(
111
+ (next: boolean) => {
112
+ if (!isControlled) setInternalEditing(next);
113
+ onEditingChange?.(next);
114
+ },
115
+ [isControlled, onEditingChange],
116
+ );
117
+
118
+ const beginEdit = useCallback(() => {
119
+ setDraft(value); // seed from the current source text
120
+ setEditing(true);
121
+ }, [value, setEditing]);
122
+
123
+ const cancel = useCallback(() => {
124
+ setDraft(value); // discard edits (original is restored)
125
+ restoreFocus.current = true;
126
+ setEditing(false);
127
+ }, [value, setEditing]);
128
+
129
+ const submit = useCallback(() => {
130
+ onEditSubmit?.(draft);
131
+ restoreFocus.current = true;
132
+ setEditing(false);
133
+ }, [draft, onEditSubmit, setEditing]);
134
+
135
+ // Restore focus to the trigger once the editor has unmounted.
136
+ useEffect(() => {
137
+ if (!editing && restoreFocus.current) {
138
+ restoreFocus.current = false;
139
+ triggerRef.current?.focus();
140
+ }
141
+ }, [editing]);
142
+
143
+ const contextValue = useMemo<MessageEditContextValue>(
144
+ () => ({ editing, draft, setDraft, beginEdit, cancel, submit, triggerRef, editorId }),
145
+ [editing, draft, beginEdit, cancel, submit, editorId],
146
+ );
147
+
148
+ return <MessageEditContext.Provider value={contextValue}>{children}</MessageEditContext.Provider>;
149
+ }
150
+
151
+ // ─── Inline editor ─────────────────────────────────────────────────────────────
152
+
153
+ export type MessageEditFormProps = Omit<HTMLAttributes<HTMLFormElement>, "onSubmit"> & {
154
+ /** Save button label. @default "Save" */
155
+ saveLabel?: string;
156
+ /** Cancel button label. @default "Cancel" */
157
+ cancelLabel?: string;
158
+ };
159
+
160
+ /** The inline editor: an autofocused textarea + Save/Cancel, Enter/Esc wired. */
161
+ export const MessageEditForm = forwardRef<HTMLFormElement, MessageEditFormProps>(
162
+ function MessageEditForm(
163
+ { className, saveLabel = "Save", cancelLabel = "Cancel", ...props },
164
+ ref,
165
+ ) {
166
+ const { t } = useLocale();
167
+ const { draft, setDraft, cancel, submit, editorId } = useMessageEditContext();
168
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
169
+
170
+ // Autofocus the textarea and place the caret at the end.
171
+ useEffect(() => {
172
+ const el = textareaRef.current;
173
+ if (el) {
174
+ el.focus();
175
+ el.setSelectionRange(el.value.length, el.value.length);
176
+ }
177
+ }, []);
178
+
179
+ const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
180
+ e.preventDefault();
181
+ submit();
182
+ };
183
+
184
+ const handleKeyDown = (e: KeyboardEvent<HTMLElement>) => {
185
+ if (e.key === "Escape") {
186
+ e.preventDefault();
187
+ cancel();
188
+ return;
189
+ }
190
+ // Enter submits from the textarea; Shift+Enter inserts a newline.
191
+ if (e.key === "Enter" && !e.shiftKey && e.target === textareaRef.current) {
192
+ e.preventDefault();
193
+ submit();
194
+ }
195
+ // NOTE: Tab is deliberately NOT trapped — this is an inline, non-modal
196
+ // editor, and the repo a11y rule forbids trapping focus outside modals.
197
+ // Tab flows naturally out of the editor; focus is restored to the trigger
198
+ // only on cancel/submit.
199
+ };
200
+
201
+ return (
202
+ <form
203
+ ref={ref}
204
+ onSubmit={handleSubmit}
205
+ onKeyDown={handleKeyDown}
206
+ className={cn("flex w-full flex-col gap-2", className)}
207
+ {...props}
208
+ >
209
+ <Textarea
210
+ ref={textareaRef}
211
+ id={editorId}
212
+ value={draft}
213
+ aria-label={t("ai.message.editMessage")}
214
+ onChange={(e) => setDraft(e.target.value)}
215
+ className="min-h-16"
216
+ />
217
+ <div className="flex items-center justify-end gap-2">
218
+ <Button type="button" variant="ghost" size="sm" onClick={cancel}>
219
+ <X aria-hidden="true" className="size-4" />
220
+ {cancelLabel}
221
+ </Button>
222
+ <Button type="submit" size="sm" disabled={draft.trim().length === 0}>
223
+ <Check aria-hidden="true" className="size-4" />
224
+ {saveLabel}
225
+ </Button>
226
+ </div>
227
+ </form>
228
+ );
229
+ },
230
+ );
231
+
232
+ // ─── Content ──────────────────────────────────────────────────────────────────
233
+
234
+ export type MessageEditContentProps = HTMLAttributes<HTMLDivElement>;
235
+
236
+ /** Renders `children` in display mode, or the inline editor while editing. */
237
+ export const MessageEditContent = forwardRef<HTMLDivElement, MessageEditContentProps>(
238
+ function MessageEditContent({ className, children, ...props }, ref) {
239
+ const { editing } = useMessageEditContext();
240
+ return (
241
+ <div ref={ref} className={cn("w-full", className)} {...props}>
242
+ {editing ? <MessageEditForm /> : children}
243
+ </div>
244
+ );
245
+ },
246
+ );
247
+
248
+ // ─── Trigger ──────────────────────────────────────────────────────────────────
249
+
250
+ export type MessageEditTriggerProps = React.ComponentProps<typeof Button>;
251
+
252
+ /** Begins editing. Focus is restored here on cancel/submit. Hidden while editing. */
253
+ export const MessageEditTrigger = forwardRef<HTMLButtonElement, MessageEditTriggerProps>(
254
+ function MessageEditTrigger(
255
+ { className, children, variant = "ghost", size = "sm", onClick, ...props },
256
+ ref,
257
+ ) {
258
+ const { editing, beginEdit, triggerRef } = useMessageEditContext();
259
+ if (editing) return null;
260
+ return (
261
+ <Button
262
+ ref={(node) => {
263
+ triggerRef.current = node;
264
+ if (typeof ref === "function") ref(node);
265
+ else if (ref) ref.current = node;
266
+ }}
267
+ variant={variant}
268
+ size={size}
269
+ className={className}
270
+ onClick={(e) => {
271
+ onClick?.(e);
272
+ beginEdit();
273
+ }}
274
+ {...props}
275
+ >
276
+ {children ?? (
277
+ <>
278
+ <Pencil aria-hidden="true" className="size-4" />
279
+ Edit
280
+ </>
281
+ )}
282
+ </Button>
283
+ );
284
+ },
285
+ );
286
+
287
+ // ─── Root convenience ──────────────────────────────────────────────────────────
288
+
289
+ export interface MessageEditProps extends Omit<
290
+ HTMLAttributes<HTMLDivElement>,
291
+ "onSubmit" | "children"
292
+ > {
293
+ /** The editable source text. */
294
+ value: string;
295
+ /** Called with the new text on save (consumer creates the branch). */
296
+ onEditSubmit?: (newText: string) => void;
297
+ /** Controlled editing state. */
298
+ editing?: boolean;
299
+ /** Uncontrolled initial editing state. */
300
+ defaultEditing?: boolean;
301
+ /** Called when editing begins/ends. */
302
+ onEditingChange?: (editing: boolean) => void;
303
+ /** The display content (usually the rendered message text). */
304
+ children: ReactNode;
305
+ }
306
+
307
+ /**
308
+ * Convenience composition: display `children` + an "Edit" trigger, swapping to
309
+ * the inline editor while editing. For toolbar-placed triggers, compose the
310
+ * parts (`MessageEditProvider` + `MessageEditTrigger` in your toolbar).
311
+ */
312
+ export const MessageEdit = forwardRef<HTMLDivElement, MessageEditProps>(function MessageEdit(
313
+ { value, onEditSubmit, editing, defaultEditing, onEditingChange, className, children, ...props },
314
+ ref,
315
+ ) {
316
+ return (
317
+ <MessageEditProvider
318
+ value={value}
319
+ onEditSubmit={onEditSubmit}
320
+ editing={editing}
321
+ defaultEditing={defaultEditing}
322
+ onEditingChange={onEditingChange}
323
+ >
324
+ <div
325
+ ref={ref}
326
+ className={cn("group/message-edit flex w-full flex-col gap-1", className)}
327
+ {...props}
328
+ >
329
+ <MessageEditContent>{children}</MessageEditContent>
330
+ <div className="flex justify-end opacity-0 transition-opacity duration-fast focus-within:opacity-100 group-hover/message-edit:opacity-100 motion-reduce:transition-none">
331
+ <MessageEditTrigger />
332
+ </div>
333
+ </div>
334
+ </MessageEditProvider>
335
+ );
336
+ });
@@ -0,0 +1,53 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { MessageFeedback } from "./message-feedback";
3
+ import { MessageActions } from "./message";
4
+
5
+ const meta = {
6
+ title: "AI/MessageFeedback",
7
+ component: MessageFeedback,
8
+ tags: ["autodocs"],
9
+ parameters: { layout: "padded" },
10
+ } satisfies Meta<typeof MessageFeedback>;
11
+ export default meta;
12
+ type Story = StoryObj<typeof meta>;
13
+
14
+ /** Default — click a thumb to submit; both controls then disable. */
15
+ export const Default: Story = {
16
+ args: { onSubmit: (f) => console.info("feedback", f) },
17
+ };
18
+
19
+ /** Submitted (positive) — the chosen thumb stays prominent, controls inert. */
20
+ export const SubmittedPositive: Story = {
21
+ args: { value: "positive" },
22
+ };
23
+
24
+ /** Submitted (negative). */
25
+ export const SubmittedNegative: Story = {
26
+ args: { value: "negative" },
27
+ };
28
+
29
+ /** Compact — tighter spacing/size for a message action toolbar. */
30
+ export const Compact: Story = {
31
+ render: () => (
32
+ <MessageActions>
33
+ <MessageFeedback compact onSubmit={(f) => console.info("feedback", f)} />
34
+ </MessageActions>
35
+ ),
36
+ };
37
+
38
+ /** Disabled — e.g. while the message is still streaming. */
39
+ export const Disabled: Story = {
40
+ args: { disabled: true },
41
+ };
42
+
43
+ /** Dark theme. */
44
+ export const DarkTheme: Story = {
45
+ args: { value: "positive" },
46
+ decorators: [
47
+ (Story) => (
48
+ <div data-theme="dark" className="rounded-lg bg-background p-6 text-foreground">
49
+ <Story />
50
+ </div>
51
+ ),
52
+ ],
53
+ };