@kitlangton/motel 0.2.0 → 0.2.4

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 (48) hide show
  1. package/AGENTS.md +5 -0
  2. package/package.json +7 -5
  3. package/src/App.tsx +233 -59
  4. package/src/daemon.test.ts +213 -6
  5. package/src/daemon.ts +174 -38
  6. package/src/domain.test.ts +62 -0
  7. package/src/domain.ts +16 -0
  8. package/src/localServer.ts +114 -128
  9. package/src/mcp.ts +172 -0
  10. package/src/motelClient.ts +166 -14
  11. package/src/registry.ts +26 -23
  12. package/src/runtime.ts +8 -2
  13. package/src/server.ts +10 -9
  14. package/src/services/AsyncIngest.ts +68 -0
  15. package/src/services/TelemetryStore.ts +262 -119
  16. package/src/services/TraceQueryService.ts +3 -1
  17. package/src/services/ingestRpc.ts +41 -0
  18. package/src/services/telemetryWorker.ts +62 -0
  19. package/src/storybook/aiChatStory.tsx +244 -0
  20. package/src/storybook/fixtures/errorState.ts +44 -0
  21. package/src/storybook/fixtures/imagePaste.ts +34 -0
  22. package/src/storybook/fixtures/index.ts +62 -0
  23. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  24. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  25. package/src/storybook/fixtures/short.ts +27 -0
  26. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  27. package/src/telemetry.test.ts +28 -0
  28. package/src/ui/AiChatView.tsx +308 -0
  29. package/src/ui/SpanContentView.tsx +181 -0
  30. package/src/ui/SpanDetail.tsx +98 -17
  31. package/src/ui/TraceDetailsPane.tsx +11 -28
  32. package/src/ui/Waterfall.tsx +43 -148
  33. package/src/ui/aiChatModel.test.ts +391 -0
  34. package/src/ui/aiChatModel.ts +773 -0
  35. package/src/ui/aiState.ts +71 -0
  36. package/src/ui/app/TraceWorkspace.tsx +288 -124
  37. package/src/ui/app/useAppLayout.ts +14 -11
  38. package/src/ui/app/useTraceScreenData.ts +174 -40
  39. package/src/ui/atoms.ts +131 -0
  40. package/src/ui/loaders.ts +120 -0
  41. package/src/ui/persistence.ts +41 -0
  42. package/src/ui/primitives.tsx +27 -13
  43. package/src/ui/state.ts +4 -199
  44. package/src/ui/useAttrFilterPicker.ts +63 -23
  45. package/src/ui/useKeyboardNav.ts +552 -364
  46. package/src/ui/waterfallModel.ts +130 -0
  47. package/src/ui/waterfallNav.test.ts +17 -1
  48. package/src/ui/waterfallNav.ts +1 -1
