@kitlangton/motel 0.2.0 → 0.2.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 (48) hide show
  1. package/AGENTS.md +5 -0
  2. package/package.json +5 -3
  3. package/src/App.tsx +233 -59
  4. package/src/daemon.test.ts +144 -7
  5. package/src/daemon.ts +113 -8
  6. package/src/domain.test.ts +62 -0
  7. package/src/domain.ts +16 -0
  8. package/src/localServer.ts +111 -121
  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 +52 -0
  15. package/src/services/TelemetryStore.ts +151 -26
  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 +243 -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 +292 -0
  29. package/src/ui/SpanContentView.tsx +181 -0
  30. package/src/ui/SpanDetail.tsx +98 -17
  31. package/src/ui/TraceDetailsPane.tsx +2 -1
  32. package/src/ui/Waterfall.tsx +38 -138
  33. package/src/ui/aiChatModel.test.ts +347 -0
  34. package/src/ui/aiChatModel.ts +736 -0
  35. package/src/ui/aiState.ts +71 -0
  36. package/src/ui/app/TraceWorkspace.tsx +291 -120
  37. package/src/ui/app/useAppLayout.ts +14 -11
  38. package/src/ui/app/useTraceScreenData.ts +173 -39
  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,191 @@ 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 traceLogsState: LogState
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
+ readonly focused: boolean
37
+ }
38
+
39
+ const TraceDetailsScene = ({
40
+ trace,
41
+ traceSummary,
42
+ traceStatus,
43
+ traceError,
44
+ traceLogsState,
45
+ contentWidth,
46
+ bodyLines,
47
+ paneWidth,
48
+ selectedSpanIndex,
49
+ collapsedSpanIds,
50
+ focused,
51
+ waterfallFilterMode,
52
+ waterfallFilterText,
53
+ onSelectSpan,
54
+ }: TraceDetailsSceneProps) => (
55
+ <TraceDetailsPane
56
+ trace={trace}
57
+ traceSummary={traceSummary}
58
+ traceStatus={traceStatus}
59
+ traceError={traceError}
60
+ traceLogsState={traceLogsState}
61
+ contentWidth={contentWidth}
62
+ bodyLines={bodyLines}
63
+ paneWidth={paneWidth}
64
+ selectedSpanIndex={selectedSpanIndex}
65
+ collapsedSpanIds={collapsedSpanIds}
66
+ focused={focused}
67
+ waterfallFilterMode={waterfallFilterMode}
68
+ waterfallFilterText={waterfallFilterText}
69
+ onSelectSpan={onSelectSpan}
70
+ />
71
+ )
72
+
73
+ interface SpanDrillInSceneProps {
74
+ readonly aiDrillIn: boolean
75
+ readonly selectedSpan: TraceItem["spans"][number] | null
76
+ readonly aiCallDetailState: AiCallDetailState
77
+ readonly aiChatChunks: readonly Chunk[]
78
+ readonly selectedChatChunkId: string | null
79
+ readonly onSelectChatChunk: (chunkId: string) => void
80
+ readonly chatDetailChunkId: string | null
81
+ readonly onOpenChatChunkDetail: (chunkId: string) => void
82
+ readonly onCloseChatChunkDetail: () => void
83
+ readonly chatDetailScrollOffset: number
84
+ readonly onSetChatDetailScrollOffset: (updater: (current: number) => number) => void
85
+ readonly contentWidth: number
86
+ readonly bodyLines: number
87
+ readonly paneWidth: number
88
+ readonly selectedAttrIndex: number
89
+ }
90
+
91
+ const SpanDrillInScene = ({
92
+ aiDrillIn,
93
+ selectedSpan,
94
+ aiCallDetailState,
95
+ aiChatChunks,
96
+ selectedChatChunkId,
97
+ onSelectChatChunk,
98
+ chatDetailChunkId,
99
+ onOpenChatChunkDetail,
100
+ onCloseChatChunkDetail,
101
+ chatDetailScrollOffset,
102
+ onSetChatDetailScrollOffset,
103
+ contentWidth,
104
+ bodyLines,
105
+ paneWidth,
106
+ selectedAttrIndex,
107
+ }: SpanDrillInSceneProps) => aiDrillIn ? (
108
+ <AiChatView
109
+ span={selectedSpan}
110
+ detailState={aiCallDetailState}
111
+ chunks={aiChatChunks}
112
+ selectedChunkId={selectedChatChunkId}
113
+ onSelectChunk={onSelectChatChunk}
114
+ detailChunkId={chatDetailChunkId}
115
+ onOpenDetail={onOpenChatChunkDetail}
116
+ onCloseDetail={onCloseChatChunkDetail}
117
+ detailScrollOffset={chatDetailScrollOffset}
118
+ onSetDetailScrollOffset={onSetChatDetailScrollOffset}
119
+ contentWidth={contentWidth}
120
+ bodyLines={bodyLines}
121
+ paneWidth={paneWidth}
122
+ />
123
+ ) : (
124
+ <SpanContentView
125
+ span={selectedSpan}
126
+ contentWidth={contentWidth}
127
+ bodyLines={bodyLines}
128
+ paneWidth={paneWidth}
129
+ selectedAttrIndex={selectedAttrIndex}
130
+ />
131
+ )
132
+
133
+ interface ServiceLogsSceneProps {
134
+ readonly selectedTraceService: string | null
135
+ readonly serviceLogState: ServiceLogState
136
+ readonly selectedServiceLogIndex: number
137
+ readonly setSelectedServiceLogIndex: (value: number | ((current: number) => number)) => void
138
+ readonly headerFooterWidth: number
139
+ readonly availableContentHeight: number
140
+ }
141
+
142
+ const ServiceLogsScene = ({
143
+ selectedTraceService,
144
+ serviceLogState,
145
+ selectedServiceLogIndex,
146
+ setSelectedServiceLogIndex,
147
+ headerFooterWidth,
148
+ availableContentHeight,
149
+ }: ServiceLogsSceneProps) => (
150
+ <box flexGrow={1} flexDirection="column" paddingLeft={1} paddingRight={1}>
151
+ <AlignedHeaderLine
152
+ left="SERVICE LOGS"
153
+ right={`${serviceLogState.data.length} logs${serviceLogState.fetchedAt ? `${SEPARATOR}${formatShortDate(serviceLogState.fetchedAt)} ${formatTimestamp(serviceLogState.fetchedAt)}` : ""}`}
154
+ width={headerFooterWidth}
155
+ rightFg={colors.count}
156
+ />
157
+ <TextLine>
158
+ <span fg={colors.defaultService}>{selectedTraceService ?? "unknown"}</span>
159
+ <span fg={colors.separator}>{SEPARATOR}</span>
160
+ <span fg={colors.count}>recent logs</span>
161
+ </TextLine>
162
+ <BlankRow />
163
+ <ServiceLogsView
164
+ serviceName={selectedTraceService}
165
+ logsState={serviceLogState}
166
+ selectedIndex={selectedServiceLogIndex}
167
+ onSelectLog={setSelectedServiceLogIndex}
168
+ contentWidth={headerFooterWidth}
169
+ bodyLines={Math.max(8, availableContentHeight - 3)}
170
+ />
171
+ </box>
172
+ )
173
+
174
+ interface NarrowDrillInHeaderProps {
175
+ readonly contentWidth: number
176
+ readonly viewLevel: 0 | 1 | 2
177
+ readonly selectedTraceSummary: TraceSummaryItem | null
178
+ readonly selectedSpan: TraceItem["spans"][number] | null
179
+ }
180
+
181
+ const NarrowDrillInHeader = ({ contentWidth, viewLevel, selectedTraceSummary, selectedSpan }: NarrowDrillInHeaderProps) => (
182
+ <>
183
+ <box paddingLeft={1} paddingRight={1} height={1} flexDirection="column">
184
+ <TextLine>
185
+ <span fg={colors.muted}>TRACES</span>
186
+ {selectedTraceSummary ? (
187
+ <>
188
+ <span fg={colors.separator}>{" "}{SEPARATOR}{" "}</span>
189
+ <span fg={viewLevel === 1 ? colors.accent : colors.muted}>{selectedTraceSummary.rootOperationName}</span>
190
+ </>
191
+ ) : null}
192
+ {viewLevel === 2 && selectedSpan ? (
193
+ <>
194
+ <span fg={colors.separator}>{" "}{SEPARATOR}{" "}</span>
195
+ <span fg={colors.accent}>{selectedSpan.operationName}</span>
196
+ </>
197
+ ) : null}
198
+ </TextLine>
199
+ </box>
200
+ <Divider width={contentWidth} />
201
+ </>
202
+ )
203
+
16
204
  interface TraceWorkspaceProps {
17
205
  readonly layout: AppLayout
18
206
  readonly detailView: DetailView
@@ -34,6 +222,16 @@ interface TraceWorkspaceProps {
34
222
  readonly viewLevel: 0 | 1 | 2
35
223
  readonly selectedSpan: TraceItem["spans"][number] | null
36
224
  readonly selectedSpanLogs: readonly LogItem[]
225
+ readonly selectedAttrIndex: number
226
+ readonly aiCallDetailState: AiCallDetailState
227
+ readonly aiChatChunks: readonly Chunk[]
228
+ readonly selectedChatChunkId: string | null
229
+ readonly onSelectChatChunk: (chunkId: string) => void
230
+ readonly chatDetailChunkId: string | null
231
+ readonly onOpenChatChunkDetail: (chunkId: string) => void
232
+ readonly onCloseChatChunkDetail: () => void
233
+ readonly chatDetailScrollOffset: number
234
+ readonly onSetChatDetailScrollOffset: (updater: (current: number) => number) => void
37
235
  readonly selectSpan: (index: number) => void
38
236
  }
39
237
 
@@ -58,8 +256,19 @@ export const TraceWorkspace = ({
58
256
  viewLevel,
59
257
  selectedSpan,
60
258
  selectedSpanLogs,
259
+ selectedAttrIndex,
260
+ aiCallDetailState,
261
+ aiChatChunks,
262
+ selectedChatChunkId,
263
+ onSelectChatChunk,
264
+ chatDetailChunkId,
265
+ onOpenChatChunkDetail,
266
+ onCloseChatChunkDetail,
267
+ chatDetailScrollOffset,
268
+ onSetChatDetailScrollOffset,
61
269
  selectSpan,
62
270
  }: TraceWorkspaceProps) => {
271
+ const aiDrillIn = selectedSpan !== null && isAiSpan(selectedSpan.tags)
63
272
  const {
64
273
  contentWidth,
65
274
  headerFooterWidth,
@@ -78,36 +287,34 @@ export const TraceWorkspace = ({
78
287
  narrowTraceListBodyHeight,
79
288
  availableContentHeight,
80
289
  } = layout
290
+ const traceDetailsProps: SharedTraceDetailsProps = {
291
+ trace: selectedTrace,
292
+ traceSummary: selectedTraceSummary,
293
+ traceStatus: traceDetailState.status,
294
+ traceError: traceDetailState.error,
295
+ traceLogsState: logState,
296
+ selectedSpanIndex,
297
+ collapsedSpanIds,
298
+ waterfallFilterMode,
299
+ waterfallFilterText,
300
+ onSelectSpan: selectSpan,
301
+ }
302
+ const drillInContentWidth = Math.max(24, contentWidth - 2)
81
303
 
82
304
  if (detailView === "service-logs") {
83
305
  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>
306
+ <ServiceLogsScene
307
+ selectedTraceService={selectedTraceService}
308
+ serviceLogState={serviceLogState}
309
+ selectedServiceLogIndex={selectedServiceLogIndex}
310
+ setSelectedServiceLogIndex={setSelectedServiceLogIndex}
311
+ headerFooterWidth={headerFooterWidth}
312
+ availableContentHeight={availableContentHeight}
313
+ />
106
314
  )
107
315
  }
108
316
 
109
317
  if (isWideLayout) {
110
- // L0: list (left) + trace preview (right). The two-pane zoom.
111
318
  if (viewLevel === 0) {
112
319
  return (
113
320
  <box flexGrow={1} flexDirection="row">
@@ -124,83 +331,65 @@ export const TraceWorkspace = ({
124
331
  </box>
125
332
  <SeparatorColumn height={wideBodyHeight} junctionChars={separatorJunctionChars} />
126
333
  <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}
334
+ <TraceDetailsScene
335
+ {...traceDetailsProps}
133
336
  contentWidth={rightContentWidth}
134
337
  bodyLines={wideBodyLines}
135
338
  paneWidth={rightPaneWidth}
136
- selectedSpanIndex={selectedSpanIndex}
137
- collapsedSpanIds={collapsedSpanIds}
138
339
  focused={false}
139
- waterfallFilterMode={waterfallFilterMode} waterfallFilterText={waterfallFilterText} onSelectSpan={selectSpan}
140
340
  />
141
341
  </box>
142
342
  </box>
143
343
  )
144
344
  }
145
345
 
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
346
  if (viewLevel === 1) {
151
347
  return (
152
348
  <box flexGrow={1} flexDirection="row">
153
349
  <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)}
350
+ <TraceDetailsScene
351
+ {...traceDetailsProps}
352
+ contentWidth={leftContentWidth}
161
353
  bodyLines={wideBodyLines}
162
354
  paneWidth={leftPaneWidth}
163
- selectedSpanIndex={selectedSpanIndex}
164
- collapsedSpanIds={collapsedSpanIds}
165
355
  focused={true}
166
- waterfallFilterMode={waterfallFilterMode} waterfallFilterText={waterfallFilterText} onSelectSpan={selectSpan}
356
+ />
357
+ </box>
358
+ <SeparatorColumn height={wideBodyHeight} junctionChars={separatorCrossChars} />
359
+ <box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
360
+ <SpanDetailPane
361
+ span={selectedSpan}
362
+ trace={selectedTrace}
363
+ logs={selectedSpanLogs}
364
+ contentWidth={rightContentWidth}
365
+ bodyLines={wideBodyLines}
366
+ paneWidth={rightPaneWidth}
367
+ focused={false}
167
368
  />
168
369
  </box>
169
370
  </box>
170
371
  )
171
372
  }
172
373
 
173
- // L2: waterfall-left + span detail-right. Still no list.
174
374
  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>
375
+ <box flexGrow={1} flexDirection="column">
376
+ <SpanDrillInScene
377
+ aiDrillIn={aiDrillIn}
378
+ selectedSpan={selectedSpan}
379
+ aiCallDetailState={aiCallDetailState}
380
+ aiChatChunks={aiChatChunks}
381
+ selectedChatChunkId={selectedChatChunkId}
382
+ onSelectChatChunk={onSelectChatChunk}
383
+ chatDetailChunkId={chatDetailChunkId}
384
+ onOpenChatChunkDetail={onOpenChatChunkDetail}
385
+ onCloseChatChunkDetail={onCloseChatChunkDetail}
386
+ chatDetailScrollOffset={chatDetailScrollOffset}
387
+ onSetChatDetailScrollOffset={onSetChatDetailScrollOffset}
388
+ contentWidth={drillInContentWidth}
389
+ bodyLines={wideBodyLines}
390
+ paneWidth={contentWidth}
391
+ selectedAttrIndex={selectedAttrIndex}
392
+ />
204
393
  </box>
205
394
  )
206
395
  }
@@ -218,19 +407,12 @@ export const TraceWorkspace = ({
218
407
  padding={sectionPadding}
219
408
  />
220
409
  <Divider width={contentWidth} />
221
- <TraceDetailsPane
222
- trace={selectedTrace}
223
- traceSummary={selectedTraceSummary}
224
- traceStatus={traceDetailState.status}
225
- traceError={traceDetailState.error}
226
- traceLogsState={logState}
410
+ <TraceDetailsScene
411
+ {...traceDetailsProps}
227
412
  contentWidth={rightContentWidth}
228
413
  bodyLines={narrowBodyLines}
229
414
  paneWidth={contentWidth}
230
- selectedSpanIndex={selectedSpanIndex}
231
- collapsedSpanIds={collapsedSpanIds}
232
415
  focused={false}
233
- waterfallFilterMode={waterfallFilterMode} waterfallFilterText={waterfallFilterText} onSelectSpan={selectSpan}
234
416
  />
235
417
  </>
236
418
  )
@@ -238,48 +420,37 @@ export const TraceWorkspace = ({
238
420
 
239
421
  return (
240
422
  <>
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} />
423
+ <NarrowDrillInHeader
424
+ contentWidth={contentWidth}
425
+ viewLevel={viewLevel}
426
+ selectedTraceSummary={selectedTraceSummary}
427
+ selectedSpan={selectedSpan}
428
+ />
259
429
  {viewLevel === 1 ? (
260
- <TraceDetailsPane
261
- trace={selectedTrace}
262
- traceSummary={selectedTraceSummary}
263
- traceStatus={traceDetailState.status}
264
- traceError={traceDetailState.error}
265
- traceLogsState={logState}
430
+ <TraceDetailsScene
431
+ {...traceDetailsProps}
266
432
  contentWidth={rightContentWidth}
267
433
  bodyLines={narrowFullBodyLines}
268
434
  paneWidth={contentWidth}
269
- selectedSpanIndex={selectedSpanIndex}
270
- collapsedSpanIds={collapsedSpanIds}
271
435
  focused={true}
272
- waterfallFilterMode={waterfallFilterMode} waterfallFilterText={waterfallFilterText} onSelectSpan={selectSpan}
273
436
  />
274
437
  ) : (
275
- <SpanDetailPane
276
- span={selectedSpan}
277
- trace={selectedTrace}
278
- logs={selectedSpanLogs}
279
- contentWidth={rightContentWidth}
438
+ <SpanDrillInScene
439
+ aiDrillIn={aiDrillIn}
440
+ selectedSpan={selectedSpan}
441
+ aiCallDetailState={aiCallDetailState}
442
+ aiChatChunks={aiChatChunks}
443
+ selectedChatChunkId={selectedChatChunkId}
444
+ onSelectChatChunk={onSelectChatChunk}
445
+ chatDetailChunkId={chatDetailChunkId}
446
+ onOpenChatChunkDetail={onOpenChatChunkDetail}
447
+ onCloseChatChunkDetail={onCloseChatChunkDetail}
448
+ chatDetailScrollOffset={chatDetailScrollOffset}
449
+ onSetChatDetailScrollOffset={onSetChatDetailScrollOffset}
450
+ contentWidth={drillInContentWidth}
280
451
  bodyLines={narrowFullBodyLines}
281
452
  paneWidth={contentWidth}
282
- focused={true}
453
+ selectedAttrIndex={selectedAttrIndex}
283
454
  />
284
455
  )}
285
456
  </>
@@ -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)