@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,424 @@
1
+ import { For, Index, Show, createMemo, createSignal } from 'solid-js'
2
+ import { JsonTree } from '@tanstack/devtools-ui'
3
+ import { useStyles } from '../../styles/use-styles'
4
+ import { formatDuration } from '../utils'
5
+ import { IterationCard } from './IterationCard'
6
+ import type { Iteration, Message } from '../../store/ai-store'
7
+ import type { Component } from 'solid-js'
8
+
9
+ /** A group of iterations triggered by a single user message */
10
+ interface UserMessageGroup {
11
+ userMessage: Message | null
12
+ iterations: Array<Iteration>
13
+ }
14
+
15
+ interface IterationTimelineProps {
16
+ iterations: Array<Iteration>
17
+ messages: Array<Message>
18
+ }
19
+
20
+ export const IterationTimeline: Component<IterationTimelineProps> = (props) => {
21
+ const styles = useStyles()
22
+ const s = () => styles().iterationTimeline
23
+
24
+ /**
25
+ * Group iterations by user messages.
26
+ * Memoized to avoid recomputing on unrelated store changes.
27
+ */
28
+ const groups = createMemo((): Array<UserMessageGroup> => {
29
+ const userMessages = props.messages.filter((m) => m.role === 'user')
30
+ const iters = props.iterations
31
+
32
+ if (userMessages.length === 0) {
33
+ return iters.length > 0 ? [{ userMessage: null, iterations: iters }] : []
34
+ }
35
+
36
+ const result: Array<UserMessageGroup> = []
37
+
38
+ const sortedUsers = [...userMessages].sort(
39
+ (a, b) => a.timestamp - b.timestamp,
40
+ )
41
+
42
+ for (let u = 0; u < sortedUsers.length; u++) {
43
+ const currentUser = sortedUsers[u]!
44
+ const nextUser = sortedUsers[u + 1]
45
+
46
+ const groupIters = iters.filter((it) => {
47
+ if (it.startedAt < currentUser.timestamp) return false
48
+ if (nextUser && it.startedAt >= nextUser.timestamp) return false
49
+ return true
50
+ })
51
+
52
+ if (groupIters.length > 0) {
53
+ result.push({ userMessage: currentUser, iterations: groupIters })
54
+ }
55
+ }
56
+
57
+ // Catch any iterations before the first user message
58
+ if (sortedUsers[0]) {
59
+ const earlyIters = iters.filter(
60
+ (it) => it.startedAt < sortedUsers[0]!.timestamp,
61
+ )
62
+ if (earlyIters.length > 0) {
63
+ result.unshift({ userMessage: null, iterations: earlyIters })
64
+ }
65
+ }
66
+
67
+ return result
68
+ })
69
+
70
+ return (
71
+ <div class={s().container}>
72
+ <Show
73
+ when={props.iterations.length > 0}
74
+ fallback={<div class={s().noIterations}>No iterations recorded</div>}
75
+ >
76
+ <div class={s().pipeline}>
77
+ <Index each={groups()}>
78
+ {(group) => (
79
+ <UserMessageGroupCard
80
+ group={group()}
81
+ allMessages={props.messages}
82
+ />
83
+ )}
84
+ </Index>
85
+ </div>
86
+ </Show>
87
+ </div>
88
+ )
89
+ }
90
+
91
+ /** Collapsible system prompt with preview */
92
+ export const SystemPromptItem: Component<{ prompt: string; index: number }> = (
93
+ props,
94
+ ) => {
95
+ const styles = useStyles()
96
+ const s = () => styles().iterationTimeline
97
+ const [expanded, setExpanded] = createSignal(false)
98
+
99
+ const isLong = () => props.prompt.length > 120
100
+ const preview = () =>
101
+ isLong() ? props.prompt.slice(0, 120) + '...' : props.prompt
102
+
103
+ return (
104
+ <div class={s().systemPromptCard}>
105
+ <div
106
+ class={s().systemPromptHeader}
107
+ onClick={() => isLong() && setExpanded(!expanded())}
108
+ >
109
+ <span class={s().systemPromptIndex}>#{props.index + 1}</span>
110
+ <span class={s().systemPromptPreview}>
111
+ {expanded() ? '' : preview()}
112
+ </span>
113
+ <Show when={isLong()}>
114
+ <span class={s().stepExpandToggle}>
115
+ {expanded() ? 'collapse' : 'expand'}
116
+ </span>
117
+ </Show>
118
+ </div>
119
+ <Show when={expanded()}>
120
+ <pre class={s().systemPromptFull}>{props.prompt}</pre>
121
+ </Show>
122
+ </div>
123
+ )
124
+ }
125
+
126
+ /** Card wrapping a user message and its child iterations */
127
+ const UserMessageGroupCard: Component<{
128
+ group: UserMessageGroup
129
+ allMessages: Array<Message>
130
+ }> = (props) => {
131
+ const styles = useStyles()
132
+ const s = () => styles().iterationTimeline
133
+ const [isOpen, setIsOpen] = createSignal(true)
134
+ const [configExpanded, setConfigExpanded] = createSignal(false)
135
+
136
+ const group = () => props.group
137
+ const userMsg = () => group().userMessage
138
+ const iters = () => group().iterations
139
+
140
+ const totalDuration = createMemo(() => {
141
+ let sum = 0
142
+ for (const it of iters()) {
143
+ if (it.completedAt) {
144
+ sum += it.completedAt - it.startedAt
145
+ }
146
+ }
147
+ return sum > 0 ? sum : undefined
148
+ })
149
+
150
+ /** Count actual tool invocations across all messages in this group */
151
+ const toolInvocationCounts = createMemo(() => {
152
+ const counts = new Map<string, number>()
153
+ const allMsgIds = new Set<string>()
154
+ for (const it of iters()) {
155
+ for (const id of it.messageIds) allMsgIds.add(id)
156
+ }
157
+ for (const msg of props.allMessages) {
158
+ if (allMsgIds.has(msg.id) && msg.toolCalls) {
159
+ for (const tc of msg.toolCalls) {
160
+ counts.set(tc.name, (counts.get(tc.name) || 0) + 1)
161
+ }
162
+ }
163
+ }
164
+ return counts
165
+ })
166
+
167
+ const totalToolCalls = createMemo(() => {
168
+ let count = 0
169
+ for (const v of toolInvocationCounts().values()) count += v
170
+ return count
171
+ })
172
+
173
+ /**
174
+ * Total usage across this group.
175
+ * Iterations store CUMULATIVE usage per request, so we take the MAX
176
+ * cumulative from each request group (the last iteration's value
177
+ * represents the total for that request).
178
+ */
179
+ const totalUsage = createMemo(() => {
180
+ const maxByRequest = new Map<
181
+ string,
182
+ { prompt: number; completion: number }
183
+ >()
184
+ for (const it of iters()) {
185
+ if (!it.usage) continue
186
+ const key = it.requestId || '__default__'
187
+ const existing = maxByRequest.get(key)
188
+ if (
189
+ !existing ||
190
+ it.usage.totalTokens > existing.prompt + existing.completion
191
+ ) {
192
+ maxByRequest.set(key, {
193
+ prompt: it.usage.promptTokens,
194
+ completion: it.usage.completionTokens,
195
+ })
196
+ }
197
+ }
198
+ let prompt = 0
199
+ let completion = 0
200
+ for (const v of maxByRequest.values()) {
201
+ prompt += v.prompt
202
+ completion += v.completion
203
+ }
204
+ if (prompt + completion === 0) return undefined
205
+ return {
206
+ promptTokens: prompt,
207
+ completionTokens: completion,
208
+ totalTokens: prompt + completion,
209
+ }
210
+ })
211
+
212
+ const isActive = () => iters().some((it) => !it.completedAt)
213
+ const hasError = () => iters().some((it) => it.finishReason === 'error')
214
+ const allCompleted = () =>
215
+ iters().every((it) => !!it.completedAt) && !hasError()
216
+
217
+ const groupAccentClass = () => {
218
+ if (isActive()) return s().cardActive
219
+ if (hasError()) return s().cardError
220
+ if (allCompleted()) return s().cardCompleted
221
+ return ''
222
+ }
223
+
224
+ const userContent = () => {
225
+ const msg = userMsg()
226
+ if (!msg) return '(no user message)'
227
+ const text = msg.content || ''
228
+ return text.length > 120 ? text.slice(0, 120) + '...' : text
229
+ }
230
+
231
+ // Config from the first iteration of this group
232
+ const firstIter = createMemo(() => iters()[0])
233
+
234
+ const configSubtitle = () => {
235
+ const first = firstIter()
236
+ if (!first) return null
237
+ const parts: Array<string> = []
238
+ if (first.model) parts.push(first.model)
239
+ if (first.provider) parts.push(first.provider)
240
+ return parts.length > 0 ? parts.join(' \u00B7 ') : null
241
+ }
242
+
243
+ const toolNames = () => firstIter()?.toolNames || []
244
+ const systemPrompts = () => firstIter()?.systemPrompts || []
245
+ const modelOptions = () => firstIter()?.modelOptions
246
+ const hasModelOptions = () => {
247
+ const opts = modelOptions()
248
+ return opts && Object.keys(opts).length > 0
249
+ }
250
+
251
+ const middlewareTransformCount = createMemo(() => {
252
+ let count = 0
253
+ for (const it of iters()) {
254
+ for (const ev of it.middlewareEvents) {
255
+ if (ev.hasTransform) count++
256
+ }
257
+ }
258
+ return count
259
+ })
260
+
261
+ const hasExpandableConfig = () =>
262
+ toolNames().length > 0 || systemPrompts().length > 0 || hasModelOptions()
263
+
264
+ return (
265
+ <div class={`${s().card} ${groupAccentClass()}`}>
266
+ {/* User message header */}
267
+ <div class={s().cardHeader} onClick={() => setIsOpen(!isOpen())}>
268
+ <div class={s().userBubble}>U</div>
269
+ <div class={s().cardHeaderContent}>
270
+ <span class={s().cardHeaderLabel}>{userContent()}</span>
271
+ {/* Config subtitle — always visible under user message */}
272
+ <div class={s().cardSubtitle}>
273
+ <Show when={configSubtitle()}>
274
+ <span class={s().subtitleText}>{configSubtitle()}</span>
275
+ </Show>
276
+ <Show when={toolNames().length > 0}>
277
+ <span class={s().subtitleBadge}>
278
+ {toolNames().length} tool{toolNames().length === 1 ? '' : 's'}
279
+ </span>
280
+ </Show>
281
+ <Show when={systemPrompts().length > 0}>
282
+ <span class={s().subtitleBadge}>
283
+ {systemPrompts().length} system prompt
284
+ {systemPrompts().length === 1 ? '' : 's'}
285
+ </span>
286
+ </Show>
287
+ <Show when={hasModelOptions()}>
288
+ <span class={s().subtitleBadge}>options</span>
289
+ </Show>
290
+ <Show when={middlewareTransformCount() > 0}>
291
+ <span class={s().subtitleBadgeWarn}>
292
+ {middlewareTransformCount()} middleware transform
293
+ {middlewareTransformCount() === 1 ? '' : 's'}
294
+ </span>
295
+ </Show>
296
+ <Show when={hasExpandableConfig()}>
297
+ <span
298
+ class={s().subtitleExpandToggle}
299
+ onClick={(e) => {
300
+ e.stopPropagation()
301
+ setConfigExpanded(!configExpanded())
302
+ }}
303
+ >
304
+ {configExpanded() ? 'hide config' : 'show config'}
305
+ </span>
306
+ </Show>
307
+ </div>
308
+ </div>
309
+ <div class={s().cardHeaderBadges}>
310
+ <Show when={iters().length > 0}>
311
+ <span
312
+ class={`${s().badge} ${s().badgeDuration}`}
313
+ title={`${iters().length} ${iters().length === 1 ? 'iteration' : 'iterations'}`}
314
+ >
315
+ 🔄 {iters().length}
316
+ </span>
317
+ </Show>
318
+ <Show when={totalToolCalls() > 0}>
319
+ <span
320
+ class={`${s().badge} ${s().badgeFinishReasonToolCalls}`}
321
+ title={`${totalToolCalls()} tool ${totalToolCalls() === 1 ? 'call' : 'calls'}`}
322
+ >
323
+ 🔧 {totalToolCalls()}
324
+ </span>
325
+ </Show>
326
+ <Show when={totalDuration()}>
327
+ <span
328
+ class={`${s().badge} ${s().badgeDuration}`}
329
+ title={formatDuration(totalDuration())}
330
+ >
331
+ ⏱️ {formatDuration(totalDuration())}
332
+ </span>
333
+ </Show>
334
+ <Show when={totalUsage()}>
335
+ <span
336
+ class={`${s().badge} ${s().badgeUsage}`}
337
+ title={`Prompt: ${totalUsage()!.promptTokens.toLocaleString()} | Completion: ${totalUsage()!.completionTokens.toLocaleString()}`}
338
+ >
339
+ 🎯 {totalUsage()!.totalTokens.toLocaleString()}
340
+ </span>
341
+ </Show>
342
+ <Show when={isActive()}>
343
+ <span class={`${s().badge} ${s().badgeDuration}`}>⟳ streaming</span>
344
+ </Show>
345
+ </div>
346
+ <span class={`${s().chevron} ${isOpen() ? s().chevronOpen : ''}`}>
347
+ {'\u25B6'}
348
+ </span>
349
+ </div>
350
+
351
+ {/* Expandable config details — sits between header and iterations */}
352
+ <div
353
+ class={`${s().configPanelWrapper} ${configExpanded() ? s().configPanelWrapperOpen : ''}`}
354
+ >
355
+ <div class={s().configPanel}>
356
+ <div>
357
+ <Show when={toolNames().length > 0}>
358
+ <div class={s().configPanelSection}>
359
+ <span class={s().configPanelLabel}>Tools</span>
360
+ <div class={s().configToolsList}>
361
+ <For each={toolNames()}>
362
+ {(name) => (
363
+ <span class={s().configToolChip}>
364
+ {name}
365
+ <span class={s().configToolChipCount}>
366
+ {toolInvocationCounts().get(name) || 0}
367
+ </span>
368
+ </span>
369
+ )}
370
+ </For>
371
+ </div>
372
+ </div>
373
+ </Show>
374
+ <Show when={systemPrompts().length > 0}>
375
+ <div class={s().configPanelSection}>
376
+ <span class={s().configPanelLabel}>
377
+ System Prompts ({systemPrompts().length})
378
+ </span>
379
+ <For each={systemPrompts()}>
380
+ {(prompt, i) => (
381
+ <SystemPromptItem prompt={prompt} index={i()} />
382
+ )}
383
+ </For>
384
+ </div>
385
+ </Show>
386
+ <Show when={hasModelOptions()}>
387
+ <div class={s().configPanelSection}>
388
+ <span class={s().configPanelLabel}>Model Options</span>
389
+ <div class={s().configJsonTreeContainer}>
390
+ <JsonTree
391
+ value={modelOptions() as Record<string, unknown>}
392
+ defaultExpansionDepth={2}
393
+ copyable
394
+ />
395
+ </div>
396
+ </div>
397
+ </Show>
398
+ </div>
399
+ </div>
400
+ </div>
401
+
402
+ {/* Iterations list — full width */}
403
+ <div class={`${s().cardBody} ${isOpen() ? s().cardBodyOpen : ''}`}>
404
+ <div class={s().cardBodyInner}>
405
+ <div class={s().iterList}>
406
+ <For each={iters()}>
407
+ {(iteration, index) => (
408
+ <IterationCard
409
+ iteration={iteration}
410
+ previousIteration={
411
+ index() > 0 ? iters()[index() - 1] : undefined
412
+ }
413
+ messages={props.allMessages}
414
+ index={index()}
415
+ isLast={index() === iters().length - 1}
416
+ />
417
+ )}
418
+ </For>
419
+ </div>
420
+ </div>
421
+ </div>
422
+ </div>
423
+ )
424
+ }
@@ -6,3 +6,6 @@ export { MessagesTab } from './MessagesTab'
6
6
  export { ChunksTab } from './ChunksTab'
7
7
  export { SummariesTab } from './SummariesTab'
8
8
  export { ActivityEventsTab } from './ActivityEventsTab'
9
+
10
+ export { IterationTimeline } from './IterationTimeline'
11
+ export { IterationCard } from './IterationCard'