@vvedantb/ui 0.3.2 → 0.4.0-rc.1
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/README.md +66 -19
- package/app/README.md +8 -8
- package/package.json +5 -30
- package/src/composer/Composer.tsx +1 -0
- package/src/composer/index.ts +1 -0
- package/src/composer/input-area/ComposerSlashMenu.tsx +137 -0
- package/src/composer/input-area/ComposerSlashRow.tsx +75 -0
- package/src/composer/input-area/ConnectionStatusMenuItem.tsx +34 -0
- package/src/composer/input-area/FadePresence.tsx +31 -0
- package/src/composer/input-area/InputActions.tsx +63 -0
- package/src/composer/input-area/InputArea.tsx +513 -0
- package/src/composer/input-area/InputBanners.tsx +61 -0
- package/src/composer/input-area/PromoBanner.tsx +53 -0
- package/src/composer/input-area/composerCompact.ts +52 -0
- package/src/composer/input-area/composerPrediction.ts +51 -0
- package/src/composer/input-area/composerSlashItems.ts +206 -0
- package/src/composer/input-area/composerSlashOptionId.ts +7 -0
- package/src/composer/input-area/composerSlashPosition.ts +40 -0
- package/src/composer/input-area/composerSlashTrigger.ts +50 -0
- package/src/composer/input-area/index.ts +30 -0
- package/src/composer/input-area/inputPresets.ts +14 -0
- package/src/composer/input-area/motion.ts +7 -0
- package/src/composer/input-area/types.ts +25 -0
- package/src/composer/input-area/useComposerCompact.ts +46 -0
- package/src/composer/input-area/useComposerSlashMenu.ts +203 -0
- package/src/composer/input-area/useInputAreaPrediction.ts +49 -0
- package/src/index.ts +21 -0
- package/src/ui/button.tsx +11 -2
- package/src/ui/dropdown-menu.tsx +35 -7
- package/tokens/README.md +2 -2
- package/src/sonner.ts +0 -2
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import type { ChangeEvent, ReactNode } from "react";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
4
|
+
import { AnimatePresence, domAnimation, LazyMotion, m } from "motion/react";
|
|
5
|
+
import { IconArrowUp, IconLoader2, IconPlayerStop } from "@tabler/icons-react";
|
|
6
|
+
import { cn } from "../../utils/cn";
|
|
7
|
+
import { Button } from "../../ui/button";
|
|
8
|
+
import { InputGroupAddon } from "../../ui/input-group";
|
|
9
|
+
import {
|
|
10
|
+
Tooltip,
|
|
11
|
+
TooltipContent,
|
|
12
|
+
TooltipProvider,
|
|
13
|
+
TooltipTrigger,
|
|
14
|
+
} from "../../ui/tooltip";
|
|
15
|
+
import {
|
|
16
|
+
PromptInput,
|
|
17
|
+
PromptInputBody,
|
|
18
|
+
PromptInputFooter,
|
|
19
|
+
PromptInputTextarea,
|
|
20
|
+
PromptInputTools,
|
|
21
|
+
} from "../../ai-elements/prompt-input";
|
|
22
|
+
import { SpeechInput } from "../../ai-elements/speech-input";
|
|
23
|
+
import { chatInputContainerClass } from "../../ui/surface-classes";
|
|
24
|
+
import { ComposerSlashMenu } from "./ComposerSlashMenu";
|
|
25
|
+
import { FadePresence } from "./FadePresence";
|
|
26
|
+
import { InputActions } from "./InputActions";
|
|
27
|
+
import { motionFast } from "./motion";
|
|
28
|
+
import type { ComposerSlashItem } from "./composerSlashItems";
|
|
29
|
+
import { useComposerCompact } from "./useComposerCompact";
|
|
30
|
+
import { useComposerSlashMenu } from "./useComposerSlashMenu";
|
|
31
|
+
import { useInputAreaPrediction } from "./useInputAreaPrediction";
|
|
32
|
+
|
|
33
|
+
const COMPACT_EDITOR =
|
|
34
|
+
"flex min-h-9 max-h-9 min-w-0 w-auto flex-1 items-center self-center overflow-hidden whitespace-pre! rounded-none px-1 py-2 text-left leading-5";
|
|
35
|
+
const COMPACT_START_ADDON =
|
|
36
|
+
"order-first shrink-0 justify-start gap-1 py-0 pl-1.5 pr-0 has-[>button]:ml-0";
|
|
37
|
+
const COMPACT_END_ADDON =
|
|
38
|
+
"order-last shrink-0 justify-end gap-1 py-0 pr-1.5 pl-1 has-[>button]:mr-0";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Default ghost text class. It matches the original app composer, whose theme
|
|
42
|
+
* defines the bare muted utility. Pass `predictionTextClassName` to use another class.
|
|
43
|
+
*/
|
|
44
|
+
export const DEFAULT_PREDICTION_TEXT_CLASS = "text-muted"; // check-tokens-allow: text-muted
|
|
45
|
+
|
|
46
|
+
export const DEFAULT_ATTACH_ACCEPT =
|
|
47
|
+
"image/png,image/jpeg,image/jpg,image/webp,application/pdf";
|
|
48
|
+
|
|
49
|
+
export type InputAreaSlashContext = {
|
|
50
|
+
/** Opens the file picker, as the `attach` slash action does. */
|
|
51
|
+
openFilePicker: () => void;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type InputAreaProps = {
|
|
55
|
+
value?: string;
|
|
56
|
+
onChange?: (value: string) => void;
|
|
57
|
+
/** Initial text when `value` is omitted (uncontrolled). */
|
|
58
|
+
defaultValue?: string;
|
|
59
|
+
placeholder?: string;
|
|
60
|
+
|
|
61
|
+
/** Called with the trimmed text. The input clears first, through `onChange`. */
|
|
62
|
+
onSubmit?: (text: string) => void;
|
|
63
|
+
onStop?: () => void;
|
|
64
|
+
isStreaming?: boolean;
|
|
65
|
+
isSending?: boolean;
|
|
66
|
+
isUploading?: boolean;
|
|
67
|
+
/** When omitted: the draft has text, or `fileCount` or `chipCount` is above 0. */
|
|
68
|
+
canSubmit?: boolean;
|
|
69
|
+
|
|
70
|
+
/** Inputs to the compact pill. */
|
|
71
|
+
fileCount?: number;
|
|
72
|
+
chipCount?: number;
|
|
73
|
+
|
|
74
|
+
showDictate?: boolean;
|
|
75
|
+
showFileUpload?: boolean;
|
|
76
|
+
/** Replaces the built-in file picker click. */
|
|
77
|
+
onAttachFile?: () => void;
|
|
78
|
+
/** Receives files chosen in the picker or dropped on the input. */
|
|
79
|
+
onFilesSelected?: (files: FileList) => void;
|
|
80
|
+
attachAccept?: string;
|
|
81
|
+
/** Receives dictated text. Default: append it to the draft. */
|
|
82
|
+
onDictateTranscription?: (text: string) => void;
|
|
83
|
+
|
|
84
|
+
banners?: ReactNode;
|
|
85
|
+
promoBanner?: ReactNode;
|
|
86
|
+
queue?: ReactNode;
|
|
87
|
+
/** Attachment strip. Shown inside the input, above the text. */
|
|
88
|
+
attachments?: ReactNode;
|
|
89
|
+
/** Chip row. Shown inside the input, above the text. */
|
|
90
|
+
chips?: ReactNode;
|
|
91
|
+
/** Extra rows in the plus menu after the attach row. Include your own separators. */
|
|
92
|
+
plusMenuExtra?: ReactNode;
|
|
93
|
+
|
|
94
|
+
/** Items for the `/` menu. Omit to turn the menu off. */
|
|
95
|
+
slashItems?: ComposerSlashItem[];
|
|
96
|
+
onSlashSelect?: (item: ComposerSlashItem, context: InputAreaSlashContext) => void;
|
|
97
|
+
/** Wrap a slash row, for example in a hover card. Return `row` for the default. */
|
|
98
|
+
renderSlashItem?: (item: ComposerSlashItem, row: ReactNode) => ReactNode;
|
|
99
|
+
|
|
100
|
+
/** Returns a continuation for the draft. Omit to turn ghost text off. */
|
|
101
|
+
getPrediction?: (text: string) => Promise<string>;
|
|
102
|
+
predictionEnabled?: boolean;
|
|
103
|
+
/** Classes on the ghost suggestion span. Default matches the original app composer. */
|
|
104
|
+
predictionTextClassName?: string;
|
|
105
|
+
|
|
106
|
+
/** Wrapper around banners, queue and the input. */
|
|
107
|
+
className?: string;
|
|
108
|
+
/** Extra classes on the input container. */
|
|
109
|
+
containerClassName?: string;
|
|
110
|
+
/** Border and ring classes for the input container. */
|
|
111
|
+
borderClassName?: string;
|
|
112
|
+
submitButtonClassName?: string;
|
|
113
|
+
submitIconClassName?: string;
|
|
114
|
+
|
|
115
|
+
/** Called on Backspace at the start of the text while `chipCount` is above 0. */
|
|
116
|
+
onRemoveLastChip?: () => void;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export function InputArea({
|
|
120
|
+
value: controlledValue,
|
|
121
|
+
onChange,
|
|
122
|
+
defaultValue = "",
|
|
123
|
+
placeholder = "Ask anything...",
|
|
124
|
+
onSubmit,
|
|
125
|
+
onStop,
|
|
126
|
+
isStreaming = false,
|
|
127
|
+
isSending = false,
|
|
128
|
+
isUploading = false,
|
|
129
|
+
canSubmit,
|
|
130
|
+
fileCount = 0,
|
|
131
|
+
chipCount = 0,
|
|
132
|
+
showDictate = false,
|
|
133
|
+
showFileUpload = true,
|
|
134
|
+
onAttachFile,
|
|
135
|
+
onFilesSelected,
|
|
136
|
+
attachAccept = DEFAULT_ATTACH_ACCEPT,
|
|
137
|
+
onDictateTranscription,
|
|
138
|
+
banners,
|
|
139
|
+
promoBanner,
|
|
140
|
+
queue,
|
|
141
|
+
attachments,
|
|
142
|
+
chips,
|
|
143
|
+
plusMenuExtra,
|
|
144
|
+
slashItems,
|
|
145
|
+
onSlashSelect,
|
|
146
|
+
renderSlashItem,
|
|
147
|
+
getPrediction,
|
|
148
|
+
predictionEnabled = true,
|
|
149
|
+
predictionTextClassName = DEFAULT_PREDICTION_TEXT_CLASS,
|
|
150
|
+
className,
|
|
151
|
+
containerClassName,
|
|
152
|
+
borderClassName,
|
|
153
|
+
submitButtonClassName,
|
|
154
|
+
submitIconClassName,
|
|
155
|
+
onRemoveLastChip,
|
|
156
|
+
}: InputAreaProps) {
|
|
157
|
+
const isControlled = controlledValue !== undefined;
|
|
158
|
+
const [internalValue, setInternalValue] = useState(defaultValue);
|
|
159
|
+
const input = isControlled ? controlledValue : internalValue;
|
|
160
|
+
const setInput = (next: string) => {
|
|
161
|
+
if (!isControlled) setInternalValue(next);
|
|
162
|
+
onChange?.(next);
|
|
163
|
+
};
|
|
164
|
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
165
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
166
|
+
const hasFiles = fileCount > 0;
|
|
167
|
+
|
|
168
|
+
const openFilePicker = () => {
|
|
169
|
+
if (onAttachFile) onAttachFile();
|
|
170
|
+
else fileInputRef.current?.click();
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const { chromeRef, compact } = useComposerCompact({
|
|
174
|
+
value: input,
|
|
175
|
+
fileCount,
|
|
176
|
+
chipCount,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const items = slashItems ?? [];
|
|
180
|
+
const slashMenu = useComposerSlashMenu({
|
|
181
|
+
value: input,
|
|
182
|
+
onValueChange: setInput,
|
|
183
|
+
items,
|
|
184
|
+
textareaRef: inputRef,
|
|
185
|
+
onSelect: (item) => onSlashSelect?.(item, { openFilePicker }),
|
|
186
|
+
enabled: items.length > 0,
|
|
187
|
+
});
|
|
188
|
+
const prediction = useInputAreaPrediction({
|
|
189
|
+
value: input,
|
|
190
|
+
suspended: slashMenu.showPopup,
|
|
191
|
+
enabled: predictionEnabled,
|
|
192
|
+
getPrediction,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const textareaClassName = cn(
|
|
196
|
+
"bg-transparent! shadow-none border-none",
|
|
197
|
+
"text-sm md:text-[15px] leading-5 placeholder:text-muted-foreground",
|
|
198
|
+
"smooth-typing-input field-sizing-content focus-visible:outline-hidden",
|
|
199
|
+
compact
|
|
200
|
+
? COMPACT_EDITOR
|
|
201
|
+
: "min-h-16 max-h-50 w-full self-stretch overflow-y-auto rounded-none px-4 pt-3.5 pb-1 text-left",
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const submitDisabled =
|
|
205
|
+
isUploading ||
|
|
206
|
+
isSending ||
|
|
207
|
+
!(canSubmit ?? (input.trim() !== "" || hasFiles || chipCount > 0));
|
|
208
|
+
|
|
209
|
+
const handleSubmit = (textOverride?: string) => {
|
|
210
|
+
if (isSending) return;
|
|
211
|
+
const text = (textOverride || input).trim();
|
|
212
|
+
if (!(canSubmit ?? (text !== "" || hasFiles || chipCount > 0))) return;
|
|
213
|
+
setInput("");
|
|
214
|
+
onSubmit?.(text);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const handleFileInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
218
|
+
if (!event.target.files) return;
|
|
219
|
+
onFilesSelected?.(event.target.files);
|
|
220
|
+
event.target.value = "";
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
225
|
+
if (e.ctrlKey || e.altKey || e.metaKey || e.key.length !== 1) return;
|
|
226
|
+
const activeElement = document.activeElement;
|
|
227
|
+
const isInputFocused =
|
|
228
|
+
activeElement instanceof HTMLInputElement ||
|
|
229
|
+
activeElement instanceof HTMLTextAreaElement;
|
|
230
|
+
if (!isInputFocused && inputRef.current) {
|
|
231
|
+
const textarea = inputRef.current;
|
|
232
|
+
textarea.focus();
|
|
233
|
+
const length = textarea.value.length;
|
|
234
|
+
textarea.setSelectionRange(length, length);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
238
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
239
|
+
}, []);
|
|
240
|
+
|
|
241
|
+
const plusButton = (
|
|
242
|
+
<InputActions
|
|
243
|
+
showFileUpload={showFileUpload}
|
|
244
|
+
onAttachFile={openFilePicker}
|
|
245
|
+
compact={compact}
|
|
246
|
+
extra={plusMenuExtra}
|
|
247
|
+
/>
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
const sendTools = (
|
|
251
|
+
<div className="flex shrink-0 items-center gap-0.5">
|
|
252
|
+
<FadePresence show={showDictate}>
|
|
253
|
+
<SpeechInput
|
|
254
|
+
size="icon-sm"
|
|
255
|
+
variant="ghost"
|
|
256
|
+
className="rounded-full bg-transparent text-muted-foreground hover:bg-surface-secondary hover:text-foreground max-sm:hit-target"
|
|
257
|
+
onTranscriptionChange={(text) =>
|
|
258
|
+
onDictateTranscription
|
|
259
|
+
? onDictateTranscription(text)
|
|
260
|
+
: setInput(input + (input ? " " : "") + text)
|
|
261
|
+
}
|
|
262
|
+
lang="en-US"
|
|
263
|
+
/>
|
|
264
|
+
</FadePresence>
|
|
265
|
+
<AnimatePresence initial={false}>
|
|
266
|
+
{isStreaming ? (
|
|
267
|
+
<m.div
|
|
268
|
+
key="composer-stop"
|
|
269
|
+
className="inline-flex"
|
|
270
|
+
initial={{ opacity: 0, scale: 0.96 }}
|
|
271
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
272
|
+
exit={{ opacity: 0, scale: 0.96 }}
|
|
273
|
+
transition={motionFast}
|
|
274
|
+
>
|
|
275
|
+
<Tooltip>
|
|
276
|
+
<TooltipTrigger asChild>
|
|
277
|
+
<Button
|
|
278
|
+
size="icon-sm"
|
|
279
|
+
type="button"
|
|
280
|
+
variant="destructive"
|
|
281
|
+
className="rounded-full max-sm:hit-target"
|
|
282
|
+
onClick={onStop}
|
|
283
|
+
aria-label="Stop generating"
|
|
284
|
+
>
|
|
285
|
+
<IconPlayerStop className="size-4" />
|
|
286
|
+
</Button>
|
|
287
|
+
</TooltipTrigger>
|
|
288
|
+
<TooltipContent>Stop generating</TooltipContent>
|
|
289
|
+
</Tooltip>
|
|
290
|
+
</m.div>
|
|
291
|
+
) : null}
|
|
292
|
+
</AnimatePresence>
|
|
293
|
+
<Tooltip>
|
|
294
|
+
<TooltipTrigger asChild>
|
|
295
|
+
<Button
|
|
296
|
+
size="icon-sm"
|
|
297
|
+
type="submit"
|
|
298
|
+
disabled={submitDisabled}
|
|
299
|
+
className={cn(
|
|
300
|
+
"size-9 rounded-full max-sm:hit-target",
|
|
301
|
+
submitButtonClassName,
|
|
302
|
+
)}
|
|
303
|
+
>
|
|
304
|
+
<span className="relative inline-flex size-5 items-center justify-center">
|
|
305
|
+
<span
|
|
306
|
+
className={cn(
|
|
307
|
+
"absolute inset-0 flex items-center justify-center transition-opacity duration-150 ease-out",
|
|
308
|
+
isUploading || isSending ? "opacity-100" : "opacity-0",
|
|
309
|
+
)}
|
|
310
|
+
>
|
|
311
|
+
<IconLoader2 className="size-4 animate-spin" />
|
|
312
|
+
</span>
|
|
313
|
+
<span
|
|
314
|
+
className={cn(
|
|
315
|
+
"flex items-center justify-center transition-opacity duration-150 ease-out",
|
|
316
|
+
isUploading || isSending ? "opacity-0" : "opacity-100",
|
|
317
|
+
)}
|
|
318
|
+
>
|
|
319
|
+
<IconArrowUp className={submitIconClassName} width={18} />
|
|
320
|
+
</span>
|
|
321
|
+
</span>
|
|
322
|
+
</Button>
|
|
323
|
+
</TooltipTrigger>
|
|
324
|
+
<TooltipContent>
|
|
325
|
+
{isStreaming ? "Queue message" : "Send message"}
|
|
326
|
+
</TooltipContent>
|
|
327
|
+
</Tooltip>
|
|
328
|
+
</div>
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
return (
|
|
332
|
+
<LazyMotion features={domAnimation}>
|
|
333
|
+
<TooltipProvider>
|
|
334
|
+
<div className="min-w-0 shrink-0 overflow-x-clip">
|
|
335
|
+
{showFileUpload && onFilesSelected ? (
|
|
336
|
+
<input
|
|
337
|
+
ref={fileInputRef}
|
|
338
|
+
type="file"
|
|
339
|
+
accept={attachAccept}
|
|
340
|
+
onChange={handleFileInputChange}
|
|
341
|
+
multiple
|
|
342
|
+
className="hidden"
|
|
343
|
+
/>
|
|
344
|
+
) : null}
|
|
345
|
+
<div className={className}>
|
|
346
|
+
{promoBanner}
|
|
347
|
+
{banners}
|
|
348
|
+
{queue}
|
|
349
|
+
<div className="flex w-full flex-col">
|
|
350
|
+
<div
|
|
351
|
+
ref={chromeRef}
|
|
352
|
+
data-composer-slash-anchor=""
|
|
353
|
+
className="w-full"
|
|
354
|
+
>
|
|
355
|
+
<PromptInput
|
|
356
|
+
onSubmit={(message) => {
|
|
357
|
+
handleSubmit(message.text);
|
|
358
|
+
}}
|
|
359
|
+
className="w-full"
|
|
360
|
+
containerClassName={cn(
|
|
361
|
+
"flex w-full min-w-0 [interpolate-size:allow-keywords] transition-[color,box-shadow,border-color,border-radius,height] duration-200",
|
|
362
|
+
chatInputContainerClass,
|
|
363
|
+
compact
|
|
364
|
+
? "h-12 flex-row flex-nowrap items-center rounded-full py-1 has-[>textarea]:h-12"
|
|
365
|
+
: "h-auto flex-col items-start rounded-surface",
|
|
366
|
+
borderClassName,
|
|
367
|
+
containerClassName,
|
|
368
|
+
)}
|
|
369
|
+
onDragOver={
|
|
370
|
+
showFileUpload && onFilesSelected
|
|
371
|
+
? (event) => {
|
|
372
|
+
event.preventDefault();
|
|
373
|
+
event.dataTransfer.dropEffect = "copy";
|
|
374
|
+
}
|
|
375
|
+
: undefined
|
|
376
|
+
}
|
|
377
|
+
onDrop={
|
|
378
|
+
showFileUpload && onFilesSelected
|
|
379
|
+
? (event) => {
|
|
380
|
+
event.preventDefault();
|
|
381
|
+
if (event.dataTransfer.files.length > 0) {
|
|
382
|
+
onFilesSelected(event.dataTransfer.files);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
: undefined
|
|
386
|
+
}
|
|
387
|
+
>
|
|
388
|
+
{showFileUpload ? attachments : null}
|
|
389
|
+
{chips}
|
|
390
|
+
{compact ? (
|
|
391
|
+
<InputGroupAddon
|
|
392
|
+
align="inline-start"
|
|
393
|
+
className={COMPACT_START_ADDON}
|
|
394
|
+
>
|
|
395
|
+
{plusButton}
|
|
396
|
+
</InputGroupAddon>
|
|
397
|
+
) : null}
|
|
398
|
+
<PromptInputBody>
|
|
399
|
+
<div
|
|
400
|
+
className={cn(
|
|
401
|
+
"relative min-w-0",
|
|
402
|
+
compact ? "flex-1" : "w-full self-stretch",
|
|
403
|
+
)}
|
|
404
|
+
>
|
|
405
|
+
{prediction.suggestion ? (
|
|
406
|
+
<div
|
|
407
|
+
aria-hidden
|
|
408
|
+
className={cn(
|
|
409
|
+
textareaClassName,
|
|
410
|
+
"pointer-events-none absolute inset-0 z-0 overflow-hidden whitespace-pre-wrap wrap-break-word text-foreground",
|
|
411
|
+
)}
|
|
412
|
+
>
|
|
413
|
+
{input}
|
|
414
|
+
<span className={predictionTextClassName}>
|
|
415
|
+
{prediction.suggestion}
|
|
416
|
+
</span>
|
|
417
|
+
</div>
|
|
418
|
+
) : null}
|
|
419
|
+
<PromptInputTextarea
|
|
420
|
+
ref={inputRef}
|
|
421
|
+
value={input}
|
|
422
|
+
placeholder={placeholder}
|
|
423
|
+
className={cn(
|
|
424
|
+
textareaClassName,
|
|
425
|
+
"relative z-10",
|
|
426
|
+
prediction.suggestion &&
|
|
427
|
+
"caret-current text-transparent",
|
|
428
|
+
)}
|
|
429
|
+
rows={1}
|
|
430
|
+
role={slashMenu.comboboxProps.role}
|
|
431
|
+
aria-multiline={slashMenu.comboboxProps["aria-multiline"]}
|
|
432
|
+
aria-haspopup={slashMenu.comboboxProps["aria-haspopup"]}
|
|
433
|
+
aria-autocomplete={
|
|
434
|
+
slashMenu.comboboxProps["aria-autocomplete"]
|
|
435
|
+
}
|
|
436
|
+
aria-expanded={slashMenu.comboboxProps["aria-expanded"]}
|
|
437
|
+
aria-controls={slashMenu.comboboxProps["aria-controls"]}
|
|
438
|
+
aria-activedescendant={
|
|
439
|
+
slashMenu.comboboxProps["aria-activedescendant"]
|
|
440
|
+
}
|
|
441
|
+
onKeyDown={(event) => {
|
|
442
|
+
if (slashMenu.onKeyDown(event)) return;
|
|
443
|
+
if (prediction.suggestion) {
|
|
444
|
+
if (event.key === "Tab" && !event.shiftKey) {
|
|
445
|
+
event.preventDefault();
|
|
446
|
+
setInput(input + prediction.suggestion);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (event.key === "Escape") {
|
|
450
|
+
event.preventDefault();
|
|
451
|
+
prediction.dismiss();
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (
|
|
456
|
+
event.key === "Backspace" &&
|
|
457
|
+
event.currentTarget.selectionStart === 0 &&
|
|
458
|
+
event.currentTarget.selectionEnd === 0 &&
|
|
459
|
+
chipCount > 0
|
|
460
|
+
) {
|
|
461
|
+
event.preventDefault();
|
|
462
|
+
onRemoveLastChip?.();
|
|
463
|
+
}
|
|
464
|
+
}}
|
|
465
|
+
onBlur={slashMenu.onBlur}
|
|
466
|
+
onChange={(e) => {
|
|
467
|
+
setInput(e.currentTarget.value);
|
|
468
|
+
}}
|
|
469
|
+
/>
|
|
470
|
+
</div>
|
|
471
|
+
</PromptInputBody>
|
|
472
|
+
{compact ? (
|
|
473
|
+
<InputGroupAddon
|
|
474
|
+
align="inline-end"
|
|
475
|
+
className={COMPACT_END_ADDON}
|
|
476
|
+
>
|
|
477
|
+
{sendTools}
|
|
478
|
+
</InputGroupAddon>
|
|
479
|
+
) : (
|
|
480
|
+
<PromptInputFooter className="w-full min-w-0 flex-nowrap px-3 pb-3 pt-0">
|
|
481
|
+
<PromptInputTools>{plusButton}</PromptInputTools>
|
|
482
|
+
{sendTools}
|
|
483
|
+
</PromptInputFooter>
|
|
484
|
+
)}
|
|
485
|
+
</PromptInput>
|
|
486
|
+
</div>
|
|
487
|
+
</div>
|
|
488
|
+
</div>
|
|
489
|
+
{typeof document !== "undefined"
|
|
490
|
+
? createPortal(
|
|
491
|
+
<AnimatePresence>
|
|
492
|
+
{slashMenu.showPopup && slashMenu.placement ? (
|
|
493
|
+
<ComposerSlashMenu
|
|
494
|
+
key="slash"
|
|
495
|
+
listboxId={slashMenu.listboxId}
|
|
496
|
+
placement={slashMenu.placement}
|
|
497
|
+
items={items}
|
|
498
|
+
query={slashMenu.trigger?.query ?? ""}
|
|
499
|
+
selectedIndex={slashMenu.selectedIndex}
|
|
500
|
+
onHoverIndex={slashMenu.setSelectedIndex}
|
|
501
|
+
onSelectItem={slashMenu.onSelectItem}
|
|
502
|
+
renderItem={renderSlashItem}
|
|
503
|
+
/>
|
|
504
|
+
) : null}
|
|
505
|
+
</AnimatePresence>,
|
|
506
|
+
document.body,
|
|
507
|
+
)
|
|
508
|
+
: null}
|
|
509
|
+
</div>
|
|
510
|
+
</TooltipProvider>
|
|
511
|
+
</LazyMotion>
|
|
512
|
+
);
|
|
513
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { IconLock } from "@tabler/icons-react";
|
|
3
|
+
import { cn } from "../../utils/cn";
|
|
4
|
+
import { FadePresence } from "./FadePresence";
|
|
5
|
+
import type { BannerFeatures } from "./types";
|
|
6
|
+
|
|
7
|
+
export type InputBannerStyles = {
|
|
8
|
+
privacy: { wrapper: string; icon: string; title: string; detail: string };
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const defaultInputBannerStyles: InputBannerStyles = {
|
|
12
|
+
privacy: {
|
|
13
|
+
wrapper: "",
|
|
14
|
+
icon: "",
|
|
15
|
+
title: "",
|
|
16
|
+
detail: "text-muted-foreground",
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type InputBannersProps = {
|
|
21
|
+
features: BannerFeatures;
|
|
22
|
+
/** Class strings for the privacy banner. The app passes its own for an exact match. */
|
|
23
|
+
styles?: InputBannerStyles;
|
|
24
|
+
privacyTitle?: ReactNode;
|
|
25
|
+
privacyDetail?: ReactNode;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function BannerWrapper({
|
|
29
|
+
children,
|
|
30
|
+
className,
|
|
31
|
+
}: {
|
|
32
|
+
children: ReactNode;
|
|
33
|
+
className: string;
|
|
34
|
+
}) {
|
|
35
|
+
return (
|
|
36
|
+
<div className="mx-auto w-full max-w-xs md:max-w-2xl">
|
|
37
|
+
<div className={cn("feature-banner", className)}>{children}</div>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function InputBanners({
|
|
43
|
+
features,
|
|
44
|
+
styles = defaultInputBannerStyles,
|
|
45
|
+
privacyTitle = "Private mode",
|
|
46
|
+
privacyDetail = "Hidden from history, search, and memories",
|
|
47
|
+
}: InputBannersProps) {
|
|
48
|
+
return (
|
|
49
|
+
<FadePresence show={features.privacy}>
|
|
50
|
+
<BannerWrapper className={styles.privacy.wrapper}>
|
|
51
|
+
<IconLock className={styles.privacy.icon} width={16} />
|
|
52
|
+
<p className={cn("text-sm font-medium", styles.privacy.title)}>
|
|
53
|
+
{privacyTitle}
|
|
54
|
+
</p>
|
|
55
|
+
<p className={cn("ml-auto text-xs", styles.privacy.detail)}>
|
|
56
|
+
{privacyDetail}
|
|
57
|
+
</p>
|
|
58
|
+
</BannerWrapper>
|
|
59
|
+
</FadePresence>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { IconSparkles, IconX } from "@tabler/icons-react";
|
|
3
|
+
import { Button } from "../../ui/button";
|
|
4
|
+
import { FadePresence } from "./FadePresence";
|
|
5
|
+
|
|
6
|
+
export type PromoBannerProps = {
|
|
7
|
+
show: boolean;
|
|
8
|
+
onUpgrade?: () => void;
|
|
9
|
+
onDismiss?: () => void;
|
|
10
|
+
/** Banner text. */
|
|
11
|
+
children?: ReactNode;
|
|
12
|
+
actionLabel?: ReactNode;
|
|
13
|
+
icon?: ReactNode;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Promo strip above the input. The app owns timing, dismissal state and billing. */
|
|
17
|
+
export function PromoBanner({
|
|
18
|
+
show,
|
|
19
|
+
onUpgrade,
|
|
20
|
+
onDismiss,
|
|
21
|
+
children = "Unlock unlimited messages, advanced features, and more",
|
|
22
|
+
actionLabel = "Upgrade",
|
|
23
|
+
icon,
|
|
24
|
+
}: PromoBannerProps) {
|
|
25
|
+
return (
|
|
26
|
+
<FadePresence show={show} className="mb-2 w-full">
|
|
27
|
+
<div className="flex items-center justify-between gap-3 px-4 py-2.5 rounded-xl bg-surface-secondary text-foreground">
|
|
28
|
+
<div className="flex items-center gap-2">
|
|
29
|
+
{icon ?? <IconSparkles className="w-4 h-4 text-foreground" />}
|
|
30
|
+
<p className="text-sm text-foreground">{children}</p>
|
|
31
|
+
</div>
|
|
32
|
+
<div className="flex items-center gap-2">
|
|
33
|
+
{onUpgrade ? (
|
|
34
|
+
<Button size="sm" onClick={onUpgrade}>
|
|
35
|
+
{actionLabel}
|
|
36
|
+
</Button>
|
|
37
|
+
) : null}
|
|
38
|
+
{onDismiss ? (
|
|
39
|
+
<Button
|
|
40
|
+
size="icon"
|
|
41
|
+
variant="ghost"
|
|
42
|
+
className="text-muted-foreground min-w-6 w-6 h-6"
|
|
43
|
+
onClick={onDismiss}
|
|
44
|
+
aria-label="Dismiss"
|
|
45
|
+
>
|
|
46
|
+
<IconX className="w-4 h-4" />
|
|
47
|
+
</Button>
|
|
48
|
+
) : null}
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</FadePresence>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
let measureCtx: CanvasRenderingContext2D | null = null;
|
|
2
|
+
|
|
3
|
+
function getMeasureContext(): CanvasRenderingContext2D | null {
|
|
4
|
+
if (measureCtx) return measureCtx;
|
|
5
|
+
if (typeof document === "undefined") return null;
|
|
6
|
+
const context = document.createElement("canvas").getContext("2d");
|
|
7
|
+
if (context === null) return null;
|
|
8
|
+
measureCtx = context;
|
|
9
|
+
return context;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isComposerCompact({
|
|
13
|
+
value,
|
|
14
|
+
fileCount,
|
|
15
|
+
chipCount,
|
|
16
|
+
exceedsPill,
|
|
17
|
+
}: {
|
|
18
|
+
value: string;
|
|
19
|
+
fileCount: number;
|
|
20
|
+
chipCount: number;
|
|
21
|
+
exceedsPill: boolean;
|
|
22
|
+
}): boolean {
|
|
23
|
+
if (fileCount > 0) return false;
|
|
24
|
+
if (chipCount > 0) return false;
|
|
25
|
+
if (value.trim().length === 0) return true;
|
|
26
|
+
if (value.includes("\n")) return false;
|
|
27
|
+
return !exceedsPill;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function editorOverflowsHorizontally(editor: HTMLElement): boolean {
|
|
31
|
+
return editor.scrollWidth > editor.clientWidth + 1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readEditorInnerWidth(editor: HTMLElement): number {
|
|
35
|
+
const style = getComputedStyle(editor);
|
|
36
|
+
const pad =
|
|
37
|
+
(Number.parseFloat(style.paddingLeft) || 0) +
|
|
38
|
+
(Number.parseFloat(style.paddingRight) || 0);
|
|
39
|
+
return Math.max(0, editor.clientWidth - pad);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function draftExceedsPillWidth(
|
|
43
|
+
text: string,
|
|
44
|
+
widthPx: number,
|
|
45
|
+
font: string,
|
|
46
|
+
): boolean {
|
|
47
|
+
if (widthPx <= 0 || text.length === 0) return false;
|
|
48
|
+
const ctx = getMeasureContext();
|
|
49
|
+
if (ctx === null) return false;
|
|
50
|
+
ctx.font = font;
|
|
51
|
+
return ctx.measureText(text).width > widthPx;
|
|
52
|
+
}
|