@tanstack/ai-devtools-core 0.3.9 → 0.3.11

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.
@@ -0,0 +1,745 @@
1
+ import {
2
+ For,
3
+ Index,
4
+ Match,
5
+ Show,
6
+ Switch,
7
+ createMemo,
8
+ createSignal,
9
+ } from 'solid-js'
10
+ import { JsonTree } from '@tanstack/devtools-ui'
11
+ import { useStyles } from '../../styles/use-styles'
12
+ import { formatDuration } from '../utils'
13
+ import { SystemPromptItem } from './IterationTimeline'
14
+ import type {
15
+ Iteration,
16
+ Message,
17
+ MiddlewareEvent,
18
+ ToolCall,
19
+ } from '../../store/ai-store'
20
+ import type { Component } from 'solid-js'
21
+
22
+ interface IterationCardProps {
23
+ iteration: Iteration
24
+ previousIteration?: Iteration
25
+ messages: Array<Message>
26
+ index: number
27
+ isLast: boolean
28
+ }
29
+
30
+ // --- Step types ---
31
+
32
+ type IterationStep =
33
+ | { kind: 'middleware'; event: MiddlewareEvent }
34
+ | { kind: 'thinking'; message: Message }
35
+ | { kind: 'assistant'; message: Message }
36
+ | { kind: 'tool_call'; toolCall: ToolCall; message: Message }
37
+ | { kind: 'tool_result'; message: Message }
38
+
39
+ // --- Helpers ---
40
+
41
+ function getIterationLabel(iter: Iteration, displayIndex: number): string {
42
+ if (!iter.completedAt) return `Iteration ${displayIndex} — Generating...`
43
+ if (iter.finishReason === 'error') return `Iteration ${displayIndex} — Error`
44
+ return `Iteration ${displayIndex}`
45
+ }
46
+
47
+ /**
48
+ * Build steps in insertion order — no timestamp sorting.
49
+ * Events are emitted in order by the server, so we respect that order.
50
+ */
51
+ function buildSteps(
52
+ iter: Iteration,
53
+ allMessages: Array<Message>,
54
+ ): Array<IterationStep> {
55
+ const steps: Array<IterationStep> = []
56
+
57
+ // 1. Middleware events come first (they happen before/during generation)
58
+ for (const event of iter.middlewareEvents) {
59
+ steps.push({ kind: 'middleware', event })
60
+ }
61
+
62
+ // 2. Messages in their natural order from the store
63
+ const iterMessages = allMessages.filter(
64
+ (m) => iter.messageIds.includes(m.id) && m.role !== 'user',
65
+ )
66
+
67
+ for (const msg of iterMessages) {
68
+ if (msg.role === 'assistant') {
69
+ // Show thinking/reasoning as its own step before text content
70
+ if (msg.thinkingContent) {
71
+ steps.push({ kind: 'thinking', message: msg })
72
+ }
73
+ if (msg.toolCalls && msg.toolCalls.length > 0) {
74
+ for (const tc of msg.toolCalls) {
75
+ steps.push({ kind: 'tool_call', toolCall: tc, message: msg })
76
+ }
77
+ }
78
+ if (msg.content) {
79
+ steps.push({ kind: 'assistant', message: msg })
80
+ }
81
+ } else if (msg.role === 'tool') {
82
+ steps.push({ kind: 'tool_result', message: msg })
83
+ }
84
+ }
85
+
86
+ return steps
87
+ }
88
+
89
+ function truncate(str: string, max: number): string {
90
+ if (str.length <= max) return str
91
+ return str.slice(0, max) + '...'
92
+ }
93
+
94
+ function tryParseJson(str: string): unknown | null {
95
+ try {
96
+ return JSON.parse(str)
97
+ } catch {
98
+ return null
99
+ }
100
+ }
101
+
102
+ // --- Step renderers ---
103
+
104
+ const MiddlewareStep: Component<{
105
+ step: Extract<IterationStep, { kind: 'middleware' }>
106
+ }> = (props) => {
107
+ const styles = useStyles()
108
+ const s = () => styles().iterationTimeline
109
+ const [expanded, setExpanded] = createSignal(false)
110
+ const ev = () => props.step.event
111
+
112
+ const badgeClass = () => {
113
+ if (ev().wasDropped) return s().mwBadgeError
114
+ if (ev().hasTransform) return s().mwBadgeTransform
115
+ return s().mwBadgeDefault
116
+ }
117
+
118
+ const suffix = () => {
119
+ if (ev().wasDropped) return 'DROP'
120
+ if (ev().hookName === 'onChunk' && ev().hasTransform) return 'TRANSFORM'
121
+ if (ev().hookName === 'onConfig' && ev().hasTransform) return 'TRANSFORM'
122
+ if (ev().hookName === 'onBeforeToolCall' && ev().hasTransform)
123
+ return 'DECISION'
124
+ return null
125
+ }
126
+
127
+ const hasChanges = () =>
128
+ ev().configChanges && Object.keys(ev().configChanges!).length > 0
129
+
130
+ return (
131
+ <>
132
+ <div class={s().step}>
133
+ <span class={`${s().stepPrefix} ${s().stepPrefixMiddleware}`}>
134
+ Middleware
135
+ </span>
136
+ <span class={`${s().mwBadge} ${badgeClass()}`}>
137
+ {ev().middlewareName}
138
+ </span>
139
+ <span class={s().mwHook}>{ev().hookName}</span>
140
+ <Show when={ev().duration !== undefined}>
141
+ <span class={s().stepDuration}>{ev().duration}ms</span>
142
+ </Show>
143
+ <Show when={suffix()}>
144
+ <span class={s().mwSuffix}>{suffix()}</span>
145
+ </Show>
146
+ <Show when={hasChanges()}>
147
+ <span
148
+ class={s().stepExpandToggle}
149
+ onClick={() => setExpanded(!expanded())}
150
+ >
151
+ {expanded() ? 'hide changes' : 'show changes'}
152
+ </span>
153
+ </Show>
154
+ </div>
155
+ <Show when={expanded() && hasChanges()}>
156
+ <div class={s().mwChangesContainer}>
157
+ <JsonTree
158
+ value={ev().configChanges as Record<string, unknown>}
159
+ defaultExpansionDepth={2}
160
+ copyable
161
+ />
162
+ </div>
163
+ </Show>
164
+ </>
165
+ )
166
+ }
167
+
168
+ const ThinkingStep: Component<{
169
+ step: Extract<IterationStep, { kind: 'thinking' }>
170
+ }> = (props) => {
171
+ const styles = useStyles()
172
+ const s = () => styles().iterationTimeline
173
+ const [expanded, setExpanded] = createSignal(false)
174
+ const msg = () => props.step.message
175
+
176
+ const thinkingText = () => msg().thinkingContent || ''
177
+ const preview = () => truncate(thinkingText(), 150)
178
+
179
+ return (
180
+ <>
181
+ <div class={s().step}>
182
+ <span class={`${s().stepPrefix} ${s().stepPrefixThinking}`}>
183
+ Thinking
184
+ </span>
185
+ <span class={s().stepContent}>{preview() || '(empty)'}</span>
186
+ <Show when={thinkingText().length > 150}>
187
+ <span
188
+ class={s().stepExpandToggle}
189
+ onClick={() => setExpanded(!expanded())}
190
+ >
191
+ {expanded() ? 'hide' : 'show full'}
192
+ </span>
193
+ </Show>
194
+ </div>
195
+ <Show when={expanded()}>
196
+ <div class={s().thinkingDetail}>{thinkingText()}</div>
197
+ </Show>
198
+ </>
199
+ )
200
+ }
201
+
202
+ const AssistantStep: Component<{
203
+ step: Extract<IterationStep, { kind: 'assistant' }>
204
+ }> = (props) => {
205
+ const styles = useStyles()
206
+ const s = () => styles().iterationTimeline
207
+ const [expanded, setExpanded] = createSignal(false)
208
+ const msg = () => props.step.message
209
+
210
+ const contentLength = () => (msg().content || '').length
211
+ const isLong = () => contentLength() > 200
212
+
213
+ const preview = () => {
214
+ const text = msg().content || ''
215
+ if (text.length <= 200) return text
216
+ return truncate(text, 200)
217
+ }
218
+
219
+ return (
220
+ <>
221
+ <div class={`${s().step} ${isLong() ? s().stepResponseLong : ''}`}>
222
+ <span class={`${s().stepPrefix} ${s().stepPrefixAssistant}`}>
223
+ Response
224
+ </span>
225
+ <span class={isLong() ? s().stepContentLong : s().stepContent}>
226
+ {preview() || '(empty)'}
227
+ </span>
228
+ <Show when={contentLength() > 200}>
229
+ <span
230
+ class={s().stepExpandToggle}
231
+ onClick={() => setExpanded(!expanded())}
232
+ >
233
+ {expanded() ? 'hide' : 'show full'}
234
+ </span>
235
+ </Show>
236
+ </div>
237
+ <Show when={expanded()}>
238
+ <div class={s().responseDetail}>{msg().content}</div>
239
+ </Show>
240
+ </>
241
+ )
242
+ }
243
+
244
+ const ToolCallStep: Component<{
245
+ step: Extract<IterationStep, { kind: 'tool_call' }>
246
+ }> = (props) => {
247
+ const styles = useStyles()
248
+ const s = () => styles().iterationTimeline
249
+ const [argsOpen, setArgsOpen] = createSignal(false)
250
+ const [resultOpen, setResultOpen] = createSignal(false)
251
+ const tc = () => props.step.toolCall
252
+
253
+ const parsedArgs = () => {
254
+ const raw = tc().arguments || '{}'
255
+ return tryParseJson(raw) || raw
256
+ }
257
+
258
+ const hasResult = () => tc().result !== undefined
259
+
260
+ const parsedResult = () => {
261
+ if (!hasResult()) return null
262
+ if (typeof tc().result === 'string') {
263
+ return tryParseJson(tc().result as string) || tc().result
264
+ }
265
+ return tc().result
266
+ }
267
+
268
+ return (
269
+ <>
270
+ <div
271
+ class={s().step}
272
+ onClick={() => setArgsOpen(!argsOpen())}
273
+ style={{ cursor: 'pointer' }}
274
+ >
275
+ <span class={`${s().stepPrefix} ${s().stepPrefixToolCall}`}>
276
+ Tool Call
277
+ </span>
278
+ <span class={`${s().mwBadge} ${s().mwBadgeToolCall}`}>{tc().name}</span>
279
+ <Show when={tc().duration !== undefined}>
280
+ <span class={s().stepDuration}>{tc().duration}ms</span>
281
+ </Show>
282
+ <span class={`${s().chevron} ${argsOpen() ? s().chevronOpen : ''}`}>
283
+ {'\u25B6'}
284
+ </span>
285
+ </div>
286
+ <div class={`${s().cardBody} ${argsOpen() ? s().cardBodyOpen : ''}`}>
287
+ <div class={s().cardBodyInner}>
288
+ <div class={s().stepJsonPanel}>
289
+ <JsonTree
290
+ value={parsedArgs() as Record<string, unknown>}
291
+ defaultExpansionDepth={0}
292
+ copyable
293
+ />
294
+ </div>
295
+ </div>
296
+ </div>
297
+ <Show when={hasResult()}>
298
+ <div
299
+ class={s().step}
300
+ onClick={() => setResultOpen(!resultOpen())}
301
+ style={{ cursor: 'pointer' }}
302
+ >
303
+ <span class={`${s().stepPrefix} ${s().stepPrefixToolResult}`}>
304
+ Result
305
+ </span>
306
+ <span class={`${s().mwBadge} ${s().mwBadgeToolResult}`}>
307
+ {tc().name}
308
+ </span>
309
+ <Show when={tc().duration !== undefined}>
310
+ <span class={s().stepDuration}>{tc().duration}ms</span>
311
+ </Show>
312
+ <span class={`${s().chevron} ${resultOpen() ? s().chevronOpen : ''}`}>
313
+ {'\u25B6'}
314
+ </span>
315
+ </div>
316
+ <div class={`${s().cardBody} ${resultOpen() ? s().cardBodyOpen : ''}`}>
317
+ <div class={s().cardBodyInner}>
318
+ <div class={s().stepJsonPanel}>
319
+ <JsonTree
320
+ value={parsedResult() as Record<string, unknown>}
321
+ defaultExpansionDepth={0}
322
+ copyable
323
+ />
324
+ </div>
325
+ </div>
326
+ </div>
327
+ </Show>
328
+ </>
329
+ )
330
+ }
331
+
332
+ const ToolResultStep: Component<{
333
+ step: Extract<IterationStep, { kind: 'tool_result' }>
334
+ }> = (props) => {
335
+ const styles = useStyles()
336
+ const s = () => styles().iterationTimeline
337
+ const [isOpen, setIsOpen] = createSignal(false)
338
+ const msg = () => props.step.message
339
+
340
+ const parsedContent = () => {
341
+ const text = msg().content || ''
342
+ return tryParseJson(text) || text
343
+ }
344
+
345
+ return (
346
+ <>
347
+ <div
348
+ class={s().step}
349
+ onClick={() => setIsOpen(!isOpen())}
350
+ style={{ cursor: 'pointer' }}
351
+ >
352
+ <span class={`${s().stepPrefix} ${s().stepPrefixToolResult}`}>
353
+ Result
354
+ </span>
355
+ <span class={`${s().chevron} ${isOpen() ? s().chevronOpen : ''}`}>
356
+ {'\u25B6'}
357
+ </span>
358
+ </div>
359
+ <div class={`${s().cardBody} ${isOpen() ? s().cardBodyOpen : ''}`}>
360
+ <div class={s().cardBodyInner}>
361
+ <div class={s().stepJsonPanel}>
362
+ <JsonTree
363
+ value={parsedContent() as Record<string, unknown>}
364
+ defaultExpansionDepth={0}
365
+ copyable
366
+ />
367
+ </div>
368
+ </div>
369
+ </div>
370
+ </>
371
+ )
372
+ }
373
+
374
+ // --- Main component ---
375
+
376
+ export const IterationCard: Component<IterationCardProps> = (props) => {
377
+ const styles = useStyles()
378
+ const s = () => styles().iterationTimeline
379
+ const [isOpen, setIsOpen] = createSignal(props.isLast)
380
+ const [configExpanded, setConfigExpanded] = createSignal(false)
381
+
382
+ const iter = () => props.iteration
383
+ const isActive = () => !iter().completedAt
384
+ const isCompleted = () =>
385
+ !!iter().completedAt && iter().finishReason !== 'error'
386
+ const isError = () => iter().finishReason === 'error'
387
+
388
+ const duration = () => {
389
+ if (!iter().completedAt) return undefined
390
+ return iter().completedAt! - iter().startedAt
391
+ }
392
+
393
+ const label = () => getIterationLabel(iter(), props.index)
394
+ const steps = createMemo(() => buildSteps(iter(), props.messages))
395
+
396
+ /**
397
+ * Compute delta usage for display.
398
+ * The store holds cumulative usage per iteration (as reported by the provider).
399
+ * If a previous iteration exists on the same request, subtract its cumulative
400
+ * to get this iteration's incremental usage.
401
+ */
402
+ const deltaUsage = createMemo(() => {
403
+ const usage = iter().usage
404
+ if (!usage) return undefined
405
+ const prev = props.previousIteration
406
+ if (!prev?.usage) return usage
407
+ // Only subtract if same request (cumulative values are per-request)
408
+ if (prev.requestId !== iter().requestId) return usage
409
+ return {
410
+ promptTokens: Math.max(0, usage.promptTokens - prev.usage.promptTokens),
411
+ completionTokens: Math.max(
412
+ 0,
413
+ usage.completionTokens - prev.usage.completionTokens,
414
+ ),
415
+ totalTokens: Math.max(0, usage.totalTokens - prev.usage.totalTokens),
416
+ }
417
+ })
418
+
419
+ const finishLabel = () => {
420
+ if (!iter().finishReason) return null
421
+ switch (iter().finishReason) {
422
+ case 'stop':
423
+ return 'completed'
424
+ case 'tool_calls':
425
+ return 'tool calls'
426
+ case 'error':
427
+ return 'error'
428
+ case 'length':
429
+ return 'max length'
430
+ default:
431
+ return iter().finishReason
432
+ }
433
+ }
434
+
435
+ const headerAccent = () => {
436
+ if (isActive()) return s().iterHeaderActive
437
+ if (isError()) return s().iterHeaderError
438
+ if (isCompleted()) return s().iterHeaderCompleted
439
+ return ''
440
+ }
441
+
442
+ // Config data from this iteration
443
+ const configSubtitle = () => {
444
+ const parts: Array<string> = []
445
+ if (iter().model) parts.push(iter().model!)
446
+ if (iter().provider) parts.push(iter().provider!)
447
+ return parts.length > 0 ? parts.join(' \u00B7 ') : null
448
+ }
449
+
450
+ const toolNames = () => iter().toolNames || []
451
+ const systemPrompts = () => iter().systemPrompts || []
452
+
453
+ /** Count actual tool invocations in this iteration's messages */
454
+ const toolInvocationCounts = createMemo(() => {
455
+ const counts = new Map<string, number>()
456
+ const msgIds = new Set(iter().messageIds)
457
+ for (const msg of props.messages) {
458
+ if (msgIds.has(msg.id) && msg.toolCalls) {
459
+ for (const tc of msg.toolCalls) {
460
+ counts.set(tc.name, (counts.get(tc.name) || 0) + 1)
461
+ }
462
+ }
463
+ }
464
+ return counts
465
+ })
466
+
467
+ const totalToolCalls = createMemo(() => {
468
+ let count = 0
469
+ for (const v of toolInvocationCounts().values()) count += v
470
+ return count
471
+ })
472
+ const modelOptions = () => iter().modelOptions
473
+ const hasModelOptions = () => {
474
+ const opts = modelOptions()
475
+ return opts && Object.keys(opts).length > 0
476
+ }
477
+
478
+ const middlewareTransformCount = createMemo(() => {
479
+ let count = 0
480
+ for (const ev of iter().middlewareEvents) {
481
+ if (ev.hasTransform) count++
482
+ }
483
+ return count
484
+ })
485
+
486
+ const hasConfigChanged = () => {
487
+ const prev = props.previousIteration
488
+ if (!prev) return false
489
+ return (
490
+ iter().model !== prev.model ||
491
+ iter().provider !== prev.provider ||
492
+ JSON.stringify(iter().toolNames) !== JSON.stringify(prev.toolNames)
493
+ )
494
+ }
495
+
496
+ const configDiffs = () => {
497
+ const prev = props.previousIteration
498
+ if (!prev) return []
499
+ const diffs: Array<{ key: string; from: string; to: string }> = []
500
+ if (iter().model !== prev.model) {
501
+ diffs.push({
502
+ key: 'model',
503
+ from: prev.model || '(none)',
504
+ to: iter().model || '(none)',
505
+ })
506
+ }
507
+ if (iter().provider !== prev.provider) {
508
+ diffs.push({
509
+ key: 'provider',
510
+ from: prev.provider || '(none)',
511
+ to: iter().provider || '(none)',
512
+ })
513
+ }
514
+ if (JSON.stringify(iter().toolNames) !== JSON.stringify(prev.toolNames)) {
515
+ diffs.push({
516
+ key: 'tools',
517
+ from: prev.toolNames?.join(', ') || '(none)',
518
+ to: iter().toolNames?.join(', ') || '(none)',
519
+ })
520
+ }
521
+ return diffs
522
+ }
523
+
524
+ const hasExpandableConfig = () =>
525
+ toolNames().length > 0 || systemPrompts().length > 0 || hasModelOptions()
526
+
527
+ return (
528
+ <div
529
+ class={s().iterCard}
530
+ style={{ 'animation-delay': `${props.index * 60}ms` }}
531
+ >
532
+ {/* Header */}
533
+ <div
534
+ class={`${s().iterCardHeader} ${headerAccent()}`}
535
+ onClick={() => setIsOpen(!isOpen())}
536
+ >
537
+ <div class={s().cardHeaderContent}>
538
+ <span class={s().iterCardTitle}>{label()}</span>
539
+ {/* Config subtitle — same pattern as user message card */}
540
+ <div class={s().cardSubtitle}>
541
+ <Show when={configSubtitle()}>
542
+ <span class={s().subtitleText}>{configSubtitle()}</span>
543
+ </Show>
544
+ <Show when={toolNames().length > 0}>
545
+ <span class={s().subtitleBadge}>
546
+ {toolNames().length} tool{toolNames().length === 1 ? '' : 's'}
547
+ </span>
548
+ </Show>
549
+ <Show when={systemPrompts().length > 0}>
550
+ <span class={s().subtitleBadge}>
551
+ {systemPrompts().length} system prompt
552
+ {systemPrompts().length === 1 ? '' : 's'}
553
+ </span>
554
+ </Show>
555
+ <Show when={hasModelOptions()}>
556
+ <span class={s().subtitleBadge}>options</span>
557
+ </Show>
558
+ <Show when={hasConfigChanged()}>
559
+ <span class={s().subtitleBadgeWarn}>config changed</span>
560
+ </Show>
561
+ <Show when={middlewareTransformCount() > 0}>
562
+ <span class={s().subtitleBadgeWarn}>
563
+ {middlewareTransformCount()} middleware transform
564
+ {middlewareTransformCount() === 1 ? '' : 's'}
565
+ </span>
566
+ </Show>
567
+ <Show when={hasExpandableConfig()}>
568
+ <span
569
+ class={s().subtitleExpandToggle}
570
+ onClick={(e) => {
571
+ e.stopPropagation()
572
+ setConfigExpanded(!configExpanded())
573
+ }}
574
+ >
575
+ {configExpanded() ? 'hide config' : 'show config'}
576
+ </span>
577
+ </Show>
578
+ </div>
579
+ </div>
580
+ <div class={s().cardHeaderBadges}>
581
+ <Show when={finishLabel()}>
582
+ <span
583
+ class={`${s().badge} ${
584
+ iter().finishReason === 'stop'
585
+ ? s().badgeFinishReasonStop
586
+ : iter().finishReason === 'tool_calls'
587
+ ? s().badgeFinishReasonToolCalls
588
+ : iter().finishReason === 'error'
589
+ ? s().mwBadgeError
590
+ : s().badgeDuration
591
+ }`}
592
+ >
593
+ {finishLabel()}
594
+ </span>
595
+ </Show>
596
+ <Show when={totalToolCalls() > 0}>
597
+ <span
598
+ class={`${s().badge} ${s().badgeFinishReasonToolCalls}`}
599
+ title={`${totalToolCalls()} tool ${totalToolCalls() === 1 ? 'call' : 'calls'}`}
600
+ >
601
+ 🔧 {totalToolCalls()}
602
+ </span>
603
+ </Show>
604
+ <Show when={duration()}>
605
+ <span
606
+ class={`${s().badge} ${s().badgeDuration}`}
607
+ title={formatDuration(duration())}
608
+ >
609
+ ⏱️ {formatDuration(duration())}
610
+ </span>
611
+ </Show>
612
+ <Show when={deltaUsage()}>
613
+ <span
614
+ class={`${s().badge} ${s().badgeUsage}`}
615
+ title={`Prompt: ${deltaUsage()!.promptTokens.toLocaleString()} | Completion: ${deltaUsage()!.completionTokens.toLocaleString()}`}
616
+ >
617
+ 🎯 {deltaUsage()!.totalTokens.toLocaleString()}
618
+ </span>
619
+ </Show>
620
+ <Show when={isActive()}>
621
+ <span class={`${s().badge} ${s().badgeDuration}`}>⟳ streaming</span>
622
+ </Show>
623
+ </div>
624
+ <span class={`${s().chevron} ${isOpen() ? s().chevronOpen : ''}`}>
625
+ {'\u25B6'}
626
+ </span>
627
+ </div>
628
+
629
+ {/* Expandable config panel — between header and steps */}
630
+ <div
631
+ class={`${s().configPanelWrapper} ${configExpanded() ? s().configPanelWrapperOpen : ''}`}
632
+ >
633
+ <div class={s().configPanel}>
634
+ <div>
635
+ <Show when={hasConfigChanged()}>
636
+ <div class={s().configPanelSection}>
637
+ <span class={s().configPanelLabel}>Config Changes</span>
638
+ <div class={s().configDiffSection}>
639
+ <For each={configDiffs()}>
640
+ {(diff) => (
641
+ <div class={s().configDiffRow}>
642
+ <span class={s().configDiffKey}>{diff.key}:</span>
643
+ <span class={s().configDiffFrom}>{diff.from}</span>
644
+ <span class={s().configDiffArrow}>{'\u2192'}</span>
645
+ <span class={s().configDiffTo}>{diff.to}</span>
646
+ </div>
647
+ )}
648
+ </For>
649
+ </div>
650
+ </div>
651
+ </Show>
652
+ <Show when={toolNames().length > 0}>
653
+ <div class={s().configPanelSection}>
654
+ <span class={s().configPanelLabel}>Tools</span>
655
+ <div class={s().configToolsList}>
656
+ <For each={toolNames()}>
657
+ {(name) => (
658
+ <span class={s().configToolChip}>
659
+ {name}
660
+ <span class={s().configToolChipCount}>
661
+ {toolInvocationCounts().get(name) || 0}
662
+ </span>
663
+ </span>
664
+ )}
665
+ </For>
666
+ </div>
667
+ </div>
668
+ </Show>
669
+ <Show when={systemPrompts().length > 0}>
670
+ <div class={s().configPanelSection}>
671
+ <span class={s().configPanelLabel}>
672
+ System Prompts ({systemPrompts().length})
673
+ </span>
674
+ <For each={systemPrompts()}>
675
+ {(prompt, i) => (
676
+ <SystemPromptItem prompt={prompt} index={i()} />
677
+ )}
678
+ </For>
679
+ </div>
680
+ </Show>
681
+ <Show when={hasModelOptions()}>
682
+ <div class={s().configPanelSection}>
683
+ <span class={s().configPanelLabel}>Model Options</span>
684
+ <div class={s().configJsonTreeContainer}>
685
+ <JsonTree
686
+ value={modelOptions() as Record<string, unknown>}
687
+ defaultExpansionDepth={2}
688
+ copyable
689
+ />
690
+ </div>
691
+ </div>
692
+ </Show>
693
+ </div>
694
+ </div>
695
+ </div>
696
+
697
+ {/* Body — step-by-step timeline */}
698
+ <div class={`${s().cardBody} ${isOpen() ? s().cardBodyOpen : ''}`}>
699
+ <div class={s().cardBodyInner}>
700
+ <Index each={steps()}>
701
+ {(step) => (
702
+ <Switch>
703
+ <Match when={step().kind === 'middleware'}>
704
+ <MiddlewareStep
705
+ step={
706
+ step() as Extract<IterationStep, { kind: 'middleware' }>
707
+ }
708
+ />
709
+ </Match>
710
+ <Match when={step().kind === 'thinking'}>
711
+ <ThinkingStep
712
+ step={
713
+ step() as Extract<IterationStep, { kind: 'thinking' }>
714
+ }
715
+ />
716
+ </Match>
717
+ <Match when={step().kind === 'assistant'}>
718
+ <AssistantStep
719
+ step={
720
+ step() as Extract<IterationStep, { kind: 'assistant' }>
721
+ }
722
+ />
723
+ </Match>
724
+ <Match when={step().kind === 'tool_call'}>
725
+ <ToolCallStep
726
+ step={
727
+ step() as Extract<IterationStep, { kind: 'tool_call' }>
728
+ }
729
+ />
730
+ </Match>
731
+ <Match when={step().kind === 'tool_result'}>
732
+ <ToolResultStep
733
+ step={
734
+ step() as Extract<IterationStep, { kind: 'tool_result' }>
735
+ }
736
+ />
737
+ </Match>
738
+ </Switch>
739
+ )}
740
+ </Index>
741
+ </div>
742
+ </div>
743
+ </div>
744
+ )
745
+ }