@@ -0,0 +1,71 @@
1
+ import { Effect } from "effect"
2
+ import * as Atom from "effect/unstable/reactivity/Atom"
3
+ import type { AiCallDetail } from "../domain.ts"
4
+ import { queryRuntime } from "../runtime.ts"
5
+ import { TraceQueryService } from "../services/TraceQueryService.ts"
6
+ import type { LoadStatus } from "./atoms.ts"
7
+
8
+ // AI chat view (full-screen when drilled into an `isAiSpan` span).
9
+ // ---------------------------------------------------------------------
10
+ // The main pane is a normal selectable list of semantic chunks (one row
11
+ // per chunk, with stable list scrolling). Opening a chunk shows its full
12
+ // content in a modal overlay that owns its own line scroll offset. This
13
+ // feels much closer to the rest of motel than the previous in-line
14
+ // expansion experiment.
15
+ // ---------------------------------------------------------------------
16
+ /** Chunk id currently selected in the list (null = first chunk). */
17
+ export const selectedChatChunkIdAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
18
+ /** Chunk id whose detail modal is currently open. */
19
+ export const chatDetailChunkIdAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
20
+ /** Line scroll offset inside the open detail modal. */
21
+ export const chatDetailScrollOffsetAtom = Atom.make(0).pipe(Atom.keepAlive)
22
+
23
+ export interface AiCallDetailState {
24
+ readonly status: LoadStatus
25
+ readonly spanId: string | null
26
+ readonly data: AiCallDetail | null
27
+ readonly error: string | null
28
+ }
29
+
30
+ export const initialAiCallDetailState: AiCallDetailState = {
31
+ status: "ready",
32
+ spanId: null,
33
+ data: null,
34
+ error: null,
35
+ }
36
+
37
+ export const aiCallDetailStateAtom = Atom.make(initialAiCallDetailState).pipe(Atom.keepAlive)
38
+
39
+ export const loadAiCallDetail = (spanId: string) =>
40
+ queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.getAiCall(spanId)))
41
+
42
+ // AI call detail cache: the `ai.prompt` payload can easily be 50KB+ and
43
+ // we don't want to re-hit SQLite every time j/k moves the selection
44
+ // between adjacent AI spans. Cleared alongside the other per-refresh
45
+ // caches in `useTraceScreenData`.
46
+ const aiCallDetailCache = new Map<string, AiCallDetail | null>()
47
+ const aiCallDetailInflight = new Map<string, Promise<AiCallDetail | null>>()
48
+
49
+ export const getCachedAiCallDetail = (spanId: string): AiCallDetail | null | undefined =>
50
+ aiCallDetailCache.has(spanId) ? aiCallDetailCache.get(spanId) ?? null : undefined
51
+
52
+ export const ensureAiCallDetail = (spanId: string): Promise<AiCallDetail | null> => {
53
+ if (aiCallDetailCache.has(spanId)) return Promise.resolve(aiCallDetailCache.get(spanId) ?? null)
54
+ const existing = aiCallDetailInflight.get(spanId)
55
+ if (existing) return existing
56
+ const request = loadAiCallDetail(spanId)
57
+ .then((data) => {
58
+ aiCallDetailCache.set(spanId, data)
59
+ return data
60
+ })
61
+ .finally(() => {
62
+ aiCallDetailInflight.delete(spanId)
63
+ })
64
+ aiCallDetailInflight.set(spanId, request)
65
+ return request
66
+ }
67
+
68
+ export const invalidateAiCallDetailCache = () => {
69
+ aiCallDetailCache.clear()
70
+ aiCallDetailInflight.clear()
71
+ }
@@ -1,9 +1,12 @@
1
- import type { LogItem, TraceItem, TraceSummaryItem } from "../../domain.ts"
1
+ import { isAiSpan, type LogItem, type TraceItem, type TraceSummaryItem } from "../../domain.ts"
2
+ import type { Chunk } from "../aiChatModel.ts"
3
+ import { AiChatView } from "../AiChatView.tsx"
2
4
  import { formatShortDate, formatTimestamp } from "../format.ts"
3
5
  import { AlignedHeaderLine, BlankRow, Divider, SeparatorColumn, TextLine } from "../primitives.tsx"
4
6
  import { ServiceLogsView } from "../ServiceLogs.tsx"
7
+ import { SpanContentView } from "../SpanContentView.tsx"
5
8
  import { SpanDetailPane } from "../SpanDetailPane.tsx"
6
- import type { DetailView, LogState, ServiceLogState, TraceDetailState } from "../state.ts"
9
+ import type { AiCallDetailState, DetailView, LogState, ServiceLogState, TraceDetailState } from "../state.ts"
7
10
  import { colors, SEPARATOR } from "../theme.ts"
8
11
  import { TraceDetailsPane } from "../TraceDetailsPane.tsx"
9
12
  import type { TraceListProps } from "../TraceList.tsx"
@@ -13,6 +16,188 @@ import type { AppLayout } from "./useAppLayout.ts"
13
16
  const separatorJunctionChars = new Map<number, string>([[3, "├"]])
14
17
  const separatorCrossChars = new Map<number, string>([[3, "┼"]])
15
18
 
