@tanstack/ai-devtools-core 0.3.38 → 0.4.1

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 (53) hide show
  1. package/dist/Shell/DKCFIT6H.js +12142 -0
  2. package/dist/Shell/SLOX2TPU.js +12119 -0
  3. package/dist/Shell/Z6HLJOVZ.js +10170 -0
  4. package/dist/dev.js +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/production/Shell/DKCFIT6H.js +12142 -0
  7. package/dist/production/Shell/SLOX2TPU.js +12119 -0
  8. package/dist/production/Shell/Z6HLJOVZ.js +10170 -0
  9. package/dist/production/dev.js +1 -1
  10. package/dist/production/index.js +1 -1
  11. package/dist/production/server.js +1 -1
  12. package/dist/server.js +1 -1
  13. package/package.json +4 -4
  14. package/src/components/Shell.tsx +74 -30
  15. package/src/components/conversation/IterationCard.tsx +261 -12
  16. package/src/components/conversation/IterationTimeline.tsx +66 -16
  17. package/src/components/conversation/index.ts +0 -9
  18. package/src/components/hooks/FixtureNamePopover.tsx +69 -0
  19. package/src/components/hooks/GenerationPanel.tsx +847 -0
  20. package/src/components/hooks/HookDashboard.tsx +292 -0
  21. package/src/components/hooks/HookDetails.tsx +1757 -0
  22. package/src/components/hooks/ToolFixtureForm.tsx +347 -0
  23. package/src/components/hooks/hook-dashboard-model.ts +134 -0
  24. package/src/components/hooks/index.ts +4 -0
  25. package/src/components/hooks/preview-messages.ts +117 -0
  26. package/src/components/hooks/preview-model.ts +195 -0
  27. package/src/components/utils/format.ts +0 -58
  28. package/src/env.d.ts +5 -0
  29. package/src/store/ai-context.tsx +1165 -159
  30. package/src/store/ai-store.ts +0 -1
  31. package/src/store/hook-registry.ts +815 -0
  32. package/src/store/message-event-utils.ts +60 -0
  33. package/src/styles/use-styles.ts +1558 -99
  34. package/dist/Shell/MRWZEHAJ.js +0 -6225
  35. package/dist/Shell/RRRNZJ5E.js +0 -7560
  36. package/dist/production/Shell/MRWZEHAJ.js +0 -6225
  37. package/dist/production/Shell/RRRNZJ5E.js +0 -7560
  38. package/src/components/ConversationDetails.tsx +0 -216
  39. package/src/components/ConversationsList.tsx +0 -21
  40. package/src/components/conversation/ActivityEventsTab.tsx +0 -57
  41. package/src/components/conversation/ChunkBadges.tsx +0 -51
  42. package/src/components/conversation/ChunkItem.tsx +0 -273
  43. package/src/components/conversation/ChunksCollapsible.tsx +0 -56
  44. package/src/components/conversation/ChunksTab.tsx +0 -87
  45. package/src/components/conversation/ConversationHeader.tsx +0 -106
  46. package/src/components/conversation/ConversationTabs.tsx +0 -205
  47. package/src/components/conversation/MessageCard.tsx +0 -174
  48. package/src/components/conversation/MessageGroup.tsx +0 -101
  49. package/src/components/conversation/MessagesTab.tsx +0 -30
  50. package/src/components/conversation/SummariesTab.tsx +0 -91
  51. package/src/components/conversation/ToolCallDisplay.tsx +0 -99
  52. package/src/components/list/ConversationRow.tsx +0 -85
  53. package/src/components/list/index.ts +0 -1
