@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.
- package/AGENTS.md +11 -1
- package/package.json +5 -3
- package/src/App.tsx +239 -59
- package/src/daemon.test.ts +144 -7
- package/src/daemon.ts +113 -8
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +62 -4
- package/src/httpApi.ts +4 -1
- package/src/localServer.ts +112 -121
- package/src/mcp.ts +172 -0
- package/src/motelClient.ts +166 -14
- package/src/registry.ts +26 -23
- package/src/runtime.ts +8 -2
- package/src/server.ts +10 -9
- package/src/services/AsyncIngest.ts +52 -0
- package/src/services/TelemetryStore.ts +285 -27
- package/src/services/TraceQueryService.ts +4 -2
- package/src/services/ingestRpc.ts +41 -0
- package/src/services/telemetryWorker.ts +62 -0
- package/src/storybook/aiChatStory.tsx +243 -0
- package/src/storybook/fixtures/errorState.ts +44 -0
- package/src/storybook/fixtures/imagePaste.ts +34 -0
- package/src/storybook/fixtures/index.ts +62 -0
- package/src/storybook/fixtures/kitchenSink.ts +148 -0
- package/src/storybook/fixtures/rawPrompt.ts +15 -0
- package/src/storybook/fixtures/short.ts +27 -0
- package/src/storybook/fixtures/toolHeavy.ts +65 -0
- package/src/telemetry.test.ts +61 -0
- package/src/ui/AiChatView.tsx +292 -0
- package/src/ui/SpanContentView.tsx +181 -0
- package/src/ui/SpanDetail.tsx +98 -17
- package/src/ui/TraceDetailsPane.tsx +35 -3
- package/src/ui/Waterfall.tsx +94 -167
- package/src/ui/aiChatModel.test.ts +347 -0
- package/src/ui/aiChatModel.ts +736 -0
- package/src/ui/aiState.ts +71 -0
- package/src/ui/app/TraceWorkspace.tsx +295 -120
- package/src/ui/app/useAppLayout.ts +14 -11
- package/src/ui/app/useTraceScreenData.ts +191 -35
- package/src/ui/atoms.ts +131 -0
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/loaders.ts +120 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +47 -21
- package/src/ui/state.ts +4 -169
- package/src/ui/useAttrFilterPicker.ts +63 -23
- package/src/ui/useKeyboardNav.ts +576 -300
- package/src/ui/waterfallFilter.test.ts +84 -0
- package/src/ui/waterfallFilter.ts +59 -0
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.test.ts +17 -1
- package/src/ui/waterfallNav.ts +1 -1
- package/web/dist/assets/{index-DKinj-OE.js → index-DnyVo03x.js} +1 -1
- package/web/dist/index.html +1 -1
package/src/telemetry.test.ts
CHANGED
|
@@ -158,6 +158,23 @@ describe("motel telemetry store", () => {
|
|
|
158
158
|
{ key: "ai.response.msToFinish", value: { stringValue: "20000" } },
|
|
159
159
|
],
|
|
160
160
|
},
|
|
161
|
+
{
|
|
162
|
+
traceId: "trace-ai",
|
|
163
|
+
spanId: "ai-stream-1-do",
|
|
164
|
+
parentSpanId: "ai-stream-1",
|
|
165
|
+
name: "ai.streamText.doStream",
|
|
166
|
+
kind: 1,
|
|
167
|
+
startTimeUnixNano: String(nowNanos + 21n * oneSecond),
|
|
168
|
+
endTimeUnixNano: String(nowNanos + 39n * oneSecond),
|
|
169
|
+
attributes: [
|
|
170
|
+
{ key: "ai.operationId", value: { stringValue: "ai.streamText.doStream" } },
|
|
171
|
+
{ key: "ai.telemetry.functionId", value: { stringValue: "session.llm" } },
|
|
172
|
+
{ key: "ai.model.provider", value: { stringValue: "openai.responses" } },
|
|
173
|
+
{ key: "ai.model.id", value: { stringValue: "gpt-5.4" } },
|
|
174
|
+
{ key: "ai.telemetry.metadata.sessionId", value: { stringValue: "ses_test123" } },
|
|
175
|
+
{ key: "ai.prompt.messages", value: { stringValue: '[{"role":"user","content":"Tell me a joke about programming"}]' } },
|
|
176
|
+
],
|
|
177
|
+
},
|
|
161
178
|
{
|
|
162
179
|
traceId: "trace-ai",
|
|
163
180
|
spanId: "ai-tool-1",
|
|
@@ -617,6 +634,17 @@ describe("motel telemetry store", () => {
|
|
|
617
634
|
expect(streamCall?.usage?.outputTokens).toBe(42)
|
|
618
635
|
})
|
|
619
636
|
|
|
637
|
+
it("dedupes nested doStream spans from AI summaries", async () => {
|
|
638
|
+
const result = await storeRuntime.runPromise(
|
|
639
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
640
|
+
store.searchAiCalls({ sessionId: "ses_test123" }),
|
|
641
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
expect(result.map((call) => call.spanId)).toEqual(["ai-stream-1"])
|
|
645
|
+
expect(result.map((call) => call.spanId)).not.toContain("ai-stream-1-do")
|
|
646
|
+
})
|
|
647
|
+
|
|
620
648
|
it("filters AI calls by model", async () => {
|
|
621
649
|
const result = await storeRuntime.runPromise(
|
|
622
650
|
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
@@ -651,6 +679,39 @@ describe("motel telemetry store", () => {
|
|
|
651
679
|
expect(result[0]?.spanId).toBe("ai-stream-1")
|
|
652
680
|
})
|
|
653
681
|
|
|
682
|
+
it("matches AI calls via words in the response text", async () => {
|
|
683
|
+
// Verifies FTS indexes ai.response.text, not just ai.prompt*. The
|
|
684
|
+
// seeded ai-stream-2 has response "Error: rate limited".
|
|
685
|
+
const result = await storeRuntime.runPromise(
|
|
686
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
687
|
+
store.searchAiCalls({ text: "rate limited" }),
|
|
688
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
689
|
+
)
|
|
690
|
+
expect(result.map((r) => r.spanId)).toContain("ai-stream-2")
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
it("matches AI calls case-insensitively and with partial words", async () => {
|
|
694
|
+
// unicode61 tokenizer is case-insensitive by default; prefix `*`
|
|
695
|
+
// handles partial terms like `"PROG"` matching `"programming"`.
|
|
696
|
+
const result = await storeRuntime.runPromise(
|
|
697
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
698
|
+
store.searchAiCalls({ text: "PROG" }),
|
|
699
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
700
|
+
)
|
|
701
|
+
expect(result.map((r) => r.spanId)).toContain("ai-stream-1")
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
it("ignores FTS special characters without syntax errors", async () => {
|
|
705
|
+
// FTS5 treats `"`, `*`, `-`, `:` as operators; toFtsQuery must
|
|
706
|
+
// strip them so raw user input never crashes the query.
|
|
707
|
+
const result = await storeRuntime.runPromise(
|
|
708
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
709
|
+
store.searchAiCalls({ text: `"joke" - about:programming*` }),
|
|
710
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
711
|
+
)
|
|
712
|
+
expect(result.map((r) => r.spanId)).toContain("ai-stream-1")
|
|
713
|
+
})
|
|
714
|
+
|
|
654
715
|
it("filters AI calls by operation type", async () => {
|
|
655
716
|
const result = await storeRuntime.runPromise(
|
|
656
717
|
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { RGBA, TextAttributes } from "@opentui/core"
|
|
2
|
+
import { useLayoutEffect, useMemo, useState } from "react"
|
|
3
|
+
import { isAiSpan, type TraceSpanItem } from "../domain.ts"
|
|
4
|
+
import {
|
|
5
|
+
buildChatListRows,
|
|
6
|
+
chunkDetailTitle,
|
|
7
|
+
renderChunkDetailLines,
|
|
8
|
+
type Chunk,
|
|
9
|
+
type ChatListRow,
|
|
10
|
+
type Role,
|
|
11
|
+
} from "./aiChatModel.ts"
|
|
12
|
+
import { formatDuration, truncateText } from "./format.ts"
|
|
13
|
+
import { AlignedHeaderLine, BlankRow, Divider, PlainLine, TextLine } from "./primitives.tsx"
|
|
14
|
+
import type { AiCallDetailState } from "./state.ts"
|
|
15
|
+
import { colors, SEPARATOR } from "./theme.ts"
|
|
16
|
+
|
|
17
|
+
export const AI_CHAT_HEADER_ROWS = 4
|
|
18
|
+
|
|
19
|
+
const roleColor = (role: Role): string => {
|
|
20
|
+
switch (role) {
|
|
21
|
+
case "user": return colors.accent
|
|
22
|
+
case "assistant": return colors.text
|
|
23
|
+
case "system": return colors.muted
|
|
24
|
+
case "tool": return colors.passing
|
|
25
|
+
case "response": return colors.accent
|
|
26
|
+
default: return colors.muted
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const rowPrefix = (chunk: Chunk | null): string => {
|
|
31
|
+
if (!chunk) return " "
|
|
32
|
+
switch (chunk.kind) {
|
|
33
|
+
case "tool-call": return "→ "
|
|
34
|
+
case "tool-result": return "← "
|
|
35
|
+
case "reasoning": return "• "
|
|
36
|
+
case "response": return "↳ "
|
|
37
|
+
default: return ""
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const rowTextColor = (chunk: Chunk | null, role: Role, selected: boolean): string => {
|
|
42
|
+
if (selected) return colors.selectedText
|
|
43
|
+
if (!chunk) return roleColor(role)
|
|
44
|
+
if (chunk.kind === "tool-call") return colors.count
|
|
45
|
+
if (chunk.kind === "tool-result") return colors.passing
|
|
46
|
+
if (chunk.kind === "reasoning") return colors.muted
|
|
47
|
+
if (chunk.kind === "system") return colors.muted
|
|
48
|
+
return colors.text
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
|
|
52
|
+
|
|
53
|
+
const chunkRows = (rows: readonly ChatListRow[]) => rows.filter((row) => row.kind === "chunk")
|
|
54
|
+
|
|
55
|
+
interface MouseScrollEvent {
|
|
56
|
+
readonly scroll?: {
|
|
57
|
+
readonly direction: string
|
|
58
|
+
readonly delta: number
|
|
59
|
+
}
|
|
60
|
+
readonly stopPropagation?: () => void
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const scrollDelta = (event: MouseScrollEvent): number => {
|
|
64
|
+
const info = event.scroll
|
|
65
|
+
if (!info) return 0
|
|
66
|
+
const magnitude = Math.max(1, Math.round(info.delta))
|
|
67
|
+
if (info.direction === "up") return -magnitude
|
|
68
|
+
if (info.direction === "down") return magnitude
|
|
69
|
+
return 0
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const ChatDetailModal = ({
|
|
73
|
+
chunk,
|
|
74
|
+
scrollOffset,
|
|
75
|
+
onScrollOffset,
|
|
76
|
+
paneWidth,
|
|
77
|
+
paneHeight,
|
|
78
|
+
onClose,
|
|
79
|
+
}: {
|
|
80
|
+
readonly chunk: Chunk
|
|
81
|
+
readonly scrollOffset: number
|
|
82
|
+
readonly onScrollOffset: (updater: (current: number) => number) => void
|
|
83
|
+
readonly paneWidth: number
|
|
84
|
+
readonly paneHeight: number
|
|
85
|
+
readonly onClose: () => void
|
|
86
|
+
}) => {
|
|
87
|
+
const modalWidth = Math.min(Math.max(56, Math.floor(paneWidth * 0.8)), paneWidth - 4)
|
|
88
|
+
const modalHeight = Math.min(Math.max(12, Math.floor(paneHeight * 0.75)), paneHeight - 2)
|
|
89
|
+
const left = Math.max(2, Math.floor((paneWidth - modalWidth) / 2))
|
|
90
|
+
const top = Math.max(1, Math.floor((paneHeight - modalHeight) / 2))
|
|
91
|
+
const innerWidth = Math.max(16, modalWidth - 4)
|
|
92
|
+
const bodyLines = Math.max(4, modalHeight - 4)
|
|
93
|
+
const lines = renderChunkDetailLines(chunk, innerWidth)
|
|
94
|
+
const maxOffset = Math.max(0, lines.length - bodyLines)
|
|
95
|
+
const offset = clamp(scrollOffset, 0, maxOffset)
|
|
96
|
+
const visible = lines.slice(offset, offset + bodyLines)
|
|
97
|
+
const meta = chunk.headerMeta ?? `${lines.length} lines`
|
|
98
|
+
const handleWheel = (event: MouseScrollEvent) => {
|
|
99
|
+
const delta = scrollDelta(event)
|
|
100
|
+
if (delta === 0) return
|
|
101
|
+
onScrollOffset((current) => clamp(current + delta, 0, maxOffset))
|
|
102
|
+
event.stopPropagation?.()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<box position="absolute" zIndex={3500} left={0} top={0} width={paneWidth} height={paneHeight} backgroundColor={RGBA.fromInts(0, 0, 0, 110)} onMouseUp={onClose}>
|
|
107
|
+
<box position="absolute" left={left} top={top} width={modalWidth} height={modalHeight} flexDirection="column" backgroundColor={RGBA.fromHex(colors.screenBg)} onMouseScroll={handleWheel}>
|
|
108
|
+
<box paddingLeft={1} paddingRight={1}>
|
|
109
|
+
<AlignedHeaderLine left={chunkDetailTitle(chunk)} right={`${meta} ${SEPARATOR} esc close`} width={modalWidth - 2} rightFg={colors.count} />
|
|
110
|
+
</box>
|
|
111
|
+
<Divider width={modalWidth} />
|
|
112
|
+
<box flexDirection="column" paddingLeft={2} paddingRight={2}>
|
|
113
|
+
{visible.map((line, i) => (
|
|
114
|
+
<PlainLine key={`detail-${i + offset}`} text={line} fg={colors.text} />
|
|
115
|
+
))}
|
|
116
|
+
{visible.length < bodyLines ? Array.from({ length: bodyLines - visible.length }, (_, i) => <BlankRow key={`detail-pad-${i}`} />) : null}
|
|
117
|
+
</box>
|
|
118
|
+
</box>
|
|
119
|
+
</box>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const AiChatView = ({
|
|
124
|
+
span,
|
|
125
|
+
detailState,
|
|
126
|
+
chunks,
|
|
127
|
+
selectedChunkId,
|
|
128
|
+
onSelectChunk,
|
|
129
|
+
detailChunkId,
|
|
130
|
+
onOpenDetail,
|
|
131
|
+
onCloseDetail,
|
|
132
|
+
detailScrollOffset,
|
|
133
|
+
onSetDetailScrollOffset,
|
|
134
|
+
contentWidth,
|
|
135
|
+
bodyLines,
|
|
136
|
+
paneWidth,
|
|
137
|
+
}: {
|
|
138
|
+
readonly span: TraceSpanItem | null
|
|
139
|
+
readonly detailState: AiCallDetailState
|
|
140
|
+
readonly chunks: readonly Chunk[]
|
|
141
|
+
readonly selectedChunkId: string | null
|
|
142
|
+
readonly onSelectChunk: (chunkId: string) => void
|
|
143
|
+
readonly detailChunkId: string | null
|
|
144
|
+
readonly onOpenDetail: (chunkId: string) => void
|
|
145
|
+
readonly onCloseDetail: () => void
|
|
146
|
+
readonly detailScrollOffset: number
|
|
147
|
+
readonly onSetDetailScrollOffset: (updater: (current: number) => number) => void
|
|
148
|
+
readonly contentWidth: number
|
|
149
|
+
readonly bodyLines: number
|
|
150
|
+
readonly paneWidth: number
|
|
151
|
+
}) => {
|
|
152
|
+
const rows = useMemo(() => buildChatListRows(chunks), [chunks])
|
|
153
|
+
const selectable = useMemo(() => chunkRows(rows), [rows])
|
|
154
|
+
const [scrollOffset, setScrollOffset] = useState(0)
|
|
155
|
+
|
|
156
|
+
const selectedChunk = useMemo(
|
|
157
|
+
() => selectedChunkId ? chunks.find((chunk) => chunk.id === selectedChunkId) ?? null : null,
|
|
158
|
+
[chunks, selectedChunkId],
|
|
159
|
+
)
|
|
160
|
+
const detailChunk = useMemo(
|
|
161
|
+
() => detailChunkId ? chunks.find((chunk) => chunk.id === detailChunkId) ?? null : null,
|
|
162
|
+
[chunks, detailChunkId],
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
const selectedRowIndex = useMemo(
|
|
166
|
+
() => selectedChunkId ? rows.findIndex((row) => row.kind === "chunk" && row.chunkId === selectedChunkId) : -1,
|
|
167
|
+
[rows, selectedChunkId],
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
useLayoutEffect(() => {
|
|
171
|
+
const maxOffset = Math.max(0, rows.length - bodyLines)
|
|
172
|
+
if (selectedRowIndex < 0) {
|
|
173
|
+
setScrollOffset((current) => clamp(current, 0, maxOffset))
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
setScrollOffset((current) => {
|
|
177
|
+
let next = clamp(current, 0, maxOffset)
|
|
178
|
+
if (selectedRowIndex < next) next = selectedRowIndex
|
|
179
|
+
else if (selectedRowIndex >= next + bodyLines) next = selectedRowIndex - bodyLines + 1
|
|
180
|
+
return clamp(next, 0, maxOffset)
|
|
181
|
+
})
|
|
182
|
+
}, [rows.length, bodyLines, selectedRowIndex])
|
|
183
|
+
|
|
184
|
+
if (!span || !isAiSpan(span.tags)) {
|
|
185
|
+
return (
|
|
186
|
+
<box flexDirection="column" width={paneWidth} height={bodyLines + AI_CHAT_HEADER_ROWS} overflow="hidden">
|
|
187
|
+
<box paddingLeft={1} paddingRight={1}>
|
|
188
|
+
<AlignedHeaderLine left="AI CHAT" right="not an ai span" width={contentWidth} rightFg={colors.muted} />
|
|
189
|
+
</box>
|
|
190
|
+
</box>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const detail = detailState.data
|
|
195
|
+
const model = detail?.model ?? span.tags["ai.model.id"] ?? "unknown model"
|
|
196
|
+
const provider = detail?.provider ?? span.tags["ai.model.provider"] ?? null
|
|
197
|
+
const operation = detail?.operation ?? span.operationName
|
|
198
|
+
const finishReason = detail?.finishReason ?? null
|
|
199
|
+
const usage = detail?.usage ?? null
|
|
200
|
+
const durationLabel = formatDuration(detail?.durationMs ?? span.durationMs)
|
|
201
|
+
const maxOffset = Math.max(0, rows.length - bodyLines)
|
|
202
|
+
const offset = clamp(scrollOffset, 0, maxOffset)
|
|
203
|
+
const visible = rows.slice(offset, offset + bodyLines)
|
|
204
|
+
const headerRight = `${operation} ${SEPARATOR} ${durationLabel} ${SEPARATOR} ${selectable.length > 0 ? `${Math.max(1, selectable.findIndex((row) => row.chunkId === selectedChunkId) + 1)}/${selectable.length}` : "0/0"}`
|
|
205
|
+
const handleListWheel = (event: MouseScrollEvent) => {
|
|
206
|
+
if (detailChunk) return
|
|
207
|
+
const delta = scrollDelta(event)
|
|
208
|
+
if (delta === 0) return
|
|
209
|
+
setScrollOffset((current) => clamp(current + delta, 0, maxOffset))
|
|
210
|
+
event.stopPropagation?.()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<box flexDirection="column" width={paneWidth} height={bodyLines + AI_CHAT_HEADER_ROWS} overflow="hidden">
|
|
215
|
+
<box paddingLeft={1} paddingRight={1}>
|
|
216
|
+
<AlignedHeaderLine left="AI CHAT" right={headerRight} width={contentWidth} rightFg={colors.count} />
|
|
217
|
+
</box>
|
|
218
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
219
|
+
<TextLine>
|
|
220
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{"✦ "}</span>
|
|
221
|
+
<span fg={colors.text}>{model}</span>
|
|
222
|
+
{provider ? <><span fg={colors.separator}>{SEPARATOR}</span><span fg={colors.muted}>{provider}</span></> : null}
|
|
223
|
+
{finishReason ? <><span fg={colors.separator}>{SEPARATOR}</span><span fg={colors.muted}>{`finish=${finishReason}`}</span></> : null}
|
|
224
|
+
</TextLine>
|
|
225
|
+
<TextLine>
|
|
226
|
+
{usage ? (
|
|
227
|
+
<>
|
|
228
|
+
<span fg={colors.muted}>{"tokens "}</span>
|
|
229
|
+
<span fg={colors.count}>{usage.inputTokens != null ? `in=${usage.inputTokens}` : ""}</span>
|
|
230
|
+
<span fg={colors.muted}>{usage.cachedInputTokens != null ? ` cached=${usage.cachedInputTokens}` : ""}</span>
|
|
231
|
+
<span fg={colors.count}>{usage.outputTokens != null ? ` out=${usage.outputTokens}` : ""}</span>
|
|
232
|
+
<span fg={colors.muted}>{usage.reasoningTokens != null ? ` reason=${usage.reasoningTokens}` : ""}</span>
|
|
233
|
+
</>
|
|
234
|
+
) : (
|
|
235
|
+
<span fg={colors.muted}>{detail?.sessionId ? `session ${detail.sessionId}` : "no usage reported"}</span>
|
|
236
|
+
)}
|
|
237
|
+
</TextLine>
|
|
238
|
+
</box>
|
|
239
|
+
<Divider width={paneWidth} />
|
|
240
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1} onMouseScroll={handleListWheel}>
|
|
241
|
+
{detailState.status === "loading" && !detail ? (
|
|
242
|
+
<PlainLine text="loading chat transcript…" fg={colors.muted} />
|
|
243
|
+
) : detailState.status === "error" ? (
|
|
244
|
+
<PlainLine text={detailState.error ?? "failed to load chat detail"} fg={colors.error} />
|
|
245
|
+
) : rows.length === 0 ? (
|
|
246
|
+
<PlainLine text="no chat content parsed from this span" fg={colors.muted} />
|
|
247
|
+
) : (
|
|
248
|
+
visible.map((row, i) => {
|
|
249
|
+
if (row.kind === "separator") {
|
|
250
|
+
return <BlankRow key={`row-${offset + i}`} />
|
|
251
|
+
}
|
|
252
|
+
if (row.kind === "role-divider") {
|
|
253
|
+
return (
|
|
254
|
+
<TextLine key={`row-${offset + i}`}>
|
|
255
|
+
<span fg={colors.separator}>{" "}</span>
|
|
256
|
+
<span fg={roleColor(row.role)} attributes={TextAttributes.BOLD}>{row.text}</span>
|
|
257
|
+
</TextLine>
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
const chunk = chunks.find((candidate) => candidate.id === row.chunkId) ?? null
|
|
261
|
+
const isSelected = row.chunkId === selectedChunkId
|
|
262
|
+
const prefix = rowPrefix(chunk)
|
|
263
|
+
const meta = row.meta ?? ""
|
|
264
|
+
const textWidth = Math.max(8, contentWidth - prefix.length - meta.length - 4)
|
|
265
|
+
const display = truncateText(row.text, textWidth)
|
|
266
|
+
const gap = Math.max(1, contentWidth - prefix.length - display.length - meta.length - 1)
|
|
267
|
+
return (
|
|
268
|
+
<box key={`row-${offset + i}`} height={1} onMouseDown={() => { if (row.chunkId) onSelectChunk(row.chunkId) }}>
|
|
269
|
+
<TextLine bg={isSelected ? colors.selectedBg : undefined}>
|
|
270
|
+
<span fg={isSelected ? roleColor(row.role) : colors.separator}>{isSelected ? "▎" : " "}</span>
|
|
271
|
+
<span fg={rowTextColor(chunk, row.role, isSelected)} attributes={isSelected ? TextAttributes.BOLD : undefined}>{prefix}{display}</span>
|
|
272
|
+
{meta ? <><span fg={colors.muted}>{" ".repeat(gap)}</span><span fg={colors.muted}>{meta}</span></> : null}
|
|
273
|
+
</TextLine>
|
|
274
|
+
</box>
|
|
275
|
+
)
|
|
276
|
+
})
|
|
277
|
+
)}
|
|
278
|
+
{visible.length < bodyLines && rows.length > 0 ? Array.from({ length: bodyLines - visible.length }, (_, i) => <BlankRow key={`pad-${i}`} />) : null}
|
|
279
|
+
</box>
|
|
280
|
+
{detailChunk ? (
|
|
281
|
+
<ChatDetailModal
|
|
282
|
+
chunk={detailChunk}
|
|
283
|
+
scrollOffset={detailScrollOffset}
|
|
284
|
+
onScrollOffset={onSetDetailScrollOffset}
|
|
285
|
+
paneWidth={paneWidth}
|
|
286
|
+
paneHeight={bodyLines + AI_CHAT_HEADER_ROWS}
|
|
287
|
+
onClose={onCloseDetail}
|
|
288
|
+
/>
|
|
289
|
+
) : null}
|
|
290
|
+
</box>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core"
|
|
2
|
+
import { useMemo } from "react"
|
|
3
|
+
import { isAiSpan, type TraceSpanItem } from "../domain.ts"
|
|
4
|
+
import { formatDuration, lifecycleLabel, wrapTextLines } from "./format.ts"
|
|
5
|
+
import { AlignedHeaderLine, BlankRow, Divider, PlainLine, TextLine } from "./primitives.tsx"
|
|
6
|
+
import { colors, SEPARATOR } from "./theme.ts"
|
|
7
|
+
|
|
8
|
+
/** Header above the attribute list: "SPAN CONTENT" + status/duration strip. */
|
|
9
|
+
export const SPAN_CONTENT_HEADER_ROWS = 4
|
|
10
|
+
/** Width of the indent used for wrapped lines of a stacked value. */
|
|
11
|
+
const VALUE_INDENT = " "
|
|
12
|
+
/** Width of the leading marker column that signals "selected" on the cursor row. */
|
|
13
|
+
const CURSOR_WIDTH = 2
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Layout plan for a single attribute row. Each attribute renders as at least
|
|
17
|
+
* two rows (key line + one wrapped value line) plus however many extra wrap
|
|
18
|
+
* lines the value needs. We precompute row counts up front so the viewport
|
|
19
|
+
* (j/k scroll) can pick exactly `bodyLines` worth of content without
|
|
20
|
+
* measuring twice.
|
|
21
|
+
*/
|
|
22
|
+
interface AttrBlock {
|
|
23
|
+
readonly key: string
|
|
24
|
+
readonly valueLines: readonly string[]
|
|
25
|
+
readonly rowCount: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Full-screen span content view (level 2). Triggered by pressing enter on
|
|
30
|
+
* a span in the waterfall. Renders every tag stacked key-above-value with
|
|
31
|
+
* no truncation and word-wrapping to fit the viewport. The selected
|
|
32
|
+
* attribute is highlighted (accent color on the key, leading `▶`) and
|
|
33
|
+
* `y` copies its value. Step 3 will branch AI-flagged spans into a
|
|
34
|
+
* specialised chat-transcript view; this is the generic fallback that
|
|
35
|
+
* also serves as the baseline for any non-AI span.
|
|
36
|
+
*/
|
|
37
|
+
export const SpanContentView = ({
|
|
38
|
+
span,
|
|
39
|
+
contentWidth,
|
|
40
|
+
bodyLines,
|
|
41
|
+
paneWidth,
|
|
42
|
+
selectedAttrIndex,
|
|
43
|
+
}: {
|
|
44
|
+
readonly span: TraceSpanItem | null
|
|
45
|
+
readonly contentWidth: number
|
|
46
|
+
readonly bodyLines: number
|
|
47
|
+
readonly paneWidth: number
|
|
48
|
+
readonly selectedAttrIndex: number
|
|
49
|
+
}) => {
|
|
50
|
+
if (!span) {
|
|
51
|
+
return (
|
|
52
|
+
<box flexDirection="column" width={paneWidth} height={bodyLines + SPAN_CONTENT_HEADER_ROWS} overflow="hidden">
|
|
53
|
+
<box paddingLeft={1} paddingRight={1}>
|
|
54
|
+
<AlignedHeaderLine left="SPAN CONTENT" right="no span selected" width={contentWidth} rightFg={colors.muted} />
|
|
55
|
+
</box>
|
|
56
|
+
<BlankRow />
|
|
57
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
58
|
+
<PlainLine text="Select a span in the waterfall to view its full content." fg={colors.muted} />
|
|
59
|
+
</box>
|
|
60
|
+
</box>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const entries = Object.entries(span.tags)
|
|
65
|
+
const selected = Math.max(0, Math.min(selectedAttrIndex, entries.length - 1))
|
|
66
|
+
const valueWrapWidth = Math.max(16, contentWidth - VALUE_INDENT.length - CURSOR_WIDTH)
|
|
67
|
+
const aiFlag = isAiSpan(span.tags)
|
|
68
|
+
|
|
69
|
+
// Block layout is memoised on entries identity — otherwise every j/k
|
|
70
|
+
// press would rewrap every attribute's value even though only the
|
|
71
|
+
// highlight moved.
|
|
72
|
+
const blocks = useMemo<readonly AttrBlock[]>(() => {
|
|
73
|
+
return entries.map(([key, value]) => {
|
|
74
|
+
// Word-wrap with a generous line cap — the view is scrollable so
|
|
75
|
+
// we can afford to show the whole value rather than forcing an
|
|
76
|
+
// ellipsis. 200 lines covers enormous LLM prompts without
|
|
77
|
+
// blowing up the render tree.
|
|
78
|
+
const valueLines = wrapTextLines(value, valueWrapWidth, 200)
|
|
79
|
+
return { key, valueLines, rowCount: 1 + Math.max(1, valueLines.length) }
|
|
80
|
+
})
|
|
81
|
+
}, [entries, valueWrapWidth])
|
|
82
|
+
|
|
83
|
+
// Viewport: pick the contiguous window of blocks that (a) fits inside
|
|
84
|
+
// bodyLines and (b) contains the selected block. We find the first
|
|
85
|
+
// start index whose accumulated row count from there through `selected`
|
|
86
|
+
// fits in the budget; that start is the window top. If the selected
|
|
87
|
+
// block alone is larger than bodyLines we still render it — oversize
|
|
88
|
+
// values just get cut at the pane edge, no ellipsis.
|
|
89
|
+
const fitsFrom = (start: number) => {
|
|
90
|
+
let rows = 0
|
|
91
|
+
for (let i = start; i <= selected; i++) {
|
|
92
|
+
rows += blocks[i]?.rowCount ?? 0
|
|
93
|
+
if (rows > bodyLines) return false
|
|
94
|
+
}
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
let windowStart = 0
|
|
98
|
+
for (let i = 0; i <= selected; i++) {
|
|
99
|
+
if (fitsFrom(i)) {
|
|
100
|
+
windowStart = i
|
|
101
|
+
break
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const visible: AttrBlock[] = []
|
|
105
|
+
let visibleRows = 0
|
|
106
|
+
for (let i = windowStart; i < blocks.length; i++) {
|
|
107
|
+
const block = blocks[i]!
|
|
108
|
+
if (visibleRows + block.rowCount > bodyLines && i > selected) break
|
|
109
|
+
visible.push(block)
|
|
110
|
+
visibleRows += block.rowCount
|
|
111
|
+
}
|
|
112
|
+
const firstVisibleIndex = windowStart
|
|
113
|
+
|
|
114
|
+
const headerStatus = `${span.status} ${SEPARATOR} ${formatDuration(span.durationMs)}`
|
|
115
|
+
const headerStatusColor = span.isRunning
|
|
116
|
+
? colors.warning
|
|
117
|
+
: span.status === "error"
|
|
118
|
+
? colors.error
|
|
119
|
+
: colors.passing
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<box flexDirection="column" width={paneWidth} height={bodyLines + SPAN_CONTENT_HEADER_ROWS} overflow="hidden">
|
|
123
|
+
<box paddingLeft={1} paddingRight={1}>
|
|
124
|
+
<AlignedHeaderLine left="SPAN CONTENT" right={headerStatus} width={contentWidth} rightFg={headerStatusColor} />
|
|
125
|
+
</box>
|
|
126
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
127
|
+
<TextLine>
|
|
128
|
+
{aiFlag ? <span fg={colors.accent}>{"\u2726 "}</span> : null}
|
|
129
|
+
<span fg={colors.text}>{span.operationName}</span>
|
|
130
|
+
</TextLine>
|
|
131
|
+
<TextLine>
|
|
132
|
+
<span fg={colors.defaultService}>{span.serviceName}</span>
|
|
133
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
134
|
+
<span fg={colors.muted}>{span.scopeName ?? "no scope"}</span>
|
|
135
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
136
|
+
<span fg={span.isRunning ? colors.warning : colors.muted}>{lifecycleLabel(span)}</span>
|
|
137
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
138
|
+
<span fg={colors.muted}>{span.spanId.slice(0, 16)}</span>
|
|
139
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
140
|
+
<span fg={colors.count}>{`${entries.length} tags`}</span>
|
|
141
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
142
|
+
<span fg={colors.muted}>{`${selected + 1}/${entries.length}`}</span>
|
|
143
|
+
</TextLine>
|
|
144
|
+
</box>
|
|
145
|
+
<Divider width={paneWidth} />
|
|
146
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
147
|
+
{entries.length === 0 ? (
|
|
148
|
+
<PlainLine text="No tags on this span." fg={colors.muted} />
|
|
149
|
+
) : (
|
|
150
|
+
visible.map((block, offset) => {
|
|
151
|
+
const index = firstVisibleIndex + offset
|
|
152
|
+
const isSelected = index === selected
|
|
153
|
+
const keyColor = isSelected ? colors.accent : colors.count
|
|
154
|
+
const cursor = isSelected ? "\u25b8 " : " "
|
|
155
|
+
return (
|
|
156
|
+
<box key={`${span.spanId}-${block.key}`} flexDirection="column">
|
|
157
|
+
<TextLine>
|
|
158
|
+
<span fg={isSelected ? colors.accent : colors.separator}>{cursor}</span>
|
|
159
|
+
<span fg={keyColor} attributes={isSelected ? TextAttributes.BOLD : undefined}>{block.key}</span>
|
|
160
|
+
</TextLine>
|
|
161
|
+
{block.valueLines.length === 0 ? (
|
|
162
|
+
<TextLine>
|
|
163
|
+
<span fg={colors.separator}>{VALUE_INDENT}</span>
|
|
164
|
+
<span fg={colors.muted}>(empty)</span>
|
|
165
|
+
</TextLine>
|
|
166
|
+
) : (
|
|
167
|
+
block.valueLines.map((line, i) => (
|
|
168
|
+
<TextLine key={i}>
|
|
169
|
+
<span fg={colors.separator}>{VALUE_INDENT}</span>
|
|
170
|
+
<span fg={isSelected ? colors.text : colors.muted}>{line}</span>
|
|
171
|
+
</TextLine>
|
|
172
|
+
))
|
|
173
|
+
)}
|
|
174
|
+
</box>
|
|
175
|
+
)
|
|
176
|
+
})
|
|
177
|
+
)}
|
|
178
|
+
</box>
|
|
179
|
+
</box>
|
|
180
|
+
)
|
|
181
|
+
}
|