@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,1668 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Command,
|
|
5
|
+
CommandEmpty,
|
|
6
|
+
CommandGroup,
|
|
7
|
+
CommandInput,
|
|
8
|
+
CommandItem,
|
|
9
|
+
CommandList,
|
|
10
|
+
CommandSeparator,
|
|
11
|
+
} from "@elabs-ai/components-ui";
|
|
12
|
+
import {
|
|
13
|
+
DropdownMenu,
|
|
14
|
+
DropdownMenuContent,
|
|
15
|
+
DropdownMenuItem,
|
|
16
|
+
DropdownMenuTrigger,
|
|
17
|
+
} from "@elabs-ai/components-ui";
|
|
18
|
+
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@elabs-ai/components-ui";
|
|
19
|
+
import {
|
|
20
|
+
InputGroup,
|
|
21
|
+
InputGroupAddon,
|
|
22
|
+
InputGroupButton,
|
|
23
|
+
InputGroupTextarea,
|
|
24
|
+
} from "@elabs-ai/components-ui";
|
|
25
|
+
import {
|
|
26
|
+
Select,
|
|
27
|
+
SelectContent,
|
|
28
|
+
SelectItem,
|
|
29
|
+
SelectTrigger,
|
|
30
|
+
SelectValue,
|
|
31
|
+
} from "@elabs-ai/components-ui";
|
|
32
|
+
import { Spinner } from "@elabs-ai/components-ui";
|
|
33
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@elabs-ai/components-ui";
|
|
34
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
35
|
+
import { useLocale } from "@elabs-ai/components-ui";
|
|
36
|
+
import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from "ai";
|
|
37
|
+
import { CornerDownLeftIcon, ImageIcon, Monitor, PlusIcon, SquareIcon, XIcon } from "lucide-react";
|
|
38
|
+
import { nanoid } from "nanoid";
|
|
39
|
+
import type {
|
|
40
|
+
ChangeEvent,
|
|
41
|
+
ChangeEventHandler,
|
|
42
|
+
ClipboardEventHandler,
|
|
43
|
+
ComponentProps,
|
|
44
|
+
FormEvent,
|
|
45
|
+
FormEventHandler,
|
|
46
|
+
HTMLAttributes,
|
|
47
|
+
KeyboardEventHandler,
|
|
48
|
+
PropsWithChildren,
|
|
49
|
+
ReactNode,
|
|
50
|
+
RefObject,
|
|
51
|
+
} from "react";
|
|
52
|
+
import {
|
|
53
|
+
Children,
|
|
54
|
+
createContext,
|
|
55
|
+
isValidElement,
|
|
56
|
+
useCallback,
|
|
57
|
+
useContext,
|
|
58
|
+
useEffect,
|
|
59
|
+
useMemo,
|
|
60
|
+
useRef,
|
|
61
|
+
useState,
|
|
62
|
+
} from "react";
|
|
63
|
+
|
|
64
|
+
// ============================================================================
|
|
65
|
+
// Helpers
|
|
66
|
+
// ============================================================================
|
|
67
|
+
|
|
68
|
+
const convertBlobUrlToDataUrl = async (url: string): Promise<string | null> => {
|
|
69
|
+
try {
|
|
70
|
+
const response = await fetch(url);
|
|
71
|
+
const blob = await response.blob();
|
|
72
|
+
// FileReader uses callback-based API, wrapping in Promise is necessary
|
|
73
|
+
// oxlint-disable-next-line eslint-plugin-promise(avoid-new)
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
const reader = new FileReader();
|
|
76
|
+
// oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
|
|
77
|
+
reader.onloadend = () => resolve(reader.result as string);
|
|
78
|
+
// oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
|
|
79
|
+
reader.onerror = () => resolve(null);
|
|
80
|
+
reader.readAsDataURL(blob);
|
|
81
|
+
});
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const captureScreenshot = async (): Promise<File | null> => {
|
|
88
|
+
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getDisplayMedia) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let stream: MediaStream | null = null;
|
|
93
|
+
const video = document.createElement("video");
|
|
94
|
+
video.muted = true;
|
|
95
|
+
video.playsInline = true;
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
stream = await navigator.mediaDevices.getDisplayMedia({
|
|
99
|
+
audio: false,
|
|
100
|
+
video: true,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
video.srcObject = stream;
|
|
104
|
+
|
|
105
|
+
// Video element uses callback-based API, wrapping in Promise is necessary
|
|
106
|
+
// oxlint-disable-next-line eslint-plugin-promise(avoid-new)
|
|
107
|
+
await new Promise<void>((resolve, reject) => {
|
|
108
|
+
// oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
|
|
109
|
+
video.onloadedmetadata = () => resolve();
|
|
110
|
+
// oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
|
|
111
|
+
video.onerror = () => reject(new Error("Failed to load screen stream"));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await video.play();
|
|
115
|
+
|
|
116
|
+
const width = video.videoWidth;
|
|
117
|
+
const height = video.videoHeight;
|
|
118
|
+
if (!width || !height) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const canvas = document.createElement("canvas");
|
|
123
|
+
canvas.width = width;
|
|
124
|
+
canvas.height = height;
|
|
125
|
+
const context = canvas.getContext("2d");
|
|
126
|
+
if (!context) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
context.drawImage(video, 0, 0, width, height);
|
|
131
|
+
// canvas.toBlob uses callback-based API, wrapping in Promise is necessary
|
|
132
|
+
// oxlint-disable-next-line eslint-plugin-promise(avoid-new)
|
|
133
|
+
const blob = await new Promise<Blob | null>((resolve) => {
|
|
134
|
+
canvas.toBlob(resolve, "image/png");
|
|
135
|
+
});
|
|
136
|
+
if (!blob) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const timestamp = new Date()
|
|
141
|
+
.toISOString()
|
|
142
|
+
.replaceAll(/[:.]/g, "-")
|
|
143
|
+
.replace("T", "_")
|
|
144
|
+
.replace("Z", "");
|
|
145
|
+
|
|
146
|
+
return new File([blob], `screenshot-${timestamp}.png`, {
|
|
147
|
+
lastModified: Date.now(),
|
|
148
|
+
type: "image/png",
|
|
149
|
+
});
|
|
150
|
+
} finally {
|
|
151
|
+
if (stream) {
|
|
152
|
+
for (const track of stream.getTracks()) {
|
|
153
|
+
track.stop();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
video.pause();
|
|
157
|
+
video.srcObject = null;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// ============================================================================
|
|
162
|
+
// Provider Context & Types
|
|
163
|
+
// ============================================================================
|
|
164
|
+
|
|
165
|
+
export interface AttachmentsContext {
|
|
166
|
+
files: (FileUIPart & { id: string })[];
|
|
167
|
+
add: (files: File[] | FileList) => void;
|
|
168
|
+
remove: (id: string) => void;
|
|
169
|
+
clear: () => void;
|
|
170
|
+
openFileDialog: () => void;
|
|
171
|
+
fileInputRef: RefObject<HTMLInputElement | null>;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface TextInputContext {
|
|
175
|
+
value: string;
|
|
176
|
+
setInput: (v: string) => void;
|
|
177
|
+
clear: () => void;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface PromptInputControllerProps {
|
|
181
|
+
textInput: TextInputContext;
|
|
182
|
+
attachments: AttachmentsContext;
|
|
183
|
+
/** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
|
|
184
|
+
__registerFileInput: (ref: RefObject<HTMLInputElement | null>, open: () => void) => void;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const PromptInputController = createContext<PromptInputControllerProps | null>(null);
|
|
188
|
+
const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(null);
|
|
189
|
+
|
|
190
|
+
export const usePromptInputController = () => {
|
|
191
|
+
const ctx = useContext(PromptInputController);
|
|
192
|
+
if (!ctx) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
"Wrap your component inside <PromptInputProvider> to use usePromptInputController().",
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return ctx;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// Optional variants (do NOT throw). Useful for dual-mode components.
|
|
201
|
+
const useOptionalPromptInputController = () => useContext(PromptInputController);
|
|
202
|
+
|
|
203
|
+
export const useProviderAttachments = () => {
|
|
204
|
+
const ctx = useContext(ProviderAttachmentsContext);
|
|
205
|
+
if (!ctx) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
"Wrap your component inside <PromptInputProvider> to use useProviderAttachments().",
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return ctx;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const useOptionalProviderAttachments = () => useContext(ProviderAttachmentsContext);
|
|
214
|
+
|
|
215
|
+
export type PromptInputProviderProps = PropsWithChildren<{
|
|
216
|
+
initialInput?: string;
|
|
217
|
+
}>;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Optional global provider that lifts PromptInput state outside of PromptInput.
|
|
221
|
+
* If you don't use it, PromptInput stays fully self-managed.
|
|
222
|
+
*/
|
|
223
|
+
export const PromptInputProvider = ({
|
|
224
|
+
initialInput: initialTextInput = "",
|
|
225
|
+
children,
|
|
226
|
+
}: PromptInputProviderProps) => {
|
|
227
|
+
// ----- textInput state
|
|
228
|
+
const [textInput, setTextInput] = useState(initialTextInput);
|
|
229
|
+
const clearInput = useCallback(() => setTextInput(""), []);
|
|
230
|
+
|
|
231
|
+
// ----- attachments state (global when wrapped)
|
|
232
|
+
const [attachmentFiles, setAttachmentFiles] = useState<(FileUIPart & { id: string })[]>([]);
|
|
233
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
234
|
+
// oxlint-disable-next-line eslint(no-empty-function)
|
|
235
|
+
const openRef = useRef<() => void>(() => {});
|
|
236
|
+
|
|
237
|
+
const add = useCallback((files: File[] | FileList) => {
|
|
238
|
+
const incoming = [...files];
|
|
239
|
+
if (incoming.length === 0) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
setAttachmentFiles((prev) => [
|
|
244
|
+
...prev,
|
|
245
|
+
...incoming.map((file) => ({
|
|
246
|
+
filename: file.name,
|
|
247
|
+
id: nanoid(),
|
|
248
|
+
mediaType: file.type,
|
|
249
|
+
type: "file" as const,
|
|
250
|
+
url: URL.createObjectURL(file),
|
|
251
|
+
})),
|
|
252
|
+
]);
|
|
253
|
+
}, []);
|
|
254
|
+
|
|
255
|
+
const remove = useCallback((id: string) => {
|
|
256
|
+
setAttachmentFiles((prev) => {
|
|
257
|
+
const found = prev.find((f) => f.id === id);
|
|
258
|
+
if (found?.url) {
|
|
259
|
+
URL.revokeObjectURL(found.url);
|
|
260
|
+
}
|
|
261
|
+
return prev.filter((f) => f.id !== id);
|
|
262
|
+
});
|
|
263
|
+
}, []);
|
|
264
|
+
|
|
265
|
+
const clear = useCallback(() => {
|
|
266
|
+
setAttachmentFiles((prev) => {
|
|
267
|
+
for (const f of prev) {
|
|
268
|
+
if (f.url) {
|
|
269
|
+
URL.revokeObjectURL(f.url);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return [];
|
|
273
|
+
});
|
|
274
|
+
}, []);
|
|
275
|
+
|
|
276
|
+
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
|
|
277
|
+
const attachmentsRef = useRef(attachmentFiles);
|
|
278
|
+
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
attachmentsRef.current = attachmentFiles;
|
|
281
|
+
}, [attachmentFiles]);
|
|
282
|
+
|
|
283
|
+
// Cleanup blob URLs on unmount to prevent memory leaks
|
|
284
|
+
useEffect(
|
|
285
|
+
() => () => {
|
|
286
|
+
for (const f of attachmentsRef.current) {
|
|
287
|
+
if (f.url) {
|
|
288
|
+
URL.revokeObjectURL(f.url);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
[],
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const openFileDialog = useCallback(() => {
|
|
296
|
+
openRef.current?.();
|
|
297
|
+
}, []);
|
|
298
|
+
|
|
299
|
+
const attachments = useMemo<AttachmentsContext>(
|
|
300
|
+
() => ({
|
|
301
|
+
add,
|
|
302
|
+
clear,
|
|
303
|
+
fileInputRef,
|
|
304
|
+
files: attachmentFiles,
|
|
305
|
+
openFileDialog,
|
|
306
|
+
remove,
|
|
307
|
+
}),
|
|
308
|
+
[attachmentFiles, add, remove, clear, openFileDialog],
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const __registerFileInput = useCallback(
|
|
312
|
+
(ref: RefObject<HTMLInputElement | null>, open: () => void) => {
|
|
313
|
+
fileInputRef.current = ref.current;
|
|
314
|
+
openRef.current = open;
|
|
315
|
+
},
|
|
316
|
+
[],
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const controller = useMemo<PromptInputControllerProps>(
|
|
320
|
+
() => ({
|
|
321
|
+
__registerFileInput,
|
|
322
|
+
attachments,
|
|
323
|
+
textInput: {
|
|
324
|
+
clear: clearInput,
|
|
325
|
+
setInput: setTextInput,
|
|
326
|
+
value: textInput,
|
|
327
|
+
},
|
|
328
|
+
}),
|
|
329
|
+
[textInput, clearInput, attachments, __registerFileInput],
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
return (
|
|
333
|
+
<PromptInputController.Provider value={controller}>
|
|
334
|
+
<ProviderAttachmentsContext.Provider value={attachments}>
|
|
335
|
+
{children}
|
|
336
|
+
</ProviderAttachmentsContext.Provider>
|
|
337
|
+
</PromptInputController.Provider>
|
|
338
|
+
);
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
// ============================================================================
|
|
342
|
+
// Component Context & Hooks
|
|
343
|
+
// ============================================================================
|
|
344
|
+
|
|
345
|
+
const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null);
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Submit-readiness, lifted into `PromptInput` so the guard and the affordance
|
|
349
|
+
* agree. `handleSubmit` is the invariant (it is reachable via `requestSubmit()`
|
|
350
|
+
* or a consumer's own button); `PromptInputSubmit`'s disabled state is the
|
|
351
|
+
* affordance. Both read `canSubmit` — a live-looking button that does nothing
|
|
352
|
+
* is an accessibility lie, and a guard nobody can see is a dead end.
|
|
353
|
+
*/
|
|
354
|
+
interface PromptInputSubmitStateContextValue {
|
|
355
|
+
/** Non-whitespace text, or at least one attachment. */
|
|
356
|
+
canSubmit: boolean;
|
|
357
|
+
/**
|
|
358
|
+
* INTERNAL — the uncontrolled textarea reports empty↔non-empty transitions.
|
|
359
|
+
* A boolean, so React bails out on identical state and typing does not make
|
|
360
|
+
* an uncontrolled input re-render per keystroke.
|
|
361
|
+
*/
|
|
362
|
+
setHasText: (hasText: boolean) => void;
|
|
363
|
+
/**
|
|
364
|
+
* True while at least one `PromptInputStop` is mounted (#351). When a
|
|
365
|
+
* consumer composes a dedicated Stop control, `PromptInputSubmit` never
|
|
366
|
+
* needs to double as Stop — it stays the Send action in every state.
|
|
367
|
+
*/
|
|
368
|
+
hasDedicatedStop: boolean;
|
|
369
|
+
/** Registers a mounted `PromptInputStop`; returns its unregister fn. */
|
|
370
|
+
registerStop: () => () => void;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const PromptInputSubmitStateContext = createContext<PromptInputSubmitStateContextValue | null>(
|
|
374
|
+
null,
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
/** Optional — `PromptInputSubmit` may legitimately render outside a `PromptInput`. */
|
|
378
|
+
const useOptionalSubmitState = () => useContext(PromptInputSubmitStateContext);
|
|
379
|
+
|
|
380
|
+
/** The `data-slot` the Enter handler uses to find the send control. */
|
|
381
|
+
const SUBMIT_SLOT = "prompt-input-submit";
|
|
382
|
+
|
|
383
|
+
export const usePromptInputAttachments = () => {
|
|
384
|
+
// Prefer local context (inside PromptInput) as it has validation, fall back to provider
|
|
385
|
+
const provider = useOptionalProviderAttachments();
|
|
386
|
+
const local = useContext(LocalAttachmentsContext);
|
|
387
|
+
const context = local ?? provider;
|
|
388
|
+
if (!context) {
|
|
389
|
+
throw new Error(
|
|
390
|
+
"usePromptInputAttachments must be used within a PromptInput or PromptInputProvider",
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
return context;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
// ============================================================================
|
|
397
|
+
// Referenced Sources (Local to PromptInput)
|
|
398
|
+
// ============================================================================
|
|
399
|
+
|
|
400
|
+
export interface ReferencedSourcesContext {
|
|
401
|
+
sources: (SourceDocumentUIPart & { id: string })[];
|
|
402
|
+
add: (sources: SourceDocumentUIPart[] | SourceDocumentUIPart) => void;
|
|
403
|
+
remove: (id: string) => void;
|
|
404
|
+
clear: () => void;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export const LocalReferencedSourcesContext = createContext<ReferencedSourcesContext | null>(null);
|
|
408
|
+
|
|
409
|
+
export const usePromptInputReferencedSources = () => {
|
|
410
|
+
const ctx = useContext(LocalReferencedSourcesContext);
|
|
411
|
+
if (!ctx) {
|
|
412
|
+
throw new Error(
|
|
413
|
+
"usePromptInputReferencedSources must be used within a LocalReferencedSourcesContext.Provider",
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
return ctx;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
export type PromptInputActionAddAttachmentsProps = ComponentProps<typeof DropdownMenuItem> & {
|
|
420
|
+
label?: string;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
export const PromptInputActionAddAttachments = ({
|
|
424
|
+
label = "Add photos or files",
|
|
425
|
+
...props
|
|
426
|
+
}: PromptInputActionAddAttachmentsProps) => {
|
|
427
|
+
const attachments = usePromptInputAttachments();
|
|
428
|
+
|
|
429
|
+
const handleSelect = useCallback(
|
|
430
|
+
(e: Event) => {
|
|
431
|
+
e.preventDefault();
|
|
432
|
+
attachments.openFileDialog();
|
|
433
|
+
},
|
|
434
|
+
[attachments],
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
return (
|
|
438
|
+
<DropdownMenuItem {...props} onSelect={handleSelect}>
|
|
439
|
+
<ImageIcon className="me-2 size-4" /> {label}
|
|
440
|
+
</DropdownMenuItem>
|
|
441
|
+
);
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
export type PromptInputActionAddScreenshotProps = ComponentProps<typeof DropdownMenuItem> & {
|
|
445
|
+
label?: string;
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
export const PromptInputActionAddScreenshot = ({
|
|
449
|
+
label = "Take screenshot",
|
|
450
|
+
onSelect,
|
|
451
|
+
...props
|
|
452
|
+
}: PromptInputActionAddScreenshotProps) => {
|
|
453
|
+
const attachments = usePromptInputAttachments();
|
|
454
|
+
|
|
455
|
+
const handleSelect = useCallback(
|
|
456
|
+
async (event: Event) => {
|
|
457
|
+
onSelect?.(event);
|
|
458
|
+
if (event.defaultPrevented) {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
const screenshot = await captureScreenshot();
|
|
464
|
+
if (screenshot) {
|
|
465
|
+
attachments.add([screenshot]);
|
|
466
|
+
}
|
|
467
|
+
} catch (error) {
|
|
468
|
+
if (
|
|
469
|
+
error instanceof DOMException &&
|
|
470
|
+
(error.name === "NotAllowedError" || error.name === "AbortError")
|
|
471
|
+
) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
throw error;
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
[onSelect, attachments],
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
return (
|
|
481
|
+
<DropdownMenuItem {...props} onSelect={handleSelect}>
|
|
482
|
+
<Monitor className="me-2 size-4" />
|
|
483
|
+
{label}
|
|
484
|
+
</DropdownMenuItem>
|
|
485
|
+
);
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
export interface PromptInputMessage {
|
|
489
|
+
text: string;
|
|
490
|
+
files: FileUIPart[];
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export type PromptInputProps = Omit<HTMLAttributes<HTMLFormElement>, "onSubmit" | "onError"> & {
|
|
494
|
+
// e.g., "image/*" or leave undefined for any
|
|
495
|
+
accept?: string;
|
|
496
|
+
multiple?: boolean;
|
|
497
|
+
// When true, accepts drops anywhere on document. Default false (opt-in).
|
|
498
|
+
globalDrop?: boolean;
|
|
499
|
+
// Render a hidden input with given name and keep it in sync for native form posts. Default false.
|
|
500
|
+
syncHiddenInput?: boolean;
|
|
501
|
+
// Minimal constraints
|
|
502
|
+
maxFiles?: number;
|
|
503
|
+
// bytes
|
|
504
|
+
maxFileSize?: number;
|
|
505
|
+
/**
|
|
506
|
+
* The fill of the inner well (the `InputGroup` `variant`). `"surface"`
|
|
507
|
+
* (default) is the standard muted composer look; `"card"` renders a
|
|
508
|
+
* `bg-card` well for nesting inside an already-tinted outer frame — the
|
|
509
|
+
* "double card" look (#254). e.g.
|
|
510
|
+
* `<div className="rounded-xl bg-surface-muted p-1.5"><PromptInput tone="card">…</PromptInput></div>`.
|
|
511
|
+
* The well fill is NOT universally "white": `--card` reads lighter
|
|
512
|
+
* (raised) than `--surface-muted` on light themes, but darker (recessed) on
|
|
513
|
+
* dark — the same theme-dependent trade-off documented
|
|
514
|
+
* for `bg-surface-muted`-as-a-well in `styling-and-tokens.md`. The well
|
|
515
|
+
* stays a legible, distinct tone against the outer frame in every theme;
|
|
516
|
+
* only the "raised" framing is light-themes-specific.
|
|
517
|
+
*/
|
|
518
|
+
tone?: "surface" | "card";
|
|
519
|
+
onError?: (err: { code: "max_files" | "max_file_size" | "accept"; message: string }) => void;
|
|
520
|
+
onSubmit: (
|
|
521
|
+
message: PromptInputMessage,
|
|
522
|
+
event: FormEvent<HTMLFormElement>,
|
|
523
|
+
) => void | Promise<void>;
|
|
524
|
+
/**
|
|
525
|
+
* Classes for the inner surface (the visible `InputGroup` well), e.g. to shape
|
|
526
|
+
* its corners. `className` styles the outer `<form>`; this styles the well —
|
|
527
|
+
* the only way to reach it from outside (used by `Composer`).
|
|
528
|
+
*/
|
|
529
|
+
surfaceClassName?: string;
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
export const PromptInput = ({
|
|
533
|
+
className,
|
|
534
|
+
surfaceClassName,
|
|
535
|
+
tone = "surface",
|
|
536
|
+
accept,
|
|
537
|
+
multiple,
|
|
538
|
+
globalDrop,
|
|
539
|
+
syncHiddenInput,
|
|
540
|
+
maxFiles,
|
|
541
|
+
maxFileSize,
|
|
542
|
+
onError,
|
|
543
|
+
onSubmit,
|
|
544
|
+
children,
|
|
545
|
+
...props
|
|
546
|
+
}: PromptInputProps) => {
|
|
547
|
+
// Try to use a provider controller if present
|
|
548
|
+
const controller = useOptionalPromptInputController();
|
|
549
|
+
const usingProvider = !!controller;
|
|
550
|
+
|
|
551
|
+
// Microcopy goes through the locale seam. `useLocale` is provider-optional —
|
|
552
|
+
// with no <LocaleProvider> it returns the shipped English defaults, so this is
|
|
553
|
+
// not a breaking change and needs no ancestor. See ADR 0017.
|
|
554
|
+
const { t } = useLocale();
|
|
555
|
+
|
|
556
|
+
// Refs
|
|
557
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
558
|
+
const formRef = useRef<HTMLFormElement | null>(null);
|
|
559
|
+
|
|
560
|
+
// ----- Local attachments (only used when no provider)
|
|
561
|
+
const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
|
|
562
|
+
const files = usingProvider ? controller.attachments.files : items;
|
|
563
|
+
|
|
564
|
+
// ----- Submit readiness (drives both the guard and the disabled affordance)
|
|
565
|
+
const [hasLocalText, setHasLocalText] = useState(false);
|
|
566
|
+
const hasText = usingProvider ? controller.textInput.value.trim().length > 0 : hasLocalText;
|
|
567
|
+
|
|
568
|
+
// ----- Dedicated Stop control registration (#351 — the composed "separate"
|
|
569
|
+
// arrangement). A ref counter tolerates >1 mounted PromptInputStop; the
|
|
570
|
+
// boolean state is what's actually read, so React bails out on identical
|
|
571
|
+
// values the same way `setHasText` does.
|
|
572
|
+
const [hasDedicatedStop, setHasDedicatedStop] = useState(false);
|
|
573
|
+
const stopCountRef = useRef(0);
|
|
574
|
+
const registerStop = useCallback(() => {
|
|
575
|
+
stopCountRef.current += 1;
|
|
576
|
+
setHasDedicatedStop(true);
|
|
577
|
+
return () => {
|
|
578
|
+
stopCountRef.current = Math.max(0, stopCountRef.current - 1);
|
|
579
|
+
if (stopCountRef.current === 0) {
|
|
580
|
+
setHasDedicatedStop(false);
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
}, []);
|
|
584
|
+
|
|
585
|
+
// ----- Local referenced sources (always local to PromptInput)
|
|
586
|
+
const [referencedSources, setReferencedSources] = useState<
|
|
587
|
+
(SourceDocumentUIPart & { id: string })[]
|
|
588
|
+
>([]);
|
|
589
|
+
|
|
590
|
+
// Keep a ref to files for cleanup on unmount (avoids stale closure)
|
|
591
|
+
const filesRef = useRef(files);
|
|
592
|
+
|
|
593
|
+
useEffect(() => {
|
|
594
|
+
filesRef.current = files;
|
|
595
|
+
}, [files]);
|
|
596
|
+
|
|
597
|
+
const openFileDialogLocal = useCallback(() => {
|
|
598
|
+
inputRef.current?.click();
|
|
599
|
+
}, []);
|
|
600
|
+
|
|
601
|
+
const matchesAccept = useCallback(
|
|
602
|
+
(f: File) => {
|
|
603
|
+
if (!accept || accept.trim() === "") {
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const patterns = accept
|
|
608
|
+
.split(",")
|
|
609
|
+
.map((s) => s.trim())
|
|
610
|
+
.filter(Boolean);
|
|
611
|
+
|
|
612
|
+
return patterns.some((pattern) => {
|
|
613
|
+
if (pattern.endsWith("/*")) {
|
|
614
|
+
// e.g: image/* -> image/
|
|
615
|
+
const prefix = pattern.slice(0, -1);
|
|
616
|
+
return f.type.startsWith(prefix);
|
|
617
|
+
}
|
|
618
|
+
return f.type === pattern;
|
|
619
|
+
});
|
|
620
|
+
},
|
|
621
|
+
[accept],
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
const addLocal = useCallback(
|
|
625
|
+
(fileList: File[] | FileList) => {
|
|
626
|
+
const incoming = [...fileList];
|
|
627
|
+
const accepted = incoming.filter((f) => matchesAccept(f));
|
|
628
|
+
if (incoming.length && accepted.length === 0) {
|
|
629
|
+
onError?.({
|
|
630
|
+
code: "accept",
|
|
631
|
+
message: t("ai.promptInput.errorAccept"),
|
|
632
|
+
});
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const withinSize = (f: File) => (maxFileSize ? f.size <= maxFileSize : true);
|
|
636
|
+
const sized = accepted.filter(withinSize);
|
|
637
|
+
if (accepted.length > 0 && sized.length === 0) {
|
|
638
|
+
onError?.({
|
|
639
|
+
code: "max_file_size",
|
|
640
|
+
message: t("ai.promptInput.errorMaxFileSize"),
|
|
641
|
+
});
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
setItems((prev) => {
|
|
646
|
+
const capacity =
|
|
647
|
+
typeof maxFiles === "number" ? Math.max(0, maxFiles - prev.length) : undefined;
|
|
648
|
+
const capped = typeof capacity === "number" ? sized.slice(0, capacity) : sized;
|
|
649
|
+
if (typeof capacity === "number" && sized.length > capacity) {
|
|
650
|
+
onError?.({
|
|
651
|
+
code: "max_files",
|
|
652
|
+
message: t("ai.promptInput.errorMaxFiles"),
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
const next: (FileUIPart & { id: string })[] = [];
|
|
656
|
+
for (const file of capped) {
|
|
657
|
+
next.push({
|
|
658
|
+
filename: file.name,
|
|
659
|
+
id: nanoid(),
|
|
660
|
+
mediaType: file.type,
|
|
661
|
+
type: "file",
|
|
662
|
+
url: URL.createObjectURL(file),
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
return [...prev, ...next];
|
|
666
|
+
});
|
|
667
|
+
},
|
|
668
|
+
[matchesAccept, maxFiles, maxFileSize, onError],
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
const removeLocal = useCallback(
|
|
672
|
+
(id: string) =>
|
|
673
|
+
setItems((prev) => {
|
|
674
|
+
const found = prev.find((file) => file.id === id);
|
|
675
|
+
if (found?.url) {
|
|
676
|
+
URL.revokeObjectURL(found.url);
|
|
677
|
+
}
|
|
678
|
+
return prev.filter((file) => file.id !== id);
|
|
679
|
+
}),
|
|
680
|
+
[],
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
// Wrapper that validates files before calling provider's add
|
|
684
|
+
const addWithProviderValidation = useCallback(
|
|
685
|
+
(fileList: File[] | FileList) => {
|
|
686
|
+
const incoming = [...fileList];
|
|
687
|
+
const accepted = incoming.filter((f) => matchesAccept(f));
|
|
688
|
+
if (incoming.length && accepted.length === 0) {
|
|
689
|
+
onError?.({
|
|
690
|
+
code: "accept",
|
|
691
|
+
message: t("ai.promptInput.errorAccept"),
|
|
692
|
+
});
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
const withinSize = (f: File) => (maxFileSize ? f.size <= maxFileSize : true);
|
|
696
|
+
const sized = accepted.filter(withinSize);
|
|
697
|
+
if (accepted.length > 0 && sized.length === 0) {
|
|
698
|
+
onError?.({
|
|
699
|
+
code: "max_file_size",
|
|
700
|
+
message: t("ai.promptInput.errorMaxFileSize"),
|
|
701
|
+
});
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const currentCount = files.length;
|
|
706
|
+
const capacity =
|
|
707
|
+
typeof maxFiles === "number" ? Math.max(0, maxFiles - currentCount) : undefined;
|
|
708
|
+
const capped = typeof capacity === "number" ? sized.slice(0, capacity) : sized;
|
|
709
|
+
if (typeof capacity === "number" && sized.length > capacity) {
|
|
710
|
+
onError?.({
|
|
711
|
+
code: "max_files",
|
|
712
|
+
message: t("ai.promptInput.errorMaxFiles"),
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (capped.length > 0) {
|
|
717
|
+
controller?.attachments.add(capped);
|
|
718
|
+
}
|
|
719
|
+
},
|
|
720
|
+
[matchesAccept, maxFileSize, maxFiles, onError, files.length, controller],
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
const clearAttachments = useCallback(
|
|
724
|
+
() =>
|
|
725
|
+
usingProvider
|
|
726
|
+
? controller?.attachments.clear()
|
|
727
|
+
: setItems((prev) => {
|
|
728
|
+
for (const file of prev) {
|
|
729
|
+
if (file.url) {
|
|
730
|
+
URL.revokeObjectURL(file.url);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return [];
|
|
734
|
+
}),
|
|
735
|
+
[usingProvider, controller],
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
const clearReferencedSources = useCallback(() => setReferencedSources([]), []);
|
|
739
|
+
|
|
740
|
+
const add = usingProvider ? addWithProviderValidation : addLocal;
|
|
741
|
+
const remove = usingProvider ? controller.attachments.remove : removeLocal;
|
|
742
|
+
const openFileDialog = usingProvider
|
|
743
|
+
? controller.attachments.openFileDialog
|
|
744
|
+
: openFileDialogLocal;
|
|
745
|
+
|
|
746
|
+
const clear = useCallback(() => {
|
|
747
|
+
clearAttachments();
|
|
748
|
+
clearReferencedSources();
|
|
749
|
+
}, [clearAttachments, clearReferencedSources]);
|
|
750
|
+
|
|
751
|
+
// Let provider know about our hidden file input so external menus can call openFileDialog()
|
|
752
|
+
useEffect(() => {
|
|
753
|
+
if (!usingProvider) {
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
controller.__registerFileInput(inputRef, () => inputRef.current?.click());
|
|
757
|
+
}, [usingProvider, controller]);
|
|
758
|
+
|
|
759
|
+
// Note: File input cannot be programmatically set for security reasons
|
|
760
|
+
// The syncHiddenInput prop is no longer functional
|
|
761
|
+
useEffect(() => {
|
|
762
|
+
if (syncHiddenInput && inputRef.current && files.length === 0) {
|
|
763
|
+
inputRef.current.value = "";
|
|
764
|
+
}
|
|
765
|
+
}, [files, syncHiddenInput]);
|
|
766
|
+
|
|
767
|
+
// Attach drop handlers on nearest form and document (opt-in)
|
|
768
|
+
useEffect(() => {
|
|
769
|
+
const form = formRef.current;
|
|
770
|
+
if (!form) {
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
if (globalDrop) {
|
|
774
|
+
// when global drop is on, let the document-level handler own drops
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const onDragOver = (e: DragEvent) => {
|
|
779
|
+
if (e.dataTransfer?.types?.includes("Files")) {
|
|
780
|
+
e.preventDefault();
|
|
781
|
+
}
|
|
782
|
+
};
|
|
783
|
+
const onDrop = (e: DragEvent) => {
|
|
784
|
+
if (e.dataTransfer?.types?.includes("Files")) {
|
|
785
|
+
e.preventDefault();
|
|
786
|
+
}
|
|
787
|
+
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
|
|
788
|
+
add(e.dataTransfer.files);
|
|
789
|
+
}
|
|
790
|
+
};
|
|
791
|
+
form.addEventListener("dragover", onDragOver);
|
|
792
|
+
form.addEventListener("drop", onDrop);
|
|
793
|
+
return () => {
|
|
794
|
+
form.removeEventListener("dragover", onDragOver);
|
|
795
|
+
form.removeEventListener("drop", onDrop);
|
|
796
|
+
};
|
|
797
|
+
}, [add, globalDrop]);
|
|
798
|
+
|
|
799
|
+
useEffect(() => {
|
|
800
|
+
if (!globalDrop) {
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const onDragOver = (e: DragEvent) => {
|
|
805
|
+
if (e.dataTransfer?.types?.includes("Files")) {
|
|
806
|
+
e.preventDefault();
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
const onDrop = (e: DragEvent) => {
|
|
810
|
+
if (e.dataTransfer?.types?.includes("Files")) {
|
|
811
|
+
e.preventDefault();
|
|
812
|
+
}
|
|
813
|
+
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
|
|
814
|
+
add(e.dataTransfer.files);
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
document.addEventListener("dragover", onDragOver);
|
|
818
|
+
document.addEventListener("drop", onDrop);
|
|
819
|
+
return () => {
|
|
820
|
+
document.removeEventListener("dragover", onDragOver);
|
|
821
|
+
document.removeEventListener("drop", onDrop);
|
|
822
|
+
};
|
|
823
|
+
}, [add, globalDrop]);
|
|
824
|
+
|
|
825
|
+
useEffect(
|
|
826
|
+
() => () => {
|
|
827
|
+
if (!usingProvider) {
|
|
828
|
+
for (const f of filesRef.current) {
|
|
829
|
+
if (f.url) {
|
|
830
|
+
URL.revokeObjectURL(f.url);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
},
|
|
835
|
+
[usingProvider],
|
|
836
|
+
);
|
|
837
|
+
|
|
838
|
+
const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
|
839
|
+
(event) => {
|
|
840
|
+
if (event.currentTarget.files) {
|
|
841
|
+
add(event.currentTarget.files);
|
|
842
|
+
}
|
|
843
|
+
// Reset input value to allow selecting files that were previously removed
|
|
844
|
+
event.currentTarget.value = "";
|
|
845
|
+
},
|
|
846
|
+
[add],
|
|
847
|
+
);
|
|
848
|
+
|
|
849
|
+
const attachmentsCtx = useMemo<AttachmentsContext>(
|
|
850
|
+
() => ({
|
|
851
|
+
add,
|
|
852
|
+
clear: clearAttachments,
|
|
853
|
+
fileInputRef: inputRef,
|
|
854
|
+
files: files.map((item) => ({ ...item, id: item.id })),
|
|
855
|
+
openFileDialog,
|
|
856
|
+
remove,
|
|
857
|
+
}),
|
|
858
|
+
[files, add, remove, clearAttachments, openFileDialog],
|
|
859
|
+
);
|
|
860
|
+
|
|
861
|
+
const submitState = useMemo<PromptInputSubmitStateContextValue>(
|
|
862
|
+
() => ({
|
|
863
|
+
canSubmit: hasText || files.length > 0,
|
|
864
|
+
hasDedicatedStop,
|
|
865
|
+
registerStop,
|
|
866
|
+
setHasText: setHasLocalText,
|
|
867
|
+
}),
|
|
868
|
+
[hasText, files.length, hasDedicatedStop, registerStop],
|
|
869
|
+
);
|
|
870
|
+
|
|
871
|
+
const refsCtx = useMemo<ReferencedSourcesContext>(
|
|
872
|
+
() => ({
|
|
873
|
+
add: (incoming: SourceDocumentUIPart[] | SourceDocumentUIPart) => {
|
|
874
|
+
const array = Array.isArray(incoming) ? incoming : [incoming];
|
|
875
|
+
setReferencedSources((prev) => [...prev, ...array.map((s) => ({ ...s, id: nanoid() }))]);
|
|
876
|
+
},
|
|
877
|
+
clear: clearReferencedSources,
|
|
878
|
+
remove: (id: string) => {
|
|
879
|
+
setReferencedSources((prev) => prev.filter((s) => s.id !== id));
|
|
880
|
+
},
|
|
881
|
+
sources: referencedSources,
|
|
882
|
+
}),
|
|
883
|
+
[referencedSources, clearReferencedSources],
|
|
884
|
+
);
|
|
885
|
+
|
|
886
|
+
const handleSubmit: FormEventHandler<HTMLFormElement> = useCallback(
|
|
887
|
+
async (event) => {
|
|
888
|
+
event.preventDefault();
|
|
889
|
+
|
|
890
|
+
const form = event.currentTarget;
|
|
891
|
+
const text = usingProvider
|
|
892
|
+
? controller.textInput.value
|
|
893
|
+
: (() => {
|
|
894
|
+
const formData = new FormData(form);
|
|
895
|
+
return (formData.get("message") as string) || "";
|
|
896
|
+
})();
|
|
897
|
+
|
|
898
|
+
// Nothing to send: no text AND no attachments. This runs BEFORE the reset
|
|
899
|
+
// below — a later guard would still have wiped the composer. Only the
|
|
900
|
+
// guard trims; the untrimmed `text` still reaches `onSubmit`, preserving
|
|
901
|
+
// the existing payload semantics. An attachments-only submit (no text) is
|
|
902
|
+
// legitimate and must still go through.
|
|
903
|
+
if (text.trim().length === 0 && files.length === 0) {
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Reset form immediately after capturing text to avoid race condition
|
|
908
|
+
// where user input during async blob conversion would be lost
|
|
909
|
+
if (!usingProvider) {
|
|
910
|
+
form.reset();
|
|
911
|
+
// `form.reset()` fires no change event, so tell the submit affordance.
|
|
912
|
+
setHasLocalText(false);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
try {
|
|
916
|
+
// Convert blob URLs to data URLs asynchronously
|
|
917
|
+
const convertedFiles: FileUIPart[] = await Promise.all(
|
|
918
|
+
files.map(async ({ id: _id, ...item }) => {
|
|
919
|
+
if (item.url?.startsWith("blob:")) {
|
|
920
|
+
const dataUrl = await convertBlobUrlToDataUrl(item.url);
|
|
921
|
+
// If conversion failed, keep the original blob URL
|
|
922
|
+
return {
|
|
923
|
+
...item,
|
|
924
|
+
url: dataUrl ?? item.url,
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
return item;
|
|
928
|
+
}),
|
|
929
|
+
);
|
|
930
|
+
|
|
931
|
+
const result = onSubmit({ files: convertedFiles, text }, event);
|
|
932
|
+
|
|
933
|
+
// Handle both sync and async onSubmit
|
|
934
|
+
if (result instanceof Promise) {
|
|
935
|
+
try {
|
|
936
|
+
await result;
|
|
937
|
+
clear();
|
|
938
|
+
if (usingProvider) {
|
|
939
|
+
controller.textInput.clear();
|
|
940
|
+
}
|
|
941
|
+
} catch {
|
|
942
|
+
// Don't clear on error - user may want to retry
|
|
943
|
+
}
|
|
944
|
+
} else {
|
|
945
|
+
// Sync function completed without throwing, clear inputs
|
|
946
|
+
clear();
|
|
947
|
+
if (usingProvider) {
|
|
948
|
+
controller.textInput.clear();
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
} catch {
|
|
952
|
+
// Don't clear on error - user may want to retry
|
|
953
|
+
}
|
|
954
|
+
},
|
|
955
|
+
[usingProvider, controller, files, onSubmit, clear],
|
|
956
|
+
);
|
|
957
|
+
|
|
958
|
+
// Render with or without local provider
|
|
959
|
+
const inner = (
|
|
960
|
+
<>
|
|
961
|
+
<input
|
|
962
|
+
accept={accept}
|
|
963
|
+
aria-label={t("ai.promptInput.uploadFiles")}
|
|
964
|
+
className="hidden"
|
|
965
|
+
multiple={multiple}
|
|
966
|
+
onChange={handleChange}
|
|
967
|
+
ref={inputRef}
|
|
968
|
+
title={t("ai.promptInput.uploadFiles")}
|
|
969
|
+
type="file"
|
|
970
|
+
/>
|
|
971
|
+
<form className={cn("w-full", className)} onSubmit={handleSubmit} ref={formRef} {...props}>
|
|
972
|
+
{/* The composer is a soft fill + focus ring, not a hard box (#194,
|
|
973
|
+
research 02 §3a BTN-3 / 08 §C.1): the chat footer already draws a
|
|
974
|
+
`border-t` (chat-shell.tsx), so the box border was redundant. */}
|
|
975
|
+
<InputGroup variant={tone} className={cn("overflow-hidden", surfaceClassName)}>
|
|
976
|
+
{children}
|
|
977
|
+
</InputGroup>
|
|
978
|
+
</form>
|
|
979
|
+
</>
|
|
980
|
+
);
|
|
981
|
+
|
|
982
|
+
const withReferencedSources = (
|
|
983
|
+
<LocalReferencedSourcesContext.Provider value={refsCtx}>
|
|
984
|
+
<PromptInputSubmitStateContext.Provider value={submitState}>
|
|
985
|
+
{inner}
|
|
986
|
+
</PromptInputSubmitStateContext.Provider>
|
|
987
|
+
</LocalReferencedSourcesContext.Provider>
|
|
988
|
+
);
|
|
989
|
+
|
|
990
|
+
// Always provide LocalAttachmentsContext so children get validated add function
|
|
991
|
+
return (
|
|
992
|
+
<LocalAttachmentsContext.Provider value={attachmentsCtx}>
|
|
993
|
+
{withReferencedSources}
|
|
994
|
+
</LocalAttachmentsContext.Provider>
|
|
995
|
+
);
|
|
996
|
+
};
|
|
997
|
+
|
|
998
|
+
export type PromptInputBodyProps = HTMLAttributes<HTMLDivElement>;
|
|
999
|
+
|
|
1000
|
+
export const PromptInputBody = ({ className, ...props }: PromptInputBodyProps) => (
|
|
1001
|
+
<div className={cn("contents", className)} {...props} />
|
|
1002
|
+
);
|
|
1003
|
+
|
|
1004
|
+
export type PromptInputTextareaProps = ComponentProps<typeof InputGroupTextarea>;
|
|
1005
|
+
|
|
1006
|
+
export const PromptInputTextarea = ({
|
|
1007
|
+
onChange,
|
|
1008
|
+
onKeyDown,
|
|
1009
|
+
className,
|
|
1010
|
+
placeholder,
|
|
1011
|
+
...props
|
|
1012
|
+
}: PromptInputTextareaProps) => {
|
|
1013
|
+
const { t } = useLocale();
|
|
1014
|
+
const controller = useOptionalPromptInputController();
|
|
1015
|
+
const attachments = usePromptInputAttachments();
|
|
1016
|
+
const submitState = useOptionalSubmitState();
|
|
1017
|
+
const [isComposing, setIsComposing] = useState(false);
|
|
1018
|
+
|
|
1019
|
+
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = useCallback(
|
|
1020
|
+
(e) => {
|
|
1021
|
+
// Call the external onKeyDown handler first
|
|
1022
|
+
onKeyDown?.(e);
|
|
1023
|
+
|
|
1024
|
+
// If the external handler prevented default, don't run internal logic
|
|
1025
|
+
if (e.defaultPrevented) {
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
if (e.key === "Enter") {
|
|
1030
|
+
if (isComposing || e.nativeEvent.isComposing) {
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
if (e.shiftKey) {
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
e.preventDefault();
|
|
1037
|
+
|
|
1038
|
+
// Don't submit when the send control is unavailable. Query by data-slot,
|
|
1039
|
+
// NOT `button[type="submit"]`: PromptInputSubmit flips to type="button"
|
|
1040
|
+
// while generating, so the old selector matched nothing mid-stream and
|
|
1041
|
+
// `null?.disabled` (undefined, falsy) let Enter send a second message.
|
|
1042
|
+
const { form } = e.currentTarget;
|
|
1043
|
+
const submitControl = form?.querySelector<HTMLButtonElement>(
|
|
1044
|
+
`[data-slot="${SUBMIT_SLOT}"]`,
|
|
1045
|
+
);
|
|
1046
|
+
// `data-action` is the current contract (#351): "stop" blocks Enter,
|
|
1047
|
+
// "send" (including the running+typed flip) lets it through. Fall back
|
|
1048
|
+
// to the older `data-generating` flag for a consumer-supplied control
|
|
1049
|
+
// that still only sets that attribute.
|
|
1050
|
+
const action =
|
|
1051
|
+
submitControl?.dataset.action ??
|
|
1052
|
+
(submitControl?.dataset.generating === "true" ? "stop" : undefined);
|
|
1053
|
+
// `aria-disabled` is checked alongside the real `disabled` property: the
|
|
1054
|
+
// resting empty-composer state marks the control aria-disabled so it stays
|
|
1055
|
+
// FOCUSABLE (a real `disabled` button is skipped by the tab order and
|
|
1056
|
+
// announces nothing), while a consumer-supplied `disabled` is a genuine
|
|
1057
|
+
// DOM disable. Enter must be refused for both.
|
|
1058
|
+
if (
|
|
1059
|
+
submitControl?.disabled ||
|
|
1060
|
+
submitControl?.getAttribute("aria-disabled") === "true" ||
|
|
1061
|
+
action === "stop"
|
|
1062
|
+
) {
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
form?.requestSubmit();
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Remove last attachment when Backspace is pressed and textarea is empty
|
|
1070
|
+
if (e.key === "Backspace" && e.currentTarget.value === "" && attachments.files.length > 0) {
|
|
1071
|
+
e.preventDefault();
|
|
1072
|
+
const lastAttachment = attachments.files.at(-1);
|
|
1073
|
+
if (lastAttachment) {
|
|
1074
|
+
attachments.remove(lastAttachment.id);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
},
|
|
1078
|
+
[onKeyDown, isComposing, attachments],
|
|
1079
|
+
);
|
|
1080
|
+
|
|
1081
|
+
const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = useCallback(
|
|
1082
|
+
(event) => {
|
|
1083
|
+
const items = event.clipboardData?.items;
|
|
1084
|
+
|
|
1085
|
+
if (!items) {
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
const files: File[] = [];
|
|
1090
|
+
|
|
1091
|
+
for (const item of items) {
|
|
1092
|
+
if (item.kind === "file") {
|
|
1093
|
+
const file = item.getAsFile();
|
|
1094
|
+
if (file) {
|
|
1095
|
+
files.push(file);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
if (files.length > 0) {
|
|
1101
|
+
event.preventDefault();
|
|
1102
|
+
attachments.add(files);
|
|
1103
|
+
}
|
|
1104
|
+
},
|
|
1105
|
+
[attachments],
|
|
1106
|
+
);
|
|
1107
|
+
|
|
1108
|
+
const handleCompositionEnd = useCallback(() => setIsComposing(false), []);
|
|
1109
|
+
const handleCompositionStart = useCallback(() => setIsComposing(true), []);
|
|
1110
|
+
|
|
1111
|
+
const controlledProps = controller
|
|
1112
|
+
? {
|
|
1113
|
+
onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {
|
|
1114
|
+
controller.textInput.setInput(e.currentTarget.value);
|
|
1115
|
+
onChange?.(e);
|
|
1116
|
+
},
|
|
1117
|
+
value: controller.textInput.value,
|
|
1118
|
+
}
|
|
1119
|
+
: {
|
|
1120
|
+
// Uncontrolled: report only the empty↔non-empty transition so the submit
|
|
1121
|
+
// affordance can enable/disable. Boolean state ⇒ React bails out on
|
|
1122
|
+
// identical values, so this stays cheap per keystroke.
|
|
1123
|
+
onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {
|
|
1124
|
+
submitState?.setHasText(e.currentTarget.value.trim().length > 0);
|
|
1125
|
+
onChange?.(e);
|
|
1126
|
+
},
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1129
|
+
return (
|
|
1130
|
+
<InputGroupTextarea
|
|
1131
|
+
className={cn("field-sizing-content max-h-48 min-h-10", className)}
|
|
1132
|
+
name="message"
|
|
1133
|
+
onCompositionEnd={handleCompositionEnd}
|
|
1134
|
+
onCompositionStart={handleCompositionStart}
|
|
1135
|
+
onKeyDown={handleKeyDown}
|
|
1136
|
+
onPaste={handlePaste}
|
|
1137
|
+
placeholder={placeholder ?? t("ai.promptInput.placeholder")}
|
|
1138
|
+
{...props}
|
|
1139
|
+
{...controlledProps}
|
|
1140
|
+
/>
|
|
1141
|
+
);
|
|
1142
|
+
};
|
|
1143
|
+
|
|
1144
|
+
export type PromptInputHeaderProps = Omit<ComponentProps<typeof InputGroupAddon>, "align">;
|
|
1145
|
+
|
|
1146
|
+
export const PromptInputHeader = ({ className, ...props }: PromptInputHeaderProps) => (
|
|
1147
|
+
<InputGroupAddon
|
|
1148
|
+
align="block-end"
|
|
1149
|
+
className={cn("order-first flex-wrap gap-1", className)}
|
|
1150
|
+
{...props}
|
|
1151
|
+
/>
|
|
1152
|
+
);
|
|
1153
|
+
|
|
1154
|
+
export type PromptInputFooterProps = Omit<ComponentProps<typeof InputGroupAddon>, "align">;
|
|
1155
|
+
|
|
1156
|
+
export const PromptInputFooter = ({ className, ...props }: PromptInputFooterProps) => (
|
|
1157
|
+
<InputGroupAddon
|
|
1158
|
+
align="block-end"
|
|
1159
|
+
className={cn("justify-between gap-1", className)}
|
|
1160
|
+
{...props}
|
|
1161
|
+
/>
|
|
1162
|
+
);
|
|
1163
|
+
|
|
1164
|
+
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
|
|
1165
|
+
|
|
1166
|
+
export const PromptInputTools = ({ className, ...props }: PromptInputToolsProps) => (
|
|
1167
|
+
<div className={cn("flex min-w-0 items-center gap-1", className)} {...props} />
|
|
1168
|
+
);
|
|
1169
|
+
|
|
1170
|
+
export type PromptInputButtonTooltip =
|
|
1171
|
+
| string
|
|
1172
|
+
| {
|
|
1173
|
+
content: ReactNode;
|
|
1174
|
+
shortcut?: string;
|
|
1175
|
+
side?: ComponentProps<typeof TooltipContent>["side"];
|
|
1176
|
+
};
|
|
1177
|
+
|
|
1178
|
+
export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton> & {
|
|
1179
|
+
tooltip?: PromptInputButtonTooltip;
|
|
1180
|
+
};
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* True when `node` renders any visible text (a non-blank string/number node,
|
|
1184
|
+
* at any nesting depth) rather than icon-only content (bare SVGs/icon
|
|
1185
|
+
* components). Adapts the same "inspect the children's shape" notion the
|
|
1186
|
+
* size heuristic below uses (`Children.count(...) > 1`) to the question that
|
|
1187
|
+
* actually matters for the accessible name: is there a visible label already,
|
|
1188
|
+
* not merely how many top-level children there are.
|
|
1189
|
+
*/
|
|
1190
|
+
const hasVisibleText = (node: ReactNode): boolean => {
|
|
1191
|
+
if (typeof node === "string" || typeof node === "number") {
|
|
1192
|
+
return String(node).trim().length > 0;
|
|
1193
|
+
}
|
|
1194
|
+
if (Array.isArray(node)) {
|
|
1195
|
+
return node.some(hasVisibleText);
|
|
1196
|
+
}
|
|
1197
|
+
if (isValidElement<{ children?: ReactNode }>(node)) {
|
|
1198
|
+
return hasVisibleText(node.props.children);
|
|
1199
|
+
}
|
|
1200
|
+
return false;
|
|
1201
|
+
};
|
|
1202
|
+
|
|
1203
|
+
export const PromptInputButton = ({
|
|
1204
|
+
variant = "ghost",
|
|
1205
|
+
className,
|
|
1206
|
+
size,
|
|
1207
|
+
tooltip,
|
|
1208
|
+
"aria-label": ariaLabelProp,
|
|
1209
|
+
...props
|
|
1210
|
+
}: PromptInputButtonProps) => {
|
|
1211
|
+
const newSize = size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm");
|
|
1212
|
+
|
|
1213
|
+
const tooltipContent = tooltip
|
|
1214
|
+
? typeof tooltip === "string"
|
|
1215
|
+
? tooltip
|
|
1216
|
+
: tooltip.content
|
|
1217
|
+
: undefined;
|
|
1218
|
+
|
|
1219
|
+
// Icon-only buttons (Attach, Voice, …) commonly carry ONLY a tooltip and no
|
|
1220
|
+
// visible text, so the tooltip is the only description of what the button
|
|
1221
|
+
// does — default the accessible name to it. A button that already has a
|
|
1222
|
+
// VISIBLE TEXT label must NOT have that label replaced by the tooltip (WCAG
|
|
1223
|
+
// 2.5.3 Label in Name) — the derived name is opt-in to icon-only buttons
|
|
1224
|
+
// only. An explicit, DEFINED `aria-label` still wins over the derived
|
|
1225
|
+
// default; an explicitly-passed `aria-label={undefined}` must NOT clobber
|
|
1226
|
+
// it, so we pull `aria-label` out of `props` above (it is applied, as the
|
|
1227
|
+
// final computed value, AFTER the `{...props}` spread below — the spread
|
|
1228
|
+
// can no longer reintroduce the `undefined` key over it).
|
|
1229
|
+
const isIconOnly = !hasVisibleText(props.children);
|
|
1230
|
+
const ariaLabel =
|
|
1231
|
+
ariaLabelProp ??
|
|
1232
|
+
(isIconOnly && typeof tooltipContent === "string" ? tooltipContent : undefined);
|
|
1233
|
+
|
|
1234
|
+
const button = (
|
|
1235
|
+
<InputGroupButton
|
|
1236
|
+
className={cn(className)}
|
|
1237
|
+
size={newSize}
|
|
1238
|
+
type="button"
|
|
1239
|
+
variant={variant}
|
|
1240
|
+
{...props}
|
|
1241
|
+
aria-label={ariaLabel}
|
|
1242
|
+
/>
|
|
1243
|
+
);
|
|
1244
|
+
|
|
1245
|
+
if (!tooltip) {
|
|
1246
|
+
return button;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
const shortcut = typeof tooltip === "string" ? undefined : tooltip.shortcut;
|
|
1250
|
+
const side = typeof tooltip === "string" ? "top" : (tooltip.side ?? "top");
|
|
1251
|
+
|
|
1252
|
+
// Self-provide so the button works anywhere — a consumer rendering a bare
|
|
1253
|
+
// composer (a block, a story) shouldn't have to remember to mount a global
|
|
1254
|
+
// TooltipProvider. Mirrors the ThemeSwitcher self-provision convention.
|
|
1255
|
+
return (
|
|
1256
|
+
<TooltipProvider>
|
|
1257
|
+
<Tooltip>
|
|
1258
|
+
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
|
1259
|
+
<TooltipContent side={side}>
|
|
1260
|
+
{tooltipContent}
|
|
1261
|
+
{shortcut && <span className="ms-2 text-muted-foreground">{shortcut}</span>}
|
|
1262
|
+
</TooltipContent>
|
|
1263
|
+
</Tooltip>
|
|
1264
|
+
</TooltipProvider>
|
|
1265
|
+
);
|
|
1266
|
+
};
|
|
1267
|
+
|
|
1268
|
+
export type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;
|
|
1269
|
+
export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
|
|
1270
|
+
<DropdownMenu {...props} />
|
|
1271
|
+
);
|
|
1272
|
+
|
|
1273
|
+
export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
|
|
1274
|
+
|
|
1275
|
+
export const PromptInputActionMenuTrigger = ({
|
|
1276
|
+
className,
|
|
1277
|
+
children,
|
|
1278
|
+
...props
|
|
1279
|
+
}: PromptInputActionMenuTriggerProps) => (
|
|
1280
|
+
<DropdownMenuTrigger asChild>
|
|
1281
|
+
<PromptInputButton className={className} {...props}>
|
|
1282
|
+
{children ?? <PlusIcon className="size-4" />}
|
|
1283
|
+
</PromptInputButton>
|
|
1284
|
+
</DropdownMenuTrigger>
|
|
1285
|
+
);
|
|
1286
|
+
|
|
1287
|
+
export type PromptInputActionMenuContentProps = ComponentProps<typeof DropdownMenuContent>;
|
|
1288
|
+
export const PromptInputActionMenuContent = ({
|
|
1289
|
+
className,
|
|
1290
|
+
...props
|
|
1291
|
+
}: PromptInputActionMenuContentProps) => (
|
|
1292
|
+
<DropdownMenuContent align="start" className={cn(className)} {...props} />
|
|
1293
|
+
);
|
|
1294
|
+
|
|
1295
|
+
export type PromptInputActionMenuItemProps = ComponentProps<typeof DropdownMenuItem>;
|
|
1296
|
+
export const PromptInputActionMenuItem = ({
|
|
1297
|
+
className,
|
|
1298
|
+
...props
|
|
1299
|
+
}: PromptInputActionMenuItemProps) => <DropdownMenuItem className={cn(className)} {...props} />;
|
|
1300
|
+
|
|
1301
|
+
// Note: Actions that perform side-effects (like opening a file dialog)
|
|
1302
|
+
// are provided in opt-in modules (e.g., prompt-input-attachments).
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* Which action the merged primary control currently performs (#351). The
|
|
1306
|
+
* component owns the AFFORDANCE only — it always invokes `onSubmit` (via the
|
|
1307
|
+
* normal form submit) once the action is "send", never `onStop`. What a
|
|
1308
|
+
* mid-turn submit MEANS (queued, interleaved, dropped) is entirely up to the
|
|
1309
|
+
* app's own `onSubmit`/runtime — brand-ui asserts nothing about that (D5).
|
|
1310
|
+
*/
|
|
1311
|
+
export type PromptInputSubmitAction = "send" | "stop";
|
|
1312
|
+
|
|
1313
|
+
export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
|
|
1314
|
+
status?: ChatStatus;
|
|
1315
|
+
onStop?: () => void;
|
|
1316
|
+
/**
|
|
1317
|
+
* Glyph for the SEND action only — survives the send↔stop flip, so a
|
|
1318
|
+
* consumer's resting glyph (e.g. `Composer`'s circular arrow) is no longer
|
|
1319
|
+
* lost while generating with a non-empty composer. Prefer this over
|
|
1320
|
+
* `children`.
|
|
1321
|
+
*/
|
|
1322
|
+
sendIcon?: ReactNode;
|
|
1323
|
+
/**
|
|
1324
|
+
* @deprecated Replaces the glyph for **every** status — ready, submitted,
|
|
1325
|
+
* streaming AND error — which is why generating/error affordances can
|
|
1326
|
+
* disappear even though the control is still live. Prefer `sendIcon`,
|
|
1327
|
+
* which only ever replaces the resting Send glyph.
|
|
1328
|
+
*/
|
|
1329
|
+
children?: ReactNode;
|
|
1330
|
+
};
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* Merged primary-action contract (#351):
|
|
1334
|
+
* 1. `ready`/`undefined`/`error` → **Send** (unchanged).
|
|
1335
|
+
* 2. `submitted`/`streaming` + composer EMPTY → **Stop** (unchanged).
|
|
1336
|
+
* 3. `submitted`/`streaming` + composer NON-EMPTY → **Send** (the fix — a
|
|
1337
|
+
* follow-up can always be composed and submitted mid-turn).
|
|
1338
|
+
* 4. A `PromptInputStop` mounted alongside → this control is ALWAYS Send;
|
|
1339
|
+
* the dedicated control owns stopping (the composed "separate" shape).
|
|
1340
|
+
*/
|
|
1341
|
+
export const PromptInputSubmit = ({
|
|
1342
|
+
className,
|
|
1343
|
+
variant = "default",
|
|
1344
|
+
size = "icon-sm",
|
|
1345
|
+
status,
|
|
1346
|
+
onStop,
|
|
1347
|
+
onClick,
|
|
1348
|
+
children,
|
|
1349
|
+
sendIcon,
|
|
1350
|
+
disabled,
|
|
1351
|
+
...props
|
|
1352
|
+
}: PromptInputSubmitProps) => {
|
|
1353
|
+
const { t } = useLocale();
|
|
1354
|
+
const isGenerating = status === "submitted" || status === "streaming";
|
|
1355
|
+
const submitState = useOptionalSubmitState();
|
|
1356
|
+
|
|
1357
|
+
const action: PromptInputSubmitAction =
|
|
1358
|
+
isGenerating && !submitState?.canSubmit && !submitState?.hasDedicatedStop ? "stop" : "send";
|
|
1359
|
+
|
|
1360
|
+
// Auto-disable only the Send action at rest, and only inside a PromptInput
|
|
1361
|
+
// (a standalone submit has no context and keeps working). `error` must stay
|
|
1362
|
+
// clickable to retry, and the Stop action is never auto-disabled. An
|
|
1363
|
+
// explicit `disabled` prop always wins.
|
|
1364
|
+
//
|
|
1365
|
+
// NOTE this drives `aria-disabled`, not the native attribute — see the render.
|
|
1366
|
+
const autoDisabled =
|
|
1367
|
+
action === "send" && status !== "error" && submitState ? !submitState.canSubmit : false;
|
|
1368
|
+
|
|
1369
|
+
let Icon = sendIcon ?? <CornerDownLeftIcon className="size-4" data-rtl-flip />;
|
|
1370
|
+
|
|
1371
|
+
if (action === "stop") {
|
|
1372
|
+
Icon = status === "submitted" ? <Spinner /> : <SquareIcon className="size-4" />;
|
|
1373
|
+
} else if (status === "error") {
|
|
1374
|
+
Icon = <XIcon className="size-4" />;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
const handleClick = useCallback(
|
|
1378
|
+
(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
1379
|
+
// `aria-disabled` (see the render) does not block activation the way the
|
|
1380
|
+
// native attribute does, so the handler has to.
|
|
1381
|
+
if (autoDisabled) {
|
|
1382
|
+
e.preventDefault();
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
if (action === "stop" && onStop) {
|
|
1386
|
+
e.preventDefault();
|
|
1387
|
+
onStop();
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
onClick?.(e);
|
|
1391
|
+
},
|
|
1392
|
+
[autoDisabled, action, onStop, onClick],
|
|
1393
|
+
);
|
|
1394
|
+
|
|
1395
|
+
return (
|
|
1396
|
+
<InputGroupButton
|
|
1397
|
+
aria-label={action === "stop" ? t("ai.promptInput.stop") : t("ai.promptInput.submit")}
|
|
1398
|
+
/*
|
|
1399
|
+
* `aria-disabled`, NOT the native `disabled` attribute, for the RESTING
|
|
1400
|
+
* empty-composer state.
|
|
1401
|
+
*
|
|
1402
|
+
* Two reasons. (1) A focused control that becomes natively disabled is
|
|
1403
|
+
* removed from the focus order by the HTML focus-fixup rule, so focus
|
|
1404
|
+
* drops to <body> — after every keyboard-initiated send, since the
|
|
1405
|
+
* composer clears and the button goes not-ready in the same commit. That
|
|
1406
|
+
* silently strands keyboard and screen-reader users mid-conversation.
|
|
1407
|
+
* (2) `interaction-guidelines.md` says a submit stays enabled until the
|
|
1408
|
+
* request starts; aria-disabled keeps it a real, focusable tab stop that
|
|
1409
|
+
* still announces its state, while `handleClick` + `handleSubmit` do the
|
|
1410
|
+
* actual blocking.
|
|
1411
|
+
*
|
|
1412
|
+
* An explicit `disabled` prop is still honoured natively — that is the
|
|
1413
|
+
* consumer deliberately taking the control out of the tab order.
|
|
1414
|
+
*/
|
|
1415
|
+
aria-disabled={autoDisabled || undefined}
|
|
1416
|
+
className={cn(autoDisabled && "cursor-not-allowed opacity-50 hover:bg-primary", className)}
|
|
1417
|
+
data-action={action}
|
|
1418
|
+
data-generating={action === "stop" ? "true" : undefined}
|
|
1419
|
+
data-slot={SUBMIT_SLOT}
|
|
1420
|
+
disabled={disabled}
|
|
1421
|
+
onClick={handleClick}
|
|
1422
|
+
size={size}
|
|
1423
|
+
type={autoDisabled || (action === "stop" && onStop) ? "button" : "submit"}
|
|
1424
|
+
variant={variant}
|
|
1425
|
+
{...props}
|
|
1426
|
+
>
|
|
1427
|
+
{children ?? (
|
|
1428
|
+
<span
|
|
1429
|
+
key={`${status ?? "ready"}-${action}`}
|
|
1430
|
+
className="flex animate-in fade-in-0 zoom-in-95 duration-fast ease-entrance"
|
|
1431
|
+
>
|
|
1432
|
+
{Icon}
|
|
1433
|
+
</span>
|
|
1434
|
+
)}
|
|
1435
|
+
</InputGroupButton>
|
|
1436
|
+
);
|
|
1437
|
+
};
|
|
1438
|
+
|
|
1439
|
+
/** The `data-slot` `PromptInputStop` renders with. */
|
|
1440
|
+
const STOP_SLOT = "prompt-input-stop";
|
|
1441
|
+
|
|
1442
|
+
export type PromptInputStopProps = ComponentProps<typeof InputGroupButton> & {
|
|
1443
|
+
status?: ChatStatus;
|
|
1444
|
+
onStop: () => void;
|
|
1445
|
+
/** Glyph override; defaults to a square Stop icon. */
|
|
1446
|
+
children?: ReactNode;
|
|
1447
|
+
};
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* A dedicated Stop control — the composed answer to "two buttons" (#351;
|
|
1451
|
+
* never a `mode` prop, per `component-api.md`'s ban on behavioural-mode
|
|
1452
|
+
* props). Renders `null` unless the turn is running (`status` is `submitted`
|
|
1453
|
+
* or `streaming`). While at least one `PromptInputStop` is mounted inside the
|
|
1454
|
+
* same `PromptInput`, `PromptInputSubmit` stays the Send action in every
|
|
1455
|
+
* state — the dedicated control owns stopping instead.
|
|
1456
|
+
*/
|
|
1457
|
+
export const PromptInputStop = ({
|
|
1458
|
+
className,
|
|
1459
|
+
variant = "outline",
|
|
1460
|
+
size = "icon-sm",
|
|
1461
|
+
status,
|
|
1462
|
+
onStop,
|
|
1463
|
+
onClick,
|
|
1464
|
+
children,
|
|
1465
|
+
...props
|
|
1466
|
+
}: PromptInputStopProps) => {
|
|
1467
|
+
const { t } = useLocale();
|
|
1468
|
+
const submitState = useOptionalSubmitState();
|
|
1469
|
+
const isGenerating = status === "submitted" || status === "streaming";
|
|
1470
|
+
|
|
1471
|
+
useEffect(() => {
|
|
1472
|
+
const unregister = submitState?.registerStop();
|
|
1473
|
+
return unregister;
|
|
1474
|
+
}, [submitState]);
|
|
1475
|
+
|
|
1476
|
+
const handleClick = useCallback(
|
|
1477
|
+
(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
1478
|
+
onClick?.(e);
|
|
1479
|
+
if (e.defaultPrevented) {
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
onStop();
|
|
1483
|
+
},
|
|
1484
|
+
[onClick, onStop],
|
|
1485
|
+
);
|
|
1486
|
+
|
|
1487
|
+
if (!isGenerating) {
|
|
1488
|
+
return null;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
return (
|
|
1492
|
+
<InputGroupButton
|
|
1493
|
+
aria-label={t("ai.promptInput.stop")}
|
|
1494
|
+
className={cn(className)}
|
|
1495
|
+
data-slot={STOP_SLOT}
|
|
1496
|
+
onClick={handleClick}
|
|
1497
|
+
size={size}
|
|
1498
|
+
type="button"
|
|
1499
|
+
variant={variant}
|
|
1500
|
+
{...props}
|
|
1501
|
+
>
|
|
1502
|
+
{children ?? <SquareIcon className="size-4" />}
|
|
1503
|
+
</InputGroupButton>
|
|
1504
|
+
);
|
|
1505
|
+
};
|
|
1506
|
+
|
|
1507
|
+
export type PromptInputSelectProps = ComponentProps<typeof Select>;
|
|
1508
|
+
|
|
1509
|
+
export const PromptInputSelect = (props: PromptInputSelectProps) => <Select {...props} />;
|
|
1510
|
+
|
|
1511
|
+
export type PromptInputSelectTriggerProps = ComponentProps<typeof SelectTrigger>;
|
|
1512
|
+
|
|
1513
|
+
export const PromptInputSelectTrigger = ({
|
|
1514
|
+
className,
|
|
1515
|
+
...props
|
|
1516
|
+
}: PromptInputSelectTriggerProps) => (
|
|
1517
|
+
<SelectTrigger
|
|
1518
|
+
className={cn(
|
|
1519
|
+
"border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors",
|
|
1520
|
+
"hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground",
|
|
1521
|
+
className,
|
|
1522
|
+
)}
|
|
1523
|
+
{...props}
|
|
1524
|
+
/>
|
|
1525
|
+
);
|
|
1526
|
+
|
|
1527
|
+
export type PromptInputSelectContentProps = ComponentProps<typeof SelectContent>;
|
|
1528
|
+
|
|
1529
|
+
export const PromptInputSelectContent = ({
|
|
1530
|
+
className,
|
|
1531
|
+
...props
|
|
1532
|
+
}: PromptInputSelectContentProps) => <SelectContent className={cn(className)} {...props} />;
|
|
1533
|
+
|
|
1534
|
+
export type PromptInputSelectItemProps = ComponentProps<typeof SelectItem>;
|
|
1535
|
+
|
|
1536
|
+
export const PromptInputSelectItem = ({ className, ...props }: PromptInputSelectItemProps) => (
|
|
1537
|
+
<SelectItem className={cn(className)} {...props} />
|
|
1538
|
+
);
|
|
1539
|
+
|
|
1540
|
+
export type PromptInputSelectValueProps = ComponentProps<typeof SelectValue>;
|
|
1541
|
+
|
|
1542
|
+
export const PromptInputSelectValue = ({ className, ...props }: PromptInputSelectValueProps) => (
|
|
1543
|
+
<SelectValue className={cn(className)} {...props} />
|
|
1544
|
+
);
|
|
1545
|
+
|
|
1546
|
+
export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>;
|
|
1547
|
+
|
|
1548
|
+
export const PromptInputHoverCard = ({
|
|
1549
|
+
openDelay = 0,
|
|
1550
|
+
closeDelay = 0,
|
|
1551
|
+
...props
|
|
1552
|
+
}: PromptInputHoverCardProps) => (
|
|
1553
|
+
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
|
|
1554
|
+
);
|
|
1555
|
+
|
|
1556
|
+
export type PromptInputHoverCardTriggerProps = ComponentProps<typeof HoverCardTrigger>;
|
|
1557
|
+
|
|
1558
|
+
export const PromptInputHoverCardTrigger = (props: PromptInputHoverCardTriggerProps) => (
|
|
1559
|
+
<HoverCardTrigger {...props} />
|
|
1560
|
+
);
|
|
1561
|
+
|
|
1562
|
+
export type PromptInputHoverCardContentProps = ComponentProps<typeof HoverCardContent>;
|
|
1563
|
+
|
|
1564
|
+
export const PromptInputHoverCardContent = ({
|
|
1565
|
+
align = "start",
|
|
1566
|
+
...props
|
|
1567
|
+
}: PromptInputHoverCardContentProps) => <HoverCardContent align={align} {...props} />;
|
|
1568
|
+
|
|
1569
|
+
export type PromptInputTabsListProps = HTMLAttributes<HTMLDivElement>;
|
|
1570
|
+
|
|
1571
|
+
export const PromptInputTabsList = ({ className, ...props }: PromptInputTabsListProps) => (
|
|
1572
|
+
<div className={cn(className)} {...props} />
|
|
1573
|
+
);
|
|
1574
|
+
|
|
1575
|
+
export type PromptInputTabProps = HTMLAttributes<HTMLDivElement>;
|
|
1576
|
+
|
|
1577
|
+
export const PromptInputTab = ({ className, ...props }: PromptInputTabProps) => (
|
|
1578
|
+
<div className={cn(className)} {...props} />
|
|
1579
|
+
);
|
|
1580
|
+
|
|
1581
|
+
export type PromptInputTabLabelProps = HTMLAttributes<HTMLHeadingElement>;
|
|
1582
|
+
|
|
1583
|
+
export const PromptInputTabLabel = ({ className, ...props }: PromptInputTabLabelProps) => (
|
|
1584
|
+
// Content provided via children in props
|
|
1585
|
+
// oxlint-disable-next-line eslint-plugin-jsx-a11y(heading-has-content)
|
|
1586
|
+
<h3 className={cn("mb-2 px-3 font-medium text-muted-foreground text-xs", className)} {...props} />
|
|
1587
|
+
);
|
|
1588
|
+
|
|
1589
|
+
export type PromptInputTabBodyProps = HTMLAttributes<HTMLDivElement>;
|
|
1590
|
+
|
|
1591
|
+
export const PromptInputTabBody = ({ className, ...props }: PromptInputTabBodyProps) => (
|
|
1592
|
+
<div className={cn("space-y-1", className)} {...props} />
|
|
1593
|
+
);
|
|
1594
|
+
|
|
1595
|
+
export type PromptInputTabItemProps = HTMLAttributes<HTMLDivElement>;
|
|
1596
|
+
|
|
1597
|
+
export const PromptInputTabItem = ({ className, ...props }: PromptInputTabItemProps) => (
|
|
1598
|
+
<div
|
|
1599
|
+
className={cn("flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent", className)}
|
|
1600
|
+
{...props}
|
|
1601
|
+
/>
|
|
1602
|
+
);
|
|
1603
|
+
|
|
1604
|
+
export type PromptInputCommandProps = ComponentProps<typeof Command>;
|
|
1605
|
+
|
|
1606
|
+
export const PromptInputCommand = ({ className, ...props }: PromptInputCommandProps) => (
|
|
1607
|
+
<Command className={cn(className)} {...props} />
|
|
1608
|
+
);
|
|
1609
|
+
|
|
1610
|
+
export type PromptInputCommandInputProps = ComponentProps<typeof CommandInput>;
|
|
1611
|
+
|
|
1612
|
+
export const PromptInputCommandInput = ({ className, ...props }: PromptInputCommandInputProps) => (
|
|
1613
|
+
<CommandInput className={cn(className)} {...props} />
|
|
1614
|
+
);
|
|
1615
|
+
|
|
1616
|
+
export type PromptInputCommandListProps = ComponentProps<typeof CommandList>;
|
|
1617
|
+
|
|
1618
|
+
export const PromptInputCommandList = ({ className, ...props }: PromptInputCommandListProps) => (
|
|
1619
|
+
<CommandList className={cn(className)} {...props} />
|
|
1620
|
+
);
|
|
1621
|
+
|
|
1622
|
+
export type PromptInputCommandEmptyProps = ComponentProps<typeof CommandEmpty>;
|
|
1623
|
+
|
|
1624
|
+
export const PromptInputCommandEmpty = ({ className, ...props }: PromptInputCommandEmptyProps) => (
|
|
1625
|
+
<CommandEmpty className={cn(className)} {...props} />
|
|
1626
|
+
);
|
|
1627
|
+
|
|
1628
|
+
export type PromptInputCommandGroupProps = ComponentProps<typeof CommandGroup>;
|
|
1629
|
+
|
|
1630
|
+
export const PromptInputCommandGroup = ({ className, ...props }: PromptInputCommandGroupProps) => (
|
|
1631
|
+
<CommandGroup className={cn(className)} {...props} />
|
|
1632
|
+
);
|
|
1633
|
+
|
|
1634
|
+
export type PromptInputCommandItemProps = ComponentProps<typeof CommandItem>;
|
|
1635
|
+
|
|
1636
|
+
/**
|
|
1637
|
+
* A selectable row inside `PromptInputCommand` (e.g. an `@`-mention popup).
|
|
1638
|
+
*
|
|
1639
|
+
* `id`/`role`/`aria-selected` are assigned internally by the `cmdk` dependency
|
|
1640
|
+
* itself — one layer below this wrapper and below `@elabs-ai/components-ui`'s
|
|
1641
|
+
* own `CommandItem` — and applied AFTER any props you pass, so a
|
|
1642
|
+
* consumer-supplied `id`/`role`/`aria-selected` is silently overridden
|
|
1643
|
+
* (`CommandItem` warns about this in development; #365).
|
|
1644
|
+
*
|
|
1645
|
+
* To wire `aria-activedescendant` from an input rendered OUTSIDE this
|
|
1646
|
+
* `PromptInputCommand` tree (the composer textarea driving the popup), use
|
|
1647
|
+
* `PromptInputCommand`'s `onActiveItemIdChange` callback (inherited from
|
|
1648
|
+
* `@elabs-ai/components-ui`'s `Command`, since `PromptInputCommandProps =
|
|
1649
|
+
* ComponentProps<typeof Command>`) instead of reading the id back
|
|
1650
|
+
* positionally from the DOM:
|
|
1651
|
+
*
|
|
1652
|
+
* ```tsx
|
|
1653
|
+
* const [activeId, setActiveId] = useState<string>();
|
|
1654
|
+
* <textarea role="combobox" aria-expanded={open} aria-controls={listId}
|
|
1655
|
+
* aria-activedescendant={activeId} />
|
|
1656
|
+
* <PromptInputCommand onActiveItemIdChange={setActiveId}>…</PromptInputCommand>
|
|
1657
|
+
* ```
|
|
1658
|
+
*/
|
|
1659
|
+
export const PromptInputCommandItem = ({ className, ...props }: PromptInputCommandItemProps) => (
|
|
1660
|
+
<CommandItem className={cn(className)} {...props} />
|
|
1661
|
+
);
|
|
1662
|
+
|
|
1663
|
+
export type PromptInputCommandSeparatorProps = ComponentProps<typeof CommandSeparator>;
|
|
1664
|
+
|
|
1665
|
+
export const PromptInputCommandSeparator = ({
|
|
1666
|
+
className,
|
|
1667
|
+
...props
|
|
1668
|
+
}: PromptInputCommandSeparatorProps) => <CommandSeparator className={cn(className)} {...props} />;
|