@tanstack/ai-devtools-core 0.3.37 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) 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/store/ai-context.tsx +1165 -159
  29. package/src/store/ai-store.ts +0 -1
  30. package/src/store/hook-registry.ts +815 -0
  31. package/src/store/message-event-utils.ts +60 -0
  32. package/src/styles/use-styles.ts +1558 -99
  33. package/dist/Shell/MRWZEHAJ.js +0 -6225
  34. package/dist/Shell/RRRNZJ5E.js +0 -7560
  35. package/dist/production/Shell/MRWZEHAJ.js +0 -6225
  36. package/dist/production/Shell/RRRNZJ5E.js +0 -7560
  37. package/src/components/ConversationDetails.tsx +0 -216
  38. package/src/components/ConversationsList.tsx +0 -21
  39. package/src/components/conversation/ActivityEventsTab.tsx +0 -57
  40. package/src/components/conversation/ChunkBadges.tsx +0 -51
  41. package/src/components/conversation/ChunkItem.tsx +0 -273
  42. package/src/components/conversation/ChunksCollapsible.tsx +0 -56
  43. package/src/components/conversation/ChunksTab.tsx +0 -87
  44. package/src/components/conversation/ConversationHeader.tsx +0 -106
  45. package/src/components/conversation/ConversationTabs.tsx +0 -205
  46. package/src/components/conversation/MessageCard.tsx +0 -174
  47. package/src/components/conversation/MessageGroup.tsx +0 -101
  48. package/src/components/conversation/MessagesTab.tsx +0 -30
  49. package/src/components/conversation/SummariesTab.tsx +0 -91
  50. package/src/components/conversation/ToolCallDisplay.tsx +0 -99
  51. package/src/components/list/ConversationRow.tsx +0 -85
  52. package/src/components/list/index.ts +0 -1
