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