deepspace 0.3.10 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/dist/cli.js +331 -115
  2. package/dist/cli.js.map +1 -1
  3. package/dist/index.d.ts +199 -16
  4. package/dist/index.js +354 -166
  5. package/dist/index.js.map +1 -1
  6. package/dist/server.d.ts +242 -73
  7. package/dist/server.js +985 -374
  8. package/dist/server.js.map +1 -1
  9. package/dist/worker.d.ts +322 -73
  10. package/dist/worker.js +961 -343
  11. package/dist/worker.js.map +1 -1
  12. package/features/admin-page/feature.json +29 -0
  13. package/features/admin-page/src/AdminPage.tsx +292 -0
  14. package/features/admin-page/src/admin-schema.ts +20 -0
  15. package/features/ai-chat/feature.json +41 -0
  16. package/features/ai-chat/src/AiChatPage.tsx +692 -0
  17. package/features/ai-chat/src/ChatPanel.tsx +1098 -0
  18. package/features/ai-chat/src/ai-chat-schema.ts +11 -0
  19. package/features/canvas/feature.json +45 -0
  20. package/features/canvas/src/CanvasEditorPage.tsx +87 -0
  21. package/features/canvas/src/CanvasListPage.tsx +202 -0
  22. package/features/canvas/src/canvas-schema.ts +33 -0
  23. package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
  24. package/features/canvas/src/components/CanvasView.tsx +369 -0
  25. package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
  26. package/features/cron/feature.json +33 -0
  27. package/features/cron/src/CronLogPage.tsx +100 -0
  28. package/features/cron/src/cron.ts +25 -0
  29. package/features/docs/feature.json +96 -0
  30. package/features/docs/src/DocsEditorPage.tsx +1012 -0
  31. package/features/docs/src/DocsListPage.tsx +951 -0
  32. package/features/docs/src/DocsOutlinePanel.tsx +68 -0
  33. package/features/docs/src/DocsPresence.tsx +138 -0
  34. package/features/docs/src/InviteDialog.tsx +336 -0
  35. package/features/docs/src/LibrarySidebar.tsx +464 -0
  36. package/features/docs/src/docs-favorites.ts +18 -0
  37. package/features/docs/src/docs-library-types.ts +15 -0
  38. package/features/docs/src/docs-schema.ts +80 -0
  39. package/features/docs/src/docs-ui.css +309 -0
  40. package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
  41. package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
  42. package/features/docs/src/editor/useDocEditor.ts +206 -0
  43. package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
  44. package/features/file-attachments/feature.json +42 -0
  45. package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
  46. package/features/file-attachments/src/attachments-constants.ts +9 -0
  47. package/features/file-attachments/src/attachments-schema.ts +45 -0
  48. package/features/file-attachments/src/useFilePreview.ts +471 -0
  49. package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
  50. package/features/file-manager/feature.json +24 -0
  51. package/features/file-manager/src/FileManagerPage.tsx +330 -0
  52. package/features/integration-test/feature.json +18 -0
  53. package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
  54. package/features/integration-test/src/integration.spec.ts +42 -0
  55. package/features/items/feature.json +33 -0
  56. package/features/items/src/ItemsPage.tsx +325 -0
  57. package/features/items/src/items-constants.ts +10 -0
  58. package/features/items/src/items-schema.ts +27 -0
  59. package/features/kanban/feature.json +18 -0
  60. package/features/kanban/src/Kanban.tsx +283 -0
  61. package/features/landing/feature.json +62 -0
  62. package/features/landing/src/LandingPage.tsx +627 -0
  63. package/features/landing/src/primitives.tsx +389 -0
  64. package/features/landing/src/sections/FAQSection.tsx +110 -0
  65. package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
  66. package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
  67. package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
  68. package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
  69. package/features/landing/src/sections/StatsSection.tsx +31 -0
  70. package/features/landing/src/sections/TeamSection.tsx +86 -0
  71. package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
  72. package/features/landing/src/sections/VideoSection.tsx +71 -0
  73. package/features/leaderboard/feature.json +33 -0
  74. package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
  75. package/features/leaderboard/src/leaderboard-constants.ts +21 -0
  76. package/features/leaderboard/src/leaderboard-schema.ts +36 -0
  77. package/features/messaging/feature.json +111 -0
  78. package/features/messaging/src/ChatMultiPage.tsx +300 -0
  79. package/features/messaging/src/ChatPage.tsx +75 -0
  80. package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
  81. package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
  82. package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
  83. package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
  84. package/features/messaging/src/components/chat/MessageList.tsx +248 -0
  85. package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
  86. package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
  87. package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
  88. package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
  89. package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
  90. package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
  91. package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
  92. package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
  93. package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
  94. package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
  95. package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
  96. package/features/messaging/src/hooks/useChatChannel.ts +64 -0
  97. package/features/messaging/src/hooks/useLongPress.ts +74 -0
  98. package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
  99. package/features/messaging/src/messaging-schema.ts +25 -0
  100. package/features/presence-test/feature.json +21 -0
  101. package/features/presence-test/src/PresenceTestPage.tsx +120 -0
  102. package/features/sidebar/feature.json +19 -0
  103. package/features/sidebar/src/AppSidebar.tsx +558 -0
  104. package/features/tasks/feature.json +33 -0
  105. package/features/tasks/src/TasksPage.tsx +598 -0
  106. package/features/tasks/src/tasks-constants.ts +43 -0
  107. package/features/tasks/src/tasks-schema.ts +56 -0
  108. package/features/testing/feature.json +49 -0
  109. package/features/testing/src/TestPage.tsx +145 -0
  110. package/features/testing/src/cpu-burn-action.ts +51 -0
  111. package/features/testing/src/subrequest-burn-action.ts +53 -0
  112. package/features/testing/src/test-schema.ts +23 -0
  113. package/features/topbar/feature.json +18 -0
  114. package/features/topbar/src/Topbar.tsx +353 -0
  115. package/features/tree/feature.json +26 -0
  116. package/features/tree/src/TreeSidebar.tsx +607 -0
  117. package/features/tree/src/tree-constants.ts +68 -0
  118. package/features/tree/src/tree-hooks.ts +258 -0
  119. package/package.json +4 -2
  120. package/scripts/add-feature.cjs +654 -0