19
+ interface SharedTraceDetailsProps {
20
+ readonly trace: TraceItem | null
21
+ readonly traceSummary: TraceSummaryItem | null
22
+ readonly traceStatus: TraceDetailState["status"]
23
+ readonly traceError: string | null
24
+ readonly traceLogCount: number
25
+ readonly selectedSpanIndex: number | null
26
+ readonly collapsedSpanIds: ReadonlySet<string>
27
+ readonly waterfallFilterMode: boolean
28
+ readonly waterfallFilterText: string
29
+ readonly onSelectSpan: (index: number) => void
30
+ }
31
+
32
+ interface TraceDetailsSceneProps extends SharedTraceDetailsProps {
33
+ readonly contentWidth: number
34
+ readonly bodyLines: number
35
+ readonly paneWidth: number
36
+ }
37
+
38
+ const TraceDetailsScene = ({
39
+ trace,
40
+ traceSummary,
41
+ traceStatus,
42
+ traceError,
43
+ traceLogCount,
44
+ contentWidth,
45
+ bodyLines,
46
+ paneWidth,
47
+ selectedSpanIndex,
48
+ collapsedSpanIds,
49
+ waterfallFilterMode,
50
+ waterfallFilterText,
51
+ onSelectSpan,
52
+ }: TraceDetailsSceneProps) => (
53
+ <TraceDetailsPane
54
+ trace={trace}
55
+ traceSummary={traceSummary}
56
+ traceStatus={traceStatus}
57
+ traceError={traceError}
58
+ traceLogCount={traceLogCount}
59
+ contentWidth={contentWidth}
60
+ bodyLines={bodyLines}
61
+ paneWidth={paneWidth}
62
+ selectedSpanIndex={selectedSpanIndex}
63
+ collapsedSpanIds={collapsedSpanIds}
64
+ waterfallFilterMode={waterfallFilterMode}
65
+ waterfallFilterText={waterfallFilterText}
66
+ onSelectSpan={onSelectSpan}
67
+ />
68
+ )
69
+
70
+ interface SpanDrillInSceneProps {
71
+ readonly aiDrillIn: boolean
72
+ readonly selectedSpan: TraceItem["spans"][number] | null
73
+ readonly aiCallDetailState: AiCallDetailState
74
+ readonly aiChatChunks: readonly Chunk[]
75
+ readonly selectedChatChunkId: string | null
76
+ readonly onSelectChatChunk: (chunkId: string) => void
77
+ readonly chatDetailChunkId: string | null
78
+ readonly onOpenChatChunkDetail: (chunkId: string) => void
79
+ readonly onCloseChatChunkDetail: () => void
80
+ readonly chatDetailScrollOffset: number
81
+ readonly onSetChatDetailScrollOffset: (updater: (current: number) => number) => void
82
+ readonly contentWidth: number
83
+ readonly bodyLines: number
84
+ readonly paneWidth: number
85
+ readonly selectedAttrIndex: number
86
+ }
87
+
88
+ const SpanDrillInScene = ({
89
+ aiDrillIn,
90
+ selectedSpan,
91
+ aiCallDetailState,
92
+ aiChatChunks,
93
+ selectedChatChunkId,
94
+ onSelectChatChunk,
95
+ chatDetailChunkId,
96
+ onOpenChatChunkDetail,
97
+ onCloseChatChunkDetail,
98
+ chatDetailScrollOffset,
99
+ onSetChatDetailScrollOffset,
100
+ contentWidth,
101
+ bodyLines,
102
+ paneWidth,
103
+ selectedAttrIndex,
104
+ }: SpanDrillInSceneProps) => aiDrillIn ? (
105
+ <AiChatView
106
+ span={selectedSpan}
107
+ detailState={aiCallDetailState}
108
+ chunks={aiChatChunks}
109
+ selectedChunkId={selectedChatChunkId}
110
+ onSelectChunk={onSelectChatChunk}
111
+ detailChunkId={chatDetailChunkId}
112
+ onOpenDetail={onOpenChatChunkDetail}
113
+ onCloseDetail={onCloseChatChunkDetail}
114
+ detailScrollOffset={chatDetailScrollOffset}
115
+ onSetDetailScrollOffset={onSetChatDetailScrollOffset}
116
+ contentWidth={contentWidth}
117
+ bodyLines={bodyLines}
118
+ paneWidth={paneWidth}
119
+ />
120
+ ) : (
121
+ <SpanContentView
122
+ span={selectedSpan}
123
+ contentWidth={contentWidth}
124
+ bodyLines={bodyLines}
125
+ paneWidth={paneWidth}
126
+ selectedAttrIndex={selectedAttrIndex}
127
+ />
128
+ )
129
+
130
+ interface ServiceLogsSceneProps {
131
+ readonly selectedTraceService: string | null
132
+ readonly serviceLogState: ServiceLogState
133
+ readonly selectedServiceLogIndex: number
134
+ readonly setSelectedServiceLogIndex: (value: number | ((current: number) => number)) => void
135
+ readonly headerFooterWidth: number
136
+ readonly availableContentHeight: number
137
+ }
138
+
139
+ const ServiceLogsScene = ({
140
+ selectedTraceService,
141
+ serviceLogState,
142
+ selectedServiceLogIndex,
143
+ setSelectedServiceLogIndex,
144
+ headerFooterWidth,
145
+ availableContentHeight,
146
+ }: ServiceLogsSceneProps) => (
147
+ <box flexGrow={1} flexDirection="column" paddingLeft={1} paddingRight={1}>
148
+ <AlignedHeaderLine
149
+ left="SERVICE LOGS"
150
+ right={`${serviceLogState.data.length} logs${serviceLogState.fetchedAt ? `${SEPARATOR}${formatShortDate(serviceLogState.fetchedAt)} ${formatTimestamp(serviceLogState.fetchedAt)}` : ""}`}
151
+ width={headerFooterWidth}
152
+ rightFg={colors.count}
153
+ />
154
+ <TextLine>
155
+ <span fg={colors.defaultService}>{selectedTraceService ?? "unknown"}</span>
156
+ <span fg={colors.separator}>{SEPARATOR}</span>
157
+ <span fg={colors.count}>recent logs</span>
158
+ </TextLine>
159
+ <BlankRow />
160
+ <ServiceLogsView
161
+ serviceName={selectedTraceService}
162
+ logsState={serviceLogState}
163
+ selectedIndex={selectedServiceLogIndex}
164
+ onSelectLog={setSelectedServiceLogIndex}
165
+ contentWidth={headerFooterWidth}
166
+ bodyLines={Math.max(8, availableContentHeight - 3)}
167
+ />
168
+ </box>
169
+ )
170
+
171
+ interface NarrowDrillInHeaderProps {
172
+ readonly contentWidth: number
173
+ readonly viewLevel: 0 | 1 | 2
174
+ readonly selectedTraceSummary: TraceSummaryItem | null
175
+ readonly selectedSpan: TraceItem["spans"][number] | null
176
+ }
177
+
178
+ const NarrowDrillInHeader = ({ contentWidth, viewLevel, selectedTraceSummary, selectedSpan }: NarrowDrillInHeaderProps) => (
179
+ <>
180
+ <box paddingLeft={1} paddingRight={1} height={1} flexDirection="column">
181
+ <TextLine>
182
+ <span fg={colors.muted}>TRACES</span>
183
+ {selectedTraceSummary ? (
184
+ <>
185
+ <span fg={colors.separator}>{" "}{SEPARATOR}{" "}</span>
186
+ <span fg={viewLevel === 1 ? colors.accent : colors.muted}>{selectedTraceSummary.rootOperationName}</span>
187
+ </>
188
+ ) : null}
189
+ {viewLevel === 2 && selectedSpan ? (
190
+ <>
191
+ <span fg={colors.separator}>{" "}{SEPARATOR}{" "}</span>
192
+ <span fg={colors.accent}>{selectedSpan.operationName}</span>
193
+ </>
194
+ ) : null}
195
+ </TextLine>
196
+ </box>
197
+ <Divider width={contentWidth} />
198
+ </>
199
+ )
200
+
16
201
  interface TraceWorkspaceProps {
17
202
  readonly layout: AppLayout
18
203
  readonly detailView: DetailView
@@ -34,6 +219,16 @@ interface TraceWorkspaceProps {
34
219
  readonly viewLevel: 0 | 1 | 2
35
220
  readonly selectedSpan: TraceItem["spans"][number] | null
36
221
  readonly selectedSpanLogs: readonly LogItem[]
222
+ readonly selectedAttrIndex: number
223
+ readonly aiCallDetailState: AiCallDetailState
224
+ readonly aiChatChunks: readonly Chunk[]
225
+ readonly selectedChatChunkId: string | null
226
+ readonly onSelectChatChunk: (chunkId: string) => void
227
+ readonly chatDetailChunkId: string | null
228
+ readonly onOpenChatChunkDetail: (chunkId: string) => void
229
+ readonly onCloseChatChunkDetail: () => void
230
+ readonly chatDetailScrollOffset: number
231
+ readonly onSetChatDetailScrollOffset: (updater: (current: number) => number) => void
37
232
  readonly selectSpan: (index: number) => void
38
233
  }
39
234
 
@@ -58,8 +253,19 @@ export const TraceWorkspace = ({
58
253
  viewLevel,
59
254
  selectedSpan,
60
255
  selectedSpanLogs,
256
+ selectedAttrIndex,
257
+ aiCallDetailState,
258
+ aiChatChunks,
259
+ selectedChatChunkId,
260
+ onSelectChatChunk,
261
+ chatDetailChunkId,
262
+ onOpenChatChunkDetail,
263
+ onCloseChatChunkDetail,
264
+ chatDetailScrollOffset,
265
+ onSetChatDetailScrollOffset,
61
266
  selectSpan,
62
267
  }: TraceWorkspaceProps) => {
268
+ const aiDrillIn = selectedSpan !== null && isAiSpan(selectedSpan.tags)
63
269
  const {
64
270
  contentWidth,
65
271
  headerFooterWidth,
@@ -78,36 +284,34 @@ export const TraceWorkspace = ({
78
284
  narrowTraceListBodyHeight,
79
285
  availableContentHeight,
80
286
  } = layout
287
+ const traceDetailsProps: SharedTraceDetailsProps = {
288
+ trace: selectedTrace,
289
+ traceSummary: selectedTraceSummary,
290
+ traceStatus: traceDetailState.status,
291
+ traceError: traceDetailState.error,
292
+ traceLogCount: logState.data.length,
293
+ selectedSpanIndex,
294
+ collapsedSpanIds,
295
+ waterfallFilterMode,
296
+ waterfallFilterText,
297
+ onSelectSpan: selectSpan,
298
+ }
299
+ const drillInContentWidth = Math.max(24, contentWidth - 2)
81
300
 
82
301
  if (detailView === "service-logs") {
83
302
  return (
84
- <box flexGrow={1} flexDirection="column" paddingLeft={1} paddingRight={1}>
85
- <AlignedHeaderLine
86
- left="SERVICE LOGS"
87
- right={`${serviceLogState.data.length} logs${serviceLogState.fetchedAt ? `${SEPARATOR}${formatShortDate(serviceLogState.fetchedAt)} ${formatTimestamp(serviceLogState.fetchedAt)}` : ""}`}
88
- width={headerFooterWidth}
89
- rightFg={colors.count}
90
- />
91
- <TextLine>
92
- <span fg={colors.defaultService}>{selectedTraceService ?? "unknown"}</span>
93
- <span fg={colors.separator}>{SEPARATOR}</span>
94
- <span fg={colors.count}>recent logs</span>
95
- </TextLine>
96
- <BlankRow />
97
- <ServiceLogsView
98
- serviceName={selectedTraceService}
99
- logsState={serviceLogState}
100
- selectedIndex={selectedServiceLogIndex}
101
- onSelectLog={setSelectedServiceLogIndex}
102
- contentWidth={headerFooterWidth}
103
- bodyLines={Math.max(8, availableContentHeight - 3)}
104
- />
105
- </box>
303
+ <ServiceLogsScene
304
+ selectedTraceService={selectedTraceService}
305
+ serviceLogState={serviceLogState}
306
+ selectedServiceLogIndex={selectedServiceLogIndex}
307
+ setSelectedServiceLogIndex={setSelectedServiceLogIndex}
308
+ headerFooterWidth={headerFooterWidth}
309
+ availableContentHeight={availableContentHeight}
310
+ />
106
311
  )
107
312
  }
108
313
 
109
314
  if (isWideLayout) {
110
- // L0: list (left) + trace preview (right). The two-pane zoom.
111
315
  if (viewLevel === 0) {
112
316
  return (
113
317
  <box flexGrow={1} flexDirection="row">
@@ -124,83 +328,63 @@ export const TraceWorkspace = ({
124
328
  </box>
125
329
  <SeparatorColumn height={wideBodyHeight} junctionChars={separatorJunctionChars} />
126
330
  <box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
127
- <TraceDetailsPane
128
- trace={selectedTrace}
129
- traceSummary={selectedTraceSummary}
130
- traceStatus={traceDetailState.status}
131
- traceError={traceDetailState.error}
132
- traceLogsState={logState}
331
+ <TraceDetailsScene
332
+ {...traceDetailsProps}
133
333
  contentWidth={rightContentWidth}
134
334
  bodyLines={wideBodyLines}
135
335
  paneWidth={rightPaneWidth}
136
- selectedSpanIndex={selectedSpanIndex}
137
- collapsedSpanIds={collapsedSpanIds}
138
- focused={false}
139
- waterfallFilterMode={waterfallFilterMode} waterfallFilterText={waterfallFilterText} onSelectSpan={selectSpan}
140
336
  />
141
337
  </box>
142
338
  </box>
143
339
  )
144
340
  }
145
341
 
146
- // L1: the user pressed enter on a trace — hide the list entirely and
147
- // let the waterfall take the full width. `leftPaneWidth` is already
148
- // `contentWidth` in this case (see useAppLayout), so one pane fills
149
- // the row.
150
342
  if (viewLevel === 1) {
151
343
  return (
152
344
  <box flexGrow={1} flexDirection="row">
153
345
  <box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column">
154
- <TraceDetailsPane
155
- trace={selectedTrace}
156
- traceSummary={selectedTraceSummary}
157
- traceStatus={traceDetailState.status}
158
- traceError={traceDetailState.error}
159
- traceLogsState={logState}
160
- contentWidth={Math.max(24, leftPaneWidth - sectionPadding * 2)}
346
+ <TraceDetailsScene
347
+ {...traceDetailsProps}
348
+ contentWidth={leftContentWidth}
161
349
  bodyLines={wideBodyLines}
162
350
  paneWidth={leftPaneWidth}
163
- selectedSpanIndex={selectedSpanIndex}
164
- collapsedSpanIds={collapsedSpanIds}
165
- focused={true}
166
- waterfallFilterMode={waterfallFilterMode} waterfallFilterText={waterfallFilterText} onSelectSpan={selectSpan}
351
+ />
352
+ </box>
353
+ <SeparatorColumn height={wideBodyHeight} junctionChars={separatorCrossChars} />
354
+ <box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
355
+ <SpanDetailPane
356
+ span={selectedSpan}
357
+ trace={selectedTrace}
358
+ logs={selectedSpanLogs}
359
+ contentWidth={rightContentWidth}
360
+ bodyLines={wideBodyLines}
361
+ paneWidth={rightPaneWidth}
362
+ focused={false}
167
363
  />
168
364
  </box>
169
365
  </box>
170
366
  )
171
367
  }
172
368
 
173
- // L2: waterfall-left + span detail-right. Still no list.
174
369
  return (
175
- <box flexGrow={1} flexDirection="row">
176
- <box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column">
177
- <TraceDetailsPane
178
- trace={selectedTrace}
179
- traceSummary={selectedTraceSummary}
180
- traceStatus={traceDetailState.status}
181
- traceError={traceDetailState.error}
182
- traceLogsState={logState}
183
- contentWidth={leftContentWidth}
184
- bodyLines={wideBodyLines}
185
- paneWidth={leftPaneWidth}
186
- selectedSpanIndex={selectedSpanIndex}
187
- collapsedSpanIds={collapsedSpanIds}
188
- focused={false}
189
- waterfallFilterMode={waterfallFilterMode} waterfallFilterText={waterfallFilterText} onSelectSpan={selectSpan}
190
- />
191
- </box>
192
- <SeparatorColumn height={wideBodyHeight} junctionChars={separatorCrossChars} />
193
- <box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
194
- <SpanDetailPane
195
- span={selectedSpan}
196
- trace={selectedTrace}
197
- logs={selectedSpanLogs}
198
- contentWidth={rightContentWidth}
199
- bodyLines={wideBodyLines}
200
- paneWidth={rightPaneWidth}
201
- focused={true}
202
- />
203
- </box>
370
+ <box flexGrow={1} flexDirection="column">
371
+ <SpanDrillInScene
372
+ aiDrillIn={aiDrillIn}
373
+ selectedSpan={selectedSpan}
374
+ aiCallDetailState={aiCallDetailState}
375
+ aiChatChunks={aiChatChunks}
376
+ selectedChatChunkId={selectedChatChunkId}
377
+ onSelectChatChunk={onSelectChatChunk}
378
+ chatDetailChunkId={chatDetailChunkId}
379
+ onOpenChatChunkDetail={onOpenChatChunkDetail}
380
+ onCloseChatChunkDetail={onCloseChatChunkDetail}
381
+ chatDetailScrollOffset={chatDetailScrollOffset}
382
+ onSetChatDetailScrollOffset={onSetChatDetailScrollOffset}
383
+ contentWidth={drillInContentWidth}
384
+ bodyLines={wideBodyLines}
385
+ paneWidth={contentWidth}
386
+ selectedAttrIndex={selectedAttrIndex}
387
+ />
204
388
  </box>
205
389
  )
206
390
  }
@@ -218,19 +402,11 @@ export const TraceWorkspace = ({
218
402
  padding={sectionPadding}
219
403
  />
220
404
  <Divider width={contentWidth} />
221
- <TraceDetailsPane
222
- trace={selectedTrace}
223
- traceSummary={selectedTraceSummary}
224
- traceStatus={traceDetailState.status}
225
- traceError={traceDetailState.error}
226
- traceLogsState={logState}
405
+ <TraceDetailsScene
406
+ {...traceDetailsProps}
227
407
  contentWidth={rightContentWidth}
228
408
  bodyLines={narrowBodyLines}
229
409
  paneWidth={contentWidth}
230
- selectedSpanIndex={selectedSpanIndex}
231
- collapsedSpanIds={collapsedSpanIds}
232
- focused={false}
233
- waterfallFilterMode={waterfallFilterMode} waterfallFilterText={waterfallFilterText} onSelectSpan={selectSpan}
234
410
  />
235
411
  </>
236
412
  )
@@ -238,48 +414,36 @@ export const TraceWorkspace = ({
238
414
 
239
415
  return (
240
416
  <>
241
- <box paddingLeft={1} paddingRight={1} height={1} flexDirection="column">
242
- <TextLine>
243
- <span fg={colors.muted}>TRACES</span>
244
- {selectedTraceSummary ? (
245
- <>
246
- <span fg={colors.separator}>{" "}{SEPARATOR}{" "}</span>
247
- <span fg={viewLevel === 1 ? colors.accent : colors.muted}>{selectedTraceSummary.rootOperationName}</span>
248
- </>
249
- ) : null}
250
- {viewLevel === 2 && selectedSpan ? (
251
- <>
252
- <span fg={colors.separator}>{" "}{SEPARATOR}{" "}</span>
253
- <span fg={colors.accent}>{selectedSpan.operationName}</span>
254
- </>
255
- ) : null}
256
- </TextLine>
257
- </box>
258
- <Divider width={contentWidth} />
417
+ <NarrowDrillInHeader
418
+ contentWidth={contentWidth}
419
+ viewLevel={viewLevel}
420
+ selectedTraceSummary={selectedTraceSummary}
421
+ selectedSpan={selectedSpan}
422
+ />
259
423
  {viewLevel === 1 ? (
260
- <TraceDetailsPane
261
- trace={selectedTrace}
262
- traceSummary={selectedTraceSummary}
263
- traceStatus={traceDetailState.status}
264
- traceError={traceDetailState.error}
265
- traceLogsState={logState}
424
+ <TraceDetailsScene
425
+ {...traceDetailsProps}
266
426
  contentWidth={rightContentWidth}
267
427
  bodyLines={narrowFullBodyLines}
268
428
  paneWidth={contentWidth}
269
- selectedSpanIndex={selectedSpanIndex}
270
- collapsedSpanIds={collapsedSpanIds}
271
- focused={true}
272
- waterfallFilterMode={waterfallFilterMode} waterfallFilterText={waterfallFilterText} onSelectSpan={selectSpan}
273
429
  />
274
430
  ) : (
275
- <SpanDetailPane
276
- span={selectedSpan}
277
- trace={selectedTrace}
278
- logs={selectedSpanLogs}
279
- contentWidth={rightContentWidth}
431
+ <SpanDrillInScene
432
+ aiDrillIn={aiDrillIn}
433
+ selectedSpan={selectedSpan}
434
+ aiCallDetailState={aiCallDetailState}
435
+ aiChatChunks={aiChatChunks}
436
+ selectedChatChunkId={selectedChatChunkId}
437
+ onSelectChatChunk={onSelectChatChunk}
438
+ chatDetailChunkId={chatDetailChunkId}
439
+ onOpenChatChunkDetail={onOpenChatChunkDetail}
440
+ onCloseChatChunkDetail={onCloseChatChunkDetail}
441
+ chatDetailScrollOffset={chatDetailScrollOffset}
442
+ onSetChatDetailScrollOffset={onSetChatDetailScrollOffset}
443
+ contentWidth={drillInContentWidth}
280
444
  bodyLines={narrowFullBodyLines}
281
445
  paneWidth={contentWidth}
282
- focused={true}
446
+ selectedAttrIndex={selectedAttrIndex}
283
447
  />
284
448
  )}
285
449
  </>
@@ -25,22 +25,25 @@ export const useAppLayout = ({ width, height, notice, detailView, selectedSpanIn
25
25
  detailView === "span-detail" ? 2 :
26
26
  selectedSpanIndex !== null ? 1 :
27
27
  0
28
- // At L0 we show list + preview side-by-side. Once drilled in (L1/L2) the
29
- // trace list is hidden entirely and the detail pane(s) take the full
30
- // width — either one pane (waterfall at L1) or a 50/50 split between
31
- // waterfall and span detail at L2.
32
- const splitRatio = viewLevelForLayout === 2 ? 0.5 : 0.4
28
+ // Split ratios for the two-pane body:
29
+ // L0 (trace list + trace preview): 40% / 60% — list narrow, preview wide
30
+ // L1 (waterfall + span preview): 60% / 40% — always-on preview,
31
+ // read-only (enter drills
32
+ // one level deeper)
33
+ // L2 (full-screen span content): single pane — the waterfall is
34
+ // hidden entirely; the
35
+ // workspace reads
36
+ // contentWidth directly
37
+ // and the split ratio
38
+ // is irrelevant.
39
+ const splitRatio = viewLevelForLayout === 1 ? 0.6 : 0.4
33
40
  const listHidden = viewLevelForLayout >= 1
34
41
  const leftPaneWidth = !isWideLayout
35
42
  ? contentWidth
36
- : listHidden
37
- ? (viewLevelForLayout === 2 ? Math.max(40, Math.floor((contentWidth - splitGap) * splitRatio)) : contentWidth)
38
- : Math.max(40, Math.floor((contentWidth - splitGap) * splitRatio))
43
+ : Math.max(40, Math.floor((contentWidth - splitGap) * splitRatio))
39
44
  const rightPaneWidth = !isWideLayout
40
45
  ? contentWidth
41
- : listHidden && viewLevelForLayout !== 2
42
- ? 0
43
- : Math.max(28, contentWidth - leftPaneWidth - splitGap)
46
+ : Math.max(28, contentWidth - leftPaneWidth - splitGap)
44
47
  // Left pane: paddingLeft (1) + scrollbar column (1). No right padding —
45
48
  // the vertical pane divider handles visual separation from the right pane.
46
49
  const leftContentWidth = isWideLayout ? Math.max(24, leftPaneWidth - 2) : Math.max(24, contentWidth - sectionPadding * 2)