@vvedantb/ui 0.2.2 → 0.3.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/README.md CHANGED
@@ -5,6 +5,9 @@ Shared React 19 web UI. Source-exported: no build step.
5
5
  | Import | What it is |
6
6
  | --- | --- |
7
7
  | `@vvedantb/ui` | Primitives (Radix-based), AI elements, `playChime` and `useReplyChime`. |
8
+ | `@vvedantb/ui/sonner` | `SonnerToaster`, its helpers and `toast`. |
9
+ | `@vvedantb/ui/composer` | `Composer`, `ComposerShell`, `ComposerGhostText`, `ComposerIconButton`, `useComposerPrediction` and the pure prediction helpers. |
10
+ | `@vvedantb/ui/audio` | `playChime`, `useReplyChime`, `playTone` and the shared audio floor. |
8
11
  | `@vvedantb/ui/cn` | `cn` class helper (clsx + tailwind-merge). |
9
12
  | `@vvedantb/ui/shell` | Router-free `SidebarHeader`, `PillTabs` and `SessionsHoverCard`. |
10
13
  | `@vvedantb/ui/app` | Alt-click confirm skip, update watcher, settings chrome, `useStableAuth`. See `app/README.md` in the repository. |
@@ -29,8 +32,102 @@ useReplyChime({ enabled: soundOn, isStreaming, threadId });
29
32
  ```
30
33
 
31
34
  - `playChime()`: soft rising two-note chime (WebAudio). One shared `AudioContext`, created on first use. It is a no-op on the server and when autoplay blocks audio. Calls less than 1 second apart play once.
35
+ - `playTone(tone | tones)`: plays `{ frequency, startOffsetS?, durationS?, peakGain?, type? }` through the same floor.
36
+ - Audio floor: every package sound shares one 1 second minimum gap. Route your own sounds through it with `withAudioFloor(() => play())` or `if (tryAcquireAudioFloor()) play()`.
37
+ - These are also available from `@vvedantb/ui/audio`, which does not load the rest of the root barrel.
32
38
  - `useReplyChime`: chimes once when `isStreaming` goes from `true` to `false` for the same `threadId`. It is silent on the first sight of a thread, when `threadId` changes, and when `threadId` is null or undefined. While `enabled` is `false` it still tracks the edge, so enabling mid-turn does not replay a finish.
33
39
 
40
+ ## Composer
41
+
42
+ Chat composer with an autosizing textarea, a footer with a plus-menu slot, action slots and a send or stop button. It also shows optional ghost-text predictions. It makes no network calls: the app supplies `getPrediction`.
43
+
44
+ ```tsx
45
+ import { Composer, ComposerIconButton } from "@vvedantb/ui"; // or "@vvedantb/ui/composer"
46
+
47
+ <Composer
48
+ value={draft}
49
+ onChange={setDraft}
50
+ onSubmit={(text) => send(text)}
51
+ streaming={isStreaming}
52
+ onStop={cancel}
53
+ plusMenu={<ComposerIconButton aria-label="Add">+</ComposerIconButton>}
54
+ attachments={chips}
55
+ getPrediction={(draft, { signal }) => fetchContinuation(draft, signal)}
56
+ />;
57
+ ```
58
+
59
+ | Prop | Default | Notes |
60
+ | --- | --- | --- |
61
+ | `value`, `defaultValue`, `onChange` | uncontrolled, `""` | Pass `value` to control it. When controlled, clear the value yourself after `onSubmit`. When uncontrolled, the composer clears after submit. |
62
+ | `onSubmit(text)` | none | Called with the trimmed draft on Enter or the send button. Not called for an empty draft, or when `disabled` or `streaming`. If it returns a rejected promise, an uncontrolled composer restores the draft. |
63
+ | `streaming`, `onStop` | `false` | The stop button replaces send and calls `onStop`. Typing stays on, Enter does not submit. |
64
+ | `disabled` | `false` | Disables the textarea and send. |
65
+ | `placeholder` | `"Message"` | Also the textarea `aria-label`. |
66
+ | `layout` | `"stacked"` | `"stacked"`: input above, footer bar with `plusMenu` left and `trailingActions` plus send or stop right. `"inline"`: one row with `plusMenu`, the input and the actions. Attachments stay above the row. |
67
+ | `renderInput(ctx)` | none | Supplies your own input instead of the textarea. See Building blocks. |
68
+ | `maxHeight` | `200` (px) | The textarea grows to this height, then scrolls. |
69
+ | `autoFocus`, `className`, `textareaClassName` | none | `className` is the outer shell. `textareaClassName` is also applied to the ghost text mirror, so put padding and font classes there, not on the shell. |
70
+ | `plusMenu` | none | Node, or `({ disabled }) => node`. Left of the footer. |
71
+ | `trailingActions` | none | Node, or `({ disabled, streaming }) => node`. Right of the footer, before send or stop. |
72
+ | `attachments` | none | Node rendered in a row above the textarea. The app renders the chips and their remove buttons. |
73
+ | `getPrediction(draft, { signal })` | none | Returns the continuation of `draft`. Pass `signal` to your request. Errors show no ghost text. |
74
+ | `predictionDebounceMs` | `500` (ms) | Pause after the last edit before a request. |
75
+ | `predictionMinLength` | `12` (characters) | Minimum trimmed draft length. |
76
+
77
+ Keys: Enter sends, Shift+Enter adds a line, and nothing sends during IME composition. Tab accepts the ghost text, and Esc hides it for the current draft.
78
+
79
+ Predictions skip drafts shorter than `predictionMinLength`, drafts whose last word starts with `@` or `/`, and drafts that match `/[.!?]\s*$/`. Each edit aborts the previous request, and a result for an older draft is dropped. Ghost text shows only while the caret is at the end of the draft, so Tab always appends. It does not insert at a caret in the middle of the text. A space joins the draft and the prediction when neither has whitespace at the join.
80
+
81
+ The ref goes to the textarea. `ComposerIconButton` is a round icon-only ghost button for the slots. The pure helpers `shouldSkipComposerPrediction`, `joinComposerPrediction`, `nextComposerPredictionState`, `visibleComposerPrediction` and `acceptComposerPrediction`, and the `useComposerPrediction` hook, are exported for apps with their own input.
82
+
83
+ ### Building blocks
84
+
85
+ `renderInput` swaps the textarea for your own input and keeps the shell, slots, send or stop and predictions. The context gives `value`, `onChange`, `onKeyDown`, `ghostText`, `acceptGhost`, `dismissGhost`, `inputRef`, `disabled`, `placeholder`, `streaming`, `maxHeight` and `textareaClassName`.
86
+
87
+ ```tsx
88
+ <Composer
89
+ layout="inline"
90
+ getPrediction={getPrediction}
91
+ renderInput={(ctx) => (
92
+ <MyEditor
93
+ ref={ctx.inputRef}
94
+ data-composer-input=""
95
+ value={ctx.value}
96
+ onChange={ctx.onChange}
97
+ onKeyDown={ctx.onKeyDown}
98
+ ghostText={ctx.ghostText}
99
+ />
100
+ )}
101
+ />
102
+ ```
103
+
104
+ Rules for a custom input:
105
+
106
+ - `onKeyDown` handles Enter (calls `preventDefault`, then submits, never during IME composition), Tab (accepts the ghost text) and Esc (dismisses it). Shift+Enter is not handled: your editor decides what a newline does.
107
+ - Autosize and the caret-at-end ghost gate apply to the default textarea only. With `renderInput`, predictions run whenever the composer is not disabled or streaming. You size the editor (use `maxHeight`) and decide when the caret allows ghost text, for example by ignoring `ghostText`.
108
+ - Put the `data-composer-input` attribute on the editor so the shell shows the focus ring.
109
+ - `ref` on `Composer` points at the default textarea only. Use `ctx.inputRef` for a custom input.
110
+
111
+ To build without `Composer`, assemble the parts yourself. `useComposerPrediction` takes the draft string and reads no element. `ComposerGhostText` draws the draft invisibly, then the ghost text, so give it the same padding and font classes as your editor and place it under the editor.
112
+
113
+ ```tsx
114
+ import { ComposerGhostText, ComposerShell, useComposerPrediction } from "@vvedantb/ui/composer";
115
+
116
+ function MentionComposer({ draft, setDraft, send }: Props) {
117
+ const prediction = useComposerPrediction({ value: draft, enabled: true, getPrediction });
118
+ return (
119
+ <ComposerShell layout="inline" plusMenu={plus} canSend={draft.trim() !== ""} onSend={send}>
120
+ <ComposerGhostText value={draft} ghostText={prediction.ghost} className="px-2 py-1 text-sm leading-6" />
121
+ <Editor data-composer-input="" className="relative px-2 py-1 text-sm leading-6" onChange={setDraft} />
122
+ </ComposerShell>
123
+ );
124
+ }
125
+ ```
126
+
127
+ `ComposerShell` props: `layout`, `disabled`, `streaming`, `canSend`, `onSend`, `onStop`, `plusMenu`, `trailingActions`, `attachments`, `className` and `children` (the input region). In the shell the Enter, Tab and Esc keys are yours to wire when you do not use `Composer`.
128
+
129
+ Fixture: `composer/examples/` (see its README). It has a `tokens.css` page and a `compat-shadcn-rgb.css` page, each with light and dark.
130
+
34
131
  ## Sessions hover card
35
132
 
36
133
  ```tsx
