@sebastianandreasson/pi-autonomous-agents 0.7.1 → 0.9.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.
- package/README.md +7 -2
- package/docs/PI_SUPERVISOR.md +1 -1
- package/package.json +1 -1
- package/src/pi-client.mjs +15 -1
- package/src/pi-sdk-turn.mjs +57 -0
- package/src/pi-supervisor.mjs +4 -0
- package/src/pi-telemetry.mjs +4 -0
- package/src/pi-visualizer-server.mjs +398 -37
package/README.md
CHANGED
|
@@ -272,11 +272,16 @@ Visualizer uses SSE for live updates instead of browser polling.
|
|
|
272
272
|
`pi-harness visualize` still exists as standalone viewer if you want to inspect run history without starting a new run.
|
|
273
273
|
|
|
274
274
|
Visualizer now includes:
|
|
275
|
+
- TODO-centric main view with current task open by default
|
|
275
276
|
- run history selector from `.pi-runtime/runs/`
|
|
276
|
-
- orchestration flow
|
|
277
|
-
-
|
|
277
|
+
- orchestration flow for selected todo
|
|
278
|
+
- 50/50 split between live worker feed and current repo edits
|
|
279
|
+
- per-iteration stage graph with retries/rechecks in diagnostics
|
|
278
280
|
- clickable graph nodes and timeline rows that show full event JSON
|
|
279
281
|
- historical run summaries and per-run last output snapshots
|
|
282
|
+
- live worker feed with thinking text, assistant text, tool calls, and tool output
|
|
283
|
+
- feed controls to hide thinking and collapse repetitive deltas
|
|
284
|
+
- pinned latest tool output panel
|
|
280
285
|
|
|
281
286
|
## Visual Review Contract
|
|
282
287
|
|
package/docs/PI_SUPERVISOR.md
CHANGED
|
@@ -57,7 +57,7 @@ The package reads `PI_CONFIG_FILE` if provided. Otherwise it falls back to the b
|
|
|
57
57
|
|
|
58
58
|
`pi-harness visualize` remains available as standalone viewer.
|
|
59
59
|
|
|
60
|
-
Visualizer reads active-run lock, per-run state, per-run iteration summary, per-run last output snapshot, and telemetry to show current stage plus historical runs.
|
|
60
|
+
Visualizer reads active-run lock, TODO file, per-run state, per-run iteration summary, per-run last output snapshot, live feed JSONL, and telemetry to show current stage plus historical runs.
|
|
61
61
|
|
|
62
62
|
## Config Contract
|
|
63
63
|
|
package/package.json
CHANGED
package/src/pi-client.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
1
3
|
import { randomUUID } from 'node:crypto'
|
|
2
4
|
import {
|
|
3
5
|
appendLog,
|
|
@@ -37,6 +39,14 @@ async function writeAgentOutputSnapshot(config, content) {
|
|
|
37
39
|
}
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
async function appendLiveFeedEvent(config, event) {
|
|
43
|
+
if (!config.runLiveFeedFile) {
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
await fs.mkdir(path.dirname(config.runLiveFeedFile), { recursive: true })
|
|
47
|
+
await fs.appendFile(config.runLiveFeedFile, `${JSON.stringify(event)}\n`, 'utf8')
|
|
48
|
+
}
|
|
49
|
+
|
|
40
50
|
async function runMockTurn({ config, sessionId, sessionFile, prompt, reason }) {
|
|
41
51
|
const nextSessionId = sessionId || `mock-${randomUUID()}`
|
|
42
52
|
const nextSessionFile = sessionFile || `${config.piRuntimeDir}/mock-${nextSessionId}.jsonl`
|
|
@@ -78,7 +88,7 @@ async function runMockTurn({ config, sessionId, sessionFile, prompt, reason }) {
|
|
|
78
88
|
}
|
|
79
89
|
}
|
|
80
90
|
|
|
81
|
-
async function runSdkTransportTurn({ config, model, sessionId, sessionFile, prompt, iteration, retryCount, reason }) {
|
|
91
|
+
async function runSdkTransportTurn({ config, model, sessionId, sessionFile, prompt, iteration, retryCount, reason, phase, role, kind }) {
|
|
82
92
|
await appendLog(
|
|
83
93
|
config.logFile,
|
|
84
94
|
`Starting SDK turn iteration=${iteration} retry=${retryCount} reason=${reason}`
|
|
@@ -118,6 +128,10 @@ async function runSdkTransportTurn({ config, model, sessionId, sessionFile, prom
|
|
|
118
128
|
retryCount,
|
|
119
129
|
reason,
|
|
120
130
|
},
|
|
131
|
+
phase,
|
|
132
|
+
role,
|
|
133
|
+
kind,
|
|
134
|
+
onLiveEvent: (event) => appendLiveFeedEvent(config, event),
|
|
121
135
|
})
|
|
122
136
|
} catch (error) {
|
|
123
137
|
const notes = error instanceof Error ? error.message : String(error)
|
package/src/pi-sdk-turn.mjs
CHANGED
|
@@ -300,6 +300,22 @@ export async function createSdkSession(pi, request) {
|
|
|
300
300
|
})
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
+
function emitLiveFeed(request, event) {
|
|
304
|
+
if (typeof request?.onLiveEvent !== 'function') {
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
request.onLiveEvent({
|
|
308
|
+
timestamp: new Date().toISOString(),
|
|
309
|
+
iteration: Number(request?.metadata?.iteration ?? 0),
|
|
310
|
+
retryCount: Number(request?.metadata?.retryCount ?? 0),
|
|
311
|
+
reason: String(request?.metadata?.reason ?? request?.reason ?? ''),
|
|
312
|
+
phase: String(request?.phase ?? ''),
|
|
313
|
+
role: String(request?.role ?? ''),
|
|
314
|
+
kind: String(request?.kind ?? ''),
|
|
315
|
+
...event,
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
303
319
|
async function safeAbort(session) {
|
|
304
320
|
try {
|
|
305
321
|
await session.abort()
|
|
@@ -430,10 +446,25 @@ export async function runSdkTurnWithPi(pi, request) {
|
|
|
430
446
|
|
|
431
447
|
if (event.type === 'agent_start') {
|
|
432
448
|
agentStarted = true
|
|
449
|
+
emitLiveFeed(request, {
|
|
450
|
+
type: 'agent_start',
|
|
451
|
+
text: 'agent started',
|
|
452
|
+
})
|
|
433
453
|
writeLive('[PI] agent started\n')
|
|
434
454
|
}
|
|
435
455
|
|
|
456
|
+
if (event.type === 'message_update' && event.assistantMessageEvent?.type === 'thinking_delta') {
|
|
457
|
+
emitLiveFeed(request, {
|
|
458
|
+
type: 'thinking_delta',
|
|
459
|
+
text: event.assistantMessageEvent.delta,
|
|
460
|
+
})
|
|
461
|
+
}
|
|
462
|
+
|
|
436
463
|
if (event.type === 'message_update' && event.assistantMessageEvent?.type === 'text_delta') {
|
|
464
|
+
emitLiveFeed(request, {
|
|
465
|
+
type: 'text_delta',
|
|
466
|
+
text: event.assistantMessageEvent.delta,
|
|
467
|
+
})
|
|
437
468
|
ensureAssistantLine()
|
|
438
469
|
writeLive(event.assistantMessageEvent.delta)
|
|
439
470
|
streamedAssistantText = true
|
|
@@ -488,21 +519,47 @@ export async function runSdkTurnWithPi(pi, request) {
|
|
|
488
519
|
requestAbortForLoop()
|
|
489
520
|
}
|
|
490
521
|
|
|
522
|
+
emitLiveFeed(request, {
|
|
523
|
+
type: 'tool_start',
|
|
524
|
+
toolName: String(event.toolName ?? ''),
|
|
525
|
+
args: event.args,
|
|
526
|
+
text: `${String(event.toolName ?? '')}${suffix}`.trim(),
|
|
527
|
+
})
|
|
491
528
|
writeLive(`[PI tool:start] ${event.toolName}${suffix}\n`)
|
|
492
529
|
if (event.toolName === 'bash' && isLargeShellRead(shellCommand)) {
|
|
493
530
|
writeLive('[PI warning] large bash file read detected; prefer read or a smaller exact window to avoid truncated context.\n')
|
|
494
531
|
}
|
|
495
532
|
}
|
|
496
533
|
|
|
534
|
+
if (event.type === 'tool_execution_update') {
|
|
535
|
+
emitLiveFeed(request, {
|
|
536
|
+
type: 'tool_update',
|
|
537
|
+
toolName: String(event.toolName ?? ''),
|
|
538
|
+
partialResult: event.partialResult,
|
|
539
|
+
text: formatValue(event.partialResult),
|
|
540
|
+
})
|
|
541
|
+
}
|
|
542
|
+
|
|
497
543
|
if (event.type === 'tool_execution_end') {
|
|
498
544
|
closeAssistantLine()
|
|
499
545
|
activeToolName = ''
|
|
500
546
|
activeToolStartedAt = 0
|
|
547
|
+
emitLiveFeed(request, {
|
|
548
|
+
type: 'tool_end',
|
|
549
|
+
toolName: String(event.toolName ?? ''),
|
|
550
|
+
isError: event.isError === true,
|
|
551
|
+
result: event.result,
|
|
552
|
+
text: `${String(event.toolName ?? '')} ${event.isError ? 'error' : 'ok'}`,
|
|
553
|
+
})
|
|
501
554
|
writeLive(`[PI tool:end] ${event.toolName} ${event.isError ? 'error' : 'ok'}\n`)
|
|
502
555
|
}
|
|
503
556
|
|
|
504
557
|
if (event.type === 'agent_end') {
|
|
505
558
|
agentEnded = true
|
|
559
|
+
emitLiveFeed(request, {
|
|
560
|
+
type: 'agent_end',
|
|
561
|
+
text: 'agent finished',
|
|
562
|
+
})
|
|
506
563
|
closeAssistantLine()
|
|
507
564
|
writeLive('[PI] agent finished\n')
|
|
508
565
|
}
|
package/src/pi-supervisor.mjs
CHANGED
|
@@ -368,6 +368,9 @@ async function runAgentInvocation({
|
|
|
368
368
|
iteration,
|
|
369
369
|
retryCount,
|
|
370
370
|
reason,
|
|
371
|
+
phase,
|
|
372
|
+
role,
|
|
373
|
+
kind,
|
|
371
374
|
})
|
|
372
375
|
const afterSnapshot = getRepoSnapshot(config.cwd)
|
|
373
376
|
const changedFiles = listChangedFiles(config.cwd)
|
|
@@ -1734,6 +1737,7 @@ async function main() {
|
|
|
1734
1737
|
config.runStateFile = path.join(runDir, 'state.json')
|
|
1735
1738
|
config.runLastIterationSummaryFile = path.join(runDir, 'last-iteration.json')
|
|
1736
1739
|
config.runLastAgentOutputFile = path.join(runDir, 'last-output.txt')
|
|
1740
|
+
config.runLiveFeedFile = path.join(runDir, 'live-feed.jsonl')
|
|
1737
1741
|
|
|
1738
1742
|
ensureRepo(config.cwd)
|
|
1739
1743
|
await ensureFileExists(config.taskFile, 'task file')
|
package/src/pi-telemetry.mjs
CHANGED
|
@@ -14,6 +14,10 @@ export async function ensureTelemetryFiles(config) {
|
|
|
14
14
|
await fs.mkdir(path.dirname(config.runLastAgentOutputFile), { recursive: true })
|
|
15
15
|
await fs.writeFile(config.runLastAgentOutputFile, '', 'utf8')
|
|
16
16
|
}
|
|
17
|
+
if (config.runLiveFeedFile) {
|
|
18
|
+
await fs.mkdir(path.dirname(config.runLiveFeedFile), { recursive: true })
|
|
19
|
+
await fs.writeFile(config.runLiveFeedFile, '', 'utf8')
|
|
20
|
+
}
|
|
17
21
|
await fs.writeFile(config.lastVerificationOutputFile, '', 'utf8')
|
|
18
22
|
await fs.writeFile(config.changedFilesFile, '', 'utf8')
|
|
19
23
|
await fs.writeFile(config.lastPromptFile, '', 'utf8')
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises'
|
|
|
2
2
|
import http from 'node:http'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import process from 'node:process'
|
|
5
|
+
import { execFileSync } from 'node:child_process'
|
|
5
6
|
import { readTelemetry } from './pi-telemetry.mjs'
|
|
6
7
|
import { readJsonFile } from './pi-repo.mjs'
|
|
7
8
|
import { deriveFlowSnapshot, deriveStageGraph, formatActiveLabel } from './pi-visualizer-shared.mjs'
|
|
@@ -28,6 +29,161 @@ async function readOptionalText(filePath, maxLength = 6000) {
|
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
async function readJsonlTail(filePath, maxItems = 200) {
|
|
33
|
+
try {
|
|
34
|
+
const raw = await fs.readFile(filePath, 'utf8')
|
|
35
|
+
return raw
|
|
36
|
+
.split('\n')
|
|
37
|
+
.map((line) => line.trim())
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
.slice(-maxItems)
|
|
40
|
+
.map((line) => JSON.parse(line))
|
|
41
|
+
} catch {
|
|
42
|
+
return []
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const MAX_DIFF_FILES = 10
|
|
47
|
+
const MAX_DIFF_CHARS_PER_FILE = 12000
|
|
48
|
+
const MAX_DIFF_TOTAL_CHARS = 40000
|
|
49
|
+
const REPO_DIFF_CACHE_MS = 2000
|
|
50
|
+
|
|
51
|
+
let repoDiffCache = {
|
|
52
|
+
cwd: '',
|
|
53
|
+
updatedAt: 0,
|
|
54
|
+
result: [],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function clampText(text, maxChars) {
|
|
58
|
+
const value = String(text ?? '')
|
|
59
|
+
if (value.length <= maxChars) {
|
|
60
|
+
return value
|
|
61
|
+
}
|
|
62
|
+
return `${value.slice(0, maxChars - 16)}\n... [truncated]`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function parseTodos(taskFile, activeTaskText = '') {
|
|
66
|
+
try {
|
|
67
|
+
const raw = await fs.readFile(taskFile, 'utf8')
|
|
68
|
+
const lines = raw.split('\n')
|
|
69
|
+
const items = []
|
|
70
|
+
let currentPhase = ''
|
|
71
|
+
|
|
72
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
73
|
+
const line = lines[index]
|
|
74
|
+
const headingMatch = /^(#+)\s+(.+)$/.exec(line)
|
|
75
|
+
if (headingMatch) {
|
|
76
|
+
const level = headingMatch[1].length
|
|
77
|
+
const text = headingMatch[2].trim()
|
|
78
|
+
if (level === 2) {
|
|
79
|
+
currentPhase = text
|
|
80
|
+
}
|
|
81
|
+
items.push({
|
|
82
|
+
id: `line-${index + 1}`,
|
|
83
|
+
kind: 'heading',
|
|
84
|
+
lineNumber: index + 1,
|
|
85
|
+
level,
|
|
86
|
+
text,
|
|
87
|
+
phase: currentPhase || text,
|
|
88
|
+
raw: line,
|
|
89
|
+
checked: false,
|
|
90
|
+
active: false,
|
|
91
|
+
})
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const checkboxMatch = /^\s*[-*]\s+\[( |x)\]\s+(.+)$/.exec(line)
|
|
96
|
+
if (checkboxMatch) {
|
|
97
|
+
const checked = checkboxMatch[1].toLowerCase() === 'x'
|
|
98
|
+
const text = checkboxMatch[2].trim()
|
|
99
|
+
items.push({
|
|
100
|
+
id: `line-${index + 1}`,
|
|
101
|
+
kind: 'task',
|
|
102
|
+
lineNumber: index + 1,
|
|
103
|
+
level: 0,
|
|
104
|
+
text,
|
|
105
|
+
phase: currentPhase,
|
|
106
|
+
raw: line,
|
|
107
|
+
checked,
|
|
108
|
+
active: text === activeTaskText,
|
|
109
|
+
})
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (line.trim() !== '') {
|
|
114
|
+
items.push({
|
|
115
|
+
id: `line-${index + 1}`,
|
|
116
|
+
kind: 'line',
|
|
117
|
+
lineNumber: index + 1,
|
|
118
|
+
level: 0,
|
|
119
|
+
text: line.trim(),
|
|
120
|
+
phase: currentPhase,
|
|
121
|
+
raw: line,
|
|
122
|
+
checked: false,
|
|
123
|
+
active: false,
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return items
|
|
129
|
+
} catch {
|
|
130
|
+
return []
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function readRepoDiff(cwd) {
|
|
135
|
+
const now = Date.now()
|
|
136
|
+
if (repoDiffCache.cwd === cwd && (now - repoDiffCache.updatedAt) < REPO_DIFF_CACHE_MS) {
|
|
137
|
+
return repoDiffCache.result
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const status = execFileSync('git', ['status', '--short'], {
|
|
142
|
+
cwd,
|
|
143
|
+
encoding: 'utf8',
|
|
144
|
+
maxBuffer: 1024 * 1024,
|
|
145
|
+
}).trim()
|
|
146
|
+
if (status === '') {
|
|
147
|
+
repoDiffCache = { cwd, updatedAt: now, result: [] }
|
|
148
|
+
return []
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const files = status
|
|
152
|
+
.split('\n')
|
|
153
|
+
.map((line) => line.slice(3).trim())
|
|
154
|
+
.filter(Boolean)
|
|
155
|
+
.slice(0, MAX_DIFF_FILES)
|
|
156
|
+
|
|
157
|
+
let remainingChars = MAX_DIFF_TOTAL_CHARS
|
|
158
|
+
const result = files.map((file) => {
|
|
159
|
+
let diff = ''
|
|
160
|
+
try {
|
|
161
|
+
diff = execFileSync('git', ['diff', '--no-ext-diff', '--unified=1', '--', file], {
|
|
162
|
+
cwd,
|
|
163
|
+
encoding: 'utf8',
|
|
164
|
+
maxBuffer: 1024 * 1024,
|
|
165
|
+
}).trim()
|
|
166
|
+
} catch {
|
|
167
|
+
diff = ''
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const allowedChars = Math.max(500, Math.min(MAX_DIFF_CHARS_PER_FILE, remainingChars))
|
|
171
|
+
const truncatedDiff = clampText(diff, allowedChars)
|
|
172
|
+
remainingChars = Math.max(0, remainingChars - truncatedDiff.length)
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
file,
|
|
176
|
+
diff: truncatedDiff,
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
repoDiffCache = { cwd, updatedAt: now, result }
|
|
181
|
+
return result
|
|
182
|
+
} catch {
|
|
183
|
+
return []
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
31
187
|
function getRunDir(config, runId) {
|
|
32
188
|
return path.join(config.piRuntimeDir, 'runs', runId)
|
|
33
189
|
}
|
|
@@ -42,6 +198,7 @@ function getRunScopedConfig(config, runId) {
|
|
|
42
198
|
stateFile: path.join(runDir, 'state.json'),
|
|
43
199
|
lastIterationSummaryFile: path.join(runDir, 'last-iteration.json'),
|
|
44
200
|
lastAgentOutputFile: path.join(runDir, 'last-output.txt'),
|
|
201
|
+
liveFeedFile: path.join(runDir, 'live-feed.jsonl'),
|
|
45
202
|
logFile: path.join(runDir, 'pi.log'),
|
|
46
203
|
}
|
|
47
204
|
}
|
|
@@ -101,11 +258,12 @@ export async function buildSnapshot(config, queryRunId = '') {
|
|
|
101
258
|
const selectedRunId = resolveSelectedRunId(queryRunId, activeRun, runs)
|
|
102
259
|
const selectedConfig = selectedRunId !== '' ? getRunScopedConfig(config, selectedRunId) : config
|
|
103
260
|
|
|
104
|
-
const [state, summary, telemetry, currentOutput] = await Promise.all([
|
|
261
|
+
const [state, summary, telemetry, currentOutput, liveFeed] = await Promise.all([
|
|
105
262
|
readJsonFile(selectedConfig.stateFile, null),
|
|
106
263
|
readJsonFile(selectedConfig.lastIterationSummaryFile, null),
|
|
107
264
|
readTelemetry(selectedConfig),
|
|
108
265
|
readOptionalText(selectedConfig.lastAgentOutputFile, 5000),
|
|
266
|
+
readJsonlTail(selectedConfig.liveFeedFile, 300),
|
|
109
267
|
])
|
|
110
268
|
|
|
111
269
|
const recentTelemetry = telemetry.slice(-160).map((event, index) => ({
|
|
@@ -123,6 +281,13 @@ export async function buildSnapshot(config, queryRunId = '') {
|
|
|
123
281
|
telemetry,
|
|
124
282
|
})
|
|
125
283
|
|
|
284
|
+
const selectedRunIsActive = selectedRunId !== '' && String(activeRun?.runId ?? '') === selectedRunId
|
|
285
|
+
const activeTaskText = String((selectedRunIsActive ? activeRun?.task : state?.inProgress?.task) ?? summary?.task ?? '').trim()
|
|
286
|
+
const [todos, currentEdits] = await Promise.all([
|
|
287
|
+
parseTodos(config.taskFile, activeTaskText),
|
|
288
|
+
Promise.resolve(selectedRunIsActive ? readRepoDiff(config.cwd) : []),
|
|
289
|
+
])
|
|
290
|
+
|
|
126
291
|
return {
|
|
127
292
|
now: new Date().toISOString(),
|
|
128
293
|
config: {
|
|
@@ -143,7 +308,10 @@ export async function buildSnapshot(config, queryRunId = '') {
|
|
|
143
308
|
activeLabel: formatActiveLabel(activeRun, flow),
|
|
144
309
|
},
|
|
145
310
|
graph,
|
|
311
|
+
todos,
|
|
312
|
+
currentEdits,
|
|
146
313
|
lastOutput: currentOutput,
|
|
314
|
+
liveFeed,
|
|
147
315
|
recentTelemetry,
|
|
148
316
|
}
|
|
149
317
|
}
|
|
@@ -188,8 +356,8 @@ export function renderHtml() {
|
|
|
188
356
|
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--pending); }
|
|
189
357
|
.dot.active { background: var(--active); box-shadow: 0 0 18px rgba(110,231,255,.6); }
|
|
190
358
|
.grid { display: grid; gap: 16px; }
|
|
191
|
-
.grid.
|
|
192
|
-
.
|
|
359
|
+
.grid.main { grid-template-columns: minmax(320px, 420px) 1fr; align-items: start; }
|
|
360
|
+
.detail-split { display:grid; grid-template-columns: 1fr 1fr; gap:16px; margin-top:16px; }
|
|
193
361
|
.card {
|
|
194
362
|
background: linear-gradient(180deg, rgba(255,255,255,.02), rgba(255,255,255,.01));
|
|
195
363
|
border: 1px solid var(--line); border-radius: 16px; padding: 16px;
|
|
@@ -198,6 +366,17 @@ export function renderHtml() {
|
|
|
198
366
|
.label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
|
|
199
367
|
.value { margin-top: 8px; font-size: 22px; font-weight: 700; }
|
|
200
368
|
.value.small { font-size: 16px; }
|
|
369
|
+
.todo-list { max-height: calc(100vh - 140px); overflow: auto; padding-right: 4px; }
|
|
370
|
+
.todo-item { border:1px solid var(--line); border-radius:14px; background: var(--panel); margin-bottom:10px; overflow:hidden; }
|
|
371
|
+
.todo-item.active { border-color: var(--active); box-shadow: 0 0 0 1px rgba(110,231,255,.25) inset; }
|
|
372
|
+
.todo-summary { list-style:none; cursor:pointer; padding:12px 14px; display:flex; gap:10px; align-items:flex-start; }
|
|
373
|
+
.todo-summary::-webkit-details-marker { display:none; }
|
|
374
|
+
.todo-line { color: var(--muted); font-size: 11px; min-width: 52px; }
|
|
375
|
+
.todo-text { flex:1; }
|
|
376
|
+
.todo-heading { font-weight:700; }
|
|
377
|
+
.todo-task { font-weight:600; }
|
|
378
|
+
.todo-checked { color: var(--done); }
|
|
379
|
+
.todo-open-body { padding: 0 14px 14px 14px; color: var(--muted); font-size: 12px; }
|
|
201
380
|
.flow { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 10px; margin-top: 14px; }
|
|
202
381
|
.step, .graph-node {
|
|
203
382
|
border: 1px solid var(--line); border-radius: 14px; padding: 12px; background: var(--panel);
|
|
@@ -216,7 +395,27 @@ export function renderHtml() {
|
|
|
216
395
|
.graph { display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap:12px; margin-top:12px; }
|
|
217
396
|
.graph-node { min-height: 120px; width: 100%; text-align: left; color: var(--text); font: inherit; cursor: pointer; }
|
|
218
397
|
.graph-arrow { color: var(--muted); text-align: center; align-self: center; }
|
|
398
|
+
.state-bar { display:flex; gap:10px; flex-wrap:wrap; margin-top:12px; }
|
|
399
|
+
.state-chip { border:1px solid var(--line); border-radius:999px; padding:6px 10px; color: var(--muted); background: rgba(255,255,255,.03); }
|
|
219
400
|
.kv { display: grid; grid-template-columns: 140px 1fr; gap: 6px 10px; margin-top: 12px; }
|
|
401
|
+
.feed-toolbar { display:flex; gap:12px; align-items:center; flex-wrap:wrap; margin-top:12px; margin-bottom:10px; }
|
|
402
|
+
.feed-toggle { display:flex; gap:6px; align-items:center; color: var(--muted); font-size: 12px; }
|
|
403
|
+
.feed { background: #0a1325; border: 1px solid var(--line); border-radius: 12px; padding: 12px; max-height: 320px; overflow: auto; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
404
|
+
.feed-item { padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
|
405
|
+
.feed-item:last-child { border-bottom: 0; }
|
|
406
|
+
.feed-head { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
|
407
|
+
.feed-type { display:inline-flex; align-items:center; border:1px solid var(--line); border-radius:999px; padding:2px 8px; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
|
|
408
|
+
.feed-type.agent_start, .feed-type.agent_end { color: var(--active); }
|
|
409
|
+
.feed-type.thinking_delta { color: #b392f0; }
|
|
410
|
+
.feed-type.text_delta { color: var(--done); }
|
|
411
|
+
.feed-type.tool_start, .feed-type.tool_update, .feed-type.tool_end { color: var(--skip); }
|
|
412
|
+
.feed-meta { color: var(--muted); font-size: 12px; }
|
|
413
|
+
.feed-text { white-space: pre-wrap; word-break: break-word; margin-top: 6px; }
|
|
414
|
+
.feed-count { color: var(--muted); font-size: 11px; }
|
|
415
|
+
.pinned-tool { background:#0a1325; border: 1px solid var(--line); border-radius:12px; padding:12px; }
|
|
416
|
+
.pinned-tool-name { font-weight:700; }
|
|
417
|
+
.pinned-tool-meta { color: var(--muted); font-size:12px; margin-top:4px; }
|
|
418
|
+
.pinned-tool-text { white-space: pre-wrap; word-break: break-word; margin-top:8px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
220
419
|
.kv div:nth-child(odd) { color: var(--muted); }
|
|
221
420
|
pre {
|
|
222
421
|
margin: 0; white-space: pre-wrap; word-break: break-word; background: #0a1325;
|
|
@@ -234,8 +433,13 @@ export function renderHtml() {
|
|
|
234
433
|
.status-pill.error { color: var(--error); }
|
|
235
434
|
.status-pill.skipped { color: var(--skip); }
|
|
236
435
|
.status-pill.active { color: var(--active); }
|
|
436
|
+
.edit-list { max-height: 360px; overflow:auto; }
|
|
437
|
+
.edit-item { border:1px solid var(--line); border-radius:12px; margin-bottom:10px; overflow:hidden; }
|
|
438
|
+
.edit-head { padding:10px 12px; background: rgba(255,255,255,.03); font-weight:600; }
|
|
237
439
|
.muted { color: var(--muted); }
|
|
238
|
-
|
|
440
|
+
details.bottom { margin-top: 16px; }
|
|
441
|
+
details.bottom summary { cursor:pointer; color: var(--muted); margin-bottom:10px; }
|
|
442
|
+
@media (max-width: 1100px) { .grid.main, .detail-split, .flow { grid-template-columns: 1fr; } .todo-list { max-height:none; } }
|
|
239
443
|
</style>
|
|
240
444
|
</head>
|
|
241
445
|
<body>
|
|
@@ -251,39 +455,48 @@ export function renderHtml() {
|
|
|
251
455
|
</div>
|
|
252
456
|
</div>
|
|
253
457
|
|
|
254
|
-
<div class="grid top">
|
|
255
|
-
<div class="card"><div class="label">Current activity</div><div class="value" id="active-label">—</div></div>
|
|
256
|
-
<div class="card"><div class="label">Iteration</div><div class="value" id="iteration">—</div></div>
|
|
257
|
-
<div class="card"><div class="label">Phase</div><div class="value small" id="phase">—</div></div>
|
|
258
|
-
<div class="card"><div class="label">Task</div><div class="value small" id="task">—</div></div>
|
|
259
|
-
</div>
|
|
260
|
-
|
|
261
|
-
<div class="card" style="margin-bottom: 16px;">
|
|
262
|
-
<div class="label">Orchestration flow</div>
|
|
263
|
-
<div class="flow" id="flow"></div>
|
|
264
|
-
</div>
|
|
265
|
-
|
|
266
|
-
<div class="card" style="margin-bottom: 16px;">
|
|
267
|
-
<div class="label">Iteration stage graph</div>
|
|
268
|
-
<div class="graph" id="graph"></div>
|
|
269
|
-
</div>
|
|
270
|
-
|
|
271
458
|
<div class="grid main">
|
|
272
459
|
<div class="card">
|
|
273
|
-
<div class="label">
|
|
274
|
-
<div
|
|
275
|
-
<table>
|
|
276
|
-
<thead><tr><th>Time</th><th>Iteration</th><th>Kind</th><th>Status</th><th>Notes</th></tr></thead>
|
|
277
|
-
<tbody id="timeline"></tbody>
|
|
278
|
-
</table>
|
|
279
|
-
</div>
|
|
460
|
+
<div class="label">TODOS</div>
|
|
461
|
+
<div class="todo-list" id="todo-list"></div>
|
|
280
462
|
</div>
|
|
281
463
|
|
|
282
|
-
<div
|
|
283
|
-
<div class="card"
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
464
|
+
<div>
|
|
465
|
+
<div class="card">
|
|
466
|
+
<div class="label">Focused todo</div>
|
|
467
|
+
<div class="value small" id="todo-focus-title">—</div>
|
|
468
|
+
<div class="state-bar" id="todo-state-bar"></div>
|
|
469
|
+
<div class="flow" id="flow"></div>
|
|
470
|
+
<div class="detail-split">
|
|
471
|
+
<div class="card" style="margin:0;">
|
|
472
|
+
<div class="label">Live worker feed</div>
|
|
473
|
+
<div class="feed-toolbar">
|
|
474
|
+
<label class="feed-toggle"><input type="checkbox" id="feed-show-thinking" checked /> <span>Show thinking</span></label>
|
|
475
|
+
<label class="feed-toggle"><input type="checkbox" id="feed-collapse-deltas" checked /> <span>Collapse deltas</span></label>
|
|
476
|
+
</div>
|
|
477
|
+
<div class="feed" id="feed">No live feed yet.</div>
|
|
478
|
+
</div>
|
|
479
|
+
<div class="grid" style="gap:16px;">
|
|
480
|
+
<div class="card" style="margin:0;"><div class="label">Latest tool output</div><div class="pinned-tool" id="pinned-tool">No tool activity yet.</div></div>
|
|
481
|
+
<div class="card" style="margin:0;">
|
|
482
|
+
<div class="label">Current edits for focused todo</div>
|
|
483
|
+
<div class="edit-list" id="edit-list">No repo edits yet.</div>
|
|
484
|
+
</div>
|
|
485
|
+
</div>
|
|
486
|
+
</div>
|
|
487
|
+
</div>
|
|
488
|
+
|
|
489
|
+
<details class="bottom card">
|
|
490
|
+
<summary>Diagnostics</summary>
|
|
491
|
+
<div class="grid" style="gap:16px;">
|
|
492
|
+
<div class="card" style="margin:0;"><div class="label">Run state</div><div class="kv" id="run-state"></div></div>
|
|
493
|
+
<div class="card" style="margin:0;"><div class="label">Iteration stage graph</div><div class="graph" id="graph"></div></div>
|
|
494
|
+
<div class="card" style="margin:0;"><div class="label">Recent telemetry timeline</div><div style="margin-top: 12px; overflow: auto; max-height: 360px;"><table><thead><tr><th>Time</th><th>Iteration</th><th>Kind</th><th>Status</th><th>Notes</th></tr></thead><tbody id="timeline"></tbody></table></div></div>
|
|
495
|
+
<div class="card" style="margin:0;"><div class="label">Selected event</div><pre id="selected-event">Click graph node or timeline row.</pre></div>
|
|
496
|
+
<div class="card" style="margin:0;"><div class="label">Last iteration summary</div><pre id="summary">—</pre></div>
|
|
497
|
+
<div class="card" style="margin:0;"><div class="label">Last agent output</div><pre id="output">—</pre></div>
|
|
498
|
+
</div>
|
|
499
|
+
</details>
|
|
287
500
|
</div>
|
|
288
501
|
</div>
|
|
289
502
|
</div>
|
|
@@ -317,8 +530,59 @@ export function renderHtml() {
|
|
|
317
530
|
|
|
318
531
|
let latestSnapshot = null
|
|
319
532
|
let selectedEventId = ''
|
|
533
|
+
let selectedTodoId = ''
|
|
320
534
|
let eventSource = null
|
|
321
535
|
|
|
536
|
+
function normalizeFeedEntry(entry) {
|
|
537
|
+
return {
|
|
538
|
+
...entry,
|
|
539
|
+
type: String(entry?.type || 'event'),
|
|
540
|
+
text: String(entry?.text || ''),
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function collapseFeedEntries(entries) {
|
|
545
|
+
const collapsed = []
|
|
546
|
+
for (const raw of entries) {
|
|
547
|
+
const entry = normalizeFeedEntry(raw)
|
|
548
|
+
const prev = collapsed[collapsed.length - 1]
|
|
549
|
+
const canMerge = prev
|
|
550
|
+
&& (entry.type === 'text_delta' || entry.type === 'thinking_delta')
|
|
551
|
+
&& prev.type === entry.type
|
|
552
|
+
&& prev.role === entry.role
|
|
553
|
+
&& prev.kind === entry.kind
|
|
554
|
+
if (canMerge) {
|
|
555
|
+
prev.text += entry.text
|
|
556
|
+
prev.count = (prev.count || 1) + 1
|
|
557
|
+
prev.timestamp = entry.timestamp
|
|
558
|
+
continue
|
|
559
|
+
}
|
|
560
|
+
collapsed.push({ ...entry, count: 1 })
|
|
561
|
+
}
|
|
562
|
+
return collapsed
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function getVisibleFeedEntries(snapshot) {
|
|
566
|
+
const showThinking = document.getElementById('feed-show-thinking')?.checked !== false
|
|
567
|
+
const collapseDeltas = document.getElementById('feed-collapse-deltas')?.checked !== false
|
|
568
|
+
const source = Array.isArray(snapshot?.liveFeed) ? snapshot.liveFeed : []
|
|
569
|
+
const filtered = source.filter((entry) => showThinking || entry.type !== 'thinking_delta')
|
|
570
|
+
return collapseDeltas ? collapseFeedEntries(filtered) : filtered.map((entry) => ({ ...normalizeFeedEntry(entry), count: 1 }))
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function renderPinnedTool(snapshot) {
|
|
574
|
+
const target = document.getElementById('pinned-tool')
|
|
575
|
+
const source = Array.isArray(snapshot?.liveFeed) ? [...snapshot.liveFeed].reverse() : []
|
|
576
|
+
const latest = source.find((entry) => entry.type === 'tool_update' || entry.type === 'tool_end' || entry.type === 'tool_start')
|
|
577
|
+
if (!latest) {
|
|
578
|
+
target.textContent = 'No tool activity yet.'
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
target.innerHTML = '<div class="pinned-tool-name">' + esc(latest.toolName || 'tool') + '</div>' +
|
|
582
|
+
'<div class="pinned-tool-meta">' + esc(latest.type) + ' · ' + esc(new Date(latest.timestamp).toLocaleTimeString()) + '</div>' +
|
|
583
|
+
'<div class="pinned-tool-text">' + esc(latest.text || '') + '</div>'
|
|
584
|
+
}
|
|
585
|
+
|
|
322
586
|
function findEventById(snapshot, eventId) {
|
|
323
587
|
if (!snapshot || !eventId) return null
|
|
324
588
|
return snapshot.graph?.nodes?.find((node) => node.id === eventId)?.event
|
|
@@ -326,6 +590,16 @@ export function renderHtml() {
|
|
|
326
590
|
|| null
|
|
327
591
|
}
|
|
328
592
|
|
|
593
|
+
function findSelectedTodo(snapshot) {
|
|
594
|
+
if (!snapshot) return null
|
|
595
|
+
const todos = Array.isArray(snapshot.todos) ? snapshot.todos : []
|
|
596
|
+
if (selectedTodoId) {
|
|
597
|
+
const direct = todos.find((item) => item.id === selectedTodoId)
|
|
598
|
+
if (direct) return direct
|
|
599
|
+
}
|
|
600
|
+
return todos.find((item) => item.active) || todos.find((item) => item.kind === 'task') || todos[0] || null
|
|
601
|
+
}
|
|
602
|
+
|
|
329
603
|
function renderSelectedEvent() {
|
|
330
604
|
const event = findEventById(latestSnapshot, selectedEventId)
|
|
331
605
|
document.getElementById('selected-event').textContent = event
|
|
@@ -333,6 +607,60 @@ export function renderHtml() {
|
|
|
333
607
|
: 'Click graph node or timeline row.'
|
|
334
608
|
}
|
|
335
609
|
|
|
610
|
+
function renderTodos(snapshot) {
|
|
611
|
+
const todos = Array.isArray(snapshot?.todos) ? snapshot.todos : []
|
|
612
|
+
const selected = findSelectedTodo(snapshot)
|
|
613
|
+
if (selected && !selectedTodoId) {
|
|
614
|
+
selectedTodoId = selected.id
|
|
615
|
+
}
|
|
616
|
+
const list = document.getElementById('todo-list')
|
|
617
|
+
list.innerHTML = todos.length > 0
|
|
618
|
+
? todos.map((item) => {
|
|
619
|
+
const active = selected && item.id === selected.id
|
|
620
|
+
const textClass = item.kind === 'heading' ? 'todo-heading' : (item.kind === 'task' ? 'todo-task' : '')
|
|
621
|
+
const checkedMark = item.kind === 'task' ? (item.checked ? '✓' : '○') : (item.kind === 'heading' ? '#' : '·')
|
|
622
|
+
const checkedClass = item.checked ? 'todo-checked' : ''
|
|
623
|
+
return '<details class="todo-item ' + (active ? 'active' : '') + '" ' + (active ? 'open' : '') + ' data-todo-id="' + esc(item.id) + '">' +
|
|
624
|
+
'<summary class="todo-summary">' +
|
|
625
|
+
'<div class="todo-line">' + esc(String(item.lineNumber)) + '</div>' +
|
|
626
|
+
'<div class="' + textClass + ' ' + checkedClass + '">' + esc(checkedMark + ' ' + item.text) + '</div>' +
|
|
627
|
+
'</summary>' +
|
|
628
|
+
'<div class="todo-open-body">' + esc(item.phase || '') + '</div>' +
|
|
629
|
+
'</details>'
|
|
630
|
+
}).join('')
|
|
631
|
+
: '<div class="muted">No TODO items found.</div>'
|
|
632
|
+
|
|
633
|
+
list.querySelectorAll('[data-todo-id]').forEach((element) => {
|
|
634
|
+
element.addEventListener('toggle', () => {
|
|
635
|
+
if (element.open) {
|
|
636
|
+
selectedTodoId = element.getAttribute('data-todo-id') || ''
|
|
637
|
+
renderSnapshot(snapshot)
|
|
638
|
+
}
|
|
639
|
+
})
|
|
640
|
+
})
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function renderFocusedTodo(snapshot) {
|
|
644
|
+
const todo = findSelectedTodo(snapshot)
|
|
645
|
+
document.getElementById('todo-focus-title').textContent = todo ? todo.text : 'No todo selected.'
|
|
646
|
+
const stateBar = document.getElementById('todo-state-bar')
|
|
647
|
+
const chips = [
|
|
648
|
+
['Current activity', snapshot?.flow?.activeLabel || 'Idle'],
|
|
649
|
+
['Iteration', snapshot?.flow?.iteration || '—'],
|
|
650
|
+
['Phase', todo?.phase || snapshot?.summary?.phase || '—'],
|
|
651
|
+
['Task status', todo?.kind === 'task' ? (todo.checked ? 'Done' : (todo.active ? 'Active' : 'Pending')) : 'Info'],
|
|
652
|
+
]
|
|
653
|
+
stateBar.innerHTML = chips.map(([label, value]) => '<div class="state-chip">' + esc(label + ': ' + value) + '</div>').join('')
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function renderCurrentEdits(snapshot) {
|
|
657
|
+
const edits = Array.isArray(snapshot?.currentEdits) ? snapshot.currentEdits : []
|
|
658
|
+
const target = document.getElementById('edit-list')
|
|
659
|
+
target.innerHTML = edits.length > 0
|
|
660
|
+
? edits.map((entry) => '<details class="edit-item" open><summary class="edit-head">' + esc(entry.file) + '</summary><pre>' + esc(entry.diff || 'No diff available.') + '</pre></details>').join('')
|
|
661
|
+
: '<div class="muted">No repo edits yet.</div>'
|
|
662
|
+
}
|
|
663
|
+
|
|
336
664
|
function bindSelectableEvents() {
|
|
337
665
|
document.querySelectorAll('[data-event-id]').forEach((element) => {
|
|
338
666
|
element.addEventListener('click', () => {
|
|
@@ -340,16 +668,27 @@ export function renderHtml() {
|
|
|
340
668
|
renderSelectedEvent()
|
|
341
669
|
})
|
|
342
670
|
})
|
|
671
|
+
;['feed-show-thinking', 'feed-collapse-deltas'].forEach((id) => {
|
|
672
|
+
const input = document.getElementById(id)
|
|
673
|
+
if (input && !input.dataset.bound) {
|
|
674
|
+
input.addEventListener('change', () => {
|
|
675
|
+
if (latestSnapshot) {
|
|
676
|
+
renderSnapshot(latestSnapshot)
|
|
677
|
+
}
|
|
678
|
+
})
|
|
679
|
+
input.dataset.bound = '1'
|
|
680
|
+
}
|
|
681
|
+
})
|
|
343
682
|
}
|
|
344
683
|
|
|
345
684
|
function renderSnapshot(data) {
|
|
346
685
|
latestSnapshot = data
|
|
347
686
|
document.getElementById('cwd').textContent = data.config.cwd
|
|
348
687
|
document.getElementById('last-refresh').textContent = 'Updated ' + new Date(data.now).toLocaleTimeString()
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
688
|
+
if (!selectedTodoId) {
|
|
689
|
+
const activeTodo = (Array.isArray(data.todos) ? data.todos.find((item) => item.active) : null) || null
|
|
690
|
+
selectedTodoId = activeTodo?.id || ''
|
|
691
|
+
}
|
|
353
692
|
|
|
354
693
|
const select = document.getElementById('run-select')
|
|
355
694
|
const selected = data.config.selectedRunId || ''
|
|
@@ -366,6 +705,10 @@ export function renderHtml() {
|
|
|
366
705
|
select.dataset.bound = '1'
|
|
367
706
|
}
|
|
368
707
|
|
|
708
|
+
renderTodos(data)
|
|
709
|
+
renderFocusedTodo(data)
|
|
710
|
+
renderCurrentEdits(data)
|
|
711
|
+
|
|
369
712
|
const flowEl = document.getElementById('flow')
|
|
370
713
|
flowEl.innerHTML = data.flow.steps.map((step) => {
|
|
371
714
|
const latest = step.latestEvent
|
|
@@ -404,6 +747,24 @@ export function renderHtml() {
|
|
|
404
747
|
document.getElementById('summary').textContent = data.summary ? JSON.stringify(data.summary, null, 2) : 'No iteration summary yet.'
|
|
405
748
|
document.getElementById('output').textContent = data.lastOutput || 'No agent output yet.'
|
|
406
749
|
|
|
750
|
+
renderPinnedTool(data)
|
|
751
|
+
const visibleFeed = getVisibleFeedEntries(data)
|
|
752
|
+
const feedEl = document.getElementById('feed')
|
|
753
|
+
feedEl.innerHTML = visibleFeed.length > 0
|
|
754
|
+
? visibleFeed.map((entry) => {
|
|
755
|
+
const meta = [entry.role, entry.kind, entry.toolName].filter(Boolean).join(' · ')
|
|
756
|
+
return '<div class="feed-item">' +
|
|
757
|
+
'<div class="feed-head">' +
|
|
758
|
+
'<div class="feed-type ' + esc(entry.type) + '">' + esc(entry.type || 'event') + '</div>' +
|
|
759
|
+
(entry.count > 1 ? '<div class="feed-count">x' + esc(entry.count) + '</div>' : '') +
|
|
760
|
+
'</div>' +
|
|
761
|
+
'<div class="feed-meta">' + esc(new Date(entry.timestamp).toLocaleTimeString()) + (meta ? ' · ' + esc(meta) : '') + '</div>' +
|
|
762
|
+
'<div class="feed-text">' + esc(entry.text || '') + '</div>' +
|
|
763
|
+
'</div>'
|
|
764
|
+
}).join('')
|
|
765
|
+
: '<div class="muted">No live feed yet.</div>'
|
|
766
|
+
feedEl.scrollTop = feedEl.scrollHeight
|
|
767
|
+
|
|
407
768
|
const timelineEvents = [...data.recentTelemetry].reverse()
|
|
408
769
|
const timeline = timelineEvents.map((event) => {
|
|
409
770
|
const status = eventStatus(event.status)
|