@@ -0,0 +1,1757 @@
1
+ import { For, Show, createEffect, createMemo, createSignal } from 'solid-js'
2
+ import { JsonTree } from '@tanstack/devtools-ui'
3
+ import { useAIStore } from '../../store/ai-context'
4
+ import { useStyles } from '../../styles/use-styles'
5
+ import {
6
+ createToolFixtureRecord,
7
+ getHookUnseenEventCount,
8
+ } from '../../store/hook-registry'
9
+ import { IterationTimeline } from '../conversation'
10
+ import { FixtureNamePopover } from './FixtureNamePopover'
11
+ import { ToolFixtureForm } from './ToolFixtureForm'
12
+ import {
13
+ createHookDashboardSummary,
14
+ getHookDisplayName,
15
+ groupHooksByCategory,
16
+ isHookRunning,
17
+ visibleHooks,
18
+ } from './hook-dashboard-model'
19
+ import {
20
+ createHoverTarget,
21
+ createHoverTargetFromDataAttributes,
22
+ getHoverDataAttributes,
23
+ hoverTargetMatchesElement,
24
+ isMessageHighlighted,
25
+ isMessageOrPartHighlighted,
26
+ parseJsonishValue,
27
+ structuredOutputJsonItems,
28
+ structuredOutputPartId,
29
+ toolCallPartId,
30
+ toolResultPartId,
31
+ } from './preview-model'
32
+ import {
33
+ hasStructuredOutputPreview,
34
+ mergePreviewMessagesForUserView,
35
+ partsToMessageText,
36
+ visiblePreviewPartsForMessage,
37
+ } from './preview-messages'
38
+ import { GenerationPanel, GenerationPreview } from './GenerationPanel'
39
+ import type { HoverOrigin, HoverTarget, PreviewJsonItem } from './preview-model'
40
+ import type {
41
+ HookRecord,
42
+ RunRecord,
43
+ ToolFixtureMessage,
44
+ ToolFixtureRecord,
45
+ } from '../../store/hook-registry'
46
+ import type { Conversation, Message, ToolCall } from '../../store/ai-store'
47
+ import type { Component, Setter } from 'solid-js'
48
+
49
+ type DetailTab = 'conversation' | 'tools' | 'state'
50
+ type MessagePart = NonNullable<Message['parts']>[number]
51
+ const scrollAnimations = new WeakMap<HTMLElement, number>()
52
+
53
+ interface PreviewMessage {
54
+ id: string
55
+ role: string
56
+ content: string
57
+ parts: Array<PreviewPart>
58
+ sourceMessage?: ToolFixtureMessage
59
+ }
60
+
61
+ interface PreviewPart {
62
+ id: string
63
+ label: string
64
+ content?: string
65
+ jsonItems?: Array<PreviewJsonItem>
66
+ kind:
67
+ | 'text'
68
+ | 'thinking'
69
+ | 'tool-call'
70
+ | 'tool-result'
71
+ | 'structured-output'
72
+ | 'media'
73
+ fixture?: PreviewToolFixture
74
+ }
75
+
76
+ interface PreviewToolFixture {
77
+ toolName: string
78
+ input: unknown
79
+ output?: unknown
80
+ toolCallId?: string
81
+ messageId?: string
82
+ errorText?: string
83
+ }
84
+
85
+ interface PreviewPartSource {
86
+ id?: unknown
87
+ type?: unknown
88
+ toolCallId?: unknown
89
+ name?: unknown
90
+ input?: unknown
91
+ arguments?: unknown
92
+ output?: unknown
93
+ content?: unknown
94
+ text?: unknown
95
+ error?: unknown
96
+ toolName?: unknown
97
+ approval?: unknown
98
+ state?: unknown
99
+ status?: unknown
100
+ raw?: unknown
101
+ partial?: unknown
102
+ data?: unknown
103
+ reasoning?: unknown
104
+ errorMessage?: unknown
105
+ source?: unknown
106
+ metadata?: unknown
107
+ }
108
+
109
+ export const HookDetails: Component = () => {
110
+ const { state, selectHook, selectConversation } = useAIStore()
111
+ const styles = useStyles()
112
+ const [activeTab, setActiveTab] = createSignal<DetailTab>('conversation')
113
+ const [hoverTarget, setHoverTarget] = createSignal<HoverTarget | null>(null)
114
+ let timelinePane: HTMLElement | undefined
115
+ let previewPane: HTMLElement | undefined
116
+
117
+ const hook = createMemo((): HookRecord | undefined => {
118
+ const id = state.hooks.activeHookId
119
+ return id ? state.hooks.hooks[id] : undefined
120
+ })
121
+
122
+ const conversation = createMemo(() => {
123
+ const activeHook = hook()
124
+ if (!activeHook) return undefined
125
+ return findConversationForHook(activeHook, state.conversations)
126
+ })
127
+
128
+ const runs = createMemo(() => {
129
+ const activeHook = hook()
130
+ if (!activeHook) return []
131
+ return activeHook.runIds
132
+ .map((id) => state.hooks.runs[id])
133
+ .filter((run): run is RunRecord => Boolean(run))
134
+ .sort((a, b) => a.updatedAt - b.updatedAt)
135
+ })
136
+
137
+ const isGenerationHook = createMemo(() => {
138
+ const outputKind = hook()?.outputKind
139
+ // 'structured' is a chat hook variant (useChat with outputSchema), not a
140
+ // generation hook — it still has a conversation timeline and tool calls.
141
+ return Boolean(
142
+ outputKind && outputKind !== 'chat' && outputKind !== 'structured',
143
+ )
144
+ })
145
+
146
+ createEffect(() => {
147
+ if (isGenerationHook() && activeTab() === 'tools') {
148
+ setActiveTab('conversation')
149
+ }
150
+ })
151
+
152
+ const previewMessages = createMemo(() => {
153
+ const activeHook = hook()
154
+ if (!activeHook) return []
155
+ const snapshotMessages = messagesFromSnapshot(activeHook.state)
156
+ const conversationMessages = conversation()?.messages ?? []
157
+ if (conversationMessages.length > 0) {
158
+ return mergePreviewMessagesForUserView(
159
+ conversationMessages.map(messageFromConversation),
160
+ snapshotMessages,
161
+ )
162
+ }
163
+ if (snapshotMessages.length > 0) return snapshotMessages
164
+ return []
165
+ })
166
+ const showSecondaryPane = createMemo(() => {
167
+ // Tools tab owns its own form/saved-fixtures layout that fills the
168
+ // primary pane; the secondary "User View" preview squeezes the tool
169
+ // detail column to zero width on narrower hookDetails widths.
170
+ if (activeTab() === 'tools' && !isGenerationHook()) return false
171
+ return isGenerationHook() || !hasStructuredOutputPreview(previewMessages())
172
+ })
173
+
174
+ createEffect(() => {
175
+ const target = hoverTarget()
176
+ if (!target) return
177
+
178
+ const pane = target.origin === 'timeline' ? previewPane : timelinePane
179
+ if (!pane) return
180
+
181
+ const element = findMatchingHoverElement(pane, target)
182
+ if (!element) return
183
+
184
+ scrollElementIntoPaneView(pane, element, {
185
+ align:
186
+ target.origin === 'preview' && target.partIds.length === 0
187
+ ? 'start'
188
+ : 'nearest',
189
+ })
190
+ })
191
+
192
+ return (
193
+ <Show
194
+ when={hook()}
195
+ fallback={
196
+ <HookOverview
197
+ onSelectHook={(selectedHook) => {
198
+ selectHook(selectedHook.id)
199
+ if (state.conversations[selectedHook.id]) {
200
+ selectConversation(selectedHook.id)
201
+ }
202
+ }}
203
+ />
204
+ }
205
+ >
206
+ {(activeHook) => (
207
+ <div
208
+ class={styles().hookDetails.container}
209
+ data-testid="ai-devtools-hook-detail"
210
+ >
211
+ <HookHeader
212
+ hook={activeHook()}
213
+ runs={runs().length}
214
+ messages={previewMessages().length}
215
+ totalTokens={conversation()?.usage?.totalTokens ?? 0}
216
+ isGenerationHook={isGenerationHook()}
217
+ />
218
+
219
+ <nav
220
+ class={styles().hookDetails.tabs}
221
+ data-testid="ai-devtools-hook-tabs"
222
+ >
223
+ <TabButton
224
+ label={isGenerationHook() ? 'Generation' : 'Conversation'}
225
+ tab="conversation"
226
+ activeTab={activeTab()}
227
+ onSelect={setActiveTab}
228
+ />
229
+ <Show when={!isGenerationHook()}>
230
+ <TabButton
231
+ label="Tools"
232
+ tab="tools"
233
+ activeTab={activeTab()}
234
+ onSelect={setActiveTab}
235
+ />
236
+ </Show>
237
+ <TabButton
238
+ label="State"
239
+ tab="state"
240
+ activeTab={activeTab()}
241
+ onSelect={setActiveTab}
242
+ />
243
+ </nav>
244
+
245
+ <div
246
+ class={`${styles().hookDetails.body} ${
247
+ showSecondaryPane() ? '' : styles().hookDetails.bodySinglePane
248
+ }`}
249
+ >
250
+ <main
251
+ class={styles().hookDetails.primary}
252
+ data-testid="ai-devtools-primary-pane"
253
+ ref={(el) => {
254
+ timelinePane = el
255
+ }}
256
+ onMouseOver={(event) =>
257
+ setHoverTargetFromEvent(event, 'timeline', setHoverTarget)
258
+ }
259
+ onMouseLeave={() => setHoverTarget(null)}
260
+ >
261
+ <Show when={activeTab() === 'conversation'}>
262
+ <Show
263
+ when={isGenerationHook()}
264
+ fallback={
265
+ <ConversationPanel
266
+ hook={activeHook()}
267
+ conversation={conversation()}
268
+ messages={previewMessages()}
269
+ hoverTarget={hoverTarget()}
270
+ onHoverTarget={setHoverTarget}
271
+ />
272
+ }
273
+ >
274
+ <GenerationPanel
275
+ hook={activeHook()}
276
+ hoverTarget={hoverTarget()}
277
+ />
278
+ </Show>
279
+ </Show>
280
+ <Show when={!isGenerationHook() && activeTab() === 'tools'}>
281
+ <ToolsView hook={activeHook()} />
282
+ </Show>
283
+ <Show when={activeTab() === 'state'}>
284
+ <JsonPanel value={activeHook().state} />
285
+ </Show>
286
+ </main>
287
+
288
+ <Show when={showSecondaryPane()}>
289
+ <aside
290
+ class={styles().hookDetails.previewPane}
291
+ data-testid="ai-devtools-preview-pane"
292
+ ref={(el) => {
293
+ previewPane = el
294
+ }}
295
+ onMouseOver={(event) =>
296
+ setHoverTargetFromEvent(event, 'preview', setHoverTarget)
297
+ }
298
+ onMouseLeave={() => setHoverTarget(null)}
299
+ >
300
+ <div class={styles().hookDetails.previewHeader}>
301
+ {isGenerationHook() ? 'Generated Output' : 'User View'}
302
+ </div>
303
+ <Show
304
+ when={isGenerationHook()}
305
+ fallback={
306
+ <MessagesPreview
307
+ hook={activeHook()}
308
+ messages={previewMessages()}
309
+ hoverTarget={hoverTarget()}
310
+ onHoverTarget={setHoverTarget}
311
+ />
312
+ }
313
+ >
314
+ <GenerationPreview
315
+ hook={activeHook()}
316
+ hoverTarget={hoverTarget()}
317
+ />
318
+ </Show>
319
+ </aside>
320
+ </Show>
321
+ </div>
322
+ </div>
323
+ )}
324
+ </Show>
325
+ )
326
+ }
327
+
328
+ const HookOverview: Component<{
329
+ onSelectHook: (hook: HookRecord) => void
330
+ }> = (props) => {
331
+ const { state } = useAIStore()
332
+ const styles = useStyles()
333
+ const hooks = createMemo(() =>
334
+ visibleHooks(Object.values(state.hooks.hooks)).sort(
335
+ (a, b) => a.registeredAt - b.registeredAt,
336
+ ),
337
+ )
338
+ const groups = createMemo(() => groupHooksByCategory(hooks()))
339
+ const summary = createMemo(() =>
340
+ createHookDashboardSummary(hooks(), state.hooks.runs),
341
+ )
342
+ const totalMessages = createMemo(() =>
343
+ Object.values(state.conversations).reduce(
344
+ (count, conversation) => count + conversation.messages.length,
345
+ 0,
346
+ ),
347
+ )
348
+ const totalTokens = createMemo(() =>
349
+ Object.values(state.conversations).reduce(
350
+ (count, conversation) => count + (conversation.usage?.totalTokens ?? 0),
351
+ 0,
352
+ ),
353
+ )
354
+
355
+ return (
356
+ <Show
357
+ when={hooks().length > 0}
358
+ fallback={
359
+ <div class={styles().hookDetails.empty}>
360
+ Open a page with TanStack AI hooks to inspect live state.
361
+ </div>
362
+ }
363
+ >
364
+ <div
365
+ class={styles().hookDetails.overview}
366
+ data-testid="ai-devtools-dashboard-overview"
367
+ >
368
+ <header class={styles().hookDetails.overviewHeader}>
369
+ <div>
370
+ <div class={styles().hookDetails.overviewTitle}>
371
+ Hooks Dashboard
372
+ </div>
373
+ <div class={styles().hookDetails.overviewSubtitle}>
374
+ Select a hook from the left or open one of the active hooks below.
375
+ </div>
376
+ </div>
377
+ </header>
378
+
379
+ <div class={styles().hookDetails.overviewMetricGrid}>
380
+ <OverviewMetric label="active hooks" value={summary().active} />
381
+ <OverviewMetric label="tools" value={summary().tools} />
382
+ <OverviewMetric
383
+ label="messages / runs"
384
+ value={`${totalMessages()} / ${summary().runs}`}
385
+ />
386
+ <OverviewMetric
387
+ label="tokens"
388
+ value={formatMetricValue(totalTokens())}
389
+ />
390
+ <OverviewMetric label="hooks" value={summary().total} />
391
+ <OverviewMetric label="active" value={summary().active} />
392
+ <OverviewMetric label="running" value={summary().running} />
393
+ <OverviewMetric label="categories" value={summary().categories} />
394
+ </div>
395
+
396
+ <div class={styles().hookDetails.overviewGroups}>
397
+ <For each={groups()}>
398
+ {(group) => (
399
+ <section
400
+ class={styles().hookDetails.overviewGroupCard}
401
+ data-testid="ai-devtools-overview-category"
402
+ data-category={group.id}
403
+ >
404
+ <div class={styles().hookDetails.overviewGroupHeader}>
405
+ <span>{group.label}</span>
406
+ <span>{group.hooks.length}</span>
407
+ </div>
408
+ <For each={group.hooks}>
409
+ {(hook) => {
410
+ const running = createMemo(() =>
411
+ isHookRunning(hook, state.hooks.runs),
412
+ )
413
+ const unseenCount = createMemo(() =>
414
+ getHookUnseenEventCount(state.hooks, hook.id),
415
+ )
416
+
417
+ return (
418
+ <button
419
+ type="button"
420
+ data-testid="ai-devtools-overview-hook"
421
+ data-hook-id={hook.id}
422
+ data-hook-name={hook.hookName}
423
+ data-display-name={getHookDisplayName(hook)}
424
+ class={styles().hookDetails.overviewHookButton}
425
+ onClick={() => props.onSelectHook(hook)}
426
+ >
427
+ <div class={styles().hookDetails.overviewHookMain}>
428
+ <span class={styles().hookDetails.overviewHookTitle}>
429
+ {getHookDisplayName(hook)}
430
+ </span>
431
+ <span class={styles().hookDetails.overviewHookId}>
432
+ <Show when={hook.displayName} fallback={hook.id}>
433
+ {hook.hookName} - {hook.id}
434
+ </Show>
435
+ </span>
436
+ </div>
437
+ <div class={styles().hookDetails.overviewHookMeta}>
438
+ <span class={styles().hookDetails.kind}>
439
+ {hook.outputKind ?? 'hook'}
440
+ </span>
441
+ <Show when={running()}>
442
+ <span class={styles().hookDetails.lifecycle}>
443
+ running
444
+ </span>
445
+ </Show>
446
+ <Show when={unseenCount() > 0}>
447
+ <span class={styles().hookDetails.kind}>
448
+ {unseenCount()} new
449
+ </span>
450
+ </Show>
451
+ </div>
452
+ </button>
453
+ )
454
+ }}
455
+ </For>
456
+ </section>
457
+ )}
458
+ </For>
459
+ </div>
460
+ </div>
461
+ </Show>
462
+ )
463
+ }
464
+
465
+ const OverviewMetric: Component<{ label: string; value: number | string }> = (
466
+ props,
467
+ ) => {
468
+ const styles = useStyles()
469
+ return (
470
+ <div class={styles().hookDetails.overviewMetricCard}>
471
+ <span class={styles().hookDetails.metricValue}>{props.value}</span>
472
+ <span class={styles().hookDetails.metricLabel}>{props.label}</span>
473
+ </div>
474
+ )
475
+ }
476
+
477
+ const HookHeader: Component<{
478
+ hook: HookRecord
479
+ runs: number
480
+ messages: number
481
+ totalTokens: number
482
+ isGenerationHook: boolean
483
+ }> = (props) => {
484
+ const styles = useStyles()
485
+ return (
486
+ <header
487
+ class={styles().hookDetails.header}
488
+ data-testid="ai-devtools-hook-header"
489
+ data-hook-id={props.hook.id}
490
+ data-hook-name={props.hook.hookName}
491
+ data-display-name={getHookDisplayName(props.hook)}
492
+ >
493
+ <div class={styles().hookDetails.headerMain}>
494
+ <div class={styles().hookDetails.titleRow}>
495
+ <span
496
+ class={styles().hookDetails.title}
497
+ data-testid="ai-devtools-hook-title"
498
+ >
499
+ {getHookDisplayName(props.hook)}
500
+ </span>
501
+ <span class={styles().hookDetails.lifecycle}>
502
+ {props.hook.lifecycle}
503
+ </span>
504
+ <Show when={props.hook.outputKind}>
505
+ <span class={styles().hookDetails.kind}>
506
+ {props.hook.outputKind}
507
+ </span>
508
+ </Show>
509
+ </div>
510
+ <div class={styles().hookDetails.identity}>
511
+ <Show when={props.hook.displayName}>
512
+ <span data-testid="ai-devtools-hook-technical-name">
513
+ {props.hook.hookName}
514
+ </span>
515
+ </Show>
516
+ <span>{props.hook.id}</span>
517
+ <Show when={props.hook.threadId}>
518
+ <span>thread {props.hook.threadId}</span>
519
+ </Show>
520
+ <Show when={props.hook.framework}>
521
+ <span>{props.hook.framework}</span>
522
+ </Show>
523
+ </div>
524
+ </div>
525
+ <div class={styles().hookDetails.metrics}>
526
+ <Metric label="runs" value={props.runs} testId="runs" />
527
+ <Show when={!props.isGenerationHook}>
528
+ <Metric
529
+ label="tools"
530
+ value={props.hook.tools.length}
531
+ testId="tools"
532
+ />
533
+ <Metric label="messages" value={props.messages} testId="messages" />
534
+ <Metric
535
+ label="tokens"
536
+ value={formatMetricValue(props.totalTokens)}
537
+ testId="tokens"
538
+ />
539
+ </Show>
540
+ </div>
541
+ </header>
542
+ )
543
+ }
544
+
545
+ const Metric: Component<{
546
+ label: string
547
+ value: number | string
548
+ testId?: string
549
+ }> = (props) => {
550
+ const styles = useStyles()
551
+ return (
552
+ <div
553
+ class={styles().hookDetails.metric}
554
+ data-testid={
555
+ props.testId ? `ai-devtools-hook-metric-${props.testId}` : undefined
556
+ }
557
+ >
558
+ <span class={styles().hookDetails.metricValue}>{props.value}</span>
559
+ <span class={styles().hookDetails.metricLabel}>{props.label}</span>
560
+ </div>
561
+ )
562
+ }
563
+
564
+ function formatMetricValue(value: number): string {
565
+ return Intl.NumberFormat(undefined, { notation: 'compact' }).format(value)
566
+ }
567
+
568
+ const TabButton: Component<{
569
+ label: string
570
+ tab: DetailTab
571
+ activeTab: DetailTab
572
+ onSelect: Setter<DetailTab>
573
+ }> = (props) => {
574
+ const styles = useStyles()
575
+ return (
576
+ <button
577
+ type="button"
578
+ data-testid="ai-devtools-hook-tab"
579
+ data-tab={props.label}
580
+ class={`${styles().hookDetails.tab} ${
581
+ props.activeTab === props.tab ? styles().hookDetails.tabActive : ''
582
+ }`}
583
+ onClick={() => props.onSelect(props.tab)}
584
+ >
585
+ {props.label}
586
+ </button>
587
+ )
588
+ }
589
+
590
+ const ConversationPanel: Component<{
591
+ hook: HookRecord
592
+ conversation?: Conversation
593
+ messages: Array<PreviewMessage>
594
+ hoverTarget: HoverTarget | null
595
+ onHoverTarget: (target: HoverTarget | null) => void
596
+ }> = (props) => {
597
+ const styles = useStyles()
598
+ return (
599
+ <Show
600
+ when={props.conversation}
601
+ fallback={
602
+ <Show
603
+ when={props.messages.length > 0}
604
+ fallback={
605
+ <div class={styles().hookDetails.stack}>
606
+ <section class={styles().hookDetails.section}>
607
+ <div class={styles().hookDetails.sectionTitle}>
608
+ Current State
609
+ </div>
610
+ <JsonPanel value={props.hook.state} compact />
611
+ </section>
612
+ </div>
613
+ }
614
+ >
615
+ <HookMessageTimeline
616
+ messages={props.messages}
617
+ hoverTarget={props.hoverTarget}
618
+ onHoverTarget={props.onHoverTarget}
619
+ />
620
+ </Show>
621
+ }
622
+ >
623
+ {(conversation) => (
624
+ <Show
625
+ when={conversation().iterations.length > 0}
626
+ fallback={
627
+ <HookMessageTimeline
628
+ messages={props.messages}
629
+ hoverTarget={props.hoverTarget}
630
+ onHoverTarget={props.onHoverTarget}
631
+ />
632
+ }
633
+ >
634
+ <IterationTimeline
635
+ iterations={conversation().iterations}
636
+ messages={conversation().messages}
637
+ hoverTarget={props.hoverTarget}
638
+ onHoverTarget={props.onHoverTarget}
639
+ />
640
+ </Show>
641
+ )}
642
+ </Show>
643
+ )
644
+ }
645
+
646
+ const MessagesPreview: Component<{
647
+ hook: HookRecord
648
+ messages: Array<PreviewMessage>
649
+ hoverTarget: HoverTarget | null
650
+ onHoverTarget: (target: HoverTarget | null) => void
651
+ }> = (props) => {
652
+ const { saveToolFixture, applyToolFixture } = useAIStore()
653
+ const styles = useStyles()
654
+ const [pendingFixture, setPendingFixture] =
655
+ createSignal<ToolFixtureRecord | null>(null)
656
+ const partClass = (kind: PreviewPart['kind']) => {
657
+ const hookDetails = styles().hookDetails
658
+ if (kind === 'thinking') return hookDetails.previewPartThinking
659
+ if (kind === 'tool-call') return hookDetails.previewPartToolCall
660
+ if (kind === 'tool-result') return hookDetails.previewPartToolResult
661
+ if (kind === 'structured-output')
662
+ return hookDetails.previewPartStructuredOutput
663
+ if (kind === 'media') return hookDetails.previewPartMedia
664
+ return ''
665
+ }
666
+ const saveObservedToolCall = (message: PreviewMessage, part: PreviewPart) => {
667
+ const fixture = createFixtureFromPreviewPart(props.hook, message, part)
668
+ if (!fixture) return
669
+ setPendingFixture(fixture)
670
+ }
671
+ const confirmSaveObservedToolCall = (name: string) => {
672
+ const fixture = pendingFixture()
673
+ if (!fixture) return
674
+ saveToolFixture({ ...fixture, name })
675
+ setPendingFixture(null)
676
+ }
677
+ const replayObservedToolCall = (
678
+ message: PreviewMessage,
679
+ part: PreviewPart,
680
+ ) => {
681
+ const fixture = createFixtureFromPreviewPart(props.hook, message, part)
682
+ if (!fixture) return
683
+ applyToolFixture(fixture)
684
+ }
685
+
686
+ return (
687
+ <Show
688
+ when={props.messages.length > 0}
689
+ fallback={<div class={styles().hookDetails.emptySmall}>No messages.</div>}
690
+ >
691
+ <div class={styles().hookDetails.messages}>
692
+ <Show when={pendingFixture()}>
693
+ {(fixture) => (
694
+ <FixtureNamePopover
695
+ defaultName={`${fixture().toolName} fixture`}
696
+ onCancel={() => setPendingFixture(null)}
697
+ onSave={confirmSaveObservedToolCall}
698
+ />
699
+ )}
700
+ </Show>
701
+ <For each={props.messages}>
702
+ {(message) => {
703
+ const visibleParts = () => visiblePreviewPartsForMessage(message)
704
+ return (
705
+ <div
706
+ {...getHoverDataAttributes({
707
+ messageIds: [message.id],
708
+ partIds: visibleParts().map((part) => part.id),
709
+ })}
710
+ class={`${styles().hookDetails.message} ${
711
+ isMessageOrPartHighlighted(
712
+ message.id,
713
+ visibleParts().map((part) => part.id),
714
+ props.hoverTarget,
715
+ )
716
+ ? styles().hookDetails.messageHighlighted
717
+ : ''
718
+ }`}
719
+ data-testid="ai-devtools-preview-message"
720
+ data-message-id={message.id}
721
+ data-role={message.role}
722
+ onMouseEnter={() =>
723
+ props.onHoverTarget(
724
+ createHoverTarget({
725
+ messageIds: [message.id],
726
+ origin: 'preview',
727
+ }),
728
+ )
729
+ }
730
+ onMouseLeave={() => props.onHoverTarget(null)}
731
+ >
732
+ <div class={styles().hookDetails.messageRole}>
733
+ {message.role}
734
+ </div>
735
+ <Show when={message.content}>
736
+ <div class={styles().hookDetails.messageContent}>
737
+ {message.content}
738
+ </div>
739
+ </Show>
740
+ <For each={visibleParts()}>
741
+ {(part) => (
742
+ <div
743
+ {...getHoverDataAttributes({
744
+ messageIds: [message.id],
745
+ partIds: [part.id],
746
+ })}
747
+ class={`${styles().hookDetails.previewPart} ${partClass(part.kind)} ${
748
+ props.hoverTarget?.partIds.includes(part.id)
749
+ ? styles().hookDetails.previewPartHighlighted
750
+ : ''
751
+ }`}
752
+ data-testid="ai-devtools-preview-part"
753
+ data-part-id={part.id}
754
+ data-part-kind={part.kind}
755
+ onMouseEnter={() =>
756
+ props.onHoverTarget(
757
+ createHoverTarget({
758
+ messageIds: [message.id],
759
+ partIds: [part.id],
760
+ origin: 'preview',
761
+ }),
762
+ )
763
+ }
764
+ onMouseLeave={() =>
765
+ props.onHoverTarget(
766
+ createHoverTarget({
767
+ messageIds: [message.id],
768
+ origin: 'preview',
769
+ }),
770
+ )
771
+ }
772
+ >
773
+ <span class={styles().hookDetails.previewPartLabel}>
774
+ {part.label}
775
+ </span>
776
+ <Show when={part.fixture}>
777
+ <div class={styles().hookDetails.previewPartActions}>
778
+ <button
779
+ type="button"
780
+ class={styles().hookDetails.previewPartActionButton}
781
+ data-testid="ai-devtools-preview-save-tool-call"
782
+ onClick={(event) => {
783
+ event.stopPropagation()
784
+ saveObservedToolCall(message, part)
785
+ }}
786
+ >
787
+ Save
788
+ </button>
789
+ <button
790
+ type="button"
791
+ class={styles().hookDetails.previewPartActionButton}
792
+ data-testid="ai-devtools-preview-replay-tool-call"
793
+ onClick={(event) => {
794
+ event.stopPropagation()
795
+ replayObservedToolCall(message, part)
796
+ }}
797
+ >
798
+ Replay
799
+ </button>
800
+ </div>
801
+ </Show>
802
+ <Show
803
+ when={part.jsonItems?.length}
804
+ fallback={
805
+ <span class={styles().hookDetails.previewPartContent}>
806
+ {part.content}
807
+ </span>
808
+ }
809
+ >
810
+ <div
811
+ class={`${styles().hookDetails.previewJsonItems} ${
812
+ part.kind === 'structured-output'
813
+ ? styles().hookDetails.previewJsonItemsCompare
814
+ : ''
815
+ }`}
816
+ >
817
+ <For each={part.jsonItems}>
818
+ {(item) => (
819
+ <div class={styles().hookDetails.previewJsonItem}>
820
+ <span
821
+ class={
822
+ styles().hookDetails.previewJsonItemLabel
823
+ }
824
+ >
825
+ {item.label}
826
+ </span>
827
+ <div
828
+ class={styles().hookDetails.previewJsonPanel}
829
+ >
830
+ <JsonTree
831
+ value={item.value}
832
+ defaultExpansionDepth={1}
833
+ copyable
834
+ />
835
+ </div>
836
+ </div>
837
+ )}
838
+ </For>
839
+ </div>
840
+ </Show>
841
+ </div>
842
+ )}
843
+ </For>
844
+ </div>
845
+ )
846
+ }}
847
+ </For>
848
+ </div>
849
+ </Show>
850
+ )
851
+ }
852
+
853
+ const HookMessageTimeline: Component<{
854
+ messages: Array<PreviewMessage>
855
+ hoverTarget: HoverTarget | null
856
+ onHoverTarget: (target: HoverTarget | null) => void
857
+ }> = (props) => {
858
+ const styles = useStyles()
859
+
860
+ return (
861
+ <Show
862
+ when={props.messages.length > 0}
863
+ fallback={
864
+ <div class={styles().hookDetails.emptySmall}>
865
+ No messages yet. Start a conversation to see messages here.
866
+ </div>
867
+ }
868
+ >
869
+ <div class={styles().hookDetails.messageTimeline}>
870
+ <For each={props.messages}>
871
+ {(message) => (
872
+ <div
873
+ {...getHoverDataAttributes({ messageIds: [message.id] })}
874
+ data-testid="ai-devtools-timeline-message"
875
+ data-message-id={message.id}
876
+ data-role={message.role}
877
+ class={`${styles().hookDetails.timelineMessage} ${
878
+ isMessageHighlighted(message.id, props.hoverTarget)
879
+ ? styles().hookDetails.timelineMessageHighlighted
880
+ : ''
881
+ }`}
882
+ onMouseEnter={() =>
883
+ props.onHoverTarget(
884
+ createHoverTarget({
885
+ messageIds: [message.id],
886
+ origin: 'timeline',
887
+ }),
888
+ )
889
+ }
890
+ onMouseLeave={() => props.onHoverTarget(null)}
891
+ >
892
+ <div class={styles().hookDetails.messageRole}>{message.role}</div>
893
+ <Show when={message.content}>
894
+ <div class={styles().hookDetails.messageContent}>
895
+ {message.content}
896
+ </div>
897
+ </Show>
898
+ <For each={visiblePreviewPartsForMessage(message)}>
899
+ {(part) => (
900
+ <div
901
+ {...getHoverDataAttributes({
902
+ messageIds: [message.id],
903
+ partIds: [part.id],
904
+ })}
905
+ class={styles().hookDetails.previewPart}
906
+ data-testid="ai-devtools-timeline-part"
907
+ data-part-id={part.id}
908
+ data-part-kind={part.kind}
909
+ >
910
+ <span class={styles().hookDetails.previewPartLabel}>
911
+ {part.label}
912
+ </span>
913
+ <Show
914
+ when={part.jsonItems?.length}
915
+ fallback={
916
+ <span class={styles().hookDetails.previewPartContent}>
917
+ {part.content}
918
+ </span>
919
+ }
920
+ >
921
+ <div
922
+ class={`${styles().hookDetails.previewJsonItems} ${
923
+ part.kind === 'structured-output'
924
+ ? styles().hookDetails.previewJsonItemsCompare
925
+ : ''
926
+ }`}
927
+ >
928
+ <For each={part.jsonItems}>
929
+ {(item) => (
930
+ <div class={styles().hookDetails.previewJsonItem}>
931
+ <span
932
+ class={
933
+ styles().hookDetails.previewJsonItemLabel
934
+ }
935
+ >
936
+ {item.label}
937
+ </span>
938
+ <div
939
+ class={styles().hookDetails.previewJsonPanel}
940
+ >
941
+ <JsonTree
942
+ value={item.value}
943
+ defaultExpansionDepth={1}
944
+ copyable
945
+ />
946
+ </div>
947
+ </div>
948
+ )}
949
+ </For>
950
+ </div>
951
+ </Show>
952
+ </div>
953
+ )}
954
+ </For>
955
+ </div>
956
+ )}
957
+ </For>
958
+ </div>
959
+ </Show>
960
+ )
961
+ }
962
+
963
+ const ToolsView: Component<{ hook: HookRecord }> = (props) => {
964
+ const { state, applyToolFixture, deleteToolFixture } = useAIStore()
965
+ const styles = useStyles()
966
+ const [selectedToolName, setSelectedToolName] = createSignal<string | null>(
967
+ props.hook.tools[0]?.name ?? null,
968
+ )
969
+
970
+ const selectedTool = createMemo(() =>
971
+ props.hook.tools.find((tool) => tool.name === selectedToolName()),
972
+ )
973
+
974
+ const savedFixtures = createMemo(() =>
975
+ state.hooks.fixtures
976
+ .filter(
977
+ (fixture) =>
978
+ fixture.hookId === props.hook.id ||
979
+ (!!props.hook.threadId && fixture.threadId === props.hook.threadId),
980
+ )
981
+ .sort((a, b) => b.createdAt - a.createdAt),
982
+ )
983
+
984
+ return (
985
+ <div class={styles().hookDetails.toolsGrid} data-testid="ai-devtools-tools">
986
+ <div class={styles().hookDetails.toolsList}>
987
+ <Show
988
+ when={props.hook.tools.length > 0}
989
+ fallback={
990
+ <div class={styles().hookDetails.emptySmall}>No tools.</div>
991
+ }
992
+ >
993
+ <For each={props.hook.tools}>
994
+ {(tool) => (
995
+ <button
996
+ type="button"
997
+ data-testid="ai-devtools-tool-row"
998
+ data-tool-name={tool.name}
999
+ class={`${styles().hookDetails.toolRow} ${
1000
+ selectedToolName() === tool.name
1001
+ ? styles().hookDetails.toolRowSelected
1002
+ : ''
1003
+ }`}
1004
+ onClick={() => setSelectedToolName(tool.name)}
1005
+ >
1006
+ <span class={styles().hookDetails.toolName}>{tool.name}</span>
1007
+ <Show when={tool.needsApproval}>
1008
+ <span class={styles().hookDetails.eventBadge}>approval</span>
1009
+ </Show>
1010
+ </button>
1011
+ )}
1012
+ </For>
1013
+ </Show>
1014
+ </div>
1015
+ <div class={styles().hookDetails.toolDetail}>
1016
+ <Show
1017
+ when={selectedTool()}
1018
+ fallback={
1019
+ <div class={styles().hookDetails.emptySmall}>Select a tool.</div>
1020
+ }
1021
+ >
1022
+ {(tool) => (
1023
+ <>
1024
+ <ToolFixtureForm
1025
+ hook={props.hook}
1026
+ tool={tool()}
1027
+ onFire={(fixture) => applyToolFixture(fixture)}
1028
+ />
1029
+ <div class={styles().hookDetails.sectionTitle}>
1030
+ Saved Fixtures
1031
+ </div>
1032
+ <Show
1033
+ when={savedFixtures().length > 0}
1034
+ fallback={
1035
+ <div class={styles().hookDetails.emptySmall}>
1036
+ No saved fixtures for this hook.
1037
+ </div>
1038
+ }
1039
+ >
1040
+ <div class={styles().hookDetails.eventList}>
1041
+ <For each={savedFixtures()}>
1042
+ {(fixture) => (
1043
+ <div
1044
+ class={styles().hookDetails.fixtureRow}
1045
+ data-testid="ai-devtools-fixture-row"
1046
+ data-fixture-name={fixture.name ?? fixture.toolName}
1047
+ data-tool-name={fixture.toolName}
1048
+ >
1049
+ <div class={styles().hookDetails.fixtureInfo}>
1050
+ <span class={styles().hookDetails.fixtureName}>
1051
+ {fixture.name ?? fixture.toolName}
1052
+ </span>
1053
+ <span class={styles().hookDetails.fixtureMeta}>
1054
+ {fixture.toolName} - {formatTime(fixture.createdAt)}
1055
+ </span>
1056
+ </div>
1057
+ <div class={styles().hookDetails.fixtureRowActions}>
1058
+ <button
1059
+ type="button"
1060
+ class={styles().hookDetails.fixtureRowButton}
1061
+ data-testid="ai-devtools-fixture-replay"
1062
+ onClick={() => applyToolFixture(fixture)}
1063
+ >
1064
+ Replay
1065
+ </button>
1066
+ <button
1067
+ type="button"
1068
+ class={`${styles().hookDetails.fixtureRowButton} ${styles().hookDetails.fixtureDangerButton}`}
1069
+ data-testid="ai-devtools-fixture-delete"
1070
+ onClick={() => deleteToolFixture(fixture.id)}
1071
+ >
1072
+ Delete
1073
+ </button>
1074
+ </div>
1075
+ </div>
1076
+ )}
1077
+ </For>
1078
+ </div>
1079
+ </Show>
1080
+ </>
1081
+ )}
1082
+ </Show>
1083
+ </div>
1084
+ </div>
1085
+ )
1086
+ }
1087
+
1088
+ const JsonPanel: Component<{ value: unknown; compact?: boolean }> = (props) => {
1089
+ const styles = useStyles()
1090
+ return (
1091
+ <div
1092
+ class={`${styles().hookDetails.jsonPanel} ${
1093
+ props.compact ? styles().hookDetails.jsonPanelCompact : ''
1094
+ }`}
1095
+ >
1096
+ <JsonTree
1097
+ value={props.value}
1098
+ defaultExpansionDepth={props.compact ? 1 : 2}
1099
+ copyable
1100
+ />
1101
+ </div>
1102
+ )
1103
+ }
1104
+
1105
+ function messagesFromSnapshot(
1106
+ state: Record<string, unknown>,
1107
+ ): Array<PreviewMessage> {
1108
+ const messages = state.messages
1109
+ if (!Array.isArray(messages)) return []
1110
+ return messages.map(messageFromUnknown).filter(isPreviewMessage)
1111
+ }
1112
+
1113
+ function setHoverTargetFromEvent(
1114
+ event: MouseEvent & { currentTarget: HTMLElement },
1115
+ origin: HoverOrigin,
1116
+ onHoverTarget: (target: HoverTarget | null) => void,
1117
+ ): void {
1118
+ const element = findClosestHoverTargetElement(
1119
+ event.target,
1120
+ event.currentTarget,
1121
+ )
1122
+ if (!element) return
1123
+
1124
+ onHoverTarget(
1125
+ createHoverTargetFromDataAttributes({
1126
+ messageIds: element.getAttribute('data-ai-devtools-hover-message-ids'),
1127
+ partIds: element.getAttribute('data-ai-devtools-hover-part-ids'),
1128
+ origin,
1129
+ }),
1130
+ )
1131
+ }
1132
+
1133
+ function findClosestHoverTargetElement(
1134
+ target: EventTarget | null,
1135
+ container: HTMLElement,
1136
+ ): HTMLElement | undefined {
1137
+ if (!(target instanceof Element)) return undefined
1138
+
1139
+ const element = target.closest<HTMLElement>(
1140
+ '[data-ai-devtools-hover-message-ids], [data-ai-devtools-hover-part-ids]',
1141
+ )
1142
+
1143
+ if (!element || !container.contains(element)) return undefined
1144
+ return element
1145
+ }
1146
+
1147
+ function findMatchingHoverElement(
1148
+ container: HTMLElement,
1149
+ target: HoverTarget,
1150
+ ): HTMLElement | undefined {
1151
+ const elements = Array.from(
1152
+ container.querySelectorAll<HTMLElement>(
1153
+ '[data-ai-devtools-hover-message-ids], [data-ai-devtools-hover-part-ids]',
1154
+ ),
1155
+ )
1156
+
1157
+ if (target.partIds.length > 0) {
1158
+ const partElement = elements.find((element) =>
1159
+ hoverTargetMatchesElement(
1160
+ { ...target, messageIds: [] },
1161
+ {
1162
+ messageIds: element.getAttribute(
1163
+ 'data-ai-devtools-hover-message-ids',
1164
+ ),
1165
+ partIds: element.getAttribute('data-ai-devtools-hover-part-ids'),
1166
+ },
1167
+ ),
1168
+ )
1169
+ if (partElement) return partElement
1170
+ }
1171
+
1172
+ return elements.find((element) =>
1173
+ hoverTargetMatchesElement(target, {
1174
+ messageIds: element.getAttribute('data-ai-devtools-hover-message-ids'),
1175
+ partIds: element.getAttribute('data-ai-devtools-hover-part-ids'),
1176
+ }),
1177
+ )
1178
+ }
1179
+
1180
+ function scrollElementIntoPaneView(
1181
+ pane: HTMLElement,
1182
+ element: HTMLElement,
1183
+ options: { align: 'nearest' | 'start' },
1184
+ ): void {
1185
+ const scrollContainer = findScrollContainer(element, pane)
1186
+ const containerRect = scrollContainer.getBoundingClientRect()
1187
+ const elementRect = element.getBoundingClientRect()
1188
+ const padding = 8
1189
+
1190
+ let nextScrollTop = scrollContainer.scrollTop
1191
+ if (options.align === 'start') {
1192
+ nextScrollTop += elementRect.top - containerRect.top - padding
1193
+ } else if (elementRect.top < containerRect.top + padding) {
1194
+ nextScrollTop += elementRect.top - containerRect.top - padding
1195
+ } else if (elementRect.bottom > containerRect.bottom - padding) {
1196
+ nextScrollTop += elementRect.bottom - containerRect.bottom + padding
1197
+ } else {
1198
+ return
1199
+ }
1200
+
1201
+ animateScrollTop(scrollContainer, Math.max(0, nextScrollTop))
1202
+ }
1203
+
1204
+ function findScrollContainer(
1205
+ element: HTMLElement,
1206
+ boundary: HTMLElement,
1207
+ ): HTMLElement {
1208
+ let current = element.parentElement
1209
+
1210
+ while (current && boundary.contains(current)) {
1211
+ const style = getComputedStyle(current)
1212
+ const canScroll =
1213
+ current.scrollHeight > current.clientHeight &&
1214
+ /(auto|scroll|overlay)/.test(style.overflowY)
1215
+
1216
+ if (canScroll) return current
1217
+ if (current === boundary) break
1218
+ current = current.parentElement
1219
+ }
1220
+
1221
+ return boundary
1222
+ }
1223
+
1224
+ function animateScrollTop(element: HTMLElement, targetScrollTop: number): void {
1225
+ const startScrollTop = element.scrollTop
1226
+ const distance = targetScrollTop - startScrollTop
1227
+ if (Math.abs(distance) < 1) return
1228
+
1229
+ const existingAnimation = scrollAnimations.get(element)
1230
+ if (existingAnimation !== undefined) {
1231
+ window.clearTimeout(existingAnimation)
1232
+ }
1233
+
1234
+ const startedAt = performance.now()
1235
+ const duration = 220
1236
+
1237
+ const step = () => {
1238
+ const now = performance.now()
1239
+ const progress = Math.min(1, (now - startedAt) / duration)
1240
+ const eased = 1 - Math.pow(1 - progress, 3)
1241
+ element.scrollTop = startScrollTop + distance * eased
1242
+
1243
+ if (progress < 1) {
1244
+ scrollAnimations.set(element, window.setTimeout(step, 16))
1245
+ return
1246
+ }
1247
+
1248
+ scrollAnimations.delete(element)
1249
+ }
1250
+
1251
+ scrollAnimations.set(element, window.setTimeout(step, 16))
1252
+ }
1253
+
1254
+ function findConversationForHook(
1255
+ hook: HookRecord,
1256
+ conversations: Record<string, Conversation>,
1257
+ ): Conversation | undefined {
1258
+ const candidates: Array<Conversation> = []
1259
+ const addCandidate = (conversation: Conversation | undefined) => {
1260
+ if (!conversation) return
1261
+ if (candidates.some((item) => item.id === conversation.id)) return
1262
+ candidates.push(conversation)
1263
+ }
1264
+
1265
+ addCandidate(conversations[hook.id])
1266
+ if (hook.clientId) addCandidate(conversations[hook.clientId])
1267
+ if (hook.threadId) addCandidate(conversations[hook.threadId])
1268
+
1269
+ if (hook.runIds.length > 0) {
1270
+ const runIds = new Set(hook.runIds)
1271
+ addCandidate(
1272
+ Object.values(conversations).find((conversation) =>
1273
+ conversation.runIds?.some((runId) => runIds.has(runId)),
1274
+ ),
1275
+ )
1276
+ }
1277
+
1278
+ const snapshotMessages = hook.state.messages
1279
+ if (Array.isArray(snapshotMessages)) {
1280
+ const messageIds = new Set(
1281
+ snapshotMessages
1282
+ .map((message) =>
1283
+ isRecord(message) && typeof message.id === 'string'
1284
+ ? message.id
1285
+ : undefined,
1286
+ )
1287
+ .filter((id): id is string => typeof id === 'string'),
1288
+ )
1289
+
1290
+ if (messageIds.size > 0) {
1291
+ addCandidate(
1292
+ Object.values(conversations).find((conversation) =>
1293
+ conversation.messages.some((message) => messageIds.has(message.id)),
1294
+ ),
1295
+ )
1296
+ }
1297
+ }
1298
+
1299
+ return (
1300
+ candidates.find(
1301
+ (conversation) =>
1302
+ conversation.iterations.length > 0 && conversation.messages.length > 0,
1303
+ ) ??
1304
+ candidates.find((conversation) => conversation.iterations.length > 0) ??
1305
+ candidates.find((conversation) => conversation.messages.length > 0) ??
1306
+ candidates[0]
1307
+ )
1308
+ }
1309
+
1310
+ function messageFromConversation(message: Message): PreviewMessage {
1311
+ const sourceMessage = toolFixtureMessageFromConversation(message)
1312
+
1313
+ if (
1314
+ message.role === 'tool' &&
1315
+ (!message.parts || message.parts.length === 0)
1316
+ ) {
1317
+ return {
1318
+ id: message.id,
1319
+ role: message.role,
1320
+ content: '',
1321
+ parts: [toolResultPartFromContent(message.id, message.content)],
1322
+ }
1323
+ }
1324
+
1325
+ return {
1326
+ id: message.id,
1327
+ role: message.role,
1328
+ content: message.content,
1329
+ ...(sourceMessage ? { sourceMessage } : {}),
1330
+ parts: [
1331
+ ...partsFromMessageParts(message.parts ?? [], message.id),
1332
+ ...partsFromToolCalls(message.toolCalls ?? [], message.id),
1333
+ ],
1334
+ }
1335
+ }
1336
+
1337
+ function messageFromUnknown(value: unknown): PreviewMessage | undefined {
1338
+ if (!isRecord(value)) return undefined
1339
+ const id = typeof value.id === 'string' ? value.id : undefined
1340
+ const role = typeof value.role === 'string' ? value.role : undefined
1341
+ if (!id || !role) return undefined
1342
+ const sourceMessage = toolFixtureMessageFromUnknown(value)
1343
+
1344
+ const content =
1345
+ typeof value.content === 'string'
1346
+ ? value.content
1347
+ : Array.isArray(value.parts)
1348
+ ? partsToMessageText(value.parts)
1349
+ : ''
1350
+
1351
+ return {
1352
+ id,
1353
+ role,
1354
+ content,
1355
+ ...(sourceMessage ? { sourceMessage } : {}),
1356
+ parts: Array.isArray(value.parts) ? partsFromUnknown(value.parts, id) : [],
1357
+ }
1358
+ }
1359
+
1360
+ function partsFromMessageParts(
1361
+ parts: Array<MessagePart>,
1362
+ messageId: string,
1363
+ ): Array<PreviewPart> {
1364
+ return parts.map((part, index) =>
1365
+ previewPartFromRecord(part, index, messageId),
1366
+ )
1367
+ }
1368
+
1369
+ function partsFromUnknown(
1370
+ parts: Array<unknown>,
1371
+ messageId?: string,
1372
+ ): Array<PreviewPart> {
1373
+ return parts
1374
+ .map((part, index) =>
1375
+ isRecord(part)
1376
+ ? previewPartFromRecord(part, index, messageId)
1377
+ : undefined,
1378
+ )
1379
+ .filter(isPreviewPart)
1380
+ }
1381
+
1382
+ function previewPartFromRecord(
1383
+ part: PreviewPartSource,
1384
+ index: number,
1385
+ messageId?: string,
1386
+ ): PreviewPart {
1387
+ const type = typeof part.type === 'string' ? part.type : 'part'
1388
+ if (type === 'tool-call') {
1389
+ const name =
1390
+ typeof part.name === 'string'
1391
+ ? part.name
1392
+ : typeof part.toolName === 'string'
1393
+ ? part.toolName
1394
+ : 'tool'
1395
+ const rawId =
1396
+ typeof part.toolCallId === 'string'
1397
+ ? part.toolCallId
1398
+ : typeof part.id === 'string'
1399
+ ? part.id
1400
+ : `${index}:${name}`
1401
+ const input = part.input ?? part.arguments
1402
+ const output = part.output
1403
+ const parsedInput = input === undefined ? {} : parseJsonishValue(input)
1404
+ const parsedOutput =
1405
+ output === undefined ? undefined : parseJsonishValue(output)
1406
+ const approvalStatus = approvalStatusFromRecord(part.approval, part.state)
1407
+ const jsonItems: Array<PreviewJsonItem> = []
1408
+ if (input !== undefined) {
1409
+ jsonItems.push({
1410
+ label: 'Input',
1411
+ value: parsedInput,
1412
+ })
1413
+ }
1414
+ if (part.approval !== undefined) {
1415
+ jsonItems.push({
1416
+ label: 'Approval',
1417
+ value: parseJsonishValue(part.approval),
1418
+ })
1419
+ }
1420
+ if (output !== undefined) {
1421
+ jsonItems.push({
1422
+ label: 'Output',
1423
+ value: parsedOutput,
1424
+ })
1425
+ }
1426
+ return {
1427
+ id: toolCallPartId(rawId),
1428
+ label: approvalStatus
1429
+ ? `tool call ${name} - ${approvalStatus}`
1430
+ : `tool call ${name}`,
1431
+ content: formatUnknown(input ?? output),
1432
+ jsonItems,
1433
+ kind: 'tool-call',
1434
+ fixture: {
1435
+ toolName: name,
1436
+ input: parsedInput,
1437
+ ...(parsedOutput !== undefined ? { output: parsedOutput } : {}),
1438
+ toolCallId: rawId,
1439
+ ...(messageId ? { messageId } : {}),
1440
+ },
1441
+ }
1442
+ }
1443
+ if (type === 'tool-result') {
1444
+ const name =
1445
+ typeof part.name === 'string'
1446
+ ? part.name
1447
+ : typeof part.toolName === 'string'
1448
+ ? part.toolName
1449
+ : undefined
1450
+ const rawId =
1451
+ typeof part.toolCallId === 'string'
1452
+ ? part.toolCallId
1453
+ : typeof part.id === 'string'
1454
+ ? part.id
1455
+ : `${index}`
1456
+ const output = part.output ?? part.content ?? part.error
1457
+ return {
1458
+ id: toolResultPartId(rawId),
1459
+ label: name ? `tool result ${name}` : 'tool result',
1460
+ content: formatUnknown(output),
1461
+ jsonItems:
1462
+ output === undefined
1463
+ ? []
1464
+ : [
1465
+ {
1466
+ label: part.error ? 'Error' : 'Output',
1467
+ value: parseJsonishValue(output),
1468
+ },
1469
+ ],
1470
+ kind: 'tool-result',
1471
+ }
1472
+ }
1473
+ if (type === 'structured-output') {
1474
+ const status = typeof part.status === 'string' ? part.status : undefined
1475
+ const raw = typeof part.raw === 'string' ? part.raw : undefined
1476
+
1477
+ return {
1478
+ id: structuredOutputPartId(messageId ?? `${index}`),
1479
+ label: status ? `structured output - ${status}` : 'structured output',
1480
+ content: raw ?? formatUnknown(part.data ?? part.partial),
1481
+ jsonItems: structuredOutputJsonItems(part),
1482
+ kind: 'structured-output',
1483
+ }
1484
+ }
1485
+ if (type === 'thinking') {
1486
+ return {
1487
+ id: `${index}:thinking`,
1488
+ label: 'reasoning',
1489
+ content: formatUnknown(part.content),
1490
+ kind: 'thinking',
1491
+ }
1492
+ }
1493
+ if (type === 'image' || type === 'audio' || type === 'video') {
1494
+ return {
1495
+ id: `${index}:${type}`,
1496
+ label: type,
1497
+ content: formatUnknown(part.source ?? part.metadata),
1498
+ kind: 'media',
1499
+ }
1500
+ }
1501
+ return {
1502
+ id: `${index}:${type}`,
1503
+ label: type,
1504
+ content: formatUnknown(part.content ?? part.text),
1505
+ kind: 'text',
1506
+ }
1507
+ }
1508
+
1509
+ function partsFromToolCalls(
1510
+ toolCalls: Array<ToolCall>,
1511
+ messageId: string,
1512
+ ): Array<PreviewPart> {
1513
+ return toolCalls.map((tool) => {
1514
+ const input = parseJsonishValue(tool.arguments)
1515
+ const output =
1516
+ tool.result === undefined ? undefined : parseJsonishValue(tool.result)
1517
+ const approvalStatus = approvalStatusFromToolCall(tool)
1518
+ const jsonItems: Array<PreviewJsonItem> = [
1519
+ {
1520
+ label: 'Input',
1521
+ value: input,
1522
+ },
1523
+ ]
1524
+
1525
+ if (tool.approvalId) {
1526
+ jsonItems.push({
1527
+ label: 'Approval',
1528
+ value: {
1529
+ id: tool.approvalId,
1530
+ required: tool.approvalRequired,
1531
+ approved: tool.approvalApproved,
1532
+ },
1533
+ })
1534
+ }
1535
+
1536
+ if (output !== undefined) {
1537
+ jsonItems.push({
1538
+ label: 'Output',
1539
+ value: output,
1540
+ })
1541
+ }
1542
+
1543
+ return {
1544
+ id: toolCallPartId(tool.id),
1545
+ label: approvalStatus
1546
+ ? `tool call ${tool.name} - ${approvalStatus}`
1547
+ : `tool call ${tool.name}`,
1548
+ content: formatUnknown(tool.arguments),
1549
+ jsonItems,
1550
+ kind: 'tool-call',
1551
+ fixture: {
1552
+ toolName: tool.name,
1553
+ input,
1554
+ ...(output !== undefined ? { output } : {}),
1555
+ toolCallId: tool.id,
1556
+ messageId,
1557
+ },
1558
+ }
1559
+ })
1560
+ }
1561
+
1562
+ function toolFixtureMessageFromConversation(
1563
+ message: Message,
1564
+ ): ToolFixtureMessage | undefined {
1565
+ if (!isToolFixtureMessageRole(message.role)) return undefined
1566
+
1567
+ const parts =
1568
+ message.parts && message.parts.length > 0
1569
+ ? message.parts
1570
+ : message.toolCalls && message.toolCalls.length > 0
1571
+ ? message.toolCalls.map(toolCallToFixturePart)
1572
+ : message.content
1573
+ ? [{ type: 'text', content: message.content }]
1574
+ : []
1575
+
1576
+ if (parts.length === 0) return undefined
1577
+
1578
+ return {
1579
+ id: message.id,
1580
+ role: message.role,
1581
+ parts: parts.map(jsonSafeValue),
1582
+ createdAt: message.timestamp,
1583
+ }
1584
+ }
1585
+
1586
+ function toolFixtureMessageFromUnknown(
1587
+ value: Record<string, unknown>,
1588
+ ): ToolFixtureMessage | undefined {
1589
+ const id = typeof value.id === 'string' ? value.id : undefined
1590
+ const role = typeof value.role === 'string' ? value.role : undefined
1591
+ if (!id || !isToolFixtureMessageRole(role)) return undefined
1592
+
1593
+ const parts = Array.isArray(value.parts)
1594
+ ? value.parts
1595
+ : typeof value.content === 'string'
1596
+ ? [{ type: 'text', content: value.content }]
1597
+ : []
1598
+ if (parts.length === 0) return undefined
1599
+
1600
+ return {
1601
+ id,
1602
+ role,
1603
+ parts: parts.map(jsonSafeValue),
1604
+ }
1605
+ }
1606
+
1607
+ function approvalStatusFromToolCall(tool: ToolCall): string | undefined {
1608
+ if (tool.approvalApproved === true || tool.state === 'approved') {
1609
+ return 'approved'
1610
+ }
1611
+ if (tool.approvalApproved === false || tool.state === 'denied') {
1612
+ return 'denied'
1613
+ }
1614
+ if (
1615
+ tool.approvalRequired ||
1616
+ tool.approvalId ||
1617
+ tool.state === 'approval-requested'
1618
+ ) {
1619
+ return 'approval requested'
1620
+ }
1621
+ if (tool.state === 'approval-responded') {
1622
+ return 'responded'
1623
+ }
1624
+ return undefined
1625
+ }
1626
+
1627
+ function approvalStatusFromRecord(
1628
+ approval: unknown,
1629
+ state: unknown,
1630
+ ): string | undefined {
1631
+ if (isRecord(approval)) {
1632
+ if (approval.approved === true) return 'approved'
1633
+ if (approval.approved === false) return 'denied'
1634
+ if (approval.needsApproval === true) return 'approval requested'
1635
+ }
1636
+ if (state === 'approved') return 'approved'
1637
+ if (state === 'denied') return 'denied'
1638
+ if (state === 'approval-requested') return 'approval requested'
1639
+ if (state === 'approval-responded') return 'responded'
1640
+ return undefined
1641
+ }
1642
+
1643
+ function toolCallToFixturePart(tool: ToolCall): Record<string, unknown> {
1644
+ return {
1645
+ type: 'tool-call',
1646
+ id: tool.id,
1647
+ name: tool.name,
1648
+ arguments: tool.arguments,
1649
+ input: parseJsonishValue(tool.arguments),
1650
+ state: tool.state,
1651
+ ...(tool.approvalId
1652
+ ? {
1653
+ approval: {
1654
+ id: tool.approvalId,
1655
+ needsApproval: tool.approvalRequired ?? false,
1656
+ ...(tool.approvalApproved !== undefined
1657
+ ? { approved: tool.approvalApproved }
1658
+ : {}),
1659
+ },
1660
+ }
1661
+ : {}),
1662
+ ...(tool.result !== undefined ? { output: tool.result } : {}),
1663
+ }
1664
+ }
1665
+
1666
+ function createFixtureFromPreviewPart(
1667
+ hook: HookRecord,
1668
+ message: PreviewMessage,
1669
+ part: PreviewPart,
1670
+ ): ReturnType<typeof createToolFixtureRecord> | undefined {
1671
+ if (!part.fixture) return undefined
1672
+ const hasRegisteredClientTool = hook.tools.some(
1673
+ (tool) => tool.name === part.fixture?.toolName,
1674
+ )
1675
+
1676
+ return createToolFixtureRecord({
1677
+ hookId: hook.id,
1678
+ ...(hook.threadId ? { threadId: hook.threadId } : {}),
1679
+ ...(message.sourceMessage ? { message: message.sourceMessage } : {}),
1680
+ ...(hasRegisteredClientTool && part.fixture.output === undefined
1681
+ ? { execute: true }
1682
+ : {}),
1683
+ ...part.fixture,
1684
+ })
1685
+ }
1686
+
1687
+ function toolResultPartFromContent(
1688
+ messageId: string,
1689
+ content: string,
1690
+ ): PreviewPart {
1691
+ return {
1692
+ id: toolResultPartId(messageId),
1693
+ label: 'tool result',
1694
+ content,
1695
+ jsonItems: [
1696
+ {
1697
+ label: 'Output',
1698
+ value: parseJsonishValue(content),
1699
+ },
1700
+ ],
1701
+ kind: 'tool-result',
1702
+ }
1703
+ }
1704
+
1705
+ function formatUnknown(value: unknown): string {
1706
+ if (value === undefined || value === null) return ''
1707
+ if (typeof value === 'string') return value
1708
+ try {
1709
+ return JSON.stringify(value, null, 2)
1710
+ } catch (error) {
1711
+ console.warn(
1712
+ '[ai-devtools] formatUnknown failed to JSON.stringify a value (likely a circular reference or BigInt); using a string placeholder instead.',
1713
+ { error },
1714
+ )
1715
+ return `[ai-devtools] unserializable value: ${
1716
+ error instanceof Error ? error.message : String(error)
1717
+ }`
1718
+ }
1719
+ }
1720
+
1721
+ function isPreviewMessage(
1722
+ value: PreviewMessage | undefined,
1723
+ ): value is PreviewMessage {
1724
+ return Boolean(value)
1725
+ }
1726
+
1727
+ function isPreviewPart(value: PreviewPart | undefined): value is PreviewPart {
1728
+ return Boolean(value)
1729
+ }
1730
+
1731
+ function isRecord(value: unknown): value is Record<string, unknown> {
1732
+ return typeof value === 'object' && value !== null
1733
+ }
1734
+
1735
+ function isToolFixtureMessageRole(
1736
+ role: unknown,
1737
+ ): role is ToolFixtureMessage['role'] {
1738
+ return role === 'system' || role === 'user' || role === 'assistant'
1739
+ }
1740
+
1741
+ function jsonSafeValue(value: unknown): unknown {
1742
+ try {
1743
+ return JSON.parse(JSON.stringify(value))
1744
+ } catch (error) {
1745
+ console.warn(
1746
+ '[ai-devtools] jsonSafeValue failed to round-trip a fixture part (likely a circular reference or BigInt); using a string placeholder instead of the original value.',
1747
+ { error },
1748
+ )
1749
+ return `[ai-devtools] unserializable value: ${
1750
+ error instanceof Error ? error.message : String(error)
1751
+ }`
1752
+ }
1753
+ }
1754
+
1755
+ function formatTime(timestamp: number): string {
1756
+ return new Date(timestamp).toLocaleTimeString()
1757
+ }