agentic-ui-kit 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/core.js +2 -2
  2. package/dist/index.js +918 -120
  3. package/dist/{src-Ce7xTfGU.js → src-D01l3nBc.js} +1 -1
  4. package/dist/styles.css +1 -1
  5. package/dist/tailwind.css +44 -1
  6. package/dist/types/core/src/events.d.ts +2 -0
  7. package/dist/types/core/src/visualize.d.ts +1 -1
  8. package/dist/types/react/src/components/attachments.d.ts +4 -1
  9. package/dist/types/react/src/components/chat-list.d.ts +4 -0
  10. package/dist/types/react/src/components/composer.d.ts +7 -1
  11. package/dist/types/react/src/components/context-meter.d.ts +24 -0
  12. package/dist/types/react/src/components/context-panel.d.ts +5 -0
  13. package/dist/types/react/src/components/copy-field.d.ts +15 -0
  14. package/dist/types/react/src/components/dictation.d.ts +73 -0
  15. package/dist/types/react/src/components/effort-slider.d.ts +1 -1
  16. package/dist/types/react/src/components/markdown.d.ts +19 -2
  17. package/dist/types/react/src/components/model-picker.d.ts +6 -1
  18. package/dist/types/react/src/components/tools.d.ts +7 -2
  19. package/dist/types/react/src/hooks/sidebar-state.d.ts +6 -0
  20. package/dist/types/react/src/hooks/sse-transport.d.ts +7 -1
  21. package/dist/types/react/src/hooks/use-chat.d.ts +14 -1
  22. package/dist/types/react/src/index.d.ts +11 -5
  23. package/dist/types/react/src/primitives/button.d.ts +11 -3
  24. package/dist/types/react/src/primitives/cx.d.ts +3 -0
  25. package/dist/types/react/src/primitives/icons.d.ts +6 -0
  26. package/dist/types/react/src/primitives/labels.d.ts +28 -0
  27. package/dist/types/react/src/primitives/menu.d.ts +23 -5
  28. package/dist/types/react/src/primitives/overlay.d.ts +13 -1
  29. package/dist/types/react/src/primitives/segmented-control.d.ts +30 -5
  30. package/dist/types/react/src/views/app-shell.d.ts +31 -3
  31. package/dist/types/react/src/views/chat-view.d.ts +6 -2
  32. package/dist/types/react/src/views/sidebar.d.ts +31 -5
  33. package/package.json +3 -2
package/dist/index.js CHANGED
@@ -1,13 +1,40 @@
1
1
  "use client";
2
- import { A as awaitingToolCalls, C as widgetLoaderMessages, D as toBlocks, F as settleToolCall, L as salvageJson, O as activityTokens, T as readEventStream, _ as replacedId, b as resolveReplaces, d as isStateRef, f as matchesSchema, g as uiActionSchema, i as parseUiMarkup, k as applyEvent } from "./src-Ce7xTfGU.js";
2
+ import { A as awaitingToolCalls, C as widgetLoaderMessages, D as toBlocks, F as settleToolCall, L as salvageJson, O as activityTokens, T as readEventStream, _ as replacedId, b as resolveReplaces, d as isStateRef, f as matchesSchema, g as uiActionSchema, i as parseUiMarkup, k as applyEvent } from "./src-D01l3nBc.js";
3
3
  import { clsx } from "clsx";