@@ -45,13 +142,13 @@ import { SessionsHoverCard } from "@vvedantb/ui/shell";
45
142
  </SessionsHoverCard>
46
143
  ```
47
144
 
48
- `children` is the trigger. Defaults: `openDelay` 400 ms, `closeDelay` 150 ms, `side="right"`, `align="start"`, `sideOffset` 8, width and padding `w-64 p-3` (override with `className`). `body` is a node or a function of `{ open }`. `loadContent` runs only while the card is open. Control the state with `open`, `defaultOpen` and `onOpenChange`. Fixture: `shell/examples/sessions-hover-card/`.
145
+ `children` is the trigger. It is rendered with `asChild` by default, so it keeps its own handlers (such as a context menu). Extra props and the ref go to it. Use `asChild={false}` for a default trigger element. The content portals to `document.body` by default, so `overflow:hidden` ancestors do not clip it. Pass `portal={false}` to opt out. `surface` is `"overlay"` (default), `"surface"` (`rounded-surface`) or `"glass"` (`glass-panel-strong`, `rounded-surface`). `className` merges over the surface without `!`. Defaults: `openDelay` 400 ms, `closeDelay` 150 ms, `side="right"`, `align="start"`, `sideOffset` 8, width and padding `w-64 p-3` (override with `className`). `body` is a node or a function of `{ open }`. `loadContent` runs only while the card is open. Control the state with `open`, `defaultOpen` and `onOpenChange`. Fixture: `shell/examples/sessions-hover-card/`.
49
146
 
50
147
  ## Toaster
51
148
 
52
149
  ```tsx
53
150
  import { toast } from "sonner";
54
- import { SonnerToaster } from "@vvedantb/ui";
151
+ import { SonnerToaster } from "@vvedantb/ui"; // or "@vvedantb/ui/sonner"
55
152
 
56
153
  <SonnerToaster
57
154
  theme={theme}
@@ -73,7 +170,8 @@ Opt-in props (all default to the current look):
73
170
 
74
171
  | Prop | Values | Effect |
75
172
  | --- | --- | --- |
76
- | `toastRadius` | `"lg"` (default), `"surface"` | `rounded-lg` or `rounded-surface` (from `tokens/css/utilities.css`). |
173
+ | `toastRadius` | `"lg"` (default), `"surface"`, `"control"` | `rounded-lg`, `rounded-surface` (from `tokens/css/utilities.css`) or `rounded-field` (the input radius). |
174
+ | `density` | `"default"`, `"compact"` | Current padding and text, or `gap-2 px-3 py-2 text-xs` with `text-xs` descriptions. |
77
175
  | `toastIcons` | `"default"`, `"filled24"` | Sonner icons or 24px filled success, info, warning and error icons, a matching loader and a 14px close cross. |
78
176
  | `closeButtonStyle` | `"default"`, `"hoverChip"` | Text-style close button or a round chip at the top-right, shown on hover and always on small screens. It turns on `closeButton` unless you set it. |
79
177
  | `sonnerVars` | `false` (default), `true` | Sets `--normal-bg`, `--normal-border`, `--normal-text`, `--normal-bg-hover` and `--normal-border-hover` from `--color-card`, `--color-border`, `--color-foreground` and `--color-muted`, and `font-family: inherit`. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/ui",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "description": "Shared React 19 UI for web apps: primitives, shell pieces, app helpers, design tokens, shared Tailwind v4 CSS and a Tailwind preset.",
6
6
  "type": "module",
@@ -21,6 +21,18 @@
21
21
  "types": "./src/utils/cn.ts",
22
22
  "default": "./src/utils/cn.ts"
23
23
  },
