@aryasaatvik/motel 0.3.0

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 (99) hide show
  1. package/AGENTS.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +137 -0
  4. package/package.json +119 -0
  5. package/skills/motel-debug/SKILL.md +203 -0
  6. package/skills/motel-debug/references/effect.md +38 -0
  7. package/src/App.tsx +414 -0
  8. package/src/StartupGate.test.ts +80 -0
  9. package/src/StartupGate.tsx +263 -0
  10. package/src/cli.ts +205 -0
  11. package/src/config.ts +45 -0
  12. package/src/daemon.test.ts +547 -0
  13. package/src/daemon.ts +486 -0
  14. package/src/domain.test.ts +62 -0
  15. package/src/domain.ts +291 -0
  16. package/src/httpApi.ts +391 -0
  17. package/src/httpListPolicy.test.ts +76 -0
  18. package/src/httpListPolicy.ts +129 -0
  19. package/src/index.tsx +25 -0
  20. package/src/instructions.ts +72 -0
  21. package/src/launchAgent.test.ts +430 -0
  22. package/src/launchAgent.ts +462 -0
  23. package/src/localServer.ts +568 -0
  24. package/src/locator.ts +138 -0
  25. package/src/mcp.ts +433 -0
  26. package/src/motel.test.ts +176 -0
  27. package/src/motel.ts +88 -0
  28. package/src/motelClient.ts +353 -0
  29. package/src/opentui-jsx.d.ts +11 -0
  30. package/src/otlp.test.ts +65 -0
  31. package/src/otlp.ts +162 -0
  32. package/src/otlpProtobuf.ts +35 -0
  33. package/src/queryFilters.ts +39 -0
  34. package/src/registry.ts +115 -0
  35. package/src/runtime.ts +40 -0
  36. package/src/server.ts +11 -0
  37. package/src/services/AsyncIngest.ts +82 -0
  38. package/src/services/LogQueryService.ts +29 -0
  39. package/src/services/TelemetryQuery.ts +62 -0
  40. package/src/services/TelemetryStore.ts +2554 -0
  41. package/src/services/TraceQueryService.ts +43 -0
  42. package/src/services/ingestRpc.ts +39 -0
  43. package/src/services/queryRpc.ts +15 -0
  44. package/src/services/telemetryQueryWorker.ts +32 -0
  45. package/src/services/telemetryWorker.ts +59 -0
  46. package/src/startupBench.ts +19 -0
  47. package/src/startupLifecycle.ts +57 -0
  48. package/src/storybook/aiChatStory.tsx +244 -0
  49. package/src/storybook/fixtures/errorState.ts +44 -0
  50. package/src/storybook/fixtures/imagePaste.ts +34 -0
  51. package/src/storybook/fixtures/index.ts +62 -0
  52. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  53. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  54. package/src/storybook/fixtures/short.ts +27 -0
  55. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  56. package/src/telemetry.test.ts +1053 -0
  57. package/src/ui/AiChatView.tsx +308 -0
  58. package/src/ui/AttrFilterModal.tsx +120 -0
  59. package/src/ui/ServiceLogs.tsx +115 -0
  60. package/src/ui/SpanContentView.tsx +184 -0
  61. package/src/ui/SpanDetail.tsx +215 -0
  62. package/src/ui/SpanDetailFull.tsx +224 -0
  63. package/src/ui/SpanDetailPane.tsx +90 -0
  64. package/src/ui/TraceDetailsPane.tsx +175 -0
  65. package/src/ui/TraceList.tsx +254 -0
  66. package/src/ui/Waterfall.tsx +389 -0
  67. package/src/ui/aiChatModel.test.ts +391 -0
  68. package/src/ui/aiChatModel.ts +773 -0
  69. package/src/ui/aiState.ts +59 -0
  70. package/src/ui/app/TraceListPane.tsx +39 -0
  71. package/src/ui/app/TraceWorkspace.tsx +452 -0
  72. package/src/ui/app/useAppLayout.ts +101 -0
  73. package/src/ui/app/useTraceScreenData.ts +590 -0
  74. package/src/ui/atoms.ts +131 -0
  75. package/src/ui/cachedLoader.test.ts +23 -0
  76. package/src/ui/cachedLoader.ts +60 -0
  77. package/src/ui/filterParser.test.ts +56 -0
  78. package/src/ui/filterParser.ts +45 -0
  79. package/src/ui/format.ts +128 -0
  80. package/src/ui/loaders.ts +101 -0
  81. package/src/ui/persistence.ts +41 -0
  82. package/src/ui/primitives.tsx +198 -0
  83. package/src/ui/state.ts +6 -0
  84. package/src/ui/theme.ts +160 -0
  85. package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
  86. package/src/ui/traceSortNav.repro.seed.ts +62 -0
  87. package/src/ui/traceSortNav.repro.test.ts +231 -0
  88. package/src/ui/useAttrFilterPicker.ts +89 -0
  89. package/src/ui/useKeyboardNav.ts +1023 -0
  90. package/src/ui/waterfallFilter.test.ts +84 -0
  91. package/src/ui/waterfallFilter.ts +59 -0
  92. package/src/ui/waterfallModel.ts +130 -0
  93. package/src/ui/waterfallNav.repro.seed.ts +86 -0
  94. package/src/ui/waterfallNav.repro.test.ts +271 -0
  95. package/src/ui/waterfallNav.test.ts +453 -0
  96. package/src/ui/waterfallNav.ts +75 -0
  97. package/web/dist/assets/index-B01z9BaO.css +2 -0
  98. package/web/dist/assets/index-CYlBaY9O.js +22 -0
  99. package/web/dist/index.html +13 -0
