@tekyzinc/gsd-t 3.13.16 → 3.16.11
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/CHANGELOG.md +44 -0
- package/README.md +1 -0
- package/bin/gsd-t-benchmark-orchestrator.js +437 -0
- package/bin/gsd-t-capture-lint.cjs +276 -0
- package/bin/gsd-t-completion-check.cjs +106 -0
- package/bin/gsd-t-orchestrator-config.cjs +64 -0
- package/bin/gsd-t-orchestrator-queue.cjs +180 -0
- package/bin/gsd-t-orchestrator-recover.cjs +231 -0
- package/bin/gsd-t-orchestrator-worker.cjs +219 -0
- package/bin/gsd-t-orchestrator.js +534 -0
- package/bin/gsd-t-stream-feed-client.cjs +151 -0
- package/bin/gsd-t-task-brief-compactor.cjs +89 -0
- package/bin/gsd-t-task-brief-template.cjs +96 -0
- package/bin/gsd-t-task-brief.js +249 -0
- package/bin/gsd-t-token-backfill.cjs +366 -0
- package/bin/gsd-t-token-capture.cjs +306 -0
- package/bin/gsd-t-token-dashboard.cjs +318 -0
- package/bin/gsd-t-token-regenerate-log.cjs +129 -0
- package/bin/gsd-t-transcript-tee.cjs +246 -0
- package/bin/gsd-t-unattended-heartbeat.cjs +188 -0
- package/bin/gsd-t-unattended-platform.cjs +191 -27
- package/bin/gsd-t-unattended-safety.cjs +8 -1
- package/bin/gsd-t-unattended.cjs +192 -31
- package/bin/gsd-t.js +329 -2
- package/bin/supervisor-pid-fingerprint.cjs +126 -0
- package/commands/gsd-t-debug.md +63 -51
- package/commands/gsd-t-design-decompose.md +2 -7
- package/commands/gsd-t-doc-ripple.md +20 -11
- package/commands/gsd-t-execute.md +82 -50
- package/commands/gsd-t-integrate.md +43 -16
- package/commands/gsd-t-plan.md +20 -7
- package/commands/gsd-t-prd.md +19 -12
- package/commands/gsd-t-quick.md +64 -29
- package/commands/gsd-t-resume.md +51 -4
- package/commands/gsd-t-unattended.md +19 -20
- package/commands/gsd-t-verify.md +48 -32
- package/commands/gsd-t-visualize.md +19 -17
- package/commands/gsd-t-wave.md +29 -27
- package/docs/architecture.md +16 -0
- package/docs/m40-benchmark-report.md +35 -0
- package/docs/requirements.md +20 -0
- package/package.json +1 -1
- package/scripts/gsd-t-dashboard-server.js +291 -4
- package/scripts/gsd-t-dashboard.html +31 -1
- package/scripts/gsd-t-design-review-server.js +3 -1
- package/scripts/gsd-t-stream-feed-server.js +428 -0
- package/scripts/gsd-t-stream-feed.html +1168 -0
- package/scripts/gsd-t-token-aggregator.js +373 -0
- package/scripts/gsd-t-transcript.html +422 -0
- package/scripts/hooks/gsd-t-in-session-probe.js +62 -0
- package/scripts/hooks/pre-commit-capture-lint +26 -0
- package/templates/CLAUDE-global.md +69 -0
- package/scripts/gsd-t-agent-dashboard-server.js +0 -424
- package/scripts/gsd-t-agent-dashboard.html +0 -1043
|
@@ -0,0 +1,1168 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>GSD-T Stream Feed</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #0a0a10; --surface: #111118; --card: #16161f; --card-hover: #1c1c28;
|
|
10
|
+
--border: #2a2a38; --border-soft: #1e1e2a;
|
|
11
|
+
--text: #e6edf3; --muted: #9098a8; --dim: #5a6070;
|
|
12
|
+
--cyan: #00d4ff; --green: #31d158; --red: #ff5f5f; --yellow: #d4a017;
|
|
13
|
+
--purple: #a371f7; --blue: #4493f8; --orange: #ff9042;
|
|
14
|
+
--mono: 'SF Mono', 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, monospace;
|
|
15
|
+
--sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, sans-serif;
|
|
16
|
+
--radius: 8px;
|
|
17
|
+
}
|
|
18
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
19
|
+
body {
|
|
20
|
+
background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 13px;
|
|
21
|
+
line-height: 1.5; height: 100vh; display: flex; flex-direction: column; overflow: hidden;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* ── Header ───────────────────────────────────────── */
|
|
25
|
+
.header {
|
|
26
|
+
padding: 10px 20px; background: var(--surface); border-bottom: 1px solid var(--border);
|
|
27
|
+
display: flex; align-items: center; gap: 14px; flex-shrink: 0;
|
|
28
|
+
}
|
|
29
|
+
.logo { font-weight: 700; letter-spacing: -0.3px; font-size: 14px; }
|
|
30
|
+
.logo .accent { color: var(--cyan); }
|
|
31
|
+
.status-badge {
|
|
32
|
+
display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px;
|
|
33
|
+
border-radius: 10px; font-size: 11px; font-weight: 600; background: var(--card);
|
|
34
|
+
}
|
|
35
|
+
.status-badge .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--dim); }
|
|
36
|
+
.status-badge.connected .dot { background: var(--green); animation: pulse 1.5s ease-in-out infinite; }
|
|
37
|
+
.status-badge.connected { color: var(--green); }
|
|
38
|
+
.status-badge.reconnecting .dot { background: var(--yellow); }
|
|
39
|
+
.status-badge.reconnecting { color: var(--yellow); }
|
|
40
|
+
.status-badge.disconnected .dot { background: var(--red); }
|
|
41
|
+
.status-badge.disconnected { color: var(--red); }
|
|
42
|
+
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .4 } }
|
|
43
|
+
.header-spacer { flex: 1; }
|
|
44
|
+
.frame-counter { font-family: var(--mono); font-size: 11px; color: var(--muted); }
|
|
45
|
+
|
|
46
|
+
/* ── Feed ─────────────────────────────────────────── */
|
|
47
|
+
.feed {
|
|
48
|
+
flex: 1; overflow-y: auto; padding: 16px 20px 80px; position: relative;
|
|
49
|
+
scroll-behavior: smooth;
|
|
50
|
+
}
|
|
51
|
+
.feed::-webkit-scrollbar { width: 10px; }
|
|
52
|
+
.feed::-webkit-scrollbar-track { background: var(--bg); }
|
|
53
|
+
.feed::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; }
|
|
54
|
+
.feed::-webkit-scrollbar-thumb:hover { background: var(--dim); }
|
|
55
|
+
|
|
56
|
+
/* ── Assistant card ───────────────────────────────── */
|
|
57
|
+
.msg-assistant {
|
|
58
|
+
background: var(--card); border: 1px solid var(--border-soft); border-radius: var(--radius);
|
|
59
|
+
padding: 12px 14px; margin-bottom: 10px; max-width: 880px;
|
|
60
|
+
}
|
|
61
|
+
.msg-assistant .msg-hd {
|
|
62
|
+
display: flex; align-items: center; gap: 8px; margin-bottom: 8px;
|
|
63
|
+
font-size: 11px; color: var(--muted);
|
|
64
|
+
}
|
|
65
|
+
.msg-assistant .msg-hd .role { color: var(--cyan); font-weight: 600; }
|
|
66
|
+
.msg-assistant .msg-hd .meta { font-family: var(--mono); color: var(--dim); }
|
|
67
|
+
.msg-assistant .msg-body { font-size: 13px; line-height: 1.55; color: var(--text); }
|
|
68
|
+
.msg-assistant .msg-body p { margin: 0 0 8px; }
|
|
69
|
+
.msg-assistant .msg-body p:last-child { margin-bottom: 0; }
|
|
70
|
+
.msg-assistant .msg-body code {
|
|
71
|
+
background: var(--bg); border: 1px solid var(--border-soft); padding: 1px 5px;
|
|
72
|
+
border-radius: 3px; font-family: var(--mono); font-size: 12px; color: var(--orange);
|
|
73
|
+
}
|
|
74
|
+
.msg-assistant .msg-body pre {
|
|
75
|
+
background: var(--bg); border: 1px solid var(--border-soft); padding: 10px;
|
|
76
|
+
border-radius: 6px; overflow-x: auto; margin: 8px 0; font-family: var(--mono);
|
|
77
|
+
font-size: 12px; line-height: 1.5;
|
|
78
|
+
}
|
|
79
|
+
.msg-assistant .msg-body pre code { background: none; border: none; padding: 0; color: var(--text); }
|
|
80
|
+
.msg-assistant .msg-body strong { color: var(--text); font-weight: 600; }
|
|
81
|
+
.msg-assistant .msg-body em { font-style: italic; color: var(--muted); }
|
|
82
|
+
.msg-assistant .msg-body ul, .msg-assistant .msg-body ol { margin: 6px 0 8px 22px; }
|
|
83
|
+
.msg-assistant .msg-body li { margin-bottom: 2px; }
|
|
84
|
+
|
|
85
|
+
/* ── Tool call (collapsible) ──────────────────────── */
|
|
86
|
+
.tool-block {
|
|
87
|
+
background: var(--card); border: 1px solid var(--border-soft); border-radius: var(--radius);
|
|
88
|
+
margin-bottom: 10px; max-width: 880px; overflow: hidden;
|
|
89
|
+
}
|
|
90
|
+
.tool-block.failed { border-color: rgba(255,95,95,0.4); }
|
|
91
|
+
.tool-hd {
|
|
92
|
+
display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer;
|
|
93
|
+
user-select: none; background: transparent; transition: background 0.1s;
|
|
94
|
+
}
|
|
95
|
+
.tool-hd:hover { background: var(--card-hover); }
|
|
96
|
+
.tool-chevron {
|
|
97
|
+
display: inline-block; width: 10px; height: 10px; color: var(--dim);
|
|
98
|
+
transition: transform 0.15s; font-family: var(--mono); font-size: 10px;
|
|
99
|
+
}
|
|
100
|
+
.tool-block.open .tool-chevron { transform: rotate(90deg); }
|
|
101
|
+
.tool-icon {
|
|
102
|
+
width: 22px; height: 22px; border-radius: 5px; background: rgba(163,113,247,0.15);
|
|
103
|
+
border: 1px solid rgba(163,113,247,0.35); color: var(--purple);
|
|
104
|
+
display: flex; align-items: center; justify-content: center;
|
|
105
|
+
font-family: var(--mono); font-size: 11px; font-weight: 600;
|
|
106
|
+
}
|
|
107
|
+
.tool-block.failed .tool-icon { background: rgba(255,95,95,0.15); border-color: var(--red); color: var(--red); }
|
|
108
|
+
.tool-name { font-family: var(--mono); font-size: 12px; color: var(--text); font-weight: 600; }
|
|
109
|
+
.tool-preview {
|
|
110
|
+
flex: 1; font-family: var(--mono); font-size: 11px; color: var(--muted);
|
|
111
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
|
|
112
|
+
}
|
|
113
|
+
.tool-dur { font-family: var(--mono); font-size: 11px; color: var(--dim); flex-shrink: 0; }
|
|
114
|
+
.tool-body {
|
|
115
|
+
display: none; padding: 0 12px 12px; border-top: 1px solid var(--border-soft);
|
|
116
|
+
font-family: var(--mono); font-size: 12px;
|
|
117
|
+
}
|
|
118
|
+
.tool-block.open .tool-body { display: block; }
|
|
119
|
+
.tool-section { margin-top: 10px; }
|
|
120
|
+
.tool-section-label {
|
|
121
|
+
font-size: 10px; color: var(--dim); text-transform: uppercase; letter-spacing: 0.7px;
|
|
122
|
+
margin-bottom: 4px; font-family: var(--sans); font-weight: 600;
|
|
123
|
+
}
|
|
124
|
+
.tool-section pre {
|
|
125
|
+
background: var(--bg); border: 1px solid var(--border-soft); padding: 8px;
|
|
126
|
+
border-radius: 5px; overflow-x: auto; font-family: var(--mono); font-size: 12px;
|
|
127
|
+
white-space: pre-wrap; word-break: break-word; color: var(--text); max-height: 400px;
|
|
128
|
+
overflow-y: auto;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* ── Boundaries ───────────────────────────────────── */
|
|
132
|
+
.boundary {
|
|
133
|
+
padding: 6px 12px; margin: 12px 0; border-radius: 4px; font-family: var(--mono);
|
|
134
|
+
font-size: 11px; color: var(--muted); background: var(--surface);
|
|
135
|
+
border-left: 3px solid var(--border);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* Task boundary: narrow horizontal banner, color-coded by state */
|
|
139
|
+
.task-banner {
|
|
140
|
+
display: flex; align-items: center; gap: 10px; padding: 8px 14px;
|
|
141
|
+
margin: 10px 0; border-radius: var(--radius);
|
|
142
|
+
background: var(--surface); border: 1px solid var(--border-soft);
|
|
143
|
+
border-left: 3px solid var(--dim); font-family: var(--mono); font-size: 12px;
|
|
144
|
+
cursor: pointer; user-select: none; transition: background 0.1s;
|
|
145
|
+
max-width: 880px;
|
|
146
|
+
}
|
|
147
|
+
.task-banner:hover { background: var(--card-hover); }
|
|
148
|
+
.task-banner.start { border-left-color: var(--green); }
|
|
149
|
+
.task-banner.done { border-left-color: var(--dim); opacity: 0.75; }
|
|
150
|
+
.task-banner.failed { border-left-color: var(--red); background: rgba(255,95,95,0.06); }
|
|
151
|
+
.task-banner .state-dot {
|
|
152
|
+
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
|
153
|
+
}
|
|
154
|
+
.task-banner.start .state-dot { background: var(--green); animation: pulse 1.5s ease-in-out infinite; }
|
|
155
|
+
.task-banner.done .state-dot { background: var(--dim); }
|
|
156
|
+
.task-banner.failed .state-dot { background: var(--red); }
|
|
157
|
+
.task-banner .task-id { color: var(--text); font-weight: 600; }
|
|
158
|
+
.task-banner .task-meta { color: var(--muted); font-size: 11px; }
|
|
159
|
+
.task-banner .task-dur { margin-left: auto; color: var(--dim); font-size: 11px; }
|
|
160
|
+
.task-banner .task-pid { color: var(--dim); font-size: 11px; }
|
|
161
|
+
|
|
162
|
+
/* Wave boundary: larger, more prominent, full-width */
|
|
163
|
+
.wave-banner {
|
|
164
|
+
display: flex; align-items: center; gap: 12px; padding: 12px 18px;
|
|
165
|
+
margin: 20px 0 12px; border-radius: var(--radius);
|
|
166
|
+
background: linear-gradient(90deg, rgba(0,212,255,0.08), rgba(0,212,255,0.02));
|
|
167
|
+
border: 1px solid rgba(0,212,255,0.25);
|
|
168
|
+
font-family: var(--mono); font-size: 13px; color: var(--text);
|
|
169
|
+
cursor: pointer; user-select: none; max-width: 880px;
|
|
170
|
+
}
|
|
171
|
+
.wave-banner:hover { background: linear-gradient(90deg, rgba(0,212,255,0.14), rgba(0,212,255,0.04)); }
|
|
172
|
+
.wave-banner.done { opacity: 0.7; border-color: rgba(0,212,255,0.15); }
|
|
173
|
+
.wave-banner.failed { background: rgba(255,95,95,0.06); border-color: rgba(255,95,95,0.35); }
|
|
174
|
+
.wave-banner .wave-icon {
|
|
175
|
+
font-size: 16px; color: var(--cyan); font-weight: 700; letter-spacing: -1px;
|
|
176
|
+
}
|
|
177
|
+
.wave-banner.failed .wave-icon { color: var(--red); }
|
|
178
|
+
.wave-banner .wave-label { font-weight: 700; color: var(--text); }
|
|
179
|
+
.wave-banner .wave-meta { color: var(--muted); font-size: 12px; }
|
|
180
|
+
.wave-banner .wave-dur { margin-left: auto; color: var(--muted); font-size: 12px; }
|
|
181
|
+
|
|
182
|
+
/* ── Empty / error states ─────────────────────────── */
|
|
183
|
+
.empty-state {
|
|
184
|
+
padding: 60px 20px; text-align: center; color: var(--dim); font-size: 13px;
|
|
185
|
+
}
|
|
186
|
+
.empty-state .big { font-size: 20px; margin-bottom: 10px; color: var(--muted); }
|
|
187
|
+
|
|
188
|
+
/* ── Filter panel ─────────────────────────────────── */
|
|
189
|
+
.filter-toggle {
|
|
190
|
+
background: var(--card); border: 1px solid var(--border); border-radius: 6px;
|
|
191
|
+
color: var(--muted); font-family: var(--sans); font-size: 11px; padding: 4px 10px;
|
|
192
|
+
cursor: pointer; display: flex; align-items: center; gap: 5px;
|
|
193
|
+
}
|
|
194
|
+
.filter-toggle:hover { color: var(--text); border-color: var(--text); }
|
|
195
|
+
.filter-toggle.active { color: var(--cyan); border-color: var(--cyan); }
|
|
196
|
+
.filter-badge {
|
|
197
|
+
background: var(--cyan); color: var(--bg); font-size: 10px; font-weight: 700;
|
|
198
|
+
border-radius: 9px; padding: 1px 6px; min-width: 16px; text-align: center;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.filter-panel {
|
|
202
|
+
position: absolute; top: 52px; right: 20px; width: 320px; max-height: 70vh;
|
|
203
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
|
204
|
+
padding: 14px; z-index: 50; box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
|
205
|
+
display: none; overflow-y: auto; font-family: var(--sans); font-size: 12px;
|
|
206
|
+
}
|
|
207
|
+
.filter-panel.open { display: block; }
|
|
208
|
+
.filter-section { margin-bottom: 14px; }
|
|
209
|
+
.filter-section:last-child { margin-bottom: 0; }
|
|
210
|
+
.filter-section-hd {
|
|
211
|
+
display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;
|
|
212
|
+
}
|
|
213
|
+
.filter-section-title {
|
|
214
|
+
font-size: 10px; text-transform: uppercase; letter-spacing: 0.7px;
|
|
215
|
+
color: var(--muted); font-weight: 600;
|
|
216
|
+
}
|
|
217
|
+
.filter-clear-btn {
|
|
218
|
+
background: none; border: none; color: var(--cyan); font-size: 11px; cursor: pointer;
|
|
219
|
+
font-family: var(--sans);
|
|
220
|
+
}
|
|
221
|
+
.filter-clear-btn:hover { color: var(--text); }
|
|
222
|
+
.filter-options { display: flex; flex-wrap: wrap; gap: 5px; }
|
|
223
|
+
.filter-option {
|
|
224
|
+
display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px;
|
|
225
|
+
background: var(--card); border: 1px solid var(--border-soft); border-radius: 4px;
|
|
226
|
+
cursor: pointer; user-select: none; font-family: var(--mono); font-size: 11px;
|
|
227
|
+
color: var(--muted); transition: background 0.1s;
|
|
228
|
+
}
|
|
229
|
+
.filter-option:hover { background: var(--card-hover); color: var(--text); }
|
|
230
|
+
.filter-option.selected {
|
|
231
|
+
background: rgba(0,212,255,0.12); border-color: rgba(0,212,255,0.5); color: var(--cyan);
|
|
232
|
+
}
|
|
233
|
+
.filter-empty {
|
|
234
|
+
color: var(--dim); font-size: 11px; font-style: italic; padding: 2px 0;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/* Filter hiding — apply to feed items that carry filter metadata */
|
|
238
|
+
.feed.filtered .filterable[data-filtered="true"] { display: none; }
|
|
239
|
+
|
|
240
|
+
/* ── Token usage corner bar ───────────────────────── */
|
|
241
|
+
.token-bar {
|
|
242
|
+
position: fixed; top: 62px; right: 20px; z-index: 45;
|
|
243
|
+
background: rgba(22,22,31,0.92); border: 1px solid var(--border);
|
|
244
|
+
border-radius: var(--radius); padding: 6px 12px;
|
|
245
|
+
font-family: var(--mono); font-size: 11px; color: var(--muted);
|
|
246
|
+
display: flex; align-items: center; gap: 10px; backdrop-filter: blur(8px);
|
|
247
|
+
}
|
|
248
|
+
.token-bar .tb-cost { color: var(--green); font-weight: 600; }
|
|
249
|
+
.token-bar .tb-tokens { color: var(--cyan); }
|
|
250
|
+
.token-bar .tb-spawns { color: var(--purple); }
|
|
251
|
+
.token-bar .tb-sep { color: var(--dim); }
|
|
252
|
+
.token-bar.empty { opacity: 0.5; }
|
|
253
|
+
|
|
254
|
+
/* Inline usage chip on task + wave banners */
|
|
255
|
+
.usage-chip {
|
|
256
|
+
font-family: var(--mono); font-size: 11px; color: var(--muted);
|
|
257
|
+
padding: 1px 6px; background: var(--bg); border-radius: 3px;
|
|
258
|
+
border: 1px solid var(--border-soft); white-space: nowrap;
|
|
259
|
+
}
|
|
260
|
+
.usage-chip.missing { color: var(--dim); font-style: italic; }
|
|
261
|
+
.usage-chip .cost { color: var(--green); font-weight: 600; }
|
|
262
|
+
|
|
263
|
+
/* ── Jump to live button ──────────────────────────── */
|
|
264
|
+
.jump-to-live {
|
|
265
|
+
position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%);
|
|
266
|
+
background: var(--cyan); color: var(--bg); font-family: var(--sans); font-size: 12px;
|
|
267
|
+
font-weight: 600; padding: 8px 16px; border-radius: 20px; border: none;
|
|
268
|
+
cursor: pointer; box-shadow: 0 4px 16px rgba(0,212,255,0.4);
|
|
269
|
+
display: none; align-items: center; gap: 6px; z-index: 40;
|
|
270
|
+
}
|
|
271
|
+
.jump-to-live.visible { display: inline-flex; }
|
|
272
|
+
.jump-to-live:hover { background: #2ce0ff; }
|
|
273
|
+
</style>
|
|
274
|
+
</head>
|
|
275
|
+
<body>
|
|
276
|
+
<div class="header">
|
|
277
|
+
<div class="logo"><span class="accent">◆</span> GSD-T <span style="color:var(--muted);font-weight:400">stream feed</span></div>
|
|
278
|
+
<div id="statusBadge" class="status-badge disconnected"><span class="dot"></span><span id="statusText">disconnected</span></div>
|
|
279
|
+
<div class="header-spacer"></div>
|
|
280
|
+
<button id="filterToggle" class="filter-toggle" type="button">
|
|
281
|
+
<span>▾ filter</span>
|
|
282
|
+
<span id="filterBadge" class="filter-badge" style="display:none">0</span>
|
|
283
|
+
</button>
|
|
284
|
+
<div class="frame-counter"><span id="frameCount">0</span> frames</div>
|
|
285
|
+
</div>
|
|
286
|
+
<div id="filterPanel" class="filter-panel">
|
|
287
|
+
<div class="filter-section">
|
|
288
|
+
<div class="filter-section-hd">
|
|
289
|
+
<span class="filter-section-title">Tasks</span>
|
|
290
|
+
<button class="filter-clear-btn" data-clear="task">clear</button>
|
|
291
|
+
</div>
|
|
292
|
+
<div id="filterOptionsTask" class="filter-options"><div class="filter-empty">none yet</div></div>
|
|
293
|
+
</div>
|
|
294
|
+
<div class="filter-section">
|
|
295
|
+
<div class="filter-section-hd">
|
|
296
|
+
<span class="filter-section-title">Domains</span>
|
|
297
|
+
<button class="filter-clear-btn" data-clear="domain">clear</button>
|
|
298
|
+
</div>
|
|
299
|
+
<div id="filterOptionsDomain" class="filter-options"><div class="filter-empty">none yet</div></div>
|
|
300
|
+
</div>
|
|
301
|
+
<div class="filter-section">
|
|
302
|
+
<div class="filter-section-hd">
|
|
303
|
+
<span class="filter-section-title">Waves</span>
|
|
304
|
+
<button class="filter-clear-btn" data-clear="wave">clear</button>
|
|
305
|
+
</div>
|
|
306
|
+
<div id="filterOptionsWave" class="filter-options"><div class="filter-empty">none yet</div></div>
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
<div id="feed" class="feed">
|
|
310
|
+
<div id="emptyState" class="empty-state">
|
|
311
|
+
<div class="big">◇</div>
|
|
312
|
+
<div>Waiting for frames…</div>
|
|
313
|
+
<div style="margin-top:6px;font-size:11px">connecting to <code style="font-family:var(--mono);color:var(--cyan)">ws://127.0.0.1:7842/feed</code></div>
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
316
|
+
<div id="tokenBar" class="token-bar empty">
|
|
317
|
+
<span class="tb-cost" id="tbCost">—</span>
|
|
318
|
+
<span class="tb-sep">·</span>
|
|
319
|
+
<span class="tb-tokens" id="tbTokens">0 tokens</span>
|
|
320
|
+
<span class="tb-sep">·</span>
|
|
321
|
+
<span class="tb-spawns" id="tbSpawns">0 spawns</span>
|
|
322
|
+
</div>
|
|
323
|
+
<button id="jumpToLive" class="jump-to-live" type="button">↓ Jump to live</button>
|
|
324
|
+
|
|
325
|
+
<script>
|
|
326
|
+
'use strict';
|
|
327
|
+
|
|
328
|
+
// ── Config ─────────────────────────────────────────
|
|
329
|
+
const WS_URL_DEFAULT = `ws://${location.hostname || '127.0.0.1'}:7842/feed?from=0`;
|
|
330
|
+
const RECONNECT_DELAY_MS = 1000;
|
|
331
|
+
|
|
332
|
+
// ── State ──────────────────────────────────────────
|
|
333
|
+
const state = {
|
|
334
|
+
ws: null,
|
|
335
|
+
frameCount: 0,
|
|
336
|
+
toolUseById: new Map(), // tool_use.id → DOM element for pairing with tool_result
|
|
337
|
+
taskStartByKey: new Map(), // `${workerPid}:${taskId}` → { bannerEl, ts, firstFrameEl }
|
|
338
|
+
waveStartByNum: new Map(), // wave number → { bannerEl, ts }
|
|
339
|
+
lastTaskKey: null, // most recent task-boundary:start key (for firstFrameEl anchor)
|
|
340
|
+
lastTaskContext: null, // { task, domain, wave } — applied to subsequent frames for filtering
|
|
341
|
+
reconnectTimer: null,
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// ── DOM refs ───────────────────────────────────────
|
|
345
|
+
const feed = document.getElementById('feed');
|
|
346
|
+
const emptyState = document.getElementById('emptyState');
|
|
347
|
+
const statusBadge = document.getElementById('statusBadge');
|
|
348
|
+
const statusText = document.getElementById('statusText');
|
|
349
|
+
const frameCountEl = document.getElementById('frameCount');
|
|
350
|
+
const filterToggle = document.getElementById('filterToggle');
|
|
351
|
+
const filterPanel = document.getElementById('filterPanel');
|
|
352
|
+
const filterBadge = document.getElementById('filterBadge');
|
|
353
|
+
const filterOptTask = document.getElementById('filterOptionsTask');
|
|
354
|
+
const filterOptDomain = document.getElementById('filterOptionsDomain');
|
|
355
|
+
const filterOptWave = document.getElementById('filterOptionsWave');
|
|
356
|
+
const jumpToLiveBtn = document.getElementById('jumpToLive');
|
|
357
|
+
const tokenBar = document.getElementById('tokenBar');
|
|
358
|
+
const tbCost = document.getElementById('tbCost');
|
|
359
|
+
const tbTokens = document.getElementById('tbTokens');
|
|
360
|
+
const tbSpawns = document.getElementById('tbSpawns');
|
|
361
|
+
|
|
362
|
+
// ── Usage aggregation state ────────────────────────
|
|
363
|
+
// Per-task aggregated usage, keyed by `${workerPid}:${taskId}`.
|
|
364
|
+
// wave totals computed on-demand from task entries matching a wave.
|
|
365
|
+
const usageState = {
|
|
366
|
+
byTaskKey: new Map(), // key → { pid, taskId, wave, domain, usage, cost, numTurns, durationMs, hasResult, spawned }
|
|
367
|
+
totals: { cost: 0, tokens: 0, spawns: 0, anyCostSeen: false },
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
function humanizeTokens(n) {
|
|
371
|
+
if (n == null || !Number.isFinite(n)) return '0';
|
|
372
|
+
const abs = Math.abs(n);
|
|
373
|
+
if (abs < 1000) return String(n);
|
|
374
|
+
if (abs < 1e6) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + 'K';
|
|
375
|
+
if (abs < 1e9) return (n / 1e6).toFixed(n >= 10e6 ? 0 : 1) + 'M';
|
|
376
|
+
return (n / 1e9).toFixed(1) + 'B';
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function formatCost(c) {
|
|
380
|
+
if (c == null || !Number.isFinite(c)) return '—';
|
|
381
|
+
return '$' + c.toFixed(2);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function refreshTokenBar() {
|
|
385
|
+
const t = usageState.totals;
|
|
386
|
+
if (t.spawns === 0 && !t.anyCostSeen) {
|
|
387
|
+
tokenBar.classList.add('empty');
|
|
388
|
+
} else {
|
|
389
|
+
tokenBar.classList.remove('empty');
|
|
390
|
+
}
|
|
391
|
+
tbCost.textContent = t.anyCostSeen ? formatCost(t.cost) : '—';
|
|
392
|
+
tbTokens.textContent = humanizeTokens(t.tokens) + ' tokens';
|
|
393
|
+
tbSpawns.textContent = t.spawns + ' spawn' + (t.spawns === 1 ? '' : 's');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── Auto-scroll + jump-to-live state ───────────────
|
|
397
|
+
// Follow mode: feed auto-scrolls to bottom on new frames.
|
|
398
|
+
// When user scrolls up (more than SCROLL_PAUSE_THRESHOLD_PX from bottom), pause auto-scroll.
|
|
399
|
+
let autoScrollEnabled = true;
|
|
400
|
+
const SCROLL_PAUSE_THRESHOLD_PX = 80;
|
|
401
|
+
|
|
402
|
+
function isNearBottom() {
|
|
403
|
+
return feed.scrollHeight - (feed.scrollTop + feed.clientHeight) < SCROLL_PAUSE_THRESHOLD_PX;
|
|
404
|
+
}
|
|
405
|
+
feed.addEventListener('scroll', () => {
|
|
406
|
+
if (isNearBottom()) {
|
|
407
|
+
autoScrollEnabled = true;
|
|
408
|
+
jumpToLiveBtn.classList.remove('visible');
|
|
409
|
+
} else {
|
|
410
|
+
autoScrollEnabled = false;
|
|
411
|
+
jumpToLiveBtn.classList.add('visible');
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
jumpToLiveBtn.addEventListener('click', () => {
|
|
415
|
+
autoScrollEnabled = true;
|
|
416
|
+
feed.scrollTop = feed.scrollHeight;
|
|
417
|
+
jumpToLiveBtn.classList.remove('visible');
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// ── Filter state + localStorage persistence ────────
|
|
421
|
+
const LS_KEY = 'gsd-t-stream-feed-filters-v1';
|
|
422
|
+
const filterState = {
|
|
423
|
+
enabled: { task: new Set(), domain: new Set(), wave: new Set() },
|
|
424
|
+
seen: { task: new Set(), domain: new Set(), wave: new Set() },
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
function loadFilterPrefs() {
|
|
428
|
+
try {
|
|
429
|
+
const raw = localStorage.getItem(LS_KEY);
|
|
430
|
+
if (!raw) return;
|
|
431
|
+
const obj = JSON.parse(raw);
|
|
432
|
+
if (obj && typeof obj === 'object') {
|
|
433
|
+
for (const k of ['task', 'domain', 'wave']) {
|
|
434
|
+
if (Array.isArray(obj[k])) filterState.enabled[k] = new Set(obj[k]);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
} catch (_) {}
|
|
438
|
+
}
|
|
439
|
+
function saveFilterPrefs() {
|
|
440
|
+
try {
|
|
441
|
+
localStorage.setItem(LS_KEY, JSON.stringify({
|
|
442
|
+
task: Array.from(filterState.enabled.task),
|
|
443
|
+
domain: Array.from(filterState.enabled.domain),
|
|
444
|
+
wave: Array.from(filterState.enabled.wave),
|
|
445
|
+
}));
|
|
446
|
+
} catch (_) {}
|
|
447
|
+
}
|
|
448
|
+
loadFilterPrefs();
|
|
449
|
+
|
|
450
|
+
filterToggle.addEventListener('click', () => {
|
|
451
|
+
filterPanel.classList.toggle('open');
|
|
452
|
+
filterToggle.classList.toggle('active', filterPanel.classList.contains('open'));
|
|
453
|
+
});
|
|
454
|
+
document.addEventListener('click', (ev) => {
|
|
455
|
+
if (!filterPanel.contains(ev.target) && !filterToggle.contains(ev.target)) {
|
|
456
|
+
filterPanel.classList.remove('open');
|
|
457
|
+
filterToggle.classList.remove('active');
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
for (const btn of document.querySelectorAll('.filter-clear-btn')) {
|
|
461
|
+
btn.addEventListener('click', (ev) => {
|
|
462
|
+
ev.stopPropagation();
|
|
463
|
+
const kind = btn.dataset.clear;
|
|
464
|
+
filterState.enabled[kind].clear();
|
|
465
|
+
renderFilterOptions();
|
|
466
|
+
applyFilters();
|
|
467
|
+
saveFilterPrefs();
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function addFilterValue(kind, value) {
|
|
472
|
+
if (value == null || value === '') return;
|
|
473
|
+
const v = String(value);
|
|
474
|
+
if (filterState.seen[kind].has(v)) return;
|
|
475
|
+
filterState.seen[kind].add(v);
|
|
476
|
+
renderFilterOptions();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function renderFilterOptions() {
|
|
480
|
+
const kinds = [
|
|
481
|
+
{ kind: 'task', el: filterOptTask },
|
|
482
|
+
{ kind: 'domain', el: filterOptDomain },
|
|
483
|
+
{ kind: 'wave', el: filterOptWave },
|
|
484
|
+
];
|
|
485
|
+
for (const { kind, el } of kinds) {
|
|
486
|
+
const values = Array.from(filterState.seen[kind]).sort((a, b) => {
|
|
487
|
+
if (kind === 'wave') return Number(a) - Number(b);
|
|
488
|
+
return String(a).localeCompare(String(b));
|
|
489
|
+
});
|
|
490
|
+
if (values.length === 0) {
|
|
491
|
+
el.innerHTML = '<div class="filter-empty">none yet</div>';
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
el.innerHTML = '';
|
|
495
|
+
for (const v of values) {
|
|
496
|
+
const opt = document.createElement('span');
|
|
497
|
+
opt.className = 'filter-option' + (filterState.enabled[kind].has(v) ? ' selected' : '');
|
|
498
|
+
opt.textContent = v;
|
|
499
|
+
opt.addEventListener('click', () => {
|
|
500
|
+
if (filterState.enabled[kind].has(v)) filterState.enabled[kind].delete(v);
|
|
501
|
+
else filterState.enabled[kind].add(v);
|
|
502
|
+
opt.classList.toggle('selected');
|
|
503
|
+
applyFilters();
|
|
504
|
+
saveFilterPrefs();
|
|
505
|
+
});
|
|
506
|
+
el.appendChild(opt);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
// Update badge
|
|
510
|
+
const total = filterState.enabled.task.size + filterState.enabled.domain.size + filterState.enabled.wave.size;
|
|
511
|
+
if (total > 0) {
|
|
512
|
+
filterBadge.textContent = String(total);
|
|
513
|
+
filterBadge.style.display = '';
|
|
514
|
+
} else {
|
|
515
|
+
filterBadge.style.display = 'none';
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function elementMatchesFilters(el) {
|
|
520
|
+
// Element is hidden if ANY active filter kind is non-empty AND the element's
|
|
521
|
+
// value for that kind is NOT in the enabled set.
|
|
522
|
+
const { task, domain, wave } = filterState.enabled;
|
|
523
|
+
if (task.size > 0) {
|
|
524
|
+
const v = el.dataset.filterTask;
|
|
525
|
+
if (!v || !task.has(v)) return false;
|
|
526
|
+
}
|
|
527
|
+
if (domain.size > 0) {
|
|
528
|
+
const v = el.dataset.filterDomain;
|
|
529
|
+
if (!v || !domain.has(v)) return false;
|
|
530
|
+
}
|
|
531
|
+
if (wave.size > 0) {
|
|
532
|
+
const v = el.dataset.filterWave;
|
|
533
|
+
if (!v || !wave.has(v)) return false;
|
|
534
|
+
}
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function applyFilters() {
|
|
539
|
+
const any = filterState.enabled.task.size + filterState.enabled.domain.size + filterState.enabled.wave.size > 0;
|
|
540
|
+
feed.classList.toggle('filtered', any);
|
|
541
|
+
if (!any) {
|
|
542
|
+
for (const el of feed.querySelectorAll('.filterable')) {
|
|
543
|
+
el.dataset.filtered = 'false';
|
|
544
|
+
}
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
for (const el of feed.querySelectorAll('.filterable')) {
|
|
548
|
+
el.dataset.filtered = elementMatchesFilters(el) ? 'false' : 'true';
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Current filter context applied to every new element: last-seen task-start's metadata.
|
|
553
|
+
function currentFilterContext() {
|
|
554
|
+
if (!state.lastTaskKey) return { task: null, domain: null, wave: null };
|
|
555
|
+
return state.lastTaskContext || { task: null, domain: null, wave: null };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function tagFilterable(el, ctx) {
|
|
559
|
+
el.classList.add('filterable');
|
|
560
|
+
if (ctx.task) el.dataset.filterTask = ctx.task;
|
|
561
|
+
if (ctx.domain) el.dataset.filterDomain = ctx.domain;
|
|
562
|
+
if (ctx.wave != null) el.dataset.filterWave = String(ctx.wave);
|
|
563
|
+
// Apply current visibility immediately
|
|
564
|
+
if (feed.classList.contains('filtered')) {
|
|
565
|
+
el.dataset.filtered = elementMatchesFilters(el) ? 'false' : 'true';
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ── Status indicator ───────────────────────────────
|
|
570
|
+
function setStatus(kind, label) {
|
|
571
|
+
statusBadge.className = `status-badge ${kind}`;
|
|
572
|
+
statusText.textContent = label;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function incrementFrameCount() {
|
|
576
|
+
state.frameCount++;
|
|
577
|
+
frameCountEl.textContent = state.frameCount;
|
|
578
|
+
if (emptyState && emptyState.parentNode) emptyState.remove();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ── Minimal markdown renderer ──────────────────────
|
|
582
|
+
// Handles: fenced code blocks, inline code, bold, italic, lists, paragraphs.
|
|
583
|
+
// Escapes HTML first, then applies markdown transforms.
|
|
584
|
+
function escapeHtml(s) {
|
|
585
|
+
return String(s)
|
|
586
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
587
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function renderMarkdown(src) {
|
|
591
|
+
if (!src) return '';
|
|
592
|
+
// Extract fenced code blocks first so their contents aren't markdown-processed.
|
|
593
|
+
const codeBlocks = [];
|
|
594
|
+
let text = String(src).replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => {
|
|
595
|
+
const idx = codeBlocks.length;
|
|
596
|
+
codeBlocks.push(`<pre><code>${escapeHtml(code.replace(/\n$/, ''))}</code></pre>`);
|
|
597
|
+
return `\x00CODEBLOCK${idx}\x00`;
|
|
598
|
+
});
|
|
599
|
+
text = escapeHtml(text);
|
|
600
|
+
// Inline code
|
|
601
|
+
text = text.replace(/`([^`\n]+)`/g, (_, c) => `<code>${c}</code>`);
|
|
602
|
+
// Bold **x** and italic *x*
|
|
603
|
+
text = text.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
|
604
|
+
text = text.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
|
|
605
|
+
// Lists: convert consecutive "- " or "* " lines into <ul>
|
|
606
|
+
text = text.replace(/(^|\n)((?:[-*] .+(?:\n|$))+)/g, (_, lead, block) => {
|
|
607
|
+
const items = block.trim().split(/\n/).map(l => l.replace(/^[-*]\s+/, '')).map(i => `<li>${i}</li>`).join('');
|
|
608
|
+
return `${lead}<ul>${items}</ul>`;
|
|
609
|
+
});
|
|
610
|
+
// Numbered lists
|
|
611
|
+
text = text.replace(/(^|\n)((?:\d+\. .+(?:\n|$))+)/g, (_, lead, block) => {
|
|
612
|
+
const items = block.trim().split(/\n/).map(l => l.replace(/^\d+\.\s+/, '')).map(i => `<li>${i}</li>`).join('');
|
|
613
|
+
return `${lead}<ol>${items}</ol>`;
|
|
614
|
+
});
|
|
615
|
+
// Paragraphs — split on blank lines, wrap non-empty chunks that aren't already block-level
|
|
616
|
+
const parts = text.split(/\n{2,}/).map(chunk => {
|
|
617
|
+
const c = chunk.trim();
|
|
618
|
+
if (!c) return '';
|
|
619
|
+
if (/^<(ul|ol|pre|h\d)/.test(c)) return c;
|
|
620
|
+
if (/^\x00CODEBLOCK\d+\x00$/.test(c)) return c;
|
|
621
|
+
return `<p>${c.replace(/\n/g, '<br>')}</p>`;
|
|
622
|
+
});
|
|
623
|
+
text = parts.filter(Boolean).join('\n');
|
|
624
|
+
// Restore code blocks
|
|
625
|
+
text = text.replace(/\x00CODEBLOCK(\d+)\x00/g, (_, i) => codeBlocks[Number(i)]);
|
|
626
|
+
return text;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ── Content extraction helpers ─────────────────────
|
|
630
|
+
function extractAssistantText(frame) {
|
|
631
|
+
// Claude stream-json: {type:"assistant", message: {content: [{type:"text", text:"..."}, {type:"tool_use", ...}]}}
|
|
632
|
+
const parts = [];
|
|
633
|
+
const toolUses = [];
|
|
634
|
+
const msg = frame && frame.message;
|
|
635
|
+
if (!msg || !Array.isArray(msg.content)) return { text: '', toolUses: [] };
|
|
636
|
+
for (const block of msg.content) {
|
|
637
|
+
if (!block || typeof block !== 'object') continue;
|
|
638
|
+
if (block.type === 'text' && typeof block.text === 'string') parts.push(block.text);
|
|
639
|
+
else if (block.type === 'tool_use') toolUses.push(block);
|
|
640
|
+
}
|
|
641
|
+
return { text: parts.join('\n\n'), toolUses };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function extractToolResult(frame) {
|
|
645
|
+
// {type:"user", message:{content:[{type:"tool_result", tool_use_id, content, is_error}]}}
|
|
646
|
+
// Also bare {type:"tool_result", ...} for direct emission.
|
|
647
|
+
if (frame && frame.type === 'tool_result') return frame;
|
|
648
|
+
const msg = frame && frame.message;
|
|
649
|
+
if (!msg || !Array.isArray(msg.content)) return null;
|
|
650
|
+
for (const block of msg.content) {
|
|
651
|
+
if (block && block.type === 'tool_result') return block;
|
|
652
|
+
}
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function formatInputPreview(input) {
|
|
657
|
+
if (input == null) return '';
|
|
658
|
+
if (typeof input === 'string') return input.length > 120 ? input.slice(0, 117) + '…' : input;
|
|
659
|
+
try {
|
|
660
|
+
const keys = Object.keys(input);
|
|
661
|
+
if (keys.length === 0) return '{}';
|
|
662
|
+
const first = keys[0];
|
|
663
|
+
const val = input[first];
|
|
664
|
+
const valStr = typeof val === 'string' ? val : JSON.stringify(val);
|
|
665
|
+
const preview = `${first}=${valStr}`;
|
|
666
|
+
return preview.length > 120 ? preview.slice(0, 117) + '…' : preview;
|
|
667
|
+
} catch (_) { return ''; }
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function formatContentPreview(content) {
|
|
671
|
+
if (content == null) return '';
|
|
672
|
+
const s = typeof content === 'string' ? content : JSON.stringify(content);
|
|
673
|
+
const firstLine = s.split('\n')[0];
|
|
674
|
+
return firstLine.length > 120 ? firstLine.slice(0, 117) + '…' : firstLine;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function formatDuration(startTs, endTs) {
|
|
678
|
+
if (!startTs || !endTs) return '';
|
|
679
|
+
const ms = new Date(endTs).getTime() - new Date(startTs).getTime();
|
|
680
|
+
if (!Number.isFinite(ms) || ms < 0) return '';
|
|
681
|
+
if (ms < 1000) return `${ms}ms`;
|
|
682
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
683
|
+
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// ── Renderers ──────────────────────────────────────
|
|
687
|
+
function renderAssistant(frame) {
|
|
688
|
+
const { text, toolUses } = extractAssistantText(frame);
|
|
689
|
+
const msgId = (frame.message && frame.message.id) || '';
|
|
690
|
+
const model = (frame.message && frame.message.model) || '';
|
|
691
|
+
|
|
692
|
+
if (text.trim()) {
|
|
693
|
+
const card = document.createElement('div');
|
|
694
|
+
card.className = 'msg-assistant';
|
|
695
|
+
const hd = document.createElement('div');
|
|
696
|
+
hd.className = 'msg-hd';
|
|
697
|
+
hd.innerHTML = `<span class="role">assistant</span><span class="meta">${escapeHtml(model || '')}${msgId ? ' · ' + escapeHtml(msgId.slice(-8)) : ''}</span>`;
|
|
698
|
+
const body = document.createElement('div');
|
|
699
|
+
body.className = 'msg-body';
|
|
700
|
+
body.innerHTML = renderMarkdown(text);
|
|
701
|
+
card.appendChild(hd);
|
|
702
|
+
card.appendChild(body);
|
|
703
|
+
tagFilterable(card, currentFilterContext());
|
|
704
|
+
feed.appendChild(card);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
for (const tu of toolUses) {
|
|
708
|
+
renderToolUse(tu, frame.ts || (frame.message && frame.message.ts));
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function renderToolUse(block, ts) {
|
|
713
|
+
const wrap = document.createElement('div');
|
|
714
|
+
wrap.className = 'tool-block';
|
|
715
|
+
wrap.dataset.toolUseId = block.id || '';
|
|
716
|
+
wrap.dataset.ts = ts || '';
|
|
717
|
+
|
|
718
|
+
const hd = document.createElement('div');
|
|
719
|
+
hd.className = 'tool-hd';
|
|
720
|
+
hd.innerHTML = `
|
|
721
|
+
<span class="tool-chevron">▶</span>
|
|
722
|
+
<span class="tool-icon">${escapeHtml((block.name || '?').slice(0, 2).toUpperCase())}</span>
|
|
723
|
+
<span class="tool-name">${escapeHtml(block.name || 'tool')}</span>
|
|
724
|
+
<span class="tool-preview">${escapeHtml(formatInputPreview(block.input))}</span>
|
|
725
|
+
<span class="tool-dur" data-role="dur"></span>
|
|
726
|
+
`;
|
|
727
|
+
hd.addEventListener('click', () => wrap.classList.toggle('open'));
|
|
728
|
+
|
|
729
|
+
const body = document.createElement('div');
|
|
730
|
+
body.className = 'tool-body';
|
|
731
|
+
body.innerHTML = `
|
|
732
|
+
<div class="tool-section">
|
|
733
|
+
<div class="tool-section-label">input</div>
|
|
734
|
+
<pre>${escapeHtml(typeof block.input === 'string' ? block.input : JSON.stringify(block.input, null, 2))}</pre>
|
|
735
|
+
</div>
|
|
736
|
+
<div class="tool-section" data-role="output" style="display:none">
|
|
737
|
+
<div class="tool-section-label">output</div>
|
|
738
|
+
<pre data-role="output-pre"></pre>
|
|
739
|
+
</div>
|
|
740
|
+
`;
|
|
741
|
+
|
|
742
|
+
wrap.appendChild(hd);
|
|
743
|
+
wrap.appendChild(body);
|
|
744
|
+
tagFilterable(wrap, currentFilterContext());
|
|
745
|
+
feed.appendChild(wrap);
|
|
746
|
+
|
|
747
|
+
if (block.id) state.toolUseById.set(block.id, wrap);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function renderToolResult(block, frameTs) {
|
|
751
|
+
const id = block.tool_use_id || block.id;
|
|
752
|
+
if (!id) return;
|
|
753
|
+
const wrap = state.toolUseById.get(id);
|
|
754
|
+
if (!wrap) return; // orphaned — tool_use never rendered (pre-subscribe)
|
|
755
|
+
|
|
756
|
+
// Pair duration
|
|
757
|
+
const startTs = wrap.dataset.ts;
|
|
758
|
+
const dur = formatDuration(startTs, frameTs);
|
|
759
|
+
const durEl = wrap.querySelector('[data-role="dur"]');
|
|
760
|
+
if (durEl && dur) durEl.textContent = dur;
|
|
761
|
+
|
|
762
|
+
// Mark as failed if needed
|
|
763
|
+
if (block.is_error) wrap.classList.add('failed');
|
|
764
|
+
|
|
765
|
+
// Populate output section
|
|
766
|
+
const outSection = wrap.querySelector('[data-role="output"]');
|
|
767
|
+
const outPre = wrap.querySelector('[data-role="output-pre"]');
|
|
768
|
+
if (outSection && outPre) {
|
|
769
|
+
const contentStr = typeof block.content === 'string'
|
|
770
|
+
? block.content
|
|
771
|
+
: Array.isArray(block.content)
|
|
772
|
+
? block.content.map(c => (c && c.type === 'text' ? c.text : JSON.stringify(c))).join('\n')
|
|
773
|
+
: JSON.stringify(block.content, null, 2);
|
|
774
|
+
outPre.textContent = contentStr;
|
|
775
|
+
outSection.style.display = '';
|
|
776
|
+
|
|
777
|
+
// Update header preview to show first line of output
|
|
778
|
+
const preview = wrap.querySelector('.tool-preview');
|
|
779
|
+
if (preview) preview.textContent = formatContentPreview(contentStr);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function renderResultSummary(frame) {
|
|
784
|
+
// {type:"result"} — final worker summary. Captures usage into usageState, then
|
|
785
|
+
// renders a compact boundary line and enriches the active task's start banner.
|
|
786
|
+
const usage = frame.usage || {};
|
|
787
|
+
const cost = typeof frame.total_cost_usd === 'number' ? frame.total_cost_usd
|
|
788
|
+
: typeof frame.cost_usd === 'number' ? frame.cost_usd : null;
|
|
789
|
+
const inTok = usage.input_tokens;
|
|
790
|
+
const outTok = usage.output_tokens;
|
|
791
|
+
const crTok = usage.cache_read_input_tokens;
|
|
792
|
+
const ccTok = usage.cache_creation_input_tokens;
|
|
793
|
+
|
|
794
|
+
// Attribute to the currently-active task (if any).
|
|
795
|
+
const key = state.lastTaskKey;
|
|
796
|
+
if (key) {
|
|
797
|
+
const entry = usageState.byTaskKey.get(key) || {};
|
|
798
|
+
const ctx = state.lastTaskContext || {};
|
|
799
|
+
if (entry.pid == null) {
|
|
800
|
+
const pidFromKey = Number(key.split(':')[0]);
|
|
801
|
+
entry.pid = Number.isFinite(pidFromKey) && pidFromKey > 0 ? pidFromKey : null;
|
|
802
|
+
}
|
|
803
|
+
entry.taskId = ctx.task || entry.taskId || null;
|
|
804
|
+
entry.domain = ctx.domain || entry.domain || null;
|
|
805
|
+
entry.wave = ctx.wave ?? entry.wave ?? null;
|
|
806
|
+
entry.usage = {
|
|
807
|
+
input_tokens: inTok, output_tokens: outTok,
|
|
808
|
+
cache_read_input_tokens: crTok, cache_creation_input_tokens: ccTok,
|
|
809
|
+
};
|
|
810
|
+
entry.cost = cost;
|
|
811
|
+
entry.numTurns = frame.num_turns ?? null;
|
|
812
|
+
entry.durationMs = frame.duration_ms ?? null;
|
|
813
|
+
entry.hasResult = true;
|
|
814
|
+
usageState.byTaskKey.set(key, entry);
|
|
815
|
+
|
|
816
|
+
// Enrich the task's start banner inline
|
|
817
|
+
const taskEntry = state.taskStartByKey.get(key);
|
|
818
|
+
if (taskEntry && taskEntry.bannerEl) enrichTaskBannerWithUsage(taskEntry.bannerEl, entry);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Update running totals (result is authoritative)
|
|
822
|
+
recomputeTotals();
|
|
823
|
+
refreshTokenBar();
|
|
824
|
+
|
|
825
|
+
// Boundary summary line
|
|
826
|
+
const div = document.createElement('div');
|
|
827
|
+
div.className = 'boundary';
|
|
828
|
+
const parts = [];
|
|
829
|
+
parts.push(cost != null ? formatCost(cost) : '—');
|
|
830
|
+
if (inTok != null) parts.push(`in=${humanizeTokens(inTok)}`);
|
|
831
|
+
if (outTok != null) parts.push(`out=${humanizeTokens(outTok)}`);
|
|
832
|
+
if (crTok != null) parts.push(`cr=${humanizeTokens(crTok)}`);
|
|
833
|
+
if (frame.num_turns != null) parts.push(`${frame.num_turns} turns`);
|
|
834
|
+
if (frame.duration_ms != null) parts.push(`${(frame.duration_ms / 1000).toFixed(1)}s`);
|
|
835
|
+
div.textContent = `✓ result ${parts.join(' · ')}`;
|
|
836
|
+
tagFilterable(div, currentFilterContext());
|
|
837
|
+
feed.appendChild(div);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function enrichTaskBannerWithUsage(bannerEl, entry) {
|
|
841
|
+
if (!bannerEl) return;
|
|
842
|
+
// Remove any existing chip first
|
|
843
|
+
const existing = bannerEl.querySelector('.usage-chip');
|
|
844
|
+
if (existing) existing.remove();
|
|
845
|
+
const chip = document.createElement('span');
|
|
846
|
+
if (!entry || !entry.hasResult) {
|
|
847
|
+
chip.className = 'usage-chip missing';
|
|
848
|
+
chip.textContent = '—';
|
|
849
|
+
} else {
|
|
850
|
+
chip.className = 'usage-chip';
|
|
851
|
+
const u = entry.usage || {};
|
|
852
|
+
const cost = entry.cost != null ? formatCost(entry.cost) : null;
|
|
853
|
+
const pieces = [];
|
|
854
|
+
if (cost) pieces.push(`<span class="cost">${cost}</span>`);
|
|
855
|
+
else pieces.push('—');
|
|
856
|
+
if (u.input_tokens != null) pieces.push(`${humanizeTokens(u.input_tokens)} in`);
|
|
857
|
+
if (u.output_tokens != null) pieces.push(`${humanizeTokens(u.output_tokens)} out`);
|
|
858
|
+
if (u.cache_read_input_tokens != null) pieces.push(`${humanizeTokens(u.cache_read_input_tokens)} cr`);
|
|
859
|
+
chip.innerHTML = pieces.join(' · ');
|
|
860
|
+
}
|
|
861
|
+
// Insert before the duration element so it reads: id · meta · chip · dur
|
|
862
|
+
const durEl = bannerEl.querySelector('.task-dur');
|
|
863
|
+
if (durEl) bannerEl.insertBefore(chip, durEl);
|
|
864
|
+
else bannerEl.appendChild(chip);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function enrichWaveBannerWithUsage(bannerEl, wave) {
|
|
868
|
+
if (!bannerEl) return;
|
|
869
|
+
const existing = bannerEl.querySelector('.usage-chip');
|
|
870
|
+
if (existing) existing.remove();
|
|
871
|
+
let totalCost = 0; let anyCost = false;
|
|
872
|
+
let inTok = 0, outTok = 0, crTok = 0;
|
|
873
|
+
let matched = 0;
|
|
874
|
+
for (const e of usageState.byTaskKey.values()) {
|
|
875
|
+
if (e.wave == null || Number(e.wave) !== Number(wave)) continue;
|
|
876
|
+
matched++;
|
|
877
|
+
if (e.cost != null) { totalCost += e.cost; anyCost = true; }
|
|
878
|
+
const u = e.usage || {};
|
|
879
|
+
if (u.input_tokens != null) inTok += u.input_tokens;
|
|
880
|
+
if (u.output_tokens != null) outTok += u.output_tokens;
|
|
881
|
+
if (u.cache_read_input_tokens != null) crTok += u.cache_read_input_tokens;
|
|
882
|
+
}
|
|
883
|
+
if (matched === 0) return;
|
|
884
|
+
const chip = document.createElement('span');
|
|
885
|
+
chip.className = anyCost ? 'usage-chip' : 'usage-chip missing';
|
|
886
|
+
const pieces = [];
|
|
887
|
+
if (anyCost) pieces.push(`<span class="cost">${formatCost(totalCost)}</span>`);
|
|
888
|
+
else pieces.push('—');
|
|
889
|
+
pieces.push(`${humanizeTokens(inTok + outTok)} tokens`);
|
|
890
|
+
pieces.push(`${matched} tasks`);
|
|
891
|
+
chip.innerHTML = pieces.join(' · ');
|
|
892
|
+
const durEl = bannerEl.querySelector('.wave-dur');
|
|
893
|
+
if (durEl) bannerEl.insertBefore(chip, durEl);
|
|
894
|
+
else bannerEl.appendChild(chip);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function recomputeTotals() {
|
|
898
|
+
let cost = 0; let anyCost = false; let tokens = 0; let spawns = 0;
|
|
899
|
+
for (const e of usageState.byTaskKey.values()) {
|
|
900
|
+
if (e.spawned) spawns++;
|
|
901
|
+
if (e.cost != null) { cost += e.cost; anyCost = true; }
|
|
902
|
+
const u = e.usage || {};
|
|
903
|
+
if (u.input_tokens != null) tokens += u.input_tokens;
|
|
904
|
+
if (u.output_tokens != null) tokens += u.output_tokens;
|
|
905
|
+
}
|
|
906
|
+
usageState.totals.cost = cost;
|
|
907
|
+
usageState.totals.anyCostSeen = anyCost;
|
|
908
|
+
usageState.totals.tokens = tokens;
|
|
909
|
+
usageState.totals.spawns = spawns;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function renderTaskBoundary(frame) {
|
|
913
|
+
const taskId = frame.taskId || '?';
|
|
914
|
+
const domain = frame.domain || '';
|
|
915
|
+
const wave = frame.wave;
|
|
916
|
+
const pid = frame.workerPid;
|
|
917
|
+
const bState = frame.state || 'start';
|
|
918
|
+
const key = `${pid || 'nopid'}:${taskId}`;
|
|
919
|
+
|
|
920
|
+
if (bState === 'start') {
|
|
921
|
+
const el = document.createElement('div');
|
|
922
|
+
el.className = 'task-banner start';
|
|
923
|
+
el.dataset.taskKey = key;
|
|
924
|
+
el.innerHTML = `
|
|
925
|
+
<span class="state-dot"></span>
|
|
926
|
+
<span class="task-id">${escapeHtml(taskId)}</span>
|
|
927
|
+
<span class="task-meta">${escapeHtml(domain)}${wave != null ? ' · wave ' + wave : ''}</span>
|
|
928
|
+
<span class="task-pid">${pid ? 'pid ' + pid : ''}</span>
|
|
929
|
+
<span class="task-dur" data-role="dur">running…</span>
|
|
930
|
+
`;
|
|
931
|
+
el.addEventListener('click', () => {
|
|
932
|
+
const entry = state.taskStartByKey.get(key);
|
|
933
|
+
const anchor = (entry && entry.firstFrameEl) || el;
|
|
934
|
+
anchor.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
935
|
+
});
|
|
936
|
+
// Register filter values
|
|
937
|
+
addFilterValue('task', taskId);
|
|
938
|
+
if (domain) addFilterValue('domain', domain);
|
|
939
|
+
if (wave != null) addFilterValue('wave', wave);
|
|
940
|
+
const ctx = { task: taskId, domain: domain || null, wave: wave ?? null };
|
|
941
|
+
tagFilterable(el, ctx);
|
|
942
|
+
feed.appendChild(el);
|
|
943
|
+
state.taskStartByKey.set(key, { bannerEl: el, ts: frame.ts, firstFrameEl: null });
|
|
944
|
+
state.lastTaskKey = key;
|
|
945
|
+
state.lastTaskContext = ctx;
|
|
946
|
+
|
|
947
|
+
// Register a new spawn for usage totals — only count if not seen before.
|
|
948
|
+
const u = usageState.byTaskKey.get(key) || {};
|
|
949
|
+
if (!u.spawned) {
|
|
950
|
+
u.spawned = true;
|
|
951
|
+
u.taskId = taskId; u.domain = domain || null; u.wave = wave ?? null;
|
|
952
|
+
u.pid = pid || null;
|
|
953
|
+
usageState.byTaskKey.set(key, u);
|
|
954
|
+
recomputeTotals();
|
|
955
|
+
refreshTokenBar();
|
|
956
|
+
}
|
|
957
|
+
} else if (bState === 'done' || bState === 'failed') {
|
|
958
|
+
const entry = state.taskStartByKey.get(key);
|
|
959
|
+
const el = document.createElement('div');
|
|
960
|
+
el.className = `task-banner ${bState === 'done' ? 'done' : 'failed'}`;
|
|
961
|
+
el.dataset.taskKey = key;
|
|
962
|
+
const dur = entry && entry.ts ? formatDuration(entry.ts, frame.ts) : '';
|
|
963
|
+
el.innerHTML = `
|
|
964
|
+
<span class="state-dot"></span>
|
|
965
|
+
<span class="task-id">${escapeHtml(taskId)}</span>
|
|
966
|
+
<span class="task-meta">${escapeHtml(domain)}${wave != null ? ' · wave ' + wave : ''} · ${escapeHtml(bState)}</span>
|
|
967
|
+
<span class="task-pid">${pid ? 'pid ' + pid : ''}</span>
|
|
968
|
+
<span class="task-dur">${escapeHtml(dur)}</span>
|
|
969
|
+
`;
|
|
970
|
+
el.addEventListener('click', () => {
|
|
971
|
+
const startEntry = state.taskStartByKey.get(key);
|
|
972
|
+
const anchor = (startEntry && (startEntry.firstFrameEl || startEntry.bannerEl)) || el;
|
|
973
|
+
anchor.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
974
|
+
});
|
|
975
|
+
tagFilterable(el, { task: taskId, domain: domain || null, wave: wave ?? null });
|
|
976
|
+
// If usage is available (result came before done), enrich this done banner too
|
|
977
|
+
const usageEntry = usageState.byTaskKey.get(key);
|
|
978
|
+
if (usageEntry && usageEntry.hasResult) enrichTaskBannerWithUsage(el, usageEntry);
|
|
979
|
+
feed.appendChild(el);
|
|
980
|
+
|
|
981
|
+
if (entry && entry.bannerEl) {
|
|
982
|
+
entry.bannerEl.classList.remove('start');
|
|
983
|
+
entry.bannerEl.classList.add(bState === 'done' ? 'done' : 'failed');
|
|
984
|
+
const durEl = entry.bannerEl.querySelector('[data-role="dur"]');
|
|
985
|
+
if (durEl) durEl.textContent = dur || bState;
|
|
986
|
+
}
|
|
987
|
+
if (state.lastTaskKey === key) {
|
|
988
|
+
state.lastTaskKey = null;
|
|
989
|
+
state.lastTaskContext = null;
|
|
990
|
+
}
|
|
991
|
+
} else {
|
|
992
|
+
const div = document.createElement('div');
|
|
993
|
+
div.className = 'boundary';
|
|
994
|
+
div.textContent = `· task ${taskId} — ${bState}`;
|
|
995
|
+
feed.appendChild(div);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function renderWaveBoundary(frame) {
|
|
1000
|
+
const wave = frame.wave;
|
|
1001
|
+
const boundaryState = frame.state || 'start';
|
|
1002
|
+
|
|
1003
|
+
if (boundaryState === 'start') {
|
|
1004
|
+
const el = document.createElement('div');
|
|
1005
|
+
el.className = 'wave-banner start';
|
|
1006
|
+
el.dataset.wave = String(wave);
|
|
1007
|
+
el.innerHTML = `
|
|
1008
|
+
<span class="wave-icon">≡</span>
|
|
1009
|
+
<span class="wave-label">Wave ${escapeHtml(String(wave ?? '?'))}</span>
|
|
1010
|
+
<span class="wave-meta">starting…</span>
|
|
1011
|
+
<span class="wave-dur" data-role="dur"></span>
|
|
1012
|
+
`;
|
|
1013
|
+
el.addEventListener('click', () => el.scrollIntoView({ behavior: 'smooth', block: 'start' }));
|
|
1014
|
+
if (wave != null) addFilterValue('wave', wave);
|
|
1015
|
+
tagFilterable(el, { task: null, domain: null, wave: wave ?? null });
|
|
1016
|
+
feed.appendChild(el);
|
|
1017
|
+
state.waveStartByNum.set(wave, { bannerEl: el, ts: frame.ts });
|
|
1018
|
+
} else if (boundaryState === 'done' || boundaryState === 'failed') {
|
|
1019
|
+
const entry = state.waveStartByNum.get(wave);
|
|
1020
|
+
const el = document.createElement('div');
|
|
1021
|
+
el.className = `wave-banner ${boundaryState}`;
|
|
1022
|
+
el.dataset.wave = String(wave);
|
|
1023
|
+
const dur = entry && entry.ts ? formatDuration(entry.ts, frame.ts) : '';
|
|
1024
|
+
el.innerHTML = `
|
|
1025
|
+
<span class="wave-icon">${boundaryState === 'done' ? '✓' : '✗'}</span>
|
|
1026
|
+
<span class="wave-label">Wave ${escapeHtml(String(wave ?? '?'))} ${escapeHtml(boundaryState)}</span>
|
|
1027
|
+
<span class="wave-meta"></span>
|
|
1028
|
+
<span class="wave-dur">${escapeHtml(dur)}</span>
|
|
1029
|
+
`;
|
|
1030
|
+
el.addEventListener('click', () => {
|
|
1031
|
+
const anchor = (entry && entry.bannerEl) || el;
|
|
1032
|
+
anchor.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
1033
|
+
});
|
|
1034
|
+
tagFilterable(el, { task: null, domain: null, wave: wave ?? null });
|
|
1035
|
+
feed.appendChild(el);
|
|
1036
|
+
enrichWaveBannerWithUsage(el, wave);
|
|
1037
|
+
if (entry && entry.bannerEl) enrichWaveBannerWithUsage(entry.bannerEl, wave);
|
|
1038
|
+
|
|
1039
|
+
if (entry && entry.bannerEl) {
|
|
1040
|
+
entry.bannerEl.classList.remove('start');
|
|
1041
|
+
entry.bannerEl.classList.add(boundaryState);
|
|
1042
|
+
const meta = entry.bannerEl.querySelector('.wave-meta');
|
|
1043
|
+
if (meta) meta.textContent = boundaryState;
|
|
1044
|
+
const durEl = entry.bannerEl.querySelector('[data-role="dur"]');
|
|
1045
|
+
if (durEl && dur) durEl.textContent = dur;
|
|
1046
|
+
}
|
|
1047
|
+
} else {
|
|
1048
|
+
const div = document.createElement('div');
|
|
1049
|
+
div.className = 'boundary';
|
|
1050
|
+
div.textContent = `≡ wave ${wave ?? '?'} — ${boundaryState}`;
|
|
1051
|
+
feed.appendChild(div);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// ── Frame dispatcher ───────────────────────────────
|
|
1056
|
+
function handleFrame(frame) {
|
|
1057
|
+
if (!frame || typeof frame !== 'object') {
|
|
1058
|
+
console.warn('[stream-feed] malformed frame (not an object):', frame);
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
incrementFrameCount();
|
|
1062
|
+
try {
|
|
1063
|
+
switch (frame.type) {
|
|
1064
|
+
case 'assistant':
|
|
1065
|
+
renderAssistant(frame);
|
|
1066
|
+
break;
|
|
1067
|
+
case 'user': {
|
|
1068
|
+
const tr = extractToolResult(frame);
|
|
1069
|
+
if (tr) renderToolResult(tr, frame.ts || (frame.message && frame.message.ts));
|
|
1070
|
+
// else: silent — user frames without tool_result content are not rendered in T2
|
|
1071
|
+
break;
|
|
1072
|
+
}
|
|
1073
|
+
case 'tool_result':
|
|
1074
|
+
renderToolResult(frame, frame.ts);
|
|
1075
|
+
break;
|
|
1076
|
+
case 'tool_use':
|
|
1077
|
+
renderToolUse(frame, frame.ts);
|
|
1078
|
+
break;
|
|
1079
|
+
case 'result':
|
|
1080
|
+
renderResultSummary(frame);
|
|
1081
|
+
break;
|
|
1082
|
+
case 'task-boundary':
|
|
1083
|
+
renderTaskBoundary(frame);
|
|
1084
|
+
break;
|
|
1085
|
+
case 'wave-boundary':
|
|
1086
|
+
renderWaveBoundary(frame);
|
|
1087
|
+
break;
|
|
1088
|
+
case 'system':
|
|
1089
|
+
case 'kicked':
|
|
1090
|
+
// ignore meta frames in T2
|
|
1091
|
+
break;
|
|
1092
|
+
default:
|
|
1093
|
+
console.warn('[stream-feed] unknown frame type:', frame.type);
|
|
1094
|
+
}
|
|
1095
|
+
} catch (e) {
|
|
1096
|
+
console.warn('[stream-feed] frame render error:', e, frame);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// Record the first non-boundary frame rendered after the most recent task-start,
|
|
1100
|
+
// so clicking a task banner can jump directly to that task's first real content.
|
|
1101
|
+
if (state.lastTaskKey && frame.type !== 'task-boundary' && frame.type !== 'wave-boundary') {
|
|
1102
|
+
const entry = state.taskStartByKey.get(state.lastTaskKey);
|
|
1103
|
+
if (entry && !entry.firstFrameEl) {
|
|
1104
|
+
const lastChild = feed.lastElementChild;
|
|
1105
|
+
if (lastChild && lastChild !== entry.bannerEl) entry.firstFrameEl = lastChild;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// Auto-scroll to bottom only if the user hasn't scrolled up.
|
|
1110
|
+
if (autoScrollEnabled) feed.scrollTop = feed.scrollHeight;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// ── WebSocket connection ───────────────────────────
|
|
1114
|
+
function connect() {
|
|
1115
|
+
if (state.ws) {
|
|
1116
|
+
try { state.ws.close(); } catch (_) {}
|
|
1117
|
+
}
|
|
1118
|
+
setStatus('reconnecting', 'connecting');
|
|
1119
|
+
let ws;
|
|
1120
|
+
try {
|
|
1121
|
+
ws = new WebSocket(WS_URL_DEFAULT);
|
|
1122
|
+
} catch (e) {
|
|
1123
|
+
console.warn('[stream-feed] WebSocket constructor failed:', e);
|
|
1124
|
+
scheduleReconnect();
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
state.ws = ws;
|
|
1128
|
+
|
|
1129
|
+
ws.addEventListener('open', () => {
|
|
1130
|
+
setStatus('connected', 'live');
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
ws.addEventListener('message', (ev) => {
|
|
1134
|
+
// Server broadcasts one JSON frame per message. Parse and dispatch.
|
|
1135
|
+
let frame;
|
|
1136
|
+
try { frame = JSON.parse(ev.data); }
|
|
1137
|
+
catch (e) {
|
|
1138
|
+
console.warn('[stream-feed] invalid JSON:', ev.data);
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
handleFrame(frame);
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
ws.addEventListener('close', () => {
|
|
1145
|
+
setStatus('disconnected', 'disconnected');
|
|
1146
|
+
scheduleReconnect();
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
ws.addEventListener('error', () => {
|
|
1150
|
+
// 'close' will follow — let that handler schedule reconnect.
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function scheduleReconnect() {
|
|
1155
|
+
if (state.reconnectTimer) return;
|
|
1156
|
+
setStatus('reconnecting', `retrying in ${RECONNECT_DELAY_MS / 1000}s`);
|
|
1157
|
+
state.reconnectTimer = setTimeout(() => {
|
|
1158
|
+
state.reconnectTimer = null;
|
|
1159
|
+
connect();
|
|
1160
|
+
}, RECONNECT_DELAY_MS);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// ── Boot ───────────────────────────────────────────
|
|
1164
|
+
renderFilterOptions();
|
|
1165
|
+
connect();
|
|
1166
|
+
</script>
|
|
1167
|
+
</body>
|
|
1168
|
+
</html>
|