@kitlangton/motel 0.2.1 → 0.2.5
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.
- package/AGENTS.md +12 -0
- package/package.json +7 -3
- package/src/App.tsx +10 -1
- package/src/StartupGate.tsx +291 -0
- package/src/daemon.test.ts +70 -0
- package/src/daemon.ts +67 -35
- package/src/index.tsx +9 -2
- package/src/localServer.ts +29 -23
- package/src/motel.ts +0 -2
- package/src/services/AsyncIngest.ts +22 -4
- package/src/services/LogQueryService.ts +2 -2
- package/src/services/TelemetryStore.ts +311 -162
- package/src/services/TraceQueryService.ts +6 -6
- package/src/startupBench.ts +19 -0
- package/src/storybook/aiChatStory.tsx +1 -0
- package/src/ui/AiChatView.tsx +25 -9
- package/src/ui/TraceDetailsPane.tsx +9 -27
- package/src/ui/Waterfall.tsx +5 -10
- package/src/ui/aiChatModel.test.ts +44 -0
- package/src/ui/aiChatModel.ts +38 -1
- package/src/ui/app/TraceWorkspace.tsx +4 -11
- package/src/ui/app/useTraceScreenData.ts +15 -7
- package/src/ui/atoms.ts +1 -1
- package/src/ui/persistence.ts +3 -3
- package/src/ui/theme.ts +7 -5
- package/src/ui/useKeyboardNav.ts +28 -2
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const EPOCH_KEY = "__motelStartupEpoch"
|
|
2
|
+
|
|
3
|
+
type GlobalWithStartupEpoch = typeof globalThis & {
|
|
4
|
+
[EPOCH_KEY]?: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const globalWithEpoch = globalThis as GlobalWithStartupEpoch
|
|
8
|
+
|
|
9
|
+
if (globalWithEpoch[EPOCH_KEY] === undefined) {
|
|
10
|
+
globalWithEpoch[EPOCH_KEY] = performance.now()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const startupBenchEnabled = process.env.MOTEL_BENCH_STARTUP_PHASES === "1"
|
|
14
|
+
|
|
15
|
+
export const startupBenchMark = (phase: string) => {
|
|
16
|
+
if (!startupBenchEnabled) return
|
|
17
|
+
const elapsedMs = performance.now() - (globalWithEpoch[EPOCH_KEY] ?? performance.now())
|
|
18
|
+
process.stderr.write(`[motel-startup] ${phase} ${elapsedMs.toFixed(3)}ms\n`)
|
|
19
|
+
}
|
|
@@ -212,6 +212,7 @@ const StoryApp = () => {
|
|
|
212
212
|
bodyLines={bodyLines}
|
|
213
213
|
paneWidth={w}
|
|
214
214
|
/>
|
|
215
|
+
<Divider width={contentWidth} />
|
|
215
216
|
<box paddingLeft={1} paddingRight={1} height={FOOTER_ROWS}>
|
|
216
217
|
<TextLine>
|
|
217
218
|
<span fg={colors.count} attributes={TextAttributes.BOLD}>1-9</span>
|
package/src/ui/AiChatView.tsx
CHANGED
|
@@ -48,6 +48,16 @@ const rowTextColor = (chunk: Chunk | null, role: Role, selected: boolean): strin
|
|
|
48
48
|
return colors.text
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
const splitToolRowText = (text: string): { readonly head: string; readonly tail: string | null } => {
|
|
52
|
+
const match = text.match(/\s{2,}/)
|
|
53
|
+
const sep = match?.index ?? -1
|
|
54
|
+
if (sep < 0) return { head: text, tail: null }
|
|
55
|
+
return {
|
|
56
|
+
head: text.slice(0, sep),
|
|
57
|
+
tail: text.slice(sep + match![0].length),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
51
61
|
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
|
|
52
62
|
|
|
53
63
|
const chunkRows = (rows: readonly ChatListRow[]) => rows.filter((row) => row.kind === "chunk")
|
|
@@ -151,15 +161,16 @@ export const AiChatView = ({
|
|
|
151
161
|
}) => {
|
|
152
162
|
const rows = useMemo(() => buildChatListRows(chunks), [chunks])
|
|
153
163
|
const selectable = useMemo(() => chunkRows(rows), [rows])
|
|
164
|
+
const chunkById = useMemo(() => new Map(chunks.map((chunk) => [chunk.id, chunk] as const)), [chunks])
|
|
165
|
+
const selectedOrdinal = useMemo(
|
|
166
|
+
() => selectedChunkId ? selectable.findIndex((row) => row.chunkId === selectedChunkId) : -1,
|
|
167
|
+
[selectable, selectedChunkId],
|
|
168
|
+
)
|
|
154
169
|
const [scrollOffset, setScrollOffset] = useState(0)
|
|
155
170
|
|
|
156
|
-
const selectedChunk = useMemo(
|
|
157
|
-
() => selectedChunkId ? chunks.find((chunk) => chunk.id === selectedChunkId) ?? null : null,
|
|
158
|
-
[chunks, selectedChunkId],
|
|
159
|
-
)
|
|
160
171
|
const detailChunk = useMemo(
|
|
161
|
-
() => detailChunkId ?
|
|
162
|
-
[
|
|
172
|
+
() => detailChunkId ? chunkById.get(detailChunkId) ?? null : null,
|
|
173
|
+
[chunkById, detailChunkId],
|
|
163
174
|
)
|
|
164
175
|
|
|
165
176
|
const selectedRowIndex = useMemo(
|
|
@@ -201,7 +212,7 @@ export const AiChatView = ({
|
|
|
201
212
|
const maxOffset = Math.max(0, rows.length - bodyLines)
|
|
202
213
|
const offset = clamp(scrollOffset, 0, maxOffset)
|
|
203
214
|
const visible = rows.slice(offset, offset + bodyLines)
|
|
204
|
-
const headerRight = `${operation} ${SEPARATOR} ${durationLabel} ${SEPARATOR} ${selectable.length > 0 ? `${Math.max(1,
|
|
215
|
+
const headerRight = `${operation} ${SEPARATOR} ${durationLabel} ${SEPARATOR} ${selectable.length > 0 ? `${Math.max(1, selectedOrdinal + 1)}/${selectable.length}` : "0/0"}`
|
|
205
216
|
const handleListWheel = (event: MouseScrollEvent) => {
|
|
206
217
|
if (detailChunk) return
|
|
207
218
|
const delta = scrollDelta(event)
|
|
@@ -257,18 +268,23 @@ export const AiChatView = ({
|
|
|
257
268
|
</TextLine>
|
|
258
269
|
)
|
|
259
270
|
}
|
|
260
|
-
const chunk =
|
|
271
|
+
const chunk = row.chunkId ? chunkById.get(row.chunkId) ?? null : null
|
|
261
272
|
const isSelected = row.chunkId === selectedChunkId
|
|
262
273
|
const prefix = rowPrefix(chunk)
|
|
263
274
|
const meta = row.meta ?? ""
|
|
264
275
|
const textWidth = Math.max(8, contentWidth - prefix.length - meta.length - 4)
|
|
265
276
|
const display = truncateText(row.text, textWidth)
|
|
266
277
|
const gap = Math.max(1, contentWidth - prefix.length - display.length - meta.length - 1)
|
|
278
|
+
const toolLike = chunk?.kind === "tool-call" || chunk?.kind === "tool-result"
|
|
279
|
+
const { head, tail } = toolLike ? splitToolRowText(display) : { head: display, tail: null }
|
|
280
|
+
const headColor = rowTextColor(chunk, row.role, isSelected)
|
|
281
|
+
const tailColor = isSelected ? colors.muted : colors.separator
|
|
267
282
|
return (
|
|
268
283
|
<box key={`row-${offset + i}`} height={1} onMouseDown={() => { if (row.chunkId) onSelectChunk(row.chunkId) }}>
|
|
269
284
|
<TextLine bg={isSelected ? colors.selectedBg : undefined}>
|
|
270
285
|
<span fg={isSelected ? roleColor(row.role) : colors.separator}>{isSelected ? "▎" : " "}</span>
|
|
271
|
-
<span fg={
|
|
286
|
+
<span fg={headColor} attributes={isSelected ? TextAttributes.BOLD : undefined}>{`${prefix}${head}`}</span>
|
|
287
|
+
{tail ? <span fg={tailColor}>{` ${tail}`}</span> : null}
|
|
272
288
|
{meta ? <><span fg={colors.muted}>{" ".repeat(gap)}</span><span fg={colors.muted}>{meta}</span></> : null}
|
|
273
289
|
</TextLine>
|
|
274
290
|
</box>
|
|
@@ -5,7 +5,7 @@ import { AlignedHeaderLine, Divider, FilterBar, PlainLine, TextLine } from "./pr
|
|
|
5
5
|
import { WaterfallTimeline } from "./Waterfall.tsx"
|
|
6
6
|
import { computeMatchingSpanIds } from "./waterfallFilter.ts"
|
|
7
7
|
import { getVisibleSpans } from "./waterfallModel.ts"
|
|
8
|
-
import type { LoadStatus
|
|
8
|
+
import type { LoadStatus } from "./state.ts"
|
|
9
9
|
import { colors, SEPARATOR } from "./theme.ts"
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -24,13 +24,12 @@ export const TraceDetailsPane = ({
|
|
|
24
24
|
traceSummary,
|
|
25
25
|
traceStatus,
|
|
26
26
|
traceError,
|
|
27
|
-
|
|
27
|
+
traceLogCount,
|
|
28
28
|
contentWidth,
|
|
29
29
|
bodyLines,
|
|
30
30
|
paneWidth,
|
|
31
31
|
selectedSpanIndex,
|
|
32
32
|
collapsedSpanIds,
|
|
33
|
-
focused = false,
|
|
34
33
|
onSelectSpan,
|
|
35
34
|
waterfallFilterMode,
|
|
36
35
|
waterfallFilterText,
|
|
@@ -39,13 +38,12 @@ export const TraceDetailsPane = ({
|
|
|
39
38
|
traceSummary: TraceSummaryItem | null
|
|
40
39
|
traceStatus: LoadStatus
|
|
41
40
|
traceError: string | null
|
|
42
|
-
|
|
41
|
+
traceLogCount: number
|
|
43
42
|
contentWidth: number
|
|
44
43
|
bodyLines: number
|
|
45
44
|
paneWidth: number
|
|
46
45
|
selectedSpanIndex: number | null
|
|
47
46
|
collapsedSpanIds: ReadonlySet<string>
|
|
48
|
-
focused?: boolean
|
|
49
47
|
onSelectSpan: (index: number) => void
|
|
50
48
|
waterfallFilterMode: boolean
|
|
51
49
|
waterfallFilterText: string
|
|
@@ -54,20 +52,6 @@ export const TraceDetailsPane = ({
|
|
|
54
52
|
() => trace ? getVisibleSpans(trace.spans, collapsedSpanIds) : [],
|
|
55
53
|
[trace, collapsedSpanIds],
|
|
56
54
|
)
|
|
57
|
-
const selectedSpan = selectedSpanIndex !== null ? filteredSpans[selectedSpanIndex] ?? null : null
|
|
58
|
-
const traceLogCount = traceLogsState.data.length
|
|
59
|
-
const spanLogCounts = useMemo(() => {
|
|
60
|
-
const counts = new Map<string, number>()
|
|
61
|
-
for (const log of traceLogsState.data) {
|
|
62
|
-
if (!log.spanId) continue
|
|
63
|
-
counts.set(log.spanId, (counts.get(log.spanId) ?? 0) + 1)
|
|
64
|
-
}
|
|
65
|
-
return counts
|
|
66
|
-
}, [traceLogsState.data])
|
|
67
|
-
const selectedSpanLogs = useMemo(
|
|
68
|
-
() => selectedSpan ? traceLogsState.data.filter((log) => log.spanId === selectedSpan.spanId) : [],
|
|
69
|
-
[selectedSpan, traceLogsState.data],
|
|
70
|
-
)
|
|
71
55
|
const matchingSpanIds = useMemo(
|
|
72
56
|
() => trace ? computeMatchingSpanIds(trace.spans, waterfallFilterText) : null,
|
|
73
57
|
[trace, waterfallFilterText],
|
|
@@ -144,14 +128,12 @@ export const TraceDetailsPane = ({
|
|
|
144
128
|
</box>
|
|
145
129
|
) : null}
|
|
146
130
|
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
bodyLines={waterfallBodyLines}
|
|
154
|
-
selectedSpanIndex={selectedSpanIndex}
|
|
131
|
+
<WaterfallTimeline
|
|
132
|
+
trace={trace}
|
|
133
|
+
filteredSpans={filteredSpans}
|
|
134
|
+
contentWidth={contentWidth}
|
|
135
|
+
bodyLines={waterfallBodyLines}
|
|
136
|
+
selectedSpanIndex={selectedSpanIndex}
|
|
155
137
|
collapsedSpanIds={collapsedSpanIds}
|
|
156
138
|
matchingSpanIds={matchingSpanIds}
|
|
157
139
|
onSelectSpan={onSelectSpan}
|
package/src/ui/Waterfall.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo, useLayoutEffect,
|
|
1
|
+
import { memo, useLayoutEffect, useState } from "react"
|
|
2
2
|
import { isAiSpan, type LogItem, type TraceItem, type TraceSpanItem } from "../domain.ts"
|
|
3
3
|
import { formatDuration, lifecycleLabel, splitDuration, truncateText } from "./format.ts"
|
|
4
4
|
import { BlankRow, TextLine } from "./primitives.tsx"
|
|
@@ -280,8 +280,6 @@ export const SpanPreview = ({
|
|
|
280
280
|
export const WaterfallTimeline = ({
|
|
281
281
|
trace,
|
|
282
282
|
filteredSpans,
|
|
283
|
-
spanLogCounts,
|
|
284
|
-
selectedSpanLogs,
|
|
285
283
|
contentWidth,
|
|
286
284
|
bodyLines,
|
|
287
285
|
selectedSpanIndex,
|
|
@@ -291,8 +289,6 @@ export const WaterfallTimeline = ({
|
|
|
291
289
|
}: {
|
|
292
290
|
trace: TraceItem
|
|
293
291
|
filteredSpans: readonly TraceSpanItem[]
|
|
294
|
-
spanLogCounts: ReadonlyMap<string, number>
|
|
295
|
-
selectedSpanLogs: readonly LogItem[]
|
|
296
292
|
contentWidth: number
|
|
297
293
|
bodyLines: number
|
|
298
294
|
selectedSpanIndex: number | null
|
|
@@ -320,13 +316,12 @@ export const WaterfallTimeline = ({
|
|
|
320
316
|
const viewportSize = Math.max(1, bodyLines)
|
|
321
317
|
const maxOffset = Math.max(0, filteredSpans.length - viewportSize)
|
|
322
318
|
const [scrollOffset, setScrollOffset] = useState(0)
|
|
323
|
-
const lastTraceIdRef = useRef<string | null>(null)
|
|
324
319
|
|
|
325
|
-
// Reset scroll offset when the trace changes.
|
|
326
|
-
|
|
320
|
+
// Reset scroll offset when the trace changes. Keep this out of render so
|
|
321
|
+
// a trace switch doesn't force a render-phase state update on hot paths.
|
|
322
|
+
useLayoutEffect(() => {
|
|
327
323
|
setScrollOffset(0)
|
|
328
|
-
|
|
329
|
-
}
|
|
324
|
+
}, [trace.traceId])
|
|
330
325
|
|
|
331
326
|
// Auto-follow selection: only if the selected span would be hidden
|
|
332
327
|
// by the current window, shift just enough to bring it back. Runs in
|
|
@@ -328,6 +328,50 @@ describe("buildChatListRows", () => {
|
|
|
328
328
|
expect(rows[0]!.text).toBe("hello there")
|
|
329
329
|
expect(rows[1]!.text).toContain("bash")
|
|
330
330
|
})
|
|
331
|
+
|
|
332
|
+
it("uses matching tool-call context for tool-result rows", () => {
|
|
333
|
+
const chunks = buildChunks(
|
|
334
|
+
makeDetail([
|
|
335
|
+
{
|
|
336
|
+
role: "assistant",
|
|
337
|
+
content: [
|
|
338
|
+
{ type: "tool-call", toolCallId: "tc-1", toolName: "bash", input: { command: "git status --short --branch" } },
|
|
339
|
+
],
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
role: "tool",
|
|
343
|
+
content: [
|
|
344
|
+
{ type: "tool-result", toolCallId: "tc-1", toolName: "bash", output: { type: "text", value: "## dev...origin/dev [ahead 8, behind 11]\n M src/file.ts" } },
|
|
345
|
+
],
|
|
346
|
+
},
|
|
347
|
+
]),
|
|
348
|
+
)
|
|
349
|
+
const rows = buildChatListRows(chunks).filter((r) => r.kind === "chunk")
|
|
350
|
+
expect(rows[1]!.text).toContain("bash git status --short --branch")
|
|
351
|
+
expect(rows[1]!.meta).toContain("## dev...origin/dev")
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it("shows read result rows with the originating file path inline", () => {
|
|
355
|
+
const chunks = buildChunks(
|
|
356
|
+
makeDetail([
|
|
357
|
+
{
|
|
358
|
+
role: "assistant",
|
|
359
|
+
content: [
|
|
360
|
+
{ type: "tool-call", toolCallId: "tc-2", toolName: "read", input: { filePath: "/src/formatter.ts", offset: 40, limit: 80 } },
|
|
361
|
+
],
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
role: "tool",
|
|
365
|
+
content: [
|
|
366
|
+
{ type: "tool-result", toolCallId: "tc-2", toolName: "read", output: { type: "text", value: "1: export const x = 1" } },
|
|
367
|
+
],
|
|
368
|
+
},
|
|
369
|
+
]),
|
|
370
|
+
)
|
|
371
|
+
const rows = buildChatListRows(chunks).filter((r) => r.kind === "chunk")
|
|
372
|
+
expect(rows[1]!.text).toContain("read /src/formatter.ts @40 +80")
|
|
373
|
+
expect(rows[1]!.meta).toContain("1: export const x = 1")
|
|
374
|
+
})
|
|
331
375
|
})
|
|
332
376
|
|
|
333
377
|
describe("chunkDetailTitle + renderChunkDetailLines", () => {
|
package/src/ui/aiChatModel.ts
CHANGED
|
@@ -479,6 +479,13 @@ const firstBodyLine = (body: string) => {
|
|
|
479
479
|
return line.replace(/\s+/g, " ").trim()
|
|
480
480
|
}
|
|
481
481
|
|
|
482
|
+
const stripTransportGlyph = (text: string) => text.replace(/^[→←]\s+/, "")
|
|
483
|
+
|
|
484
|
+
const toolRowPreview = (text: string, width = 40) => {
|
|
485
|
+
const compact = firstBodyLine(text)
|
|
486
|
+
return compact.length > 0 ? shorten(compact, width) : null
|
|
487
|
+
}
|
|
488
|
+
|
|
482
489
|
/**
|
|
483
490
|
* Stable list rows for the main chat pane. One role divider per turn,
|
|
484
491
|
* one selectable row per chunk. Plain text chunks use their first body
|
|
@@ -487,6 +494,10 @@ const firstBodyLine = (body: string) => {
|
|
|
487
494
|
*/
|
|
488
495
|
export const buildChatListRows = (chunks: readonly Chunk[]): readonly ChatListRow[] => {
|
|
489
496
|
const rows: ChatListRow[] = []
|
|
497
|
+
const toolCallById = new Map<string, Chunk>()
|
|
498
|
+
for (const chunk of chunks) {
|
|
499
|
+
if (chunk.kind === "tool-call" && chunk.toolCallId) toolCallById.set(chunk.toolCallId, chunk)
|
|
500
|
+
}
|
|
490
501
|
let prevRole: Role | null = null
|
|
491
502
|
let prevMessageIndex = -1
|
|
492
503
|
|
|
@@ -522,7 +533,33 @@ export const buildChatListRows = (chunks: readonly Chunk[]): readonly ChatListRo
|
|
|
522
533
|
// structured chunk headers here. Otherwise tool rows render as
|
|
523
534
|
// `→ → bash ...` and results as `← ← read ...`.
|
|
524
535
|
if (chunk.kind === "tool-call" || chunk.kind === "tool-result") {
|
|
525
|
-
text = text
|
|
536
|
+
text = stripTransportGlyph(text)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (chunk.kind === "tool-call") {
|
|
540
|
+
const preview = toolRowPreview(chunk.body)
|
|
541
|
+
// Keep row text focused on the primary action (already encoded in
|
|
542
|
+
// `header`) and use the dim right column for "there is more here"
|
|
543
|
+
// metadata only when it adds signal. For JSON-heavy tool args this
|
|
544
|
+
// is usually just noise, so we currently leave meta alone.
|
|
545
|
+
if (preview && preview !== text) {
|
|
546
|
+
meta = meta ?? null
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (chunk.kind === "tool-result") {
|
|
551
|
+
const matchingCall = chunk.toolCallId ? toolCallById.get(chunk.toolCallId) ?? null : null
|
|
552
|
+
if (matchingCall) {
|
|
553
|
+
// Carry the originating call summary into the result row so the
|
|
554
|
+
// list can answer "result of what?" without opening the modal.
|
|
555
|
+
// Example: `← bash git status --short --branch`,
|
|
556
|
+
// `← read /src/formatter.ts @40 +80`.
|
|
557
|
+
text = stripTransportGlyph(matchingCall.header)
|
|
558
|
+
}
|
|
559
|
+
const preview = toolRowPreview(chunk.body)
|
|
560
|
+
if (preview) {
|
|
561
|
+
meta = chunk.headerMeta ? `${chunk.headerMeta} · ${preview}` : preview
|
|
562
|
+
}
|
|
526
563
|
}
|
|
527
564
|
if (chunk.kind === "system") {
|
|
528
565
|
text = "prompt"
|
|
@@ -21,7 +21,7 @@ interface SharedTraceDetailsProps {
|
|
|
21
21
|
readonly traceSummary: TraceSummaryItem | null
|
|
22
22
|
readonly traceStatus: TraceDetailState["status"]
|
|
23
23
|
readonly traceError: string | null
|
|
24
|
-
readonly
|
|
24
|
+
readonly traceLogCount: number
|
|
25
25
|
readonly selectedSpanIndex: number | null
|
|
26
26
|
readonly collapsedSpanIds: ReadonlySet<string>
|
|
27
27
|
readonly waterfallFilterMode: boolean
|
|
@@ -33,7 +33,6 @@ interface TraceDetailsSceneProps extends SharedTraceDetailsProps {
|
|
|
33
33
|
readonly contentWidth: number
|
|
34
34
|
readonly bodyLines: number
|
|
35
35
|
readonly paneWidth: number
|
|
36
|
-
readonly focused: boolean
|
|
37
36
|
}
|
|
38
37
|
|
|
39
38
|
const TraceDetailsScene = ({
|
|
@@ -41,13 +40,12 @@ const TraceDetailsScene = ({
|
|
|
41
40
|
traceSummary,
|
|
42
41
|
traceStatus,
|
|
43
42
|
traceError,
|
|
44
|
-
|
|
43
|
+
traceLogCount,
|
|
45
44
|
contentWidth,
|
|
46
45
|
bodyLines,
|
|
47
46
|
paneWidth,
|
|
48
47
|
selectedSpanIndex,
|
|
49
48
|
collapsedSpanIds,
|
|
50
|
-
focused,
|
|
51
49
|
waterfallFilterMode,
|
|
52
50
|
waterfallFilterText,
|
|
53
51
|
onSelectSpan,
|
|
@@ -57,13 +55,12 @@ const TraceDetailsScene = ({
|
|
|
57
55
|
traceSummary={traceSummary}
|
|
58
56
|
traceStatus={traceStatus}
|
|
59
57
|
traceError={traceError}
|
|
60
|
-
|
|
58
|
+
traceLogCount={traceLogCount}
|
|
61
59
|
contentWidth={contentWidth}
|
|
62
60
|
bodyLines={bodyLines}
|
|
63
61
|
paneWidth={paneWidth}
|
|
64
62
|
selectedSpanIndex={selectedSpanIndex}
|
|
65
63
|
collapsedSpanIds={collapsedSpanIds}
|
|
66
|
-
focused={focused}
|
|
67
64
|
waterfallFilterMode={waterfallFilterMode}
|
|
68
65
|
waterfallFilterText={waterfallFilterText}
|
|
69
66
|
onSelectSpan={onSelectSpan}
|
|
@@ -292,7 +289,7 @@ export const TraceWorkspace = ({
|
|
|
292
289
|
traceSummary: selectedTraceSummary,
|
|
293
290
|
traceStatus: traceDetailState.status,
|
|
294
291
|
traceError: traceDetailState.error,
|
|
295
|
-
|
|
292
|
+
traceLogCount: logState.data.length,
|
|
296
293
|
selectedSpanIndex,
|
|
297
294
|
collapsedSpanIds,
|
|
298
295
|
waterfallFilterMode,
|
|
@@ -336,7 +333,6 @@ export const TraceWorkspace = ({
|
|
|
336
333
|
contentWidth={rightContentWidth}
|
|
337
334
|
bodyLines={wideBodyLines}
|
|
338
335
|
paneWidth={rightPaneWidth}
|
|
339
|
-
focused={false}
|
|
340
336
|
/>
|
|
341
337
|
</box>
|
|
342
338
|
</box>
|
|
@@ -352,7 +348,6 @@ export const TraceWorkspace = ({
|
|
|
352
348
|
contentWidth={leftContentWidth}
|
|
353
349
|
bodyLines={wideBodyLines}
|
|
354
350
|
paneWidth={leftPaneWidth}
|
|
355
|
-
focused={true}
|
|
356
351
|
/>
|
|
357
352
|
</box>
|
|
358
353
|
<SeparatorColumn height={wideBodyHeight} junctionChars={separatorCrossChars} />
|
|
@@ -412,7 +407,6 @@ export const TraceWorkspace = ({
|
|
|
412
407
|
contentWidth={rightContentWidth}
|
|
413
408
|
bodyLines={narrowBodyLines}
|
|
414
409
|
paneWidth={contentWidth}
|
|
415
|
-
focused={false}
|
|
416
410
|
/>
|
|
417
411
|
</>
|
|
418
412
|
)
|
|
@@ -432,7 +426,6 @@ export const TraceWorkspace = ({
|
|
|
432
426
|
contentWidth={rightContentWidth}
|
|
433
427
|
bodyLines={narrowFullBodyLines}
|
|
434
428
|
paneWidth={contentWidth}
|
|
435
|
-
focused={true}
|
|
436
429
|
/>
|
|
437
430
|
) : (
|
|
438
431
|
<SpanDrillInScene
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
detailViewAtom,
|
|
14
14
|
ensureAiCallDetail,
|
|
15
15
|
ensureTraceAttributeKeys,
|
|
16
|
+
ensureTraceAttributeValues,
|
|
16
17
|
filterModeAtom,
|
|
17
18
|
filterTextAtom,
|
|
18
19
|
getCachedAiCallDetail,
|
|
@@ -21,7 +22,6 @@ import {
|
|
|
21
22
|
initialServiceLogState,
|
|
22
23
|
initialTraceDetailState,
|
|
23
24
|
invalidateAiCallDetailCache,
|
|
24
|
-
invalidateFacetCaches,
|
|
25
25
|
loadFilteredTraceSummaries,
|
|
26
26
|
loadRecentTraceSummaries,
|
|
27
27
|
loadServiceLogs,
|
|
@@ -47,7 +47,7 @@ import {
|
|
|
47
47
|
import { isAiSpan } from "../../domain.ts"
|
|
48
48
|
import { buildChunks, type Chunk } from "../aiChatModel.ts"
|
|
49
49
|
import { parseFilterText } from "../filterParser.ts"
|
|
50
|
-
import { getVisibleSpans } from "../
|
|
50
|
+
import { getVisibleSpans } from "../waterfallModel.ts"
|
|
51
51
|
|
|
52
52
|
const clampSelectionIndex = (index: number, length: number) => {
|
|
53
53
|
if (length === 0) return 0
|
|
@@ -166,17 +166,25 @@ export const useTraceScreenData = () => {
|
|
|
166
166
|
serviceLogCacheRef.current.clear()
|
|
167
167
|
traceDetailInflightRef.current.clear()
|
|
168
168
|
traceLogInflightRef.current.clear()
|
|
169
|
-
invalidateFacetCaches()
|
|
170
169
|
invalidateAiCallDetailCache()
|
|
171
170
|
}, [refreshNonce])
|
|
172
171
|
|
|
173
172
|
// Pre-warm the attribute picker facet keys for the currently-selected
|
|
174
|
-
// service so pressing `f` feels instant.
|
|
175
|
-
//
|
|
173
|
+
// service so pressing `f` feels instant. Once keys are known, also
|
|
174
|
+
// prefetch the value lists for the first few visible keys so the common
|
|
175
|
+
// path of opening `f`, picking a top key, and reopening again stays
|
|
176
|
+
// near-instant. Fire-and-forget; errors are surfaced when the user
|
|
177
|
+
// actually opens the picker.
|
|
176
178
|
useEffect(() => {
|
|
177
179
|
if (!selectedTraceService) return
|
|
178
|
-
void ensureTraceAttributeKeys(selectedTraceService)
|
|
179
|
-
|
|
180
|
+
void ensureTraceAttributeKeys(selectedTraceService)
|
|
181
|
+
.then((entry) => Promise.allSettled(
|
|
182
|
+
entry.data
|
|
183
|
+
.slice(0, 6)
|
|
184
|
+
.map((row) => ensureTraceAttributeValues(selectedTraceService, row.value)),
|
|
185
|
+
))
|
|
186
|
+
.catch(() => {})
|
|
187
|
+
}, [selectedTraceService])
|
|
180
188
|
|
|
181
189
|
useEffect(() => {
|
|
182
190
|
let cancelled = false
|
package/src/ui/atoms.ts
CHANGED
|
@@ -87,7 +87,7 @@ export const selectedSpanIndexAtom = Atom.make<number | null>(null).pipe(Atom.ke
|
|
|
87
87
|
export const selectedAttrIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
88
88
|
export const detailViewAtom = Atom.make<DetailView>("waterfall").pipe(Atom.keepAlive)
|
|
89
89
|
export const showHelpAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
90
|
-
export const autoRefreshAtom = Atom.make(
|
|
90
|
+
export const autoRefreshAtom = Atom.make(true).pipe(Atom.keepAlive)
|
|
91
91
|
export const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
92
92
|
export const filterTextAtom = Atom.make("").pipe(Atom.keepAlive)
|
|
93
93
|
|
package/src/ui/persistence.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs"
|
|
2
2
|
import { dirname } from "node:path"
|
|
3
3
|
import { config } from "../config.ts"
|
|
4
|
-
import type
|
|
4
|
+
import { defaultThemeName, type ThemeName } from "./theme.ts"
|
|
5
5
|
|
|
6
6
|
const lastServicePath = `${dirname(config.otel.databasePath)}/last-service.txt`
|
|
7
7
|
|
|
@@ -26,9 +26,9 @@ const lastThemePath = `${dirname(config.otel.databasePath)}/last-theme.txt`
|
|
|
26
26
|
export const readLastTheme = (): ThemeName => {
|
|
27
27
|
try {
|
|
28
28
|
const raw = readFileSync(lastThemePath, "utf-8").trim()
|
|
29
|
-
return raw === "tokyo-night" || raw === "catppuccin" || raw === "motel-default" ? raw :
|
|
29
|
+
return raw === "tokyo-night" || raw === "catppuccin" || raw === "motel-default" ? raw : defaultThemeName
|
|
30
30
|
} catch {
|
|
31
|
-
return
|
|
31
|
+
return defaultThemeName
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
package/src/ui/theme.ts
CHANGED
|
@@ -135,13 +135,15 @@ export const themes = {
|
|
|
135
135
|
|
|
136
136
|
export type ThemeName = keyof typeof themes
|
|
137
137
|
|
|
138
|
-
export const
|
|
138
|
+
export const defaultThemeName: ThemeName = "tokyo-night"
|
|
139
139
|
|
|
140
|
-
export const
|
|
141
|
-
|
|
140
|
+
export const themeOrder: readonly ThemeName[] = ["tokyo-night", "catppuccin", "motel-default"]
|
|
141
|
+
|
|
142
|
+
export const colors: ThemeColors = { ...themes[defaultThemeName].colors }
|
|
143
|
+
export const waterfallColors: ThemeWaterfallColors = { ...themes[defaultThemeName].waterfall }
|
|
142
144
|
|
|
143
145
|
export const applyTheme = (name: ThemeName) => {
|
|
144
|
-
const theme = themes[name] ??
|
|
146
|
+
const theme = themes[name] ?? themes[defaultThemeName]
|
|
145
147
|
Object.assign(colors, theme.colors)
|
|
146
148
|
Object.assign(waterfallColors, theme.waterfall)
|
|
147
149
|
return theme
|
|
@@ -152,7 +154,7 @@ export const cycleThemeName = (current: ThemeName) => {
|
|
|
152
154
|
return themeOrder[nextIndex] ?? themeOrder[0]
|
|
153
155
|
}
|
|
154
156
|
|
|
155
|
-
export const themeLabel = (name: ThemeName) => themes[name]?.label ??
|
|
157
|
+
export const themeLabel = (name: ThemeName) => themes[name]?.label ?? themes[defaultThemeName].label
|
|
156
158
|
|
|
157
159
|
export const SEPARATOR = " \u00b7 "
|
|
158
160
|
export const G_PREFIX_TIMEOUT_MS = 500
|
package/src/ui/useKeyboardNav.ts
CHANGED
|
@@ -19,6 +19,9 @@ import {
|
|
|
19
19
|
detailViewAtom,
|
|
20
20
|
filterModeAtom,
|
|
21
21
|
filterTextAtom,
|
|
22
|
+
getCachedFacetKeys,
|
|
23
|
+
getCachedFacetValues,
|
|
24
|
+
initialAttrFacetState,
|
|
22
25
|
refreshNonceAtom,
|
|
23
26
|
selectedAttrIndexAtom,
|
|
24
27
|
selectedChatChunkIdAtom,
|
|
@@ -39,7 +42,7 @@ import { filterFacets } from "./AttrFilterModal.tsx"
|
|
|
39
42
|
import { G_PREFIX_TIMEOUT_MS } from "./theme.ts"
|
|
40
43
|
import { cycleThemeName, themeLabel } from "./theme.ts"
|
|
41
44
|
import { computeMatchingSpanIds, findAdjacentMatch } from "./waterfallFilter.ts"
|
|
42
|
-
import { getVisibleSpans } from "./
|
|
45
|
+
import { getVisibleSpans } from "./waterfallModel.ts"
|
|
43
46
|
import { resolveCollapseStep } from "./waterfallNav.ts"
|
|
44
47
|
|
|
45
48
|
/**
|
|
@@ -125,7 +128,7 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
125
128
|
const [pickerMode, setPickerMode] = useAtom(attrPickerModeAtom)
|
|
126
129
|
const [pickerInput, setPickerInput] = useAtom(attrPickerInputAtom)
|
|
127
130
|
const [pickerIndex, setPickerIndex] = useAtom(attrPickerIndexAtom)
|
|
128
|
-
const [attrFacets] = useAtom(attrFacetStateAtom)
|
|
131
|
+
const [attrFacets, setAttrFacets] = useAtom(attrFacetStateAtom)
|
|
129
132
|
const [activeAttrKey, setActiveAttrKey] = useAtom(activeAttrKeyAtom)
|
|
130
133
|
const [activeAttrValue, setActiveAttrValue] = useAtom(activeAttrValueAtom)
|
|
131
134
|
const [waterfallFilterMode, setWaterfallFilterMode] = useAtom(waterfallFilterModeAtom)
|
|
@@ -259,6 +262,26 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
259
262
|
setPickerIndex(0)
|
|
260
263
|
}
|
|
261
264
|
|
|
265
|
+
const hydrateCachedPickerKeys = (service: string | null) => {
|
|
266
|
+
if (!service) {
|
|
267
|
+
setAttrFacets(initialAttrFacetState)
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
const cached = getCachedFacetKeys(service)
|
|
271
|
+
if (!cached) return
|
|
272
|
+
setAttrFacets({ status: "ready", key: null, data: cached.data, error: null })
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const hydrateCachedPickerValues = (service: string | null, key: string | null) => {
|
|
276
|
+
if (!service || !key) {
|
|
277
|
+
setAttrFacets(initialAttrFacetState)
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
const cached = getCachedFacetValues(service, key)
|
|
281
|
+
if (!cached) return
|
|
282
|
+
setAttrFacets({ status: "ready", key, data: cached.data, error: null })
|
|
283
|
+
}
|
|
284
|
+
|
|
262
285
|
const closePicker = () => {
|
|
263
286
|
setPickerMode("off")
|
|
264
287
|
resetPicker()
|
|
@@ -587,6 +610,7 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
587
610
|
const row = rows[clampedIndex]
|
|
588
611
|
if (!row) return true
|
|
589
612
|
if (s.pickerMode === "keys") {
|
|
613
|
+
hydrateCachedPickerValues(s.selectedTraceService, row.value)
|
|
590
614
|
setActiveAttrKey(row.value)
|
|
591
615
|
setPickerMode("values")
|
|
592
616
|
resetPicker()
|
|
@@ -604,6 +628,7 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
604
628
|
return true
|
|
605
629
|
}
|
|
606
630
|
if (s.pickerMode === "values") {
|
|
631
|
+
hydrateCachedPickerKeys(s.selectedTraceService)
|
|
607
632
|
setPickerMode("keys")
|
|
608
633
|
setActiveAttrKey(null)
|
|
609
634
|
setPickerIndex(0)
|
|
@@ -857,6 +882,7 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
857
882
|
return true
|
|
858
883
|
}
|
|
859
884
|
if ((key.name === "f" || key.name === "F") && !key.ctrl && !key.meta) {
|
|
885
|
+
hydrateCachedPickerKeys(s.selectedTraceService)
|
|
860
886
|
setPickerMode("keys")
|
|
861
887
|
resetPicker()
|
|
862
888
|
setActiveAttrKey(null)
|