agentic-ui-kit 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +241 -2
- package/dist/core.js +1 -0
- package/dist/index.js +2875 -0
- package/dist/sse-BA_ebMcH.js +408 -0
- package/dist/styles.css +3 -0
- package/dist/tailwind.css +350 -0
- package/dist/types/core/src/blocks.d.ts +22 -0
- package/dist/types/core/src/events.d.ts +75 -0
- package/dist/types/core/src/index.d.ts +7 -0
- package/dist/types/core/src/partial-json.d.ts +7 -0
- package/dist/types/core/src/reduce.d.ts +33 -0
- package/dist/types/core/src/sse.d.ts +8 -0
- package/dist/types/core/src/types.d.ts +171 -0
- package/dist/types/react/src/components/activity-group.d.ts +13 -0
- package/dist/types/react/src/components/approval-card.d.ts +11 -0
- package/dist/types/react/src/components/attachments.d.ts +78 -0
- package/dist/types/react/src/components/chat-header.d.ts +30 -0
- package/dist/types/react/src/components/chat-list.d.ts +47 -0
- package/dist/types/react/src/components/composer.d.ts +45 -0
- package/dist/types/react/src/components/context-panel.d.ts +13 -0
- package/dist/types/react/src/components/markdown.d.ts +8 -0
- package/dist/types/react/src/components/message-list.d.ts +18 -0
- package/dist/types/react/src/components/messages.d.ts +33 -0
- package/dist/types/react/src/components/model-picker.d.ts +16 -0
- package/dist/types/react/src/components/token-counter.d.ts +16 -0
- package/dist/types/react/src/components/tools.d.ts +68 -0
- package/dist/types/react/src/components/word-fade.d.ts +19 -0
- package/dist/types/react/src/core.d.ts +1 -0
- package/dist/types/react/src/hooks/agent.d.ts +86 -0
- package/dist/types/react/src/hooks/sidebar-state.d.ts +18 -0
- package/dist/types/react/src/hooks/smooth-text.d.ts +9 -0
- package/dist/types/react/src/hooks/sse-transport.d.ts +15 -0
- package/dist/types/react/src/hooks/use-chat.d.ts +68 -0
- package/dist/types/react/src/index.d.ts +57 -0
- package/dist/types/react/src/primitives/appearance.d.ts +38 -0
- package/dist/types/react/src/primitives/badge.d.ts +15 -0
- package/dist/types/react/src/primitives/button.d.ts +32 -0
- package/dist/types/react/src/primitives/caret.d.ts +4 -0
- package/dist/types/react/src/primitives/chat-theme.d.ts +22 -0
- package/dist/types/react/src/primitives/code-block.d.ts +13 -0
- package/dist/types/react/src/primitives/context-menu.d.ts +13 -0
- package/dist/types/react/src/primitives/disclosure.d.ts +16 -0
- package/dist/types/react/src/primitives/highlight.d.ts +13 -0
- package/dist/types/react/src/primitives/menu.d.ts +52 -0
- package/dist/types/react/src/primitives/overlay.d.ts +21 -0
- package/dist/types/react/src/primitives/panel.d.ts +19 -0
- package/dist/types/react/src/primitives/tooltip.d.ts +10 -0
- package/dist/types/react/src/views/app-shell.d.ts +20 -0
- package/dist/types/react/src/views/chat-view.d.ts +51 -0
- package/dist/types/react/src/views/sidebar.d.ts +38 -0
- package/package.json +80 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,2875 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { a as activityTokens, f as settleToolCall, i as toBlocks, n as readEventStream, o as applyEvent, s as awaitingToolCalls } from "./sse-BA_ebMcH.js";
|
|
3
|
+
import { clsx } from "clsx";
|
|
4
|
+
import { Autocomplete, Button as Button$1, Dialog, 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, Tooltip as Tooltip$1, TooltipTrigger, useFilter } from "react-aria-components";
|
|
5
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
import { AlertTriangle, ArrowDown, ArrowUp, Check, ChevronDown, ChevronRight, ChevronsUpDown, Copy, FileText, Folder, FolderOpen, FolderPlus, Globe, MoreHorizontal, PanelLeft, Pencil, Pin, PinOff, Plus, RefreshCw, Share, ShieldQuestion, Square, ThumbsDown, ThumbsUp, Trash2, Volume2, X } from "lucide-react";
|
|
7
|
+
import { Fragment as Fragment$1, createContext, createElement, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
8
|
+
import { UNSAFE_PortalProvider } from "react-aria";
|
|
9
|
+
import { createMathPlugin } from "@streamdown/math";
|
|
10
|
+
import { Streamdown, defaultRehypePlugins } from "streamdown";
|
|
11
|
+
// src/primitives/tooltip.tsx
|
|
12
|
+
function Tooltip({ content, children, placement = "top", delay = 600 }) {
|
|
13
|
+
return /* @__PURE__ */ jsxs(TooltipTrigger, {
|
|
14
|
+
delay,
|
|
15
|
+
closeDelay: 0,
|
|
16
|
+
children: [children, /* @__PURE__ */ jsx(Tooltip$1, {
|
|
17
|
+
placement,
|
|
18
|
+
offset: 6,
|
|
19
|
+
className: "rounded-chat-sm bg-chat-raised px-2 py-1 font-chat-sans text-xs text-chat-fg shadow-chat-popover motion-safe:data-entering:animate-chat-in motion-safe:data-exiting:animate-chat-out",
|
|
20
|
+
children: content
|
|
21
|
+
})]
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
// src/primitives/button.tsx
|
|
25
|
+
const focusRing = "outline-none data-focus-visible:ring-2 data-focus-visible:ring-chat-focus";
|
|
26
|
+
const variants = {
|
|
27
|
+
/** Plain text button, highlights on hover. Sidebar rows, header buttons. */
|
|
28
|
+
ghost: "text-chat-fg data-hovered:bg-chat-hover data-pressed:bg-chat-selected",
|
|
29
|
+
/** Filled with the surface color, e.g. the selected Chat/Cowork toggle. */
|
|
30
|
+
subtle: "bg-chat-surface text-chat-fg data-hovered:bg-chat-hover data-pressed:bg-chat-selected",
|
|
31
|
+
/** The send button. */
|
|
32
|
+
primary: "bg-chat-accent text-white data-hovered:brightness-110 data-pressed:brightness-95"
|
|
33
|
+
};
|
|
34
|
+
const sizes = {
|
|
35
|
+
sm: "h-7 gap-1.5 px-2 text-sm [@media(pointer:coarse)]:h-9",
|
|
36
|
+
md: "h-8 gap-2 px-2.5 text-sm [@media(pointer:coarse)]:h-10"
|
|
37
|
+
};
|
|
38
|
+
function Button({ variant = "ghost", size = "md", className, ...props }) {
|
|
39
|
+
return /* @__PURE__ */ jsx(Button$1, {
|
|
40
|
+
...props,
|
|
41
|
+
className: clsx("inline-flex cursor-pointer items-center rounded-chat-sm font-chat-sans whitespace-nowrap transition-colors select-none", "data-disabled:cursor-default data-disabled:opacity-40", focusRing, variants[variant], sizes[size], className)
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function IconButton({ label, variant = "ghost", size = "md", tooltip = true, className, ...props }) {
|
|
45
|
+
const button = /* @__PURE__ */ jsx(Button$1, {
|
|
46
|
+
...props,
|
|
47
|
+
"aria-label": label,
|
|
48
|
+
className: clsx("inline-flex shrink-0 cursor-pointer items-center justify-center rounded-chat-sm text-chat-muted transition-colors select-none", "data-hovered:text-chat-fg data-disabled:cursor-default data-disabled:opacity-40 [&_svg]:size-4", focusRing, variants[variant], variant === "ghost" && "text-chat-muted", size === "sm" ? "size-7 [@media(pointer:coarse)]:size-9" : "size-8 [@media(pointer:coarse)]:size-10", className)
|
|
49
|
+
});
|
|
50
|
+
return tooltip ? /* @__PURE__ */ jsx(Tooltip, {
|
|
51
|
+
content: label,
|
|
52
|
+
children: button
|
|
53
|
+
}) : button;
|
|
54
|
+
}
|
|
55
|
+
// src/primitives/badge.tsx
|
|
56
|
+
const tones = {
|
|
57
|
+
/** Outlined, e.g. "Recommended". */
|
|
58
|
+
outline: "border border-chat-border-strong text-chat-muted",
|
|
59
|
+
/** Amber, e.g. "5.5× or more usage". */
|
|
60
|
+
warning: "bg-chat-warning-bg text-chat-warning"
|
|
61
|
+
};
|
|
62
|
+
function Badge({ children, tone = "outline", icon, className }) {
|
|
63
|
+
return /* @__PURE__ */ jsxs("span", {
|
|
64
|
+
className: clsx("inline-flex items-center gap-1 rounded-full px-2 py-px font-chat-sans text-xs whitespace-nowrap [&_svg]:size-3", tones[tone], className),
|
|
65
|
+
children: [icon, children]
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
// src/primitives/caret.tsx
|
|
69
|
+
/** The small filled triangle claude.ai uses after expandable labels. Points right; rotate to point down. */
|
|
70
|
+
function Caret({ className }) {
|
|
71
|
+
return /* @__PURE__ */ jsx("svg", {
|
|
72
|
+
"aria-hidden": true,
|
|
73
|
+
viewBox: "0 0 8 8",
|
|
74
|
+
className: clsx("size-2 shrink-0 fill-current transition-transform duration-150", className),
|
|
75
|
+
children: /* @__PURE__ */ jsx("path", { d: "M2 1.2 6.2 4 2 6.8z" })
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
// src/primitives/disclosure.tsx
|
|
79
|
+
/** A row that expands to show more, like "Recalled memory ▸". */
|
|
80
|
+
function Disclosure({ summary, children, className, summaryClassName, panelClassName, indicator = "caret", trailing, ...props }) {
|
|
81
|
+
return /* @__PURE__ */ jsxs(Disclosure$1, {
|
|
82
|
+
...props,
|
|
83
|
+
...className !== void 0 && { className },
|
|
84
|
+
children: [/* @__PURE__ */ jsx(Heading, {
|
|
85
|
+
className: "m-0 font-normal",
|
|
86
|
+
children: /* @__PURE__ */ jsxs(Button$1, {
|
|
87
|
+
slot: "trigger",
|
|
88
|
+
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),
|
|
89
|
+
children: [
|
|
90
|
+
indicator === "chevron" && /* @__PURE__ */ jsx(ChevronRight, {
|
|
91
|
+
"aria-hidden": true,
|
|
92
|
+
className: "size-3.5 shrink-0 transition-transform group-aria-expanded/trigger:rotate-90"
|
|
93
|
+
}),
|
|
94
|
+
/* @__PURE__ */ jsx("span", {
|
|
95
|
+
className: "min-w-0 truncate",
|
|
96
|
+
children: summary
|
|
97
|
+
}),
|
|
98
|
+
indicator === "caret" && /* @__PURE__ */ jsx(Caret, { className: "group-aria-expanded/trigger:rotate-90" }),
|
|
99
|
+
trailing && /* @__PURE__ */ jsx("span", {
|
|
100
|
+
className: "ml-auto shrink-0 pl-2",
|
|
101
|
+
children: trailing
|
|
102
|
+
})
|
|
103
|
+
]
|
|
104
|
+
})
|
|
105
|
+
}), /* @__PURE__ */ jsx(DisclosurePanel, { children: /* @__PURE__ */ jsx("div", {
|
|
106
|
+
className: panelClassName,
|
|
107
|
+
children
|
|
108
|
+
}) })]
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
// src/primitives/menu.tsx
|
|
112
|
+
/** A search box above a menu that filters its items, like "Search or create a project". */
|
|
113
|
+
function MenuSearch({ placeholder, children, value, onChange }) {
|
|
114
|
+
const { contains } = useFilter({ sensitivity: "base" });
|
|
115
|
+
return /* @__PURE__ */ jsxs(Autocomplete, {
|
|
116
|
+
filter: contains,
|
|
117
|
+
...value !== void 0 && { inputValue: value },
|
|
118
|
+
...onChange && { onInputChange: onChange },
|
|
119
|
+
children: [/* @__PURE__ */ jsx(SearchField, {
|
|
120
|
+
"aria-label": placeholder,
|
|
121
|
+
autoFocus: true,
|
|
122
|
+
className: "border-b border-chat-popover-border",
|
|
123
|
+
children: /* @__PURE__ */ jsx(Input, {
|
|
124
|
+
placeholder,
|
|
125
|
+
className: "w-full bg-transparent px-3.5 py-2.5 text-sm text-chat-fg outline-none placeholder:text-chat-subtle"
|
|
126
|
+
})
|
|
127
|
+
}), children]
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/** Floating panel for menus and pickers: surface color, rounded, soft shadow, fades in. */
|
|
131
|
+
function Popover({ className, offset = 6, ...props }) {
|
|
132
|
+
return /* @__PURE__ */ jsx(Popover$1, {
|
|
133
|
+
offset,
|
|
134
|
+
...props,
|
|
135
|
+
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)
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
function Menu({ className, ...props }) {
|
|
139
|
+
return /* @__PURE__ */ jsx(Menu$1, {
|
|
140
|
+
...props,
|
|
141
|
+
className: clsx("max-h-[inherit] min-w-52 overflow-auto p-1.5 outline-none", className)
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
function MenuItem({ children, icon, description, detail, shortcut, tone = "default", className, ...props }) {
|
|
145
|
+
const textValue = props.textValue ?? (typeof children === "string" ? children : void 0);
|
|
146
|
+
return /* @__PURE__ */ jsx(MenuItem$1, {
|
|
147
|
+
...props,
|
|
148
|
+
...textValue !== void 0 && { textValue },
|
|
149
|
+
className: clsx("group flex min-h-7 cursor-pointer items-center gap-2.5 rounded-chat-sm px-2 py-1 text-sm 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),
|
|
150
|
+
children: ({ isSelected, hasSubmenu, selectionMode }) => /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
151
|
+
icon && /* @__PURE__ */ jsx("span", {
|
|
152
|
+
className: clsx("flex size-4 shrink-0 items-center [&_svg]:size-4", tone === "danger" ? "text-chat-danger" : "text-chat-fg"),
|
|
153
|
+
children: icon
|
|
154
|
+
}),
|
|
155
|
+
/* @__PURE__ */ jsxs("span", {
|
|
156
|
+
className: "flex min-w-0 flex-1 flex-col",
|
|
157
|
+
children: [/* @__PURE__ */ jsx(Text, {
|
|
158
|
+
slot: "label",
|
|
159
|
+
className: "truncate",
|
|
160
|
+
children
|
|
161
|
+
}), description && /* @__PURE__ */ jsx(Text, {
|
|
162
|
+
slot: "description",
|
|
163
|
+
className: "text-xs text-chat-muted",
|
|
164
|
+
children: description
|
|
165
|
+
})]
|
|
166
|
+
}),
|
|
167
|
+
detail && /* @__PURE__ */ jsx("span", {
|
|
168
|
+
className: "shrink-0 text-sm text-chat-muted",
|
|
169
|
+
children: detail
|
|
170
|
+
}),
|
|
171
|
+
shortcut && /* @__PURE__ */ jsx(Keyboard, {
|
|
172
|
+
className: "shrink-0 font-chat-sans text-xs text-chat-subtle",
|
|
173
|
+
children: shortcut
|
|
174
|
+
}),
|
|
175
|
+
selectionMode !== "none" && isSelected && /* @__PURE__ */ jsx(Check, {
|
|
176
|
+
"aria-hidden": true,
|
|
177
|
+
className: "size-4 shrink-0 text-chat-check"
|
|
178
|
+
}),
|
|
179
|
+
hasSubmenu && /* @__PURE__ */ jsx(ChevronRight, {
|
|
180
|
+
"aria-hidden": true,
|
|
181
|
+
className: "size-4 shrink-0 text-chat-muted"
|
|
182
|
+
})
|
|
183
|
+
] })
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
function MenuSection({ title, children, ...selection }) {
|
|
187
|
+
return /* @__PURE__ */ jsxs(MenuSection$1, {
|
|
188
|
+
...selection,
|
|
189
|
+
className: "[&:not(:first-child)]:mt-1",
|
|
190
|
+
children: [title && /* @__PURE__ */ jsx(Header, {
|
|
191
|
+
className: "px-2 pt-1.5 pb-1 text-xs text-chat-subtle",
|
|
192
|
+
children: title
|
|
193
|
+
}), children]
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
function MenuSeparator() {
|
|
197
|
+
return /* @__PURE__ */ jsx(Separator, { className: "-mx-1.5 my-1.5 border-t border-chat-popover-border" });
|
|
198
|
+
}
|
|
199
|
+
/** Explanatory text inside a menu, above or below the items. Not focusable. */
|
|
200
|
+
function MenuNote({ children, className }) {
|
|
201
|
+
return /* @__PURE__ */ jsx("div", {
|
|
202
|
+
className: clsx("px-2 py-1.5 text-sm leading-snug text-chat-muted", className),
|
|
203
|
+
children
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
// src/primitives/context-menu.tsx
|
|
207
|
+
/**
|
|
208
|
+
* Opens `menu` at the pointer on right-click, or at the focused element with the Menu key or Shift+F10.
|
|
209
|
+
* Long-press opens it where the browser fires `contextmenu` for touch (Android); give touch users a
|
|
210
|
+
* visible "…" button as well.
|
|
211
|
+
*/
|
|
212
|
+
function ContextMenu({ menu, children, className }) {
|
|
213
|
+
const root = useRef(null);
|
|
214
|
+
const [at, setAt] = useState();
|
|
215
|
+
const openAt = (x, y) => {
|
|
216
|
+
const box = root.current?.getBoundingClientRect();
|
|
217
|
+
if (box) setAt({
|
|
218
|
+
x: x - box.left,
|
|
219
|
+
y: y - box.top
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
const onContextMenu = (e) => {
|
|
223
|
+
e.preventDefault();
|
|
224
|
+
openAt(e.clientX, e.clientY);
|
|
225
|
+
};
|
|
226
|
+
const onKeyDown = (e) => {
|
|
227
|
+
if (e.key !== "ContextMenu" && !(e.shiftKey && e.key === "F10")) return;
|
|
228
|
+
e.preventDefault();
|
|
229
|
+
const target = e.target.getBoundingClientRect();
|
|
230
|
+
openAt(target.left + 8, target.bottom);
|
|
231
|
+
};
|
|
232
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
233
|
+
ref: root,
|
|
234
|
+
onContextMenu,
|
|
235
|
+
onKeyDown,
|
|
236
|
+
className: clsx("relative", className),
|
|
237
|
+
children: [children, /* @__PURE__ */ jsxs(MenuTrigger$1, {
|
|
238
|
+
isOpen: at !== void 0,
|
|
239
|
+
onOpenChange: (open) => !open && setAt(void 0),
|
|
240
|
+
children: [/* @__PURE__ */ jsx(Button$1, {
|
|
241
|
+
"aria-hidden": true,
|
|
242
|
+
excludeFromTabOrder: true,
|
|
243
|
+
className: "pointer-events-none absolute size-0 opacity-0",
|
|
244
|
+
style: {
|
|
245
|
+
left: at?.x ?? 0,
|
|
246
|
+
top: at?.y ?? 0
|
|
247
|
+
}
|
|
248
|
+
}), /* @__PURE__ */ jsx(Popover, {
|
|
249
|
+
placement: "bottom start",
|
|
250
|
+
offset: 2,
|
|
251
|
+
children: menu
|
|
252
|
+
})]
|
|
253
|
+
})]
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
// src/primitives/overlay.tsx
|
|
257
|
+
/** A centered dialog over a dimmed page. Escape and clicking outside close it; focus stays inside. */
|
|
258
|
+
function Modal({ isOpen, onOpenChange, title, children, className, ...props }) {
|
|
259
|
+
return /* @__PURE__ */ jsx(ModalOverlay, {
|
|
260
|
+
isOpen,
|
|
261
|
+
onOpenChange,
|
|
262
|
+
isDismissable: true,
|
|
263
|
+
className: "fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 data-entering:animate-chat-fade-in data-exiting:animate-chat-fade-out",
|
|
264
|
+
children: /* @__PURE__ */ jsx(Modal$1, {
|
|
265
|
+
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),
|
|
266
|
+
children: /* @__PURE__ */ jsxs(Dialog, {
|
|
267
|
+
...props,
|
|
268
|
+
className: "flex min-h-0 flex-1 flex-col outline-none",
|
|
269
|
+
children: [title && /* @__PURE__ */ jsx(Heading, {
|
|
270
|
+
slot: "title",
|
|
271
|
+
className: "px-5 pt-4 pb-3 text-base font-medium",
|
|
272
|
+
children: title
|
|
273
|
+
}), children]
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
/** A panel sliding in from the side over a dimmed page, e.g. the sidebar on phones. */
|
|
279
|
+
function Drawer({ isOpen, onOpenChange, side = "left", children, className, ...props }) {
|
|
280
|
+
return /* @__PURE__ */ jsx(ModalOverlay, {
|
|
281
|
+
isOpen,
|
|
282
|
+
onOpenChange,
|
|
283
|
+
isDismissable: true,
|
|
284
|
+
className: "fixed inset-0 z-40 bg-black/50 data-entering:animate-chat-fade-in data-exiting:animate-chat-fade-out",
|
|
285
|
+
children: /* @__PURE__ */ jsx(Modal$1, {
|
|
286
|
+
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),
|
|
287
|
+
children: /* @__PURE__ */ jsx(Dialog, {
|
|
288
|
+
...props,
|
|
289
|
+
className: "h-full outline-none",
|
|
290
|
+
children
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
// src/primitives/panel.tsx
|
|
296
|
+
/**
|
|
297
|
+
* A titled region that fills its container and lets the body scroll: a dock panel, an agent pane in a
|
|
298
|
+
* grid, a settings card. A `ChatView` placed in the body sizes itself to the panel.
|
|
299
|
+
*/
|
|
300
|
+
function Panel({ title, actions, footer, children, variant = "card", className, ...props }) {
|
|
301
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
302
|
+
"aria-label": props["aria-label"],
|
|
303
|
+
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),
|
|
304
|
+
children: [
|
|
305
|
+
(title || actions) && /* @__PURE__ */ jsxs("header", {
|
|
306
|
+
className: "flex h-10 shrink-0 items-center gap-2 border-b border-chat-divider px-3 text-sm",
|
|
307
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
308
|
+
className: "min-w-0 flex-1 truncate font-medium",
|
|
309
|
+
children: title
|
|
310
|
+
}), actions && /* @__PURE__ */ jsx("div", {
|
|
311
|
+
className: "flex shrink-0 items-center gap-0.5",
|
|
312
|
+
children: actions
|
|
313
|
+
})]
|
|
314
|
+
}),
|
|
315
|
+
/* @__PURE__ */ jsx("div", {
|
|
316
|
+
className: "min-h-0 flex-1 overflow-y-auto [scrollbar-width:thin]",
|
|
317
|
+
children
|
|
318
|
+
}),
|
|
319
|
+
footer && /* @__PURE__ */ jsx("footer", {
|
|
320
|
+
className: "shrink-0 border-t border-chat-divider px-3 py-2",
|
|
321
|
+
children: footer
|
|
322
|
+
})
|
|
323
|
+
]
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
// src/primitives/appearance.tsx
|
|
327
|
+
const DEFAULT_CODE_THEMES = {
|
|
328
|
+
light: "github-light",
|
|
329
|
+
dark: "one-dark-pro"
|
|
330
|
+
};
|
|
331
|
+
const AppearanceContext = createContext({});
|
|
332
|
+
function AppearanceProvider({ codeThemes, motion, children }) {
|
|
333
|
+
const { light, dark, background } = codeThemes ?? {};
|
|
334
|
+
const value = useMemo(() => ({
|
|
335
|
+
codeThemes: {
|
|
336
|
+
...light && { light },
|
|
337
|
+
...dark && { dark },
|
|
338
|
+
...background && { background }
|
|
339
|
+
},
|
|
340
|
+
motion
|
|
341
|
+
}), [
|
|
342
|
+
light,
|
|
343
|
+
dark,
|
|
344
|
+
background,
|
|
345
|
+
motion
|
|
346
|
+
]);
|
|
347
|
+
return /* @__PURE__ */ jsx(AppearanceContext.Provider, {
|
|
348
|
+
value,
|
|
349
|
+
children
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
function useCodeThemes() {
|
|
353
|
+
return useContext(AppearanceContext).codeThemes ?? {};
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Whether motion should be reduced, for a given `motion` setting. "system" also honors
|
|
357
|
+
* `data-chat-motion` on <html> (for hosts that do not use ChatTheme), then the OS media query.
|
|
358
|
+
*/
|
|
359
|
+
function shouldReduceMotion(motion = "system") {
|
|
360
|
+
if (motion !== "system") return motion === "reduce";
|
|
361
|
+
if (typeof document !== "undefined") {
|
|
362
|
+
const root = document.documentElement.getAttribute("data-chat-motion");
|
|
363
|
+
if (root === "reduce" || root === "full") return root === "reduce";
|
|
364
|
+
}
|
|
365
|
+
return typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Returns a function that tells, at call time, whether JS-driven animation (text streaming, counters)
|
|
369
|
+
* should be skipped. Call it inside effects. The CSS side uses the same setting through `data-chat-motion`.
|
|
370
|
+
*/
|
|
371
|
+
function useReducedMotion() {
|
|
372
|
+
const { motion } = useContext(AppearanceContext);
|
|
373
|
+
return useCallback(() => shouldReduceMotion(motion), [motion]);
|
|
374
|
+
}
|
|
375
|
+
// src/primitives/highlight.ts
|
|
376
|
+
let shiki;
|
|
377
|
+
const loadShiki = () => shiki ??= import("shiki");
|
|
378
|
+
const cache = /* @__PURE__ */ new Map();
|
|
379
|
+
const warned = /* @__PURE__ */ new Set();
|
|
380
|
+
/** The theme id if Shiki bundles it; otherwise the default, with one console.warn per unknown id. */
|
|
381
|
+
function pick(id, fallback, known) {
|
|
382
|
+
if (!id) return fallback;
|
|
383
|
+
if (Object.hasOwn(known, id)) return id;
|
|
384
|
+
if (!warned.has(id)) {
|
|
385
|
+
warned.add(id);
|
|
386
|
+
console.warn(`agentic-ui-kit: unknown Shiki code theme "${id}", using "${fallback}".`);
|
|
387
|
+
}
|
|
388
|
+
return fallback;
|
|
389
|
+
}
|
|
390
|
+
/** Parses "--a:#fff;--b:#000" into an object. */
|
|
391
|
+
function parseVars(css) {
|
|
392
|
+
const vars = {};
|
|
393
|
+
for (const part of css?.split(";") ?? []) {
|
|
394
|
+
const at = part.indexOf(":");
|
|
395
|
+
if (at > 0) vars[part.slice(0, at).trim()] = part.slice(at + 1).trim();
|
|
396
|
+
}
|
|
397
|
+
return vars;
|
|
398
|
+
}
|
|
399
|
+
async function highlight(code, language, light, dark) {
|
|
400
|
+
const key = `${light}\n${dark}\n${language}\n${code}`;
|
|
401
|
+
const hit = cache.get(key);
|
|
402
|
+
if (hit) return hit;
|
|
403
|
+
const { codeToTokens, bundledLanguages, bundledLanguagesAlias, bundledThemes } = await loadShiki();
|
|
404
|
+
const lang = language.toLowerCase();
|
|
405
|
+
if (!(lang in bundledLanguages) && !(lang in bundledLanguagesAlias)) return void 0;
|
|
406
|
+
const { tokens, bg, fg } = await codeToTokens(code, {
|
|
407
|
+
lang,
|
|
408
|
+
themes: {
|
|
409
|
+
light: pick(light, DEFAULT_CODE_THEMES.light, bundledThemes),
|
|
410
|
+
dark: pick(dark, DEFAULT_CODE_THEMES.dark, bundledThemes)
|
|
411
|
+
},
|
|
412
|
+
defaultColor: false
|
|
413
|
+
});
|
|
414
|
+
const result = {
|
|
415
|
+
lines: tokens,
|
|
416
|
+
vars: {
|
|
417
|
+
...parseVars(bg),
|
|
418
|
+
...parseVars(fg)
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
if (cache.size > 200) cache.clear();
|
|
422
|
+
cache.set(key, result);
|
|
423
|
+
return result;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Highlights `code` in the background, with the themes from the nearest ChatTheme. Returns the latest
|
|
427
|
+
* finished result, so streaming code keeps its colors while the next version is highlighted instead of
|
|
428
|
+
* flashing back to plain text; a theme change re-highlights.
|
|
429
|
+
*/
|
|
430
|
+
function useHighlight(code, language) {
|
|
431
|
+
const { light, dark } = useCodeThemes();
|
|
432
|
+
const [result, setResult] = useState();
|
|
433
|
+
useEffect(() => {
|
|
434
|
+
if (!language) return setResult(void 0);
|
|
435
|
+
let cancelled = false;
|
|
436
|
+
highlight(code, language, light, dark).then((r) => !cancelled && setResult(r), () => !cancelled && setResult(void 0));
|
|
437
|
+
return () => {
|
|
438
|
+
cancelled = true;
|
|
439
|
+
};
|
|
440
|
+
}, [
|
|
441
|
+
code,
|
|
442
|
+
language,
|
|
443
|
+
light,
|
|
444
|
+
dark
|
|
445
|
+
]);
|
|
446
|
+
return result;
|
|
447
|
+
}
|
|
448
|
+
// src/primitives/code-block.tsx
|
|
449
|
+
/** Monospace code with Shiki highlighting, scrolling past a fixed height. */
|
|
450
|
+
function CodeBlock({ code, language, label = language, variant = "plain", className }) {
|
|
451
|
+
const result = useHighlight(code, language);
|
|
452
|
+
const lines = result?.lines;
|
|
453
|
+
const themeBackground = useCodeThemes().background === "theme" && variant === "boxed" && !!result;
|
|
454
|
+
const done = lines?.map((l) => l.map((t) => t.content).join("")).join("\n");
|
|
455
|
+
const highlighted = lines && done !== void 0 && code.startsWith(done) ? lines : void 0;
|
|
456
|
+
const tail = highlighted ? code.slice(done.length) : "";
|
|
457
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
458
|
+
"data-chat-code-block": true,
|
|
459
|
+
"data-chat-code-bg": themeBackground ? "theme" : void 0,
|
|
460
|
+
style: themeBackground ? result.vars : void 0,
|
|
461
|
+
className: clsx(variant === "boxed" && "my-4 rounded-chat-md border border-chat-code-border bg-chat-code-bg px-4 py-3.5", className),
|
|
462
|
+
children: [label && /* @__PURE__ */ jsx("div", {
|
|
463
|
+
className: "mb-3 font-chat-sans text-xs text-chat-subtle",
|
|
464
|
+
children: label
|
|
465
|
+
}), /* @__PURE__ */ jsx("pre", {
|
|
466
|
+
className: clsx("overflow-auto font-chat-mono text-[0.8125rem] leading-[1.75] whitespace-pre text-chat-fg", variant === "plain" && "max-h-56", "[scrollbar-color:var(--chat-border-strong)_transparent] [scrollbar-width:thin]"),
|
|
467
|
+
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", {
|
|
468
|
+
className: "chat-code-token",
|
|
469
|
+
style: token.htmlStyle,
|
|
470
|
+
children: token.content
|
|
471
|
+
}, j))] }, i)) : code, tail] })
|
|
472
|
+
})]
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
// src/primitives/chat-theme.tsx
|
|
476
|
+
/**
|
|
477
|
+
* Applies a chat theme to part of a page. Menus, tooltips and dialogs opened inside render into this
|
|
478
|
+
* element instead of `<body>`, so they get the same theme. An app themed as a whole (variables on
|
|
479
|
+
* `:root`) does not need it for colors, but still uses it for `codeThemes` and `motion`.
|
|
480
|
+
*/
|
|
481
|
+
function ChatTheme({ theme, codeThemes, motion, children, className }) {
|
|
482
|
+
const portal = useRef(null);
|
|
483
|
+
const getContainer = useCallback(() => portal.current, []);
|
|
484
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
485
|
+
"data-chat-theme": theme,
|
|
486
|
+
"data-chat-motion": motion,
|
|
487
|
+
className: clsx("bg-chat-bg font-chat-sans text-chat-fg", className),
|
|
488
|
+
children: [/* @__PURE__ */ jsx(AppearanceProvider, {
|
|
489
|
+
codeThemes,
|
|
490
|
+
motion,
|
|
491
|
+
children: /* @__PURE__ */ jsx(UNSAFE_PortalProvider, {
|
|
492
|
+
getContainer,
|
|
493
|
+
children
|
|
494
|
+
})
|
|
495
|
+
}), /* @__PURE__ */ jsx("div", { ref: portal })]
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
// src/hooks/agent.ts
|
|
499
|
+
/**
|
|
500
|
+
* The tools and context the page currently offers. Components register what they own while mounted, so
|
|
501
|
+
* the model's tools follow what is on screen.
|
|
502
|
+
*/
|
|
503
|
+
var AgentRegistry = class {
|
|
504
|
+
tools = /* @__PURE__ */ new Map();
|
|
505
|
+
contexts = /* @__PURE__ */ new Map();
|
|
506
|
+
listeners = /* @__PURE__ */ new Set();
|
|
507
|
+
version = 0;
|
|
508
|
+
addTool(holder) {
|
|
509
|
+
const name = holder.current.name;
|
|
510
|
+
this.tools.set(name, holder);
|
|
511
|
+
this.emit();
|
|
512
|
+
return () => {
|
|
513
|
+
if (this.tools.get(name) !== holder) return;
|
|
514
|
+
this.tools.delete(name);
|
|
515
|
+
this.emit();
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
setContext(key, value) {
|
|
519
|
+
this.contexts.set(key, value);
|
|
520
|
+
this.emit();
|
|
521
|
+
return () => {
|
|
522
|
+
if (this.contexts.get(key) !== value) return;
|
|
523
|
+
this.contexts.delete(key);
|
|
524
|
+
this.emit();
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
tool(name) {
|
|
528
|
+
return this.tools.get(name)?.current;
|
|
529
|
+
}
|
|
530
|
+
list() {
|
|
531
|
+
return [...this.tools.values()].map((h) => h.current);
|
|
532
|
+
}
|
|
533
|
+
/** Client tools, for the request. */
|
|
534
|
+
specs() {
|
|
535
|
+
return this.list().filter((t) => t.execute).map(({ name, description, inputSchema }) => ({
|
|
536
|
+
name,
|
|
537
|
+
...description && { description },
|
|
538
|
+
...inputSchema && { inputSchema }
|
|
539
|
+
}));
|
|
540
|
+
}
|
|
541
|
+
/** Everything the page shared with `useAgentContext`, for the request. */
|
|
542
|
+
context() {
|
|
543
|
+
return Object.fromEntries(this.contexts);
|
|
544
|
+
}
|
|
545
|
+
subscribe = (listener) => {
|
|
546
|
+
this.listeners.add(listener);
|
|
547
|
+
return () => this.listeners.delete(listener);
|
|
548
|
+
};
|
|
549
|
+
getVersion = () => this.version;
|
|
550
|
+
emit() {
|
|
551
|
+
this.version++;
|
|
552
|
+
for (const listener of this.listeners) listener();
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
const AgentContext = createContext(null);
|
|
556
|
+
/** Holds the tools and context for the chats and the page inside it. */
|
|
557
|
+
function AgentProvider({ registry, children }) {
|
|
558
|
+
const [own] = useState(() => registry ?? new AgentRegistry());
|
|
559
|
+
return createElement(AgentContext.Provider, { value: registry ?? own }, children);
|
|
560
|
+
}
|
|
561
|
+
const useAgentRegistry = () => useContext(AgentContext);
|
|
562
|
+
/** Re-renders when tools or context change, e.g. for an inspector. */
|
|
563
|
+
function useAgentSnapshot() {
|
|
564
|
+
const registry = useAgentRegistry();
|
|
565
|
+
useSyncExternalStore(registry?.subscribe ?? noopSubscribe, registry?.getVersion ?? zero, zero);
|
|
566
|
+
return registry;
|
|
567
|
+
}
|
|
568
|
+
const noopSubscribe = () => () => {};
|
|
569
|
+
const zero = () => 0;
|
|
570
|
+
/**
|
|
571
|
+
* Offers a tool while the calling component is mounted. The latest `tool` is always used, so `execute` can
|
|
572
|
+
* close over current state; it is only re-registered when the name changes.
|
|
573
|
+
*/
|
|
574
|
+
function useAgentTool(tool) {
|
|
575
|
+
const registry = useAgentRegistry();
|
|
576
|
+
const holder = useRef(tool);
|
|
577
|
+
useLayoutEffect(() => {
|
|
578
|
+
holder.current = tool;
|
|
579
|
+
});
|
|
580
|
+
useEffect(() => registry?.addTool(holder), [registry, tool.name]);
|
|
581
|
+
}
|
|
582
|
+
/** Shares page state with the model, e.g. the form's current values. Sent with each request. */
|
|
583
|
+
function useAgentContext(key, value) {
|
|
584
|
+
const registry = useAgentRegistry();
|
|
585
|
+
useEffect(() => registry?.setContext(key, value), [
|
|
586
|
+
registry,
|
|
587
|
+
key,
|
|
588
|
+
value
|
|
589
|
+
]);
|
|
590
|
+
}
|
|
591
|
+
// src/hooks/use-chat.ts
|
|
592
|
+
let counter$1 = 0;
|
|
593
|
+
const localId = () => `local_${Date.now().toString(36)}_${(counter$1++).toString(36)}`;
|
|
594
|
+
function userMessage(text, parentId, attachments = []) {
|
|
595
|
+
return {
|
|
596
|
+
id: localId(),
|
|
597
|
+
parentId,
|
|
598
|
+
role: "user",
|
|
599
|
+
status: "done",
|
|
600
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
601
|
+
parts: [...attachments, ...text.trim() ? [{
|
|
602
|
+
type: "text",
|
|
603
|
+
id: localId(),
|
|
604
|
+
text,
|
|
605
|
+
status: "done"
|
|
606
|
+
}] : []]
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
const wantsApproval = (part, tool) => part.approval === void 0 && (part.needsApproval === true || (typeof tool?.approval === "function" ? tool.approval(part.input) : tool?.approval === true));
|
|
610
|
+
const DECLINED = "Declined by the user";
|
|
611
|
+
/**
|
|
612
|
+
* A linear conversation driven by a Transport: sends, streams the reply through `applyEvent`, stops and
|
|
613
|
+
* retries. With an `AgentProvider` it also runs the agent loop: client tools run in the page, approvals
|
|
614
|
+
* wait for the user, then the reply continues; every finished tool call reaches its tool's `onResult`.
|
|
615
|
+
*/
|
|
616
|
+
function useChat({ transport, conversationId, initialMessages = [], onTitle, agent, maxSteps = 12 }) {
|
|
617
|
+
const contextAgent = useAgentRegistry();
|
|
618
|
+
const registry = agent === void 0 ? contextAgent : agent;
|
|
619
|
+
const [messages, setMessages] = useState(initialMessages);
|
|
620
|
+
const [busy, setBusy] = useState(false);
|
|
621
|
+
const [approvals, setApprovals] = useState([]);
|
|
622
|
+
const run = useRef(null);
|
|
623
|
+
const latest = useRef({
|
|
624
|
+
messages,
|
|
625
|
+
transport,
|
|
626
|
+
conversationId,
|
|
627
|
+
onTitle,
|
|
628
|
+
registry
|
|
629
|
+
});
|
|
630
|
+
latest.current = {
|
|
631
|
+
messages,
|
|
632
|
+
transport,
|
|
633
|
+
conversationId,
|
|
634
|
+
onTitle,
|
|
635
|
+
registry
|
|
636
|
+
};
|
|
637
|
+
const loop = useRef({
|
|
638
|
+
options: {},
|
|
639
|
+
step: 0
|
|
640
|
+
});
|
|
641
|
+
const reported = useRef(null);
|
|
642
|
+
reported.current ??= new Set(initialMessages.flatMap((m) => m.parts.map((p) => p.id)));
|
|
643
|
+
const seen = useRef(/* @__PURE__ */ new Map());
|
|
644
|
+
const report = useCallback((message) => {
|
|
645
|
+
for (const part of message.parts) {
|
|
646
|
+
if (part.type !== "tool_call" || reported.current.has(part.id)) continue;
|
|
647
|
+
const tool = latest.current.registry?.tool(part.name);
|
|
648
|
+
try {
|
|
649
|
+
if (seen.current.get(part.id) !== part) {
|
|
650
|
+
seen.current.set(part.id, part);
|
|
651
|
+
tool?.onCall?.(part);
|
|
652
|
+
}
|
|
653
|
+
if (part.status === "done" || part.status === "error") {
|
|
654
|
+
reported.current.add(part.id);
|
|
655
|
+
seen.current.delete(part.id);
|
|
656
|
+
tool?.onResult?.(part);
|
|
657
|
+
}
|
|
658
|
+
} catch (error) {
|
|
659
|
+
console.error(`Tool callback for ${part.name} failed`, error);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}, []);
|
|
663
|
+
/** Puts a changed message into the list and the ref, so the next step sees it at once. */
|
|
664
|
+
const commit = useCallback((message) => {
|
|
665
|
+
const list = latest.current.messages;
|
|
666
|
+
const index = list.findIndex((m) => m.id === message.id);
|
|
667
|
+
const next = index === -1 ? [...list, message] : list.with(index, message);
|
|
668
|
+
latest.current.messages = next;
|
|
669
|
+
setMessages(next);
|
|
670
|
+
report(message);
|
|
671
|
+
}, [report]);
|
|
672
|
+
const finish = useCallback((reply, status, error) => {
|
|
673
|
+
if (!reply || reply.status !== "streaming") return;
|
|
674
|
+
const ended = applyEvent(reply, {
|
|
675
|
+
type: "run.end",
|
|
676
|
+
runId: reply.runId ?? "",
|
|
677
|
+
seq: (reply.lastSeq ?? 0) + 1,
|
|
678
|
+
status,
|
|
679
|
+
...error !== void 0 && { error }
|
|
680
|
+
});
|
|
681
|
+
if (ended) commit(ended);
|
|
682
|
+
}, [commit]);
|
|
683
|
+
const advanceRef = useRef(() => {});
|
|
684
|
+
const start = useCallback((history, options, continuation) => {
|
|
685
|
+
const message = history.at(-1);
|
|
686
|
+
const controller = new AbortController();
|
|
687
|
+
const current = {
|
|
688
|
+
controller,
|
|
689
|
+
...continuation && { reply: continuation }
|
|
690
|
+
};
|
|
691
|
+
run.current = current;
|
|
692
|
+
latest.current.messages = history;
|
|
693
|
+
setMessages(history);
|
|
694
|
+
setBusy(true);
|
|
695
|
+
let followUp = false;
|
|
696
|
+
(async () => {
|
|
697
|
+
try {
|
|
698
|
+
const { transport, conversationId, registry } = latest.current;
|
|
699
|
+
const tools = registry?.specs();
|
|
700
|
+
for await (const event of transport.send({
|
|
701
|
+
message,
|
|
702
|
+
history,
|
|
703
|
+
signal: controller.signal,
|
|
704
|
+
...continuation && { continuation: true },
|
|
705
|
+
...tools?.length && { tools },
|
|
706
|
+
...registry && { context: registry.context() },
|
|
707
|
+
...conversationId !== void 0 && { conversationId },
|
|
708
|
+
...options.model !== void 0 && { model: options.model },
|
|
709
|
+
...options.effort !== void 0 && { effort: options.effort }
|
|
710
|
+
})) {
|
|
711
|
+
if (controller.signal.aborted) break;
|
|
712
|
+
if (event.type === "title") {
|
|
713
|
+
latest.current.onTitle?.(event.title);
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
const next = applyEvent(current.reply, event);
|
|
717
|
+
if (!next || next === current.reply) continue;
|
|
718
|
+
current.reply = next;
|
|
719
|
+
current.runId = next.runId;
|
|
720
|
+
commit(next);
|
|
721
|
+
}
|
|
722
|
+
if (controller.signal.aborted) finish(current.reply, "stopped");
|
|
723
|
+
else if (current.reply?.status === "done" && awaitingToolCalls(current.reply).length > 0) followUp = true;
|
|
724
|
+
} catch (error) {
|
|
725
|
+
if (controller.signal.aborted) finish(current.reply, "stopped");
|
|
726
|
+
else finish(current.reply, "error", error instanceof Error ? error.message : String(error));
|
|
727
|
+
} finally {
|
|
728
|
+
if (run.current === current) {
|
|
729
|
+
run.current = null;
|
|
730
|
+
if (followUp) advanceRef.current(current.reply);
|
|
731
|
+
else setBusy(false);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
})();
|
|
735
|
+
}, [commit, finish]);
|
|
736
|
+
/** After a run ends with awaiting calls: ask, or run the client tools and continue. */
|
|
737
|
+
const advance = useCallback((reply) => {
|
|
738
|
+
const { registry } = latest.current;
|
|
739
|
+
const awaiting = awaitingToolCalls(reply);
|
|
740
|
+
const ask = awaiting.filter((p) => wantsApproval(p, registry?.tool(p.name)));
|
|
741
|
+
if (ask.length > 0) {
|
|
742
|
+
setApprovals(ask.map((part) => ({
|
|
743
|
+
messageId: reply.id,
|
|
744
|
+
part
|
|
745
|
+
})));
|
|
746
|
+
setBusy(false);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const controller = new AbortController();
|
|
750
|
+
const current = {
|
|
751
|
+
controller,
|
|
752
|
+
reply
|
|
753
|
+
};
|
|
754
|
+
run.current = current;
|
|
755
|
+
setBusy(true);
|
|
756
|
+
(async () => {
|
|
757
|
+
let message = reply;
|
|
758
|
+
const set = (part, fields) => {
|
|
759
|
+
message = settleToolCall(message, part.id, fields);
|
|
760
|
+
current.reply = message;
|
|
761
|
+
commit(message);
|
|
762
|
+
};
|
|
763
|
+
for (const part of awaiting) {
|
|
764
|
+
if (controller.signal.aborted) break;
|
|
765
|
+
if (part.approval === "denied") set(part, {
|
|
766
|
+
status: "error",
|
|
767
|
+
error: DECLINED
|
|
768
|
+
});
|
|
769
|
+
else if (part.runsOn === "client") {
|
|
770
|
+
const tool = latest.current.registry?.tool(part.name);
|
|
771
|
+
if (!tool?.execute) {
|
|
772
|
+
set(part, {
|
|
773
|
+
status: "error",
|
|
774
|
+
error: `This page has no handler for ${part.name}`
|
|
775
|
+
});
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
set(part, { status: "running" });
|
|
779
|
+
const began = performance.now();
|
|
780
|
+
try {
|
|
781
|
+
set(part, {
|
|
782
|
+
status: "done",
|
|
783
|
+
result: await tool.execute(part.input, {
|
|
784
|
+
callId: part.id,
|
|
785
|
+
messageId: message.id,
|
|
786
|
+
signal: controller.signal
|
|
787
|
+
}),
|
|
788
|
+
durationMs: Math.round(performance.now() - began)
|
|
789
|
+
});
|
|
790
|
+
} catch (error) {
|
|
791
|
+
set(part, {
|
|
792
|
+
status: "error",
|
|
793
|
+
error: error instanceof Error ? error.message : String(error)
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
} else set(part, { status: "running" });
|
|
797
|
+
}
|
|
798
|
+
if (run.current !== current) return;
|
|
799
|
+
run.current = null;
|
|
800
|
+
if (controller.signal.aborted) {
|
|
801
|
+
for (const part of awaitingToolCalls(message).concat(message.parts.filter((p) => p.type === "tool_call" && p.status === "running"))) set(part, {
|
|
802
|
+
status: "error",
|
|
803
|
+
error: "Stopped"
|
|
804
|
+
});
|
|
805
|
+
setBusy(false);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
if (loop.current.step >= maxSteps) {
|
|
809
|
+
setBusy(false);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
loop.current.step++;
|
|
813
|
+
const list = latest.current.messages;
|
|
814
|
+
start(list.slice(0, list.findIndex((m) => m.id === message.id) + 1), loop.current.options, message);
|
|
815
|
+
})();
|
|
816
|
+
}, [
|
|
817
|
+
commit,
|
|
818
|
+
maxSteps,
|
|
819
|
+
start
|
|
820
|
+
]);
|
|
821
|
+
advanceRef.current = advance;
|
|
822
|
+
const respond = useCallback((partId, granted) => {
|
|
823
|
+
const pending = approvals.find((a) => a.part.id === partId);
|
|
824
|
+
if (!pending) return;
|
|
825
|
+
const message = latest.current.messages.find((m) => m.id === pending.messageId);
|
|
826
|
+
if (!message) return;
|
|
827
|
+
const next = settleToolCall(message, partId, { approval: granted ? "granted" : "denied" });
|
|
828
|
+
commit(next);
|
|
829
|
+
const rest = approvals.filter((a) => a.part.id !== partId);
|
|
830
|
+
setApprovals(rest);
|
|
831
|
+
if (rest.length === 0) advance(next);
|
|
832
|
+
}, [
|
|
833
|
+
approvals,
|
|
834
|
+
advance,
|
|
835
|
+
commit
|
|
836
|
+
]);
|
|
837
|
+
/** Declines whatever is waiting, e.g. because the user moved on. */
|
|
838
|
+
const declineAll = useCallback(() => {
|
|
839
|
+
for (const { messageId, part } of approvals) {
|
|
840
|
+
const message = latest.current.messages.find((m) => m.id === messageId);
|
|
841
|
+
if (message) commit(settleToolCall(message, part.id, {
|
|
842
|
+
approval: "denied",
|
|
843
|
+
status: "error",
|
|
844
|
+
error: DECLINED
|
|
845
|
+
}));
|
|
846
|
+
}
|
|
847
|
+
for (const message of latest.current.messages) for (const part of awaitingToolCalls(message)) commit(settleToolCall(message, part.id, {
|
|
848
|
+
status: "error",
|
|
849
|
+
error: DECLINED
|
|
850
|
+
}));
|
|
851
|
+
setApprovals([]);
|
|
852
|
+
}, [approvals, commit]);
|
|
853
|
+
const send = useCallback((text, options = {}) => {
|
|
854
|
+
const attachments = options.attachments ?? [];
|
|
855
|
+
if (run.current || !text.trim() && attachments.length === 0) return;
|
|
856
|
+
declineAll();
|
|
857
|
+
loop.current = {
|
|
858
|
+
options,
|
|
859
|
+
step: 0
|
|
860
|
+
};
|
|
861
|
+
const { messages } = latest.current;
|
|
862
|
+
start([...messages, userMessage(text, messages.at(-1)?.id ?? null, attachments)], options);
|
|
863
|
+
}, [declineAll, start]);
|
|
864
|
+
const stop = useCallback(() => {
|
|
865
|
+
const current = run.current;
|
|
866
|
+
if (!current) return declineAll();
|
|
867
|
+
current.controller.abort();
|
|
868
|
+
if (current.runId) latest.current.transport.stop?.(current.runId);
|
|
869
|
+
}, [declineAll]);
|
|
870
|
+
const retry = useCallback((assistantId, options = {}) => {
|
|
871
|
+
if (run.current) return;
|
|
872
|
+
const { messages } = latest.current;
|
|
873
|
+
const index = messages.findIndex((m) => m.id === assistantId);
|
|
874
|
+
if (index < 1 || messages[index - 1].role !== "user") return;
|
|
875
|
+
setApprovals([]);
|
|
876
|
+
loop.current = {
|
|
877
|
+
options,
|
|
878
|
+
step: 0
|
|
879
|
+
};
|
|
880
|
+
start(messages.slice(0, index), options);
|
|
881
|
+
}, [start]);
|
|
882
|
+
useEffect(() => () => run.current?.controller.abort(), []);
|
|
883
|
+
return {
|
|
884
|
+
messages,
|
|
885
|
+
busy,
|
|
886
|
+
approvals,
|
|
887
|
+
respond,
|
|
888
|
+
send,
|
|
889
|
+
stop,
|
|
890
|
+
retry
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
// src/hooks/sse-transport.ts
|
|
894
|
+
/** A `Transport` for backends that stream `StreamEvent`s over server-sent events (see `formatEvent`). */
|
|
895
|
+
function sseTransport({ url, headers, stopUrl, fetch: fetchImpl = fetch }) {
|
|
896
|
+
const headersFor = async () => ({
|
|
897
|
+
"content-type": "application/json",
|
|
898
|
+
...Object.fromEntries(new Headers(typeof headers === "function" ? await headers() : headers))
|
|
899
|
+
});
|
|
900
|
+
return {
|
|
901
|
+
async *send({ signal, ...body }) {
|
|
902
|
+
const response = await fetchImpl(url, {
|
|
903
|
+
method: "POST",
|
|
904
|
+
headers: await headersFor(),
|
|
905
|
+
body: JSON.stringify(body),
|
|
906
|
+
signal
|
|
907
|
+
});
|
|
908
|
+
if (!response.ok || !response.body) throw new Error(`${url} answered ${response.status} ${response.statusText}`.trim());
|
|
909
|
+
yield* readEventStream(response.body);
|
|
910
|
+
},
|
|
911
|
+
...stopUrl && { async stop(runId) {
|
|
912
|
+
await fetchImpl(stopUrl, {
|
|
913
|
+
method: "POST",
|
|
914
|
+
headers: await headersFor(),
|
|
915
|
+
body: JSON.stringify({ runId })
|
|
916
|
+
});
|
|
917
|
+
} }
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
// src/hooks/smooth-text.ts
|
|
921
|
+
/** Slowest reveal, in characters per second, so a trickle of tokens still reads as steady typing. */
|
|
922
|
+
const MIN_RATE = 45;
|
|
923
|
+
/** How many seconds of backlog the reveal aims to hold; bigger bursts speed it up. */
|
|
924
|
+
const LAG_SECONDS = .35;
|
|
925
|
+
/** Once the stream ends, the rest is revealed at least this fast. */
|
|
926
|
+
const FINISH_RATE = 600;
|
|
927
|
+
/** Text is released in groups this far apart, so each group of words fades in together. */
|
|
928
|
+
const CHUNK_MS = 90;
|
|
929
|
+
/**
|
|
930
|
+
* Paces streamed text so it appears at an even rate instead of in network-sized bursts. The reveal speed
|
|
931
|
+
* follows the backlog: it never falls far behind and never stalls. It stops at word ends so a word fades
|
|
932
|
+
* in whole. When `streaming` ends the rest catches up quickly. With reduced motion it returns `text`.
|
|
933
|
+
*
|
|
934
|
+
* One animation loop runs while there is text left to show; new text only moves its target, so frequent
|
|
935
|
+
* small deltas do not restart it. Text is released in groups every CHUNK_MS rather than every frame.
|
|
936
|
+
*/
|
|
937
|
+
function useSmoothText(text, streaming) {
|
|
938
|
+
const [shown, setShown] = useState(() => streaming ? "" : text);
|
|
939
|
+
const state = useRef({
|
|
940
|
+
shown,
|
|
941
|
+
target: text,
|
|
942
|
+
streaming,
|
|
943
|
+
frame: 0,
|
|
944
|
+
last: 0,
|
|
945
|
+
carry: 0,
|
|
946
|
+
lastCommit: 0
|
|
947
|
+
});
|
|
948
|
+
const prefersReducedMotion = useReducedMotion();
|
|
949
|
+
const s = state.current;
|
|
950
|
+
s.target = text;
|
|
951
|
+
s.streaming = streaming;
|
|
952
|
+
useEffect(() => {
|
|
953
|
+
if (prefersReducedMotion() || !text.startsWith(s.shown)) {
|
|
954
|
+
cancelAnimationFrame(s.frame);
|
|
955
|
+
s.frame = 0;
|
|
956
|
+
s.shown = text;
|
|
957
|
+
setShown(text);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
if (s.frame || s.shown.length >= text.length) return;
|
|
961
|
+
s.last = performance.now();
|
|
962
|
+
const step = (now) => {
|
|
963
|
+
const dt = Math.min(Math.max(now - s.last, 0), 100) / 1e3;
|
|
964
|
+
s.last = now;
|
|
965
|
+
const full = s.target;
|
|
966
|
+
const backlog = full.length - s.shown.length;
|
|
967
|
+
const rate = s.streaming ? Math.max(MIN_RATE, backlog / LAG_SECONDS) : Math.max(FINISH_RATE, backlog * 4);
|
|
968
|
+
s.carry += rate * dt;
|
|
969
|
+
const advance = Math.floor(s.carry);
|
|
970
|
+
if (advance > 0 && now - s.lastCommit >= CHUNK_MS) {
|
|
971
|
+
s.lastCommit = now;
|
|
972
|
+
s.carry -= advance;
|
|
973
|
+
s.shown = full.slice(0, wordEnd(full, Math.min(s.shown.length + advance, full.length)));
|
|
974
|
+
setShown(s.shown);
|
|
975
|
+
}
|
|
976
|
+
s.frame = s.shown.length < full.length ? requestAnimationFrame(step) : 0;
|
|
977
|
+
};
|
|
978
|
+
s.frame = requestAnimationFrame(step);
|
|
979
|
+
}, [
|
|
980
|
+
text,
|
|
981
|
+
streaming,
|
|
982
|
+
s,
|
|
983
|
+
prefersReducedMotion
|
|
984
|
+
]);
|
|
985
|
+
useEffect(() => () => cancelAnimationFrame(s.frame), [s]);
|
|
986
|
+
return shown;
|
|
987
|
+
}
|
|
988
|
+
/** Moves `index` forward to the end of the word it falls in, so words are never cut in half. */
|
|
989
|
+
function wordEnd(text, index) {
|
|
990
|
+
let i = index;
|
|
991
|
+
while (i < text.length && !/\s/.test(text[i])) i++;
|
|
992
|
+
return i;
|
|
993
|
+
}
|
|
994
|
+
// src/hooks/sidebar-state.ts
|
|
995
|
+
/** Below this width the sidebar is a drawer over the content (phones). */
|
|
996
|
+
const DRAWER_QUERY = "(max-width: 767.98px)";
|
|
997
|
+
function useMediaQuery(query) {
|
|
998
|
+
return useSyncExternalStore((notify) => {
|
|
999
|
+
const list = window.matchMedia(query);
|
|
1000
|
+
list.addEventListener("change", notify);
|
|
1001
|
+
return () => list.removeEventListener("change", notify);
|
|
1002
|
+
}, () => window.matchMedia(query).matches, () => false);
|
|
1003
|
+
}
|
|
1004
|
+
const SidebarContext = createContext({
|
|
1005
|
+
collapsed: false,
|
|
1006
|
+
drawer: false,
|
|
1007
|
+
drawerOpen: false,
|
|
1008
|
+
toggle: () => {},
|
|
1009
|
+
dismiss: () => {}
|
|
1010
|
+
});
|
|
1011
|
+
const useSidebar = () => useContext(SidebarContext);
|
|
1012
|
+
// src/components/word-fade.ts
|
|
1013
|
+
const OPAQUE_TAGS = /* @__PURE__ */ new Set([
|
|
1014
|
+
"code",
|
|
1015
|
+
"pre",
|
|
1016
|
+
"svg",
|
|
1017
|
+
"math",
|
|
1018
|
+
"script",
|
|
1019
|
+
"style"
|
|
1020
|
+
]);
|
|
1021
|
+
function classes(node) {
|
|
1022
|
+
const value = node.properties?.className;
|
|
1023
|
+
if (Array.isArray(value)) return value.map(String);
|
|
1024
|
+
return typeof value === "string" ? value.split(/\s+/) : [];
|
|
1025
|
+
}
|
|
1026
|
+
function isMath(node) {
|
|
1027
|
+
return classes(node).some((c) => c === "katex" || c === "katex-display" || c.startsWith("math-") || c === "language-math");
|
|
1028
|
+
}
|
|
1029
|
+
function wordSpan(word) {
|
|
1030
|
+
return {
|
|
1031
|
+
type: "element",
|
|
1032
|
+
tagName: "span",
|
|
1033
|
+
properties: { className: ["chat-word"] },
|
|
1034
|
+
children: [{
|
|
1035
|
+
type: "text",
|
|
1036
|
+
value: word
|
|
1037
|
+
}]
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
function wrap(node) {
|
|
1041
|
+
if (!node.children) return;
|
|
1042
|
+
const out = [];
|
|
1043
|
+
for (const child of node.children) {
|
|
1044
|
+
if (child.type === "text") {
|
|
1045
|
+
for (const piece of (child.value ?? "").split(/(\s+)/)) {
|
|
1046
|
+
if (!piece) continue;
|
|
1047
|
+
out.push(/^\s+$/.test(piece) ? {
|
|
1048
|
+
type: "text",
|
|
1049
|
+
value: piece
|
|
1050
|
+
} : wordSpan(piece));
|
|
1051
|
+
}
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
if (child.type === "element") {
|
|
1055
|
+
if (isMath(child)) child.properties = {
|
|
1056
|
+
...child.properties,
|
|
1057
|
+
className: [...classes(child), "chat-word"]
|
|
1058
|
+
};
|
|
1059
|
+
else if (!OPAQUE_TAGS.has(child.tagName ?? "")) wrap(child);
|
|
1060
|
+
}
|
|
1061
|
+
out.push(child);
|
|
1062
|
+
}
|
|
1063
|
+
node.children = out;
|
|
1064
|
+
}
|
|
1065
|
+
function rehypeWordFade() {
|
|
1066
|
+
return (tree) => wrap(tree);
|
|
1067
|
+
}
|
|
1068
|
+
// src/components/markdown.tsx
|
|
1069
|
+
const fadePlugins = [...Object.values(defaultRehypePlugins), rehypeWordFade];
|
|
1070
|
+
const math = createMathPlugin({ singleDollarTextMath: true });
|
|
1071
|
+
/** Streamdown's own utility classes get this prefix, so neither they nor the host's classes apply. */
|
|
1072
|
+
const PREFIX = "llmchat";
|
|
1073
|
+
function textOf(node) {
|
|
1074
|
+
if (!node) return "";
|
|
1075
|
+
if (node.type === "text") return node.value ?? "";
|
|
1076
|
+
return (node.children ?? []).map(textOf).join("");
|
|
1077
|
+
}
|
|
1078
|
+
const components = {
|
|
1079
|
+
pre: ({ children }) => /* @__PURE__ */ jsx(Fragment, { children }),
|
|
1080
|
+
code: ({ node, className, children }) => {
|
|
1081
|
+
const hast = node;
|
|
1082
|
+
const language = /language-([\w+#-]+)/.exec(className ?? "")?.[1];
|
|
1083
|
+
const multiline = hast?.position && hast.position.start.line !== hast.position.end.line;
|
|
1084
|
+
if (!language && !multiline) return /* @__PURE__ */ jsx("code", {
|
|
1085
|
+
"data-chat-inline-code": true,
|
|
1086
|
+
children
|
|
1087
|
+
});
|
|
1088
|
+
return /* @__PURE__ */ jsx(CodeBlock, {
|
|
1089
|
+
variant: "boxed",
|
|
1090
|
+
code: textOf(hast).replace(/\n$/, ""),
|
|
1091
|
+
...language && { language }
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
};
|
|
1095
|
+
/** Message markdown: GFM, KaTeX math ($…$ and $$…$$), Shiki code blocks, smooth fade-in while streaming. */
|
|
1096
|
+
function Markdown({ children, streaming = false, className }) {
|
|
1097
|
+
const text = useSmoothText(children, streaming);
|
|
1098
|
+
const animating = streaming || text.length < children.length;
|
|
1099
|
+
const [streamed, setStreamed] = useState(streaming);
|
|
1100
|
+
if (streaming && !streamed) setStreamed(true);
|
|
1101
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1102
|
+
className: clsx("chat-markdown", streamed && "chat-fade", className),
|
|
1103
|
+
children: /* @__PURE__ */ jsx(MarkdownBody, {
|
|
1104
|
+
text,
|
|
1105
|
+
animating,
|
|
1106
|
+
fade: streamed
|
|
1107
|
+
})
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
const MarkdownBody = memo(function MarkdownBody({ text, animating, fade }) {
|
|
1111
|
+
return /* @__PURE__ */ jsx(Streamdown, {
|
|
1112
|
+
prefix: PREFIX,
|
|
1113
|
+
mode: animating || fade ? "streaming" : "static",
|
|
1114
|
+
...fade && { rehypePlugins: fadePlugins },
|
|
1115
|
+
plugins: { math },
|
|
1116
|
+
components,
|
|
1117
|
+
controls: false,
|
|
1118
|
+
lineNumbers: false,
|
|
1119
|
+
linkSafety: { enabled: false },
|
|
1120
|
+
children: text
|
|
1121
|
+
});
|
|
1122
|
+
});
|
|
1123
|
+
// src/components/tools.tsx
|
|
1124
|
+
const ToolViewsContext = createContext(void 0);
|
|
1125
|
+
function ToolViewsProvider({ tools, children }) {
|
|
1126
|
+
return /* @__PURE__ */ jsx(ToolViewsContext.Provider, {
|
|
1127
|
+
value: tools,
|
|
1128
|
+
children
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
function useToolView(name) {
|
|
1132
|
+
return useToolViews()[name] ?? {};
|
|
1133
|
+
}
|
|
1134
|
+
/** The provider's views, plus views brought by tools the page registered (`ChatTool.view`). */
|
|
1135
|
+
function useToolViews() {
|
|
1136
|
+
const base = useContext(ToolViewsContext) ?? defaultToolViews;
|
|
1137
|
+
const registry = useAgentSnapshot();
|
|
1138
|
+
const version = registry?.getVersion() ?? 0;
|
|
1139
|
+
return useMemo(() => {
|
|
1140
|
+
const own = registry?.list().filter((t) => t.view !== void 0) ?? [];
|
|
1141
|
+
return own.length ? {
|
|
1142
|
+
...base,
|
|
1143
|
+
...Object.fromEntries(own.map((t) => [t.name, t.view]))
|
|
1144
|
+
} : base;
|
|
1145
|
+
}, [
|
|
1146
|
+
base,
|
|
1147
|
+
registry,
|
|
1148
|
+
version
|
|
1149
|
+
]);
|
|
1150
|
+
}
|
|
1151
|
+
/** `useAgentTool` for tools that bring a view. */
|
|
1152
|
+
const useChatTool = useAgentTool;
|
|
1153
|
+
/** Streaming or running: the model or a tool is busy with it. */
|
|
1154
|
+
const isWorking = (part) => part.status !== "done" && part.status !== "error" && part.status !== "awaiting";
|
|
1155
|
+
/** Row label for any activity part. */
|
|
1156
|
+
function activityLabel(part, view) {
|
|
1157
|
+
if (part.type === "thinking") {
|
|
1158
|
+
if (part.title) return part.title;
|
|
1159
|
+
if (part.status !== "done") return "Thinking…";
|
|
1160
|
+
return part.durationMs ? `Thought for ${formatDuration(part.durationMs)}` : "Thought";
|
|
1161
|
+
}
|
|
1162
|
+
return view?.label?.(part) ?? part.title ?? part.name;
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Heading of an activity group. While live it follows the latest step. Once done it lists what the tools
|
|
1166
|
+
* did, in order ("Ran a command, searched the web"); a group of only thinking shows the first title.
|
|
1167
|
+
*/
|
|
1168
|
+
function activityHeading(parts, views) {
|
|
1169
|
+
const waiting = parts.find((p) => p.type === "tool_call" && p.status === "awaiting");
|
|
1170
|
+
if (waiting) return /* @__PURE__ */ jsxs(Fragment, { children: [activityLabel(waiting, views[waiting.name]), " · waiting for you"] });
|
|
1171
|
+
if (parts.some(isWorking)) {
|
|
1172
|
+
const last = parts.at(-1);
|
|
1173
|
+
return activityLabel(last, last.type === "tool_call" ? views[last.name] : void 0);
|
|
1174
|
+
}
|
|
1175
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1176
|
+
for (const part of parts) if (part.type === "tool_call") counts.set(part.name, (counts.get(part.name) ?? 0) + 1);
|
|
1177
|
+
if (counts.size === 0) return parts[0] ? activityLabel(parts[0]) : "";
|
|
1178
|
+
const text = [...counts].map(([name, count]) => views[name]?.summary?.(count) ?? `used ${name}`).join(", ");
|
|
1179
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
1180
|
+
}
|
|
1181
|
+
const plural = (count, one, many) => count === 1 ? one : many(count);
|
|
1182
|
+
function recordOf(value) {
|
|
1183
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
1184
|
+
}
|
|
1185
|
+
function stringOf(value) {
|
|
1186
|
+
return typeof value === "string" ? value : void 0;
|
|
1187
|
+
}
|
|
1188
|
+
/** A shell command. Input `{ command, description? }`; result a string or `{ stdout, stderr, exitCode }`. */
|
|
1189
|
+
const commandTool = {
|
|
1190
|
+
label: () => "Ran a command",
|
|
1191
|
+
hint: (part) => stringOf(recordOf(part.input).description),
|
|
1192
|
+
summary: (n) => plural(n, "ran a command", (n) => `ran ${n} commands`),
|
|
1193
|
+
Detail: function CommandDetail({ part }) {
|
|
1194
|
+
const command = stringOf(recordOf(part.input).command) ?? part.inputJson;
|
|
1195
|
+
const result = recordOf(part.result);
|
|
1196
|
+
const output = stringOf(part.result) ?? [stringOf(result.stdout), stringOf(result.stderr)].filter(Boolean).join("\n");
|
|
1197
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1198
|
+
className: "space-y-4",
|
|
1199
|
+
children: [
|
|
1200
|
+
/* @__PURE__ */ jsx(CodeBlock, {
|
|
1201
|
+
label: "bash",
|
|
1202
|
+
code: command
|
|
1203
|
+
}),
|
|
1204
|
+
(output || part.status === "done") && /* @__PURE__ */ jsx(CodeBlock, {
|
|
1205
|
+
label: "Output",
|
|
1206
|
+
code: output || "(no output)"
|
|
1207
|
+
}),
|
|
1208
|
+
part.error && /* @__PURE__ */ jsx("p", {
|
|
1209
|
+
className: "text-sm text-chat-danger",
|
|
1210
|
+
children: part.error
|
|
1211
|
+
})
|
|
1212
|
+
]
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
};
|
|
1216
|
+
/** A web search. Input `{ query }`; result `{ results: SearchResult[] }` or `SearchResult[]`. */
|
|
1217
|
+
const webSearchTool = {
|
|
1218
|
+
label: () => "Searched the web",
|
|
1219
|
+
subtitle: (part) => stringOf(recordOf(part.input).query) ?? part.subtitle,
|
|
1220
|
+
summary: (n) => plural(n, "searched the web", (n) => `searched the web ${n} times`),
|
|
1221
|
+
Detail: function WebSearchDetail({ part }) {
|
|
1222
|
+
const results = Array.isArray(part.result) ? part.result : recordOf(part.result).results;
|
|
1223
|
+
if (part.error) return /* @__PURE__ */ jsx("p", {
|
|
1224
|
+
className: "text-sm text-chat-danger",
|
|
1225
|
+
children: part.error
|
|
1226
|
+
});
|
|
1227
|
+
if (!results?.length) return /* @__PURE__ */ jsx("p", {
|
|
1228
|
+
className: "text-sm text-chat-muted",
|
|
1229
|
+
children: "No results"
|
|
1230
|
+
});
|
|
1231
|
+
return /* @__PURE__ */ jsx("ul", {
|
|
1232
|
+
className: "-mx-1",
|
|
1233
|
+
children: results.map((result) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("a", {
|
|
1234
|
+
href: result.url,
|
|
1235
|
+
target: "_blank",
|
|
1236
|
+
rel: "noreferrer",
|
|
1237
|
+
className: "flex items-center gap-2.5 rounded-chat-sm px-1 py-2 text-[0.9375rem] text-chat-muted outline-none hover:text-chat-fg focus-visible:ring-2 focus-visible:ring-chat-focus",
|
|
1238
|
+
children: [
|
|
1239
|
+
result.favicon ? /* @__PURE__ */ jsx("img", {
|
|
1240
|
+
src: result.favicon,
|
|
1241
|
+
alt: "",
|
|
1242
|
+
className: "size-4 shrink-0 rounded-sm"
|
|
1243
|
+
}) : /* @__PURE__ */ jsx(Globe, {
|
|
1244
|
+
"aria-hidden": true,
|
|
1245
|
+
className: "size-4 shrink-0"
|
|
1246
|
+
}),
|
|
1247
|
+
/* @__PURE__ */ jsx("span", {
|
|
1248
|
+
className: "min-w-0 flex-1 truncate",
|
|
1249
|
+
children: result.title
|
|
1250
|
+
}),
|
|
1251
|
+
/* @__PURE__ */ jsx("span", {
|
|
1252
|
+
className: "shrink-0 text-sm",
|
|
1253
|
+
children: hostOf(result.url)
|
|
1254
|
+
})
|
|
1255
|
+
]
|
|
1256
|
+
}) }, result.url))
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
};
|
|
1260
|
+
/** Memory lookups, e.g. Anthropic's memory tool. Input `{ path }`. */
|
|
1261
|
+
const memoryTool = {
|
|
1262
|
+
label: () => "Recalled memory",
|
|
1263
|
+
subtitle: (part) => {
|
|
1264
|
+
return stringOf(recordOf(part.input).path)?.split("/").at(-1) ?? part.subtitle;
|
|
1265
|
+
},
|
|
1266
|
+
summary: () => "recalled memory"
|
|
1267
|
+
};
|
|
1268
|
+
const defaultToolViews = {
|
|
1269
|
+
bash: commandTool,
|
|
1270
|
+
shell: commandTool,
|
|
1271
|
+
web_search: webSearchTool,
|
|
1272
|
+
memory: memoryTool,
|
|
1273
|
+
memory_view: memoryTool
|
|
1274
|
+
};
|
|
1275
|
+
function hostOf(url) {
|
|
1276
|
+
try {
|
|
1277
|
+
return new URL(url).host;
|
|
1278
|
+
} catch {
|
|
1279
|
+
return url;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
/** "9s", "1m 5s". */
|
|
1283
|
+
function formatDuration(ms) {
|
|
1284
|
+
const seconds = Math.max(1, Math.round(ms / 1e3));
|
|
1285
|
+
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
1286
|
+
}
|
|
1287
|
+
// src/components/token-counter.tsx
|
|
1288
|
+
/** Compact token count: 526, 1.4k, 33k, 76.8k, 1M. */
|
|
1289
|
+
function formatTokens(n) {
|
|
1290
|
+
if (n < 1e3) return String(Math.round(n));
|
|
1291
|
+
if (n < 1e6) return `${trim(n / 1e3)}k`;
|
|
1292
|
+
return `${trim(n / 1e6)}M`;
|
|
1293
|
+
}
|
|
1294
|
+
function trim(value) {
|
|
1295
|
+
return (Math.round(value * 10) / 10).toString();
|
|
1296
|
+
}
|
|
1297
|
+
/**
|
|
1298
|
+
* Follows `target` with a number that counts up smoothly instead of jumping. Each frame closes part of the
|
|
1299
|
+
* gap, so bursts of tokens roll in rather than land at once. Jumps straight there with reduced motion.
|
|
1300
|
+
*/
|
|
1301
|
+
function useCountUp(target) {
|
|
1302
|
+
const [value, setValue] = useState(target);
|
|
1303
|
+
const valueRef = useRef(target);
|
|
1304
|
+
const prefersReducedMotion = useReducedMotion();
|
|
1305
|
+
useEffect(() => {
|
|
1306
|
+
if (prefersReducedMotion()) {
|
|
1307
|
+
valueRef.current = target;
|
|
1308
|
+
setValue(target);
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
let frame = 0;
|
|
1312
|
+
let last = performance.now();
|
|
1313
|
+
const step = (now) => {
|
|
1314
|
+
const dt = Math.min(Math.max(now - last, 0), 100);
|
|
1315
|
+
last = now;
|
|
1316
|
+
const gap = target - valueRef.current;
|
|
1317
|
+
const move = Math.min(Math.max(Math.abs(gap) * (1 - Math.pow(.1, dt / 250)), 1), Math.abs(gap));
|
|
1318
|
+
const next = Math.abs(gap) < 1 ? target : valueRef.current + Math.sign(gap) * move;
|
|
1319
|
+
valueRef.current = next;
|
|
1320
|
+
setValue(next);
|
|
1321
|
+
if (next !== target) frame = requestAnimationFrame(step);
|
|
1322
|
+
};
|
|
1323
|
+
frame = requestAnimationFrame(step);
|
|
1324
|
+
return () => cancelAnimationFrame(frame);
|
|
1325
|
+
}, [target, prefersReducedMotion]);
|
|
1326
|
+
return Math.round(value);
|
|
1327
|
+
}
|
|
1328
|
+
/** A token count that counts up as `tokens` grows and holds still when it stops growing. */
|
|
1329
|
+
function TokenCounter({ tokens, variant = "standalone", className }) {
|
|
1330
|
+
const shown = useCountUp(tokens);
|
|
1331
|
+
if (variant === "inline") return /* @__PURE__ */ jsxs("span", {
|
|
1332
|
+
className: clsx("text-chat-subtle tabular-nums", className),
|
|
1333
|
+
children: [
|
|
1334
|
+
"(",
|
|
1335
|
+
formatTokens(shown),
|
|
1336
|
+
" tokens)"
|
|
1337
|
+
]
|
|
1338
|
+
});
|
|
1339
|
+
return /* @__PURE__ */ jsxs("span", {
|
|
1340
|
+
className: clsx("inline-flex items-center gap-1 font-chat-sans text-xs text-chat-subtle tabular-nums", className),
|
|
1341
|
+
children: [
|
|
1342
|
+
/* @__PURE__ */ jsx(ArrowDown, {
|
|
1343
|
+
"aria-hidden": true,
|
|
1344
|
+
className: "size-3"
|
|
1345
|
+
}),
|
|
1346
|
+
formatTokens(shown),
|
|
1347
|
+
" tokens"
|
|
1348
|
+
]
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
// src/components/activity-group.tsx
|
|
1352
|
+
/**
|
|
1353
|
+
* The "Ran a command, searched the web ▾" dropdown: a one-line heading that opens a bordered box with one
|
|
1354
|
+
* row per step. Each row expands on its own; tool rows render through the registered `ToolView`.
|
|
1355
|
+
* A group of one step skips the rows: the heading is the step's own label and the box holds its detail.
|
|
1356
|
+
*/
|
|
1357
|
+
function ActivityGroup({ parts, defaultExpanded = false, className }) {
|
|
1358
|
+
const views = useToolViews();
|
|
1359
|
+
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
1360
|
+
const live = parts.some(isWorking);
|
|
1361
|
+
const tokens = activityTokens(parts);
|
|
1362
|
+
const after = live && tokens > 0 && /* @__PURE__ */ jsx(TokenCounter, {
|
|
1363
|
+
variant: "inline",
|
|
1364
|
+
tokens,
|
|
1365
|
+
className: "ml-1.5"
|
|
1366
|
+
});
|
|
1367
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1368
|
+
className,
|
|
1369
|
+
children: parts.length === 1 ? /* @__PURE__ */ jsx(SingleStep, {
|
|
1370
|
+
part: parts[0],
|
|
1371
|
+
live,
|
|
1372
|
+
after,
|
|
1373
|
+
expanded,
|
|
1374
|
+
onExpandedChange: setExpanded
|
|
1375
|
+
}) : /* @__PURE__ */ jsx(Disclosure, {
|
|
1376
|
+
isExpanded: expanded,
|
|
1377
|
+
onExpandedChange: setExpanded,
|
|
1378
|
+
summary: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Live, {
|
|
1379
|
+
live,
|
|
1380
|
+
children: activityHeading(parts, views)
|
|
1381
|
+
}), after] }),
|
|
1382
|
+
summaryClassName: "text-[0.9375rem]",
|
|
1383
|
+
panelClassName: "pt-3",
|
|
1384
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
1385
|
+
className: "divide-y divide-chat-divider rounded-chat-md border border-chat-divider",
|
|
1386
|
+
children: parts.map((part) => /* @__PURE__ */ jsx(Step, { part }, part.id))
|
|
1387
|
+
})
|
|
1388
|
+
})
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
const SHIMMER_MS = 2e3;
|
|
1392
|
+
/**
|
|
1393
|
+
* Sweeps a highlight across a label while its step is running. The animation starts at the phase of a
|
|
1394
|
+
* shared clock, so every live label sweeps in step and a remount does not restart it.
|
|
1395
|
+
*/
|
|
1396
|
+
function Live({ live, children }) {
|
|
1397
|
+
return live ? /* @__PURE__ */ jsx(Shimmer, { children }) : /* @__PURE__ */ jsx(Fragment, { children });
|
|
1398
|
+
}
|
|
1399
|
+
function Shimmer({ children }) {
|
|
1400
|
+
const [delay] = useState(() => `-${Math.round(performance.now() % SHIMMER_MS)}ms`);
|
|
1401
|
+
return /* @__PURE__ */ jsx("span", {
|
|
1402
|
+
className: "chat-shimmer",
|
|
1403
|
+
style: {
|
|
1404
|
+
animationDelay: delay,
|
|
1405
|
+
["--chat-shimmer-duration"]: `${SHIMMER_MS}ms`
|
|
1406
|
+
},
|
|
1407
|
+
children
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
function SingleStep({ part, live, after, expanded, onExpandedChange }) {
|
|
1411
|
+
const { summary, detail } = useStepContent(part);
|
|
1412
|
+
return /* @__PURE__ */ jsx(Disclosure, {
|
|
1413
|
+
isExpanded: expanded,
|
|
1414
|
+
onExpandedChange,
|
|
1415
|
+
summary: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Live, {
|
|
1416
|
+
live,
|
|
1417
|
+
children: summary
|
|
1418
|
+
}), after] }),
|
|
1419
|
+
summaryClassName: "text-[0.9375rem]",
|
|
1420
|
+
panelClassName: "pt-3",
|
|
1421
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
1422
|
+
className: "rounded-chat-md border border-chat-divider px-3 py-2",
|
|
1423
|
+
children: detail
|
|
1424
|
+
})
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
function Step({ part }) {
|
|
1428
|
+
const { summary, detail } = useStepContent(part);
|
|
1429
|
+
const live = isWorking(part);
|
|
1430
|
+
return /* @__PURE__ */ jsx(Disclosure, {
|
|
1431
|
+
className: "px-3 py-2",
|
|
1432
|
+
summaryClassName: "text-[0.9375rem]",
|
|
1433
|
+
panelClassName: "pt-2 pb-1",
|
|
1434
|
+
summary: /* @__PURE__ */ jsx(Live, {
|
|
1435
|
+
live,
|
|
1436
|
+
children: summary
|
|
1437
|
+
}),
|
|
1438
|
+
children: detail
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
1441
|
+
/** The label line and expanded body of one step. Tool calls go through their registered view. */
|
|
1442
|
+
function useStepContent(part) {
|
|
1443
|
+
const view = useToolView(part.type === "tool_call" ? part.name : "");
|
|
1444
|
+
if (part.type === "thinking") return {
|
|
1445
|
+
summary: activityLabel(part),
|
|
1446
|
+
detail: part.visibility === "hidden" || !part.text ? /* @__PURE__ */ jsx("p", {
|
|
1447
|
+
className: "text-sm text-chat-subtle",
|
|
1448
|
+
children: "The model's reasoning is not shown."
|
|
1449
|
+
}) : /* @__PURE__ */ jsx("div", {
|
|
1450
|
+
className: "max-h-96 overflow-y-auto [scrollbar-width:thin]",
|
|
1451
|
+
children: /* @__PURE__ */ jsx(Markdown, {
|
|
1452
|
+
className: "chat-thinking",
|
|
1453
|
+
streaming: part.status === "streaming",
|
|
1454
|
+
children: part.text
|
|
1455
|
+
})
|
|
1456
|
+
})
|
|
1457
|
+
};
|
|
1458
|
+
const subtitle = view.subtitle ? view.subtitle(part) : part.subtitle;
|
|
1459
|
+
const Detail = view.Detail ?? DefaultDetail;
|
|
1460
|
+
return {
|
|
1461
|
+
summary: /* @__PURE__ */ jsxs("span", {
|
|
1462
|
+
title: view.hint?.(part),
|
|
1463
|
+
children: [activityLabel(part, view), subtitle && /* @__PURE__ */ jsx("span", {
|
|
1464
|
+
className: "ml-1.5 text-chat-fg",
|
|
1465
|
+
children: subtitle
|
|
1466
|
+
})]
|
|
1467
|
+
}),
|
|
1468
|
+
detail: /* @__PURE__ */ jsx(Detail, { part })
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
function DefaultDetail({ part }) {
|
|
1472
|
+
if (part.error) return /* @__PURE__ */ jsx("p", {
|
|
1473
|
+
className: "text-sm text-chat-danger",
|
|
1474
|
+
children: part.error
|
|
1475
|
+
});
|
|
1476
|
+
const body = part.result ?? part.input ?? part.inputJson;
|
|
1477
|
+
if (typeof body === "string") return /* @__PURE__ */ jsx("p", {
|
|
1478
|
+
className: "text-[0.9375rem] leading-relaxed whitespace-pre-wrap text-chat-muted",
|
|
1479
|
+
children: body
|
|
1480
|
+
});
|
|
1481
|
+
return /* @__PURE__ */ jsx("pre", {
|
|
1482
|
+
className: "max-h-56 overflow-auto font-chat-mono text-[0.8125rem] leading-relaxed text-chat-muted",
|
|
1483
|
+
children: JSON.stringify(body, null, 2)
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
// src/components/attachments.tsx
|
|
1487
|
+
let counter = 0;
|
|
1488
|
+
const draftId = () => `att_${Date.now().toString(36)}_${(counter++).toString(36)}`;
|
|
1489
|
+
/** Composer attachments: pasted text, images with thumbnails, files, each uploading on its own. */
|
|
1490
|
+
function useAttachments({ upload } = {}) {
|
|
1491
|
+
const [attachments, setAttachments] = useState([]);
|
|
1492
|
+
const uploads = useRef(/* @__PURE__ */ new Map());
|
|
1493
|
+
const uploadRef = useRef(upload);
|
|
1494
|
+
uploadRef.current = upload;
|
|
1495
|
+
const patch = useCallback((id, fields) => {
|
|
1496
|
+
setAttachments((list) => list.map((a) => a.id === id ? {
|
|
1497
|
+
...a,
|
|
1498
|
+
...fields
|
|
1499
|
+
} : a));
|
|
1500
|
+
}, []);
|
|
1501
|
+
const addFiles = useCallback((files) => {
|
|
1502
|
+
for (const file of files) {
|
|
1503
|
+
const id = draftId();
|
|
1504
|
+
const image = file.type.startsWith("image/");
|
|
1505
|
+
const doUpload = uploadRef.current;
|
|
1506
|
+
const draft = {
|
|
1507
|
+
id,
|
|
1508
|
+
kind: image ? "image" : "file",
|
|
1509
|
+
name: file.name,
|
|
1510
|
+
mime: file.type || "application/octet-stream",
|
|
1511
|
+
size: file.size,
|
|
1512
|
+
status: doUpload ? "uploading" : "ready",
|
|
1513
|
+
...image && { previewUrl: URL.createObjectURL(file) }
|
|
1514
|
+
};
|
|
1515
|
+
setAttachments((list) => [...list, draft]);
|
|
1516
|
+
if (!doUpload) continue;
|
|
1517
|
+
const controller = new AbortController();
|
|
1518
|
+
uploads.current.set(id, controller);
|
|
1519
|
+
doUpload(file, controller.signal).then((result) => patch(id, {
|
|
1520
|
+
status: "ready",
|
|
1521
|
+
fileId: result.fileId,
|
|
1522
|
+
...result.url && { url: result.url }
|
|
1523
|
+
})).catch((error) => {
|
|
1524
|
+
if (!controller.signal.aborted) patch(id, {
|
|
1525
|
+
status: "error",
|
|
1526
|
+
error: error instanceof Error ? error.message : "Upload failed"
|
|
1527
|
+
});
|
|
1528
|
+
}).finally(() => uploads.current.delete(id));
|
|
1529
|
+
}
|
|
1530
|
+
}, [patch]);
|
|
1531
|
+
const addText = useCallback((text) => {
|
|
1532
|
+
setAttachments((list) => [...list, {
|
|
1533
|
+
id: draftId(),
|
|
1534
|
+
kind: "text",
|
|
1535
|
+
name: "Pasted text",
|
|
1536
|
+
mime: "text/plain",
|
|
1537
|
+
size: new Blob([text]).size,
|
|
1538
|
+
text,
|
|
1539
|
+
status: "ready"
|
|
1540
|
+
}]);
|
|
1541
|
+
}, []);
|
|
1542
|
+
const updateText = useCallback((id, text) => patch(id, {
|
|
1543
|
+
text,
|
|
1544
|
+
size: new Blob([text]).size
|
|
1545
|
+
}), [patch]);
|
|
1546
|
+
const remove = useCallback((id) => {
|
|
1547
|
+
uploads.current.get(id)?.abort();
|
|
1548
|
+
setAttachments((list) => {
|
|
1549
|
+
const gone = list.find((a) => a.id === id);
|
|
1550
|
+
if (gone?.previewUrl) URL.revokeObjectURL(gone.previewUrl);
|
|
1551
|
+
return list.filter((a) => a.id !== id);
|
|
1552
|
+
});
|
|
1553
|
+
}, []);
|
|
1554
|
+
const clear = useCallback(() => setAttachments([]), []);
|
|
1555
|
+
const toParts = useCallback(() => attachments.filter((a) => a.status === "ready").map((a) => ({
|
|
1556
|
+
type: "attachment",
|
|
1557
|
+
id: a.id,
|
|
1558
|
+
status: "done",
|
|
1559
|
+
fileId: a.fileId ?? a.id,
|
|
1560
|
+
name: a.name,
|
|
1561
|
+
mime: a.mime,
|
|
1562
|
+
size: a.size,
|
|
1563
|
+
...(a.url ?? a.previewUrl) && { url: a.url ?? a.previewUrl },
|
|
1564
|
+
...a.text !== void 0 && { text: a.text }
|
|
1565
|
+
})), [attachments]);
|
|
1566
|
+
useEffect(() => () => uploads.current.forEach((c) => c.abort()), []);
|
|
1567
|
+
return {
|
|
1568
|
+
attachments,
|
|
1569
|
+
uploading: attachments.some((a) => a.status === "uploading"),
|
|
1570
|
+
addFiles,
|
|
1571
|
+
addText,
|
|
1572
|
+
updateText,
|
|
1573
|
+
remove,
|
|
1574
|
+
clear,
|
|
1575
|
+
toParts
|
|
1576
|
+
};
|
|
1577
|
+
}
|
|
1578
|
+
const extension = (name) => /\.([a-z0-9]{1,5})$/i.exec(name)?.[1]?.toUpperCase();
|
|
1579
|
+
/** A square card: pasted text preview with a PASTED badge, an image thumbnail, or a file with its type. */
|
|
1580
|
+
function AttachmentCard({ attachment: a, onRemove, onOpen, size = "md", className }) {
|
|
1581
|
+
const image = a.mime.startsWith("image/") && a.url;
|
|
1582
|
+
const pasted = a.text !== void 0;
|
|
1583
|
+
const uploading = a.status === "uploading";
|
|
1584
|
+
const failed = a.status === "error";
|
|
1585
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1586
|
+
className: clsx("group/card relative shrink-0 rounded-chat-md border bg-chat-bg font-chat-sans", failed ? "border-chat-danger" : "border-chat-border", size === "md" ? "size-30" : "size-24", className),
|
|
1587
|
+
title: failed ? a.error ?? "Upload failed" : a.name,
|
|
1588
|
+
children: [
|
|
1589
|
+
/* @__PURE__ */ jsx("div", {
|
|
1590
|
+
className: clsx("absolute inset-0 overflow-hidden rounded-[inherit]", uploading && "chat-shimmer-surface"),
|
|
1591
|
+
children: image ? /* @__PURE__ */ jsx("img", {
|
|
1592
|
+
src: a.url,
|
|
1593
|
+
alt: a.name,
|
|
1594
|
+
className: clsx("size-full object-cover", uploading && "opacity-60")
|
|
1595
|
+
}) : pasted ? /* @__PURE__ */ jsx("p", {
|
|
1596
|
+
className: "h-full overflow-hidden p-2.5 pb-9 text-[0.5625rem] leading-snug break-all text-chat-muted [mask-image:linear-gradient(to_bottom,black_45%,transparent_72%)]",
|
|
1597
|
+
children: a.text
|
|
1598
|
+
}) : /* @__PURE__ */ jsxs("div", {
|
|
1599
|
+
className: "flex h-full flex-col gap-1.5 p-2.5",
|
|
1600
|
+
children: [/* @__PURE__ */ jsx(FileText, {
|
|
1601
|
+
"aria-hidden": true,
|
|
1602
|
+
className: "size-5 shrink-0 text-chat-muted"
|
|
1603
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
1604
|
+
className: "line-clamp-3 text-xs break-words text-chat-fg",
|
|
1605
|
+
children: a.name
|
|
1606
|
+
})]
|
|
1607
|
+
})
|
|
1608
|
+
}),
|
|
1609
|
+
!image && /* @__PURE__ */ jsxs("div", {
|
|
1610
|
+
className: "absolute inset-x-2 bottom-2 flex items-end justify-between gap-1",
|
|
1611
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
1612
|
+
className: clsx("rounded-[0.3rem] border bg-chat-bg px-1.5 py-0.5 text-[0.625rem] leading-none tracking-wide uppercase", failed ? "border-chat-danger text-chat-danger" : "border-chat-border-strong text-chat-muted"),
|
|
1613
|
+
children: failed ? "Failed" : pasted ? "Pasted" : extension(a.name) ?? "File"
|
|
1614
|
+
}), pasted && onOpen && /* @__PURE__ */ jsx(IconButton, {
|
|
1615
|
+
label: "Edit pasted text",
|
|
1616
|
+
size: "sm",
|
|
1617
|
+
onPress: onOpen,
|
|
1618
|
+
className: "size-6 border border-chat-border-strong bg-chat-bg [&_svg]:size-3.5 [@media(pointer:coarse)]:size-7!",
|
|
1619
|
+
children: /* @__PURE__ */ jsx(Pencil, {})
|
|
1620
|
+
})]
|
|
1621
|
+
}),
|
|
1622
|
+
pasted && onOpen && !onRemove && /* @__PURE__ */ jsx("button", {
|
|
1623
|
+
type: "button",
|
|
1624
|
+
"aria-label": "View pasted text",
|
|
1625
|
+
onClick: onOpen,
|
|
1626
|
+
className: "absolute inset-0 cursor-pointer rounded-[inherit] outline-none focus-visible:ring-2 focus-visible:ring-chat-focus"
|
|
1627
|
+
}),
|
|
1628
|
+
onRemove && /* @__PURE__ */ jsx(IconButton, {
|
|
1629
|
+
label: `Remove ${a.name}`,
|
|
1630
|
+
variant: "subtle",
|
|
1631
|
+
size: "sm",
|
|
1632
|
+
tooltip: false,
|
|
1633
|
+
onPress: onRemove,
|
|
1634
|
+
className: clsx("absolute -top-2 -right-2 size-6 rounded-full border border-chat-border-strong [&_svg]:size-3.5 [@media(pointer:coarse)]:size-7!", "opacity-0 group-hover/card:opacity-100 group-focus-within/card:opacity-100 data-focus-visible:opacity-100 [@media(hover:none)]:opacity-100"),
|
|
1635
|
+
children: /* @__PURE__ */ jsx(X, {})
|
|
1636
|
+
})
|
|
1637
|
+
]
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
/** The composer's row of cards above the text. Renders nothing when empty. */
|
|
1641
|
+
function AttachmentRow({ attachments, onRemove, onEditText }) {
|
|
1642
|
+
const [editing, setEditing] = useState();
|
|
1643
|
+
if (attachments.length === 0) return null;
|
|
1644
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("ul", {
|
|
1645
|
+
"aria-label": "Attachments",
|
|
1646
|
+
className: "flex gap-2.5 overflow-x-auto px-0.5 pt-2.5 pb-1 [scrollbar-width:thin]",
|
|
1647
|
+
children: attachments.map((a) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(AttachmentCard, {
|
|
1648
|
+
attachment: {
|
|
1649
|
+
...a,
|
|
1650
|
+
...a.previewUrl && { url: a.previewUrl }
|
|
1651
|
+
},
|
|
1652
|
+
onRemove: () => onRemove(a.id),
|
|
1653
|
+
...a.kind === "text" && { onOpen: () => setEditing(a) }
|
|
1654
|
+
}) }, a.id))
|
|
1655
|
+
}), /* @__PURE__ */ jsx(PastedTextDialog, {
|
|
1656
|
+
text: editing?.text,
|
|
1657
|
+
onClose: () => setEditing(void 0),
|
|
1658
|
+
...onEditText && editing && { onSave: (text) => onEditText(editing.id, text) }
|
|
1659
|
+
})] });
|
|
1660
|
+
}
|
|
1661
|
+
/** Shows pasted text in a modal, editable when `onSave` is set. */
|
|
1662
|
+
function PastedTextDialog({ text, onClose, onSave, title = "Pasted text" }) {
|
|
1663
|
+
const [draft, setDraft] = useState(text ?? "");
|
|
1664
|
+
useEffect(() => setDraft(text ?? ""), [text]);
|
|
1665
|
+
return /* @__PURE__ */ jsxs(Modal, {
|
|
1666
|
+
isOpen: text !== void 0,
|
|
1667
|
+
onOpenChange: (open) => !open && onClose(),
|
|
1668
|
+
title,
|
|
1669
|
+
children: [/* @__PURE__ */ jsx("textarea", {
|
|
1670
|
+
value: draft,
|
|
1671
|
+
onChange: (e) => setDraft(e.target.value),
|
|
1672
|
+
readOnly: !onSave,
|
|
1673
|
+
"aria-label": title,
|
|
1674
|
+
className: "mx-5 min-h-64 flex-1 resize-none rounded-chat-md border border-chat-border bg-chat-bg p-3 font-chat-mono text-sm leading-relaxed text-chat-fg outline-none focus:border-chat-border-strong [scrollbar-width:thin]"
|
|
1675
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
1676
|
+
className: "flex justify-end gap-2 px-5 py-4",
|
|
1677
|
+
children: [/* @__PURE__ */ jsx(Button, {
|
|
1678
|
+
variant: "subtle",
|
|
1679
|
+
onPress: onClose,
|
|
1680
|
+
children: onSave ? "Cancel" : "Close"
|
|
1681
|
+
}), onSave && /* @__PURE__ */ jsx(Button, {
|
|
1682
|
+
variant: "primary",
|
|
1683
|
+
onPress: () => {
|
|
1684
|
+
onSave(draft);
|
|
1685
|
+
onClose();
|
|
1686
|
+
},
|
|
1687
|
+
children: "Save"
|
|
1688
|
+
})]
|
|
1689
|
+
})]
|
|
1690
|
+
});
|
|
1691
|
+
}
|
|
1692
|
+
// src/components/messages.tsx
|
|
1693
|
+
/** "just now", "5 minutes ago", "21 hours ago", "yesterday", then a date. */
|
|
1694
|
+
function formatRelativeTime(iso, now = Date.now()) {
|
|
1695
|
+
const seconds = (new Date(iso).getTime() - now) / 1e3;
|
|
1696
|
+
const rtf = new Intl.RelativeTimeFormat(void 0, { numeric: "auto" });
|
|
1697
|
+
if (Math.abs(seconds) < 60) return "just now";
|
|
1698
|
+
if (Math.abs(seconds) < 3600) return rtf.format(Math.round(seconds / 60), "minute");
|
|
1699
|
+
if (Math.abs(seconds) < 86400) return rtf.format(Math.round(seconds / 3600), "hour");
|
|
1700
|
+
if (Math.abs(seconds) < 604800) return rtf.format(Math.round(seconds / 86400), "day");
|
|
1701
|
+
return new Date(iso).toLocaleDateString(void 0, {
|
|
1702
|
+
month: "short",
|
|
1703
|
+
day: "numeric"
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
/** Plain text of a message, for copy and read aloud. */
|
|
1707
|
+
function messageText(message) {
|
|
1708
|
+
return message.parts.map((p) => p.type === "text" ? p.text : p.type === "custom" ? p.text ?? "" : "").filter(Boolean).join("\n\n");
|
|
1709
|
+
}
|
|
1710
|
+
function CopyButton({ text }) {
|
|
1711
|
+
const [copied, setCopied] = useState(false);
|
|
1712
|
+
return /* @__PURE__ */ jsx(IconButton, {
|
|
1713
|
+
label: copied ? "Copied" : "Copy",
|
|
1714
|
+
size: "sm",
|
|
1715
|
+
onPress: () => {
|
|
1716
|
+
navigator.clipboard?.writeText(text);
|
|
1717
|
+
setCopied(true);
|
|
1718
|
+
setTimeout(() => setCopied(false), 1500);
|
|
1719
|
+
},
|
|
1720
|
+
children: copied ? /* @__PURE__ */ jsx(Check, {}) : /* @__PURE__ */ jsx(Copy, {})
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1723
|
+
const timeClass = "px-1 font-chat-sans text-xs text-chat-subtle";
|
|
1724
|
+
/** The user's turn: a bubble on the right, with time, retry, edit and copy under it on hover. */
|
|
1725
|
+
function UserMessage({ message, onRetry, onEdit, actions, className }) {
|
|
1726
|
+
const text = messageText(message);
|
|
1727
|
+
const attachments = message.parts.filter((p) => p.type === "attachment");
|
|
1728
|
+
const [viewing, setViewing] = useState();
|
|
1729
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1730
|
+
className: clsx("group/message flex flex-col items-end", className),
|
|
1731
|
+
children: [
|
|
1732
|
+
attachments.length > 0 && /* @__PURE__ */ jsx("ul", {
|
|
1733
|
+
"aria-label": "Attachments",
|
|
1734
|
+
className: "mb-2 flex max-w-[85%] flex-wrap justify-end gap-2",
|
|
1735
|
+
children: attachments.map((a) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(AttachmentCard, {
|
|
1736
|
+
attachment: a,
|
|
1737
|
+
size: "sm",
|
|
1738
|
+
...a.text !== void 0 && { onOpen: () => setViewing(a.text) }
|
|
1739
|
+
}) }, a.id))
|
|
1740
|
+
}),
|
|
1741
|
+
/* @__PURE__ */ jsx(PastedTextDialog, {
|
|
1742
|
+
text: viewing,
|
|
1743
|
+
onClose: () => setViewing(void 0)
|
|
1744
|
+
}),
|
|
1745
|
+
text && /* @__PURE__ */ jsx("div", {
|
|
1746
|
+
className: "max-w-[85%] rounded-chat-lg bg-chat-bubble px-4 py-2.5 font-chat-sans text-[0.9375rem] leading-relaxed whitespace-pre-wrap text-chat-fg",
|
|
1747
|
+
children: text
|
|
1748
|
+
}),
|
|
1749
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1750
|
+
className: "mt-1 flex items-center gap-0.5 opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100 [@media(hover:none)]:opacity-100",
|
|
1751
|
+
children: [
|
|
1752
|
+
/* @__PURE__ */ jsx("span", {
|
|
1753
|
+
className: timeClass,
|
|
1754
|
+
children: formatRelativeTime(message.createdAt)
|
|
1755
|
+
}),
|
|
1756
|
+
onRetry && /* @__PURE__ */ jsx(IconButton, {
|
|
1757
|
+
label: "Retry",
|
|
1758
|
+
size: "sm",
|
|
1759
|
+
onPress: onRetry,
|
|
1760
|
+
children: /* @__PURE__ */ jsx(RefreshCw, {})
|
|
1761
|
+
}),
|
|
1762
|
+
onEdit && /* @__PURE__ */ jsx(IconButton, {
|
|
1763
|
+
label: "Edit",
|
|
1764
|
+
size: "sm",
|
|
1765
|
+
onPress: onEdit,
|
|
1766
|
+
children: /* @__PURE__ */ jsx(Pencil, {})
|
|
1767
|
+
}),
|
|
1768
|
+
/* @__PURE__ */ jsx(CopyButton, { text }),
|
|
1769
|
+
actions
|
|
1770
|
+
]
|
|
1771
|
+
})
|
|
1772
|
+
]
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
/**
|
|
1776
|
+
* The assistant's turn: activity groups (each with its own token count), markdown, and other parts in
|
|
1777
|
+
* order. When done, copy / read aloud / feedback / retry and the time sit under it.
|
|
1778
|
+
*/
|
|
1779
|
+
function AssistantMessage({ message, onRetry, onFeedback, onReadAloud, renderPart, actions, className }) {
|
|
1780
|
+
const streaming = message.status === "streaming";
|
|
1781
|
+
const views = useToolViews();
|
|
1782
|
+
const blocks = toBlocks(message.parts, (part) => views[part.name]?.Block !== void 0);
|
|
1783
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1784
|
+
className: clsx("group/message flex flex-col gap-4", className),
|
|
1785
|
+
children: [
|
|
1786
|
+
blocks.map((block) => {
|
|
1787
|
+
if (block.kind === "activity") return /* @__PURE__ */ jsx(ActivityGroup, { parts: block.parts }, block.id);
|
|
1788
|
+
if (block.kind === "tool") {
|
|
1789
|
+
const ToolBlock = views[block.part.name].Block;
|
|
1790
|
+
return /* @__PURE__ */ jsx(ToolBlock, { part: block.part }, block.id);
|
|
1791
|
+
}
|
|
1792
|
+
const part = block.part;
|
|
1793
|
+
if (part.type === "text") return /* @__PURE__ */ jsx(Markdown, {
|
|
1794
|
+
streaming: part.status === "streaming",
|
|
1795
|
+
children: part.text
|
|
1796
|
+
}, part.id);
|
|
1797
|
+
const custom = renderPart?.(part);
|
|
1798
|
+
if (custom) return /* @__PURE__ */ jsx("div", { children: custom }, part.id);
|
|
1799
|
+
if (part.type === "custom" && part.text) return /* @__PURE__ */ jsx(Markdown, { children: part.text }, part.id);
|
|
1800
|
+
return null;
|
|
1801
|
+
}),
|
|
1802
|
+
message.status === "error" && /* @__PURE__ */ jsx("p", {
|
|
1803
|
+
className: "font-chat-sans text-sm text-chat-danger",
|
|
1804
|
+
children: message.error ?? "Something went wrong."
|
|
1805
|
+
}),
|
|
1806
|
+
streaming ? /* @__PURE__ */ jsx("div", { className: "h-7" }) : /* @__PURE__ */ jsxs("div", {
|
|
1807
|
+
className: "-ml-1.5 flex h-7 items-center gap-0.5",
|
|
1808
|
+
children: [
|
|
1809
|
+
/* @__PURE__ */ jsx(CopyButton, { text: messageText(message) }),
|
|
1810
|
+
onReadAloud && /* @__PURE__ */ jsx(IconButton, {
|
|
1811
|
+
label: "Read aloud",
|
|
1812
|
+
size: "sm",
|
|
1813
|
+
onPress: onReadAloud,
|
|
1814
|
+
children: /* @__PURE__ */ jsx(Volume2, {})
|
|
1815
|
+
}),
|
|
1816
|
+
onFeedback && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(IconButton, {
|
|
1817
|
+
label: "Good response",
|
|
1818
|
+
size: "sm",
|
|
1819
|
+
onPress: () => onFeedback("good"),
|
|
1820
|
+
children: /* @__PURE__ */ jsx(ThumbsUp, {})
|
|
1821
|
+
}), /* @__PURE__ */ jsx(IconButton, {
|
|
1822
|
+
label: "Bad response",
|
|
1823
|
+
size: "sm",
|
|
1824
|
+
onPress: () => onFeedback("bad"),
|
|
1825
|
+
children: /* @__PURE__ */ jsx(ThumbsDown, {})
|
|
1826
|
+
})] }),
|
|
1827
|
+
onRetry && /* @__PURE__ */ jsx(IconButton, {
|
|
1828
|
+
label: "Retry",
|
|
1829
|
+
size: "sm",
|
|
1830
|
+
onPress: onRetry,
|
|
1831
|
+
children: /* @__PURE__ */ jsx(RefreshCw, {})
|
|
1832
|
+
}),
|
|
1833
|
+
actions,
|
|
1834
|
+
/* @__PURE__ */ jsx("span", {
|
|
1835
|
+
className: timeClass,
|
|
1836
|
+
children: formatRelativeTime(message.createdAt)
|
|
1837
|
+
})
|
|
1838
|
+
]
|
|
1839
|
+
})
|
|
1840
|
+
]
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
// src/components/approval-card.tsx
|
|
1844
|
+
/**
|
|
1845
|
+
* Asks the user before a tool runs, above the composer. Shows the tool's label and what it will do: its
|
|
1846
|
+
* view's `Detail` when it has one, else the input. Replying in the composer instead declines it.
|
|
1847
|
+
*/
|
|
1848
|
+
function ApprovalCard({ approval, onRespond, className }) {
|
|
1849
|
+
const { part } = approval;
|
|
1850
|
+
const view = useToolView(part.name);
|
|
1851
|
+
const Detail = view.Detail;
|
|
1852
|
+
const preview = {
|
|
1853
|
+
...part,
|
|
1854
|
+
status: "awaiting"
|
|
1855
|
+
};
|
|
1856
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1857
|
+
role: "alertdialog",
|
|
1858
|
+
"aria-label": `Allow ${part.name}?`,
|
|
1859
|
+
className: clsx("flex flex-col gap-3 rounded-chat-lg border border-chat-border-strong bg-chat-surface p-3 font-chat-sans text-chat-fg shadow-chat-popover", className),
|
|
1860
|
+
children: [
|
|
1861
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1862
|
+
className: "flex items-center gap-2 text-sm",
|
|
1863
|
+
children: [/* @__PURE__ */ jsx(ShieldQuestion, {
|
|
1864
|
+
"aria-hidden": true,
|
|
1865
|
+
className: "size-4 shrink-0 text-chat-accent"
|
|
1866
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
1867
|
+
className: "min-w-0 flex-1 font-medium",
|
|
1868
|
+
children: view.ask?.(part) ?? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1869
|
+
"Allow ",
|
|
1870
|
+
activityLabel(part, view),
|
|
1871
|
+
"?"
|
|
1872
|
+
] })
|
|
1873
|
+
})]
|
|
1874
|
+
}),
|
|
1875
|
+
/* @__PURE__ */ jsx("div", {
|
|
1876
|
+
className: "max-h-48 overflow-auto [scrollbar-width:thin]",
|
|
1877
|
+
children: Detail ? /* @__PURE__ */ jsx(Detail, { part: preview }) : /* @__PURE__ */ jsx(CodeBlock, {
|
|
1878
|
+
variant: "boxed",
|
|
1879
|
+
label: part.name,
|
|
1880
|
+
code: JSON.stringify(part.input ?? {}, null, 2)
|
|
1881
|
+
})
|
|
1882
|
+
}),
|
|
1883
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1884
|
+
className: "flex justify-end gap-2",
|
|
1885
|
+
children: [/* @__PURE__ */ jsx(Button, {
|
|
1886
|
+
variant: "ghost",
|
|
1887
|
+
className: "border border-chat-border-strong",
|
|
1888
|
+
onPress: () => onRespond(part.id, false),
|
|
1889
|
+
children: "Deny"
|
|
1890
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
1891
|
+
variant: "primary",
|
|
1892
|
+
autoFocus: true,
|
|
1893
|
+
onPress: () => onRespond(part.id, true),
|
|
1894
|
+
children: "Allow"
|
|
1895
|
+
})]
|
|
1896
|
+
})
|
|
1897
|
+
]
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
// src/components/context-panel.tsx
|
|
1901
|
+
const FREE_COLOR = "var(--chat-raised)";
|
|
1902
|
+
const DEFAULT_COLOR = "var(--chat-subtle)";
|
|
1903
|
+
/**
|
|
1904
|
+
* What fills the context window: a usage line, a segmented bar, one row per category with its share, and
|
|
1905
|
+
* expandable lists (each MCP tool, each memory file). Deferred categories are listed but not counted.
|
|
1906
|
+
*/
|
|
1907
|
+
function ContextPanel({ usage, title = "Context", defaultExpanded = true, className }) {
|
|
1908
|
+
const counted = usage.categories.filter((c) => !c.deferred);
|
|
1909
|
+
const deferred = usage.categories.filter((c) => c.deferred);
|
|
1910
|
+
const used = counted.reduce((sum, c) => sum + (c.reserved ? 0 : c.tokens), 0);
|
|
1911
|
+
const reserved = counted.reduce((sum, c) => sum + (c.reserved ? c.tokens : 0), 0);
|
|
1912
|
+
const free = Math.max(usage.limit - used - reserved, 0);
|
|
1913
|
+
const share = (tokens) => formatShare(tokens / usage.limit);
|
|
1914
|
+
return /* @__PURE__ */ jsxs(Disclosure, {
|
|
1915
|
+
summary: title,
|
|
1916
|
+
defaultExpanded,
|
|
1917
|
+
className: clsx("font-chat-sans text-chat-fg", className),
|
|
1918
|
+
summaryClassName: "text-[0.9375rem] text-chat-fg",
|
|
1919
|
+
panelClassName: "pt-3",
|
|
1920
|
+
children: [
|
|
1921
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1922
|
+
className: "mb-2 flex items-baseline justify-between text-[0.9375rem]",
|
|
1923
|
+
children: [/* @__PURE__ */ jsxs("span", { children: [Math.round(used / usage.limit * 100), "% used"] }), /* @__PURE__ */ jsxs("span", {
|
|
1924
|
+
className: "text-sm text-chat-muted tabular-nums",
|
|
1925
|
+
children: [
|
|
1926
|
+
formatTokens(used),
|
|
1927
|
+
" / ",
|
|
1928
|
+
formatTokens(usage.limit)
|
|
1929
|
+
]
|
|
1930
|
+
})]
|
|
1931
|
+
}),
|
|
1932
|
+
/* @__PURE__ */ jsx("div", {
|
|
1933
|
+
role: "meter",
|
|
1934
|
+
"aria-label": "Context used",
|
|
1935
|
+
"aria-valuemin": 0,
|
|
1936
|
+
"aria-valuemax": usage.limit,
|
|
1937
|
+
"aria-valuenow": used,
|
|
1938
|
+
className: "mb-3 flex h-1 gap-px overflow-hidden rounded-full",
|
|
1939
|
+
style: { background: FREE_COLOR },
|
|
1940
|
+
children: counted.map((c) => /* @__PURE__ */ jsx("span", {
|
|
1941
|
+
className: "h-full min-w-[2px]",
|
|
1942
|
+
style: {
|
|
1943
|
+
width: `${c.tokens / usage.limit * 100}%`,
|
|
1944
|
+
background: c.color ?? DEFAULT_COLOR
|
|
1945
|
+
}
|
|
1946
|
+
}, c.id))
|
|
1947
|
+
}),
|
|
1948
|
+
/* @__PURE__ */ jsxs("ul", {
|
|
1949
|
+
className: "space-y-1",
|
|
1950
|
+
children: [counted.map((c) => /* @__PURE__ */ jsx(CategoryRow, {
|
|
1951
|
+
category: c,
|
|
1952
|
+
share: share(c.tokens)
|
|
1953
|
+
}, c.id)), /* @__PURE__ */ jsx(CategoryRow, {
|
|
1954
|
+
category: {
|
|
1955
|
+
id: "free",
|
|
1956
|
+
label: "Free space",
|
|
1957
|
+
tokens: free,
|
|
1958
|
+
color: FREE_COLOR
|
|
1959
|
+
},
|
|
1960
|
+
share: share(free)
|
|
1961
|
+
})]
|
|
1962
|
+
}),
|
|
1963
|
+
deferred.length > 0 && /* @__PURE__ */ jsx("ul", {
|
|
1964
|
+
className: "mt-3 space-y-1",
|
|
1965
|
+
children: deferred.map((c) => /* @__PURE__ */ jsx(CategoryRow, {
|
|
1966
|
+
category: c,
|
|
1967
|
+
share: "—"
|
|
1968
|
+
}, c.id))
|
|
1969
|
+
}),
|
|
1970
|
+
usage.groups && usage.groups.length > 0 && /* @__PURE__ */ jsx("div", {
|
|
1971
|
+
className: "mt-3 space-y-1.5",
|
|
1972
|
+
children: usage.groups.map((g) => /* @__PURE__ */ jsx(GroupList, { group: g }, g.id))
|
|
1973
|
+
})
|
|
1974
|
+
]
|
|
1975
|
+
});
|
|
1976
|
+
}
|
|
1977
|
+
function CategoryRow({ category, share }) {
|
|
1978
|
+
const label = category.hint ? /* @__PURE__ */ jsx(Tooltip, {
|
|
1979
|
+
content: category.hint,
|
|
1980
|
+
children: /* @__PURE__ */ jsx(Focusable, { children: /* @__PURE__ */ jsx("span", {
|
|
1981
|
+
tabIndex: 0,
|
|
1982
|
+
className: "cursor-help underline decoration-chat-subtle decoration-dotted underline-offset-4 outline-none",
|
|
1983
|
+
children: category.label
|
|
1984
|
+
}) })
|
|
1985
|
+
}) : category.label;
|
|
1986
|
+
return /* @__PURE__ */ jsxs("li", {
|
|
1987
|
+
className: "flex items-center gap-2.5 text-sm",
|
|
1988
|
+
children: [
|
|
1989
|
+
/* @__PURE__ */ jsx("span", {
|
|
1990
|
+
"aria-hidden": true,
|
|
1991
|
+
className: "size-2.5 shrink-0 rounded-[2px]",
|
|
1992
|
+
style: { background: category.color ?? (category.deferred ? "var(--chat-selected)" : DEFAULT_COLOR) }
|
|
1993
|
+
}),
|
|
1994
|
+
/* @__PURE__ */ jsx("span", {
|
|
1995
|
+
className: "min-w-0 flex-1 truncate text-chat-muted",
|
|
1996
|
+
children: label
|
|
1997
|
+
}),
|
|
1998
|
+
/* @__PURE__ */ jsx("span", {
|
|
1999
|
+
className: "w-14 text-right text-chat-muted tabular-nums",
|
|
2000
|
+
children: formatTokens(category.tokens)
|
|
2001
|
+
}),
|
|
2002
|
+
/* @__PURE__ */ jsx("span", {
|
|
2003
|
+
className: "w-12 text-right text-chat-muted tabular-nums",
|
|
2004
|
+
children: share
|
|
2005
|
+
})
|
|
2006
|
+
]
|
|
2007
|
+
});
|
|
2008
|
+
}
|
|
2009
|
+
function GroupList({ group }) {
|
|
2010
|
+
return /* @__PURE__ */ jsx(Disclosure, {
|
|
2011
|
+
summary: group.label,
|
|
2012
|
+
trailing: /* @__PURE__ */ jsx("span", {
|
|
2013
|
+
className: "text-sm text-chat-subtle tabular-nums",
|
|
2014
|
+
children: group.items.length
|
|
2015
|
+
}),
|
|
2016
|
+
indicator: "chevron",
|
|
2017
|
+
summaryClassName: "text-sm",
|
|
2018
|
+
panelClassName: "pt-1.5",
|
|
2019
|
+
children: /* @__PURE__ */ jsx("ul", {
|
|
2020
|
+
className: "max-h-72 space-y-1 overflow-y-auto pl-6 [scrollbar-color:var(--chat-border-strong)_transparent] [scrollbar-width:thin]",
|
|
2021
|
+
children: group.items.map((item) => /* @__PURE__ */ jsxs("li", {
|
|
2022
|
+
className: "flex items-center gap-2 text-sm text-chat-muted",
|
|
2023
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
2024
|
+
className: "min-w-0 flex-1 truncate",
|
|
2025
|
+
children: item.label
|
|
2026
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
2027
|
+
className: "w-12 shrink-0 text-right tabular-nums",
|
|
2028
|
+
children: formatTokens(item.tokens)
|
|
2029
|
+
})]
|
|
2030
|
+
}, item.id))
|
|
2031
|
+
})
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
2034
|
+
function formatShare(fraction) {
|
|
2035
|
+
return `${(fraction * 100).toFixed(1)}%`;
|
|
2036
|
+
}
|
|
2037
|
+
// src/components/composer.tsx
|
|
2038
|
+
/** Touch keyboards have no easy Shift+Enter, so there Enter adds a line and the button sends. */
|
|
2039
|
+
const coarsePointer = () => typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches;
|
|
2040
|
+
/** The chat box: growing textarea, toolbar slots, send / stop. Enter sends, Shift+Enter adds a line. */
|
|
2041
|
+
function Composer({ onSubmit, onStop, busy = false, value, onChange, placeholder = "Reply…", variant = "docked", leading, trailing, above, onFiles, onLongPaste, longPasteChars = 2e3, allowEmpty = false, submitDisabled = false, isDisabled = false, autoFocus, className }) {
|
|
2042
|
+
const [own, setOwn] = useState("");
|
|
2043
|
+
const text = value ?? own;
|
|
2044
|
+
const setText = (next) => {
|
|
2045
|
+
if (value === void 0) setOwn(next);
|
|
2046
|
+
onChange?.(next);
|
|
2047
|
+
};
|
|
2048
|
+
const area = useRef(null);
|
|
2049
|
+
const fit = useCallback(() => {
|
|
2050
|
+
const el = area.current;
|
|
2051
|
+
if (!el) return;
|
|
2052
|
+
el.style.height = "auto";
|
|
2053
|
+
el.style.height = `${el.scrollHeight}px`;
|
|
2054
|
+
el.style.overflowY = el.scrollHeight > el.clientHeight + 1 ? "auto" : "hidden";
|
|
2055
|
+
}, []);
|
|
2056
|
+
useLayoutEffect(fit, [
|
|
2057
|
+
text,
|
|
2058
|
+
variant,
|
|
2059
|
+
fit
|
|
2060
|
+
]);
|
|
2061
|
+
useEffect(() => {
|
|
2062
|
+
const el = area.current;
|
|
2063
|
+
if (!el || typeof ResizeObserver === "undefined") return;
|
|
2064
|
+
let width = el.clientWidth;
|
|
2065
|
+
const observer = new ResizeObserver(() => {
|
|
2066
|
+
if (el.clientWidth !== width) width = el.clientWidth, fit();
|
|
2067
|
+
});
|
|
2068
|
+
observer.observe(el);
|
|
2069
|
+
return () => observer.disconnect();
|
|
2070
|
+
}, [fit]);
|
|
2071
|
+
const [dragging, setDragging] = useState(false);
|
|
2072
|
+
const canSend = !busy && !isDisabled && !submitDisabled && (allowEmpty || text.trim().length > 0);
|
|
2073
|
+
const submit = () => {
|
|
2074
|
+
if (!canSend) return;
|
|
2075
|
+
onSubmit(text);
|
|
2076
|
+
setText("");
|
|
2077
|
+
};
|
|
2078
|
+
const onKeyDown = (e) => {
|
|
2079
|
+
if (e.key !== "Enter" || e.shiftKey || e.nativeEvent.isComposing || coarsePointer()) return;
|
|
2080
|
+
e.preventDefault();
|
|
2081
|
+
submit();
|
|
2082
|
+
};
|
|
2083
|
+
const onPaste = (e) => {
|
|
2084
|
+
const files = [...e.clipboardData.files];
|
|
2085
|
+
if (files.length && onFiles) {
|
|
2086
|
+
e.preventDefault();
|
|
2087
|
+
onFiles(files);
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
const pasted = e.clipboardData.getData("text/plain");
|
|
2091
|
+
if (onLongPaste && pasted.length > longPasteChars) {
|
|
2092
|
+
e.preventDefault();
|
|
2093
|
+
onLongPaste(pasted);
|
|
2094
|
+
}
|
|
2095
|
+
};
|
|
2096
|
+
const dropProps = onFiles && {
|
|
2097
|
+
onDragOver: (e) => {
|
|
2098
|
+
if (!e.dataTransfer.types.includes("Files")) return;
|
|
2099
|
+
e.preventDefault();
|
|
2100
|
+
setDragging(true);
|
|
2101
|
+
},
|
|
2102
|
+
onDragLeave: (e) => {
|
|
2103
|
+
if (!e.currentTarget.contains(e.relatedTarget)) setDragging(false);
|
|
2104
|
+
},
|
|
2105
|
+
onDrop: (e) => {
|
|
2106
|
+
if (!e.dataTransfer.files.length) return;
|
|
2107
|
+
e.preventDefault();
|
|
2108
|
+
setDragging(false);
|
|
2109
|
+
onFiles([...e.dataTransfer.files]);
|
|
2110
|
+
}
|
|
2111
|
+
};
|
|
2112
|
+
const compact = variant === "compact";
|
|
2113
|
+
const action = busy ? /* @__PURE__ */ jsx(IconButton, {
|
|
2114
|
+
label: "Stop",
|
|
2115
|
+
variant: "primary",
|
|
2116
|
+
onPress: onStop,
|
|
2117
|
+
className: "rounded-chat-md [&_svg]:size-3.5 [&_svg]:fill-current",
|
|
2118
|
+
children: /* @__PURE__ */ jsx(Square, {})
|
|
2119
|
+
}) : /* @__PURE__ */ jsx(IconButton, {
|
|
2120
|
+
label: "Send",
|
|
2121
|
+
variant: "primary",
|
|
2122
|
+
onPress: submit,
|
|
2123
|
+
isDisabled: !canSend,
|
|
2124
|
+
className: "rounded-chat-md",
|
|
2125
|
+
children: /* @__PURE__ */ jsx(ArrowUp, {})
|
|
2126
|
+
});
|
|
2127
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
2128
|
+
className: clsx("flex flex-col rounded-[1.25rem] border border-chat-border bg-chat-surface font-chat-sans shadow-chat-popover", "focus-within:border-chat-border-strong", dragging && "border-chat-accent! border-dashed", compact ? "gap-1 p-2" : "gap-2 p-3", className),
|
|
2129
|
+
...dropProps,
|
|
2130
|
+
onPointerDown: (e) => {
|
|
2131
|
+
if (e.target === e.currentTarget) {
|
|
2132
|
+
e.preventDefault();
|
|
2133
|
+
area.current?.focus();
|
|
2134
|
+
}
|
|
2135
|
+
},
|
|
2136
|
+
children: [above, /* @__PURE__ */ jsxs("div", {
|
|
2137
|
+
className: clsx("flex gap-2", compact ? "items-end" : "flex-col"),
|
|
2138
|
+
children: [
|
|
2139
|
+
compact && leading,
|
|
2140
|
+
/* @__PURE__ */ jsx("textarea", {
|
|
2141
|
+
ref: area,
|
|
2142
|
+
rows: 1,
|
|
2143
|
+
value: text,
|
|
2144
|
+
onChange: (e) => setText(e.target.value),
|
|
2145
|
+
onKeyDown,
|
|
2146
|
+
onPaste,
|
|
2147
|
+
placeholder,
|
|
2148
|
+
"aria-label": "Message",
|
|
2149
|
+
disabled: isDisabled,
|
|
2150
|
+
autoFocus,
|
|
2151
|
+
className: clsx("w-full resize-none bg-transparent px-1.5 text-chat-fg outline-none placeholder:text-chat-subtle", "text-base leading-relaxed [scrollbar-width:thin]", compact ? "max-h-[min(10rem,30cqh)] py-1" : variant === "centered" ? "max-h-[min(24rem,45cqh)] min-h-12 pt-1" : "max-h-[min(18rem,35cqh)] min-h-6 pt-1")
|
|
2152
|
+
}),
|
|
2153
|
+
compact ? /* @__PURE__ */ jsxs("div", {
|
|
2154
|
+
className: "flex shrink-0 items-center gap-1",
|
|
2155
|
+
children: [trailing, action]
|
|
2156
|
+
}) : /* @__PURE__ */ jsxs("div", {
|
|
2157
|
+
className: "flex items-center gap-1",
|
|
2158
|
+
children: [
|
|
2159
|
+
/* @__PURE__ */ jsx("div", {
|
|
2160
|
+
className: "flex min-w-0 flex-1 items-center gap-1",
|
|
2161
|
+
children: leading
|
|
2162
|
+
}),
|
|
2163
|
+
trailing,
|
|
2164
|
+
action
|
|
2165
|
+
]
|
|
2166
|
+
})
|
|
2167
|
+
]
|
|
2168
|
+
})]
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2171
|
+
/** The `+` button that opens the attach menu above the composer. */
|
|
2172
|
+
function AttachButton({ children, label = "Add files and more" }) {
|
|
2173
|
+
return /* @__PURE__ */ jsxs(MenuTrigger, { children: [/* @__PURE__ */ jsx(IconButton, {
|
|
2174
|
+
label,
|
|
2175
|
+
children: /* @__PURE__ */ jsx(Plus, {})
|
|
2176
|
+
}), /* @__PURE__ */ jsx(Popover, {
|
|
2177
|
+
placement: "top start",
|
|
2178
|
+
children
|
|
2179
|
+
})] });
|
|
2180
|
+
}
|
|
2181
|
+
// src/components/model-picker.tsx
|
|
2182
|
+
/** "Opus 5.5 High ⌄": the models, an Effort submenu for models that have levels, and More models. */
|
|
2183
|
+
function ModelPicker({ models, model, onModelChange, effort, onEffortChange, effortNote = "Higher effort means more thorough responses, but takes longer and uses your limits faster.", footnote, className }) {
|
|
2184
|
+
const current = models.find((m) => m.id === model);
|
|
2185
|
+
const efforts = current?.efforts ?? [];
|
|
2186
|
+
const currentEffort = efforts.find((e) => e.id === (effort ?? current?.defaultEffort));
|
|
2187
|
+
const main = models.filter((m) => !m.secondary);
|
|
2188
|
+
const more = models.filter((m) => m.secondary);
|
|
2189
|
+
const item = (m) => /* @__PURE__ */ jsx(MenuItem, {
|
|
2190
|
+
id: m.id,
|
|
2191
|
+
description: m.disabledReason ?? m.description,
|
|
2192
|
+
isDisabled: m.disabled ?? false,
|
|
2193
|
+
children: m.label
|
|
2194
|
+
}, m.id);
|
|
2195
|
+
const pick = (keys) => {
|
|
2196
|
+
if (keys === "all") return;
|
|
2197
|
+
const [id] = keys;
|
|
2198
|
+
if (id !== void 0) onModelChange(String(id));
|
|
2199
|
+
};
|
|
2200
|
+
return /* @__PURE__ */ jsxs(MenuTrigger, { children: [/* @__PURE__ */ jsxs(Button, {
|
|
2201
|
+
size: "sm",
|
|
2202
|
+
"aria-label": `Model: ${current?.label ?? model}`,
|
|
2203
|
+
className: clsx("text-chat-muted data-hovered:text-chat-fg", className),
|
|
2204
|
+
children: [
|
|
2205
|
+
/* @__PURE__ */ jsx("span", {
|
|
2206
|
+
className: "min-w-0 truncate text-chat-fg",
|
|
2207
|
+
children: current?.label ?? model
|
|
2208
|
+
}),
|
|
2209
|
+
currentEffort && /* @__PURE__ */ jsx("span", {
|
|
2210
|
+
className: "chat-picker-detail text-chat-muted",
|
|
2211
|
+
children: currentEffort.label
|
|
2212
|
+
}),
|
|
2213
|
+
/* @__PURE__ */ jsx(ChevronDown, {
|
|
2214
|
+
"aria-hidden": true,
|
|
2215
|
+
className: "size-3.5"
|
|
2216
|
+
})
|
|
2217
|
+
]
|
|
2218
|
+
}), /* @__PURE__ */ jsxs(Popover, {
|
|
2219
|
+
placement: "top end",
|
|
2220
|
+
className: "w-72",
|
|
2221
|
+
children: [/* @__PURE__ */ jsxs(Menu, {
|
|
2222
|
+
"aria-label": "Model",
|
|
2223
|
+
children: [
|
|
2224
|
+
/* @__PURE__ */ jsx(MenuSection, {
|
|
2225
|
+
selectionMode: "single",
|
|
2226
|
+
selectedKeys: [model],
|
|
2227
|
+
onSelectionChange: pick,
|
|
2228
|
+
children: main.map(item)
|
|
2229
|
+
}),
|
|
2230
|
+
(efforts.length > 0 || more.length > 0) && /* @__PURE__ */ jsx(MenuSeparator, {}),
|
|
2231
|
+
efforts.length > 0 && onEffortChange && /* @__PURE__ */ jsxs(SubmenuTrigger, { children: [/* @__PURE__ */ jsx(MenuItem, {
|
|
2232
|
+
id: "__effort",
|
|
2233
|
+
detail: currentEffort?.label,
|
|
2234
|
+
children: "Effort"
|
|
2235
|
+
}), /* @__PURE__ */ jsxs(Popover, {
|
|
2236
|
+
placement: "end top",
|
|
2237
|
+
className: "w-80",
|
|
2238
|
+
children: [/* @__PURE__ */ jsx(MenuNote, { children: effortNote }), /* @__PURE__ */ jsx(Menu, {
|
|
2239
|
+
"aria-label": "Effort",
|
|
2240
|
+
selectionMode: "single",
|
|
2241
|
+
selectedKeys: currentEffort ? [currentEffort.id] : [],
|
|
2242
|
+
onSelectionChange: (keys) => {
|
|
2243
|
+
if (keys === "all") return;
|
|
2244
|
+
const [id] = keys;
|
|
2245
|
+
if (id !== void 0) onEffortChange(String(id));
|
|
2246
|
+
},
|
|
2247
|
+
children: efforts.map((e) => /* @__PURE__ */ jsx(MenuItem, {
|
|
2248
|
+
id: e.id,
|
|
2249
|
+
textValue: e.label,
|
|
2250
|
+
children: /* @__PURE__ */ jsxs("span", {
|
|
2251
|
+
className: "flex items-center gap-2",
|
|
2252
|
+
children: [
|
|
2253
|
+
e.label,
|
|
2254
|
+
e.recommended && /* @__PURE__ */ jsx(Badge, { children: "Recommended" }),
|
|
2255
|
+
e.warning && /* @__PURE__ */ jsx(Badge, {
|
|
2256
|
+
tone: "warning",
|
|
2257
|
+
icon: /* @__PURE__ */ jsx(AlertTriangle, {}),
|
|
2258
|
+
children: e.warning
|
|
2259
|
+
})
|
|
2260
|
+
]
|
|
2261
|
+
})
|
|
2262
|
+
}, e.id))
|
|
2263
|
+
})]
|
|
2264
|
+
})] }),
|
|
2265
|
+
more.length > 0 && /* @__PURE__ */ jsxs(SubmenuTrigger, { children: [/* @__PURE__ */ jsx(MenuItem, {
|
|
2266
|
+
id: "__more",
|
|
2267
|
+
children: "More models"
|
|
2268
|
+
}), /* @__PURE__ */ jsx(Popover, {
|
|
2269
|
+
placement: "end top",
|
|
2270
|
+
className: "w-72",
|
|
2271
|
+
children: /* @__PURE__ */ jsx(Menu, {
|
|
2272
|
+
"aria-label": "More models",
|
|
2273
|
+
selectionMode: "single",
|
|
2274
|
+
selectedKeys: [model],
|
|
2275
|
+
onSelectionChange: pick,
|
|
2276
|
+
children: more.map(item)
|
|
2277
|
+
})
|
|
2278
|
+
})] })
|
|
2279
|
+
]
|
|
2280
|
+
}), footnote && /* @__PURE__ */ jsx(MenuNote, {
|
|
2281
|
+
className: "border-t border-chat-popover-border text-xs",
|
|
2282
|
+
children: footnote
|
|
2283
|
+
})]
|
|
2284
|
+
})] });
|
|
2285
|
+
}
|
|
2286
|
+
// src/components/message-list.tsx
|
|
2287
|
+
/** How close to the bottom still counts as "at the bottom", in px. */
|
|
2288
|
+
const STICK_SLOP = 48;
|
|
2289
|
+
/**
|
|
2290
|
+
* The scrolling conversation. It follows a streaming reply while the reader is at the bottom and leaves
|
|
2291
|
+
* them alone once they scroll up, with a button to jump back. Sending a message always jumps down.
|
|
2292
|
+
*/
|
|
2293
|
+
function MessageList({ messages, onRetry, onEdit, onFeedback, renderPart, footer, className }) {
|
|
2294
|
+
const scroller = useRef(null);
|
|
2295
|
+
const content = useRef(null);
|
|
2296
|
+
const stuck = useRef(true);
|
|
2297
|
+
const [atBottom, setAtBottom] = useState(true);
|
|
2298
|
+
const toBottom = useCallback((behavior = "auto") => {
|
|
2299
|
+
const el = scroller.current;
|
|
2300
|
+
if (el) el.scrollTo({
|
|
2301
|
+
top: el.scrollHeight,
|
|
2302
|
+
behavior
|
|
2303
|
+
});
|
|
2304
|
+
}, []);
|
|
2305
|
+
const onScroll = () => {
|
|
2306
|
+
const el = scroller.current;
|
|
2307
|
+
if (!el) return;
|
|
2308
|
+
const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_SLOP;
|
|
2309
|
+
stuck.current = bottom;
|
|
2310
|
+
setAtBottom(bottom);
|
|
2311
|
+
};
|
|
2312
|
+
useEffect(() => {
|
|
2313
|
+
const el = content.current;
|
|
2314
|
+
if (!el) return;
|
|
2315
|
+
const observer = new ResizeObserver(() => {
|
|
2316
|
+
if (stuck.current) toBottom();
|
|
2317
|
+
});
|
|
2318
|
+
observer.observe(el);
|
|
2319
|
+
return () => observer.disconnect();
|
|
2320
|
+
}, [toBottom]);
|
|
2321
|
+
const lastUser = messages.findLast((m) => m.role === "user")?.id;
|
|
2322
|
+
useLayoutEffect(() => {
|
|
2323
|
+
stuck.current = true;
|
|
2324
|
+
setAtBottom(true);
|
|
2325
|
+
toBottom();
|
|
2326
|
+
}, [lastUser, toBottom]);
|
|
2327
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
2328
|
+
className: clsx("relative min-h-0 flex-1", className),
|
|
2329
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
2330
|
+
ref: scroller,
|
|
2331
|
+
onScroll,
|
|
2332
|
+
className: "h-full overflow-y-auto [scrollbar-gutter:stable] [scrollbar-width:thin]",
|
|
2333
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
2334
|
+
ref: content,
|
|
2335
|
+
className: "flex min-h-full flex-col",
|
|
2336
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
2337
|
+
role: "log",
|
|
2338
|
+
"aria-label": "Conversation",
|
|
2339
|
+
className: "mx-auto flex w-full max-w-[calc(var(--chat-content-width)+2*var(--chat-gutter,var(--chat-content-padding)))] flex-1 flex-col gap-6 px-[var(--chat-gutter,var(--chat-content-padding))] pt-6 pb-4",
|
|
2340
|
+
children: messages.map((m) => m.role === "user" ? /* @__PURE__ */ jsx(UserMessage, {
|
|
2341
|
+
message: m,
|
|
2342
|
+
...onEdit && { onEdit: () => onEdit(m.id) }
|
|
2343
|
+
}, m.id) : /* @__PURE__ */ jsx(AssistantMessage, {
|
|
2344
|
+
message: m,
|
|
2345
|
+
...onRetry && { onRetry: () => onRetry(m.id) },
|
|
2346
|
+
...onFeedback && { onFeedback: (f) => onFeedback(m.id, f) },
|
|
2347
|
+
...renderPart && { renderPart }
|
|
2348
|
+
}, m.id))
|
|
2349
|
+
}), footer && /* @__PURE__ */ jsx("div", {
|
|
2350
|
+
className: "sticky bottom-0",
|
|
2351
|
+
children: footer
|
|
2352
|
+
})]
|
|
2353
|
+
})
|
|
2354
|
+
}), !atBottom && /* @__PURE__ */ jsx(IconButton, {
|
|
2355
|
+
label: "Scroll to bottom",
|
|
2356
|
+
variant: "subtle",
|
|
2357
|
+
onPress: () => {
|
|
2358
|
+
stuck.current = true;
|
|
2359
|
+
toBottom("smooth");
|
|
2360
|
+
},
|
|
2361
|
+
className: "absolute bottom-[min(10rem,40%)] left-1/2 -translate-x-1/2 rounded-full border border-chat-border shadow-chat-popover",
|
|
2362
|
+
children: /* @__PURE__ */ jsx(ArrowDown, {})
|
|
2363
|
+
})]
|
|
2364
|
+
});
|
|
2365
|
+
}
|
|
2366
|
+
// src/components/chat-header.tsx
|
|
2367
|
+
/** The bar above a chat: breadcrumb and title with its menu, actions on the right. */
|
|
2368
|
+
function ChatHeader({ title, menu, leading, actions, className }) {
|
|
2369
|
+
return /* @__PURE__ */ jsxs("header", {
|
|
2370
|
+
className: clsx("flex h-12 shrink-0 items-center gap-1 px-3 font-chat-sans", className),
|
|
2371
|
+
children: [
|
|
2372
|
+
leading,
|
|
2373
|
+
menu ? /* @__PURE__ */ jsxs(MenuTrigger, { children: [/* @__PURE__ */ jsxs(Button, {
|
|
2374
|
+
size: "sm",
|
|
2375
|
+
className: "min-w-0 text-chat-fg",
|
|
2376
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
2377
|
+
className: "truncate",
|
|
2378
|
+
children: title
|
|
2379
|
+
}), /* @__PURE__ */ jsx(ChevronDown, {
|
|
2380
|
+
"aria-hidden": true,
|
|
2381
|
+
className: "size-3.5 shrink-0 text-chat-muted"
|
|
2382
|
+
})]
|
|
2383
|
+
}), /* @__PURE__ */ jsx(Popover, {
|
|
2384
|
+
placement: "bottom start",
|
|
2385
|
+
children: menu
|
|
2386
|
+
})] }) : /* @__PURE__ */ jsx("h1", {
|
|
2387
|
+
className: "min-w-0 truncate px-2 text-sm text-chat-fg",
|
|
2388
|
+
children: title
|
|
2389
|
+
}),
|
|
2390
|
+
/* @__PURE__ */ jsx("div", {
|
|
2391
|
+
className: "ml-auto flex shrink-0 items-center gap-1",
|
|
2392
|
+
children: actions
|
|
2393
|
+
})
|
|
2394
|
+
]
|
|
2395
|
+
});
|
|
2396
|
+
}
|
|
2397
|
+
/** Pin, Rename, Add to project (searchable), Share, Delete. Each shows only when its handler is set. */
|
|
2398
|
+
function ChatTitleMenu({ pinned, onPin, onRename, projects, onAddToProject, onCreateProject, onShare, onDelete, children }) {
|
|
2399
|
+
const act = (key) => {
|
|
2400
|
+
if (key === "pin") onPin?.();
|
|
2401
|
+
else if (key === "rename") onRename?.();
|
|
2402
|
+
else if (key === "share") onShare?.();
|
|
2403
|
+
else if (key === "delete") onDelete?.();
|
|
2404
|
+
};
|
|
2405
|
+
return /* @__PURE__ */ jsxs(Menu, {
|
|
2406
|
+
"aria-label": "Chat actions",
|
|
2407
|
+
className: "min-w-44",
|
|
2408
|
+
onAction: act,
|
|
2409
|
+
children: [
|
|
2410
|
+
onPin && /* @__PURE__ */ jsx(MenuItem, {
|
|
2411
|
+
id: "pin",
|
|
2412
|
+
icon: pinned ? /* @__PURE__ */ jsx(PinOff, {}) : /* @__PURE__ */ jsx(Pin, {}),
|
|
2413
|
+
shortcut: "P",
|
|
2414
|
+
children: pinned ? "Unpin" : "Pin"
|
|
2415
|
+
}),
|
|
2416
|
+
onRename && /* @__PURE__ */ jsx(MenuItem, {
|
|
2417
|
+
id: "rename",
|
|
2418
|
+
icon: /* @__PURE__ */ jsx(Pencil, {}),
|
|
2419
|
+
shortcut: "R",
|
|
2420
|
+
children: "Rename"
|
|
2421
|
+
}),
|
|
2422
|
+
onAddToProject && /* @__PURE__ */ jsxs(SubmenuTrigger, { children: [/* @__PURE__ */ jsx(MenuItem, {
|
|
2423
|
+
id: "project",
|
|
2424
|
+
icon: /* @__PURE__ */ jsx(FolderPlus, {}),
|
|
2425
|
+
children: "Add to project"
|
|
2426
|
+
}), /* @__PURE__ */ jsx(Popover, {
|
|
2427
|
+
placement: "end top",
|
|
2428
|
+
className: "w-60",
|
|
2429
|
+
children: /* @__PURE__ */ jsx(MenuSearch, {
|
|
2430
|
+
placeholder: "Search or create a project",
|
|
2431
|
+
children: /* @__PURE__ */ jsxs(Menu, {
|
|
2432
|
+
"aria-label": "Projects",
|
|
2433
|
+
className: "max-h-72",
|
|
2434
|
+
onAction: (key) => key === "__new" ? onCreateProject?.() : onAddToProject(String(key)),
|
|
2435
|
+
children: [onCreateProject && /* @__PURE__ */ jsx(MenuItem, {
|
|
2436
|
+
id: "__new",
|
|
2437
|
+
icon: /* @__PURE__ */ jsx(Plus, {}),
|
|
2438
|
+
children: "New project"
|
|
2439
|
+
}), (projects ?? []).map((p) => /* @__PURE__ */ jsx(MenuItem, {
|
|
2440
|
+
id: p.id,
|
|
2441
|
+
children: p.name
|
|
2442
|
+
}, p.id))]
|
|
2443
|
+
})
|
|
2444
|
+
})
|
|
2445
|
+
})] }),
|
|
2446
|
+
onShare && /* @__PURE__ */ jsx(MenuItem, {
|
|
2447
|
+
id: "share",
|
|
2448
|
+
icon: /* @__PURE__ */ jsx(Share, {}),
|
|
2449
|
+
children: "Share chat"
|
|
2450
|
+
}),
|
|
2451
|
+
children,
|
|
2452
|
+
onDelete && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(MenuSeparator, {}), /* @__PURE__ */ jsx(MenuItem, {
|
|
2453
|
+
id: "delete",
|
|
2454
|
+
icon: /* @__PURE__ */ jsx(Trash2, {}),
|
|
2455
|
+
shortcut: "D",
|
|
2456
|
+
tone: "danger",
|
|
2457
|
+
children: "Delete"
|
|
2458
|
+
})] })
|
|
2459
|
+
]
|
|
2460
|
+
});
|
|
2461
|
+
}
|
|
2462
|
+
// src/components/chat-list.tsx
|
|
2463
|
+
const isProject = (item) => "type" in item && item.type === "project";
|
|
2464
|
+
const rowClass = clsx("group/row relative flex h-8 w-full min-w-0 cursor-pointer items-center gap-2 rounded-chat-sm px-2 text-left font-chat-sans text-sm text-chat-fg outline-none", "data-hovered:bg-chat-hover data-pressed:bg-chat-selected data-focus-visible:ring-2 data-focus-visible:ring-chat-focus data-focus-visible:ring-inset", "[@media(pointer:coarse)]:h-10");
|
|
2465
|
+
/**
|
|
2466
|
+
* The chat picker: sections of chats and projects, collapsible, with an active row, running and unread
|
|
2467
|
+
* markers and a per-row menu. Usable on its own (in a host's own sidebar) or inside `Sidebar`.
|
|
2468
|
+
*/
|
|
2469
|
+
function ChatList({ sections, activeId, onSelect, rowMenu, className, ...props }) {
|
|
2470
|
+
const row = (chat, nested = false) => /* @__PURE__ */ jsx(ChatRow, {
|
|
2471
|
+
chat,
|
|
2472
|
+
active: chat.id === activeId,
|
|
2473
|
+
nested,
|
|
2474
|
+
...onSelect && { onSelect },
|
|
2475
|
+
...rowMenu && { menu: rowMenu(chat) }
|
|
2476
|
+
}, chat.id);
|
|
2477
|
+
const items = (list) => list.map((item) => isProject(item) ? /* @__PURE__ */ jsx(ProjectRow, {
|
|
2478
|
+
project: item,
|
|
2479
|
+
activeId,
|
|
2480
|
+
row
|
|
2481
|
+
}, item.id) : row(item));
|
|
2482
|
+
return /* @__PURE__ */ jsx("nav", {
|
|
2483
|
+
"aria-label": props["aria-label"] ?? "Chats",
|
|
2484
|
+
className: clsx("flex flex-col gap-3", className),
|
|
2485
|
+
children: sections.map((section) => section.title ? /* @__PURE__ */ jsxs(Disclosure$1, {
|
|
2486
|
+
defaultExpanded: section.defaultExpanded ?? true,
|
|
2487
|
+
className: "group/section",
|
|
2488
|
+
children: [/* @__PURE__ */ jsxs(Heading, {
|
|
2489
|
+
className: "m-0 flex h-7 items-center gap-1 pr-1 pl-2 font-chat-sans text-xs font-normal text-chat-subtle",
|
|
2490
|
+
children: [
|
|
2491
|
+
/* @__PURE__ */ jsxs(Button$1, {
|
|
2492
|
+
slot: "trigger",
|
|
2493
|
+
className: "group/trigger flex min-w-0 cursor-pointer items-center gap-1 rounded-chat-sm outline-none data-hovered:text-chat-muted data-focus-visible:ring-2 data-focus-visible:ring-chat-focus",
|
|
2494
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
2495
|
+
className: "truncate",
|
|
2496
|
+
children: section.title
|
|
2497
|
+
}), /* @__PURE__ */ jsx(ChevronRight, {
|
|
2498
|
+
"aria-hidden": true,
|
|
2499
|
+
className: "size-3 shrink-0 opacity-0 transition group-hover/section:opacity-100 group-aria-expanded/trigger:rotate-90 group-data-focus-visible/trigger:opacity-100 [@media(hover:none)]:opacity-100"
|
|
2500
|
+
})]
|
|
2501
|
+
}),
|
|
2502
|
+
section.badge,
|
|
2503
|
+
/* @__PURE__ */ jsx("span", {
|
|
2504
|
+
className: "ml-auto flex items-center",
|
|
2505
|
+
children: section.action
|
|
2506
|
+
})
|
|
2507
|
+
]
|
|
2508
|
+
}), /* @__PURE__ */ jsxs(DisclosurePanel, { children: [/* @__PURE__ */ jsx("ul", {
|
|
2509
|
+
className: "flex flex-col gap-px",
|
|
2510
|
+
children: items(section.items)
|
|
2511
|
+
}), section.onViewAll && /* @__PURE__ */ jsx(Button$1, {
|
|
2512
|
+
onPress: section.onViewAll,
|
|
2513
|
+
className: clsx(rowClass, "text-chat-muted"),
|
|
2514
|
+
children: "View all"
|
|
2515
|
+
})] })]
|
|
2516
|
+
}, section.id) : /* @__PURE__ */ jsx("ul", {
|
|
2517
|
+
className: "flex flex-col gap-px",
|
|
2518
|
+
children: items(section.items)
|
|
2519
|
+
}, section.id))
|
|
2520
|
+
});
|
|
2521
|
+
}
|
|
2522
|
+
function ChatRow({ chat, active, nested, onSelect, menu }) {
|
|
2523
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
2524
|
+
const { dismiss } = useSidebar();
|
|
2525
|
+
const markerClass = "size-2 shrink-0 rounded-full";
|
|
2526
|
+
const marker = chat.running ? /* @__PURE__ */ jsx("span", {
|
|
2527
|
+
role: "img",
|
|
2528
|
+
"aria-label": "Running",
|
|
2529
|
+
className: clsx(markerClass, "animate-pulse bg-chat-accent motion-reduce:animate-none")
|
|
2530
|
+
}) : chat.unread ? /* @__PURE__ */ jsx("span", {
|
|
2531
|
+
role: "img",
|
|
2532
|
+
"aria-label": "Unread",
|
|
2533
|
+
className: clsx(markerClass, "bg-chat-check")
|
|
2534
|
+
}) : null;
|
|
2535
|
+
const content = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
|
|
2536
|
+
className: clsx("min-w-0 flex-1 truncate", chat.unread && !active && "font-medium"),
|
|
2537
|
+
children: chat.title
|
|
2538
|
+
}), marker] });
|
|
2539
|
+
const common = {
|
|
2540
|
+
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"),
|
|
2541
|
+
"aria-current": active ? "page" : void 0,
|
|
2542
|
+
onPress: () => (onSelect?.(chat.id), dismiss())
|
|
2543
|
+
};
|
|
2544
|
+
return /* @__PURE__ */ jsxs("li", {
|
|
2545
|
+
className: "group/item relative",
|
|
2546
|
+
children: [chat.href ? /* @__PURE__ */ jsx(Link, {
|
|
2547
|
+
...common,
|
|
2548
|
+
href: chat.href,
|
|
2549
|
+
children: content
|
|
2550
|
+
}) : /* @__PURE__ */ jsx(Button$1, {
|
|
2551
|
+
...common,
|
|
2552
|
+
children: content
|
|
2553
|
+
}), menu && /* @__PURE__ */ jsxs(MenuTrigger, {
|
|
2554
|
+
isOpen: menuOpen,
|
|
2555
|
+
onOpenChange: setMenuOpen,
|
|
2556
|
+
children: [/* @__PURE__ */ jsx(IconButton, {
|
|
2557
|
+
label: `More options for ${chat.title}`,
|
|
2558
|
+
size: "sm",
|
|
2559
|
+
tooltip: false,
|
|
2560
|
+
className: clsx("absolute top-1/2 right-1 size-6 -translate-y-1/2 [@media(pointer:coarse)]:size-8", menuOpen || active ? "opacity-100" : "opacity-0 group-hover/item:opacity-100 group-focus-within/item:opacity-100 [@media(hover:none)]:opacity-100"),
|
|
2561
|
+
children: /* @__PURE__ */ jsx(MoreHorizontal, {})
|
|
2562
|
+
}), /* @__PURE__ */ jsx(Popover, {
|
|
2563
|
+
placement: "bottom start",
|
|
2564
|
+
children: menu
|
|
2565
|
+
})]
|
|
2566
|
+
})]
|
|
2567
|
+
});
|
|
2568
|
+
}
|
|
2569
|
+
function ProjectRow({ project, activeId, row }) {
|
|
2570
|
+
const holdsActive = project.chats.some((c) => c.id === activeId);
|
|
2571
|
+
return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(Disclosure$1, {
|
|
2572
|
+
defaultExpanded: project.defaultExpanded ?? holdsActive,
|
|
2573
|
+
children: ({ isExpanded }) => /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Heading, {
|
|
2574
|
+
className: "m-0 font-normal",
|
|
2575
|
+
children: /* @__PURE__ */ jsxs(Button$1, {
|
|
2576
|
+
slot: "trigger",
|
|
2577
|
+
className: rowClass,
|
|
2578
|
+
children: [
|
|
2579
|
+
isExpanded ? /* @__PURE__ */ jsx(FolderOpen, {
|
|
2580
|
+
"aria-hidden": true,
|
|
2581
|
+
className: "size-4 shrink-0 text-chat-muted"
|
|
2582
|
+
}) : /* @__PURE__ */ jsx(Folder, {
|
|
2583
|
+
"aria-hidden": true,
|
|
2584
|
+
className: "size-4 shrink-0 text-chat-muted"
|
|
2585
|
+
}),
|
|
2586
|
+
/* @__PURE__ */ jsx("span", {
|
|
2587
|
+
className: "min-w-0 flex-1 truncate",
|
|
2588
|
+
children: project.name
|
|
2589
|
+
}),
|
|
2590
|
+
!isExpanded && project.chats.some((c) => c.unread || c.running) && /* @__PURE__ */ jsx(Badge, { children: "New" })
|
|
2591
|
+
]
|
|
2592
|
+
})
|
|
2593
|
+
}), /* @__PURE__ */ jsx(DisclosurePanel, { children: /* @__PURE__ */ jsx("ul", {
|
|
2594
|
+
className: "flex flex-col gap-px",
|
|
2595
|
+
children: project.chats.map((c) => row(c, true))
|
|
2596
|
+
}) })] })
|
|
2597
|
+
}) });
|
|
2598
|
+
}
|
|
2599
|
+
// src/views/chat-view.tsx
|
|
2600
|
+
/** Column defaults per density; `contentWidth` / `contentPadding` override them. */
|
|
2601
|
+
const densityVars = {
|
|
2602
|
+
full: {},
|
|
2603
|
+
panel: {
|
|
2604
|
+
"--chat-content-width": "100%",
|
|
2605
|
+
"--chat-content-padding": "1rem",
|
|
2606
|
+
"--chat-message-font-size": "1rem"
|
|
2607
|
+
},
|
|
2608
|
+
inline: {
|
|
2609
|
+
"--chat-content-width": "100%",
|
|
2610
|
+
"--chat-content-padding": "0.75rem",
|
|
2611
|
+
"--chat-message-font-size": "0.9375rem"
|
|
2612
|
+
}
|
|
2613
|
+
};
|
|
2614
|
+
/** A whole chat: header, the conversation, the docked composer. An empty chat shows `emptyState`. */
|
|
2615
|
+
function ChatView({ messages, busy = false, onSend, onStop, approvals, onApprove, onRetry, onEdit, onFeedback, renderPart, header, composer, emptyState, disclaimer, density = "full", contentWidth, contentPadding, className }) {
|
|
2616
|
+
const style = {
|
|
2617
|
+
...densityVars[density],
|
|
2618
|
+
...contentWidth && { "--chat-content-width": contentWidth },
|
|
2619
|
+
...contentPadding && { "--chat-content-padding": contentPadding }
|
|
2620
|
+
};
|
|
2621
|
+
const empty = messages.length === 0;
|
|
2622
|
+
const [body, short] = useShortPane();
|
|
2623
|
+
const pending = onApprove && approvals?.[0];
|
|
2624
|
+
const box = (variant) => /* @__PURE__ */ jsx(Composer, {
|
|
2625
|
+
variant: density === "inline" || short && !empty ? "compact" : variant,
|
|
2626
|
+
busy,
|
|
2627
|
+
onSubmit: onSend,
|
|
2628
|
+
...onStop && { onStop },
|
|
2629
|
+
...composer,
|
|
2630
|
+
...pending && { placeholder: "Or reply directly…" }
|
|
2631
|
+
});
|
|
2632
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
2633
|
+
"data-chat-density": density,
|
|
2634
|
+
style,
|
|
2635
|
+
className: clsx("chat-view flex h-full min-h-0 flex-col bg-chat-bg text-chat-fg", className),
|
|
2636
|
+
children: [header, /* @__PURE__ */ jsx("div", {
|
|
2637
|
+
ref: body,
|
|
2638
|
+
className: "chat-view-body flex min-h-0 flex-1 flex-col",
|
|
2639
|
+
children: empty ? /* @__PURE__ */ jsx("div", {
|
|
2640
|
+
className: "flex flex-1 flex-col items-center justify-center overflow-y-auto px-(--chat-gutter) pb-[10cqh]",
|
|
2641
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
2642
|
+
className: "w-full max-w-(--chat-content-width)",
|
|
2643
|
+
children: emptyState ? emptyState(box("centered")) : box("centered")
|
|
2644
|
+
})
|
|
2645
|
+
}) : /* @__PURE__ */ jsx(MessageList, {
|
|
2646
|
+
messages,
|
|
2647
|
+
...onRetry && { onRetry },
|
|
2648
|
+
...onEdit && { onEdit },
|
|
2649
|
+
...onFeedback && { onFeedback },
|
|
2650
|
+
...renderPart && { renderPart },
|
|
2651
|
+
footer: /* @__PURE__ */ jsx("div", {
|
|
2652
|
+
className: clsx("bg-linear-to-t from-chat-bg from-70% to-transparent px-(--chat-gutter)", short ? "pt-2" : "pt-4"),
|
|
2653
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
2654
|
+
className: "mx-auto w-full max-w-(--chat-content-width)",
|
|
2655
|
+
children: [
|
|
2656
|
+
pending && /* @__PURE__ */ jsx(ApprovalCard, {
|
|
2657
|
+
approval: pending,
|
|
2658
|
+
onRespond: onApprove,
|
|
2659
|
+
className: "mb-2"
|
|
2660
|
+
}, pending.part.id),
|
|
2661
|
+
box("docked"),
|
|
2662
|
+
/* @__PURE__ */ jsx("div", {
|
|
2663
|
+
className: clsx("flex items-center justify-center py-1 pb-[max(0.25rem,env(safe-area-inset-bottom))] text-center font-chat-sans text-xs text-chat-subtle", short ? "min-h-2" : "min-h-6"),
|
|
2664
|
+
children: disclaimer && !short && /* @__PURE__ */ jsx("span", { children: disclaimer })
|
|
2665
|
+
})
|
|
2666
|
+
]
|
|
2667
|
+
})
|
|
2668
|
+
})
|
|
2669
|
+
})
|
|
2670
|
+
})]
|
|
2671
|
+
});
|
|
2672
|
+
}
|
|
2673
|
+
/** Below this pane height (px) the docked composer is one row and the disclaimer hides, e.g. stacked panes or a phone on its side. */
|
|
2674
|
+
const SHORT_PANE = 360;
|
|
2675
|
+
/** Tracks the chat body's own height, so it adapts to its pane rather than the window. */
|
|
2676
|
+
function useShortPane() {
|
|
2677
|
+
const [short, setShort] = useState(false);
|
|
2678
|
+
const observer = useRef(null);
|
|
2679
|
+
return [useCallback((el) => {
|
|
2680
|
+
observer.current?.disconnect();
|
|
2681
|
+
if (!el) return;
|
|
2682
|
+
observer.current = new ResizeObserver(([entry]) => entry && setShort(entry.contentRect.height < SHORT_PANE));
|
|
2683
|
+
observer.current.observe(el);
|
|
2684
|
+
}, []), short];
|
|
2685
|
+
}
|
|
2686
|
+
/** A new chat: greeting over a centered composer. */
|
|
2687
|
+
function EmptyState({ greeting, icon, children, footer }) {
|
|
2688
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
2689
|
+
className: "flex flex-col gap-8",
|
|
2690
|
+
children: [
|
|
2691
|
+
/* @__PURE__ */ jsxs("h1", {
|
|
2692
|
+
className: "chat-empty-greeting flex items-center justify-center gap-3 text-center font-chat-serif text-[clamp(1.5rem,6cqw,2.5rem)] font-normal text-chat-fg",
|
|
2693
|
+
children: [icon && /* @__PURE__ */ jsx("span", {
|
|
2694
|
+
className: "flex shrink-0 text-chat-accent [&_svg]:size-[1em]",
|
|
2695
|
+
children: icon
|
|
2696
|
+
}), greeting]
|
|
2697
|
+
}),
|
|
2698
|
+
children,
|
|
2699
|
+
footer
|
|
2700
|
+
]
|
|
2701
|
+
});
|
|
2702
|
+
}
|
|
2703
|
+
// src/views/app-shell.tsx
|
|
2704
|
+
/**
|
|
2705
|
+
* A page with a sidebar: pinned and collapsible to an icon rail from tablet width up, a drawer over the
|
|
2706
|
+
* content on phones. Children get `useSidebar()` and `<SidebarToggle>`.
|
|
2707
|
+
*/
|
|
2708
|
+
function AppShell({ sidebar, children, defaultCollapsed, className }) {
|
|
2709
|
+
const drawer = useMediaQuery(DRAWER_QUERY);
|
|
2710
|
+
const [collapsed, setCollapsed] = useState(() => defaultCollapsed ?? (typeof window !== "undefined" && window.matchMedia("(max-width: 1023.98px)").matches));
|
|
2711
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
2712
|
+
const toggle = useCallback(() => drawer ? setDrawerOpen((o) => !o) : setCollapsed((c) => !c), [drawer]);
|
|
2713
|
+
const dismiss = useCallback(() => setDrawerOpen(false), []);
|
|
2714
|
+
const pinned = useMemo(() => ({
|
|
2715
|
+
collapsed,
|
|
2716
|
+
drawer,
|
|
2717
|
+
drawerOpen,
|
|
2718
|
+
toggle,
|
|
2719
|
+
dismiss
|
|
2720
|
+
}), [
|
|
2721
|
+
collapsed,
|
|
2722
|
+
drawer,
|
|
2723
|
+
drawerOpen,
|
|
2724
|
+
toggle,
|
|
2725
|
+
dismiss
|
|
2726
|
+
]);
|
|
2727
|
+
const inDrawer = useMemo(() => ({
|
|
2728
|
+
...pinned,
|
|
2729
|
+
collapsed: false
|
|
2730
|
+
}), [pinned]);
|
|
2731
|
+
return /* @__PURE__ */ jsxs(SidebarContext.Provider, {
|
|
2732
|
+
value: pinned,
|
|
2733
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
2734
|
+
className: clsx("flex h-dvh overflow-hidden bg-chat-bg text-chat-fg", className),
|
|
2735
|
+
children: [!drawer && /* @__PURE__ */ jsx("aside", {
|
|
2736
|
+
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)"),
|
|
2737
|
+
children: sidebar
|
|
2738
|
+
}), /* @__PURE__ */ jsx("main", {
|
|
2739
|
+
className: "flex min-w-0 flex-1 flex-col",
|
|
2740
|
+
children
|
|
2741
|
+
})]
|
|
2742
|
+
}), drawer && /* @__PURE__ */ jsx(Drawer, {
|
|
2743
|
+
isOpen: drawerOpen,
|
|
2744
|
+
onOpenChange: setDrawerOpen,
|
|
2745
|
+
"aria-label": "Sidebar",
|
|
2746
|
+
children: /* @__PURE__ */ jsx(SidebarContext.Provider, {
|
|
2747
|
+
value: inDrawer,
|
|
2748
|
+
children: sidebar
|
|
2749
|
+
})
|
|
2750
|
+
})]
|
|
2751
|
+
});
|
|
2752
|
+
}
|
|
2753
|
+
/** Opens the drawer on phones, collapses / expands the pinned sidebar elsewhere. */
|
|
2754
|
+
function SidebarToggle({ drawerOnly = false, className }) {
|
|
2755
|
+
const { drawer, drawerOpen, collapsed, toggle } = useSidebar();
|
|
2756
|
+
if (drawerOnly && !drawer) return null;
|
|
2757
|
+
return /* @__PURE__ */ jsx(IconButton, {
|
|
2758
|
+
label: drawer ? drawerOpen ? "Close sidebar" : "Open sidebar" : collapsed ? "Expand sidebar" : "Collapse sidebar",
|
|
2759
|
+
onPress: toggle,
|
|
2760
|
+
className,
|
|
2761
|
+
children: /* @__PURE__ */ jsx(PanelLeft, {})
|
|
2762
|
+
});
|
|
2763
|
+
}
|
|
2764
|
+
// src/views/sidebar.tsx
|
|
2765
|
+
/** The claude.ai sidebar frame. Collapses to an icon rail inside `AppShell`; a drawer on phones. */
|
|
2766
|
+
function Sidebar({ brand, headerActions, nav, children, footer, className }) {
|
|
2767
|
+
const { collapsed } = useSidebar();
|
|
2768
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
2769
|
+
className: clsx("flex h-full flex-col font-chat-sans", collapsed ? "w-13" : "w-full", className),
|
|
2770
|
+
children: [
|
|
2771
|
+
/* @__PURE__ */ jsxs("div", {
|
|
2772
|
+
className: clsx("flex h-12 shrink-0 items-center gap-1 px-2", collapsed && "justify-center"),
|
|
2773
|
+
children: [
|
|
2774
|
+
!collapsed && /* @__PURE__ */ jsx("div", {
|
|
2775
|
+
className: "min-w-0 flex-1 truncate px-1.5 font-chat-serif text-lg text-chat-fg",
|
|
2776
|
+
children: brand
|
|
2777
|
+
}),
|
|
2778
|
+
!collapsed && headerActions,
|
|
2779
|
+
/* @__PURE__ */ jsx(SidebarToggle, {})
|
|
2780
|
+
]
|
|
2781
|
+
}),
|
|
2782
|
+
nav && /* @__PURE__ */ jsx("ul", {
|
|
2783
|
+
className: clsx("flex shrink-0 flex-col gap-px px-2 pb-2", collapsed && "items-center"),
|
|
2784
|
+
children: nav
|
|
2785
|
+
}),
|
|
2786
|
+
!collapsed && /* @__PURE__ */ jsx("div", {
|
|
2787
|
+
className: "min-h-0 flex-1 overflow-y-auto px-2 pt-2 pb-4 [scrollbar-width:thin]",
|
|
2788
|
+
children
|
|
2789
|
+
}),
|
|
2790
|
+
collapsed && /* @__PURE__ */ jsx("div", { className: "flex-1" }),
|
|
2791
|
+
footer && /* @__PURE__ */ jsx("div", {
|
|
2792
|
+
className: "shrink-0 border-t border-chat-divider p-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]",
|
|
2793
|
+
children: footer
|
|
2794
|
+
})
|
|
2795
|
+
]
|
|
2796
|
+
});
|
|
2797
|
+
}
|
|
2798
|
+
/** A nav row: icon and label; just the icon with a tooltip when collapsed. Closes the drawer when picked. */
|
|
2799
|
+
function SidebarItem({ icon, label, onPress, href, active, shortcut }) {
|
|
2800
|
+
const { collapsed, dismiss } = useSidebar();
|
|
2801
|
+
const className = clsx("group flex h-8 cursor-pointer items-center gap-2.5 rounded-chat-sm text-left text-sm 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");
|
|
2802
|
+
const press = () => {
|
|
2803
|
+
onPress?.();
|
|
2804
|
+
dismiss();
|
|
2805
|
+
};
|
|
2806
|
+
const content = /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
2807
|
+
icon,
|
|
2808
|
+
collapsed ? /* @__PURE__ */ jsx("span", {
|
|
2809
|
+
className: "sr-only",
|
|
2810
|
+
children: label
|
|
2811
|
+
}) : /* @__PURE__ */ jsx("span", {
|
|
2812
|
+
className: "min-w-0 flex-1 truncate",
|
|
2813
|
+
children: label
|
|
2814
|
+
}),
|
|
2815
|
+
!collapsed && shortcut && /* @__PURE__ */ jsx("kbd", {
|
|
2816
|
+
className: "font-chat-sans text-xs text-chat-subtle opacity-0 group-data-hovered:opacity-100",
|
|
2817
|
+
children: shortcut
|
|
2818
|
+
})
|
|
2819
|
+
] });
|
|
2820
|
+
const common = {
|
|
2821
|
+
className,
|
|
2822
|
+
onPress: press,
|
|
2823
|
+
"aria-current": active ? "page" : void 0
|
|
2824
|
+
};
|
|
2825
|
+
const control = href ? /* @__PURE__ */ jsx(Link, {
|
|
2826
|
+
...common,
|
|
2827
|
+
href,
|
|
2828
|
+
children: content
|
|
2829
|
+
}) : /* @__PURE__ */ jsx(Button$1, {
|
|
2830
|
+
...common,
|
|
2831
|
+
children: content
|
|
2832
|
+
});
|
|
2833
|
+
return /* @__PURE__ */ jsx("li", { children: collapsed ? /* @__PURE__ */ jsx(Tooltip, {
|
|
2834
|
+
content: label,
|
|
2835
|
+
placement: "right",
|
|
2836
|
+
children: control
|
|
2837
|
+
}) : control });
|
|
2838
|
+
}
|
|
2839
|
+
const initials = (name) => name.split(/\s+/).slice(0, 2).map((w) => w[0]?.toUpperCase() ?? "").join("");
|
|
2840
|
+
/** The account row at the bottom: avatar, name, plan, and a menu. */
|
|
2841
|
+
function SidebarUser({ name, detail, avatarUrl, menu }) {
|
|
2842
|
+
const { collapsed } = useSidebar();
|
|
2843
|
+
const avatar = avatarUrl ? /* @__PURE__ */ jsx("img", {
|
|
2844
|
+
src: avatarUrl,
|
|
2845
|
+
alt: "",
|
|
2846
|
+
className: "size-8 shrink-0 rounded-full object-cover"
|
|
2847
|
+
}) : /* @__PURE__ */ jsx("span", {
|
|
2848
|
+
"aria-hidden": true,
|
|
2849
|
+
className: "flex size-8 shrink-0 items-center justify-center rounded-full bg-chat-fg text-xs font-medium text-chat-bg",
|
|
2850
|
+
children: initials(name)
|
|
2851
|
+
});
|
|
2852
|
+
const button = /* @__PURE__ */ jsxs(Button$1, {
|
|
2853
|
+
"aria-label": collapsed ? name : void 0,
|
|
2854
|
+
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"),
|
|
2855
|
+
children: [avatar, !collapsed && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("span", {
|
|
2856
|
+
className: "flex min-w-0 flex-1 flex-col",
|
|
2857
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
2858
|
+
className: "truncate text-sm text-chat-fg",
|
|
2859
|
+
children: name
|
|
2860
|
+
}), detail && /* @__PURE__ */ jsx("span", {
|
|
2861
|
+
className: "truncate text-xs text-chat-muted",
|
|
2862
|
+
children: detail
|
|
2863
|
+
})]
|
|
2864
|
+
}), menu && /* @__PURE__ */ jsx(ChevronsUpDown, {
|
|
2865
|
+
"aria-hidden": true,
|
|
2866
|
+
className: "size-4 shrink-0 text-chat-muted"
|
|
2867
|
+
})] })]
|
|
2868
|
+
});
|
|
2869
|
+
if (!menu) return button;
|
|
2870
|
+
return /* @__PURE__ */ jsxs(MenuTrigger, { children: [button, /* @__PURE__ */ jsx(Popover, {
|
|
2871
|
+
placement: "top start",
|
|
2872
|
+
children: menu
|
|
2873
|
+
})] });
|
|
2874
|
+
}
|
|
2875
|
+
export { ActivityGroup, AgentProvider, AgentRegistry, AppShell, ApprovalCard, AssistantMessage, AttachButton, AttachmentCard, AttachmentRow, Badge, Button, Caret, ChatHeader, ChatList, ChatTheme, ChatTitleMenu, ChatView, CodeBlock, Composer, ContextMenu, ContextPanel, DRAWER_QUERY, Disclosure, Drawer, EmptyState, IconButton, Markdown, Menu, MenuItem, MenuNote, MenuSearch, MenuSection, MenuSeparator, MenuTrigger, MessageList, Modal, ModelPicker, Panel, PastedTextDialog, Popover, Sidebar, SidebarItem, SidebarToggle, SidebarUser, SubmenuTrigger, TokenCounter, ToolViewsProvider, Tooltip, UserMessage, activityHeading, activityLabel, commandTool, defaultToolViews, formatDuration, formatRelativeTime, formatTokens, memoryTool, messageText, sseTransport, useAgentContext, useAgentRegistry, useAgentSnapshot, useAgentTool, useAttachments, useChat, useChatTool, useCountUp, useMediaQuery, useSidebar, useSmoothText, useToolView, useToolViews, userMessage, webSearchTool };
|