24
+ "./sonner": {
25
+ "types": "./src/sonner.ts",
26
+ "default": "./src/sonner.ts"
27
+ },
28
+ "./audio": {
29
+ "types": "./src/audio/index.ts",
30
+ "default": "./src/audio/index.ts"
31
+ },
32
+ "./composer": {
33
+ "types": "./src/composer/index.ts",
34
+ "default": "./src/composer/index.ts"
35
+ },
24
36
  "./shell": {
25
37
  "types": "./shell/src/index.ts",
26
38
  "default": "./shell/src/index.ts"
@@ -1,17 +1,49 @@
1
- import { useEffect, useState } from "react";
2
- import type { ReactNode } from "react";
3
- import {
4
- HoverCard,
5
- HoverCardContent,
6
- HoverCardTrigger,
7
- } from "../../src/ui/hover-card";
1
+ import { forwardRef, useEffect, useState } from "react";
2
+ import type { ComponentPropsWithoutRef, ComponentRef, ReactNode } from "react";
3
+ import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
4
+ import { HoverCard, HoverCardTrigger } from "../../src/ui/hover-card";
5
+ import { floatingSurfaceAnimateClass, floatingSurfaceClass } from "../../src/ui/_menu-classes";
8
6
  import { cn } from "../../src/utils/cn";
9
7
 
10
8
  export type SessionsHoverCardContext = { open: boolean };
11
9
 
12
- export type SessionsHoverCardProps = {
13
- /** The element that triggers the card, such as a list row. Rendered with `asChild`. */
10
+ export type SessionsHoverCardSurface = "overlay" | "surface" | "glass";
11
+
12
+ /**
13
+ * Surface classes. Only `overlay` carries the shared floating surface, so the
14
+ * others need no `!` overrides, and `className` merges over all of them.
15
+ */
16
+ const surfaceClasses: Record<SessionsHoverCardSurface, string> = {
17
+ overlay: floatingSurfaceClass,
18
+ surface: "rounded-surface bg-overlay text-overlay-foreground smooth-shadow-ring-lg",
19
+ glass: "rounded-surface glass-panel-strong text-foreground smooth-shadow-ring-lg",
20
+ };
21
+
22
+ export type SessionsHoverCardProps = Omit<
23
+ ComponentPropsWithoutRef<typeof HoverCardTrigger>,
24
+ "className" | "children"
25
+ > & {
26
+ /**
27
+ * The trigger. With `asChild` (default) it is the element that triggers the
28
+ * card, such as a list row. Trigger props and the ref go to that element,
29
+ * which keeps its own handlers. With `asChild={false}` the children render
30
+ * inside a default trigger element.
31
+ */
14
32
  children: ReactNode;
33
+ /** Default `true`. */
34
+ asChild?: boolean;
35
+ /**
36
+ * Render the content in a portal on `document.body`, so `overflow:hidden`
37
+ * ancestors do not clip it. Default `true`. Use `false` to keep the content
38
+ * in place in the DOM.
39
+ */
40
+ portal?: boolean;
41
+ /**
42
+ * `"overlay"` (default): floating overlay, `rounded-lg`. `"surface"`: same
43
+ * fill with `rounded-surface`. `"glass"`: `glass-panel-strong` with
44
+ * `rounded-surface`.
45
+ */
46
+ surface?: SessionsHoverCardSurface;
15
47
  header?: ReactNode;
16
48
  /**
17
49
  * Body slot. A function runs only while the card is open, so it can start
@@ -34,7 +66,7 @@ export type SessionsHoverCardProps = {
34
66
  side?: "top" | "right" | "bottom" | "left";
35
67
  align?: "start" | "center" | "end";
36
68
  sideOffset?: number;
37
- /** Merged over the default `w-64 p-3`. */
69
+ /** Merged over the default `w-64 p-3` and the surface classes. */
38
70
  className?: string;
39
71
  };
40
72
 
@@ -86,8 +118,14 @@ function RenderBody({
86
118
  * position, width and padding, and gives header, body and footer slots. The
87
119
  * app supplies the content.
88
120
  */
89
- export function SessionsHoverCard({
121
+ export const SessionsHoverCard = forwardRef<
122
+ ComponentRef<typeof HoverCardTrigger>,
123
+ SessionsHoverCardProps
124
+ >(function SessionsHoverCard({
90
125
  children,
126
+ asChild = true,
127
+ portal = true,
128
+ surface = "overlay",
91
129
  header,
92
130
  body,
93
131
  loadContent,
@@ -102,7 +140,8 @@ export function SessionsHoverCard({
102
140
  align = "start",
103
141
  sideOffset = 8,
104
142
  className,
105
- }: SessionsHoverCardProps) {
143
+ ...triggerProps
144
+ }, ref) {
106
145
  const [openState, setOpenState] = useState(defaultOpen);
107
146
  const open = openProp ?? openState;
108
147
 
@@ -119,6 +158,31 @@ export function SessionsHoverCard({
119
158
  body
120
159
  );
121
160
 
161
+ const content = (
162
+ <HoverCardPrimitive.Content
163
+ side={side}
164
+ align={align}
165
+ sideOffset={sideOffset}
166
+ className={cn(
167
+ "z-50 outline-none max-sm:max-h-[85dvh] max-sm:max-w-[calc(100vw-2rem)] max-sm:overflow-y-auto",
168
+ surfaceClasses[surface],
169
+ floatingSurfaceAnimateClass,
170
+ "w-64 p-3",
171
+ className,
172
+ )}
173
+ >
174
+ {header ? (
175
+ <div className="mb-2 text-sm font-medium text-foreground">{header}</div>
176
+ ) : null}
177
+ {bodyNode != null ? (
178
+ <div className="text-sm text-muted-foreground">{bodyNode}</div>
179
+ ) : null}
180
+ {footer ? (
181
+ <div className="mt-2 text-xs text-muted-foreground">{footer}</div>
182
+ ) : null}
183
+ </HoverCardPrimitive.Content>
184
+ );
185
+
122
186
  return (
123
187
  <HoverCard
124
188
  open={open}
@@ -126,23 +190,10 @@ export function SessionsHoverCard({
126
190
  openDelay={openDelay}
127
191
  closeDelay={closeDelay}
128
192
  >
129
- <HoverCardTrigger asChild>{children}</HoverCardTrigger>
130
- <HoverCardContent
131
- side={side}
132
- align={align}
133
- sideOffset={sideOffset}
134
- className={cn("w-64 p-3", className)}
135
- >
136
- {header ? (
137
- <div className="mb-2 text-sm font-medium text-foreground">{header}</div>
138
- ) : null}
139
- {bodyNode != null ? (
140
- <div className="text-sm text-muted-foreground">{bodyNode}</div>
141
- ) : null}
142
- {footer ? (
143
- <div className="mt-2 text-xs text-muted-foreground">{footer}</div>
144
- ) : null}
145
- </HoverCardContent>
193
+ <HoverCardTrigger ref={ref} asChild={asChild} {...triggerProps}>
194
+ {children}
195
+ </HoverCardTrigger>
196
+ {portal ? <HoverCardPrimitive.Portal>{content}</HoverCardPrimitive.Portal> : content}
146
197
  </HoverCard>
147
198
  );
148
- }
199
+ });
@@ -8,4 +8,5 @@ export { SessionsHoverCard } from "./SessionsHoverCard.js";
8
8
  export type {
9
9
  SessionsHoverCardContext,
10
10
  SessionsHoverCardProps,
11
+ SessionsHoverCardSurface,
11
12
  } from "./SessionsHoverCard.js";
@@ -0,0 +1,37 @@
1
+ /** Calls closer together than this are dropped, so overlapping events sound once. */
2
+ export const AUDIO_FLOOR_MIN_GAP_S = 1;
3
+
4
+ let lastAcquiredAtS: number | null = null;
5
+
6
+ function nowS(): number {
7
+ return performance.now() / 1000;
8
+ }
9
+
10
+ /**
11
+ * Takes the shared audio floor. Returns `true` when at least 1 second has
12
+ * passed since the last sound that took it, and starts a new gap. Returns
13
+ * `false` otherwise, and the caller should stay silent.
14
+ *
15
+ * Every package sound (`playChime`, `playTone`) goes through this one floor.
16
+ * Apps can route their own sounds through it too.
17
+ */
18
+ export function tryAcquireAudioFloor(): boolean {
19
+ const now = nowS();
20
+ if (lastAcquiredAtS !== null && now - lastAcquiredAtS < AUDIO_FLOOR_MIN_GAP_S) {
21
+ return false;
22
+ }
23
+ lastAcquiredAtS = now;
24
+ return true;
25
+ }
26
+
27
+ /** Runs `play` only if the shared floor is free. Returns whether it ran. */
28
+ export function withAudioFloor(play: () => void): boolean {
29
+ if (!tryAcquireAudioFloor()) return false;
30
+ play();
31
+ return true;
32
+ }
33
+
34
+ /** Clears the floor. For tests. */
35
+ export function resetAudioFloor(): void {
36
+ lastAcquiredAtS = null;
37
+ }
@@ -1,3 +1,10 @@
1
1
  export { playChime } from "./playChime";
2
+ export { playTone } from "./playTone";
3
+ export type { Tone } from "./playTone";
4
+ export {
5
+ AUDIO_FLOOR_MIN_GAP_S,
6
+ tryAcquireAudioFloor,
7
+ withAudioFloor,
8
+ } from "./audioFloor";
2
9
  export { useReplyChime, nextReplyChimeState } from "./useReplyChime";
3
10
  export type { ReplyChimeInput, ReplyChimeWatch } from "./useReplyChime";
@@ -1,9 +1,4 @@
1
- type Tone = {
2
- frequency: number;
3
- startOffsetS: number;
4
- durationS: number;
5
- peakGain: number;
6
- };
1
+ import { playTone, type Tone } from "./playTone";
7
2
 
8
3
  /** A soft rising interval (A5 then D6). The second note overlaps the first. */
9
4
  const REPLY_CHIME: readonly Tone[] = [
@@ -11,66 +6,13 @@ const REPLY_CHIME: readonly Tone[] = [
11
6
  { frequency: 1174.66, startOffsetS: 0.09, durationS: 0.28, peakGain: 0.14 },
12
7
  ];
13
8
 
14
- /** Long enough to avoid a click on attack. */
15
- const ATTACK_S = 0.012;
16
-
17
- /** Calls closer together than this are dropped, so overlapping events sound once. */
18
- const MIN_INTERVAL_S = 1;
19
-
20
- let sharedContext: AudioContext | null = null;
21
- let lastPlayedAtS: number | null = null;
22
-
23
- function getAudioContext(): AudioContext | null {
24
- if (sharedContext) return sharedContext;
25
- if (typeof window === "undefined" || !window.AudioContext) return null;
26
- try {
27
- sharedContext = new window.AudioContext();
28
- } catch {
29
- return null;
30
- }
31
- return sharedContext;
32
- }
33
-
34
9
  /**
35
10
  * Plays a soft rising two-note chime with WebAudio.
36
11
  *
37
12
  * The context is created on first use and shared. The call is a no-op on the
38
- * server, without WebAudio, or while autoplay policy blocks audio.
13
+ * server, without WebAudio, or while autoplay policy blocks audio. It shares
14
+ * the 1 second floor with every other package sound.
39
15
  */
40
16
  export function playChime(): void {
41
- const context = getAudioContext();
42
- if (!context) return;
43
-
44
- if (context.state === "suspended") {
45
- try {
46
- context.resume().catch(() => undefined);
47
- } catch {
48
- return;
49
- }
50
- }
51
-
52
- const start = context.currentTime;
53
- if (lastPlayedAtS !== null && start - lastPlayedAtS < MIN_INTERVAL_S) return;
54
- lastPlayedAtS = start;
55
-
56
- try {
57
- for (const tone of REPLY_CHIME) {
58
- const oscillator = context.createOscillator();
59
- const envelope = context.createGain();
60
- oscillator.type = "sine";
61
- oscillator.frequency.value = tone.frequency;
62
-
63
- const toneStart = start + tone.startOffsetS;
64
- const toneEnd = toneStart + tone.durationS;
65
- envelope.gain.setValueAtTime(0, toneStart);
66
- envelope.gain.linearRampToValueAtTime(tone.peakGain, toneStart + ATTACK_S);
67
- envelope.gain.exponentialRampToValueAtTime(0.0001, toneEnd);
68
-
69
- oscillator.connect(envelope).connect(context.destination);
70
- oscillator.start(toneStart);
71
- oscillator.stop(toneEnd);
72
- }
73
- } catch {
74
- // Audio is optional. Stay silent on any failure.
75
- }
17
+ playTone(REPLY_CHIME);
76
18
  }
@@ -0,0 +1,73 @@
1
+ import { tryAcquireAudioFloor } from "./audioFloor";
2
+
3
+ export type Tone = {
4
+ /** Pitch in Hz. */
5
+ frequency: number;
6
+ /** Delay from the start of the call, in seconds. Default 0. */
7
+ startOffsetS?: number;
8
+ /** Length in seconds. Default 0.28. */
9
+ durationS?: number;
10
+ /** Peak gain from 0 to 1. Default 0.14. */
11
+ peakGain?: number;
12
+ /** Default "sine". */
13
+ type?: OscillatorType;
14
+ };
15
+
16
+ /** Long enough to avoid a click on attack. */
17
+ const ATTACK_S = 0.012;
18
+
19
+ let sharedContext: AudioContext | null = null;
20
+
21
+ function getAudioContext(): AudioContext | null {
22
+ if (sharedContext) return sharedContext;
23
+ if (typeof window === "undefined" || !window.AudioContext) return null;
24
+ try {
25
+ sharedContext = new window.AudioContext();
26
+ } catch {
27
+ return null;
28
+ }
29
+ return sharedContext;
30
+ }
31
+
32
+ /**
33
+ * Plays one tone or a sequence of overlapping tones with WebAudio, through the
34
+ * shared 1 second floor (see `tryAcquireAudioFloor`). The context is created on
35
+ * first use and shared. The call is a no-op on the server, without WebAudio,
36
+ * or while autoplay policy blocks audio.
37
+ */
38
+ export function playTone(tones: Tone | readonly Tone[]): void {
39
+ const context = getAudioContext();
40
+ if (!context) return;
41
+
42
+ if (context.state === "suspended") {
43
+ try {
44
+ context.resume().catch(() => undefined);
45
+ } catch {
46
+ return;
47
+ }
48
+ }
49
+
50
+ if (!tryAcquireAudioFloor()) return;
51
+
52
+ try {
53
+ const start = context.currentTime;
54
+ for (const tone of Array.isArray(tones) ? tones : [tones as Tone]) {
55
+ const oscillator = context.createOscillator();
56
+ const envelope = context.createGain();
57
+ oscillator.type = tone.type ?? "sine";
58
+ oscillator.frequency.value = tone.frequency;
59
+
60
+ const toneStart = start + (tone.startOffsetS ?? 0);
61
+ const toneEnd = toneStart + (tone.durationS ?? 0.28);
62
+ envelope.gain.setValueAtTime(0, toneStart);
63
+ envelope.gain.linearRampToValueAtTime(tone.peakGain ?? 0.14, toneStart + ATTACK_S);
64
+ envelope.gain.exponentialRampToValueAtTime(0.0001, toneEnd);
65
+
66
+ oscillator.connect(envelope).connect(context.destination);
67
+ oscillator.start(toneStart);
68
+ oscillator.stop(toneEnd);
69
+ }
70
+ } catch {
71
+ // Audio is optional. Stay silent on any failure.
72
+ }
73
+ }
@@ -0,0 +1,469 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { IconArrowUp, IconPlayerStop } from "@tabler/icons-react";
5
+
6
+ import { cn } from "../utils/cn";
7
+ import { Button } from "../ui/button";
8
+ import {
9
+ COMPOSER_PREDICTION_DEBOUNCE_MS,
10
+ COMPOSER_PREDICTION_MIN_LENGTH,
11
+ acceptComposerPrediction,
12
+ } from "./prediction";
13
+ import { useComposerPrediction } from "./useComposerPrediction";
14
+
15
+ export type ComposerSlotContext = { disabled: boolean };
16
+ export type ComposerTrailingContext = { disabled: boolean; streaming: boolean };
17
+ export type ComposerLayout = "stacked" | "inline";
18
+
19
+ export type ComposerInputRenderContext = {
20
+ value: string;
21
+ onChange: (value: string) => void;
22
+ /**
23
+ * Shared key handler. Enter submits (preventDefault, not while composing),
24
+ * Tab accepts the ghost text, Esc dismisses it. It ignores IME composition.
25
+ * Shift+Enter is not handled: the caller decides what a newline does.
26
+ */
27
+ onKeyDown: (event: React.KeyboardEvent) => void;
28
+ /** Visible ghost text including the joining space, or `""`. */
29
+ ghostText: string;
30
+ acceptGhost: () => void;
31
+ dismissGhost: () => void;
32
+ /** Attach to the input element (textarea or contenteditable root). */
33
+ inputRef: React.RefObject<HTMLElement | null>;
34
+ disabled: boolean;
35
+ placeholder: string;
36
+ streaming: boolean;
37
+ maxHeight: number;
38
+ textareaClassName?: string;
39
+ };
40
+
41
+ export type ComposerProps = {
42
+ /** Controlled value. Omit it for an uncontrolled composer. */
43
+ value?: string;
44
+ defaultValue?: string;
45
+ onChange?: (value: string) => void;
46
+
47
+ /** Called with the trimmed draft on Enter or the send button. Never called for an empty draft. */
48
+ onSubmit?: (value: string) => void | Promise<void>;
49
+ onStop?: () => void;
50
+ /** Shows the stop button in place of send. Typing stays enabled. Enter does not submit. */
51
+ streaming?: boolean;
52
+ /** Disables the textarea and the send button. */
53
+ disabled?: boolean;
54
+
55
+ placeholder?: string;
56
+ /** Maximum textarea height in px. The textarea scrolls after it. Default 200. */
57
+ maxHeight?: number;
58
+ autoFocus?: boolean;
59
+ /** Classes for the outer shell. */
60
+ className?: string;
61
+ /** `"stacked"` (default): input above, footer bar below. `"inline"`: one row with plus, input and actions. */
62
+ layout?: ComposerLayout;
63
+ /**
64
+ * Supplies your own input (for example a contenteditable editor) in place of
65
+ * the textarea. The shell, slots, send/stop and predictions stay. Autosize
66
+ * and the caret-at-end gate apply to the default textarea only: the caller
67
+ * sizes the editor and decides when ghost text is valid.
68
+ */
69
+ renderInput?: (ctx: ComposerInputRenderContext) => React.ReactNode;
70
+ /** Classes for the textarea. Also applied to the ghost text mirror, so keep padding and font here. */
71
+ textareaClassName?: string;
72
+
73
+ /** Left of the footer. */
74
+ plusMenu?: React.ReactNode | ((ctx: ComposerSlotContext) => React.ReactNode);
75
+ /** Right of the footer, before send or stop. */
76
+ trailingActions?:
77
+ | React.ReactNode
78
+ | ((ctx: ComposerTrailingContext) => React.ReactNode);
79
+ /** Row above the textarea. The caller renders the chips and their remove buttons. */
80
+ attachments?: React.ReactNode;
81
+
82
+ /**
83
+ * Returns the continuation of a draft, shown as ghost text. The package makes
84
+ * no requests: the caller owns the network call and should pass `signal` to it.
85
+ */
86
+ getPrediction?: (draft: string, opts: { signal: AbortSignal }) => Promise<string>;
87
+ /** Default 500 ms. */
88
+ predictionDebounceMs?: number;
89
+ /** Default 12 characters (trimmed). */
90
+ predictionMinLength?: number;
91
+ };
92
+
93
+ const DEFAULT_MAX_HEIGHT = 200;
94
+
95
+ // The textarea and its ghost text mirror must share these metrics.
96
+ const TEXT_METRICS_BASE = "block w-full text-sm leading-6 break-words whitespace-pre-wrap";
97
+ const textMetrics = (layout: ComposerLayout) =>
98
+ cn(TEXT_METRICS_BASE, layout === "inline" ? "px-2 py-1" : "px-4 pt-3.5 pb-1");
99
+
100
+ function isCaretAtEnd(el: HTMLTextAreaElement): boolean {
101
+ return el.selectionStart === el.selectionEnd && el.selectionEnd === el.value.length;
102
+ }
103
+
104
+ /** Icon-only ghost button for the footer: `plusMenu` triggers, `trailingActions`. */
105
+ const ComposerIconButton = React.forwardRef<
106
+ HTMLButtonElement,
107
+ React.ComponentProps<typeof Button>
108
+ >(({ className, variant = "ghost", size = "icon-sm", type = "button", ...props }, ref) => (
109
+ <Button
110
+ ref={ref}
111
+ type={type}
112
+ variant={variant}
113
+ size={size}
114
+ className={cn("rounded-full", className)}
115
+ {...props}
116
+ />
117
+ ));
118
+ ComposerIconButton.displayName = "ComposerIconButton";
119
+
120
+
121
+ type SlotNode<C> = React.ReactNode | ((ctx: C) => React.ReactNode);
122
+
123
+ export type ComposerShellProps = {
124
+ layout?: ComposerLayout;
125
+ disabled?: boolean;
126
+ streaming?: boolean;
127
+ /** Enables the send button. */
128
+ canSend?: boolean;
129
+ onSend?: () => void;
130
+ onStop?: () => void;
131
+ plusMenu?: SlotNode<ComposerSlotContext>;
132
+ trailingActions?: SlotNode<ComposerTrailingContext>;
133
+ attachments?: React.ReactNode;
134
+ className?: string;
135
+ /** The input region: a textarea, a contenteditable, plus any ghost overlay. */
136
+ children?: React.ReactNode;
137
+ };
138
+
139
+ /**
140
+ * Outer chrome of the composer: border, field tokens, focus ring, attachments,
141
+ * plus-menu and trailing slots, and send or stop. Put your input in `children`.
142
+ * The focus ring follows any descendant with the `data-composer-input`
143
+ * attribute that matches `:focus-visible`.
144
+ */
145
+ function ComposerShell({
146
+ layout = "stacked",
147
+ disabled = false,
148
+ streaming = false,
149
+ canSend = false,
150
+ onSend,
151
+ onStop,
152
+ plusMenu,
153
+ trailingActions,
154
+ attachments,
155
+ className,
156
+ children,
157
+ }: ComposerShellProps) {
158
+ const plus = typeof plusMenu === "function" ? plusMenu({ disabled }) : plusMenu;
159
+ const trailing =
160
+ typeof trailingActions === "function"
161
+ ? trailingActions({ disabled, streaming })
162
+ : trailingActions;
163
+ const inline = layout === "inline";
164
+
165
+ const action = streaming ? (
166
+ <ComposerIconButton
167
+ variant="default"
168
+ aria-label="Stop generating"
169
+ data-slot="composer-stop"
170
+ onClick={() => onStop?.()}
171
+ >
172
+ <IconPlayerStop className="size-4" aria-hidden="true" />
173
+ </ComposerIconButton>
174
+ ) : (
175
+ <ComposerIconButton
176
+ variant="default"
177
+ aria-label="Send message"
178
+ data-slot="composer-send"
179
+ disabled={!canSend}
180
+ onClick={() => onSend?.()}
181
+ >
182
+ <IconArrowUp className="size-4" aria-hidden="true" />
183
+ </ComposerIconButton>
184
+ );
185
+
186
+ return (
187
+ <div
188
+ data-slot="composer"
189
+ data-layout={layout}
190
+ data-disabled={disabled || undefined}
191
+ data-streaming={streaming || undefined}
192
+ className={cn(
193
+ "group/composer relative flex w-full min-w-0 flex-col rounded-field border border-border bg-field-background text-sm transition-[border-color,box-shadow] duration-200 ease-smooth",
194
+ "has-[[data-composer-input]:focus-visible]:border-focus-border has-[[data-composer-input]:focus-visible]:ring-2 has-[[data-composer-input]:focus-visible]:ring-focus-ring",
195
+ disabled && "opacity-60",
196
+ className,
197
+ )}
198
+ >
199
+ {attachments ? (
200
+ <div data-slot="composer-attachments" className="flex flex-wrap items-center gap-2 px-3 pt-3">
201
+ {attachments}
202
+ </div>
203
+ ) : null}
204
+
205
+ {inline ? (
206
+ <div data-slot="composer-row" className="flex min-w-0 items-end gap-1 p-1.5">
207
+ {plus ? <div className="flex shrink-0 items-center gap-1">{plus}</div> : null}
208
+ <div data-slot="composer-input-region" className="relative min-w-0 flex-1">
209
+ {children}
210
+ </div>
211
+ <div className="flex shrink-0 items-center gap-1">
212
+ {trailing}
213
+ {action}
214
+ </div>
215
+ </div>
216
+ ) : (
217
+ <>
218
+ <div data-slot="composer-input-region" className="relative min-w-0">
219
+ {children}
220
+ </div>
221
+ <div data-slot="composer-footer" className="flex items-center gap-1 px-2 pt-1 pb-2">
222
+ {plus ? <div className="flex items-center gap-1">{plus}</div> : null}
223
+ <div className="ml-auto flex items-center gap-1">
224
+ {trailing}
225
+ {action}
226
+ </div>
227
+ </div>
228
+ </>
229
+ )}
230
+ </div>
231
+ );
232
+ }
233
+
234
+ export type ComposerGhostTextProps = {
235
+ value: string;
236
+ ghostText: string;
237
+ className?: string;
238
+ };
239
+
240
+ /**
241
+ * Input-agnostic ghost overlay. It renders the draft invisibly, then the ghost
242
+ * text, so the ghost lands after the last character. Position it absolutely
243
+ * under (or over) your input and give it the same padding and font classes.
244
+ */
245
+ const ComposerGhostText = React.forwardRef<HTMLDivElement, ComposerGhostTextProps>(
246
+ function ComposerGhostText({ value, ghostText, className }, ref) {
247
+ return (
248
+ <div
249
+ ref={ref}
250
+ aria-hidden="true"
251
+ data-slot="composer-ghost"
252
+ className={cn(
253
+ "pointer-events-none absolute inset-0 overflow-y-auto [scrollbar-color:transparent_transparent] [&::-webkit-scrollbar]:bg-transparent",
254
+ className,
255
+ )}
256
+ >
257
+ <span className="invisible">{value}</span>
258
+ {ghostText ? <span className="text-muted-foreground/50">{ghostText}</span> : null}
259
+ </div>
260
+ );
261
+ },
262
+ );
263
+
264
+ /**
265
+ * Chat composer: autosizing textarea, plus-menu and action slots, send/stop
266
+ * button and optional ghost-text predictions. The ref points at the textarea.
267
+ */
268
+ const Composer = React.forwardRef<HTMLTextAreaElement, ComposerProps>(function Composer(
269
+ {
270
+ value: valueProp,
271
+ defaultValue = "",
272
+ onChange,
273
+ onSubmit,
274
+ onStop,
275
+ streaming = false,
276
+ disabled = false,
277
+ placeholder = "Message",
278
+ maxHeight = DEFAULT_MAX_HEIGHT,
279
+ autoFocus,
280
+ className,
281
+ textareaClassName,
282
+ layout = "stacked",
283
+ renderInput,
284
+ plusMenu,
285
+ trailingActions,
286
+ attachments,
287
+ getPrediction,
288
+ predictionDebounceMs = COMPOSER_PREDICTION_DEBOUNCE_MS,
289
+ predictionMinLength = COMPOSER_PREDICTION_MIN_LENGTH,
290
+ },
291
+ forwardedRef,
292
+ ) {
293
+ const isControlled = valueProp !== undefined;
294
+ const [inner, setInner] = React.useState(defaultValue);
295
+ const value = isControlled ? valueProp : inner;
296
+
297
+ const textareaRef = React.useRef<HTMLTextAreaElement | null>(null);
298
+ const mirrorRef = React.useRef<HTMLDivElement | null>(null);
299
+ const customInputRef = React.useRef<HTMLElement | null>(null);
300
+ const custom = renderInput !== undefined;
301
+ const setRefs = React.useCallback(
302
+ (node: HTMLTextAreaElement | null) => {
303
+ textareaRef.current = node;
304
+ if (typeof forwardedRef === "function") forwardedRef(node);
305
+ else if (forwardedRef) forwardedRef.current = node;
306
+ },
307
+ [forwardedRef],
308
+ );
309
+
310
+ const setValue = React.useCallback(
311
+ (next: string) => {
312
+ if (!isControlled) setInner(next);
313
+ onChange?.(next);
314
+ },
315
+ [isControlled, onChange],
316
+ );
317
+
318
+ // Ghost text shows only while the caret sits at the end of the draft.
319
+ const [caretAtEnd, setCaretAtEnd] = React.useState(true);
320
+ const prediction = useComposerPrediction({
321
+ value,
322
+ enabled: (custom || caretAtEnd) && !disabled && !streaming,
323
+ getPrediction,
324
+ debounceMs: predictionDebounceMs,
325
+ minLength: predictionMinLength,
326
+ });
327
+ const ghost = prediction.ghost;
328
+
329
+ // Autosize: grow with the content up to `maxHeight`, then scroll.
330
+ React.useLayoutEffect(() => {
331
+ const el = textareaRef.current;
332
+ if (custom || !el) return;
333
+ el.style.height = "auto";
334
+ const full = el.scrollHeight;
335
+ el.style.height = `${Math.min(full, maxHeight)}px`;
336
+ el.style.overflowY = full > maxHeight ? "auto" : "hidden";
337
+ }, [value, maxHeight, custom]);
338
+
339
+ const syncScroll = () => {
340
+ if (mirrorRef.current && textareaRef.current) {
341
+ mirrorRef.current.scrollTop = textareaRef.current.scrollTop;
342
+ }
343
+ };
344
+ React.useLayoutEffect(syncScroll, [value, ghost]);
345
+
346
+ const canSend = !disabled && !streaming && value.trim() !== "";
347
+
348
+ const submit = () => {
349
+ if (!canSend) return;
350
+ const draft = value.trim();
351
+ const result = onSubmit?.(draft);
352
+ if (!isControlled) setInner("");
353
+ if (!isControlled) onChange?.("");
354
+ prediction.clear();
355
+ if (result && typeof result.then === "function") {
356
+ // A failed async submit gives the draft back, unless the user already typed again.
357
+ result.then(undefined, () => {
358
+ if (!isControlled) setInner((current) => (current === "" ? draft : current));
359
+ });
360
+ }
361
+ };
362
+
363
+ const acceptGhost = () => {
364
+ if (!ghost) return;
365
+ prediction.clear();
366
+ setValue(acceptComposerPrediction(value, ghost));
367
+ };
368
+
369
+ const onKeyDown = (event: React.KeyboardEvent) => {
370
+ const el = event.currentTarget;
371
+ const native = event.nativeEvent;
372
+ if (native.isComposing || event.keyCode === 229) return;
373
+
374
+ if (event.key === "Tab" && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey) {
375
+ // The caret gate applies to the default textarea only. A custom input owns it.
376
+ const atEnd = custom || !(el instanceof HTMLTextAreaElement) || isCaretAtEnd(el);
377
+ if (ghost && atEnd) {
378
+ event.preventDefault();
379
+ acceptGhost();
380
+ }
381
+ return;
382
+ }
383
+ if (event.key === "Escape") {
384
+ if (ghost) {
385
+ event.preventDefault();
386
+ prediction.dismiss();
387
+ }
388
+ return;
389
+ }
390
+ if (event.key === "Enter" && !event.shiftKey) {
391
+ event.preventDefault();
392
+ submit();
393
+ }
394
+ };
395
+
396
+ const updateCaret = (el: HTMLTextAreaElement) => setCaretAtEnd(isCaretAtEnd(el));
397
+
398
+ const metrics = textMetrics(layout);
399
+
400
+ const input = renderInput ? (
401
+ renderInput({
402
+ value,
403
+ onChange: setValue,
404
+ onKeyDown,
405
+ ghostText: ghost,
406
+ acceptGhost,
407
+ dismissGhost: prediction.dismiss,
408
+ inputRef: customInputRef,
409
+ disabled,
410
+ placeholder,
411
+ streaming,
412
+ maxHeight,
413
+ textareaClassName,
414
+ })
415
+ ) : (
416
+ <>
417
+ <ComposerGhostText
418
+ ref={mirrorRef}
419
+ value={value}
420
+ ghostText={ghost}
421
+ className={cn(metrics, textareaClassName)}
422
+ />
423
+ <textarea
424
+ ref={setRefs}
425
+ data-slot="composer-textarea"
426
+ data-composer-input=""
427
+ rows={1}
428
+ value={value}
429
+ placeholder={placeholder}
430
+ aria-label={placeholder}
431
+ disabled={disabled}
432
+ autoFocus={autoFocus}
433
+ onChange={(event) => {
434
+ setValue(event.target.value);
435
+ updateCaret(event.target);
436
+ }}
437
+ onKeyDown={onKeyDown}
438
+ onSelect={(event) => updateCaret(event.currentTarget)}
439
+ onKeyUp={(event) => updateCaret(event.currentTarget)}
440
+ onClick={(event) => updateCaret(event.currentTarget)}
441
+ onScroll={syncScroll}
442
+ className={cn(
443
+ metrics,
444
+ "relative resize-none overflow-y-hidden border-0 bg-transparent text-foreground outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed",
445
+ textareaClassName,
446
+ )}
447
+ />
448
+ </>
449
+ );
450
+
451
+ return (
452
+ <ComposerShell
453
+ layout={layout}
454
+ disabled={disabled}
455
+ streaming={streaming}
456
+ canSend={canSend}
457
+ onSend={submit}
458
+ onStop={onStop}
459
+ plusMenu={plusMenu}
460
+ trailingActions={trailingActions}
461
+ attachments={attachments}
462
+ className={className}
463
+ >
464
+ {input}
465
+ </ComposerShell>
466
+ );
467
+ });
468
+
469
+ export { Composer, ComposerGhostText, ComposerIconButton, ComposerShell };
@@ -0,0 +1,23 @@
1
+ export { Composer, ComposerGhostText, ComposerIconButton, ComposerShell } from "./Composer";
2
+ export type {
3
+ ComposerGhostTextProps,
4
+ ComposerInputRenderContext,
5
+ ComposerLayout,
6
+ ComposerProps,
7
+ ComposerShellProps,
8
+ ComposerSlotContext,
9
+ ComposerTrailingContext,
10
+ } from "./Composer";
11
+ export { useComposerPrediction } from "./useComposerPrediction";
12
+ export type { ComposerPrediction, UseComposerPredictionOptions } from "./useComposerPrediction";
13
+ export {
14
+ COMPOSER_PREDICTION_DEBOUNCE_MS,
15
+ COMPOSER_PREDICTION_MIN_LENGTH,
16
+ acceptComposerPrediction,
17
+ initialComposerPredictionState,
18
+ joinComposerPrediction,
19
+ nextComposerPredictionState,
20
+ shouldSkipComposerPrediction,
21
+ visibleComposerPrediction,
22
+ } from "./prediction";
23
+ export type { ComposerPredictionAction, ComposerPredictionState } from "./prediction";
@@ -0,0 +1,94 @@
1
+ /** Default trailing-edge debounce, in ms: how long the draft must sit before a prediction is requested. */
2
+ export const COMPOSER_PREDICTION_DEBOUNCE_MS = 500;
3
+
4
+ /** Default minimum draft length (trimmed characters) before a prediction is requested. */
5
+ export const COMPOSER_PREDICTION_MIN_LENGTH = 12;
6
+
7
+ /**
8
+ * True when no prediction should be requested for this draft.
9
+ *
10
+ * Skips drafts shorter than `minLength` (trimmed), drafts whose last word starts
11
+ * with `@` or `/` (an in-progress mention or command), and drafts that end a
12
+ * sentence (`.`, `!` or `?`, then optional whitespace).
13
+ */
14
+ export function shouldSkipComposerPrediction(
15
+ value: string,
16
+ minLength: number = COMPOSER_PREDICTION_MIN_LENGTH,
17
+ ): boolean {
18
+ if (value.trim().length < minLength) return true;
19
+ const lastWord = value.split(/\s/).pop() ?? "";
20
+ if (lastWord.startsWith("@") || lastWord.startsWith("/")) return true;
21
+ if (/[.!?]\s*$/.test(value)) return true;
22
+ return false;
23
+ }
24
+
25
+ /**
26
+ * The ghost text for a draft: the bare continuation from the caller, with a
27
+ * joining space added when neither side has whitespace at the join.
28
+ */
29
+ export function joinComposerPrediction(draft: string, prediction: string): string {
30
+ if (prediction === "") return "";
31
+ return /\s$/.test(draft) || /^\s/.test(prediction) ? prediction : ` ${prediction}`;
32
+ }
33
+
34
+ /** What `getPrediction` last returned, and the draft it was requested for. */
35
+ export type ComposerPredictionState = {
36
+ /** Draft the stored `text` answers, or `null` when there is none. */
37
+ forValue: string | null;
38
+ /** Raw text from `getPrediction` (no joining space). */
39
+ text: string;
40
+ /** Draft for which the user pressed Esc. Suppresses requests until the draft changes. */
41
+ dismissedFor: string | null;
42
+ };
43
+
44
+ export type ComposerPredictionAction =
45
+ /** A request finished. `current` is the draft at the time it finished. */
46
+ | { type: "result"; forValue: string; text: string; current: string }
47
+ /** Esc: hide the prediction for this draft. */
48
+ | { type: "dismiss"; value: string }
49
+ /** Tab accepted the prediction, or the caller reset it. */
50
+ | { type: "clear" };
51
+
52
+ export const initialComposerPredictionState: ComposerPredictionState = {
53
+ forValue: null,
54
+ text: "",
55
+ dismissedFor: null,
56
+ };
57
+
58
+ /**
59
+ * Pure reducer for prediction state. A result is dropped when the draft moved
60
+ * on while the request ran, or when it is empty.
61
+ */
62
+ export function nextComposerPredictionState(
63
+ state: ComposerPredictionState,
64
+ action: ComposerPredictionAction,
65
+ ): ComposerPredictionState {
66
+ switch (action.type) {
67
+ case "result":
68
+ if (action.forValue !== action.current || action.text === "") return state;
69
+ return { ...state, forValue: action.forValue, text: action.text };
70
+ case "dismiss":
71
+ return { forValue: null, text: "", dismissedFor: action.value };
72
+ case "clear":
73
+ return { ...state, forValue: null, text: "" };
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Ghost text to show for `value`, or `""`. It is empty when the state answers a
79
+ * different draft, the draft is dismissed or the draft is skipped.
80
+ */
81
+ export function visibleComposerPrediction(
82
+ state: ComposerPredictionState,
83
+ value: string,
84
+ minLength: number = COMPOSER_PREDICTION_MIN_LENGTH,
85
+ ): string {
86
+ if (state.forValue !== value || state.dismissedFor === value) return "";
87
+ if (shouldSkipComposerPrediction(value, minLength)) return "";
88
+ return joinComposerPrediction(value, state.text);
89
+ }
90
+
91
+ /** The draft after Tab: the draft with the ghost text appended. */
92
+ export function acceptComposerPrediction(value: string, ghost: string): string {
93
+ return value + ghost;
94
+ }
@@ -0,0 +1,86 @@
1
+ import * as React from "react";
2
+
3
+ import {
4
+ COMPOSER_PREDICTION_DEBOUNCE_MS,
5
+ COMPOSER_PREDICTION_MIN_LENGTH,
6
+ initialComposerPredictionState,
7
+ nextComposerPredictionState,
8
+ shouldSkipComposerPrediction,
9
+ visibleComposerPrediction,
10
+ } from "./prediction";
11
+
12
+ export type UseComposerPredictionOptions = {
13
+ value: string;
14
+ /** Off when there is no `getPrediction`, the composer is busy, or the caret is not at the end. */
15
+ enabled: boolean;
16
+ getPrediction?: (draft: string, opts: { signal: AbortSignal }) => Promise<string>;
17
+ debounceMs?: number;
18
+ minLength?: number;
19
+ };
20
+
21
+ export type ComposerPrediction = {
22
+ /** Ghost text to render after the draft, including any joining space. `""` when none. */
23
+ ghost: string;
24
+ dismiss: () => void;
25
+ clear: () => void;
26
+ };
27
+
28
+ /**
29
+ * Debounced ghost-text prediction for a draft. It does no I/O itself: the caller
30
+ * supplies `getPrediction`. Each draft change aborts the previous request. A
31
+ * result that arrives after its signal aborted, or after the draft changed, is
32
+ * dropped. Errors from `getPrediction` are swallowed: a failed prediction shows
33
+ * nothing.
34
+ */
35
+ export function useComposerPrediction({
36
+ value,
37
+ enabled,
38
+ getPrediction,
39
+ debounceMs = COMPOSER_PREDICTION_DEBOUNCE_MS,
40
+ minLength = COMPOSER_PREDICTION_MIN_LENGTH,
41
+ }: UseComposerPredictionOptions): ComposerPrediction {
42
+ const [state, dispatch] = React.useReducer(
43
+ nextComposerPredictionState,
44
+ initialComposerPredictionState,
45
+ );
46
+
47
+ const latestValue = React.useRef(value);
48
+ latestValue.current = value;
49
+ const getPredictionRef = React.useRef(getPrediction);
50
+ getPredictionRef.current = getPrediction;
51
+ const active = enabled && getPrediction !== undefined;
52
+
53
+ const skip = shouldSkipComposerPrediction(value, minLength);
54
+ const settled = state.forValue === value || state.dismissedFor === value;
55
+
56
+ React.useEffect(() => {
57
+ if (!active || skip || settled) return;
58
+ const controller = new AbortController();
59
+ const timer = setTimeout(() => {
60
+ const request = getPredictionRef.current;
61
+ if (!request) return;
62
+ request(value, { signal: controller.signal }).then(
63
+ (text) => {
64
+ if (controller.signal.aborted) return;
65
+ dispatch({ type: "result", forValue: value, text, current: latestValue.current });
66
+ },
67
+ () => {},
68
+ );
69
+ }, debounceMs);
70
+ return () => {
71
+ clearTimeout(timer);
72
+ controller.abort();
73
+ };
74
+ }, [value, active, skip, settled, debounceMs]);
75
+
76
+ const ghost = active ? visibleComposerPrediction(state, value, minLength) : "";
77
+
78
+ return {
79
+ ghost,
80
+ dismiss: React.useCallback(
81
+ () => dispatch({ type: "dismiss", value: latestValue.current }),
82
+ [],
83
+ ),
84
+ clear: React.useCallback(() => dispatch({ type: "clear" }), []),
85
+ };
86
+ }
package/src/index.ts CHANGED
@@ -98,3 +98,5 @@ export * from "./ai-elements/speech-input";
98
98
  export * from "./ai-elements/suggestion";
99
99
 
100
100
  export * from "./audio";
101
+
102
+ export * from "./composer";
package/src/sonner.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./ui/sonner";
2
+ export { toast } from "sonner";
package/src/ui/sonner.tsx CHANGED
@@ -26,8 +26,13 @@ type ToastClassNames = NonNullable<
26
26
  type ToasterStyle = CSSProperties & Record<`--${string}`, string>;
27
27
 
28
28
  type SonnerToasterOptions = {
29
- /** `"lg"` (default) uses `rounded-lg`. `"surface"` uses `rounded-surface`. */
30
- toastRadius?: "lg" | "surface";
29
+ /**
30
+ * `"lg"` (default) uses `rounded-lg`. `"surface"` uses `rounded-surface`.
31
+ * `"control"` uses `rounded-field`, the radius of inputs and selects.
32
+ */
33
+ toastRadius?: "lg" | "surface" | "control";
34
+ /** `"default"` keeps the current padding and text size. `"compact"` tightens padding, gap and text. */
35
+ density?: "default" | "compact";
31
36
  /** `"default"` keeps sonner's icons. `"filled24"` uses 24px filled icons. */
32
37
  toastIcons?: "default" | "filled24";
33
38
  /**
@@ -48,22 +53,31 @@ const hoverChipCloseButtonClassName =
48
53
  const defaultCloseButtonClassName =
49
54
  "rounded-lg bg-default px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-default/78";
50
55
 
56
+ const toastRadiusClasses = {
57
+ lg: "rounded-lg",
58
+ surface: "rounded-surface",
59
+ control: "rounded-field",
60
+ } as const;
61
+
51
62
  /** Default `classNames`, with the opt-in variants applied. No options gives the 0.2.1 defaults. */
52
63
  function createToasterClassNames({
53
64
  toastRadius = "lg",
65
+ density = "default",
54
66
  closeButtonStyle = "default",
55
67
  descriptionMuted = false,
56
68
  }: Pick<
57
69
  SonnerToasterOptions,
58
- "toastRadius" | "closeButtonStyle" | "descriptionMuted"
70
+ "toastRadius" | "density" | "closeButtonStyle" | "descriptionMuted"
59
71
  > = {}): ToastClassNames {
60
- const radius = toastRadius === "surface" ? "rounded-surface" : "rounded-lg";
72
+ const radius = toastRadiusClasses[toastRadius];
73
+ const compact = density === "compact";
74
+ const descriptionSize = compact ? "text-xs" : "text-[13px]";
61
75
  return {
62
- toast: `group toast glass-panel-strong flex w-full items-center gap-3 ${radius} px-4 py-3 text-sm text-foreground smooth-shadow-ring-md`,
76
+ toast: `group toast glass-panel-strong flex w-full items-center ${compact ? "gap-2" : "gap-3"} ${radius} ${compact ? "px-3 py-2 text-xs" : "px-4 py-3 text-sm"} text-foreground smooth-shadow-ring-md`,
63
77
  title: "font-medium leading-snug",
64
78
  description: descriptionMuted
65
- ? "text-[13px] leading-snug text-muted-foreground!"
66
- : "text-[13px] leading-snug text-muted-foreground",
79
+ ? `${descriptionSize} leading-snug text-muted-foreground!`
80
+ : `${descriptionSize} leading-snug text-muted-foreground`,
67
81
  actionButton:
68
82
  "rounded-lg bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90",
69
83
  cancelButton:
@@ -129,6 +143,7 @@ function mergeToastOptions(
129
143
 
130
144
  function SonnerToaster({
131
145
  toastRadius,
146
+ density,
132
147
  toastIcons = "default",
133
148
  closeButtonStyle = "default",
134
149
  sonnerVars = false,
@@ -157,6 +172,7 @@ function SonnerToaster({
157
172
  unstyled: true,
158
173
  classNames: createToasterClassNames({
159
174
  toastRadius,
175
+ density,
160
176
  closeButtonStyle,
161
177
  descriptionMuted,
162
178
  }),
@@ -7,6 +7,10 @@ function initialDark(): boolean {
7
7
  return new URLSearchParams(window.location.search).get("theme") === "dark";
8
8
  }
9
9
 
10
+ const compact =
11
+ typeof window !== "undefined" &&
12
+ new URLSearchParams(window.location.search).get("variant") === "compact";
13
+
10
14
  function fire() {
11
15
  toast.success("Changes saved", {
12
16
  description: "Your settings were updated and will apply to new sessions.",
@@ -42,7 +46,8 @@ export function Fixture() {
42
46
  </main>
43
47
  <SonnerToaster
44
48
  theme={dark ? "dark" : "light"}
45
- toastRadius="surface"
49
+ toastRadius={compact ? "control" : "surface"}
50
+ density={compact ? "compact" : "default"}
46
51
  toastIcons="filled24"
47
52
  closeButtonStyle="hoverChip"
48
53
  sonnerVars
@@ -15,4 +15,4 @@ Run it from this folder (it is not a workspace member, and React resolves from t
15
15
  pnpm install --ignore-workspace && pnpm dev
16
16
  ```
17
17
 
18
- Open the printed URL plus `/tokens.html` or `/compat.html`. Add `?theme=dark` for dark mode, or use "Toggle theme". Hover the toast to see the close chip. `Fixture.tsx` is not part of the package typecheck.
18
+ Open the printed URL plus `/tokens.html` or `/compat.html`. Add `?variant=compact` for the compact density with `toastRadius="control"`. Add `?theme=dark` for dark mode (combine with `&`), or use "Toggle theme". Hover the toast to see the close chip. `Fixture.tsx` is not part of the package typecheck.