@kitlangton/motel 0.1.3 → 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 (55) hide show
  1. package/AGENTS.md +11 -1
  2. package/package.json +5 -3
  3. package/src/App.tsx +239 -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 +62 -4
  8. package/src/httpApi.ts +4 -1
  9. package/src/localServer.ts +112 -121
  10. package/src/mcp.ts +172 -0
  11. package/src/motelClient.ts +166 -14
  12. package/src/registry.ts +26 -23
  13. package/src/runtime.ts +8 -2
  14. package/src/server.ts +10 -9
  15. package/src/services/AsyncIngest.ts +52 -0
  16. package/src/services/TelemetryStore.ts +285 -27
  17. package/src/services/TraceQueryService.ts +4 -2
  18. package/src/services/ingestRpc.ts +41 -0
  19. package/src/services/telemetryWorker.ts +62 -0
  20. package/src/storybook/aiChatStory.tsx +243 -0
  21. package/src/storybook/fixtures/errorState.ts +44 -0
  22. package/src/storybook/fixtures/imagePaste.ts +34 -0
  23. package/src/storybook/fixtures/index.ts +62 -0
  24. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  25. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  26. package/src/storybook/fixtures/short.ts +27 -0
  27. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  28. package/src/telemetry.test.ts +61 -0
  29. package/src/ui/AiChatView.tsx +292 -0
  30. package/src/ui/SpanContentView.tsx +181 -0
  31. package/src/ui/SpanDetail.tsx +98 -17
  32. package/src/ui/TraceDetailsPane.tsx +35 -3
  33. package/src/ui/Waterfall.tsx +94 -167
  34. package/src/ui/aiChatModel.test.ts +347 -0
  35. package/src/ui/aiChatModel.ts +736 -0
  36. package/src/ui/aiState.ts +71 -0
  37. package/src/ui/app/TraceWorkspace.tsx +295 -120
  38. package/src/ui/app/useAppLayout.ts +14 -11
  39. package/src/ui/app/useTraceScreenData.ts +191 -35
  40. package/src/ui/atoms.ts +131 -0
  41. package/src/ui/filterParser.test.ts +56 -0
  42. package/src/ui/filterParser.ts +45 -0
  43. package/src/ui/loaders.ts +120 -0
  44. package/src/ui/persistence.ts +41 -0
  45. package/src/ui/primitives.tsx +47 -21
  46. package/src/ui/state.ts +4 -169
  47. package/src/ui/useAttrFilterPicker.ts +63 -23
  48. package/src/ui/useKeyboardNav.ts +576 -300
  49. package/src/ui/waterfallFilter.test.ts +84 -0
  50. package/src/ui/waterfallFilter.ts +59 -0
  51. package/src/ui/waterfallModel.ts +130 -0
  52. package/src/ui/waterfallNav.test.ts +17 -1
  53. package/src/ui/waterfallNav.ts +1 -1
  54. package/web/dist/assets/{index-DKinj-OE.js → index-DnyVo03x.js} +1 -1
  55. package/web/dist/index.html +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,11 +16,198 @@ 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
19
207
  readonly filterMode: boolean
20
208
  readonly filterText: string
209
+ readonly waterfallFilterMode: boolean
210
+ readonly waterfallFilterText: string
21
211
  readonly traceListProps: TraceListProps
22
212
  readonly selectedTraceService: string | null
23
213
  readonly serviceLogState: ServiceLogState
@@ -32,6 +222,16 @@ interface TraceWorkspaceProps {
32
222
  readonly viewLevel: 0 | 1 | 2
33
223
  readonly selectedSpan: TraceItem["spans"][number] | null
34
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
35
235
  readonly selectSpan: (index: number) => void
36
236
  }
37
237
 
