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