@@ -0,0 +1,847 @@
1
+ import {
2
+ For,
3
+ Show,
4
+ createEffect,
5
+ createMemo,
6
+ createSignal,
7
+ onCleanup,
8
+ } from 'solid-js'
9
+ import { Portal } from 'solid-js/web'
10
+ import { JsonTree } from '@tanstack/devtools-ui'
11
+ import { useStyles } from '../../styles/use-styles'
12
+ import { getHoverDataAttributes, isMessageHighlighted } from './preview-model'
13
+ import { getHookDisplayName } from './hook-dashboard-model'
14
+ import type { HookRecord } from '../../store/hook-registry'
15
+ import type { HoverTarget } from './preview-model'
16
+ import type { Component, JSX } from 'solid-js'
17
+
18
+ interface GenerationPanelProps {
19
+ hook: HookRecord
20
+ hoverTarget: HoverTarget | null
21
+ }
22
+
23
+ interface GenerationProgress {
24
+ value: number
25
+ message?: string
26
+ }
27
+
28
+ interface GenerationMediaItem {
29
+ src: string
30
+ sourceType?: string
31
+ mimeType?: string
32
+ format?: string
33
+ duration?: number
34
+ }
35
+
36
+ interface GenerationVideoJob {
37
+ jobId: string
38
+ status?: string
39
+ progress?: number
40
+ error?: string
41
+ }
42
+
43
+ interface GenerationRunView {
44
+ id: string
45
+ input: unknown | null
46
+ result: unknown | null
47
+ preview: GenerationPreviewState
48
+ status: string
49
+ isLoading: boolean
50
+ progress?: GenerationProgress
51
+ startedAt?: number
52
+ updatedAt?: number
53
+ completedAt?: number
54
+ error?: string
55
+ jobId?: string
56
+ videoStatus?: unknown
57
+ }
58
+
59
+ interface GenerationOutputView {
60
+ id: string
61
+ runId: string
62
+ runLabel: string
63
+ title: string
64
+ kind: 'image' | 'audio' | 'video' | 'text' | 'structured'
65
+ item?: GenerationMediaItem
66
+ text?: string
67
+ value?: unknown
68
+ job?: GenerationVideoJob
69
+ completedAt?: number
70
+ }
71
+
72
+ type GenerationPreviewState =
73
+ | {
74
+ kind: 'image'
75
+ items: Array<GenerationMediaItem>
76
+ }
77
+ | {
78
+ kind: 'audio'
79
+ items: Array<GenerationMediaItem>
80
+ }
81
+ | {
82
+ kind: 'video'
83
+ items: Array<GenerationMediaItem>
84
+ job?: GenerationVideoJob
85
+ }
86
+ | {
87
+ kind: 'text'
88
+ text: string
89
+ }
90
+ | {
91
+ kind: 'structured'
92
+ value: unknown
93
+ }
94
+ | {
95
+ kind: 'empty'
96
+ }
97
+
98
+ export const GenerationPanel: Component<GenerationPanelProps> = (props) => {
99
+ const styles = useStyles()
100
+ let runsContainer: HTMLDivElement | undefined
101
+ const generationRuns = createMemo(() =>
102
+ sortGenerationRuns(
103
+ generationRunsFromUnknown(props.hook.state.runs, props.hook.state),
104
+ ),
105
+ )
106
+
107
+ createEffect(() => {
108
+ generationRuns().length
109
+ queueMicrotask(() => {
110
+ if (runsContainer) {
111
+ runsContainer.scrollTop = runsContainer.scrollHeight
112
+ }
113
+ })
114
+ })
115
+
116
+ return (
117
+ <Show
118
+ when={generationRuns().length > 0}
119
+ fallback={
120
+ <section class={styles().hookDetails.section}>
121
+ <div class={styles().hookDetails.sectionTitle}>Runs</div>
122
+ <div class={styles().hookDetails.emptySmall}>
123
+ No generation runs yet.
124
+ </div>
125
+ </section>
126
+ }
127
+ >
128
+ <div
129
+ class={styles().hookDetails.generationRuns}
130
+ ref={(element) => {
131
+ runsContainer = element
132
+ }}
133
+ >
134
+ <For each={generationRuns()}>
135
+ {(run, index) => (
136
+ <GenerationRunCard
137
+ hook={props.hook}
138
+ run={run}
139
+ index={index() + 1}
140
+ totalRuns={generationRuns().length}
141
+ highlighted={isMessageHighlighted(run.id, props.hoverTarget)}
142
+ />
143
+ )}
144
+ </For>
145
+ </div>
146
+ </Show>
147
+ )
148
+ }
149
+
150
+ export const GenerationPreview: Component<{
151
+ hook: HookRecord
152
+ hoverTarget: HoverTarget | null
153
+ }> = (props) => {
154
+ const styles = useStyles()
155
+ const [expandedOutput, setExpandedOutput] =
156
+ createSignal<GenerationOutputView | null>(null)
157
+ const generationRuns = createMemo(() =>
158
+ sortGenerationRuns(
159
+ generationRunsFromUnknown(props.hook.state.runs, props.hook.state),
160
+ ),
161
+ )
162
+ const outputs = createMemo(() =>
163
+ generationRuns().flatMap((run, index) =>
164
+ outputsFromRun(run, runLabel(index, generationRuns().length)),
165
+ ),
166
+ )
167
+
168
+ createEffect(() => {
169
+ if (!expandedOutput()) return
170
+
171
+ const handleKeyDown = (event: KeyboardEvent) => {
172
+ if (event.key === 'Escape') {
173
+ setExpandedOutput(null)
174
+ }
175
+ }
176
+
177
+ document.addEventListener('keydown', handleKeyDown)
178
+ onCleanup(() => document.removeEventListener('keydown', handleKeyDown))
179
+ })
180
+
181
+ return (
182
+ <>
183
+ <Show
184
+ when={outputs().length > 0}
185
+ fallback={
186
+ <div class={styles().hookDetails.emptySmall}>No output yet.</div>
187
+ }
188
+ >
189
+ <div class={styles().hookDetails.generationOutputGrid}>
190
+ <For each={outputs()}>
191
+ {(output) => (
192
+ <GenerationOutputTile
193
+ output={output}
194
+ highlighted={isOutputHighlighted(output, props.hoverTarget)}
195
+ onOpen={() => setExpandedOutput(output)}
196
+ />
197
+ )}
198
+ </For>
199
+ </div>
200
+ </Show>
201
+
202
+ <Portal>
203
+ <Show when={expandedOutput()}>
204
+ {(output) => (
205
+ <GenerationOutputModal
206
+ output={output()}
207
+ onClose={() => setExpandedOutput(null)}
208
+ />
209
+ )}
210
+ </Show>
211
+ </Portal>
212
+ </>
213
+ )
214
+ }
215
+
216
+ const GenerationRunCard: Component<{
217
+ hook: HookRecord
218
+ run: GenerationRunView
219
+ index: number
220
+ totalRuns: number
221
+ highlighted: boolean
222
+ }> = (props) => {
223
+ const styles = useStyles()
224
+ const completedAt = createMemo(() => props.run.completedAt)
225
+ const updatedAt = createMemo(() => props.run.updatedAt ?? props.run.startedAt)
226
+ const progress = createMemo(() => props.run.progress)
227
+ const outputCount = createMemo(() => outputsFromRun(props.run, '').length)
228
+
229
+ return (
230
+ <article
231
+ {...getHoverDataAttributes({ messageIds: [props.run.id] })}
232
+ data-testid="ai-devtools-generation-run"
233
+ data-run-id={props.run.id}
234
+ data-run-label={runLabel(props.index - 1, props.totalRuns)}
235
+ class={`${styles().hookDetails.runCard} ${
236
+ props.highlighted ? styles().hookDetails.runCardHighlighted : ''
237
+ }`}
238
+ >
239
+ <header class={styles().hookDetails.runHeader}>
240
+ <div class={styles().hookDetails.runHeading}>
241
+ <div class={styles().hookDetails.runTitle}>
242
+ {runLabel(props.index - 1, props.totalRuns)}
243
+ </div>
244
+ <div class={styles().hookDetails.runMeta}>{props.run.id}</div>
245
+ </div>
246
+ <div class={styles().hookDetails.runStatusGroup}>
247
+ <span class={styles().hookDetails.runStatus}>
248
+ {props.run.isLoading ? 'running' : props.run.status}
249
+ </span>
250
+ <span class={styles().hookDetails.runStatusMuted}>
251
+ {props.hook.outputKind ?? 'generation'}
252
+ </span>
253
+ </div>
254
+ </header>
255
+
256
+ <div class={styles().hookDetails.runCardBody}>
257
+ <div class={styles().hookDetails.runSummaryGrid}>
258
+ <GenerationMeta label="hook" value={getHookDisplayName(props.hook)} />
259
+ <GenerationMeta
260
+ label="updated"
261
+ value={
262
+ completedAt()
263
+ ? `completed ${formatTime(completedAt())}`
264
+ : `updated ${formatTime(updatedAt())}`
265
+ }
266
+ />
267
+ <GenerationMeta label="outputs" value={outputCount().toString()} />
268
+ <GenerationMeta
269
+ label="loading"
270
+ value={props.run.isLoading ? 'yes' : 'no'}
271
+ />
272
+ <Show when={props.run.jobId}>
273
+ {(jobId) => <GenerationMeta label="job" value={jobId()} />}
274
+ </Show>
275
+ <Show when={videoStatusLabel(props.run.videoStatus)}>
276
+ {(status) => <GenerationMeta label="video" value={status()} />}
277
+ </Show>
278
+ </div>
279
+
280
+ <Show when={progress()}>
281
+ {(currentProgress) => <ProgressMeter progress={currentProgress()} />}
282
+ </Show>
283
+
284
+ <Show when={props.run.error}>
285
+ {(message) => (
286
+ <div class={styles().hookDetails.errorText}>{message()}</div>
287
+ )}
288
+ </Show>
289
+
290
+ <div class={styles().hookDetails.runDetailsGrid}>
291
+ <RunField label="Input">
292
+ <JsonBlock value={props.run.input ?? null} compact />
293
+ </RunField>
294
+ <Show
295
+ when={props.run.result !== null && props.run.result !== undefined}
296
+ >
297
+ <RunField label="Result Data">
298
+ <JsonBlock value={props.run.result} compact />
299
+ </RunField>
300
+ </Show>
301
+ </div>
302
+ </div>
303
+ </article>
304
+ )
305
+ }
306
+
307
+ const GenerationOutputTile: Component<{
308
+ output: GenerationOutputView
309
+ highlighted: boolean
310
+ onOpen: () => void
311
+ }> = (props) => {
312
+ const styles = useStyles()
313
+ return (
314
+ <div
315
+ role="button"
316
+ tabIndex={0}
317
+ data-testid="ai-devtools-generation-output"
318
+ data-output-id={props.output.id}
319
+ data-run-id={props.output.runId}
320
+ data-output-kind={props.output.kind}
321
+ {...getHoverDataAttributes({
322
+ messageIds: [props.output.runId],
323
+ partIds: [props.output.id],
324
+ })}
325
+ class={`${styles().hookDetails.outputTile} ${
326
+ props.highlighted ? styles().hookDetails.outputTileHighlighted : ''
327
+ }`}
328
+ onClick={props.onOpen}
329
+ onKeyDown={(event) => {
330
+ if (event.key === 'Enter' || event.key === ' ') {
331
+ event.preventDefault()
332
+ props.onOpen()
333
+ }
334
+ }}
335
+ >
336
+ <div class={styles().hookDetails.outputTileBody}>
337
+ <OutputContent output={props.output} compact />
338
+ </div>
339
+ <div class={styles().hookDetails.outputTileFooter}>
340
+ <span>{props.output.runLabel}</span>
341
+ <span>{props.output.title}</span>
342
+ </div>
343
+ </div>
344
+ )
345
+ }
346
+
347
+ const GenerationOutputModal: Component<{
348
+ output: GenerationOutputView
349
+ onClose: () => void
350
+ }> = (props) => {
351
+ const styles = useStyles()
352
+ return (
353
+ <div
354
+ class={styles().hookDetails.outputModalBackdrop}
355
+ data-testid="ai-devtools-output-modal-backdrop"
356
+ role="presentation"
357
+ onClick={(event) => {
358
+ if (event.currentTarget === event.target) {
359
+ props.onClose()
360
+ }
361
+ }}
362
+ >
363
+ <section
364
+ class={styles().hookDetails.outputModalDialog}
365
+ data-testid="ai-devtools-output-modal"
366
+ data-output-id={props.output.id}
367
+ data-output-kind={props.output.kind}
368
+ role="dialog"
369
+ aria-modal="true"
370
+ aria-label={`${props.output.runLabel} ${props.output.title}`}
371
+ >
372
+ <header class={styles().hookDetails.outputModalHeader}>
373
+ <div>
374
+ <div class={styles().hookDetails.outputModalTitle}>
375
+ {props.output.title}
376
+ </div>
377
+ <div class={styles().hookDetails.runMeta}>
378
+ {props.output.runLabel}
379
+ {props.output.completedAt
380
+ ? ` - ${formatTime(props.output.completedAt)}`
381
+ : ''}
382
+ </div>
383
+ </div>
384
+ <button
385
+ type="button"
386
+ class={styles().hookDetails.outputModalClose}
387
+ data-testid="ai-devtools-output-modal-close"
388
+ aria-label="Close output preview"
389
+ onClick={props.onClose}
390
+ >
391
+ x
392
+ </button>
393
+ </header>
394
+ <div class={styles().hookDetails.outputModalBody}>
395
+ <OutputContent output={props.output} />
396
+ </div>
397
+ </section>
398
+ </div>
399
+ )
400
+ }
401
+
402
+ const OutputContent: Component<{
403
+ output: GenerationOutputView
404
+ compact?: boolean
405
+ }> = (props) => {
406
+ const styles = useStyles()
407
+ const item = createMemo(() => props.output.item)
408
+ return (
409
+ <Show
410
+ when={item()}
411
+ fallback={
412
+ <Show
413
+ when={props.output.kind === 'text'}
414
+ fallback={
415
+ <div
416
+ class={
417
+ props.compact
418
+ ? styles().hookDetails.outputTileText
419
+ : styles().hookDetails.outputModalText
420
+ }
421
+ >
422
+ <JsonTree
423
+ value={props.output.value}
424
+ defaultExpansionDepth={props.compact ? 1 : 2}
425
+ copyable={!props.compact}
426
+ />
427
+ </div>
428
+ }
429
+ >
430
+ <div
431
+ class={
432
+ props.compact
433
+ ? styles().hookDetails.outputTileText
434
+ : styles().hookDetails.outputModalText
435
+ }
436
+ >
437
+ {props.output.text}
438
+ </div>
439
+ </Show>
440
+ }
441
+ >
442
+ {(media) => (
443
+ <Show
444
+ when={props.output.kind === 'image'}
445
+ fallback={
446
+ <Show
447
+ when={props.output.kind === 'video'}
448
+ fallback={
449
+ <audio
450
+ data-testid="ai-devtools-audio-output"
451
+ src={media().src}
452
+ controls
453
+ preload="metadata"
454
+ onClick={(event) => event.stopPropagation()}
455
+ />
456
+ }
457
+ >
458
+ <video
459
+ data-testid="ai-devtools-video-output"
460
+ src={media().src}
461
+ controls={!props.compact}
462
+ muted={props.compact}
463
+ preload="metadata"
464
+ />
465
+ </Show>
466
+ }
467
+ >
468
+ <img
469
+ data-testid="ai-devtools-image-output"
470
+ src={media().src}
471
+ alt={props.output.title}
472
+ loading="lazy"
473
+ class={
474
+ props.compact
475
+ ? styles().hookDetails.outputTileImage
476
+ : styles().hookDetails.outputModalMedia
477
+ }
478
+ />
479
+ </Show>
480
+ )}
481
+ </Show>
482
+ )
483
+ }
484
+
485
+ const GenerationMeta: Component<{ label: string; value: string }> = (props) => {
486
+ const styles = useStyles()
487
+ return (
488
+ <div class={styles().hookDetails.generationMetaItem}>
489
+ <span class={styles().hookDetails.generationMetaLabel}>
490
+ {props.label}
491
+ </span>{' '}
492
+ <span class={styles().hookDetails.generationMetaValue}>
493
+ {props.value}
494
+ </span>
495
+ </div>
496
+ )
497
+ }
498
+
499
+ const RunField: Component<{ label: string; children: JSX.Element }> = (
500
+ props,
501
+ ) => {
502
+ const styles = useStyles()
503
+ return (
504
+ <div class={styles().hookDetails.runField}>
505
+ <div class={styles().hookDetails.generationMetaLabel}>{props.label}</div>
506
+ {props.children}
507
+ </div>
508
+ )
509
+ }
510
+
511
+ const ProgressMeter: Component<{ progress: GenerationProgress }> = (props) => {
512
+ const styles = useStyles()
513
+ const value = createMemo(() => clampProgress(props.progress.value))
514
+ return (
515
+ <div class={styles().hookDetails.progressBlock}>
516
+ <div class={styles().hookDetails.progressText}>
517
+ <span>{value()}%</span>
518
+ <Show when={props.progress.message}>
519
+ {(message) => <span>{message()}</span>}
520
+ </Show>
521
+ </div>
522
+ <div
523
+ class={styles().hookDetails.progressTrack}
524
+ data-testid="ai-devtools-generation-progress"
525
+ role="progressbar"
526
+ aria-valuemin="0"
527
+ aria-valuemax="100"
528
+ aria-valuenow={value()}
529
+ >
530
+ <div
531
+ class={styles().hookDetails.progressFill}
532
+ style={{ width: `${value()}%` }}
533
+ />
534
+ </div>
535
+ </div>
536
+ )
537
+ }
538
+
539
+ const JsonBlock: Component<{ value: unknown; compact?: boolean }> = (props) => {
540
+ const styles = useStyles()
541
+ return (
542
+ <div
543
+ class={`${styles().hookDetails.jsonPanel} ${
544
+ props.compact ? styles().hookDetails.jsonPanelCompact : ''
545
+ }`}
546
+ >
547
+ <JsonTree
548
+ value={props.value}
549
+ defaultExpansionDepth={props.compact ? 1 : 2}
550
+ copyable
551
+ />
552
+ </div>
553
+ )
554
+ }
555
+
556
+ function generationRunsFromUnknown(
557
+ value: unknown,
558
+ currentState: Record<string, unknown>,
559
+ ): Array<GenerationRunView> {
560
+ if (Array.isArray(value)) {
561
+ const runs = value.map(runFromUnknown).filter(isGenerationRunView)
562
+ if (runs.length > 0) {
563
+ return runs
564
+ }
565
+ }
566
+
567
+ const currentRun = runFromCurrentSnapshot(currentState)
568
+ return currentRun ? [currentRun] : []
569
+ }
570
+
571
+ function sortGenerationRuns(
572
+ runs: Array<GenerationRunView>,
573
+ ): Array<GenerationRunView> {
574
+ return [...runs].sort((a, b) => runSortTime(a) - runSortTime(b))
575
+ }
576
+
577
+ function runSortTime(run: GenerationRunView): number {
578
+ return run.startedAt ?? run.updatedAt ?? run.completedAt ?? 0
579
+ }
580
+
581
+ function runFromUnknown(value: unknown): GenerationRunView | undefined {
582
+ if (!isRecord(value) || typeof value.id !== 'string') {
583
+ return undefined
584
+ }
585
+
586
+ const progress = progressFromUnknown(value.progress)
587
+ const error = errorTextFromUnknown(value.error)
588
+ const startedAt = numberFromUnknown(value.startedAt)
589
+ const updatedAt = numberFromUnknown(value.updatedAt)
590
+ const completedAt = numberFromUnknown(value.completedAt)
591
+ const jobId = stringFromUnknown(value.jobId)
592
+ return {
593
+ id: value.id,
594
+ input: 'input' in value ? value.input : null,
595
+ result: 'result' in value ? value.result : null,
596
+ preview: previewFromUnknown(value.preview, value.result),
597
+ status: stringFromUnknown(value.status) ?? 'unknown',
598
+ isLoading: value.isLoading === true,
599
+ ...(progress ? { progress } : {}),
600
+ ...(startedAt !== undefined ? { startedAt } : {}),
601
+ ...(updatedAt !== undefined ? { updatedAt } : {}),
602
+ ...(completedAt !== undefined ? { completedAt } : {}),
603
+ ...(error ? { error } : {}),
604
+ ...(jobId ? { jobId } : {}),
605
+ ...('videoStatus' in value ? { videoStatus: value.videoStatus } : {}),
606
+ }
607
+ }
608
+
609
+ function runFromCurrentSnapshot(
610
+ state: Record<string, unknown>,
611
+ ): GenerationRunView | undefined {
612
+ const progress = progressFromUnknown(state.progress)
613
+ const error = errorTextFromUnknown(state.error)
614
+ const jobId = stringFromUnknown(state.jobId)
615
+ const hasCurrentRun =
616
+ state.isLoading === true ||
617
+ (state.input !== null && state.input !== undefined) ||
618
+ (state.result !== null && state.result !== undefined) ||
619
+ progress !== undefined ||
620
+ error !== undefined
621
+
622
+ if (!hasCurrentRun) return undefined
623
+
624
+ return {
625
+ id: stringFromUnknown(state.activeRunId) ?? 'current',
626
+ input: state.input ?? null,
627
+ result: state.result ?? null,
628
+ preview: previewFromUnknown(state.preview, state.result),
629
+ status: stringFromUnknown(state.status) ?? 'unknown',
630
+ isLoading: state.isLoading === true,
631
+ ...(progress ? { progress } : {}),
632
+ ...(error ? { error } : {}),
633
+ ...(jobId ? { jobId } : {}),
634
+ ...('videoStatus' in state ? { videoStatus: state.videoStatus } : {}),
635
+ }
636
+ }
637
+
638
+ function outputsFromRun(
639
+ run: GenerationRunView,
640
+ runLabelValue: string,
641
+ ): Array<GenerationOutputView> {
642
+ if (run.preview.kind === 'image' || run.preview.kind === 'audio') {
643
+ const kind = run.preview.kind
644
+ const items = run.preview.items
645
+
646
+ return items.map((item, index) => ({
647
+ id: `${run.id}:${kind}:${index}`,
648
+ runId: run.id,
649
+ runLabel: runLabelValue,
650
+ title: `${titleCase(kind)} ${index + 1}`,
651
+ kind,
652
+ item,
653
+ ...completedAtPatch(run),
654
+ }))
655
+ }
656
+
657
+ if (run.preview.kind === 'video') {
658
+ const preview = run.preview
659
+ const items = preview.items
660
+ const job = preview.job
661
+
662
+ return items.map((item, index) => ({
663
+ id: `${run.id}:video:${index}`,
664
+ runId: run.id,
665
+ runLabel: runLabelValue,
666
+ title: `Video ${index + 1}`,
667
+ kind: 'video',
668
+ item,
669
+ ...(job ? { job } : {}),
670
+ ...completedAtPatch(run),
671
+ }))
672
+ }
673
+
674
+ if (run.preview.kind === 'text' && run.preview.text.trim().length > 0) {
675
+ return [
676
+ {
677
+ id: `${run.id}:text`,
678
+ runId: run.id,
679
+ runLabel: runLabelValue,
680
+ title: 'Text',
681
+ kind: 'text',
682
+ text: run.preview.text,
683
+ ...completedAtPatch(run),
684
+ },
685
+ ]
686
+ }
687
+
688
+ if (run.preview.kind === 'structured') {
689
+ return [
690
+ {
691
+ id: `${run.id}:structured`,
692
+ runId: run.id,
693
+ runLabel: runLabelValue,
694
+ title: 'Structured',
695
+ kind: 'structured',
696
+ value: run.preview.value,
697
+ ...completedAtPatch(run),
698
+ },
699
+ ]
700
+ }
701
+
702
+ return []
703
+ }
704
+
705
+ function completedAtPatch(
706
+ run: GenerationRunView,
707
+ ): Pick<GenerationOutputView, 'completedAt'> | Record<string, never> {
708
+ return run.completedAt !== undefined ? { completedAt: run.completedAt } : {}
709
+ }
710
+
711
+ function isOutputHighlighted(
712
+ output: GenerationOutputView,
713
+ hoverTarget: HoverTarget | null,
714
+ ): boolean {
715
+ return (
716
+ isMessageHighlighted(output.runId, hoverTarget) ||
717
+ (hoverTarget?.partIds.includes(output.id) ?? false)
718
+ )
719
+ }
720
+
721
+ function isGenerationRunView(
722
+ value: GenerationRunView | undefined,
723
+ ): value is GenerationRunView {
724
+ return value !== undefined
725
+ }
726
+
727
+ function previewFromUnknown(
728
+ value: unknown,
729
+ fallbackResult: unknown,
730
+ ): GenerationPreviewState {
731
+ if (isRecord(value) && typeof value.kind === 'string') {
732
+ if (value.kind === 'image') {
733
+ return { kind: 'image', items: mediaItemsFromUnknown(value.items) }
734
+ }
735
+ if (value.kind === 'audio') {
736
+ return { kind: 'audio', items: mediaItemsFromUnknown(value.items) }
737
+ }
738
+ if (value.kind === 'video') {
739
+ return {
740
+ kind: 'video',
741
+ items: mediaItemsFromUnknown(value.items),
742
+ ...(isVideoJob(value.job) ? { job: value.job } : {}),
743
+ }
744
+ }
745
+ if (value.kind === 'text') {
746
+ return {
747
+ kind: 'text',
748
+ text: typeof value.text === 'string' ? value.text : '',
749
+ }
750
+ }
751
+ if (value.kind === 'structured') {
752
+ return { kind: 'structured', value: value.value }
753
+ }
754
+ if (value.kind === 'empty') {
755
+ return { kind: 'empty' }
756
+ }
757
+ }
758
+
759
+ if (fallbackResult === null || fallbackResult === undefined) {
760
+ return { kind: 'empty' }
761
+ }
762
+
763
+ return {
764
+ kind: 'structured',
765
+ value: fallbackResult,
766
+ }
767
+ }
768
+
769
+ function progressFromUnknown(value: unknown): GenerationProgress | undefined {
770
+ if (!isRecord(value) || typeof value.value !== 'number') {
771
+ return undefined
772
+ }
773
+
774
+ return {
775
+ value: value.value,
776
+ ...(typeof value.message === 'string' ? { message: value.message } : {}),
777
+ }
778
+ }
779
+
780
+ function mediaItemsFromUnknown(value: unknown): Array<GenerationMediaItem> {
781
+ if (!Array.isArray(value)) return []
782
+ return value.filter(isMediaItem)
783
+ }
784
+
785
+ function isMediaItem(value: unknown): value is GenerationMediaItem {
786
+ if (!isRecord(value) || typeof value.src !== 'string') {
787
+ return false
788
+ }
789
+ return true
790
+ }
791
+
792
+ function isVideoJob(value: unknown): value is GenerationVideoJob {
793
+ return isRecord(value) && typeof value.jobId === 'string'
794
+ }
795
+
796
+ function errorTextFromUnknown(value: unknown): string | undefined {
797
+ if (typeof value === 'string') return value
798
+ if (isRecord(value) && typeof value.message === 'string') {
799
+ return value.message
800
+ }
801
+ return undefined
802
+ }
803
+
804
+ function stringFromUnknown(value: unknown): string | undefined {
805
+ return typeof value === 'string' && value.length > 0 ? value : undefined
806
+ }
807
+
808
+ function numberFromUnknown(value: unknown): number | undefined {
809
+ return typeof value === 'number' ? value : undefined
810
+ }
811
+
812
+ function videoStatusLabel(value: unknown): string | undefined {
813
+ if (!isRecord(value)) return undefined
814
+
815
+ const status = stringFromUnknown(value.status)
816
+ const progress = numberFromUnknown(value.progress)
817
+ if (!status && progress === undefined) return undefined
818
+
819
+ return [status, progress !== undefined ? formatProgress(progress) : undefined]
820
+ .filter((part): part is string => Boolean(part))
821
+ .join(' ')
822
+ }
823
+
824
+ function runLabel(index: number, totalRuns: number): string {
825
+ return `Run ${index + 1} of ${totalRuns}`
826
+ }
827
+
828
+ function titleCase(value: string): string {
829
+ return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`
830
+ }
831
+
832
+ function clampProgress(value: number): number {
833
+ return Math.max(0, Math.min(100, Math.round(value)))
834
+ }
835
+
836
+ function formatProgress(value: number | undefined): string {
837
+ return typeof value === 'number' ? `${clampProgress(value)}%` : 'unknown'
838
+ }
839
+
840
+ function formatTime(timestamp: number | undefined): string {
841
+ if (timestamp === undefined) return 'unknown'
842
+ return new Date(timestamp).toLocaleTimeString()
843
+ }
844
+
845
+ function isRecord(value: unknown): value is Record<string, unknown> {
846
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
847
+ }