@@ -40,6 +240,8 @@ export const TraceWorkspace = ({
40
240
  detailView,
41
241
  filterMode,
42
242
  filterText,
243
+ waterfallFilterMode,
244
+ waterfallFilterText,
43
245
  traceListProps,
44
246
  selectedTraceService,
45
247
  serviceLogState,
@@ -54,8 +256,19 @@ export const TraceWorkspace = ({
54
256
  viewLevel,
55
257
  selectedSpan,
56
258
  selectedSpanLogs,
259
+ selectedAttrIndex,
260
+ aiCallDetailState,
261
+ aiChatChunks,
262
+ selectedChatChunkId,
263
+ onSelectChatChunk,
264
+ chatDetailChunkId,
265
+ onOpenChatChunkDetail,
266
+ onCloseChatChunkDetail,
267
+ chatDetailScrollOffset,
268
+ onSetChatDetailScrollOffset,
57
269
  selectSpan,
58
270
  }: TraceWorkspaceProps) => {
271
+ const aiDrillIn = selectedSpan !== null && isAiSpan(selectedSpan.tags)
59
272
  const {
60
273
  contentWidth,
61
274
  headerFooterWidth,
@@ -74,36 +287,34 @@ export const TraceWorkspace = ({
74
287
  narrowTraceListBodyHeight,
75
288
  availableContentHeight,
76
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)
77
303
 
78
304
  if (detailView === "service-logs") {
79
305
  return (
80
- <box flexGrow={1} flexDirection="column" paddingLeft={1} paddingRight={1}>
81
- <AlignedHeaderLine
82
- left="SERVICE LOGS"
83
- right={`${serviceLogState.data.length} logs${serviceLogState.fetchedAt ? `${SEPARATOR}${formatShortDate(serviceLogState.fetchedAt)} ${formatTimestamp(serviceLogState.fetchedAt)}` : ""}`}
84
- width={headerFooterWidth}
85
- rightFg={colors.count}
86
- />
87
- <TextLine>
88
- <span fg={colors.defaultService}>{selectedTraceService ?? "unknown"}</span>
89
- <span fg={colors.separator}>{SEPARATOR}</span>
90
- <span fg={colors.count}>recent logs</span>
91
- </TextLine>
92
- <BlankRow />
93
- <ServiceLogsView
94
- serviceName={selectedTraceService}
95
- logsState={serviceLogState}
96
- selectedIndex={selectedServiceLogIndex}
97
- onSelectLog={setSelectedServiceLogIndex}
98
- contentWidth={headerFooterWidth}
99
- bodyLines={Math.max(8, availableContentHeight - 3)}
100
- />
101
- </box>
306
+ <ServiceLogsScene
307
+ selectedTraceService={selectedTraceService}
308
+ serviceLogState={serviceLogState}
309
+ selectedServiceLogIndex={selectedServiceLogIndex}
310
+ setSelectedServiceLogIndex={setSelectedServiceLogIndex}
311
+ headerFooterWidth={headerFooterWidth}
312
+ availableContentHeight={availableContentHeight}
313
+ />
102
314
  )
103
315
  }
104
316
 
105
317
  if (isWideLayout) {
106
- // L0: list (left) + trace preview (right). The two-pane zoom.
107
318
  if (viewLevel === 0) {
108
319
  return (
109
320
  <box flexGrow={1} flexDirection="row">
@@ -120,83 +331,65 @@ export const TraceWorkspace = ({
120
331
  </box>
121
332
  <SeparatorColumn height={wideBodyHeight} junctionChars={separatorJunctionChars} />
122
333
  <box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
123
- <TraceDetailsPane
124
- trace={selectedTrace}
125
- traceSummary={selectedTraceSummary}
126
- traceStatus={traceDetailState.status}
127
- traceError={traceDetailState.error}
128
- traceLogsState={logState}
334
+ <TraceDetailsScene
335
+ {...traceDetailsProps}
129
336
  contentWidth={rightContentWidth}
130
337
  bodyLines={wideBodyLines}
131
338
  paneWidth={rightPaneWidth}
132
- selectedSpanIndex={selectedSpanIndex}
133
- collapsedSpanIds={collapsedSpanIds}
134
339
  focused={false}
135
- onSelectSpan={selectSpan}
136
340
  />
137
341
  </box>
138
342
  </box>
139
343
  )
140
344
  }
141
345
 
142
- // L1: the user pressed enter on a trace — hide the list entirely and
143
- // let the waterfall take the full width. `leftPaneWidth` is already
144
- // `contentWidth` in this case (see useAppLayout), so one pane fills
145
- // the row.
146
346
  if (viewLevel === 1) {
147
347
  return (
148
348
  <box flexGrow={1} flexDirection="row">
149
349
  <box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column">
150
- <TraceDetailsPane
151
- trace={selectedTrace}
152
- traceSummary={selectedTraceSummary}
153
- traceStatus={traceDetailState.status}
154
- traceError={traceDetailState.error}
155
- traceLogsState={logState}
156
- contentWidth={Math.max(24, leftPaneWidth - sectionPadding * 2)}
350
+ <TraceDetailsScene
351
+ {...traceDetailsProps}
352
+ contentWidth={leftContentWidth}
157
353
  bodyLines={wideBodyLines}
158
354
  paneWidth={leftPaneWidth}
159
- selectedSpanIndex={selectedSpanIndex}
160
- collapsedSpanIds={collapsedSpanIds}
161
355
  focused={true}
162
- 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}
163
368
  />
164
369
  </box>
165
370
  </box>
166
371
  )
167
372
  }
168
373
 
169
- // L2: waterfall-left + span detail-right. Still no list.
170
374
  return (
171
- <box flexGrow={1} flexDirection="row">
172
- <box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column">
173
- <TraceDetailsPane
174
- trace={selectedTrace}
175
- traceSummary={selectedTraceSummary}
176
- traceStatus={traceDetailState.status}
177
- traceError={traceDetailState.error}
178
- traceLogsState={logState}
179
- contentWidth={leftContentWidth}
180
- bodyLines={wideBodyLines}
181
- paneWidth={leftPaneWidth}
182
- selectedSpanIndex={selectedSpanIndex}
183
- collapsedSpanIds={collapsedSpanIds}
184
- focused={false}
185
- onSelectSpan={selectSpan}
186
- />
187
- </box>
188
- <SeparatorColumn height={wideBodyHeight} junctionChars={separatorCrossChars} />
189
- <box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
190
- <SpanDetailPane
191
- span={selectedSpan}
192
- trace={selectedTrace}
193
- logs={selectedSpanLogs}
194
- contentWidth={rightContentWidth}
195
- bodyLines={wideBodyLines}
196
- paneWidth={rightPaneWidth}
197
- focused={true}
198
- />
199
- </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
+ />
200
393
  </box>
201
394
  )
202
395
  }
@@ -214,19 +407,12 @@ export const TraceWorkspace = ({
214
407
  padding={sectionPadding}
215
408
  />
216
409
  <Divider width={contentWidth} />
217
- <TraceDetailsPane
218
- trace={selectedTrace}
219
- traceSummary={selectedTraceSummary}
220
- traceStatus={traceDetailState.status}
221
- traceError={traceDetailState.error}
222
- traceLogsState={logState}
410
+ <TraceDetailsScene
411
+ {...traceDetailsProps}
223
412
  contentWidth={rightContentWidth}
224
413
  bodyLines={narrowBodyLines}
225
414
  paneWidth={contentWidth}
226
- selectedSpanIndex={selectedSpanIndex}
227
- collapsedSpanIds={collapsedSpanIds}
228
415
  focused={false}
229
- onSelectSpan={selectSpan}
230
416
  />
231
417
  </>
232
418
  )
@@ -234,48 +420,37 @@ export const TraceWorkspace = ({
234
420
 
235
421
  return (
236
422
  <>
237
- <box paddingLeft={1} paddingRight={1} height={1} flexDirection="column">
238
- <TextLine>
239
- <span fg={colors.muted}>TRACES</span>
240
- {selectedTraceSummary ? (
241
- <>
242
- <span fg={colors.separator}>{" "}{SEPARATOR}{" "}</span>
243
- <span fg={viewLevel === 1 ? colors.accent : colors.muted}>{selectedTraceSummary.rootOperationName}</span>
244
- </>
245
- ) : null}
246
- {viewLevel === 2 && selectedSpan ? (
247
- <>
248
- <span fg={colors.separator}>{" "}{SEPARATOR}{" "}</span>
249
- <span fg={colors.accent}>{selectedSpan.operationName}</span>
250
- </>
251
- ) : null}
252
- </TextLine>
253
- </box>
254
- <Divider width={contentWidth} />
423
+ <NarrowDrillInHeader
424
+ contentWidth={contentWidth}
425
+ viewLevel={viewLevel}
426
+ selectedTraceSummary={selectedTraceSummary}
427
+ selectedSpan={selectedSpan}
428
+ />
255
429
  {viewLevel === 1 ? (
256
- <TraceDetailsPane
257
- trace={selectedTrace}
258
- traceSummary={selectedTraceSummary}
259
- traceStatus={traceDetailState.status}
260
- traceError={traceDetailState.error}
261
- traceLogsState={logState}
430
+ <TraceDetailsScene
431
+ {...traceDetailsProps}
262
432
  contentWidth={rightContentWidth}
263
433
  bodyLines={narrowFullBodyLines}
264
434
  paneWidth={contentWidth}
265
- selectedSpanIndex={selectedSpanIndex}
266
- collapsedSpanIds={collapsedSpanIds}
267
435
  focused={true}
268
- onSelectSpan={selectSpan}
269
436
  />
270
437
  ) : (
271
- <SpanDetailPane
272
- span={selectedSpan}
273
- trace={selectedTrace}
274
- logs={selectedSpanLogs}
275
- 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}
276
451
  bodyLines={narrowFullBodyLines}
277
452
  paneWidth={contentWidth}
278
- focused={true}
453
+ selectedAttrIndex={selectedAttrIndex}
279
454
  />
280
455
  )}
281
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)