4
- import { Autocomplete, Button as Button$1, Dialog, DialogTrigger, Disclosure as Disclosure$1, DisclosurePanel, Focusable, Header, Heading, Input, Keyboard, Link, Menu as Menu$1, MenuItem as MenuItem$1, MenuSection as MenuSection$1, MenuTrigger, MenuTrigger as MenuTrigger$1, Modal as Modal$1, ModalOverlay, Popover as Popover$1, SearchField, Separator, SubmenuTrigger, Text, ToggleButton, ToggleButtonGroup, Tooltip as Tooltip$1, TooltipTrigger, useFilter } from "react-aria-components";
5
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
- import { Component, Fragment as Fragment$1, createContext, createElement, memo, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
7
- import { AlertTriangle, ArrowDown, ArrowUp, Check, ChevronDown, ChevronRight, ChevronsUpDown, Copy, FileText, Folder, FolderOpen, FolderPlus, Globe, Info, MoreHorizontal, PanelLeft, Pencil, Pin, PinOff, Plus, RefreshCw, Share, ShieldQuestion, Square, ThumbsDown, ThumbsUp, Trash2, Volume2, X } from "lucide-react";
4
+ import { extendTailwindMerge } from "tailwind-merge";
5
+ import { Children, Component, Fragment, createContext, createElement, isValidElement, memo, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
6
+ import { Autocomplete, Button as Button$1, Dialog, DialogTrigger, DialogTrigger as DialogTrigger$1, Disclosure as Disclosure$1, DisclosurePanel, Focusable, Header, Heading, Input, Keyboard, Link, Menu as Menu$1, MenuItem as MenuItem$1, MenuSection as MenuSection$1, MenuTrigger, MenuTrigger as MenuTrigger$1, Modal as Modal$1, ModalOverlay, Popover as Popover$1, SearchField, Separator, SubmenuTrigger, Text, ToggleButton, ToggleButtonGroup, Tooltip as Tooltip$1, TooltipTrigger, useFilter } from "react-aria-components";
7
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
+ import { AlertTriangle, ArrowDown, ArrowUp, Check, ChevronDown, ChevronRight, ChevronsUpDown, Copy, FileText, Folder, FolderOpen, FolderPlus, Globe, Info, Mic, MoreHorizontal, PanelLeft, Pencil, Pin, PinOff, Plus, RefreshCw, Share, ShieldQuestion, Square, ThumbsDown, ThumbsUp, Trash2, Volume2, X } from "lucide-react";
8
9
  import { UNSAFE_PortalProvider } from "react-aria";
9
10
  import { createMathPlugin } from "@streamdown/math";
10
- import { Streamdown, defaultRehypePlugins } from "streamdown";
11
+ import { Streamdown, defaultComponents, defaultRehypePlugins } from "streamdown";
12
+ // src/primitives/cx.ts
13
+ const twMerge = extendTailwindMerge({ extend: { theme: {
14
+ radius: [
15
+ "chat-sm",
16
+ "chat-md",
17
+ "chat-lg",
18
+ "chat-popover"
19
+ ],
20
+ shadow: ["chat-popover"],
21
+ animate: [
22
+ "chat-in",
23
+ "chat-out",
24
+ "chat-fade-in",
25
+ "chat-fade-out",
26
+ "chat-drawer-in",
27
+ "chat-drawer-out",
28
+ "chat-drawer-in-right",
29
+ "chat-drawer-out-right",
30
+ "chat-sheet-in",
31
+ "chat-sheet-out"
32
+ ]
33
+ } } });
34
+ /** `clsx` plus tailwind-merge: later classes win over earlier ones that set the same property, so a caller's `h-7` or `rounded-none` beats the kit's. */
35
+ function cx(...inputs) {
36
+ return twMerge(clsx(inputs));
37
+ }
11
38
  // src/primitives/tooltip.tsx
12
39
  function Tooltip({ content, children, placement = "top", delay = 600 }) {
13
40
  return /* @__PURE__ */ jsxs(TooltipTrigger, {
@@ -38,23 +65,88 @@ const variants = {
38
65
  */
39
66
  danger: "bg-[var(--chat-danger-bg,color-mix(in_srgb,var(--chat-danger)_70%,black))] font-medium text-[var(--chat-danger-fg,#fff)] data-hovered:brightness-110 data-pressed:brightness-95"
40
67
  };
41
- const sizes = {
68
+ const sizes$1 = {
42
69
  sm: "h-7 gap-1.5 px-2 text-(length:--chat-ui-font-size) leading-5 [@media(pointer:coarse)]:h-9",
43
70
  md: "h-8 gap-2 px-2.5 text-(length:--chat-ui-font-size) leading-5 [@media(pointer:coarse)]:h-10",
44
71
  /** Dialog and empty-state actions. */
45
72
  lg: "h-9 gap-2 px-3.5 text-(length:--chat-ui-font-size) leading-5 [@media(pointer:coarse)]:h-11"
46
73
  };
47
- function Button({ variant = "ghost", size = "md", className, ...props }) {
48
- return /* @__PURE__ */ jsx(Button$1, {
74
+ /** Press handlers dropped from a button that is disabled with a reason; it only looks and announces as disabled. */
75
+ const pressHandlers = [
76
+ "onPress",
77
+ "onPressStart",
78
+ "onPressEnd",
79
+ "onPressChange",
80
+ "onPressUp",
81
+ "onClick"
82
+ ];
83
+ /**
84
+ * A disabled button that still explains itself. React Aria's `isDisabled` makes the button unfocusable and
85
+ * hoverable-less, so there'd be nothing to hang a tooltip on. With a reason the button instead stays focusable
86
+ * with `aria-disabled`, loses its press handlers and never submits a form, and the reason is shown as a tooltip
87
+ * and read as its description.
88
+ */
89
+ function useDisabledReason(props, disabledReason) {
90
+ const id = useId();
91
+ const active = Boolean(props.isDisabled && disabledReason);
92
+ if (!active) return {
93
+ active,
94
+ props,
95
+ reasonId: void 0
96
+ };
97
+ const rest = {
98
+ ...props,
99
+ isDisabled: false,
100
+ type: "button"
101
+ };
102
+ for (const h of pressHandlers) delete rest[h];
103
+ rest["aria-disabled"] = true;
104
+ rest["data-disabled"] = true;
105
+ rest["aria-describedby"] = [props["aria-describedby"], id].filter(Boolean).join(" ");
106
+ return {
107
+ active,
108
+ props: rest,
109
+ reasonId: id
110
+ };
111
+ }
112
+ /** Hover and press styles are dropped while a button is disabled with a reason, to look as disabled as one that is. */
113
+ const noInteraction = (classes) => classes.replace(/data-(hovered|pressed):\S+\s*/g, "");
114
+ function WithReason({ active, reasonId, reason, children }) {
115
+ if (!active) return children;
116
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Tooltip, {
117
+ content: reason,
118
+ children
119
+ }), /* @__PURE__ */ jsx("span", {
120
+ id: reasonId,
121
+ hidden: true,
122
+ children: reason
123
+ })] });
124
+ }
125
+ function Button({ variant = "ghost", size = "md", className, disabledReason, ...rest }) {
126
+ const { active, props, reasonId } = useDisabledReason(rest, disabledReason);
127
+ const button = /* @__PURE__ */ jsx(Button$1, {
49
128
  ...props,
50
- className: clsx("inline-flex cursor-pointer items-center rounded-chat-sm font-chat-sans whitespace-nowrap transition-colors select-none [&_svg]:size-4 [&_svg]:shrink-0", "data-disabled:cursor-default data-disabled:opacity-40", focusRing, variants[variant], sizes[size], className)
129
+ className: cx("inline-flex cursor-pointer items-center justify-center rounded-chat-sm font-chat-sans whitespace-nowrap transition-colors select-none [&_svg]:size-4 [&_svg]:shrink-0", "data-disabled:cursor-default data-disabled:opacity-40", focusRing, active ? noInteraction(variants[variant]) : variants[variant], sizes$1[size], className)
130
+ });
131
+ return /* @__PURE__ */ jsx(WithReason, {
132
+ active,
133
+ reasonId,
134
+ reason: disabledReason,
135
+ children: button
51
136
  });
52
137
  }
53
- function IconButton({ label, variant = "ghost", size = "md", tooltip = true, className, ...props }) {
138
+ function IconButton({ label, variant = "ghost", size = "md", tooltip = true, className, disabledReason, ...rest }) {
139
+ const { active, props, reasonId } = useDisabledReason(rest, disabledReason);
54
140
  const button = /* @__PURE__ */ jsx(Button$1, {
55
141
  ...props,
56
142
  "aria-label": label,
57
- className: clsx("inline-flex shrink-0 cursor-pointer items-center justify-center rounded-chat-sm transition-colors select-none", "data-disabled:cursor-default data-disabled:opacity-40 [&_svg]:size-4", focusRing, variants[variant], variant === "ghost" && "text-chat-muted data-hovered:text-chat-fg", size === "sm" ? "size-7 [@media(pointer:coarse)]:size-9" : "size-8 [@media(pointer:coarse)]:size-10", className)
143
+ className: cx("inline-flex shrink-0 cursor-pointer items-center justify-center rounded-chat-sm transition-colors select-none", "data-disabled:cursor-default data-disabled:opacity-40 [&_svg]:size-4", focusRing, active ? noInteraction(variants[variant]) : variants[variant], variant === "ghost" && (active ? "text-chat-muted" : "text-chat-muted data-hovered:text-chat-fg"), size === "sm" ? "size-7 [@media(pointer:coarse)]:size-9" : "size-8 [@media(pointer:coarse)]:size-10", className)
144
+ });
145
+ if (active) return /* @__PURE__ */ jsx(WithReason, {
146
+ active: true,
147
+ reasonId,
148
+ reason: disabledReason,
149
+ children: button
58
150
  });
59
151
  return tooltip ? /* @__PURE__ */ jsx(Tooltip, {
60
152
  content: label,
@@ -70,7 +162,7 @@ const tones = {
70
162
  };
71
163
  function Badge({ children, tone = "outline", icon, className }) {
72
164
  return /* @__PURE__ */ jsxs("span", {
73
- className: clsx("inline-flex items-center gap-1 rounded-full px-2 py-px font-chat-sans text-(length:--chat-ui-font-size-sm) leading-4 whitespace-nowrap [&_svg]:size-3", tones[tone], className),
165
+ className: cx("inline-flex items-center gap-1 rounded-full px-2 py-px font-chat-sans text-(length:--chat-ui-font-size-sm) leading-4 whitespace-nowrap [&_svg]:size-3", tones[tone], className),
74
166
  children: [icon, children]
75
167
  });
76
168
  }
@@ -97,6 +189,9 @@ const defaultIcons = {
97
189
  send: /* @__PURE__ */ jsx(ArrowUp, {}),
98
190
  stop: /* @__PURE__ */ jsx(Square, {}),
99
191
  attach: /* @__PURE__ */ jsx(Plus, {}),
192
+ mic: /* @__PURE__ */ jsx(Mic, {}),
193
+ dictationCancel: /* @__PURE__ */ jsx(X, {}),
194
+ dictationAccept: /* @__PURE__ */ jsx(Check, {}),
100
195
  scrollToBottom: /* @__PURE__ */ jsx(ArrowDown, {}),
101
196
  tokens: /* @__PURE__ */ jsx(ArrowDown, {}),
102
197
  sidebar: /* @__PURE__ */ jsx(PanelLeft, {}),
@@ -167,6 +262,9 @@ const defaultLabels = {
167
262
  duration: (seconds) => seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`,
168
263
  ranCommandLabel: "Ran a command",
169
264
  ranCommands: (count) => plural(count, "ran a command", (n) => `ran ${n} commands`),
265
+ runCommandAsk: "Run this command?",
266
+ searchWebAsk: "Search the web?",
267
+ readPageAsk: "Read this page?",
170
268
  searchedWebLabel: "Searched the web",
171
269
  readPageLabel: "Read page",
172
270
  readPages: (count) => plural(count, "read a page", (n) => `read ${n} pages`),
@@ -178,7 +276,7 @@ const defaultLabels = {
178
276
  noResults: "No results",
179
277
  reasoningHidden: "The model's reasoning is not shown.",
180
278
  allowTool: (name) => `Allow ${name}?`,
181
- allowAction: (label) => /* @__PURE__ */ jsxs(Fragment, { children: [
279
+ allowAction: (label) => /* @__PURE__ */ jsxs(Fragment$1, { children: [
182
280
  "Allow ",
183
281
  label,
184
282
  "?"
@@ -190,9 +288,14 @@ const defaultLabels = {
190
288
  openSidebar: "Open sidebar",
191
289
  expandSidebar: "Expand sidebar",
192
290
  collapseSidebar: "Collapse sidebar",
291
+ resizeSidebar: "Resize sidebar",
292
+ withShortcut: (label, keys) => `${label} (${keys})`,
293
+ ctrlKey: "Ctrl",
193
294
  context: "Context",
194
295
  contextUsed: "Context used",
195
296
  percentUsed: (percent) => `${percent}% used`,
297
+ contextSummary: (used, limit, percent) => `Context: ${used} / ${limit} (${percent}%)`,
298
+ contextLoading: "Context: loading",
196
299
  freeSpace: "Free space",
197
300
  tokenCount: (count) => `${count} tokens`,
198
301
  replyPlaceholder: "Reply…",
@@ -200,6 +303,11 @@ const defaultLabels = {
200
303
  message: "Message",
201
304
  stop: "Stop",
202
305
  send: "Send",
306
+ listening: "Listening…",
307
+ transcribing: "Transcribing…",
308
+ dictate: "Dictate",
309
+ dictationCancel: "Cancel dictation",
310
+ dictationAccept: "Use dictation",
203
311
  addFiles: "Add files and more",
204
312
  attachments: "Attachments",
205
313
  editPastedText: "Edit pasted text",
@@ -232,6 +340,7 @@ const defaultLabels = {
232
340
  justNow: "just now",
233
341
  copy: "Copy",
234
342
  copied: "Copied",
343
+ copyFieldLabel: "Link",
235
344
  retry: "Retry",
236
345
  edit: "Edit",
237
346
  readAloud: "Read aloud",
@@ -291,27 +400,40 @@ function CopyButton({ text, className }) {
291
400
  });
292
401
  }
293
402
  // src/primitives/segmented-control.tsx
403
+ const sizes = {
404
+ sm: {
405
+ button: "h-6",
406
+ iconOnly: "w-7"
407
+ },
408
+ md: {
409
+ button: "h-8",
410
+ iconOnly: "w-9"
411
+ }
412
+ };
294
413
  /**
295
- * One of a few options, side by side on a track, like claude.ai's Chat / Cowork switch. Themed with
414
+ * One of a few options, side by side on a track, similar to claude.ai's Chat / Cowork switch. Themed with
296
415
  * `--chat-segment-track` (default: a shade darker than the surface it sits on) and `--chat-segment-selected`
297
416
  * (default `--chat-raised`).
298
417
  */
299
- function SegmentedControl({ label, options, value, onChange, className }) {
418
+ function SegmentedControl({ label, options, value, onChange, clearable, isDisabled, size = "sm", className }) {
300
419
  return /* @__PURE__ */ jsx(ToggleButtonGroup, {
301
420
  "aria-label": label,
302
421
  selectionMode: "single",
303
- disallowEmptySelection: true,
304
- selectedKeys: [value],
422
+ disallowEmptySelection: !clearable,
423
+ ...isDisabled && { isDisabled },
424
+ selectedKeys: value === null ? [] : [value],
305
425
  onSelectionChange: (keys) => {
306
426
  const next = [...keys][0];
307
427
  if (next !== void 0) onChange(next);
428
+ else if (clearable) onChange(null);
308
429
  },
309
- className: clsx("inline-flex shrink-0 items-center gap-0.5 rounded-chat-md p-0.5", "bg-[var(--chat-segment-track,color-mix(in_srgb,var(--chat-bg)_55%,transparent))]", className),
430
+ className: cx("inline-flex shrink-0 items-center gap-0.5 rounded-chat-md p-0.5", "bg-[var(--chat-segment-track,color-mix(in_srgb,var(--chat-bg)_55%,transparent))]", className),
310
431
  children: options.map((o) => {
311
432
  const button = /* @__PURE__ */ jsxs(ToggleButton, {
312
433
  id: o.id,
434
+ ...o.isDisabled && { isDisabled: true },
313
435
  ...o.iconOnly && { "aria-label": o.label },
314
- className: clsx("inline-flex h-6 cursor-pointer items-center justify-center gap-1.5 rounded-[calc(var(--chat-radius-md)-2px)] font-chat-sans text-(length:--chat-ui-font-size) leading-5 text-chat-muted outline-none transition-colors select-none [&_svg]:size-4", o.iconOnly ? "w-7" : "px-2", "data-hovered:text-chat-fg data-focus-visible:ring-2 data-focus-visible:ring-chat-focus", "data-selected:bg-[var(--chat-segment-selected,var(--chat-raised))] data-selected:text-chat-fg data-selected:shadow-[inset_0_0_0_1px_var(--chat-border-strong)]"),
436
+ className: cx("inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-[calc(var(--chat-radius-md)-2px)] font-chat-sans text-(length:--chat-ui-font-size) leading-5 text-chat-muted outline-none transition-colors select-none [&_svg]:size-4", sizes[size].button, o.iconOnly ? sizes[size].iconOnly : "px-2", "data-disabled:cursor-default data-disabled:opacity-40", "data-hovered:text-chat-fg data-focus-visible:ring-2 data-focus-visible:ring-chat-focus", "data-selected:bg-[var(--chat-segment-selected,var(--chat-raised))] data-selected:text-chat-fg data-selected:shadow-[inset_0_0_0_1px_var(--chat-border-strong)]"),
315
437
  children: [o.icon, !o.iconOnly && o.label]
316
438
  }, o.id);
317
439
  return o.iconOnly ? /* @__PURE__ */ jsx(Tooltip, {
@@ -332,7 +454,7 @@ function Disclosure({ summary, children, className, summaryClassName, panelClass
332
454
  className: "m-0 font-normal",
333
455
  children: /* @__PURE__ */ jsxs(Button$1, {
334
456
  slot: "trigger",
335
- className: clsx("group/trigger flex max-w-full cursor-pointer items-center gap-1.5 rounded-chat-sm text-left text-chat-muted outline-none", "data-hovered:text-chat-fg data-focus-visible:ring-2 data-focus-visible:ring-chat-focus", summaryClassName),
457
+ className: cx("group/trigger flex max-w-full cursor-pointer items-center gap-1.5 rounded-chat-sm text-left text-chat-muted outline-none", "data-hovered:text-chat-fg data-focus-visible:ring-2 data-focus-visible:ring-chat-focus", summaryClassName),
336
458
  children: [
337
459
  indicator === "chevron" && /* @__PURE__ */ jsx(IconSlot, {
338
460
  className: "transition-transform group-aria-expanded/trigger:rotate-90 [&_svg]:size-3.5",
@@ -359,6 +481,11 @@ function Disclosure({ summary, children, className, summaryClassName, panelClass
359
481
  });
360
482
  }
361
483
  // src/primitives/menu.tsx
484
+ /**
485
+ * Wraps a trigger button and a kit `Popover` (or `Modal`) so that pressing the button toggles it, without
486
+ * importing react-aria. The same component as `DialogTrigger`; use whichever name reads better.
487
+ */
488
+ const PopoverTrigger = DialogTrigger$1;
362
489
  /** A search box above a menu that filters its items, like "Search or create a project". */
363
490
  function MenuSearch({ placeholder, children, value, onChange }) {
364
491
  const { contains } = useFilter({ sensitivity: "base" });
@@ -377,41 +504,71 @@ function MenuSearch({ placeholder, children, value, onChange }) {
377
504
  }), children]
378
505
  });
379
506
  }
380
- /** Floating panel for menus and pickers: surface color, rounded, soft shadow, fades in. */
381
- function Popover({ className, offset = 6, ...props }) {
507
+ /** Viewport narrower than `max` px. False on the server and where `matchMedia` is missing. */
508
+ function useNarrower(max) {
509
+ const query = max === false ? null : `(max-width: ${max - .02}px)`;
510
+ return useSyncExternalStore((notify) => {
511
+ if (!query || typeof window === "undefined" || !window.matchMedia) return () => {};
512
+ const list = window.matchMedia(query);
513
+ list.addEventListener("change", notify);
514
+ return () => list.removeEventListener("change", notify);
515
+ }, () => query && typeof window !== "undefined" && window.matchMedia ? window.matchMedia(query).matches : false, () => false);
516
+ }
517
+ /** Floating panel for menus and pickers: surface color, rounded, soft shadow, fades in. On phones it's a bottom sheet. */
518
+ function Popover({ className, offset = 6, sheet = 768, width = "auto", fullWidthOnPhones, children, ...props }) {
519
+ if (useNarrower(sheet)) {
520
+ const { isOpen, onOpenChange } = props;
521
+ return /* @__PURE__ */ jsx(ModalOverlay, {
522
+ ...isOpen !== void 0 && { isOpen },
523
+ ...onOpenChange && { onOpenChange },
524
+ isDismissable: true,
525
+ className: "fixed inset-0 z-50 flex items-end bg-(--chat-backdrop) data-entering:animate-chat-fade-in data-exiting:animate-chat-fade-out",
526
+ children: /* @__PURE__ */ jsx(Modal$1, {
527
+ className: cx("flex max-h-[85dvh] w-full flex-col overflow-auto rounded-t-chat-lg border-t border-chat-popover-border bg-chat-popover pb-[env(safe-area-inset-bottom)] font-chat-sans text-chat-fg shadow-chat-popover outline-none", "data-entering:animate-chat-sheet-in data-exiting:animate-chat-sheet-out", className),
528
+ children: typeof children === "function" ? children({
529
+ placement: "bottom",
530
+ trigger: null,
531
+ isEntering: false,
532
+ isExiting: false,
533
+ defaultChildren: void 0
534
+ }) : children
535
+ })
536
+ });
537
+ }
382
538
  return /* @__PURE__ */ jsx(Popover$1, {
383
539
  offset,
384
540
  ...props,
385
- className: clsx("rounded-chat-popover border border-chat-popover-border bg-chat-popover font-chat-sans text-chat-fg shadow-chat-popover outline-none", "motion-safe:data-entering:animate-chat-in motion-safe:data-exiting:animate-chat-out", className)
541
+ className: cx("rounded-chat-popover border border-chat-popover-border bg-chat-popover font-chat-sans text-chat-fg shadow-chat-popover outline-none", "motion-safe:data-entering:animate-chat-in motion-safe:data-exiting:animate-chat-out", width === "trigger" && "w-(--trigger-width) [&_[role=menu]]:min-w-0", fullWidthOnPhones && "max-sm:w-[calc(100vw-1.5rem)]", className),
542
+ children
386
543
  });
387
544
  }
388
545
  function Menu({ className, ...props }) {
389
546
  return /* @__PURE__ */ jsx(Menu$1, {
390
547
  ...props,
391
- className: clsx("max-h-[inherit] min-w-52 overflow-auto p-1.5 outline-none", className)
548
+ className: cx("max-h-[inherit] min-w-52 overflow-auto p-1.5 outline-none", className)
392
549
  });
393
550
  }
394
- function MenuItem({ children, icon, description, descriptionPlacement = "below", detail, shortcut, tone = "default", className, ...props }) {
551
+ function MenuItem({ children, icon, description, descriptionPlacement = "below", detail, shortcut, tone = "default", indicator = "check", className, ...props }) {
395
552
  const icons = useIcons();
396
553
  const textValue = props.textValue ?? (typeof children === "string" ? children : void 0);
397
554
  return /* @__PURE__ */ jsx(MenuItem$1, {
398
555
  ...props,
399
556
  ...textValue !== void 0 && { textValue },
400
- className: clsx("group flex min-h-7 cursor-pointer items-center gap-2.5 rounded-chat-sm px-2 py-1 text-(length:--chat-ui-font-size) leading-5 text-chat-fg outline-none select-none", "data-focused:bg-chat-popover-hover data-open:bg-chat-popover-hover data-disabled:opacity-40", tone === "danger" && "text-chat-danger", className),
401
- children: ({ isSelected, hasSubmenu, selectionMode }) => /* @__PURE__ */ jsxs(Fragment, { children: [
557
+ className: cx("group flex min-h-7 cursor-pointer items-center gap-2.5 rounded-chat-sm px-2 py-1 text-(length:--chat-ui-font-size) leading-5 text-chat-fg outline-none select-none", "data-focused:bg-chat-popover-hover data-open:bg-chat-popover-hover data-disabled:opacity-40", tone === "danger" && "text-chat-danger", className),
558
+ children: ({ isSelected, hasSubmenu, selectionMode }) => /* @__PURE__ */ jsxs(Fragment$1, { children: [
402
559
  icon && /* @__PURE__ */ jsx("span", {
403
- className: clsx("flex size-4 shrink-0 items-center [&_svg]:size-4", tone === "danger" ? "text-chat-danger" : "text-chat-fg"),
560
+ className: cx("flex size-4 shrink-0 items-center [&_svg]:size-4", tone === "danger" ? "text-chat-danger" : "text-chat-fg"),
404
561
  children: icon
405
562
  }),
406
563
  /* @__PURE__ */ jsxs("span", {
407
- className: clsx("flex min-w-0 flex-1", descriptionPlacement === "beside" ? "items-baseline gap-2" : "flex-col"),
564
+ className: cx("flex min-w-0 flex-1", descriptionPlacement === "beside" ? "items-baseline gap-2" : "flex-col"),
408
565
  children: [/* @__PURE__ */ jsx(Text, {
409
566
  slot: "label",
410
- className: clsx("truncate", descriptionPlacement === "beside" && "shrink-0"),
567
+ className: cx("truncate", descriptionPlacement === "beside" && "shrink-0"),
411
568
  children
412
569
  }), description && /* @__PURE__ */ jsx(Text, {
413
570
  slot: "description",
414
- className: clsx("text-chat-muted", descriptionPlacement === "beside" ? "truncate text-(length:--chat-ui-font-size) leading-5" : "text-(length:--chat-ui-font-size-sm) leading-4"),
571
+ className: cx("text-chat-muted", descriptionPlacement === "beside" ? "truncate text-(length:--chat-ui-font-size) leading-5" : "text-(length:--chat-ui-font-size-sm) leading-4"),
415
572
  children: description
416
573
  })]
417
574
  }),
@@ -423,7 +580,12 @@ function MenuItem({ children, icon, description, descriptionPlacement = "below",
423
580
  className: "shrink-0 font-chat-sans text-(length:--chat-ui-font-size-sm) leading-4 text-chat-subtle",
424
581
  children: shortcut
425
582
  }),
426
- selectionMode !== "none" && isSelected && /* @__PURE__ */ jsx(IconSlot, {
583
+ selectionMode !== "none" && indicator === "switch" && /* @__PURE__ */ jsx("span", {
584
+ "aria-hidden": true,
585
+ "data-selected": isSelected || void 0,
586
+ className: "relative h-4 w-7 shrink-0 rounded-full bg-chat-border-strong transition-colors after:absolute after:top-0.5 after:left-0.5 after:size-3 after:rounded-full after:bg-chat-bg after:transition-transform data-selected:bg-chat-accent data-selected:after:translate-x-3 data-selected:after:bg-chat-accent-fg"
587
+ }),
588
+ selectionMode !== "none" && indicator === "check" && isSelected && /* @__PURE__ */ jsx(IconSlot, {
427
589
  className: "text-chat-check [&_svg]:size-4",
428
590
  children: icons.check
429
591
  }),
@@ -450,7 +612,7 @@ function MenuSeparator() {
450
612
  /** Explanatory text inside a menu, above or below the items. Not focusable. */
451
613
  function MenuNote({ children, className }) {
452
614
  return /* @__PURE__ */ jsx("div", {
453
- className: clsx("px-2 py-1.5 text-(length:--chat-ui-font-size) leading-snug text-chat-muted", className),
615
+ className: cx("px-2 py-1.5 text-(length:--chat-ui-font-size) leading-snug text-chat-muted", className),
454
616
  children
455
617
  });
456
618
  }
@@ -484,7 +646,7 @@ function ContextMenu({ menu, children, className }) {
484
646
  ref: root,
485
647
  onContextMenu,
486
648
  onKeyDown,
487
- className: clsx("relative", className),
649
+ className: cx("relative", className),
488
650
  children: [children, /* @__PURE__ */ jsxs(MenuTrigger$1, {
489
651
  isOpen: at !== void 0,
490
652
  onOpenChange: (open) => !open && setAt(void 0),
@@ -505,15 +667,23 @@ function ContextMenu({ menu, children, className }) {
505
667
  });
506
668
  }
507
669
  // src/primitives/overlay.tsx
670
+ const modalSizes = {
671
+ sm: "max-w-sm",
672
+ md: "max-w-lg",
673
+ lg: "max-w-2xl",
674
+ xl: "max-w-5xl",
675
+ /** Fills the page inside the overlay's padding. */
676
+ full: "h-full max-h-none max-w-none"
677
+ };
508
678
  /** A centered dialog over a dimmed page. Escape and clicking outside close it; focus stays inside. */
509
- function Modal({ isOpen, onOpenChange, title, children, className, ...props }) {
679
+ function Modal({ isOpen, onOpenChange, title, size = "lg", placement = "center", children, className, ...props }) {
510
680
  return /* @__PURE__ */ jsx(ModalOverlay, {
511
681
  isOpen,
512
682
  onOpenChange,
513
683
  isDismissable: true,
514
- className: "fixed inset-0 z-50 flex items-center justify-center bg-(--chat-backdrop) p-4 data-entering:animate-chat-fade-in data-exiting:animate-chat-fade-out",
684
+ className: cx("fixed inset-0 z-50 flex justify-center bg-(--chat-backdrop) p-4 data-entering:animate-chat-fade-in data-exiting:animate-chat-fade-out", placement === "top" ? "items-start pt-[10dvh]" : "items-center"),
515
685
  children: /* @__PURE__ */ jsx(Modal$1, {
516
- className: clsx("flex max-h-[85dvh] w-full max-w-2xl flex-col rounded-chat-lg border border-chat-popover-border bg-chat-popover font-chat-sans text-chat-fg shadow-chat-popover", "data-entering:animate-chat-in data-exiting:animate-chat-out", className),
686
+ className: cx("flex max-h-[85dvh] w-full flex-col rounded-chat-lg border border-chat-popover-border bg-chat-popover font-chat-sans text-chat-fg shadow-chat-popover", modalSizes[size], "data-entering:animate-chat-in data-exiting:animate-chat-out", className),
517
687
  children: /* @__PURE__ */ jsxs(Dialog, {
518
688
  ...props,
519
689
  className: "flex min-h-0 flex-1 flex-col outline-none",
@@ -534,7 +704,7 @@ function Drawer({ isOpen, onOpenChange, side = "left", children, className, ...p
534
704
  isDismissable: true,
535
705
  className: "fixed inset-0 z-40 bg-(--chat-backdrop) data-entering:animate-chat-fade-in data-exiting:animate-chat-fade-out",
536
706
  children: /* @__PURE__ */ jsx(Modal$1, {
537
- className: clsx("fixed inset-y-0 w-[min(20rem,85vw)] bg-chat-sidebar text-chat-fg shadow-chat-popover outline-none", side === "left" ? "left-0 data-entering:animate-chat-drawer-in data-exiting:animate-chat-drawer-out" : "right-0 data-entering:animate-chat-drawer-in-right data-exiting:animate-chat-drawer-out-right", className),
707
+ className: cx("fixed inset-y-0 w-[min(20rem,85vw)] bg-chat-sidebar text-chat-fg shadow-chat-popover outline-none", side === "left" ? "left-0 data-entering:animate-chat-drawer-in data-exiting:animate-chat-drawer-out" : "right-0 data-entering:animate-chat-drawer-in-right data-exiting:animate-chat-drawer-out-right", className),
538
708
  children: /* @__PURE__ */ jsx(Dialog, {
539
709
  ...props,
540
710
  className: "h-full outline-none",
@@ -551,7 +721,7 @@ function Drawer({ isOpen, onOpenChange, side = "left", children, className, ...p
551
721
  function Panel({ title, actions, footer, children, variant = "card", className, ...props }) {
552
722
  return /* @__PURE__ */ jsxs("section", {
553
723
  "aria-label": props["aria-label"],
554
- className: clsx("flex min-h-0 min-w-0 flex-col overflow-hidden bg-chat-bg font-chat-sans text-chat-fg", variant === "card" && "rounded-chat-md border border-chat-border", className),
724
+ className: cx("flex min-h-0 min-w-0 flex-col overflow-hidden bg-chat-bg font-chat-sans text-chat-fg", variant === "card" && "rounded-chat-md border border-chat-border", className),
555
725
  children: [
556
726
  (title || actions) && /* @__PURE__ */ jsxs("header", {
557
727
  className: "flex h-10 shrink-0 items-center gap-2 border-b border-chat-divider px-3 text-(length:--chat-ui-font-size) leading-5",
@@ -718,7 +888,7 @@ function CodeBlock({ code, language, label = language, variant = "plain", copy =
718
888
  }), copy && /* @__PURE__ */ jsx(CopyButton, { text: code })]
719
889
  }), /* @__PURE__ */ jsx("pre", {
720
890
  className: clsx("overflow-auto font-chat-mono text-(length:--chat-code-font-size) leading-[1.75] whitespace-pre text-chat-fg", variant === "plain" && "max-h-56", "[scrollbar-color:var(--chat-border-strong)_transparent] [scrollbar-width:thin]"),
721
- children: /* @__PURE__ */ jsxs("code", { children: [highlighted ? highlighted.map((line, i) => /* @__PURE__ */ jsxs(Fragment$1, { children: [i > 0 && "\n", line.map((token, j) => /* @__PURE__ */ jsx("span", {
891
+ children: /* @__PURE__ */ jsxs("code", { children: [highlighted ? highlighted.map((line, i) => /* @__PURE__ */ jsxs(Fragment, { children: [i > 0 && "\n", line.map((token, j) => /* @__PURE__ */ jsx("span", {
722
892
  className: "chat-code-token",
723
893
  style: token.htmlStyle,
724
894
  children: token.content
@@ -873,7 +1043,7 @@ const DECLINED = "Declined by the user";
873
1043
  * retries. With an `AgentProvider` it also runs the agent loop: client tools run in the page, approvals
874
1044
  * wait for the user, then the reply continues; every finished tool call reaches its tool's `onResult`.
875
1045
  */
876
- function useChat({ transport, conversationId, initialMessages = [], onTitle, agent, maxSteps = 12 }) {
1046
+ function useChat({ transport, conversationId, initialMessages = [], onTitle, agent, maxSteps = 12, resume = false }) {
877
1047
  const contextAgent = useAgentRegistry();
878
1048
  const registry = agent === void 0 ? contextAgent : agent;
879
1049
  const [messages, setMessages] = useState(initialMessages);
@@ -941,7 +1111,7 @@ function useChat({ transport, conversationId, initialMessages = [], onTitle, age
941
1111
  if (ended) commit(ended);
942
1112
  }, [commit]);
943
1113
  const advanceRef = useRef(() => {});
944
- const start = useCallback((history, options, continuation) => {
1114
+ const start = useCallback((history, options, continuation, joining) => {
945
1115
  const message = history.at(-1);
946
1116
  const controller = new AbortController();
947
1117
  const current = {
@@ -957,7 +1127,7 @@ function useChat({ transport, conversationId, initialMessages = [], onTitle, age
957
1127
  try {
958
1128
  const { transport, conversationId, registry } = latest.current;
959
1129
  const tools = registry?.specs();
960
- for await (const event of transport.send({
1130
+ const events = joining && conversationId !== void 0 && transport.resume ? transport.resume(conversationId, controller.signal) : transport.send({
961
1131
  message,
962
1132
  history,
963
1133
  signal: controller.signal,
@@ -967,7 +1137,8 @@ function useChat({ transport, conversationId, initialMessages = [], onTitle, age
967
1137
  ...conversationId !== void 0 && { conversationId },
968
1138
  ...options.model !== void 0 && { model: options.model },
969
1139
  ...options.effort !== void 0 && { effort: options.effort }
970
- })) {
1140
+ });
1141
+ for await (const event of events) {
971
1142
  if (controller.signal.aborted) break;
972
1143
  if (event.type === "title") {
973
1144
  latest.current.onTitle?.(event.title);
@@ -1139,7 +1310,30 @@ function useChat({ transport, conversationId, initialMessages = [], onTitle, age
1139
1310
  };
1140
1311
  start(messages.slice(0, index), options);
1141
1312
  }, [start]);
1142
- useEffect(() => () => run.current?.controller.abort(), []);
1313
+ const joined = useRef(false);
1314
+ useEffect(() => {
1315
+ if (!resume || joined.current || run.current) return;
1316
+ const { transport, conversationId, messages } = latest.current;
1317
+ if (!transport.resume || conversationId === void 0) return;
1318
+ joined.current = true;
1319
+ const last = messages.at(-1);
1320
+ const history = last?.role === "assistant" && last.status === "streaming" ? messages.slice(0, -1) : messages;
1321
+ loop.current = {
1322
+ options: {},
1323
+ step: 0
1324
+ };
1325
+ start(history, {}, void 0, true);
1326
+ }, [resume, start]);
1327
+ const mounted = useRef(false);
1328
+ useEffect(() => {
1329
+ mounted.current = true;
1330
+ return () => {
1331
+ mounted.current = false;
1332
+ queueMicrotask(() => {
1333
+ if (!mounted.current) run.current?.controller.abort();
1334
+ });
1335
+ };
1336
+ }, []);
1143
1337
  return {
1144
1338
  messages,
1145
1339
  busy,
@@ -1152,7 +1346,7 @@ function useChat({ transport, conversationId, initialMessages = [], onTitle, age
1152
1346
  }
1153
1347
  // src/hooks/sse-transport.ts
1154
1348
  /** A `Transport` for backends that stream `StreamEvent`s over server-sent events (see `formatEvent`). */
1155
- function sseTransport({ url, headers, stopUrl, fetch: fetchImpl = fetch }) {
1349
+ function sseTransport({ url, headers, stopUrl, resumeUrl, fetch: fetchImpl = fetch }) {
1156
1350
  const headersFor = async () => ({
1157
1351
  "content-type": "application/json",
1158
1352
  ...Object.fromEntries(new Headers(typeof headers === "function" ? await headers() : headers))
@@ -1168,6 +1362,20 @@ function sseTransport({ url, headers, stopUrl, fetch: fetchImpl = fetch }) {
1168
1362
  if (!response.ok || !response.body) throw new Error(`${url} answered ${response.status} ${response.statusText}`.trim());
1169
1363
  yield* readEventStream(response.body);
1170
1364
  },
1365
+ ...resumeUrl && { async *resume(conversationId, signal) {
1366
+ const target = typeof resumeUrl === "function" ? resumeUrl(conversationId) : `${resumeUrl}${resumeUrl.includes("?") ? "&" : "?"}conversationId=${encodeURIComponent(conversationId)}`;
1367
+ const { "content-type": _, ...headers } = await headersFor();
1368
+ const response = await fetchImpl(target, {
1369
+ method: "GET",
1370
+ headers: {
1371
+ ...headers,
1372
+ accept: "text/event-stream"
1373
+ },
1374
+ signal
1375
+ });
1376
+ if (!response.ok || !response.body) throw new Error(`${target} answered ${response.status} ${response.statusText}`.trim());
1377
+ yield* readEventStream(response.body);
1378
+ } },
1171
1379
  ...stopUrl && { async stop(runId) {
1172
1380
  await fetchImpl(stopUrl, {
1173
1381
  method: "POST",
@@ -1272,6 +1480,30 @@ const SidebarContext = createContext({
1272
1480
  dismiss: () => {}
1273
1481
  });
1274
1482
  const useSidebar = () => useContext(SidebarContext);
1483
+ /** State that the parent may own: `value` makes it controlled, otherwise `initial` seeds it. */
1484
+ function useControllable(value, initial, onChange) {
1485
+ const [inner, setInner] = useState(initial);
1486
+ const current = value ?? inner;
1487
+ const latest = useRef({
1488
+ current,
1489
+ value,
1490
+ onChange
1491
+ });
1492
+ latest.current = {
1493
+ current,
1494
+ value,
1495
+ onChange
1496
+ };
1497
+ return [current, useCallback((next) => {
1498
+ const { current, value, onChange } = latest.current;
1499
+ const resolved = typeof next === "function" ? next(current) : next;
1500
+ if (resolved === current) return;
1501
+ if (value === void 0) setInner(resolved);
1502
+ onChange?.(resolved);
1503
+ }, [])];
1504
+ }
1505
+ /** True on Apple platforms, where the shortcut modifier is ⌘ instead of Ctrl. */
1506
+ const isMacPlatform = () => typeof navigator !== "undefined" && /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent);
1275
1507
  // src/components/replacements.tsx
1276
1508
  /**
1277
1509
  * In-place updates of drawn visuals: a tool call whose input has `replaces: <id>` takes the place of that
@@ -2185,7 +2417,7 @@ function Code({ node, className, children }) {
2185
2417
  });
2186
2418
  }
2187
2419
  const components = {
2188
- pre: ({ children }) => /* @__PURE__ */ jsx(Fragment, { children }),
2420
+ pre: ({ children }) => /* @__PURE__ */ jsx(Fragment$1, { children }),
2189
2421
  code: ({ node, className, children }) => /* @__PURE__ */ jsx(Code, {
2190
2422
  node,
2191
2423
  className,
@@ -2193,12 +2425,39 @@ const components = {
2193
2425
  })
2194
2426
  };
2195
2427
  const LinksContext = createContext(void 0);
2196
- function MarkdownLinksProvider({ renderLink, children }) {
2428
+ function MarkdownLinksProvider({ renderLink, renderLinkList, children }) {
2429
+ const value = useMemo(() => ({
2430
+ renderLink,
2431
+ renderLinkList
2432
+ }), [renderLink, renderLinkList]);
2197
2433
  return /* @__PURE__ */ jsx(LinksContext.Provider, {
2198
- value: renderLink,
2434
+ value,
2199
2435
  children
2200
2436
  });
2201
2437
  }
2438
+ /** The rendered link an item consists of: an `a` alone, or alone in its paragraph (a loose list). */
2439
+ function soleLink(children) {
2440
+ const nodes = Children.toArray(children).filter((c) => !(typeof c === "string" && !c.trim()));
2441
+ if (nodes.length !== 1 || !isValidElement(nodes[0])) return void 0;
2442
+ const props = nodes[0].props;
2443
+ if (typeof props.href === "string") return {
2444
+ href: props.href,
2445
+ children: props.children
2446
+ };
2447
+ return props.node?.tagName === "p" ? soleLink(props.children) : void 0;
2448
+ }
2449
+ function listOf(ordered, props, renderLinkList) {
2450
+ const Default = ordered ? defaultComponents.ol : defaultComponents.ul;
2451
+ const { children } = props;
2452
+ const links = Children.toArray(children).filter((c) => isValidElement(c)).map((item) => soleLink(item.props.children));
2453
+ const list = /* @__PURE__ */ jsx(Default, { ...props });
2454
+ if (links.length === 0 || links.some((l) => !l)) return list;
2455
+ return renderLinkList({
2456
+ ordered,
2457
+ links,
2458
+ children: list
2459
+ }) ?? list;
2460
+ }
2202
2461
  /** Message markdown: GFM, KaTeX math ($…$ and $$…$$), Shiki code blocks, smooth fade-in while streaming. */
2203
2462
  function Markdown({ children, streaming = false, className, partId }) {
2204
2463
  const text = useSmoothText(children, streaming);
@@ -2229,22 +2488,26 @@ function BlockScope({ id, streaming, children }) {
2229
2488
  });
2230
2489
  }
2231
2490
  const MarkdownBody = memo(function MarkdownBody({ text, animating, fade }) {
2232
- const renderLink = useContext(LinksContext);
2233
- const withLinks = useMemo(() => renderLink ? {
2491
+ const { renderLink, renderLinkList } = useContext(LinksContext) ?? {};
2492
+ const withLinks = useMemo(() => ({
2234
2493
  ...components,
2235
- a: ({ href, children }) => {
2494
+ ...renderLink && { a: ({ href, children }) => {
2236
2495
  const custom = href ? renderLink({
2237
2496
  href,
2238
2497
  children
2239
2498
  }) : void 0;
2240
- return custom !== void 0 ? /* @__PURE__ */ jsx(Fragment, { children: custom }) : /* @__PURE__ */ jsx("a", {
2499
+ return custom !== void 0 ? /* @__PURE__ */ jsx(Fragment$1, { children: custom }) : /* @__PURE__ */ jsx("a", {
2241
2500
  href,
2242
2501
  target: "_blank",
2243
2502
  rel: "noopener noreferrer",
2244
2503
  children
2245
2504
  });
2505
+ } },
2506
+ ...renderLinkList && {
2507
+ ul: (props) => listOf(false, props, renderLinkList),
2508
+ ol: (props) => listOf(true, props, renderLinkList)
2246
2509
  }
2247
- } : components, [renderLink]);
2510
+ }), [renderLink, renderLinkList]);
2248
2511
  return /* @__PURE__ */ jsx(Streamdown, {
2249
2512
  prefix: PREFIX,
2250
2513
  mode: animating || fade ? "streaming" : "static",
@@ -2304,7 +2567,7 @@ function activityLabel(part, labels, view) {
2304
2567
  */
2305
2568
  function activityHeading(parts, views, labels) {
2306
2569
  const waiting = parts.find((p) => p.type === "tool_call" && p.status === "awaiting");
2307
- if (waiting) return /* @__PURE__ */ jsxs(Fragment, { children: [activityLabel(waiting, labels, views[waiting.name]), labels.waitingForYou] });
2570
+ if (waiting) return /* @__PURE__ */ jsxs(Fragment$1, { children: [activityLabel(waiting, labels, views[waiting.name]), labels.waitingForYou] });
2308
2571
  if (parts.some(isWorking)) {
2309
2572
  const last = parts.at(-1);
2310
2573
  return activityLabel(last, labels, last.type === "tool_call" ? views[last.name] : void 0);
@@ -2323,6 +2586,7 @@ function stringOf(value) {
2323
2586
  }
2324
2587
  /** A shell command. Input `{ command, description? }`; result a string or `{ stdout, stderr, exitCode }`. */
2325
2588
  const commandTool = {
2589
+ ask: (_, labels) => labels.runCommandAsk,
2326
2590
  label: (_, labels) => labels.ranCommandLabel,
2327
2591
  hint: (part) => stringOf(recordOf(part.input).description),
2328
2592
  summary: (n, labels) => labels.ranCommands(n),
@@ -2355,6 +2619,7 @@ const commandTool = {
2355
2619
  * part's display `data`, else its `result`.
2356
2620
  */
2357
2621
  const webSearchTool = {
2622
+ ask: (_, labels) => labels.searchWebAsk,
2358
2623
  label: (_, labels) => labels.searchedWebLabel,
2359
2624
  subtitle: (part) => stringOf(recordOf(part.input).query) ?? part.subtitle,
2360
2625
  summary: (n, labels) => labels.searchedWeb(n),
@@ -2402,6 +2667,7 @@ const webSearchTool = {
2402
2667
  };
2403
2668
  /** Reads a web page. Input `{ url }`; the row shows the page's host, the detail links to it. */
2404
2669
  const webFetchTool = {
2670
+ ask: (_, labels) => labels.readPageAsk,
2405
2671
  label: (_, labels) => labels.readPageLabel,
2406
2672
  subtitle: (part) => {
2407
2673
  const url = stringOf(recordOf(part.input).url);
@@ -2542,7 +2808,7 @@ function ActivityGroup({ parts, defaultExpanded = false, className }) {
2542
2808
  }) : /* @__PURE__ */ jsx(Disclosure, {
2543
2809
  isExpanded: expanded,
2544
2810
  onExpandedChange: setExpanded,
2545
- summary: /* @__PURE__ */ jsxs(Fragment, { children: [
2811
+ summary: /* @__PURE__ */ jsxs(Fragment$1, { children: [
2546
2812
  /* @__PURE__ */ jsx(Lead, { live }),
2547
2813
  /* @__PURE__ */ jsx(Live, {
2548
2814
  live,
@@ -2576,7 +2842,7 @@ const SHIMMER_MS = 2e3;
2576
2842
  * shared clock, so every live label sweeps in step and a remount does not restart it.
2577
2843
  */
2578
2844
  function Live({ live, children }) {
2579
- return live ? /* @__PURE__ */ jsx(Shimmer, { children }) : /* @__PURE__ */ jsx(Fragment, { children });
2845
+ return live ? /* @__PURE__ */ jsx(Shimmer, { children }) : /* @__PURE__ */ jsx(Fragment$1, { children });
2580
2846
  }
2581
2847
  function Shimmer({ children }) {
2582
2848
  const [delay] = useState(() => `-${Math.round(performance.now() % SHIMMER_MS)}ms`);
@@ -2594,7 +2860,7 @@ function SingleStep({ part, live, after, expanded, onExpandedChange }) {
2594
2860
  return /* @__PURE__ */ jsx(Disclosure, {
2595
2861
  isExpanded: expanded,
2596
2862
  onExpandedChange,
2597
- summary: /* @__PURE__ */ jsxs(Fragment, { children: [
2863
+ summary: /* @__PURE__ */ jsxs(Fragment$1, { children: [
2598
2864
  /* @__PURE__ */ jsx(Lead, { live }),
2599
2865
  /* @__PURE__ */ jsx(Live, {
2600
2866
  live,
@@ -2765,11 +3031,19 @@ function useAttachments({ upload } = {}) {
2765
3031
  toParts
2766
3032
  };
2767
3033
  }
3034
+ const IMAGE_EXTENSION = /\.(png|jpe?g|gif|webp|avif|svg|bmp|ico)$/i;
3035
+ /** Whether the card draws a thumbnail: by mime when given, else by `kind`, else by the file extension of the url or name. */
3036
+ function isImage({ mime, kind, url, name }) {
3037
+ if (mime) return mime.startsWith("image/");
3038
+ if (kind) return kind === "image";
3039
+ const path = url && !url.startsWith("data:") ? url.split(/[?#]/)[0] : "";
3040
+ return url?.startsWith("data:image/") === true || IMAGE_EXTENSION.test(path) || IMAGE_EXTENSION.test(name);
3041
+ }
2768
3042
  const extension = (name) => /\.([a-z0-9]{1,5})$/i.exec(name)?.[1]?.toUpperCase();
2769
3043
  /** A square card: pasted text preview with a PASTED badge, an image thumbnail, or a file with its type. */
2770
3044
  function AttachmentCard({ attachment: a, onRemove, onOpen, size = "md", className }) {
2771
3045
  const icons = useIcons();
2772
- const image = a.mime.startsWith("image/") && a.url;
3046
+ const image = a.url && isImage(a);
2773
3047
  const pasted = a.text !== void 0;
2774
3048
  const labels = useLabels();
2775
3049
  const uploading = a.status === "uploading";
@@ -2834,7 +3108,7 @@ function AttachmentRow({ attachments, onRemove, onEditText }) {
2834
3108
  const [editing, setEditing] = useState();
2835
3109
  const labels = useLabels();
2836
3110
  if (attachments.length === 0) return null;
2837
- return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("ul", {
3111
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("ul", {
2838
3112
  "aria-label": labels.attachments,
2839
3113
  className: "flex gap-2.5 overflow-x-auto px-0.5 pt-2.5 pb-1 [scrollbar-width:thin]",
2840
3114
  children: attachments.map((a) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(AttachmentCard, {
@@ -3004,7 +3278,7 @@ function AssistantMessage({ message, onRetry, onFeedback, onReadAloud, renderPar
3004
3278
  if (block.kind === "activity") {
3005
3279
  const cards = block.parts.filter((p) => p.type === "tool_call" && views[p.name]?.Card);
3006
3280
  if (!cards.length) return /* @__PURE__ */ jsx(ActivityGroup, { parts: block.parts }, block.id);
3007
- return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(ActivityGroup, { parts: block.parts }), cards.map((p) => {
3281
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(ActivityGroup, { parts: block.parts }), cards.map((p) => {
3008
3282
  if (p.type !== "tool_call") return null;
3009
3283
  const Card = views[p.name].Card;
3010
3284
  return /* @__PURE__ */ jsx(Card, { part: p }, p.id);
@@ -3046,7 +3320,7 @@ function AssistantMessage({ message, onRetry, onFeedback, onReadAloud, renderPar
3046
3320
  onPress: onReadAloud,
3047
3321
  children: icons.speak
3048
3322
  }),
3049
- onFeedback && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(IconButton, {
3323
+ onFeedback && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(IconButton, {
3050
3324
  label: labels.goodResponse,
3051
3325
  size: "sm",
3052
3326
  onPress: () => onFeedback("good"),
@@ -3100,7 +3374,7 @@ function ApprovalCard({ approval, onRespond, className }) {
3100
3374
  children: icons.approval
3101
3375
  }), /* @__PURE__ */ jsx("span", {
3102
3376
  className: "min-w-0 flex-1 font-medium",
3103
- children: view.ask?.(part) ?? labels.allowAction(activityLabel(part, labels, view))
3377
+ children: view.ask?.(part, labels) ?? labels.allowAction(view.askLabel?.(part, labels) ?? activityLabel(part, labels, view))
3104
3378
  })]
3105
3379
  }),
3106
3380
  /* @__PURE__ */ jsx("div", {
@@ -3131,6 +3405,16 @@ function ApprovalCard({ approval, onRespond, className }) {
3131
3405
  // src/components/context-panel.tsx
3132
3406
  const FREE_COLOR = "var(--chat-raised)";
3133
3407
  const DEFAULT_COLOR = "var(--chat-subtle)";
3408
+ /** Tokens used (reserved and deferred categories excluded) and tokens still free. */
3409
+ function contextTotals(usage) {
3410
+ const counted = usage.categories.filter((c) => !c.deferred);
3411
+ const used = counted.reduce((sum, c) => sum + (c.reserved ? 0 : c.tokens), 0);
3412
+ const reserved = counted.reduce((sum, c) => sum + (c.reserved ? c.tokens : 0), 0);
3413
+ return {
3414
+ used,
3415
+ free: Math.max(usage.limit - used - reserved, 0)
3416
+ };
3417
+ }
3134
3418
  /**
3135
3419
  * What fills the context window: a usage line, a segmented bar, one row per category with its share, and
3136
3420
  * expandable lists (each MCP tool, each memory file). Deferred categories are listed but not counted.
@@ -3139,9 +3423,7 @@ function ContextPanel({ usage, title, defaultExpanded = true, className }) {
3139
3423
  const labels = useLabels();
3140
3424
  const counted = usage.categories.filter((c) => !c.deferred);
3141
3425
  const deferred = usage.categories.filter((c) => c.deferred);
3142
- const used = counted.reduce((sum, c) => sum + (c.reserved ? 0 : c.tokens), 0);
3143
- const reserved = counted.reduce((sum, c) => sum + (c.reserved ? c.tokens : 0), 0);
3144
- const free = Math.max(usage.limit - used - reserved, 0);
3426
+ const { used, free } = contextTotals(usage);
3145
3427
  const share = (tokens) => formatShare(tokens / usage.limit);
3146
3428
  return /* @__PURE__ */ jsxs(Disclosure, {
3147
3429
  summary: title ?? labels.context,
@@ -3266,11 +3548,363 @@ function GroupList({ group }) {
3266
3548
  function formatShare(fraction) {
3267
3549
  return `${(fraction * 100).toFixed(1)}%`;
3268
3550
  }
3551
+ // src/components/context-meter.tsx
3552
+ const SIZE = 16;
3553
+ const STROKE = 2;
3554
+ const RADIUS = 7;
3555
+ const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
3556
+ /**
3557
+ * A small progress ring for the composer toolbar, filled to the share of the context window in use (accent,
3558
+ * then warning, then danger). Hover for the numbers; click or Enter opens the ContextPanel above it.
3559
+ */
3560
+ function ContextMeter({ usage, tooltip, tooltipNote, isLoading = false, warnAt = .75, dangerAt = .9, placement = "top end", className }) {
3561
+ const labels = useLabels();
3562
+ const { used } = contextTotals(usage);
3563
+ const fraction = usage.limit > 0 ? Math.min(used / usage.limit, 1) : 0;
3564
+ const summary = isLoading ? labels.contextLoading : labels.contextSummary(formatTokens(used), formatTokens(usage.limit), Math.round(fraction * 100));
3565
+ const color = fraction >= dangerAt ? "text-chat-danger" : fraction >= warnAt ? "text-chat-warning" : "text-chat-accent";
3566
+ const button = /* @__PURE__ */ jsx(Button, {
3567
+ variant: "ghost",
3568
+ size: "sm",
3569
+ "aria-label": summary,
3570
+ isDisabled: isLoading,
3571
+ className: clsx("size-7 justify-center px-0 text-chat-muted", isLoading && "data-disabled:opacity-100", className),
3572
+ children: /* @__PURE__ */ jsxs("svg", {
3573
+ "aria-hidden": true,
3574
+ viewBox: `0 0 ${SIZE} ${SIZE}`,
3575
+ className: clsx("size-4", isLoading && "motion-safe:animate-spin"),
3576
+ fill: "none",
3577
+ strokeWidth: STROKE,
3578
+ children: [/* @__PURE__ */ jsx("circle", {
3579
+ cx: SIZE / 2,
3580
+ cy: SIZE / 2,
3581
+ r: RADIUS,
3582
+ stroke: "var(--chat-raised)"
3583
+ }), /* @__PURE__ */ jsx("circle", {
3584
+ cx: SIZE / 2,
3585
+ cy: SIZE / 2,
3586
+ r: RADIUS,
3587
+ stroke: "currentColor",
3588
+ strokeLinecap: "round",
3589
+ strokeDasharray: CIRCUMFERENCE,
3590
+ strokeDashoffset: CIRCUMFERENCE * (1 - (isLoading ? .25 : fraction)),
3591
+ transform: `rotate(-90 ${SIZE / 2} ${SIZE / 2})`,
3592
+ className: clsx(color, "transition-[stroke-dashoffset]")
3593
+ })]
3594
+ })
3595
+ });
3596
+ return /* @__PURE__ */ jsxs(DialogTrigger$1, { children: [/* @__PURE__ */ jsx(Tooltip, {
3597
+ content: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("div", { children: tooltip ?? summary }), tooltipNote && /* @__PURE__ */ jsx("div", {
3598
+ className: "text-chat-muted",
3599
+ children: tooltipNote
3600
+ })] }),
3601
+ children: button
3602
+ }), /* @__PURE__ */ jsx(Popover, {
3603
+ placement,
3604
+ className: "w-80 overflow-auto p-3",
3605
+ children: /* @__PURE__ */ jsx(Dialog, {
3606
+ "aria-label": labels.context,
3607
+ className: "outline-none",
3608
+ children: /* @__PURE__ */ jsx(ContextPanel, { usage })
3609
+ })
3610
+ })] });
3611
+ }
3612
+ // src/components/copy-field.tsx
3613
+ /** How long "Copied" stays before the button reads "Copy" again. */
3614
+ const COPIED_MS = 2e3;
3615
+ /**
3616
+ * A read-only, selectable field with a Copy button, for share links. The button says "Copied" for about two
3617
+ * seconds (and announces it to screen readers). Where the clipboard is unavailable or refuses, it selects the
3618
+ * text instead so the reader can copy it by hand.
3619
+ */
3620
+ function CopyField({ value, "aria-label": ariaLabel, onCopy, className }) {
3621
+ const icons = useIcons();
3622
+ const labels = useLabels();
3623
+ const inputRef = useRef(null);
3624
+ const timer = useRef(void 0);
3625
+ const [copied, setCopied] = useState(false);
3626
+ useEffect(() => () => clearTimeout(timer.current), []);
3627
+ const copy = async () => {
3628
+ try {
3629
+ if (!navigator.clipboard) throw new Error("no clipboard");
3630
+ await navigator.clipboard.writeText(value);
3631
+ } catch {
3632
+ inputRef.current?.focus();
3633
+ inputRef.current?.select();
3634
+ return;
3635
+ }
3636
+ setCopied(true);
3637
+ onCopy?.();
3638
+ clearTimeout(timer.current);
3639
+ timer.current = setTimeout(() => setCopied(false), COPIED_MS);
3640
+ };
3641
+ return /* @__PURE__ */ jsxs("div", {
3642
+ className: clsx("flex items-center gap-2 font-chat-sans", className),
3643
+ children: [
3644
+ /* @__PURE__ */ jsx("input", {
3645
+ ref: inputRef,
3646
+ readOnly: true,
3647
+ value,
3648
+ "aria-label": ariaLabel ?? labels.copyFieldLabel,
3649
+ onFocus: (e) => e.currentTarget.select(),
3650
+ className: "h-9 min-w-0 flex-1 rounded-chat-md border border-chat-border bg-chat-bg px-3 text-(length:--chat-ui-font-size) text-chat-fg outline-none focus:border-chat-border-strong"
3651
+ }),
3652
+ /* @__PURE__ */ jsxs(Button, {
3653
+ variant: "subtle",
3654
+ size: "lg",
3655
+ onPress: () => void copy(),
3656
+ children: [/* @__PURE__ */ jsx(IconSlot, { children: copied ? icons.copied : icons.copy }), copied ? labels.copied : labels.copy]
3657
+ }),
3658
+ /* @__PURE__ */ jsx("span", {
3659
+ role: "status",
3660
+ className: "sr-only",
3661
+ children: copied ? labels.copied : ""
3662
+ })
3663
+ ]
3664
+ });
3665
+ }
3666
+ // src/components/dictation.tsx
3667
+ /**
3668
+ * Voice dictation in the composer, similar to claude.ai's: while listening the words fill the box as they are
3669
+ * heard ("Listening…" before the first), a dotted meter follows the voice, and ✕ / ✓ take the send button's
3670
+ * place. The kit draws the state and records; the words come from a transcription model the host plugs in
3671
+ * (`useDictation({ transcribe })`), or from the browser's speech recognition without one.
3672
+ */
3673
+ /** A row of dots that grow into bars with the voice; flat dots when it is quiet. */
3674
+ function VoiceMeter({ levels, count = 16, className }) {
3675
+ const recent = levels.slice(-count);
3676
+ const dots = [...Array(count - recent.length).fill(0), ...recent];
3677
+ return /* @__PURE__ */ jsx("div", {
3678
+ "aria-hidden": true,
3679
+ className: clsx("flex h-6 items-center gap-[3px] px-1", className),
3680
+ children: dots.map((level, i) => /* @__PURE__ */ jsx("span", {
3681
+ className: "chat-voice-dot w-[3px] rounded-full bg-chat-subtle transition-[height] duration-75",
3682
+ style: { height: `${Math.max(3, Math.min(1, level) * 20)}px` }
3683
+ }, i))
3684
+ });
3685
+ }
3686
+ /** The mic button that starts dictation; put it in the composer's `trailing`. */
3687
+ function DictationButton({ onPress, isDisabled, className }) {
3688
+ const icons = useIcons();
3689
+ const labels = useLabels();
3690
+ return /* @__PURE__ */ jsx(IconButton, {
3691
+ label: labels.dictate,
3692
+ onPress,
3693
+ ...isDisabled && { isDisabled },
3694
+ ...className && { className },
3695
+ children: icons.mic
3696
+ });
3697
+ }
3698
+ /** The meter, ✕ and ✓: what the composer shows in place of send while dictating. */
3699
+ function DictationControls({ dictation, onAccept, finishing }) {
3700
+ const icons = useIcons();
3701
+ const labels = useLabels();
3702
+ const square = "rounded-[var(--chat-send-radius,var(--chat-radius-md))]! bg-chat-raised border border-chat-border";
3703
+ return /* @__PURE__ */ jsxs("div", {
3704
+ className: "flex items-center gap-1",
3705
+ children: [
3706
+ /* @__PURE__ */ jsx(VoiceMeter, { levels: dictation.levels }),
3707
+ /* @__PURE__ */ jsx(IconButton, {
3708
+ label: labels.dictationCancel,
3709
+ onPress: dictation.onCancel,
3710
+ className: square,
3711
+ children: icons.dictationCancel
3712
+ }),
3713
+ /* @__PURE__ */ jsx(IconButton, {
3714
+ label: labels.dictationAccept,
3715
+ onPress: onAccept,
3716
+ isDisabled: finishing,
3717
+ className: square,
3718
+ children: icons.dictationAccept
3719
+ })
3720
+ ]
3721
+ });
3722
+ }
3723
+ const recognitionClass = () => {
3724
+ if (typeof window === "undefined") return void 0;
3725
+ const w = window;
3726
+ return w.SpeechRecognition ?? w.webkitSpeechRecognition;
3727
+ };
3728
+ /** Encodes mono samples as 16-bit PCM WAV. */
3729
+ function encodeWav(samples, sampleRate) {
3730
+ const view = /* @__PURE__ */ new DataView(/* @__PURE__ */ new ArrayBuffer(44 + samples.length * 2));
3731
+ const text = (at, s) => [...s].forEach((c, i) => view.setUint8(at + i, c.charCodeAt(0)));
3732
+ text(0, "RIFF");
3733
+ view.setUint32(4, 36 + samples.length * 2, true);
3734
+ text(8, "WAVE");
3735
+ text(12, "fmt ");
3736
+ view.setUint32(16, 16, true);
3737
+ view.setUint16(20, 1, true);
3738
+ view.setUint16(22, 1, true);
3739
+ view.setUint32(24, sampleRate, true);
3740
+ view.setUint32(28, sampleRate * 2, true);
3741
+ view.setUint16(32, 2, true);
3742
+ view.setUint16(34, 16, true);
3743
+ text(36, "data");
3744
+ view.setUint32(40, samples.length * 2, true);
3745
+ samples.forEach((v, i) => view.setInt16(44 + i * 2, Math.max(-1, Math.min(1, v)) * 32767, true));
3746
+ return new Blob([view.buffer], { type: "audio/wav" });
3747
+ }
3748
+ /** A recording (whatever MediaRecorder made) as 16 kHz mono WAV, the format transcription APIs all take. */
3749
+ async function toWav(recording) {
3750
+ const decoder = new AudioContext();
3751
+ try {
3752
+ const decoded = await decoder.decodeAudioData(await recording.arrayBuffer());
3753
+ const rate = 16e3;
3754
+ const offline = new OfflineAudioContext(1, Math.max(1, Math.ceil(decoded.duration * rate)), rate);
3755
+ const source = offline.createBufferSource();
3756
+ source.buffer = decoded;
3757
+ source.connect(offline.destination);
3758
+ source.start();
3759
+ return encodeWav((await offline.startRendering()).getChannelData(0), rate);
3760
+ } finally {
3761
+ decoder.close();
3762
+ }
3763
+ }
3764
+ /**
3765
+ * Dictation for the composer: records the mic, draws its level, and fills the box with the words as they
3766
+ * are heard. Plug in your transcription model with `transcribe`; a streaming model can instead drive
3767
+ * `DictationState` directly.
3768
+ */
3769
+ function useDictation({ transcribe, format = "wav", partialEvery = 1500, lang, onError } = {}) {
3770
+ const [active, setActive] = useState(false);
3771
+ const [transcript, setTranscript] = useState("");
3772
+ const [levels, setLevels] = useState([]);
3773
+ const session = useRef(void 0);
3774
+ const latest = useRef({
3775
+ transcribe,
3776
+ format,
3777
+ partialEvery,
3778
+ lang,
3779
+ onError
3780
+ });
3781
+ latest.current = {
3782
+ transcribe,
3783
+ format,
3784
+ partialEvery,
3785
+ lang,
3786
+ onError
3787
+ };
3788
+ const reset = useCallback(() => {
3789
+ session.current?.stop();
3790
+ session.current = void 0;
3791
+ setActive(false);
3792
+ setTranscript("");
3793
+ setLevels([]);
3794
+ }, []);
3795
+ useEffect(() => reset, [reset]);
3796
+ const start = useCallback(() => {
3797
+ if (session.current || typeof navigator === "undefined" || !navigator.mediaDevices) return;
3798
+ const { transcribe, format, partialEvery, lang, onError } = latest.current;
3799
+ const audioOf = (blob) => format === "wav" ? toWav(blob) : Promise.resolve(blob);
3800
+ const Recognition = transcribe ? void 0 : recognitionClass();
3801
+ if (!transcribe && !Recognition) return;
3802
+ const abort = new AbortController();
3803
+ const cleanups = [() => abort.abort()];
3804
+ const stop = () => cleanups.splice(0).forEach((f) => f());
3805
+ let heard = "";
3806
+ const fail = (error) => {
3807
+ if (abort.signal.aborted) return;
3808
+ onError?.(error);
3809
+ reset();
3810
+ };
3811
+ let recognition;
3812
+ if (Recognition) {
3813
+ recognition = new Recognition();
3814
+ const r = recognition;
3815
+ r.continuous = true;
3816
+ r.interimResults = true;
3817
+ r.lang = lang ?? document.documentElement.lang;
3818
+ r.onresult = (e) => setTranscript(heard = Array.from(e.results, (x) => x[0]?.transcript ?? "").join("").trim());
3819
+ r.onerror = (e) => e.error !== "no-speech" && e.error !== "aborted" && fail(new Error(e.error));
3820
+ r.onend = () => !abort.signal.aborted && r.start();
3821
+ r.start();
3822
+ cleanups.push(() => (r.onend = null, r.abort()));
3823
+ }
3824
+ const chunks = [];
3825
+ let recorder;
3826
+ const ready = navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
3827
+ if (abort.signal.aborted) return stream.getTracks().forEach((t) => t.stop());
3828
+ cleanups.push(() => stream.getTracks().forEach((t) => t.stop()));
3829
+ const audio = new AudioContext();
3830
+ cleanups.push(() => void audio.close());
3831
+ const analyser = audio.createAnalyser();
3832
+ analyser.fftSize = 512;
3833
+ audio.createMediaStreamSource(stream).connect(analyser);
3834
+ const samples = new Float32Array(analyser.fftSize);
3835
+ let last = 0;
3836
+ let frame = requestAnimationFrame(function tick(now) {
3837
+ frame = requestAnimationFrame(tick);
3838
+ if (now - last < 70) return;
3839
+ last = now;
3840
+ analyser.getFloatTimeDomainData(samples);
3841
+ let sum = 0;
3842
+ for (const v of samples) sum += v * v;
3843
+ setLevels((l) => [...l.slice(-31), Math.min(1, Math.sqrt(sum / samples.length) * 6)]);
3844
+ });
3845
+ cleanups.push(() => cancelAnimationFrame(frame));
3846
+ if (!transcribe) return;
3847
+ const r = new MediaRecorder(stream);
3848
+ recorder = r;
3849
+ r.ondataavailable = (e) => e.data.size && chunks.push(e.data);
3850
+ r.start(250);
3851
+ cleanups.push(() => r.state !== "inactive" && r.stop());
3852
+ if (partialEvery > 0) {
3853
+ let busy = false;
3854
+ const timer = setInterval(() => {
3855
+ if (busy || !chunks.length) return;
3856
+ busy = true;
3857
+ audioOf(new Blob(chunks, { type: r.mimeType })).then((wav) => transcribe(wav, abort.signal)).then((text) => !abort.signal.aborted && setTranscript(heard = text.trim())).catch(() => {}).finally(() => busy = false);
3858
+ }, partialEvery);
3859
+ cleanups.push(() => clearInterval(timer));
3860
+ }
3861
+ }, fail);
3862
+ session.current = {
3863
+ stop,
3864
+ finish: async () => {
3865
+ if (!transcribe) return stop(), heard;
3866
+ await ready;
3867
+ const r = recorder;
3868
+ if (r && r.state !== "inactive") await new Promise((done) => {
3869
+ r.addEventListener("stop", () => done(), { once: true });
3870
+ r.stop();
3871
+ });
3872
+ if (!chunks.length) return stop(), heard;
3873
+ const text = await transcribe(await audioOf(new Blob(chunks, { type: r?.mimeType })), abort.signal);
3874
+ stop();
3875
+ return text.trim();
3876
+ }
3877
+ };
3878
+ setActive(true);
3879
+ }, [reset]);
3880
+ const accept = useCallback(async () => {
3881
+ const s = session.current;
3882
+ if (!s) return;
3883
+ try {
3884
+ return await s.finish();
3885
+ } catch (error) {
3886
+ latest.current.onError?.(error);
3887
+ return;
3888
+ } finally {
3889
+ if (session.current === s) reset();
3890
+ }
3891
+ }, [reset]);
3892
+ return {
3893
+ supported: typeof navigator !== "undefined" && !!navigator.mediaDevices && (!!transcribe || recognitionClass() !== void 0),
3894
+ start,
3895
+ state: active ? {
3896
+ levels,
3897
+ transcript,
3898
+ onCancel: reset,
3899
+ onAccept: accept
3900
+ } : void 0
3901
+ };
3902
+ }
3269
3903
  // src/components/composer.tsx
3270
3904
  /** Touch keyboards have no easy Shift+Enter, so there Enter adds a line and the button sends. */
3271
3905
  const coarsePointer = () => typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches;
3272
3906
  /** The chat box: growing textarea, toolbar slots, send / stop. Enter sends, Shift+Enter adds a line. */
3273
- function Composer({ onSubmit, onStop, busy = false, value, onChange, placeholder, variant = "docked", leading, trailing, above, onFiles, onLongPaste, longPasteChars = 2e3, allowEmpty = false, submitDisabled = false, isDisabled = false, autoFocus, className }) {
3907
+ function Composer({ onSubmit, onStop, busy = false, value, onChange, placeholder, variant = "docked", leading, trailing, above, onFiles, onLongPaste, longPasteChars = 2e3, allowEmpty = false, submitDisabled = false, isDisabled = false, dictation, autoFocus, className }) {
3274
3908
  const icons = useIcons();
3275
3909
  const labels = useLabels();
3276
3910
  const [own, setOwn] = useState("");
@@ -3302,8 +3936,22 @@ function Composer({ onSubmit, onStop, busy = false, value, onChange, placeholder
3302
3936
  observer.observe(el);
3303
3937
  return () => observer.disconnect();
3304
3938
  }, [fit]);
3939
+ const [finishing, setFinishing] = useState(false);
3940
+ const joined = (words) => words ? text.trim() ? `${text.replace(/\s+$/, "")} ${words}` : words : text;
3941
+ const acceptDictation = async () => {
3942
+ if (!dictation || finishing) return;
3943
+ const heard = dictation.transcript;
3944
+ setFinishing(true);
3945
+ try {
3946
+ const words = await dictation.onAccept();
3947
+ setText(joined(typeof words === "string" ? words : heard));
3948
+ } finally {
3949
+ setFinishing(false);
3950
+ area.current?.focus();
3951
+ }
3952
+ };
3305
3953
  const [dragging, setDragging] = useState(false);
3306
- const canSend = !busy && !isDisabled && !submitDisabled && (allowEmpty || text.trim().length > 0);
3954
+ const canSend = !dictation && !busy && !isDisabled && !submitDisabled && (allowEmpty || text.trim().length > 0);
3307
3955
  const submit = () => {
3308
3956
  if (!canSend) return;
3309
3957
  onSubmit(text);
@@ -3344,7 +3992,11 @@ function Composer({ onSubmit, onStop, busy = false, value, onChange, placeholder
3344
3992
  }
3345
3993
  };
3346
3994
  const compact = variant === "compact";
3347
- const action = busy ? /* @__PURE__ */ jsx(IconButton, {
3995
+ const action = dictation ? /* @__PURE__ */ jsx(DictationControls, {
3996
+ dictation,
3997
+ onAccept: () => void acceptDictation(),
3998
+ finishing
3999
+ }) : busy ? /* @__PURE__ */ jsx(IconButton, {
3348
4000
  label: labels.stop,
3349
4001
  variant: "primary",
3350
4002
  onPress: onStop,
@@ -3374,11 +4026,12 @@ function Composer({ onSubmit, onStop, busy = false, value, onChange, placeholder
3374
4026
  /* @__PURE__ */ jsx("textarea", {
3375
4027
  ref: area,
3376
4028
  rows: 1,
3377
- value: text,
4029
+ value: dictation ? joined(dictation.transcript) : text,
4030
+ readOnly: !!dictation,
3378
4031
  onChange: (e) => setText(e.target.value),
3379
4032
  onKeyDown,
3380
4033
  onPaste,
3381
- placeholder: placeholder ?? labels.replyPlaceholder,
4034
+ placeholder: dictation ? finishing ? labels.transcribing : labels.listening : placeholder ?? labels.replyPlaceholder,
3382
4035
  "aria-label": labels.message,
3383
4036
  disabled: isDisabled,
3384
4037
  autoFocus,
@@ -3386,7 +4039,7 @@ function Composer({ onSubmit, onStop, busy = false, value, onChange, placeholder
3386
4039
  }),
3387
4040
  compact ? /* @__PURE__ */ jsxs("div", {
3388
4041
  className: "flex shrink-0 items-center gap-1",
3389
- children: [trailing, action]
4042
+ children: [!dictation && trailing, action]
3390
4043
  }) : /* @__PURE__ */ jsxs("div", {
3391
4044
  className: "flex items-center gap-1",
3392
4045
  children: [
@@ -3394,7 +4047,7 @@ function Composer({ onSubmit, onStop, busy = false, value, onChange, placeholder
3394
4047
  className: "flex min-w-0 flex-1 items-center gap-1",
3395
4048
  children: leading
3396
4049
  }),
3397
- trailing,
4050
+ !dictation && trailing,
3398
4051
  action
3399
4052
  ]
3400
4053
  })
@@ -3505,11 +4158,11 @@ function EffortSlider({ efforts, value, onChange, label, className }) {
3505
4158
  ]
3506
4159
  });
3507
4160
  }
3508
- /** A composer pill ("◎ Medium") that opens the effort slider above it, like chatgpt.com's Think button. */
4161
+ /** A composer pill ("◎ Medium") that opens the effort slider above it, similar to chatgpt.com's Think button. */
3509
4162
  function EffortButton({ icon, children, className, ...slider }) {
3510
4163
  const current = slider.efforts.find((e) => e.id === slider.value) ?? slider.efforts[0];
3511
4164
  const labels = useLabels();
3512
- return /* @__PURE__ */ jsxs(DialogTrigger, { children: [/* @__PURE__ */ jsxs(Button, {
4165
+ return /* @__PURE__ */ jsxs(DialogTrigger$1, { children: [/* @__PURE__ */ jsxs(Button, {
3513
4166
  variant: "ghost",
3514
4167
  size: "sm",
3515
4168
  className: clsx("text-chat-muted data-hovered:text-chat-fg", className),
@@ -3537,6 +4190,7 @@ function ModelPicker({ models, model, onModelChange, effort, onEffortChange, eff
3537
4190
  const more = models.filter((m) => m.secondary);
3538
4191
  const item = (m) => /* @__PURE__ */ jsx(MenuItem, {
3539
4192
  id: m.id,
4193
+ ...m.icon && { icon: m.icon },
3540
4194
  description: m.disabledReason ?? m.description,
3541
4195
  isDisabled: m.disabled ?? false,
3542
4196
  children: m.label
@@ -3551,6 +4205,10 @@ function ModelPicker({ models, model, onModelChange, effort, onEffortChange, eff
3551
4205
  "aria-label": labels.modelButton(current?.label ?? model),
3552
4206
  className: clsx("text-chat-muted data-hovered:text-chat-fg", className),
3553
4207
  children: [
4208
+ current?.icon && /* @__PURE__ */ jsx(IconSlot, {
4209
+ className: "text-chat-fg [&_svg]:size-3.5",
4210
+ children: current.icon
4211
+ }),
3554
4212
  /* @__PURE__ */ jsx("span", {
3555
4213
  className: "min-w-0 truncate text-chat-fg",
3556
4214
  children: current?.label ?? model
@@ -3654,10 +4312,12 @@ function MessageList({ messages, onRetry, onEdit, onFeedback, onReadAloud, actio
3654
4312
  behavior
3655
4313
  });
3656
4314
  }, []);
3657
- const onScroll = () => {
4315
+ const nearBottom = () => {
3658
4316
  const el = scroller.current;
3659
- if (!el) return;
3660
- const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_SLOP;
4317
+ return !el || el.scrollHeight - el.scrollTop - el.clientHeight < STICK_SLOP;
4318
+ };
4319
+ const onScroll = () => {
4320
+ const bottom = nearBottom();
3661
4321
  stuck.current = bottom;
3662
4322
  setAtBottom(bottom);
3663
4323
  };
@@ -3674,7 +4334,7 @@ function MessageList({ messages, onRetry, onEdit, onFeedback, onReadAloud, actio
3674
4334
  const jumpedFor = useRef(start === "top" ? lastUser : null);
3675
4335
  useLayoutEffect(() => {
3676
4336
  if (lastUser === jumpedFor.current) {
3677
- onScroll();
4337
+ setAtBottom(nearBottom());
3678
4338
  return;
3679
4339
  }
3680
4340
  jumpedFor.current = lastUser;
@@ -3820,7 +4480,7 @@ function ChatTitleMenu({ pinned, onPin, onRename, projects, onAddToProject, onCr
3820
4480
  children: labels.shareChat
3821
4481
  }),
3822
4482
  children,
3823
- onDelete && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(MenuSeparator, {}), /* @__PURE__ */ jsx(MenuItem, {
4483
+ onDelete && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(MenuSeparator, {}), /* @__PURE__ */ jsx(MenuItem, {
3824
4484
  id: "delete",
3825
4485
  icon: icons.delete,
3826
4486
  shortcut: "D",
@@ -3922,15 +4582,22 @@ function ChatRow({ chat, active, nested, onSelect, menu }) {
3922
4582
  role: "img",
3923
4583
  "aria-label": labels.running,
3924
4584
  className: clsx(markerClass, "animate-pulse bg-chat-accent motion-reduce:animate-none")
3925
- }) : chat.unread ? /* @__PURE__ */ jsx("span", {
4585
+ }) : chat.unread && !chat.muted ? /* @__PURE__ */ jsx("span", {
3926
4586
  role: "img",
3927
4587
  "aria-label": labels.unread,
3928
4588
  className: clsx(markerClass, "bg-chat-check")
3929
4589
  }) : null;
3930
- const content = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
3931
- className: clsx("min-w-0 flex-1 truncate", chat.unread && !active && "font-medium"),
3932
- children: chat.title
3933
- }), marker] });
4590
+ const content = /* @__PURE__ */ jsxs(Fragment$1, { children: [
4591
+ /* @__PURE__ */ jsx("span", {
4592
+ className: clsx("min-w-0 flex-1 truncate", chat.unread && !active && !chat.muted && "font-medium", chat.muted && "text-chat-muted"),
4593
+ children: chat.title
4594
+ }),
4595
+ chat.detail != null && /* @__PURE__ */ jsx("span", {
4596
+ className: "shrink-0 text-(length:--chat-ui-font-size-sm) leading-4 text-chat-subtle tabular-nums",
4597
+ children: chat.detail
4598
+ }),
4599
+ marker
4600
+ ] });
3934
4601
  const common = {
3935
4602
  className: clsx(rowClass, nested && "pl-8", active && "bg-chat-selected", menu && (menuOpen || active ? "pr-8" : "group-hover/item:pr-8 group-focus-within/item:pr-8"), menu && "[@media(hover:none)]:pr-10"),
3936
4603
  "aria-current": active ? "page" : void 0,
@@ -3967,7 +4634,7 @@ function ProjectRow({ project, activeId, row }) {
3967
4634
  const holdsActive = project.chats.some((c) => c.id === activeId);
3968
4635
  return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(Disclosure$1, {
3969
4636
  defaultExpanded: project.defaultExpanded ?? holdsActive,
3970
- children: ({ isExpanded }) => /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Heading, {
4637
+ children: ({ isExpanded }) => /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Heading, {
3971
4638
  className: "m-0 font-normal",
3972
4639
  children: /* @__PURE__ */ jsxs(Button$1, {
3973
4640
  slot: "trigger",
@@ -4026,6 +4693,7 @@ function ChatView({ messages, busy = false, onSend, onStop, approvals, onApprove
4026
4693
  const labels = useLabels();
4027
4694
  const pending = onApprove && approvals?.[0];
4028
4695
  const box = (variant) => {
4696
+ if (composer === false) return null;
4029
4697
  const element = /* @__PURE__ */ jsx(Composer, {
4030
4698
  variant: density === "inline" || short && !empty ? "compact" : variant,
4031
4699
  busy,
@@ -4159,41 +4827,84 @@ function ChatTranscript({ messages, renderPart, widgets, header, notice, footer,
4159
4827
  });
4160
4828
  }
4161
4829
  // src/views/app-shell.tsx
4830
+ const DEFAULT_WIDTH = 288;
4831
+ const KEY_STEP = 16;
4832
+ const clampWidth = (px, min, max) => Math.min(max, Math.max(min, Math.round(px)));
4833
+ function readStoredWidth(key) {
4834
+ if (!key) return void 0;
4835
+ try {
4836
+ const n = Number(window.localStorage.getItem(key));
4837
+ return n > 0 ? n : void 0;
4838
+ } catch {
4839
+ return;
4840
+ }
4841
+ }
4162
4842
  /**
4163
4843
  * A page with a sidebar: pinned and collapsible to an icon rail from tablet width up, a drawer over the
4164
4844
  * content on phones. Children get `useSidebar()` and `<SidebarToggle>`.
4165
4845
  */
4166
- function AppShell({ sidebar, children, defaultCollapsed, className }) {
4846
+ function AppShell({ sidebar, children, defaultCollapsed, collapsed: collapsedProp, onCollapsedChange, scroll = "inner", resizable, width: widthProp, defaultWidth, onWidthChange, persistKey, shortcut = true, className }) {
4167
4847
  const drawer = useMediaQuery(DRAWER_QUERY);
4168
- const [collapsed, setCollapsed] = useState(() => defaultCollapsed ?? (typeof window !== "undefined" && window.matchMedia("(max-width: 1023.98px)").matches));
4848
+ const labels = useLabels();
4849
+ const [collapsed, setCollapsed] = useControllable(collapsedProp, () => defaultCollapsed ?? (typeof window !== "undefined" && window.matchMedia("(max-width: 1023.98px)").matches), onCollapsedChange);
4169
4850
  const [drawerOpen, setDrawerOpen] = useState(false);
4170
- const toggle = useCallback(() => drawer ? setDrawerOpen((o) => !o) : setCollapsed((c) => !c), [drawer]);
4851
+ const toggle = useCallback(() => drawer ? setDrawerOpen((o) => !o) : setCollapsed((c) => !c), [drawer, setCollapsed]);
4171
4852
  const dismiss = useCallback(() => setDrawerOpen(false), []);
4853
+ const min = resizable?.min ?? 0;
4854
+ const max = resizable?.max ?? Infinity;
4855
+ const [rawWidth, setWidth] = useControllable(widthProp, () => readStoredWidth(persistKey) ?? defaultWidth ?? DEFAULT_WIDTH, (px) => {
4856
+ if (persistKey) try {
4857
+ window.localStorage.setItem(persistKey, String(px));
4858
+ } catch {}
4859
+ onWidthChange?.(px);
4860
+ });
4861
+ const width = resizable ? clampWidth(rawWidth, min, max) : void 0;
4862
+ const shortcutKeys = shortcut ? `${isMacPlatform() ? "⌘" : labels.ctrlKey} B` : void 0;
4863
+ useEffect(() => {
4864
+ if (!shortcut) return;
4865
+ const mac = isMacPlatform();
4866
+ const onKey = (e) => {
4867
+ if (e.defaultPrevented || e.altKey || e.shiftKey || e.key.toLowerCase() !== "b") return;
4868
+ if (mac ? !e.metaKey || e.ctrlKey : !e.ctrlKey || e.metaKey) return;
4869
+ e.preventDefault();
4870
+ toggle();
4871
+ };
4872
+ window.addEventListener("keydown", onKey);
4873
+ return () => window.removeEventListener("keydown", onKey);
4874
+ }, [shortcut, toggle]);
4172
4875
  const pinned = useMemo(() => ({
4173
4876
  collapsed,
4174
4877
  drawer,
4175
4878
  drawerOpen,
4176
4879
  toggle,
4177
- dismiss
4880
+ dismiss,
4881
+ ...shortcutKeys && { shortcut: shortcutKeys }
4178
4882
  }), [
4179
4883
  collapsed,
4180
4884
  drawer,
4181
4885
  drawerOpen,
4182
4886
  toggle,
4183
- dismiss
4887
+ dismiss,
4888
+ shortcutKeys
4184
4889
  ]);
4185
- const labels = useLabels();
4186
4890
  const inDrawer = useMemo(() => ({
4187
4891
  ...pinned,
4188
4892
  collapsed: false
4189
4893
  }), [pinned]);
4894
+ const windowScroll = scroll === "window";
4190
4895
  return /* @__PURE__ */ jsxs(SidebarContext.Provider, {
4191
4896
  value: pinned,
4192
4897
  children: [/* @__PURE__ */ jsxs("div", {
4193
- className: clsx("flex h-dvh overflow-hidden bg-chat-bg text-chat-fg", className),
4194
- children: [!drawer && /* @__PURE__ */ jsx("aside", {
4195
- className: clsx("shrink-0 overflow-hidden border-r border-chat-divider bg-chat-sidebar transition-[width] duration-200 ease-out motion-reduce:transition-none", collapsed ? "w-13" : "w-(--chat-sidebar-width,18rem)"),
4196
- children: sidebar
4898
+ className: clsx("flex bg-chat-bg text-chat-fg", windowScroll ? "min-h-dvh" : "h-dvh overflow-hidden", className),
4899
+ children: [!drawer && /* @__PURE__ */ jsxs("aside", {
4900
+ style: width ? { "--chat-sidebar-width": `${width}px` } : void 0,
4901
+ className: clsx("relative shrink-0 border-r border-chat-divider bg-chat-sidebar transition-[width] duration-200 ease-out motion-reduce:transition-none data-resizing:transition-none", windowScroll ? "sticky top-0 h-dvh self-start overflow-clip" : "overflow-hidden", collapsed ? "w-13" : "w-(--chat-sidebar-width,18rem)"),
4902
+ children: [sidebar, resizable && !collapsed && width && /* @__PURE__ */ jsx(ResizeHandle, {
4903
+ width,
4904
+ min,
4905
+ max,
4906
+ onCommit: setWidth
4907
+ })]
4197
4908
  }), /* @__PURE__ */ jsx("main", {
4198
4909
  className: "flex min-w-0 flex-1 flex-col",
4199
4910
  children
@@ -4209,15 +4920,58 @@ function AppShell({ sidebar, children, defaultCollapsed, className }) {
4209
4920
  })]
4210
4921
  });
4211
4922
  }
4212
- /** Opens the drawer on phones, collapses / expands the pinned sidebar elsewhere. */
4923
+ /** The draggable right edge of the sidebar. A drag moves the aside's width variable alone and commits on release. */
4924
+ function ResizeHandle({ width, min, max, onCommit }) {
4925
+ const labels = useLabels();
4926
+ const live = useRef(null);
4927
+ const aside = (el) => el.closest("aside");
4928
+ const move = (el, px) => {
4929
+ live.current = clampWidth(px, min, max);
4930
+ aside(el).style.setProperty("--chat-sidebar-width", `${live.current}px`);
4931
+ };
4932
+ const end = (el) => {
4933
+ delete aside(el).dataset.resizing;
4934
+ if (live.current != null) onCommit(live.current);
4935
+ live.current = null;
4936
+ };
4937
+ return /* @__PURE__ */ jsx("div", {
4938
+ role: "separator",
4939
+ "aria-orientation": "vertical",
4940
+ "aria-label": labels.resizeSidebar,
4941
+ "aria-valuenow": width,
4942
+ "aria-valuemin": min,
4943
+ "aria-valuemax": max,
4944
+ tabIndex: 0,
4945
+ className: "group/handle absolute inset-y-0 right-0 z-10 w-1.5 cursor-col-resize touch-none outline-none",
4946
+ onPointerDown: (e) => {
4947
+ e.currentTarget.setPointerCapture(e.pointerId);
4948
+ aside(e.currentTarget).dataset.resizing = "";
4949
+ live.current = null;
4950
+ },
4951
+ onPointerMove: (e) => {
4952
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) move(e.currentTarget, e.clientX - aside(e.currentTarget).getBoundingClientRect().left);
4953
+ },
4954
+ onPointerUp: (e) => end(e.currentTarget),
4955
+ onPointerCancel: (e) => end(e.currentTarget),
4956
+ onKeyDown: (e) => {
4957
+ const step = e.shiftKey ? 64 : KEY_STEP;
4958
+ const next = e.key === "ArrowLeft" ? width - step : e.key === "ArrowRight" ? width + step : e.key === "Home" ? min : e.key === "End" ? max : null;
4959
+ if (next == null) return;
4960
+ e.preventDefault();
4961
+ onCommit(clampWidth(next, min, max));
4962
+ },
4963
+ children: /* @__PURE__ */ jsx("span", { className: "absolute inset-y-0 right-0 w-px bg-transparent transition-colors group-hover/handle:bg-chat-border-strong group-focus-visible/handle:w-0.5 group-focus-visible/handle:bg-chat-focus in-data-resizing:bg-chat-focus" })
4964
+ });
4965
+ }
4966
+ /** Opens the drawer on phones, collapses / expands the pinned sidebar elsewhere. Its label names the shortcut, e.g. "(⌘ B)". */
4213
4967
  function SidebarToggle({ drawerOnly = false, className }) {
4214
4968
  const icons = useIcons();
4215
- const { drawer, drawerOpen, collapsed, toggle } = useSidebar();
4969
+ const { drawer, drawerOpen, collapsed, toggle, shortcut } = useSidebar();
4216
4970
  const labels = useLabels();
4217
4971
  if (drawerOnly && !drawer) return null;
4218
4972
  const label = drawer ? drawerOpen ? labels.closeSidebar : labels.openSidebar : collapsed ? labels.expandSidebar : labels.collapseSidebar;
4219
4973
  return /* @__PURE__ */ jsx(IconButton, {
4220
- label,
4974
+ label: shortcut ? labels.withShortcut(label, shortcut) : label,
4221
4975
  onPress: toggle,
4222
4976
  className,
4223
4977
  children: icons.sidebar
@@ -4225,7 +4979,7 @@ function SidebarToggle({ drawerOnly = false, className }) {
4225
4979
  }
4226
4980
  // src/views/sidebar.tsx
4227
4981
  /** The claude.ai sidebar frame. Collapses to an icon rail inside `AppShell`; a drawer on phones. */
4228
- function Sidebar({ brand, brandIcon, headerActions, nav, children, footer, className }) {
4982
+ function Sidebar({ brand, brandIcon, headerActions, railActions, nav, children, footer, className }) {
4229
4983
  const { collapsed } = useSidebar();
4230
4984
  return /* @__PURE__ */ jsxs("div", {
4231
4985
  className: clsx("flex h-full flex-col font-chat-sans", collapsed ? "w-13" : "w-full", className),
@@ -4251,8 +5005,12 @@ function Sidebar({ brand, brandIcon, headerActions, nav, children, footer, class
4251
5005
  /* @__PURE__ */ jsx(SidebarToggle, {})
4252
5006
  ]
4253
5007
  }),
4254
- nav && /* @__PURE__ */ jsx("ul", {
4255
- className: clsx("flex shrink-0 flex-col gap-px px-2 pb-2", collapsed && "items-center"),
5008
+ collapsed && railActions && /* @__PURE__ */ jsx("div", {
5009
+ className: "flex shrink-0 flex-col items-center gap-1 pb-2",
5010
+ children: railActions
5011
+ }),
5012
+ nav && /* @__PURE__ */ jsx(SidebarList, {
5013
+ className: clsx("shrink-0 px-2 pb-2", collapsed && "items-center"),
4256
5014
  children: nav
4257
5015
  }),
4258
5016
  !collapsed && /* @__PURE__ */ jsx("div", {
@@ -4267,32 +5025,65 @@ function Sidebar({ brand, brandIcon, headerActions, nav, children, footer, class
4267
5025
  ]
4268
5026
  });
4269
5027
  }
4270
- /** A nav row: icon and label; just the icon with a tooltip when collapsed. Closes the drawer when picked. */
4271
- function SidebarItem({ icon, label, onPress, href, active, shortcut }) {
5028
+ /** The `<ul>` for `SidebarItem`s outside `Sidebar`'s `nav` slot (e.g. inside a section): no bullets, no padding. */
5029
+ function SidebarList({ children, className }) {
5030
+ return /* @__PURE__ */ jsx("ul", {
5031
+ className: clsx("m-0 flex list-none flex-col gap-px p-0", className),
5032
+ children
5033
+ });
5034
+ }
5035
+ const iconSize = "[&_svg]:size-(--chat-sidebar-icon-size,1rem) [&_svg]:shrink-0";
5036
+ /**
5037
+ * A nav row: icon and label; just the icon with a tooltip when collapsed. Closes the drawer when picked.
5038
+ * Icons are 1rem, or `--chat-sidebar-icon-size`.
5039
+ */
5040
+ function SidebarItem({ icon, label, onPress, href, active, shortcut, badge, description, trailing, title, className: extra, ...rest }) {
4272
5041
  const { collapsed, dismiss } = useSidebar();
4273
- const className = clsx("group flex h-8 cursor-pointer items-center gap-2.5 rounded-chat-sm text-left text-(length:--chat-ui-font-size) leading-5 text-chat-fg outline-none [@media(pointer:coarse)]:h-10", "data-hovered:bg-chat-hover data-pressed:bg-chat-selected data-focus-visible:ring-2 data-focus-visible:ring-chat-focus", "[&_svg]:size-4 [&_svg]:shrink-0", collapsed ? "w-9 justify-center [@media(pointer:coarse)]:w-10" : "w-full px-2", active && "bg-chat-selected");
5042
+ const data = Object.fromEntries(Object.entries(rest).filter(([k]) => k.startsWith("data-")));
5043
+ const hasTrailing = !collapsed && trailing != null;
5044
+ const className = clsx("group flex min-h-8 cursor-pointer items-center gap-2.5 rounded-chat-sm text-left text-(length:--chat-ui-font-size) leading-5 text-chat-fg outline-none [@media(pointer:coarse)]:min-h-10", "data-hovered:bg-chat-hover data-pressed:bg-chat-selected data-focus-visible:ring-2 data-focus-visible:ring-chat-focus", iconSize, collapsed ? "relative w-9 justify-center [@media(pointer:coarse)]:w-10" : "w-full px-2", description && !collapsed && "py-1", hasTrailing && "group-hover/item:pr-9 group-focus-within/item:pr-9 [@media(hover:none)]:pr-10", active && "bg-chat-selected");
4274
5045
  const press = () => {
4275
5046
  onPress?.();
4276
5047
  dismiss();
4277
5048
  };
4278
- const content = /* @__PURE__ */ jsxs(Fragment, { children: [
5049
+ const showIfCut = (e) => {
5050
+ const el = e.currentTarget;
5051
+ el.title = el.scrollWidth > el.clientWidth ? label : "";
5052
+ };
5053
+ const content = /* @__PURE__ */ jsxs(Fragment$1, { children: [
4279
5054
  icon,
4280
- collapsed ? /* @__PURE__ */ jsx("span", {
5055
+ collapsed ? /* @__PURE__ */ jsxs("span", {
4281
5056
  className: "sr-only",
4282
- children: label
4283
- }) : /* @__PURE__ */ jsx("span", {
4284
- className: "min-w-0 flex-1 truncate",
4285
- children: label
5057
+ children: [label, (typeof badge === "string" || typeof badge === "number") && ` (${badge})`]
5058
+ }) : /* @__PURE__ */ jsxs("span", {
5059
+ className: "flex min-w-0 flex-1 flex-col",
5060
+ children: [/* @__PURE__ */ jsx("span", {
5061
+ className: "truncate",
5062
+ onPointerEnter: title ? void 0 : showIfCut,
5063
+ children: label
5064
+ }), description && /* @__PURE__ */ jsx("span", {
5065
+ className: "truncate text-(length:--chat-ui-font-size-sm) leading-4 text-chat-muted",
5066
+ children: description
5067
+ })]
4286
5068
  }),
4287
5069
  !collapsed && shortcut && /* @__PURE__ */ jsx("kbd", {
4288
5070
  className: "font-chat-sans text-(length:--chat-ui-font-size-sm) leading-4 text-chat-subtle opacity-0 group-data-hovered:opacity-100",
4289
5071
  children: shortcut
5072
+ }),
5073
+ !collapsed && badge != null && /* @__PURE__ */ jsx("span", {
5074
+ className: "shrink-0 text-(length:--chat-ui-font-size-sm) leading-4 text-chat-muted tabular-nums",
5075
+ children: badge
5076
+ }),
5077
+ collapsed && badge != null && /* @__PURE__ */ jsx("span", {
5078
+ "aria-hidden": true,
5079
+ className: "absolute top-1 right-1 size-2 rounded-full bg-chat-accent"
4290
5080
  })
4291
5081
  ] });
4292
5082
  const common = {
4293
5083
  className,
4294
5084
  onPress: press,
4295
- "aria-current": active ? "page" : void 0
5085
+ "aria-current": active ? "page" : void 0,
5086
+ ...title && { title }
4296
5087
  };
4297
5088
  const control = href ? /* @__PURE__ */ jsx(Link, {
4298
5089
  ...common,
@@ -4302,11 +5093,18 @@ function SidebarItem({ icon, label, onPress, href, active, shortcut }) {
4302
5093
  ...common,
4303
5094
  children: content
4304
5095
  });
4305
- return /* @__PURE__ */ jsx("li", { children: collapsed ? /* @__PURE__ */ jsx(Tooltip, {
4306
- content: label,
4307
- placement: "right",
4308
- children: control
4309
- }) : control });
5096
+ return /* @__PURE__ */ jsxs("li", {
5097
+ ...data,
5098
+ className: clsx("group/item relative list-none", extra),
5099
+ children: [collapsed ? /* @__PURE__ */ jsx(Tooltip, {
5100
+ content: label,
5101
+ placement: "right",
5102
+ children: control
5103
+ }) : control, hasTrailing && /* @__PURE__ */ jsx("div", {
5104
+ className: "absolute top-1/2 right-1 flex -translate-y-1/2 items-center opacity-0 group-hover/item:opacity-100 group-focus-within/item:opacity-100 [@media(hover:none)]:opacity-100",
5105
+ children: trailing
5106
+ })]
5107
+ });
4310
5108
  }
4311
5109
  const initials = (name) => name.split(/\s+/).slice(0, 2).map((w) => w[0]?.toUpperCase() ?? "").join("");
4312
5110
  /** The account row at the bottom: avatar, name, plan, and a menu. */
@@ -4325,7 +5123,7 @@ function SidebarUser({ name, detail, avatarUrl, menu }) {
4325
5123
  const button = /* @__PURE__ */ jsxs(Button$1, {
4326
5124
  "aria-label": collapsed ? name : void 0,
4327
5125
  className: clsx("flex h-12 w-full cursor-pointer items-center gap-2.5 rounded-chat-md text-left outline-none", "data-hovered:bg-chat-hover data-pressed:bg-chat-selected data-focus-visible:ring-2 data-focus-visible:ring-chat-focus", collapsed ? "justify-center" : "px-1.5"),
4328
- children: [avatar, !collapsed && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("span", {
5126
+ children: [avatar, !collapsed && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("span", {
4329
5127
  className: "flex min-w-0 flex-1 flex-col",
4330
5128
  children: [/* @__PURE__ */ jsx("span", {
4331
5129
  className: "truncate text-(length:--chat-ui-font-size) leading-5 text-chat-fg",
@@ -4345,4 +5143,4 @@ function SidebarUser({ name, detail, avatarUrl, menu }) {
4345
5143
  children: menu
4346
5144
  })] });
4347
5145
  }
4348
- export { ActivityGroup, AgentProvider, AgentRegistry, AppShell, ApprovalCard, AssistantMessage, AttachButton, AttachmentCard, AttachmentRow, Badge, Button, Caret, ChatHeader, ChatList, ChatNotice, ChatTheme, ChatTitleMenu, ChatTranscript, ChatView, CodeBlock, Composer, ContextMenu, ContextPanel, CopyButton, DRAWER_QUERY, Disclosure, Drawer, EffortButton, EffortSlider, EmptyState, IconButton, Markdown, MarkdownLinksProvider, Menu, MenuItem, MenuNote, MenuSearch, MenuSection, MenuSeparator, MenuTrigger, MessageList, Modal, ModelPicker, Panel, PastedTextDialog, Popover, SegmentedControl, Sidebar, SidebarItem, SidebarToggle, SidebarUser, SubmenuTrigger, TokenCounter, ToolViewsProvider, Tooltip, UiComponentsProvider, UiMarkupBlock, UiTree, UserMessage, WIDGET_MAX_HEIGHT, WidgetFrame, WidgetHostProvider, WidgetPartView, activityHeading, activityLabel, commandTool, defaultIcons, defaultLabels, defaultToolViews, formatDuration, formatRelativeTime, formatTokens, htmlComponent, intersectAllow, memoryTool, messageText, openInNewTab, readTreeInput, readVisualizeInput, sseTransport, uiMarkupToolView, uiTreeToolView, useAgentContext, useAgentRegistry, useAgentSnapshot, useAgentTool, useAttachments, useChat, useChatTool, useCountUp, useIcons, useLabels, useMediaQuery, useSidebar, useSmoothText, useToolView, useToolViews, useUiAction, useUiMarkupLanguage, useUiState, useWidgetHost, userMessage, webFetchTool, webSearchTool, widgetCsp, widgetToolView };
5146
+ export { ActivityGroup, AgentProvider, AgentRegistry, AppShell, ApprovalCard, AssistantMessage, AttachButton, AttachmentCard, AttachmentRow, Badge, Button, Caret, ChatHeader, ChatList, ChatNotice, ChatTheme, ChatTitleMenu, ChatTranscript, ChatView, CodeBlock, Composer, ContextMenu, ContextMeter, ContextPanel, CopyButton, CopyField, DRAWER_QUERY, DialogTrigger, DictationButton, DictationControls, Disclosure, Drawer, EffortButton, EffortSlider, EmptyState, IconButton, Markdown, MarkdownLinksProvider, Menu, MenuItem, MenuNote, MenuSearch, MenuSection, MenuSeparator, MenuTrigger, MessageList, Modal, ModelPicker, Panel, PastedTextDialog, Popover, PopoverTrigger, SegmentedControl, Sidebar, SidebarItem, SidebarList, SidebarToggle, SidebarUser, SubmenuTrigger, TokenCounter, ToolViewsProvider, Tooltip, UiComponentsProvider, UiMarkupBlock, UiTree, UserMessage, VoiceMeter, WIDGET_MAX_HEIGHT, WidgetFrame, WidgetHostProvider, WidgetPartView, activityHeading, activityLabel, commandTool, defaultIcons, defaultLabels, defaultToolViews, encodeWav, formatDuration, formatRelativeTime, formatTokens, htmlComponent, intersectAllow, memoryTool, messageText, openInNewTab, readTreeInput, readVisualizeInput, sseTransport, uiMarkupToolView, uiTreeToolView, useAgentContext, useAgentRegistry, useAgentSnapshot, useAgentTool, useAttachments, useChat, useChatTool, useCountUp, useDictation, useIcons, useLabels, useMediaQuery, useSidebar, useSmoothText, useToolView, useToolViews, useUiAction, useUiMarkupLanguage, useUiState, useWidgetHost, userMessage, webFetchTool, webSearchTool, widgetCsp, widgetToolView };