@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.
Files changed (31) hide show
  1. package/README.md +66 -19
  2. package/app/README.md +8 -8
  3. package/package.json +5 -30
  4. package/src/composer/Composer.tsx +1 -0
  5. package/src/composer/index.ts +1 -0
  6. package/src/composer/input-area/ComposerSlashMenu.tsx +137 -0
  7. package/src/composer/input-area/ComposerSlashRow.tsx +75 -0
  8. package/src/composer/input-area/ConnectionStatusMenuItem.tsx +34 -0
  9. package/src/composer/input-area/FadePresence.tsx +31 -0
  10. package/src/composer/input-area/InputActions.tsx +63 -0
  11. package/src/composer/input-area/InputArea.tsx +513 -0
  12. package/src/composer/input-area/InputBanners.tsx +61 -0
  13. package/src/composer/input-area/PromoBanner.tsx +53 -0
  14. package/src/composer/input-area/composerCompact.ts +52 -0
  15. package/src/composer/input-area/composerPrediction.ts +51 -0
  16. package/src/composer/input-area/composerSlashItems.ts +206 -0
  17. package/src/composer/input-area/composerSlashOptionId.ts +7 -0
  18. package/src/composer/input-area/composerSlashPosition.ts +40 -0
  19. package/src/composer/input-area/composerSlashTrigger.ts +50 -0
  20. package/src/composer/input-area/index.ts +30 -0
  21. package/src/composer/input-area/inputPresets.ts +14 -0
  22. package/src/composer/input-area/motion.ts +7 -0
  23. package/src/composer/input-area/types.ts +25 -0
  24. package/src/composer/input-area/useComposerCompact.ts +46 -0
  25. package/src/composer/input-area/useComposerSlashMenu.ts +203 -0
  26. package/src/composer/input-area/useInputAreaPrediction.ts +49 -0
  27. package/src/index.ts +21 -0
  28. package/src/ui/button.tsx +11 -2
  29. package/src/ui/dropdown-menu.tsx +35 -7
  30. package/tokens/README.md +2 -2
  31. 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
+ }