@@ -0,0 +1,59 @@
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 { TelemetryStoreReadonly } from "../services/TelemetryStore.ts"
6
+ import type { LoadStatus } from "./atoms.ts"
7
+ import { makeCachedLoader } from "./cachedLoader.ts"
8
+
9
+ // AI chat view (full-screen when drilled into an `isAiSpan` span).
10
+ // ---------------------------------------------------------------------
11
+ // The main pane is a normal selectable list of semantic chunks (one row
12
+ // per chunk, with stable list scrolling). Opening a chunk shows its full
13
+ // content in a modal overlay that owns its own line scroll offset. This
14
+ // feels much closer to the rest of motel than the previous in-line
15
+ // expansion experiment.
16
+ // ---------------------------------------------------------------------
17
+ /** Chunk id currently selected in the list (null = first chunk). */
18
+ export const selectedChatChunkIdAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
19
+ /** Chunk id whose detail modal is currently open. */
20
+ export const chatDetailChunkIdAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
21
+ /** Line scroll offset inside the open detail modal. */
22
+ export const chatDetailScrollOffsetAtom = Atom.make(0).pipe(Atom.keepAlive)
23
+
24
+ export interface AiCallDetailState {
25
+ readonly status: LoadStatus
26
+ readonly spanId: string | null
27
+ readonly data: AiCallDetail | null
28
+ readonly error: string | null
29
+ }
30
+
31
+ export const initialAiCallDetailState: AiCallDetailState = {
32
+ status: "ready",
33
+ spanId: null,
34
+ data: null,
35
+ error: null,
36
+ }
37
+
38
+ export const aiCallDetailStateAtom = Atom.make(initialAiCallDetailState).pipe(Atom.keepAlive)
39
+
40
+ export const loadAiCallDetail = (spanId: string) =>
41
+ queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.getAiCall(spanId)))
42
+
43
+ // AI call detail cache: the `ai.prompt` payload can easily be 50KB+ and
44
+ // we don't want to re-hit SQLite every time j/k moves the selection
45
+ // between adjacent AI spans. Cleared alongside the other per-refresh
46
+ // caches in `useTraceScreenData`.
47
+ const aiCallDetailLoader = makeCachedLoader<string, AiCallDetail | null>({
48
+ load: loadAiCallDetail,
49
+ })
50
+
51
+ export const getCachedAiCallDetail = (spanId: string): AiCallDetail | null | undefined =>
52
+ aiCallDetailLoader.get(spanId)
53
+
54
+ export const ensureAiCallDetail = (spanId: string): Promise<AiCallDetail | null> =>
55
+ aiCallDetailLoader.ensure(spanId)
56
+
57
+ export const invalidateAiCallDetailCache = () => {
58
+ aiCallDetailLoader.invalidate()
59
+ }
@@ -0,0 +1,39 @@
1
+ import { FilterBar } from "../primitives.tsx"
2
+ import { TraceListBody, TraceListHeader, type TraceListProps } from "../TraceList.tsx"
3
+
4
+ interface TraceListPaneProps {
5
+ readonly traceListProps: TraceListProps
6
+ readonly filterMode: boolean
7
+ readonly filterText: string
8
+ readonly filterWidth: number
9
+ readonly containerHeight: number
10
+ readonly bodyHeight: number
11
+ readonly padding: number
12
+ }
13
+
14
+ /**
15
+ * Replaced the opentui <scrollbox> with a direct virtual-windowed body.
16
+ * Rationale: the scrollbox's scrollSize is updated during opentui's render
17
+ * pass, not during React commit, so the useLayoutEffect that adjusted
18
+ * scrollTop on refresh was reading a stale max and clamping our intended
19
+ * scroll position. Rendering only the visible rows ourselves keeps the
20
+ * viewport math entirely in React state and eliminates the race.
21
+ */
22
+ export const TraceListPane = ({
23
+ traceListProps,
24
+ filterMode,
25
+ filterText,
26
+ filterWidth,
27
+ containerHeight,
28
+ bodyHeight,
29
+ padding,
30
+ }: TraceListPaneProps) => {
31
+ const bodyRows = Math.max(1, filterMode ? bodyHeight - 1 : bodyHeight)
32
+ return (
33
+ <box height={containerHeight} flexDirection="column" paddingLeft={padding} paddingRight={0}>
34
+ <TraceListHeader {...traceListProps} />
35
+ {filterMode ? <FilterBar text={filterText} width={filterWidth} /> : null}
36
+ <TraceListBody {...traceListProps} viewportRows={bodyRows} />
37
+ </box>
38
+ )
39
+ }
@@ -0,0 +1,452 @@
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"
4
+ import { formatShortDate, formatTimestamp } from "../format.ts"
5
+ import { AlignedHeaderLine, BlankRow, Divider, SeparatorColumn, TextLine } from "../primitives.tsx"
6
+ import { ServiceLogsView } from "../ServiceLogs.tsx"
7
+ import { SpanContentView } from "../SpanContentView.tsx"
8
+ import { SpanDetailPane } from "../SpanDetailPane.tsx"
9
+ import type { DetailView, LogState, ServiceLogState, TraceDetailState } from "../atoms.ts"
10
+ import type { AiCallDetailState } from "../aiState.ts"
11
+ import { colors, SEPARATOR } from "../theme.ts"
12
+ import { TraceDetailsPane } from "../TraceDetailsPane.tsx"
13
+ import type { TraceListProps } from "../TraceList.tsx"
14
+ import { TraceListPane } from "./TraceListPane.tsx"
15
+ import type { AppLayout } from "./useAppLayout.ts"
16
+
17
+ const separatorJunctionChars = new Map<number, string>([[3, "├"]])
18
+ const separatorCrossChars = new Map<number, string>([[3, "┼"]])
19
+
20
+ interface SharedTraceDetailsProps {
21
+ readonly trace: TraceItem | null
22
+ readonly traceSummary: TraceSummaryItem | null
23
+ readonly traceStatus: TraceDetailState["status"]
24
+ readonly traceError: string | null
25
+ readonly traceLogCount: number
26
+ readonly selectedSpanIndex: number | null
27
+ readonly collapsedSpanIds: ReadonlySet<string>
28
+ readonly waterfallFilterMode: boolean
29
+ readonly waterfallFilterText: string
30
+ readonly onSelectSpan: (index: number) => void
31
+ }
32
+
33
+ interface TraceDetailsSceneProps extends SharedTraceDetailsProps {
34
+ readonly contentWidth: number
35
+ readonly bodyLines: number
36
+ readonly paneWidth: number
37
+ }
38
+
39
+ const TraceDetailsScene = ({
40
+ trace,
41
+ traceSummary,
42
+ traceStatus,
43
+ traceError,
44
+ traceLogCount,
45
+ contentWidth,
46
+ bodyLines,
47
+ paneWidth,
48
+ selectedSpanIndex,
49
+ collapsedSpanIds,
50
+ waterfallFilterMode,
51
+ waterfallFilterText,
52
+ onSelectSpan,
53
+ }: TraceDetailsSceneProps) => (
54
+ <TraceDetailsPane
55
+ trace={trace}
56
+ traceSummary={traceSummary}
57
+ traceStatus={traceStatus}
58
+ traceError={traceError}
59
+ traceLogCount={traceLogCount}
60
+ contentWidth={contentWidth}
61
+ bodyLines={bodyLines}
62
+ paneWidth={paneWidth}
63
+ selectedSpanIndex={selectedSpanIndex}
64
+ collapsedSpanIds={collapsedSpanIds}
65
+ waterfallFilterMode={waterfallFilterMode}
66
+ waterfallFilterText={waterfallFilterText}
67
+ onSelectSpan={onSelectSpan}
68
+ />
69
+ )
70
+
71
+ interface SpanDrillInSceneProps {
72
+ readonly aiDrillIn: boolean
73
+ readonly selectedSpan: TraceItem["spans"][number] | null
74
+ readonly aiCallDetailState: AiCallDetailState
75
+ readonly aiChatChunks: readonly Chunk[]
76
+ readonly selectedChatChunkId: string | null
77
+ readonly onSelectChatChunk: (chunkId: string) => void
78
+ readonly chatDetailChunkId: string | null
79
+ readonly onOpenChatChunkDetail: (chunkId: string) => void
80
+ readonly onCloseChatChunkDetail: () => void
81
+ readonly chatDetailScrollOffset: number
82
+ readonly onSetChatDetailScrollOffset: (updater: (current: number) => number) => void
83
+ readonly contentWidth: number
84
+ readonly bodyLines: number
85
+ readonly paneWidth: number
86
+ readonly selectedAttrIndex: number
87
+ }
88
+
89
+ const SpanDrillInScene = ({
90
+ aiDrillIn,
91
+ selectedSpan,
92
+ aiCallDetailState,
93
+ aiChatChunks,
94
+ selectedChatChunkId,
95
+ onSelectChatChunk,
96
+ chatDetailChunkId,
97
+ onOpenChatChunkDetail,
98
+ onCloseChatChunkDetail,
99
+ chatDetailScrollOffset,
100
+ onSetChatDetailScrollOffset,
101
+ contentWidth,
102
+ bodyLines,
103
+ paneWidth,
104
+ selectedAttrIndex,
105
+ }: SpanDrillInSceneProps) => aiDrillIn ? (
106
+ <AiChatView
107
+ span={selectedSpan}
108
+ detailState={aiCallDetailState}
109
+ chunks={aiChatChunks}
110
+ selectedChunkId={selectedChatChunkId}
111
+ onSelectChunk={onSelectChatChunk}
112
+ detailChunkId={chatDetailChunkId}
113
+ onOpenDetail={onOpenChatChunkDetail}
114
+ onCloseDetail={onCloseChatChunkDetail}
115
+ detailScrollOffset={chatDetailScrollOffset}
116
+ onSetDetailScrollOffset={onSetChatDetailScrollOffset}
117
+ contentWidth={contentWidth}
118
+ bodyLines={bodyLines}
119
+ paneWidth={paneWidth}
120
+ />
121
+ ) : (
122
+ <SpanContentView
123
+ span={selectedSpan}
124
+ contentWidth={contentWidth}
125
+ bodyLines={bodyLines}
126
+ paneWidth={paneWidth}
127
+ selectedAttrIndex={selectedAttrIndex}
128
+ />
129
+ )
130
+
131
+ interface ServiceLogsSceneProps {
132
+ readonly selectedTraceService: string | null
133
+ readonly serviceLogState: ServiceLogState
134
+ readonly selectedServiceLogIndex: number
135
+ readonly setSelectedServiceLogIndex: (value: number | ((current: number) => number)) => void
136
+ readonly headerFooterWidth: number
137
+ readonly availableContentHeight: number
138
+ }
139
+
140
+ const ServiceLogsScene = ({
141
+ selectedTraceService,
142
+ serviceLogState,
143
+ selectedServiceLogIndex,
144
+ setSelectedServiceLogIndex,
145
+ headerFooterWidth,
146
+ availableContentHeight,
147
+ }: ServiceLogsSceneProps) => (
148
+ <box flexGrow={1} flexDirection="column" paddingLeft={1} paddingRight={1}>
149
+ <AlignedHeaderLine
150
+ left="SERVICE LOGS"
151
+ right={`${serviceLogState.data.length} logs${serviceLogState.fetchedAt ? `${SEPARATOR}${formatShortDate(serviceLogState.fetchedAt)} ${formatTimestamp(serviceLogState.fetchedAt)}` : ""}`}
152
+ width={headerFooterWidth}
153
+ rightFg={colors.count}
154
+ />
155
+ <TextLine>
156
+ <span fg={colors.defaultService}>{selectedTraceService ?? "unknown"}</span>
157
+ <span fg={colors.separator}>{SEPARATOR}</span>
158
+ <span fg={colors.count}>recent logs</span>
159
+ </TextLine>
160
+ <BlankRow />
161
+ <ServiceLogsView
162
+ serviceName={selectedTraceService}
163
+ logsState={serviceLogState}
164
+ selectedIndex={selectedServiceLogIndex}
165
+ onSelectLog={setSelectedServiceLogIndex}
166
+ contentWidth={headerFooterWidth}
167
+ bodyLines={Math.max(8, availableContentHeight - 3)}
168
+ />
169
+ </box>
170
+ )
171
+
172
+ interface NarrowDrillInHeaderProps {
173
+ readonly contentWidth: number
174
+ readonly viewLevel: 0 | 1 | 2
175
+ readonly selectedTraceSummary: TraceSummaryItem | null
176
+ readonly selectedSpan: TraceItem["spans"][number] | null
177
+ }
178
+
179
+ const NarrowDrillInHeader = ({ contentWidth, viewLevel, selectedTraceSummary, selectedSpan }: NarrowDrillInHeaderProps) => (
180
+ <>
181
+ <box paddingLeft={1} paddingRight={1} height={1} flexDirection="column">
182
+ <TextLine>
183
+ <span fg={colors.muted}>TRACES</span>
184
+ {selectedTraceSummary ? (
185
+ <>
186
+ <span fg={colors.separator}>{" "}{SEPARATOR}{" "}</span>
187
+ <span fg={viewLevel === 1 ? colors.accent : colors.muted}>{selectedTraceSummary.rootOperationName}</span>
188
+ </>
189
+ ) : null}
190
+ {viewLevel === 2 && selectedSpan ? (
191
+ <>
192
+ <span fg={colors.separator}>{" "}{SEPARATOR}{" "}</span>
193
+ <span fg={colors.accent}>{selectedSpan.operationName}</span>
194
+ </>
195
+ ) : null}
196
+ </TextLine>
197
+ </box>
198
+ <Divider width={contentWidth} />
199
+ </>
200
+ )
201
+
202
+ interface TraceWorkspaceProps {
203
+ readonly layout: AppLayout
204
+ readonly detailView: DetailView
205
+ readonly filterMode: boolean
206
+ readonly filterText: string
207
+ readonly waterfallFilterMode: boolean
208
+ readonly waterfallFilterText: string
209
+ readonly traceListProps: TraceListProps
210
+ readonly selectedTraceService: string | null
211
+ readonly serviceLogState: ServiceLogState
212
+ readonly selectedServiceLogIndex: number
213
+ readonly setSelectedServiceLogIndex: (value: number | ((current: number) => number)) => void
214
+ readonly traceDetailState: TraceDetailState
215
+ readonly selectedTrace: TraceItem | null
216
+ readonly selectedTraceSummary: TraceSummaryItem | null
217
+ readonly logState: LogState
218
+ readonly selectedSpanIndex: number | null
219
+ readonly collapsedSpanIds: ReadonlySet<string>
220
+ readonly viewLevel: 0 | 1 | 2
221
+ readonly selectedSpan: TraceItem["spans"][number] | null
222
+ readonly selectedSpanLogs: readonly LogItem[]
223
+ readonly selectedAttrIndex: number
224
+ readonly aiCallDetailState: AiCallDetailState
225
+ readonly aiChatChunks: readonly Chunk[]
226
+ readonly selectedChatChunkId: string | null
227
+ readonly onSelectChatChunk: (chunkId: string) => void
228
+ readonly chatDetailChunkId: string | null
229
+ readonly onOpenChatChunkDetail: (chunkId: string) => void
230
+ readonly onCloseChatChunkDetail: () => void
231
+ readonly chatDetailScrollOffset: number
232
+ readonly onSetChatDetailScrollOffset: (updater: (current: number) => number) => void
233
+ readonly selectSpan: (index: number) => void
234
+ }
235
+
236
+ export const TraceWorkspace = ({
237
+ layout,
238
+ detailView,
239
+ filterMode,
240
+ filterText,
241
+ waterfallFilterMode,
242
+ waterfallFilterText,
243
+ traceListProps,
244
+ selectedTraceService,
245
+ serviceLogState,
246
+ selectedServiceLogIndex,
247
+ setSelectedServiceLogIndex,
248
+ traceDetailState,
249
+ selectedTrace,
250
+ selectedTraceSummary,
251
+ logState,
252
+ selectedSpanIndex,
253
+ collapsedSpanIds,
254
+ viewLevel,
255
+ selectedSpan,
256
+ selectedSpanLogs,
257
+ selectedAttrIndex,
258
+ aiCallDetailState,
259
+ aiChatChunks,
260
+ selectedChatChunkId,
261
+ onSelectChatChunk,
262
+ chatDetailChunkId,
263
+ onOpenChatChunkDetail,
264
+ onCloseChatChunkDetail,
265
+ chatDetailScrollOffset,
266
+ onSetChatDetailScrollOffset,
267
+ selectSpan,
268
+ }: TraceWorkspaceProps) => {
269
+ const aiDrillIn = selectedSpan !== null && isAiSpan(selectedSpan.tags)
270
+ const {
271
+ contentWidth,
272
+ headerFooterWidth,
273
+ isWideLayout,
274
+ leftPaneWidth,
275
+ rightPaneWidth,
276
+ leftContentWidth,
277
+ rightContentWidth,
278
+ sectionPadding,
279
+ wideBodyHeight,
280
+ wideBodyLines,
281
+ narrowListHeight,
282
+ narrowBodyLines,
283
+ narrowFullBodyLines,
284
+ wideTraceListBodyHeight,
285
+ narrowTraceListBodyHeight,
286
+ availableContentHeight,
287
+ } = layout
288
+ const traceDetailsProps: SharedTraceDetailsProps = {
289
+ trace: selectedTrace,
290
+ traceSummary: selectedTraceSummary,
291
+ traceStatus: traceDetailState.status,
292
+ traceError: traceDetailState.error,
293
+ traceLogCount: logState.data.length,
294
+ selectedSpanIndex,
295
+ collapsedSpanIds,
296
+ waterfallFilterMode,
297
+ waterfallFilterText,
298
+ onSelectSpan: selectSpan,
299
+ }
300
+ const drillInContentWidth = Math.max(24, contentWidth - 2)
301
+
302
+ if (detailView === "service-logs") {
303
+ return (
304
+ <ServiceLogsScene
305
+ selectedTraceService={selectedTraceService}
306
+ serviceLogState={serviceLogState}
307
+ selectedServiceLogIndex={selectedServiceLogIndex}
308
+ setSelectedServiceLogIndex={setSelectedServiceLogIndex}
309
+ headerFooterWidth={headerFooterWidth}
310
+ availableContentHeight={availableContentHeight}
311
+ />
312
+ )
313
+ }
314
+
315
+ if (isWideLayout) {
316
+ if (viewLevel === 0) {
317
+ return (
318
+ <box flexGrow={1} flexDirection="row">
319
+ <box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column">
320
+ <TraceListPane
321
+ traceListProps={traceListProps}
322
+ filterMode={filterMode}
323
+ filterText={filterText}
324
+ filterWidth={leftContentWidth}
325
+ containerHeight={wideBodyHeight}
326
+ bodyHeight={wideTraceListBodyHeight}
327
+ padding={sectionPadding}
328
+ />
329
+ </box>
330
+ <SeparatorColumn height={wideBodyHeight} junctionChars={separatorJunctionChars} />
331
+ <box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
332
+ <TraceDetailsScene
333
+ {...traceDetailsProps}
334
+ contentWidth={rightContentWidth}
335
+ bodyLines={wideBodyLines}
336
+ paneWidth={rightPaneWidth}
337
+ />
338
+ </box>
339
+ </box>
340
+ )
341
+ }
342
+
343
+ if (viewLevel === 1) {
344
+ return (
345
+ <box flexGrow={1} flexDirection="row">
346
+ <box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column">
347
+ <TraceDetailsScene
348
+ {...traceDetailsProps}
349
+ contentWidth={leftContentWidth}
350
+ bodyLines={wideBodyLines}
351
+ paneWidth={leftPaneWidth}
352
+ />
353
+ </box>
354
+ <SeparatorColumn height={wideBodyHeight} junctionChars={separatorCrossChars} />
355
+ <box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
356
+ <SpanDetailPane
357
+ span={selectedSpan}
358
+ trace={selectedTrace}
359
+ logs={selectedSpanLogs}
360
+ contentWidth={rightContentWidth}
361
+ bodyLines={wideBodyLines}
362
+ paneWidth={rightPaneWidth}
363
+ focused={false}
364
+ />
365
+ </box>
366
+ </box>
367
+ )
368
+ }
369
+
370
+ return (
371
+ <box flexGrow={1} flexDirection="column">
372
+ <SpanDrillInScene
373
+ aiDrillIn={aiDrillIn}
374
+ selectedSpan={selectedSpan}
375
+ aiCallDetailState={aiCallDetailState}
376
+ aiChatChunks={aiChatChunks}
377
+ selectedChatChunkId={selectedChatChunkId}
378
+ onSelectChatChunk={onSelectChatChunk}
379
+ chatDetailChunkId={chatDetailChunkId}
380
+ onOpenChatChunkDetail={onOpenChatChunkDetail}
381
+ onCloseChatChunkDetail={onCloseChatChunkDetail}
382
+ chatDetailScrollOffset={chatDetailScrollOffset}
383
+ onSetChatDetailScrollOffset={onSetChatDetailScrollOffset}
384
+ contentWidth={drillInContentWidth}
385
+ bodyLines={wideBodyLines}
386
+ paneWidth={contentWidth}
387
+ selectedAttrIndex={selectedAttrIndex}
388
+ />
389
+ </box>
390
+ )
391
+ }
392
+
393
+ if (viewLevel === 0) {
394
+ return (
395
+ <>
396
+ <TraceListPane
397
+ traceListProps={traceListProps}
398
+ filterMode={filterMode}
399
+ filterText={filterText}
400
+ filterWidth={leftContentWidth}
401
+ containerHeight={narrowListHeight}
402
+ bodyHeight={narrowTraceListBodyHeight}
403
+ padding={sectionPadding}
404
+ />
405
+ <Divider width={contentWidth} />
406
+ <TraceDetailsScene
407
+ {...traceDetailsProps}
408
+ contentWidth={rightContentWidth}
409
+ bodyLines={narrowBodyLines}
410
+ paneWidth={contentWidth}
411
+ />
412
+ </>
413
+ )
414
+ }
415
+
416
+ return (
417
+ <>
418
+ <NarrowDrillInHeader
419
+ contentWidth={contentWidth}
420
+ viewLevel={viewLevel}
421
+ selectedTraceSummary={selectedTraceSummary}
422
+ selectedSpan={selectedSpan}
423
+ />
424
+ {viewLevel === 1 ? (
425
+ <TraceDetailsScene
426
+ {...traceDetailsProps}
427
+ contentWidth={rightContentWidth}
428
+ bodyLines={narrowFullBodyLines}
429
+ paneWidth={contentWidth}
430
+ />
431
+ ) : (
432
+ <SpanDrillInScene
433
+ aiDrillIn={aiDrillIn}
434
+ selectedSpan={selectedSpan}
435
+ aiCallDetailState={aiCallDetailState}
436
+ aiChatChunks={aiChatChunks}
437
+ selectedChatChunkId={selectedChatChunkId}
438
+ onSelectChatChunk={onSelectChatChunk}
439
+ chatDetailChunkId={chatDetailChunkId}
440
+ onOpenChatChunkDetail={onOpenChatChunkDetail}
441
+ onCloseChatChunkDetail={onCloseChatChunkDetail}
442
+ chatDetailScrollOffset={chatDetailScrollOffset}
443
+ onSetChatDetailScrollOffset={onSetChatDetailScrollOffset}
444
+ contentWidth={drillInContentWidth}
445
+ bodyLines={narrowFullBodyLines}
446
+ paneWidth={contentWidth}
447
+ selectedAttrIndex={selectedAttrIndex}
448
+ />
449
+ )}
450
+ </>
451
+ )
452
+ }
@@ -0,0 +1,101 @@
1
+ import { useMemo } from "react"
2
+ import { fitCell } from "../format.ts"
3
+ import type { DetailView } from "../atoms.ts"
4
+
5
+ interface UseAppLayoutInput {
6
+ readonly width: number | undefined
7
+ readonly height: number | undefined
8
+ readonly notice: string | null
9
+ readonly detailView: DetailView
10
+ readonly selectedSpanIndex: number | null
11
+ }
12
+
13
+ export const useAppLayout = ({ width, height, notice, detailView, selectedSpanIndex }: UseAppLayoutInput) => useMemo(() => {
14
+ const contentWidth = Math.max(60, width ?? 100)
15
+ const isWideLayout = (width ?? 100) >= 100
16
+ const splitGap = 1
17
+ const sectionPadding = 1
18
+ const traceListHeaderHeight = 1
19
+ const footerNotice = notice ? fitCell(notice, Math.max(24, contentWidth - 2)) : null
20
+ const footerHeight = 1
21
+ const footerFrameHeight = footerHeight > 0 ? 1 + footerHeight : 0
22
+ const frameHeight = 1 + 1 + footerFrameHeight
23
+ const availableContentHeight = Math.max(10, (height ?? 24) - frameHeight)
24
+ const viewLevelForLayout: 0 | 1 | 2 =
25
+ detailView === "span-detail" ? 2 :
26
+ selectedSpanIndex !== null ? 1 :
27
+ 0
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
40
+ const listHidden = viewLevelForLayout >= 1
41
+ const leftPaneWidth = !isWideLayout
42
+ ? contentWidth
43
+ : Math.max(40, Math.floor((contentWidth - splitGap) * splitRatio))
44
+ const rightPaneWidth = !isWideLayout
45
+ ? contentWidth
46
+ : Math.max(28, contentWidth - leftPaneWidth - splitGap)
47
+ // Left pane: paddingLeft (1) + scrollbar column (1). No right padding —
48
+ // the vertical pane divider handles visual separation from the right pane.
49
+ const leftContentWidth = isWideLayout ? Math.max(24, leftPaneWidth - 2) : Math.max(24, contentWidth - sectionPadding * 2)
50
+ // Right pane: both left and right padding. Trace details and span detail
51
+ // content needs a little breathing room on the right so long op names
52
+ // and the duration column don't butt against the pane border.
53
+ const rightContentWidth = isWideLayout ? Math.max(24, rightPaneWidth - sectionPadding * 2) : Math.max(24, contentWidth - sectionPadding * 2)
54
+ const headerFooterWidth = Math.max(24, contentWidth - 2)
55
+ const wideBodyHeight = availableContentHeight
56
+ // TraceDetailsPane + SpanDetailPane both reserve 4 rows for their header
57
+ // (title, op line, meta line, divider), so `bodyLines = paneHeight - 4`
58
+ // makes the pane fill its parent exactly. Using `-5` here left a visible
59
+ // blank row between the last waterfall span and the bottom divider.
60
+ const wideBodyLines = Math.max(8, wideBodyHeight - 4)
61
+ const narrowSplitHeight = Math.max(10, availableContentHeight - 1)
62
+ const narrowListHeight = Math.max(4, Math.min(10, Math.floor(narrowSplitHeight * 0.4), narrowSplitHeight - 9))
63
+ const narrowDetailHeight = narrowSplitHeight - narrowListHeight
64
+ const narrowBodyLines = Math.max(2, narrowDetailHeight - 4)
65
+ const narrowFullBodyLines = Math.max(8, availableContentHeight - 6)
66
+ const wideTraceListBodyHeight = Math.max(1, wideBodyHeight - traceListHeaderHeight)
67
+ const narrowTraceListBodyHeight = Math.max(1, narrowListHeight - traceListHeaderHeight)
68
+ const traceViewportRows = isWideLayout ? wideTraceListBodyHeight : narrowTraceListBodyHeight
69
+ const tracePageSize = Math.max(1, traceViewportRows - 1)
70
+ const spanViewportRows = Math.max(1, (isWideLayout ? wideBodyLines : narrowBodyLines) - 1)
71
+ const spanPageSize = Math.max(1, spanViewportRows - 1)
72
+
73
+ return {
74
+ contentWidth,
75
+ isWideLayout,
76
+ splitGap,
77
+ sectionPadding,
78
+ availableContentHeight,
79
+ viewLevel: viewLevelForLayout,
80
+ listHidden,
81
+ footerNotice,
82
+ footerHeight,
83
+ leftPaneWidth,
84
+ rightPaneWidth,
85
+ leftContentWidth,
86
+ rightContentWidth,
87
+ headerFooterWidth,
88
+ wideBodyHeight,
89
+ wideBodyLines,
90
+ narrowListHeight,
91
+ narrowBodyLines,
92
+ narrowFullBodyLines,
93
+ wideTraceListBodyHeight,
94
+ narrowTraceListBodyHeight,
95
+ traceViewportRows,
96
+ tracePageSize,
97
+ spanPageSize,
98
+ } as const
99
+ }, [detailView, height, notice, selectedSpanIndex, width])
100
+
101
+ export type AppLayout = ReturnType<typeof useAppLayout>