@@ -0,0 +1,1098 @@
1
+ /**
2
+ * ChatPanel — DO-backed AI chat surface.
3
+ *
4
+ * Reads canonical messages via `useQuery('ai-messages')` and sends new turns
5
+ * through `POST /api/ai/chat`. The panel itself owns no chat lifecycle —
6
+ * pass `chatId={null}` and we'll auto-create on first send via `onChatCreated`.
7
+ */
8
+
9
+ import {
10
+ useState,
11
+ useRef,
12
+ useEffect,
13
+ useMemo,
14
+ useCallback,
15
+ type KeyboardEvent,
16
+ type ReactNode,
17
+ } from 'react'
18
+ import ReactMarkdown, { type Components } from 'react-markdown'
19
+ import remarkGfm from 'remark-gfm'
20
+ import remarkBreaks from 'remark-breaks'
21
+ import rehypeHighlight from 'rehype-highlight'
22
+ import {
23
+ AlertCircle,
24
+ ArrowUp,
25
+ Check,
26
+ ChevronDown,
27
+ Copy,
28
+ Square,
29
+ } from 'lucide-react'
30
+ // Atom One Dark — always-dark code blocks, reads cleanly under both
31
+ // light and dark host themes (matches the conventional chat-app pattern).
32
+ import 'highlight.js/styles/atom-one-dark.css'
33
+ import {
34
+ getAuthToken,
35
+ useQuery,
36
+ parseSseLine,
37
+ decodeAiStreamChunk,
38
+ type AiStreamAction,
39
+ } from 'deepspace'
40
+
41
+ type ModelOption = { id: string; label: string; provider: string }
42
+
43
+ // The ai-messages columns. useQuery wraps this in a RecordData envelope
44
+ // (recordId / data / createdAt / updatedAt) — we flatten before rendering.
45
+ type AiMessageData = {
46
+ chatId: string
47
+ userId: string
48
+ role: 'user' | 'assistant' | 'system'
49
+ content: string
50
+ parts?: unknown[]
51
+ }
52
+
53
+ type RenderMessage = {
54
+ id: string
55
+ role: 'user' | 'assistant' | 'system'
56
+ content: string
57
+ parts?: unknown[]
58
+ }
59
+
60
+ type InFlightMessage = {
61
+ id: string
62
+ role: 'user' | 'assistant'
63
+ content: string
64
+ parts: MessagePart[]
65
+ /** Server-assigned recordId for the row that will be persisted at
66
+ * onFinish — set on assistant messages once the response header
67
+ * `X-Asst-Id` arrives. Dedup against the WebSocket-broadcast persisted
68
+ * row checks this id (which matches), avoiding the clock-skew bug that
69
+ * bit the previous `spawnTime` vs `createdAt` comparison. Undefined for
70
+ * user rows (whose `id` already matches the server-side recordId). */
71
+ serverId?: string
72
+ /** The chat this overlay belongs to. Items from a previous chat must
73
+ * not render in the current view. */
74
+ forChatId: string
75
+ }
76
+
77
+ export interface ChatPanelProps {
78
+ /** Active chat. `null` triggers auto-create on first send. */
79
+ chatId: string | null
80
+ /** Current user id; scopes the messages query as defense-in-depth. */
81
+ userId: string
82
+ /** Fired when the panel auto-creates a chat. */
83
+ onChatCreated?: (chatId: string) => void
84
+ /** Models to show in the picker. Defaults to the SDK's default set. */
85
+ models?: ModelOption[]
86
+ /** Clickable prompts shown when the conversation is empty. */
87
+ emptyStatePrompts?: string[]
88
+ /** Applied to the outer container. Use to control size, border, background. */
89
+ className?: string
90
+ /** Optional header slot (title, close button, etc.). Rendered above messages. */
91
+ header?: ReactNode
92
+ /** Tighter paddings and type for narrow containers (<400px). */
93
+ compact?: boolean
94
+ /** Suspend send while the parent has a chat-create in flight. Without
95
+ * this, a fast typist can submit between the parent's eager-create POST
96
+ * and its response — both layers would auto-create, spawning two chats. */
97
+ disabled?: boolean
98
+ }
99
+
100
+ const MODEL_STORAGE_KEY = 'deepspace-ai-model'
101
+
102
+ const DEFAULT_PROMPTS = [
103
+ 'What can you help with?',
104
+ 'Summarize recent activity',
105
+ 'List my collections',
106
+ ]
107
+
108
+ // Mirrors the allowlist in the scaffolded worker.ts. Sonnet 4.6 is the
109
+ // default — balanced cost/capability with the same 1M-token context as
110
+ // Opus 4.7 at ~3x lower price. Within each provider we list flagship → cheap
111
+ // so the picker shows the most capable option first when a user opens it.
112
+ // Override by passing your own `models` prop.
113
+ const DEFAULT_MODELS: ModelOption[] = [
114
+ { id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', provider: 'Anthropic' },
115
+ { id: 'claude-opus-4-7', label: 'Claude Opus 4.7', provider: 'Anthropic' },
116
+ { id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', provider: 'Anthropic' },
117
+ { id: 'gpt-5.4', label: 'GPT-5.4', provider: 'OpenAI' },
118
+ { id: 'gpt-5.4-mini', label: 'GPT-5.4 mini', provider: 'OpenAI' },
119
+ { id: 'gpt-5.4-nano', label: 'GPT-5.4 nano', provider: 'OpenAI' },
120
+ { id: 'gpt-oss-120b', label: 'GPT-OSS 120B', provider: 'Cerebras' },
121
+ ]
122
+
123
+ // ============================================================================
124
+ // useStreamingChat — POSTs /api/ai/chat, parses Vercel data-stream lines,
125
+ // exposes an in-flight overlay until useQuery delivers the persisted rows.
126
+ // ============================================================================
127
+
128
+ function useStreamingChat(
129
+ chatId: string | null,
130
+ modelId: string | undefined,
131
+ onChatCreated?: (id: string) => void,
132
+ ) {
133
+ const [inFlight, setInFlight] = useState<InFlightMessage[]>([])
134
+ const [isLoading, setIsLoading] = useState(false)
135
+ // Mirror of `isLoading` for use inside `send`'s in-flight guard. Reading
136
+ // from state directly would force `isLoading` into the useCallback deps,
137
+ // recreating `send` on every loading flip and exposing a stale-closure
138
+ // window where a programmatic retry could see the pre-flip closure.
139
+ const isLoadingRef = useRef(false)
140
+ const [error, setError] = useState<Error | null>(null)
141
+ const abortRef = useRef<AbortController | null>(null)
142
+ const lastSendRef = useRef<{ content: string } | null>(null)
143
+ const prevChatIdRef = useRef<string | null>(chatId)
144
+
145
+ // Abort on any transition AWAY from a real chatId — chat switch (id→id) and
146
+ // chat clear (id→null, e.g. the active chat being deleted). null→newId is
147
+ // the auto-create promotion mid-send and must NOT abort, or the user's
148
+ // first message dies before it reaches the server. Without aborting on
149
+ // id→null, deleting the chat you're streaming in lets onFinish persist an
150
+ // orphan assistant row tagged with a chatId that no longer exists.
151
+ useEffect(() => {
152
+ const prev = prevChatIdRef.current
153
+ prevChatIdRef.current = chatId
154
+ if (prev !== null && prev !== chatId) {
155
+ abortRef.current?.abort()
156
+ setInFlight([])
157
+ }
158
+ }, [chatId])
159
+
160
+ // Abort any in-flight stream on unmount — without this, navigating away
161
+ // mid-stream leaks the fetch (response body keeps being consumed, setState
162
+ // fires on an unmounted component, AI tokens keep accruing server-side).
163
+ useEffect(() => {
164
+ return () => {
165
+ abortRef.current?.abort()
166
+ }
167
+ }, [])
168
+
169
+ const send = useCallback(
170
+ async (content: string) => {
171
+ // Defense-in-depth guard for programmatic call paths (retry, etc.) —
172
+ // the canSend UI gate already blocks form submits while loading. We
173
+ // read from a ref (not state) so this callback stays stable across
174
+ // loading transitions; that closes the stale-closure window a retry
175
+ // click could otherwise hit during the brief moment between
176
+ // setIsLoading(true) and React re-rendering with the new closure.
177
+ if (isLoadingRef.current) return
178
+ setError(null)
179
+ lastSendRef.current = { content }
180
+
181
+ let activeChatId = chatId
182
+ if (!activeChatId) {
183
+ try {
184
+ const token = await getAuthToken()
185
+ const res = await fetch('/api/ai/chats', {
186
+ method: 'POST',
187
+ headers: {
188
+ 'Content-Type': 'application/json',
189
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
190
+ },
191
+ body: JSON.stringify({}),
192
+ })
193
+ if (!res.ok) throw new Error(`Failed to create chat: ${res.status}`)
194
+ const data = (await res.json()) as { chat: { id: string } }
195
+ activeChatId = data.chat.id
196
+ onChatCreated?.(activeChatId)
197
+ } catch (err) {
198
+ setError(err instanceof Error ? err : new Error(String(err)))
199
+ return
200
+ }
201
+ }
202
+
203
+ const localTs = Date.now()
204
+ const userMessageId = `usr-${localTs}-${Math.random().toString(36).slice(2, 8)}`
205
+ const assistantId = `asst-pending-${localTs}`
206
+ // forChatId uses the resolved id (whether passed in or just created above).
207
+ const userMsg: InFlightMessage = { id: userMessageId, role: 'user', content, parts: [], forChatId: activeChatId }
208
+ const asstMsg: InFlightMessage = { id: assistantId, role: 'assistant', content: '', parts: [], forChatId: activeChatId }
209
+ setInFlight([userMsg, asstMsg])
210
+ isLoadingRef.current = true
211
+ setIsLoading(true)
212
+ abortRef.current = new AbortController()
213
+
214
+ try {
215
+ const token = await getAuthToken()
216
+ const res = await fetch('/api/ai/chat', {
217
+ method: 'POST',
218
+ headers: {
219
+ 'Content-Type': 'application/json',
220
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
221
+ },
222
+ body: JSON.stringify({ chatId: activeChatId, userMessageId, content, modelId }),
223
+ signal: abortRef.current.signal,
224
+ })
225
+ if (!res.ok || !res.body) {
226
+ const detail = res.body ? await res.text().catch(() => '') : ''
227
+ throw new Error(detail || `Request failed: ${res.status}`)
228
+ }
229
+
230
+ // Tag the in-flight assistant row with the server-assigned id so the
231
+ // dedup memo can drop it once the persisted row arrives over WS,
232
+ // independent of either party's clock.
233
+ const serverAsstId = res.headers.get('X-Asst-Id')
234
+ if (serverAsstId) {
235
+ setInFlight((cur) =>
236
+ cur.map((m) => (m.id === assistantId ? { ...m, serverId: serverAsstId } : m)),
237
+ )
238
+ }
239
+
240
+ // v5's `toUIMessageStreamResponse` emits SSE: `data: <json>\n\n`
241
+ // events terminating with `data: [DONE]\n\n`. `parseSseLine` +
242
+ // `decodeAiStreamChunk` are the SDK's pure decoders (covered by
243
+ // `ai-stream.test.ts`); we just apply the resulting actions to
244
+ // local React state.
245
+ const reader = res.body.getReader()
246
+ const decoder = new TextDecoder()
247
+ let buf = ''
248
+ while (true) {
249
+ const { done, value } = await reader.read()
250
+ if (done) break
251
+ buf += decoder.decode(value, { stream: true })
252
+ const lines = buf.split('\n')
253
+ buf = lines.pop() ?? ''
254
+ for (const line of lines) handleSseLine(line, assistantId, setInFlight, setError)
255
+ }
256
+ // Flush any pending bytes the streaming decoder held back at a
257
+ // multi-byte boundary, then process the trailing line.
258
+ buf += decoder.decode()
259
+ handleSseLine(buf, assistantId, setInFlight, setError)
260
+ } catch (err) {
261
+ const e = err instanceof Error ? err : new Error(String(err))
262
+ if (e.name !== 'AbortError') {
263
+ console.error('[chat] STREAM error', { name: e.name, message: e.message })
264
+ setError(e)
265
+ }
266
+ } finally {
267
+ isLoadingRef.current = false
268
+ setIsLoading(false)
269
+ abortRef.current = null
270
+ // No wall-clock timer to clear inFlight. Dedup is by id: user rows
271
+ // share their id with the server; assistant rows are matched via
272
+ // the server-emitted `X-Asst-Id` header captured into `serverId`.
273
+ }
274
+ },
275
+ [chatId, modelId, onChatCreated],
276
+ )
277
+
278
+ const stop = useCallback(() => {
279
+ abortRef.current?.abort()
280
+ }, [])
281
+
282
+ const retry = useCallback(() => {
283
+ if (lastSendRef.current) void send(lastSendRef.current.content)
284
+ }, [send])
285
+
286
+ return { send, stop, retry, isLoading, error, inFlight }
287
+ }
288
+
289
+ // SDK-decoded chunk → React state mutation. The decoder vocabulary
290
+ // (`AiStreamAction`) lives in `deepspace/src/client/ai-stream.ts` and is
291
+ // covered by unit tests. This function does the React-bound part: which
292
+ // in-flight message to update, how to coalesce text, when to drop the
293
+ // overlay. Keep it dumb — anything the decoder can decide should stay
294
+ // in the decoder so it remains testable in isolation.
295
+ function handleSseLine(
296
+ line: string,
297
+ assistantId: string,
298
+ setInFlight: React.Dispatch<React.SetStateAction<InFlightMessage[]>>,
299
+ setError: (e: Error | null) => void,
300
+ ) {
301
+ const chunk = parseSseLine(line)
302
+ if (!chunk) return
303
+ const action = decodeAiStreamChunk(chunk)
304
+ if (!action) return
305
+ applyStreamAction(action, assistantId, setInFlight, setError)
306
+ }
307
+
308
+ function applyStreamAction(
309
+ action: AiStreamAction,
310
+ assistantId: string,
311
+ setInFlight: React.Dispatch<React.SetStateAction<InFlightMessage[]>>,
312
+ setError: (e: Error | null) => void,
313
+ ) {
314
+ switch (action.type) {
315
+ case 'append-text':
316
+ // Append to the last text part if it's still the tail; otherwise start
317
+ // a new text part. Keeps text + tool-invocations in chronological order.
318
+ setInFlight((cur) =>
319
+ cur.map((m) => {
320
+ if (m.id !== assistantId) return m
321
+ const last = m.parts[m.parts.length - 1]
322
+ if (last && last.type === 'text') {
323
+ const merged: MessagePart = { type: 'text', text: (last as { text: string }).text + action.delta }
324
+ return { ...m, content: m.content + action.delta, parts: [...m.parts.slice(0, -1), merged] }
325
+ }
326
+ return {
327
+ ...m,
328
+ content: m.content + action.delta,
329
+ parts: [...m.parts, { type: 'text', text: action.delta }],
330
+ }
331
+ }),
332
+ )
333
+ return
334
+
335
+ case 'upsert-tool-call':
336
+ upsertToolInvocation(setInFlight, assistantId, action.toolCallId, {
337
+ toolName: action.toolName,
338
+ state: 'call',
339
+ args: action.input,
340
+ })
341
+ return
342
+
343
+ case 'finalize-tool-call':
344
+ finalizeToolInvocation(setInFlight, assistantId, action.toolCallId, {
345
+ result: action.result as ToolInvocation['result'],
346
+ })
347
+ return
348
+
349
+ case 'fail-tool-input':
350
+ // Schema validation failed BEFORE the tool ran; no preceding
351
+ // `tool-input-available` was emitted. Upsert a failed invocation
352
+ // (we have toolName + the rejected input) so the user sees a tool
353
+ // row with the failure dot, not a stuck spinner / nothing at all.
354
+ console.error('[chat] STREAM tool-input-error', {
355
+ toolCallId: action.toolCallId,
356
+ errorText: action.errorText,
357
+ })
358
+ upsertToolInvocation(setInFlight, assistantId, action.toolCallId, {
359
+ toolName: action.toolName,
360
+ state: 'result',
361
+ args: action.input,
362
+ result: { success: false, error: action.errorText } as ToolInvocation['result'],
363
+ })
364
+ return
365
+
366
+ case 'fail-tool-output':
367
+ // Tool ran (or failed during execution); a preceding `upsert-tool-call`
368
+ // already produced the in-flight invocation, so we just finalize it.
369
+ console.error('[chat] STREAM tool-output-error', {
370
+ toolCallId: action.toolCallId,
371
+ errorText: action.errorText,
372
+ })
373
+ finalizeToolInvocation(setInFlight, assistantId, action.toolCallId, {
374
+ result: { success: false, error: action.errorText } as ToolInvocation['result'],
375
+ })
376
+ return
377
+
378
+ case 'stream-error':
379
+ console.error('[chat] STREAM error', action.errorText)
380
+ setError(new Error(action.errorText))
381
+ // Drop the assistant overlay entirely — emptying it would leave a
382
+ // zero-content flex container that still eats the parent's gap, showing
383
+ // a phantom vertical space between the user message and the error banner.
384
+ setInFlight((cur) => cur.filter((m) => m.id !== assistantId))
385
+ return
386
+
387
+ case 'abort':
388
+ // Server-side abort with no error chunk to follow. Drop the empty
389
+ // assistant overlay so we don't leave a phantom bubble — but keep
390
+ // it if the user already saw partial text OR a tool invocation
391
+ // landed before the abort. `parts.length > 0` covers both: a
392
+ // text-delta appends to both `content` and `parts`, and a tool
393
+ // call lands in `parts` only.
394
+ setInFlight((cur) => cur.filter((m) => m.id !== assistantId || m.parts.length > 0))
395
+ return
396
+
397
+ default: {
398
+ // Exhaustiveness check — adding a new variant to AiStreamAction
399
+ // without a case here will fail this assignment at compile time.
400
+ const _exhaustive: never = action
401
+ void _exhaustive
402
+ return
403
+ }
404
+ }
405
+ }
406
+
407
+ function upsertToolInvocation(
408
+ setInFlight: React.Dispatch<React.SetStateAction<InFlightMessage[]>>,
409
+ assistantId: string,
410
+ toolCallId: string,
411
+ inv: ToolInvocation,
412
+ ) {
413
+ const part: MessagePart = { type: 'tool-invocation', toolInvocation: inv, toolCallId }
414
+ // Dedup by toolCallId — replace if already present (the multi-step loop
415
+ // can re-emit a tool-input-available for the same call on retry).
416
+ setInFlight((cur) =>
417
+ cur.map((m) => {
418
+ if (m.id !== assistantId) return m
419
+ const existingIdx = m.parts.findIndex(
420
+ (p) => p.type === 'tool-invocation' && (p as ToolInvocationPart).toolCallId === toolCallId,
421
+ )
422
+ if (existingIdx >= 0) {
423
+ const next = m.parts.slice()
424
+ next[existingIdx] = part
425
+ return { ...m, parts: next }
426
+ }
427
+ return { ...m, parts: [...m.parts, part] }
428
+ }),
429
+ )
430
+ }
431
+
432
+ function finalizeToolInvocation(
433
+ setInFlight: React.Dispatch<React.SetStateAction<InFlightMessage[]>>,
434
+ assistantId: string,
435
+ toolCallId: string,
436
+ patch: { result: ToolInvocation['result'] },
437
+ ) {
438
+ setInFlight((cur) =>
439
+ cur.map((m) => {
440
+ if (m.id !== assistantId) return m
441
+ const parts = m.parts.map((p) => {
442
+ if (
443
+ p.type === 'tool-invocation' &&
444
+ (p as ToolInvocationPart).toolCallId === toolCallId
445
+ ) {
446
+ const inv = (p as ToolInvocationPart).toolInvocation
447
+ const updated: ToolInvocation = { ...inv, state: 'result', result: patch.result }
448
+ return { ...p, toolInvocation: updated }
449
+ }
450
+ return p
451
+ })
452
+ return { ...m, parts }
453
+ }),
454
+ )
455
+ }
456
+
457
+ // ============================================================================
458
+ // ChatPanel
459
+ // ============================================================================
460
+
461
+ export function ChatPanel({
462
+ chatId,
463
+ userId,
464
+ onChatCreated,
465
+ models: modelsProp,
466
+ emptyStatePrompts,
467
+ className,
468
+ header,
469
+ compact = false,
470
+ disabled = false,
471
+ }: ChatPanelProps) {
472
+ const models = modelsProp ?? DEFAULT_MODELS
473
+ const scrollRef = useRef<HTMLDivElement>(null)
474
+ const inputRef = useRef<HTMLTextAreaElement>(null)
475
+
476
+ const [modelId, setModelId] = useState<string | undefined>(() => {
477
+ let saved: string | null = null
478
+ try { saved = typeof window === 'undefined' ? null : window.localStorage.getItem(MODEL_STORAGE_KEY) } catch { saved = null }
479
+ if (saved && models.some((m) => m.id === saved)) return saved
480
+ return models[0]?.id
481
+ })
482
+ function updateModelId(id: string) {
483
+ setModelId(id)
484
+ try { if (typeof window !== 'undefined') window.localStorage.setItem(MODEL_STORAGE_KEY, id) } catch { /* ignore */ }
485
+ }
486
+
487
+ const groupedModels = useMemo(() => groupModelsByProvider(models), [models])
488
+ const selectedModel = models.find((m) => m.id === modelId)
489
+
490
+ const { send, stop, retry, isLoading, error, inFlight } = useStreamingChat(
491
+ chatId,
492
+ modelId,
493
+ onChatCreated,
494
+ )
495
+
496
+ // Sentinel where-clause yields an empty result when no chat is selected.
497
+ const queryWhere = useMemo(
498
+ () => ({ chatId: chatId ?? '__none__', userId }),
499
+ [chatId, userId],
500
+ )
501
+ const { records: persistedRecords } = useQuery<AiMessageData>('ai-messages', {
502
+ where: queryWhere,
503
+ orderBy: 'createdAt',
504
+ orderDir: 'asc',
505
+ })
506
+
507
+ const messages: RenderMessage[] = useMemo(() => {
508
+ const persisted: RenderMessage[] = persistedRecords.map((r) => ({
509
+ id: r.recordId,
510
+ role: r.data.role,
511
+ content: r.data.content ?? '',
512
+ parts: r.data.parts,
513
+ }))
514
+ const persistedIds = new Set(persisted.map((m) => m.id))
515
+
516
+ const tail = inFlight.filter((m) => {
517
+ // Only render in-flight items that belong to the chat we're currently
518
+ // viewing. Prevents the previous chat's bubbles from leaking through
519
+ // when activeChatId switches (new chat, chat select).
520
+ if (m.forChatId !== chatId) return false
521
+ // User rows: id is the same client/server. Assistant rows: serverId is
522
+ // the worker-assigned recordId (from the X-Asst-Id response header) and
523
+ // matches the WS-broadcast persisted row. Dedup is purely id-based
524
+ // — there is no clock comparison, so client/server clock skew can't
525
+ // leave a duplicate stuck on screen.
526
+ if (persistedIds.has(m.id)) return false
527
+ if (m.serverId && persistedIds.has(m.serverId)) return false
528
+ return true
529
+ })
530
+ return [...persisted, ...tail]
531
+ }, [persistedRecords, inFlight, chatId])
532
+
533
+ const [input, setInput] = useState('')
534
+
535
+ // Auto-scroll only when the user is already pinned to the bottom.
536
+ const stickToBottomRef = useRef(true)
537
+ useEffect(() => {
538
+ const el = scrollRef.current
539
+ if (!el) return
540
+ const onScroll = () => {
541
+ const gap = el.scrollHeight - el.scrollTop - el.clientHeight
542
+ stickToBottomRef.current = gap < 80
543
+ }
544
+ el.addEventListener('scroll', onScroll, { passive: true })
545
+ return () => el.removeEventListener('scroll', onScroll)
546
+ }, [])
547
+
548
+ useEffect(() => {
549
+ const el = scrollRef.current
550
+ if (!el || !stickToBottomRef.current) return
551
+ el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
552
+ }, [messages])
553
+
554
+ // Auto-grow textarea, capped at 200px.
555
+ useEffect(() => {
556
+ const el = inputRef.current
557
+ if (!el) return
558
+ if (!input) {
559
+ el.style.height = ''
560
+ return
561
+ }
562
+ const raf = requestAnimationFrame(() => {
563
+ el.style.height = 'auto'
564
+ el.style.height = `${Math.min(el.scrollHeight, 200)}px`
565
+ })
566
+ return () => cancelAnimationFrame(raf)
567
+ }, [input])
568
+
569
+ const canSend = input.trim().length > 0 && !isLoading && !disabled
570
+
571
+ function submit() {
572
+ if (!canSend) return
573
+ const value = input.trim()
574
+ setInput('')
575
+ void send(value)
576
+ }
577
+
578
+ function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
579
+ if (e.key === 'Enter' && !e.shiftKey) {
580
+ e.preventDefault()
581
+ submit()
582
+ }
583
+ }
584
+
585
+ function onPickPrompt(prompt: string) {
586
+ setInput(prompt)
587
+ inputRef.current?.focus()
588
+ }
589
+
590
+ const prompts = emptyStatePrompts ?? DEFAULT_PROMPTS
591
+ const lastMessage = messages[messages.length - 1]
592
+ const streamingAssistantId =
593
+ isLoading && lastMessage?.role === 'assistant' ? lastMessage.id : null
594
+ const waitingForAssistant = isLoading && lastMessage?.role === 'user'
595
+
596
+ const outerPx = compact ? 'px-4' : 'px-6'
597
+ const textSize = compact ? 'text-[13px]' : 'text-[14px]'
598
+ const turnGap = compact ? 'gap-6' : 'gap-8'
599
+
600
+ return (
601
+ <div
602
+ className={`relative flex h-full min-h-0 flex-col bg-background text-foreground ${textSize} ${className ?? ''}`}
603
+ >
604
+ {header && <div className="shrink-0">{header}</div>}
605
+
606
+ {/* min-h-0 is required on a flex-1 child so overflow-y-auto actually
607
+ activates instead of growing the parent. */}
608
+ <div
609
+ ref={scrollRef}
610
+ role="log"
611
+ aria-live="polite"
612
+ aria-atomic="false"
613
+ aria-label="Assistant conversation"
614
+ className={`min-h-0 flex-1 overflow-y-auto ${outerPx} py-8`}
615
+ >
616
+ {messages.length === 0 ? (
617
+ <EmptyState prompts={prompts} onPick={onPickPrompt} />
618
+ ) : (
619
+ <div className={`mx-auto flex max-w-[44rem] flex-col ${turnGap}`}>
620
+ {messages.map((m) => (
621
+ <MessageTurn
622
+ key={m.id}
623
+ role={m.role}
624
+ content={m.content}
625
+ parts={m.parts as unknown[] | undefined}
626
+ isStreaming={m.id === streamingAssistantId}
627
+ />
628
+ ))}
629
+ {waitingForAssistant && <ThinkingIndicator />}
630
+ </div>
631
+ )}
632
+ </div>
633
+
634
+ {error && (
635
+ <div className={`mx-auto mb-2 w-full max-w-[44rem] ${outerPx}`} role="alert">
636
+ <div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-destructive">
637
+ <AlertCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
638
+ <div className="flex-1 text-[13px] leading-relaxed">{error.message}</div>
639
+ <button
640
+ type="button"
641
+ onClick={retry}
642
+ className="rounded-md border border-destructive/30 px-2 py-0.5 text-[12px] font-medium transition-colors hover:bg-destructive/10"
643
+ >
644
+ Retry
645
+ </button>
646
+ </div>
647
+ </div>
648
+ )}
649
+
650
+ <div className={`shrink-0 border-t border-border/60 ${outerPx} pb-4 pt-3`}>
651
+ <form
652
+ onSubmit={(e) => {
653
+ e.preventDefault()
654
+ submit()
655
+ }}
656
+ className="mx-auto w-full max-w-[44rem]"
657
+ >
658
+ <div className="relative rounded-2xl border border-border bg-background transition-colors focus-within:border-foreground/25 focus-within:ring-4 focus-within:ring-foreground/[0.04]">
659
+ <textarea
660
+ ref={inputRef}
661
+ rows={1}
662
+ value={input}
663
+ onChange={(e) => setInput(e.target.value)}
664
+ onKeyDown={onKeyDown}
665
+ disabled={disabled}
666
+ placeholder={disabled ? 'Creating chat…' : 'Message the assistant…'}
667
+ className="block w-full resize-none bg-transparent px-4 pt-3 pb-12 leading-[1.5] text-foreground placeholder:text-muted-foreground/60 outline-none disabled:cursor-not-allowed"
668
+ />
669
+
670
+ <div className="pointer-events-none absolute inset-x-2 bottom-2 flex items-center justify-between">
671
+ {groupedModels && modelId && selectedModel ? (
672
+ <ModelPicker
673
+ grouped={groupedModels}
674
+ modelId={modelId}
675
+ label={selectedModel.label}
676
+ onChange={updateModelId}
677
+ />
678
+ ) : (
679
+ <span />
680
+ )}
681
+
682
+ {isLoading ? (
683
+ <button
684
+ type="button"
685
+ onClick={stop}
686
+ aria-label="Stop generating"
687
+ className="pointer-events-auto inline-flex h-8 w-8 items-center justify-center rounded-full bg-foreground text-background transition-opacity hover:opacity-90"
688
+ >
689
+ <Square className="h-3 w-3 fill-current" aria-hidden="true" />
690
+ </button>
691
+ ) : (
692
+ <button
693
+ type="submit"
694
+ disabled={!canSend}
695
+ aria-label="Send message"
696
+ className="pointer-events-auto inline-flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground transition-all hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/60 disabled:cursor-not-allowed"
697
+ >
698
+ <ArrowUp className="h-4 w-4" aria-hidden="true" />
699
+ </button>
700
+ )}
701
+ </div>
702
+ </div>
703
+ </form>
704
+ </div>
705
+ </div>
706
+ )
707
+ }
708
+
709
+
710
+ // ----- Turn rendering ----------------------------------------------------
711
+
712
+ type ToolInvocationPart = {
713
+ type: 'tool-invocation'
714
+ toolInvocation: ToolInvocation
715
+ toolCallId: string
716
+ }
717
+
718
+ type MessagePart =
719
+ | { type: 'text'; text: string }
720
+ | ToolInvocationPart
721
+
722
+ interface MessageTurnProps {
723
+ role: 'user' | 'assistant' | 'system'
724
+ content: string
725
+ parts?: unknown[]
726
+ isStreaming: boolean
727
+ }
728
+
729
+ function MessageTurn({ role, content, parts, isStreaming }: MessageTurnProps) {
730
+ const orderedParts: MessagePart[] = useMemo(() => {
731
+ if (Array.isArray(parts) && parts.length > 0) {
732
+ const cast = parts as MessagePart[]
733
+ const hasText = cast.some((p) => (p as { type?: string }).type === 'text')
734
+ if (hasText) return cast
735
+ if (content) return [...cast, { type: 'text', text: content }]
736
+ return cast
737
+ }
738
+ if (content) return [{ type: 'text', text: content }]
739
+ return []
740
+ }, [parts, content])
741
+
742
+ if (role === 'user') {
743
+ return (
744
+ <div className="flex justify-end">
745
+ <div className="max-w-[85%] rounded-2xl bg-muted px-4 py-2.5 leading-relaxed text-foreground whitespace-pre-wrap break-words">
746
+ {content}
747
+ </div>
748
+ </div>
749
+ )
750
+ }
751
+
752
+ if (role === 'system') {
753
+ // Server compaction wraps summaries in this prefix; strip for display.
754
+ const body = content.replace(/^Earlier conversation summary:\n?/, '')
755
+ return (
756
+ <div className="rounded-md border border-dashed border-border bg-muted/30 px-3 py-2 text-[12px] text-muted-foreground">
757
+ <div className="mb-1 font-medium tracking-tight text-foreground/80">
758
+ Earlier conversation summary
759
+ </div>
760
+ <div className="whitespace-pre-wrap leading-relaxed">{body}</div>
761
+ </div>
762
+ )
763
+ }
764
+
765
+ return (
766
+ <div className="flex flex-col gap-3">
767
+ {orderedParts.map((p, i) => {
768
+ if (p.type === 'text') {
769
+ const text = (p as { text: string }).text
770
+ if (!text) return null
771
+ // Markdown styling without `@tailwindcss/typography`: arbitrary
772
+ // descendant selectors (`[&_p]:my-2`) target the markdown's
773
+ // emitted HTML directly. Avoids a plugin dep on every scaffolded
774
+ // app — `prose` classes emit zero CSS unless typography is
775
+ // installed, which is what made paragraphs / line breaks
776
+ // collapse into one wall of text.
777
+ //
778
+ // Code blocks are always-dark (`bg-zinc-900`) so they stay
779
+ // readable under both light and dark host themes — matches the
780
+ // atom-one-dark hljs palette imported above. Inline `<code>`
781
+ // remains theme-aware via `bg-muted`.
782
+ return (
783
+ <div
784
+ key={i}
785
+ className="text-foreground leading-relaxed
786
+ [&_p]:my-2 [&_p]:[overflow-wrap:anywhere] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0
787
+ [&_h1]:text-lg [&_h1]:font-semibold [&_h1]:my-3
788
+ [&_h2]:text-base [&_h2]:font-semibold [&_h2]:my-3
789
+ [&_h3]:font-semibold [&_h3]:my-2
790
+ [&_strong]:font-semibold [&_em]:italic
791
+ [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2
792
+ [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-6
793
+ [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-6
794
+ [&_li]:my-0.5
795
+ [&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground
796
+ [&_hr]:my-3 [&_hr]:border-border
797
+ [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-[0.9em]
798
+ [&_pre]:bg-zinc-900 [&_pre]:text-zinc-100 [&_pre]:rounded-md [&_pre]:p-3 [&_pre]:my-2 [&_pre]:overflow-x-auto
799
+ [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:rounded-none [&_pre_code]:text-inherit
800
+ [&_table]:my-2 [&_table]:w-full [&_table]:border-collapse
801
+ [&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:font-semibold [&_th]:text-left
802
+ [&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1"
803
+ >
804
+ <ReactMarkdown
805
+ remarkPlugins={[remarkGfm, remarkBreaks]}
806
+ rehypePlugins={[rehypeHighlight]}
807
+ components={{ pre: CodeBlock, a: ExternalLink }}
808
+ >
809
+ {text}
810
+ </ReactMarkdown>
811
+ </div>
812
+ )
813
+ }
814
+ if (p.type === 'tool-invocation') {
815
+ const inv = (p as ToolInvocationPart).toolInvocation
816
+ return <ToolRow key={i} inv={inv} />
817
+ }
818
+ return null
819
+ })}
820
+ {isStreaming && <LiveIndicator />}
821
+ </div>
822
+ )
823
+ }
824
+
825
+ // ----- Empty state --------------------------------------------------------
826
+
827
+ function EmptyState({ prompts, onPick }: { prompts: string[]; onPick: (p: string) => void }) {
828
+ return (
829
+ <div className="mx-auto flex h-full max-w-[28rem] flex-col items-start justify-center gap-4 px-2">
830
+ <div className="space-y-1">
831
+ <h2 className="text-[17px] font-medium tracking-tight text-foreground">
832
+ How can I help?
833
+ </h2>
834
+ <p className="text-[13px] leading-relaxed text-muted-foreground">
835
+ I can query records, list schemas, and inspect collections on your behalf.
836
+ </p>
837
+ </div>
838
+ <div className="flex flex-col gap-0.5 pt-2">
839
+ <div className="pb-1 text-[10.5px] font-medium tracking-[0.08em] uppercase text-muted-foreground">
840
+ Try
841
+ </div>
842
+ {prompts.map((p) => (
843
+ <button
844
+ key={p}
845
+ type="button"
846
+ onClick={() => onPick(p)}
847
+ className="group flex items-center gap-2 py-1 text-left text-[13px] text-muted-foreground transition-colors hover:text-foreground"
848
+ >
849
+ <span className="h-px w-3 shrink-0 bg-border transition-colors group-hover:bg-muted-foreground" />
850
+ <span className="truncate">{p}</span>
851
+ </button>
852
+ ))}
853
+ </div>
854
+ </div>
855
+ )
856
+ }
857
+
858
+ // ----- Pending + Live indicators -----------------------------------------
859
+
860
+ function ThinkingIndicator() {
861
+ return (
862
+ <div className="flex items-center gap-2 text-[13px] text-muted-foreground">
863
+ <EllipsisDots />
864
+ <span>Thinking</span>
865
+ </div>
866
+ )
867
+ }
868
+
869
+ function LiveIndicator() {
870
+ return (
871
+ <div className="flex items-center gap-2 text-[12px] text-muted-foreground" aria-live="polite">
872
+ <span className="relative flex h-1.5 w-1.5">
873
+ <span className="absolute inset-0 rounded-full bg-primary/50 animate-ping" />
874
+ <span className="relative h-1.5 w-1.5 rounded-full bg-primary" />
875
+ </span>
876
+ <span className="tracking-wide">Working</span>
877
+ </div>
878
+ )
879
+ }
880
+
881
+ function EllipsisDots() {
882
+ return (
883
+ <span className="inline-flex items-center gap-[3px]">
884
+ <span className="h-1 w-1 rounded-full bg-muted-foreground animate-[pulse_1.4s_ease-in-out_0ms_infinite]" />
885
+ <span className="h-1 w-1 rounded-full bg-muted-foreground animate-[pulse_1.4s_ease-in-out_180ms_infinite]" />
886
+ <span className="h-1 w-1 rounded-full bg-muted-foreground animate-[pulse_1.4s_ease-in-out_360ms_infinite]" />
887
+ </span>
888
+ )
889
+ }
890
+
891
+ // ----- Tool row ----------------------------------------------------------
892
+
893
+ type ToolInvocation = {
894
+ toolName: string
895
+ state: 'call' | 'result' | 'partial-call'
896
+ args?: unknown
897
+ result?: { success?: boolean } & Record<string, unknown>
898
+ }
899
+
900
+ // ----- Code block + copy button ------------------------------------------
901
+
902
+ // `pre` slot for ReactMarkdown — wraps the highlighted <pre><code> with a
903
+ // copy-to-clipboard button. Positioned absolutely; revealed on hover via
904
+ // `group-hover` so it doesn't add visual noise on every code block.
905
+ const CodeBlock: Components['pre'] = ({ children, node: _node, ref: _ref, ...props }) => {
906
+ const ref = useRef<HTMLPreElement>(null)
907
+ const [copied, setCopied] = useState(false)
908
+ const onCopy = () => {
909
+ const text = ref.current?.textContent ?? ''
910
+ if (!text) return
911
+ void navigator.clipboard.writeText(text).then(() => {
912
+ setCopied(true)
913
+ setTimeout(() => setCopied(false), 1500)
914
+ })
915
+ }
916
+ return (
917
+ <div className="group relative">
918
+ <button
919
+ type="button"
920
+ onClick={onCopy}
921
+ aria-label={copied ? 'Copied' : 'Copy code'}
922
+ className="absolute right-2 top-2 inline-flex h-6 items-center gap-1 rounded-md border border-zinc-700 bg-zinc-800 px-2 text-[11px] font-medium text-zinc-200 opacity-0 transition-opacity hover:bg-zinc-700 focus-visible:opacity-100 group-hover:opacity-100"
923
+ >
924
+ {copied
925
+ ? <Check className="h-3 w-3" aria-hidden="true" />
926
+ : <Copy className="h-3 w-3" aria-hidden="true" />}
927
+ <span>{copied ? 'Copied' : 'Copy'}</span>
928
+ </button>
929
+ <pre ref={ref} {...props}>{children}</pre>
930
+ </div>
931
+ )
932
+ }
933
+
934
+ // Markdown links default to navigating in the same tab — which kills the
935
+ // chat session. Force new-tab + noopener for safety.
936
+ const ExternalLink: Components['a'] = ({ children, href, node: _node, ...props }) => {
937
+ return (
938
+ <a href={href} target="_blank" rel="noopener noreferrer" {...props}>
939
+ {children}
940
+ </a>
941
+ )
942
+ }
943
+
944
+ function ToolRow({ inv }: { inv: ToolInvocation }) {
945
+ const done = inv.state === 'result'
946
+ const failed = done && inv.result?.success === false
947
+ const running = !done
948
+ const { label, path } = describeTool(inv.toolName, inv.args as Record<string, unknown> | undefined)
949
+
950
+ return (
951
+ <div className={`flex items-center gap-2 text-[13px] leading-tight ${running ? 'animate-[pulse_2s_ease-in-out_infinite]' : ''}`}>
952
+ <span className="inline-flex h-4 w-4 shrink-0 items-center justify-center">
953
+ {running ? <Spinner /> : failed ? <FailDot /> : <Check className="h-3 w-3 shrink-0 text-foreground/60" aria-hidden="true" />}
954
+ </span>
955
+ <span className={running ? 'text-foreground' : 'text-muted-foreground'}>{label}</span>
956
+ {path && (
957
+ <code className="truncate font-mono text-[12.5px] text-foreground/70">{path}</code>
958
+ )}
959
+ {running && <EllipsisDots />}
960
+ </div>
961
+ )
962
+ }
963
+
964
+ function Spinner() {
965
+ return (
966
+ <svg width="12" height="12" viewBox="0 0 12 12" className="animate-spin text-foreground/70">
967
+ <circle cx="6" cy="6" r="4.5" fill="none" stroke="currentColor" strokeOpacity="0.2" strokeWidth="1.5" />
968
+ <path d="M10.5 6 A 4.5 4.5 0 0 1 6 10.5" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
969
+ </svg>
970
+ )
971
+ }
972
+
973
+ function FailDot() {
974
+ return <span className="h-1.5 w-1.5 rounded-full bg-destructive" aria-label="failed" />
975
+ }
976
+
977
+ function describeTool(
978
+ name: string,
979
+ args?: Record<string, unknown>,
980
+ ): { label: string; path?: string } {
981
+ const collection = typeof args?.collection === 'string' ? args.collection : undefined
982
+ const recordId = typeof args?.recordId === 'string' ? args.recordId : undefined
983
+
984
+ switch (name) {
985
+ case 'schema_list':
986
+ return { label: 'Listing collections' }
987
+ case 'schema_describe':
988
+ return { label: 'Describing', path: collection }
989
+ case 'records_query':
990
+ return { label: 'Reading', path: collection }
991
+ case 'records_get':
992
+ return {
993
+ label: 'Fetching',
994
+ path: collection && recordId ? `${collection}/${recordId}` : collection,
995
+ }
996
+ case 'user_current':
997
+ return { label: 'Checking current user' }
998
+ default:
999
+ return { label: 'Running', path: name }
1000
+ }
1001
+ }
1002
+
1003
+ // ----- Model picker ------------------------------------------------------
1004
+
1005
+ function ModelPicker({
1006
+ grouped,
1007
+ modelId,
1008
+ label,
1009
+ onChange,
1010
+ }: {
1011
+ grouped: Array<[string, ModelOption[]]>
1012
+ modelId: string
1013
+ label: string
1014
+ onChange: (id: string) => void
1015
+ }) {
1016
+ const [open, setOpen] = useState(false)
1017
+ const containerRef = useRef<HTMLDivElement>(null)
1018
+
1019
+ useEffect(() => {
1020
+ if (!open) return
1021
+ function onDocDown(e: MouseEvent) {
1022
+ if (!containerRef.current?.contains(e.target as Node)) setOpen(false)
1023
+ }
1024
+ function onKey(e: globalThis.KeyboardEvent) {
1025
+ if (e.key === 'Escape') setOpen(false)
1026
+ }
1027
+ document.addEventListener('mousedown', onDocDown)
1028
+ document.addEventListener('keydown', onKey)
1029
+ return () => {
1030
+ document.removeEventListener('mousedown', onDocDown)
1031
+ document.removeEventListener('keydown', onKey)
1032
+ }
1033
+ }, [open])
1034
+
1035
+ return (
1036
+ <div ref={containerRef} className="pointer-events-auto relative inline-flex">
1037
+ <button
1038
+ type="button"
1039
+ onClick={() => setOpen((v) => !v)}
1040
+ className="inline-flex items-center gap-1 rounded-full px-2 py-1 text-[12px] text-muted-foreground transition-colors hover:text-foreground"
1041
+ aria-haspopup="menu"
1042
+ aria-expanded={open}
1043
+ >
1044
+ <span className="max-w-[10rem] truncate">{label}</span>
1045
+ <ChevronDown className="h-3 w-3 opacity-70" aria-hidden="true" />
1046
+ </button>
1047
+
1048
+ {open && (
1049
+ <div
1050
+ role="menu"
1051
+ className="absolute bottom-full left-0 z-30 mb-2 w-64 max-h-[22rem] overflow-y-auto rounded-lg border border-border bg-popover text-popover-foreground shadow-lg"
1052
+ >
1053
+ {grouped.map(([provider, items], pIdx) => (
1054
+ <div key={provider}>
1055
+ {pIdx > 0 && <div className="border-t border-border/60" />}
1056
+ <div className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
1057
+ {provider}
1058
+ </div>
1059
+ {items.map((m) => {
1060
+ const active = m.id === modelId
1061
+ return (
1062
+ <button
1063
+ key={m.id}
1064
+ type="button"
1065
+ role="menuitem"
1066
+ onClick={() => {
1067
+ onChange(m.id)
1068
+ setOpen(false)
1069
+ }}
1070
+ className={`flex w-full items-center justify-between gap-3 px-3 py-1.5 text-left text-[12.5px] transition-colors ${
1071
+ active ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50'
1072
+ }`}
1073
+ >
1074
+ <span className="truncate">{m.label}</span>
1075
+ {active && <Check className="h-3 w-3 shrink-0 text-foreground/60" aria-hidden="true" />}
1076
+ </button>
1077
+ )
1078
+ })}
1079
+ </div>
1080
+ ))}
1081
+ </div>
1082
+ )}
1083
+ </div>
1084
+ )
1085
+ }
1086
+
1087
+ // ----- Utils -------------------------------------------------------------
1088
+
1089
+ function groupModelsByProvider(models?: ModelOption[]): Array<[string, ModelOption[]]> | null {
1090
+ if (!models || models.length === 0) return null
1091
+ const map = new Map<string, ModelOption[]>()
1092
+ for (const m of models) {
1093
+ const list = map.get(m.provider) ?? []
1094
+ list.push(m)
1095
+ map.set(m.provider, list)
1096
+ }
1097
+ return Array.from(map.entries())
1098
+ }