@tekyzinc/gsd-t 3.29.11 → 4.0.12
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 +146 -0
- package/bin/gsd-t-parallel.cjs +64 -13
- package/bin/gsd-t.js +48 -376
- package/bin/journey-coverage.cjs +6 -3
- package/bin/parallel-cli.cjs +13 -1
- package/commands/gsd-t-debug.md +47 -533
- package/commands/gsd-t-design-decompose.md +24 -497
- package/commands/gsd-t-doc-ripple.md +23 -139
- package/commands/gsd-t-execute.md +41 -958
- package/commands/gsd-t-help.md +0 -2
- package/commands/gsd-t-impact.md +26 -295
- package/commands/gsd-t-integrate.md +44 -369
- package/commands/gsd-t-milestone.md +25 -124
- package/commands/gsd-t-partition.md +28 -539
- package/commands/gsd-t-plan.md +26 -472
- package/commands/gsd-t-prd.md +25 -311
- package/commands/gsd-t-quick.md +1 -1
- package/commands/gsd-t-resume.md +0 -33
- package/commands/gsd-t-verify.md +52 -569
- package/commands/gsd-t-wave.md +41 -430
- package/package.json +1 -1
- package/scripts/gsd-t-calibration-hook.js +3 -1
- package/scripts/hooks/pre-commit-journey-coverage +0 -2
- package/scripts/hooks/pre-commit-playwright-gate +0 -2
- package/templates/CLAUDE-global.md +43 -173
- package/templates/CLAUDE-project.md +118 -104
- package/templates/prompts/design-verify-subagent.md +3 -0
- package/templates/prompts/qa-subagent.md +3 -0
- package/templates/prompts/red-team-subagent.md +5 -2
- package/templates/workflows/_lib.js +176 -0
- package/templates/workflows/gsd-t-debug.workflow.js +86 -0
- package/templates/workflows/gsd-t-execute.workflow.js +206 -0
- package/templates/workflows/gsd-t-integrate.workflow.js +71 -0
- package/templates/workflows/gsd-t-phase.workflow.js +94 -0
- package/templates/workflows/gsd-t-quick.workflow.js +72 -0
- package/templates/workflows/gsd-t-verify.workflow.js +348 -0
- package/templates/workflows/gsd-t-wave.workflow.js +43 -0
- package/bin/check-headless-sessions.js +0 -140
- package/bin/context-budget-audit.cjs +0 -447
- package/bin/context-meter-config.cjs +0 -101
- package/bin/context-meter-config.test.cjs +0 -101
- package/bin/event-stream.cjs +0 -205
- package/bin/gsd-t-benchmark-orchestrator.js +0 -437
- package/bin/gsd-t-capture-lint.cjs +0 -440
- package/bin/gsd-t-economics.cjs +0 -315
- package/bin/gsd-t-in-session-usage.cjs +0 -213
- package/bin/gsd-t-orchestrator-config.cjs +0 -161
- package/bin/gsd-t-orchestrator-queue.cjs +0 -180
- package/bin/gsd-t-orchestrator-recover.cjs +0 -231
- package/bin/gsd-t-orchestrator-worker.cjs +0 -219
- package/bin/gsd-t-orchestrator.js +0 -535
- package/bin/gsd-t-parallel-probe.cjs +0 -132
- package/bin/gsd-t-ratelimit-probe-worker.cjs +0 -237
- package/bin/gsd-t-ratelimit-probe.cjs +0 -648
- package/bin/gsd-t-report-tokens.cjs +0 -549
- package/bin/gsd-t-stream-feed-client.cjs +0 -151
- package/bin/gsd-t-token-backfill.cjs +0 -366
- package/bin/gsd-t-token-capture.cjs +0 -321
- package/bin/gsd-t-token-dashboard.cjs +0 -353
- package/bin/gsd-t-token-regenerate-log.cjs +0 -129
- package/bin/gsd-t-tool-attribution.cjs +0 -377
- package/bin/gsd-t-tool-cost.cjs +0 -195
- package/bin/gsd-t-transcript-tee.cjs +0 -246
- package/bin/gsd-t-unattended-heartbeat.cjs +0 -188
- package/bin/gsd-t-unattended-platform.cjs +0 -551
- package/bin/gsd-t-unattended-platform.js +0 -381
- package/bin/gsd-t-unattended-safety.cjs +0 -773
- package/bin/gsd-t-unattended-safety.js +0 -788
- package/bin/gsd-t-unattended.cjs +0 -2109
- package/bin/gsd-t-unattended.js +0 -1367
- package/bin/gsd-t-worker-dispatch.cjs +0 -211
- package/bin/handoff-lock.cjs +0 -249
- package/bin/handoff-lock.js +0 -249
- package/bin/headless-auto-spawn.cjs +0 -711
- package/bin/headless-auto-spawn.js +0 -373
- package/bin/headless-exit-codes.cjs +0 -67
- package/bin/live-activity-report.cjs +0 -615
- package/bin/log-tail.cjs +0 -81
- package/bin/m44-proof-measure.cjs +0 -285
- package/bin/m46-iter-proof.cjs +0 -149
- package/bin/m46-worker-proof.cjs +0 -201
- package/bin/m55-substrate-proof.cjs +0 -134
- package/bin/metrics-rollup.js +0 -200
- package/bin/model-windows.cjs +0 -99
- package/bin/model-windows.test.cjs +0 -75
- package/bin/parallelism-report.cjs +0 -535
- package/bin/runway-estimator.cjs +0 -242
- package/bin/spawn-plan-derive.cjs +0 -163
- package/bin/spawn-plan-status-updater.cjs +0 -292
- package/bin/spawn-plan-writer.cjs +0 -204
- package/bin/supervisor-pid-fingerprint.cjs +0 -126
- package/bin/token-budget.cjs +0 -265
- package/bin/unattended-watch-format.cjs +0 -178
- package/bin/watch-progress.js +0 -155
- package/commands/gsd-t-unattended-stop.md +0 -85
- package/commands/gsd-t-unattended-watch.md +0 -465
- package/commands/gsd-t-unattended.md +0 -476
- package/commands/gsd-t-visualize.md +0 -116
- package/scripts/gsd-t-context-meter.e2e.test.js +0 -364
- package/scripts/gsd-t-context-meter.js +0 -341
- package/scripts/gsd-t-context-meter.test.js +0 -471
- package/scripts/gsd-t-dashboard-server.js +0 -1203
- package/scripts/gsd-t-post-commit-spawn-plan.sh +0 -86
- package/scripts/gsd-t-transcript.html +0 -1821
- package/scripts/hooks/gsd-t-conversation-capture.js +0 -439
- package/scripts/hooks/gsd-t-in-session-usage-hook.js +0 -84
- package/scripts/spawn-plan-fmt-tokens.cjs +0 -80
- package/templates/hooks/post-commit-spawn-plan.sh +0 -85
|
@@ -1,1821 +0,0 @@
|
|
|
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.0">
|
|
6
|
-
<title>__PROJECT_NAME__</title>
|
|
7
|
-
<style>
|
|
8
|
-
:root {
|
|
9
|
-
--bg: #0d1117;
|
|
10
|
-
--bg-raised: #161b22;
|
|
11
|
-
--bg-soft: #1f2937;
|
|
12
|
-
--fg: #c9d1d9;
|
|
13
|
-
--fg-dim: #8b949e;
|
|
14
|
-
--fg-xdim: #6e7681;
|
|
15
|
-
--accent: #58a6ff;
|
|
16
|
-
--accent-warm: #f0883e;
|
|
17
|
-
--border: #30363d;
|
|
18
|
-
--green: #3fb950;
|
|
19
|
-
--yellow: #d29922;
|
|
20
|
-
--red: #f85149;
|
|
21
|
-
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
22
|
-
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
23
|
-
}
|
|
24
|
-
* { box-sizing: border-box; }
|
|
25
|
-
/* M47 D1: split-pane layout. Top pane = main in-session conversation
|
|
26
|
-
(zero-click default), bottom pane = user-selected spawn. Splitter
|
|
27
|
-
handle in the middle is keyboard- + mouse-draggable; persisted via
|
|
28
|
-
sessionStorage. Right-rail collapsibility is also persisted. */
|
|
29
|
-
:root {
|
|
30
|
-
--main-pane-pct: 50; /* clamped 10–90 by JS */
|
|
31
|
-
--right-rail-w: 320px; /* 0 when collapsed via [data-collapsed] */
|
|
32
|
-
}
|
|
33
|
-
body { margin: 0; background: var(--bg); color: var(--fg); font-family: var(--sans); font-size: 14px; line-height: 1.55; display: grid; grid-template-columns: 280px 1fr var(--right-rail-w); grid-template-rows: auto 1fr; min-height: 100vh; }
|
|
34
|
-
body > header { grid-column: 1 / -1; }
|
|
35
|
-
body > aside.left-rail { grid-column: 1; grid-row: 2; background: var(--bg-raised); border-right: 1px solid var(--border); padding: 12px 0; overflow-y: auto; max-height: calc(100vh - 50px); position: sticky; top: 49px; align-self: start; }
|
|
36
|
-
/* M47 D1: <main> becomes a vertical flex container holding the two streams + splitter handle. */
|
|
37
|
-
body > main { grid-column: 2; grid-row: 2; display: flex; flex-direction: column; min-height: calc(100vh - 50px); }
|
|
38
|
-
/* M47 D1 — when the right rail is collapsed, the body grid track becomes 0. */
|
|
39
|
-
body[data-right-rail-collapsed="true"] { --right-rail-w: 0px; }
|
|
40
|
-
body[data-right-rail-collapsed="true"] > aside.spawn-panel { display: none; }
|
|
41
|
-
/* M47 D1 — split-pane: top + bottom share calc(100vh - 50px - splitter) by var %. */
|
|
42
|
-
main #main-stream, main #spawn-stream { overflow-y: auto; padding: 16px; padding-bottom: 80px; max-width: 960px; margin: 0 auto; width: 100%; box-sizing: border-box; }
|
|
43
|
-
main #main-stream { flex: 0 0 calc(var(--main-pane-pct) * 1%); border-bottom: none; }
|
|
44
|
-
main #spawn-stream { flex: 1 1 auto; }
|
|
45
|
-
main .splitter { flex: 0 0 6px; background: var(--border); cursor: row-resize; user-select: none; outline: none; transition: background 120ms ease; }
|
|
46
|
-
main .splitter:hover, main .splitter:focus { background: var(--accent); }
|
|
47
|
-
main .splitter:focus-visible { box-shadow: inset 0 0 0 1px var(--accent); }
|
|
48
|
-
main .pane-empty { color: var(--fg-xdim); font-style: italic; padding: 12px; font-size: 13px; }
|
|
49
|
-
/* M47 D1 — left-rail 3 sections (Main / Live / Completed). */
|
|
50
|
-
aside.left-rail .rail-main, aside.left-rail .rail-live, aside.left-rail .rail-completed { padding: 0 0 8px 0; }
|
|
51
|
-
aside.left-rail .rail-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px 4px 12px; font-size: 11px; text-transform: uppercase; color: var(--fg-xdim); letter-spacing: 0.08em; }
|
|
52
|
-
aside.left-rail .rail-header .chevron { background: transparent; border: 0; color: var(--fg-xdim); cursor: pointer; font-family: var(--mono); font-size: 11px; padding: 0 4px; }
|
|
53
|
-
aside.left-rail .rail-header .chevron:hover { color: var(--fg); }
|
|
54
|
-
aside.left-rail .rail-completed[data-expanded="false"] .rail-body { display: none; }
|
|
55
|
-
aside.left-rail .rail-completed[data-expanded="false"] .rail-header .chevron { transform: rotate(-90deg); display: inline-block; }
|
|
56
|
-
/* M47 D1 — status badge dots on completed entries. */
|
|
57
|
-
aside .node .badge-status { flex: 0 0 auto; font-family: var(--mono); font-size: 9px; padding: 1px 5px; border-radius: 8px; margin-right: 4px; border: 1px solid var(--border); color: var(--fg-xdim); background: var(--bg); }
|
|
58
|
-
aside .node .badge-status.success { color: var(--green); border-color: var(--green); }
|
|
59
|
-
aside .node .badge-status.failed { color: var(--red); border-color: var(--red); }
|
|
60
|
-
aside .node .badge-status.killed { color: var(--fg-xdim); border-color: var(--fg-xdim); }
|
|
61
|
-
aside .node .badge-status.completed { color: var(--fg-dim); border-color: var(--border); }
|
|
62
|
-
/* M47 D1 — right-rail collapse toggle */
|
|
63
|
-
.spawn-panel .panel-toggle { background: transparent; border: 1px solid var(--border); color: var(--fg-xdim); font-family: var(--mono); font-size: 11px; padding: 1px 6px; cursor: pointer; border-radius: 3px; }
|
|
64
|
-
.spawn-panel .panel-toggle:hover { color: var(--fg); border-color: var(--accent); }
|
|
65
|
-
/* M44 D8 — right-side spawn-plan panel (additive; does not alter main stream) */
|
|
66
|
-
body > aside.spawn-panel { grid-column: 3; grid-row: 2; background: var(--bg-raised); border-left: 1px solid var(--border); padding: 12px 14px; overflow-y: auto; max-height: calc(100vh - 50px); position: sticky; top: 49px; align-self: start; font-size: 12px; }
|
|
67
|
-
.spawn-panel h2 { margin: 0 0 6px 0; font-size: 11px; text-transform: uppercase; color: var(--fg-xdim); letter-spacing: 0.08em; }
|
|
68
|
-
.spawn-panel section { border: 1px solid var(--border); border-radius: 4px; padding: 8px 10px; margin-bottom: 10px; background: var(--bg); }
|
|
69
|
-
.spawn-panel section.dimmed { opacity: 0.45; }
|
|
70
|
-
.spawn-panel section .sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
|
71
|
-
.spawn-panel section .sec-title { font-weight: 600; color: var(--fg); font-size: 12px; }
|
|
72
|
-
.spawn-panel section .sec-sub { color: var(--fg-xdim); font-family: var(--mono); font-size: 10px; }
|
|
73
|
-
.spawn-panel section .sec-totals { font-family: var(--mono); font-size: 10px; color: var(--fg-dim); margin-top: 2px; text-align: right; }
|
|
74
|
-
.spawn-panel .task-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; }
|
|
75
|
-
.spawn-panel .task-row .icon { flex: 0 0 14px; font-family: var(--mono); color: var(--fg-xdim); }
|
|
76
|
-
.spawn-panel .task-row.status-done .icon { color: var(--green); }
|
|
77
|
-
.spawn-panel .task-row.status-in-progress .icon { color: var(--accent); }
|
|
78
|
-
.spawn-panel .task-row .title { flex: 1; color: var(--fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
79
|
-
.spawn-panel .task-row.status-done .title { color: var(--fg-dim); }
|
|
80
|
-
.spawn-panel .task-row .tokens { flex: 0 0 auto; font-family: var(--mono); font-size: 10px; color: var(--fg-xdim); text-align: right; }
|
|
81
|
-
.spawn-panel .empty { color: var(--fg-xdim); font-style: italic; padding: 4px 0; font-size: 11px; }
|
|
82
|
-
.spawn-panel .wave-group { border-top: 1px dashed var(--border); padding-top: 4px; margin-top: 4px; }
|
|
83
|
-
.spawn-panel .wave-group:first-child { border-top: none; padding-top: 0; margin-top: 0; }
|
|
84
|
-
/* M44 D9 — parallelism panel (additive; scoped to its own class). */
|
|
85
|
-
.parallelism-panel { margin-top: 14px; border: 1px solid var(--border); border-radius: 4px; padding: 10px 12px; background: var(--bg); transition: border-color 200ms ease; }
|
|
86
|
-
.parallelism-panel.color-green { border-color: #10b981; }
|
|
87
|
-
.parallelism-panel.color-yellow { border-color: #f59e0b; }
|
|
88
|
-
.parallelism-panel.color-red { border-color: #ef4444; }
|
|
89
|
-
.parallelism-panel.color-dimmed { border-color: #374151; opacity: 0.5; }
|
|
90
|
-
.parallelism-panel h3 { margin: 0 0 6px 0; font-size: 11px; text-transform: uppercase; color: var(--fg-xdim); letter-spacing: 0.08em; display: flex; justify-content: space-between; align-items: baseline; }
|
|
91
|
-
.parallelism-panel h3 .pp-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: currentColor; margin-right: 6px; vertical-align: middle; }
|
|
92
|
-
.parallelism-panel .pp-dot.dot-green { color: #10b981; }
|
|
93
|
-
.parallelism-panel .pp-dot.dot-yellow { color: #f59e0b; }
|
|
94
|
-
.parallelism-panel .pp-dot.dot-red { color: #ef4444; }
|
|
95
|
-
.parallelism-panel .pp-dot.dot-dimmed { color: #374151; }
|
|
96
|
-
.parallelism-panel .pp-row { display: flex; justify-content: space-between; font-size: 11px; padding: 2px 0; font-family: var(--mono); }
|
|
97
|
-
.parallelism-panel .pp-row .pp-k { color: var(--fg-xdim); }
|
|
98
|
-
.parallelism-panel .pp-row .pp-v { color: var(--fg); }
|
|
99
|
-
.parallelism-panel .pp-gates { margin-top: 6px; padding-top: 6px; border-top: 1px dashed var(--border); font-size: 11px; font-family: var(--mono); color: var(--fg-dim); }
|
|
100
|
-
.parallelism-panel .pp-gates span { margin-right: 8px; }
|
|
101
|
-
.parallelism-panel .pp-actions { display: flex; gap: 6px; margin-top: 8px; }
|
|
102
|
-
.parallelism-panel .pp-actions button { flex: 1; padding: 4px 8px; font-size: 11px; background: var(--bg-raised); color: var(--fg); border: 1px solid var(--border); border-radius: 3px; cursor: pointer; font-family: var(--sans); }
|
|
103
|
-
.parallelism-panel .pp-actions button:hover { border-color: var(--accent); }
|
|
104
|
-
.parallelism-panel .pp-actions button.danger:hover { border-color: #ef4444; color: #ef4444; }
|
|
105
|
-
.spawn-panel .wave-label { font-size: 10px; color: var(--fg-xdim); font-family: var(--mono); text-transform: uppercase; letter-spacing: 0.05em; margin: 4px 0 2px 0; }
|
|
106
|
-
aside h3 { margin: 0 12px 8px 12px; font-size: 11px; text-transform: uppercase; color: var(--fg-xdim); letter-spacing: 0.08em; }
|
|
107
|
-
aside .tree { font-size: 13px; }
|
|
108
|
-
aside .node { display: flex; align-items: center; padding: 5px 12px; cursor: pointer; gap: 6px; }
|
|
109
|
-
aside .node:hover { background: var(--bg-soft); }
|
|
110
|
-
aside .node.active { background: var(--bg-soft); border-left: 2px solid var(--accent); }
|
|
111
|
-
aside .node .dot { flex: 0 0 8px; width: 8px; height: 8px; border-radius: 50%; background: var(--fg-xdim); }
|
|
112
|
-
aside .node.running .dot { background: var(--green); animation: pulse 2s ease-in-out infinite; }
|
|
113
|
-
aside .node.stopped .dot { background: var(--yellow); }
|
|
114
|
-
aside .node.failed .dot { background: var(--red); }
|
|
115
|
-
aside .node .name { flex: 1; font-family: var(--mono); font-size: 11px; color: var(--fg-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
116
|
-
aside .node.active .name { color: var(--fg); }
|
|
117
|
-
aside .node .kill { flex: 0 0 auto; background: transparent; border: 1px solid var(--border); color: var(--fg-xdim); border-radius: 3px; padding: 1px 6px; font-size: 10px; cursor: pointer; font-family: var(--mono); }
|
|
118
|
-
aside .node.running .kill { color: var(--red); border-color: var(--red); }
|
|
119
|
-
aside .node .kill:hover:not([disabled]) { background: var(--red); color: #fff; }
|
|
120
|
-
aside .node .kill[disabled] { opacity: 0.3; cursor: not-allowed; }
|
|
121
|
-
aside .empty { padding: 12px; color: var(--fg-xdim); font-size: 12px; font-style: italic; }
|
|
122
|
-
/* M43 D6 — tool-cost panel */
|
|
123
|
-
aside .panel { border-top: 1px solid var(--border); margin-top: 10px; padding-top: 10px; }
|
|
124
|
-
aside .panel > summary { list-style: none; cursor: pointer; padding: 4px 12px; display: flex; align-items: center; gap: 6px; color: var(--fg-dim); font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; }
|
|
125
|
-
aside .panel > summary::-webkit-details-marker { display: none; }
|
|
126
|
-
aside .panel > summary::before { content: "▶"; font-size: 8px; color: var(--fg-xdim); transition: transform 0.15s ease; }
|
|
127
|
-
aside .panel[open] > summary::before { transform: rotate(90deg); }
|
|
128
|
-
aside .panel .panel-body { padding: 6px 12px 10px 12px; }
|
|
129
|
-
aside .panel .live-badge { margin-left: auto; font-size: 9px; font-family: var(--mono); background: var(--bg-soft); color: var(--fg-xdim); border: 1px solid var(--border); border-radius: 8px; padding: 1px 6px; }
|
|
130
|
-
aside .panel .live-badge.on { background: rgba(63, 185, 80, 0.15); color: var(--green); border-color: var(--green); }
|
|
131
|
-
aside .tool-row { display: flex; align-items: center; gap: 6px; padding: 3px 0; font-family: var(--mono); font-size: 11px; }
|
|
132
|
-
aside .tool-row .tool-name { color: var(--fg); flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
133
|
-
aside .tool-row .tool-tokens { color: var(--fg-dim); flex: 0 0 auto; }
|
|
134
|
-
aside .tool-empty { color: var(--fg-xdim); font-size: 11px; font-style: italic; }
|
|
135
|
-
aside .tool-error { color: var(--yellow); font-size: 11px; font-style: italic; }
|
|
136
|
-
/* M45 D2 — in-session conversation left-rail badge */
|
|
137
|
-
aside .node.in-session .name { color: var(--yellow); }
|
|
138
|
-
aside .node.in-session.active .name { color: var(--accent-warm, var(--yellow)); }
|
|
139
|
-
aside .node .label-in-session { color: var(--yellow); margin-right: 4px; }
|
|
140
|
-
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
|
141
|
-
/* 10s attention-pulse for a freshly-arrived spawn — fires once when
|
|
142
|
-
auto-follow snaps focus to a new session, then self-removes. */
|
|
143
|
-
aside .node.pulsing { animation: attn 1.25s ease-in-out 8; box-shadow: inset 0 0 0 0 var(--accent); }
|
|
144
|
-
@keyframes attn {
|
|
145
|
-
0% { background: var(--bg-soft); box-shadow: inset 3px 0 0 0 var(--accent), 0 0 0 0 rgba(88,166,255,0.6); }
|
|
146
|
-
50% { background: rgba(88,166,255,0.18); box-shadow: inset 3px 0 0 0 var(--accent), 0 0 0 6px rgba(88,166,255,0); }
|
|
147
|
-
100% { background: var(--bg-soft); box-shadow: inset 3px 0 0 0 var(--accent), 0 0 0 0 rgba(88,166,255,0); }
|
|
148
|
-
}
|
|
149
|
-
header .auto-follow { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--fg-dim); cursor: pointer; user-select: none; margin-left: 12px; }
|
|
150
|
-
header .auto-follow input { margin: 0; cursor: pointer; }
|
|
151
|
-
header { position: sticky; top: 0; z-index: 10; background: var(--bg-raised); border-bottom: 1px solid var(--border); padding: 10px 16px; display: flex; align-items: center; gap: 12px; }
|
|
152
|
-
header .title { font-weight: 600; }
|
|
153
|
-
header .spawn-id { font-family: var(--mono); color: var(--fg-dim); font-size: 12px; }
|
|
154
|
-
header .status { margin-left: auto; font-size: 12px; color: var(--fg-dim); }
|
|
155
|
-
header .status .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--yellow); margin-right: 6px; vertical-align: middle; }
|
|
156
|
-
header .status.connected .dot { background: var(--green); }
|
|
157
|
-
header .status.ended .dot { background: var(--fg-xdim); }
|
|
158
|
-
header .status.error .dot { background: var(--red); }
|
|
159
|
-
|
|
160
|
-
/* M47 D1 — padding moved onto the inner #main-stream / #spawn-stream containers
|
|
161
|
-
so the splitter sits flush between them. The outer <main> is a flex column. */
|
|
162
|
-
.frame { margin: 8px 0; }
|
|
163
|
-
.frame.system { color: var(--fg-xdim); font-style: italic; font-size: 12px; padding: 2px 0; }
|
|
164
|
-
.frame.user { border-left: 3px solid var(--accent); padding: 6px 12px; background: var(--bg-raised); border-radius: 0 4px 4px 0; }
|
|
165
|
-
.frame.user .prefix { color: var(--accent); font-weight: 600; margin-right: 6px; }
|
|
166
|
-
.frame.assistant-text { font-family: var(--mono); white-space: pre-wrap; word-break: break-word; }
|
|
167
|
-
.frame.thinking { color: var(--fg-xdim); font-style: italic; font-size: 13px; padding: 4px 8px; border-left: 2px dashed var(--border); margin: 6px 0; }
|
|
168
|
-
.frame.thinking summary { cursor: pointer; color: var(--fg-dim); }
|
|
169
|
-
.frame.tool-use { margin: 8px 0; border: 1px solid var(--border); border-radius: 4px; background: var(--bg-raised); }
|
|
170
|
-
.frame.tool-use summary { padding: 8px 12px; cursor: pointer; color: var(--accent-warm); font-family: var(--mono); font-size: 13px; list-style: none; }
|
|
171
|
-
.frame.tool-use summary::before { content: "⎿ "; color: var(--fg-dim); }
|
|
172
|
-
.frame.tool-use[open] summary { border-bottom: 1px solid var(--border); }
|
|
173
|
-
.frame.tool-use pre { margin: 0; padding: 10px 12px; font-family: var(--mono); font-size: 12px; color: var(--fg); white-space: pre-wrap; word-break: break-word; overflow-x: auto; }
|
|
174
|
-
.frame.tool-result { margin: 4px 0 8px 24px; padding: 8px 12px; border-left: 2px solid var(--border); background: var(--bg-soft); border-radius: 0 4px 4px 0; }
|
|
175
|
-
.frame.tool-result pre { margin: 0; font-family: var(--mono); font-size: 12px; color: var(--fg-dim); white-space: pre-wrap; word-break: break-word; max-height: 240px; overflow: hidden; }
|
|
176
|
-
.frame.tool-result.expanded pre { max-height: none; }
|
|
177
|
-
.frame.tool-result .show-more { display: block; margin-top: 6px; color: var(--accent); cursor: pointer; font-size: 11px; font-family: var(--mono); }
|
|
178
|
-
.frame.raw { color: var(--fg-xdim); font-family: var(--mono); font-size: 11px; white-space: pre-wrap; padding: 2px 0; }
|
|
179
|
-
.frame.boundary { margin: 12px 0; padding: 8px 12px; background: var(--bg-soft); border-left: 3px solid var(--accent-warm); border-radius: 0 4px 4px 0; font-size: 13px; }
|
|
180
|
-
.frame.boundary.done { border-left-color: var(--green); }
|
|
181
|
-
.frame.boundary.failed { border-left-color: var(--red); }
|
|
182
|
-
.frame.boundary .label { color: var(--accent-warm); font-weight: 600; font-family: var(--mono); font-size: 12px; margin-right: 8px; }
|
|
183
|
-
.frame.boundary.done .label { color: var(--green); }
|
|
184
|
-
.frame.boundary.failed .label { color: var(--red); }
|
|
185
|
-
.frame.boundary .meta { color: var(--fg-dim); font-family: var(--mono); font-size: 12px; }
|
|
186
|
-
/* M48 — chat-bubble frame types emitted by the in-session conversation
|
|
187
|
-
capture hook. user_turn reuses .frame.user styling; assistant_turn
|
|
188
|
-
gets a softer right-aligned-feel bubble; session_start is a tiny badge. */
|
|
189
|
-
.frame.assistant-turn { border-left: 3px solid var(--green); padding: 6px 12px; background: var(--bg-raised); border-radius: 0 4px 4px 0; }
|
|
190
|
-
.frame.assistant-turn .prefix { color: var(--green); font-weight: 600; margin-right: 6px; font-family: var(--mono); }
|
|
191
|
-
.frame.assistant-turn .body { white-space: pre-wrap; word-break: break-word; }
|
|
192
|
-
.frame.user-turn .body { white-space: pre-wrap; word-break: break-word; }
|
|
193
|
-
.frame.session-start { display: inline-flex; align-items: center; gap: 6px; margin: 8px 0; padding: 3px 10px; background: rgba(88,166,255,0.10); border: 1px solid var(--border); border-radius: 12px; font-family: var(--mono); font-size: 11px; color: var(--fg-dim); }
|
|
194
|
-
.frame.session-start .badge { color: var(--accent); font-weight: 600; }
|
|
195
|
-
.frame.tool-call-line { font-family: var(--mono); font-size: 12px; color: var(--accent-warm); padding: 2px 0; }
|
|
196
|
-
.frame.truncated-tag { color: var(--fg-xdim); font-style: italic; font-size: 11px; margin-left: 6px; }
|
|
197
|
-
|
|
198
|
-
.jump-to-live { position: fixed; bottom: 24px; right: 24px; background: var(--accent); color: #fff; border: none; padding: 10px 16px; border-radius: 20px; cursor: pointer; font-size: 13px; font-family: var(--sans); font-weight: 600; box-shadow: 0 4px 12px rgba(0,0,0,0.4); display: none; z-index: 20; }
|
|
199
|
-
.jump-to-live.visible { display: block; }
|
|
200
|
-
.jump-to-live:hover { background: #79c0ff; }
|
|
201
|
-
/* Q2b — compact_marker inline timeline badge */
|
|
202
|
-
.frame.compact-marker { display: flex; align-items: center; gap: 8px; margin: 14px 0; padding: 0; }
|
|
203
|
-
.frame.compact-marker .cm-line { flex: 1; height: 1px; background: #d29922; opacity: 0.4; }
|
|
204
|
-
.frame.compact-marker .cm-badge { flex: 0 0 auto; background: rgba(210,153,34,0.15); border: 1px solid #d29922; border-radius: 12px; padding: 3px 10px; font-family: var(--mono); font-size: 11px; color: #d29922; font-weight: 600; cursor: default; white-space: nowrap; }
|
|
205
|
-
.frame.compact-marker .cm-badge:hover { background: rgba(210,153,34,0.28); }
|
|
206
|
-
/* Per-frame arrival timestamp pill — renders to the LEFT of every frame
|
|
207
|
-
so a stuck stream is visible at a glance (large gap between consecutive ts). */
|
|
208
|
-
.frame > .ts { display: inline-block; flex: 0 0 auto; font-family: var(--mono); font-size: 10px; color: var(--fg-xdim); margin-right: 8px; vertical-align: top; min-width: 64px; user-select: none; }
|
|
209
|
-
.frame.tool-use > .ts, .frame.thinking > .ts { padding: 8px 0 0 12px; }
|
|
210
|
-
.frame.tool-result > .ts { padding-top: 8px; }
|
|
211
|
-
.frame.compact-marker > .ts { padding: 0; min-width: 64px; }
|
|
212
|
-
|
|
213
|
-
/* M54 D2 T1 — LIVE ACTIVITY rail section.
|
|
214
|
-
Additive only. No existing CSS renamed or removed. */
|
|
215
|
-
#rail-live-activity { padding: 0 0 8px 0; border-bottom: 1px solid var(--border); margin-bottom: 4px; }
|
|
216
|
-
#rail-live-activity .la-heading { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px 4px 12px; font-size: 11px; text-transform: uppercase; color: var(--fg-xdim); letter-spacing: 0.08em; }
|
|
217
|
-
.la-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
218
|
-
.la-entry { display: grid; grid-template-columns: 10px 18px 1fr auto; align-items: center; gap: 4px; padding: 5px 12px; cursor: pointer; border-radius: 4px; font-size: 12px; }
|
|
219
|
-
.la-entry:hover { background: var(--bg-soft); }
|
|
220
|
-
.la-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
221
|
-
.la-dot-running { background: #2dd4bf; }
|
|
222
|
-
.la-dot-stale { background: var(--fg-xdim, #6b7280); }
|
|
223
|
-
.la-icon { font-family: var(--mono); font-size: 13px; text-align: center; color: var(--fg-dim); }
|
|
224
|
-
.la-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40ch; color: var(--fg); font-family: var(--mono); font-size: 11px; }
|
|
225
|
-
.la-duration { font-family: var(--mono); font-size: 11px; color: var(--fg-xdim); text-align: right; white-space: nowrap; }
|
|
226
|
-
.la-empty { padding: 4px 12px 0 12px; color: var(--fg-xdim); font-size: 11px; font-style: italic; }
|
|
227
|
-
/* Kind icon slots — glyphs set in JS via KIND_ICONS map */
|
|
228
|
-
.la-icon-bash { color: var(--accent-warm, #f0a500); }
|
|
229
|
-
.la-icon-monitor { color: var(--accent, #58a6ff); }
|
|
230
|
-
.la-icon-tool { color: var(--fg-dim, #8b949e); }
|
|
231
|
-
.la-icon-spawn { color: var(--green, #3fb950); }
|
|
232
|
-
@keyframes accent-pulse {
|
|
233
|
-
0% { box-shadow: inset 2px 0 0 0 #2dd4bf; opacity: 1; }
|
|
234
|
-
50% { box-shadow: inset 2px 0 0 0 #2dd4bf; opacity: 0.6; }
|
|
235
|
-
100% { box-shadow: inset 2px 0 0 0 #2dd4bf; opacity: 1; }
|
|
236
|
-
}
|
|
237
|
-
.la-pulsing { animation: accent-pulse 1.5s ease-in-out infinite; }
|
|
238
|
-
</style>
|
|
239
|
-
</head>
|
|
240
|
-
<body data-spawn-id="__SPAWN_ID__">
|
|
241
|
-
<header>
|
|
242
|
-
<div class="title">__PROJECT_NAME__</div>
|
|
243
|
-
<div class="spawn-id" id="hdr-spawn-id"></div>
|
|
244
|
-
<label class="auto-follow" title="When ON, snap focus to the most recent live spawn as soon as it appears."><input type="checkbox" id="auto-follow" checked> auto-follow latest</label>
|
|
245
|
-
<div class="status" id="hdr-status"><span class="dot"></span><span class="label">connecting…</span></div>
|
|
246
|
-
</header>
|
|
247
|
-
<aside class="left-rail">
|
|
248
|
-
<!-- M47 D1 — three pre-rendered sections. Bucketing logic in Task 5
|
|
249
|
-
consumes D2's `status` field; until then, in-session entries land
|
|
250
|
-
in Main Session and everything else falls through to Live Spawns. -->
|
|
251
|
-
<section class="rail-main" data-rail-section="main">
|
|
252
|
-
<div class="rail-header"><span>★ Main Session</span></div>
|
|
253
|
-
<div class="rail-body" id="rail-main-body"><div class="empty">No in-session conversation captured yet.</div></div>
|
|
254
|
-
</section>
|
|
255
|
-
<!-- M54 D2 T1 — LIVE ACTIVITY section (additive, between Main Session and Live Spawns).
|
|
256
|
-
Populated by 5s polling JS (D2 T2). CSS @keyframes accent-pulse in <style> block above. -->
|
|
257
|
-
<section id="rail-live-activity">
|
|
258
|
-
<div class="la-heading"><span>⚡ Live Activity</span></div>
|
|
259
|
-
<ul id="la-list" class="la-list">
|
|
260
|
-
<!-- Entries injected by polling JS (T2). Empty section header visible when list is empty. -->
|
|
261
|
-
</ul>
|
|
262
|
-
<div class="la-empty" id="la-empty-msg">No active operations detected.</div>
|
|
263
|
-
</section>
|
|
264
|
-
<section class="rail-live" data-rail-section="live">
|
|
265
|
-
<div class="rail-header"><span>Live Spawns</span></div>
|
|
266
|
-
<div class="rail-body">
|
|
267
|
-
<!-- Legacy mount point: existing renderTree() writes into #tree as a
|
|
268
|
-
no-op fallback while Task 5 bucketing rolls out. -->
|
|
269
|
-
<div class="tree" id="tree"></div>
|
|
270
|
-
</div>
|
|
271
|
-
</section>
|
|
272
|
-
<section class="rail-completed" data-rail-section="completed" data-expanded="true">
|
|
273
|
-
<div class="rail-header"><span>Completed</span><button class="chevron" id="rail-completed-toggle" aria-label="Toggle completed section">▾</button></div>
|
|
274
|
-
<div class="rail-body" id="rail-completed-body"><div class="empty">No completed spawns yet.</div></div>
|
|
275
|
-
</section>
|
|
276
|
-
<details class="panel" id="tool-cost-panel" open>
|
|
277
|
-
<summary>Tool Cost <span class="live-badge" id="tool-cost-live">off</span></summary>
|
|
278
|
-
<div class="panel-body" id="tool-cost-body">
|
|
279
|
-
<div class="tool-empty">Loading…</div>
|
|
280
|
-
</div>
|
|
281
|
-
</details>
|
|
282
|
-
</aside>
|
|
283
|
-
<main>
|
|
284
|
-
<!-- M47 D1 — top pane: main in-session conversation (zero-click default).
|
|
285
|
-
Wired by Task 4 to /api/main-session + /transcript/in-session-:id/stream. -->
|
|
286
|
-
<section id="main-stream" aria-label="Main session conversation">
|
|
287
|
-
<div class="pane-empty">No in-session conversation captured yet.</div>
|
|
288
|
-
</section>
|
|
289
|
-
<!-- Splitter: keyboard accessible (ArrowUp/ArrowDown nudge ±5%, Home/End snap). -->
|
|
290
|
-
<div class="splitter" id="splitter" role="separator" tabindex="0" aria-orientation="horizontal" aria-label="Resize main vs spawn pane"></div>
|
|
291
|
-
<!-- Bottom pane: user-selected spawn. Existing #stream id preserved so
|
|
292
|
-
legacy selectors keep working — it now lives inside #spawn-stream. -->
|
|
293
|
-
<section id="spawn-stream" aria-label="Selected spawn transcript">
|
|
294
|
-
<div id="stream"></div>
|
|
295
|
-
</section>
|
|
296
|
-
</main>
|
|
297
|
-
<!-- M44 D8 — Spawn Plan panel: Layer 1 (project) + Layer 2 (active spawn).
|
|
298
|
-
Additive; does not change the transcript stream rendering.
|
|
299
|
-
M47 D1 — wrapped in <aside class="spawn-panel" data-collapsed> with a toggle. -->
|
|
300
|
-
<aside class="spawn-panel" id="spawn-plan-panel" data-collapsed="false">
|
|
301
|
-
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
|
|
302
|
-
<h2 style="margin:0;">Spawn Plan</h2>
|
|
303
|
-
<button class="panel-toggle" id="spawn-panel-toggle" aria-label="Collapse right rail">⟶</button>
|
|
304
|
-
</div>
|
|
305
|
-
<section id="spawn-layer-project">
|
|
306
|
-
<div class="sec-head"><span class="sec-title" id="project-title">Project</span><span class="sec-sub" id="project-sub">loading…</span></div>
|
|
307
|
-
<div class="sec-totals" id="project-totals">—</div>
|
|
308
|
-
<div id="project-tasks"><div class="empty">loading…</div></div>
|
|
309
|
-
</section>
|
|
310
|
-
<section id="spawn-layer-active">
|
|
311
|
-
<div class="sec-head"><span class="sec-title" id="active-title">Active Spawn</span><span class="sec-sub" id="active-sub">—</span></div>
|
|
312
|
-
<div class="sec-totals" id="active-totals">—</div>
|
|
313
|
-
<div id="active-tasks"><div class="empty">no active spawn plan</div></div>
|
|
314
|
-
</section>
|
|
315
|
-
<!-- M44 D9 — parallelism panel (additive). Reads /api/parallelism every 5s.
|
|
316
|
-
Color border reflects color_state (green/yellow/red/dimmed). -->
|
|
317
|
-
<div class="parallelism-panel color-dimmed" id="parallelism-panel">
|
|
318
|
-
<h3><span><span class="pp-dot dot-dimmed" id="pp-dot"></span>Parallelism</span><span id="pp-mode" style="font-size:10px;color:var(--fg-xdim);">idle</span></h3>
|
|
319
|
-
<div class="pp-row"><span class="pp-k">active workers</span><span class="pp-v" id="pp-active">—</span></div>
|
|
320
|
-
<div class="pp-row"><span class="pp-k">ready tasks</span><span class="pp-v" id="pp-ready">—</span></div>
|
|
321
|
-
<div class="pp-row"><span class="pp-k">parallelism factor</span><span class="pp-v" id="pp-factor">—</span></div>
|
|
322
|
-
<div class="pp-row"><span class="pp-k">oldest spawn</span><span class="pp-v" id="pp-oldest">—</span></div>
|
|
323
|
-
<div class="pp-gates" id="pp-gates"><span>dep: —</span><span>disj: —</span><span>eco: —</span></div>
|
|
324
|
-
<div class="pp-actions">
|
|
325
|
-
<button id="pp-report-btn" title="Download full parallelism post-mortem for the current wave">📄 Full Report</button>
|
|
326
|
-
<button id="pp-stop-btn" class="danger" title="Write .gsd-t/.unattended/stop sentinel to halt an active supervisor">Stop Supervisor</button>
|
|
327
|
-
</div>
|
|
328
|
-
</div>
|
|
329
|
-
</aside>
|
|
330
|
-
<button class="jump-to-live" id="jump-btn">↓ Jump to live</button>
|
|
331
|
-
|
|
332
|
-
<script>
|
|
333
|
-
(function () {
|
|
334
|
-
'use strict';
|
|
335
|
-
|
|
336
|
-
const spawnId = document.body.getAttribute('data-spawn-id');
|
|
337
|
-
document.getElementById('hdr-spawn-id').textContent = spawnId;
|
|
338
|
-
|
|
339
|
-
const stream = document.getElementById('stream');
|
|
340
|
-
const statusEl = document.getElementById('hdr-status');
|
|
341
|
-
const statusLabel = statusEl.querySelector('.label');
|
|
342
|
-
const jumpBtn = document.getElementById('jump-btn');
|
|
343
|
-
|
|
344
|
-
// M47 D1 — sessionStorage keys (centralized so tests can grep them).
|
|
345
|
-
const SS_KEY_SELECTED = 'gsd-t.viewer.selectedSpawnId';
|
|
346
|
-
const SS_KEY_SPLITTER = 'gsd-t.viewer.splitterPct';
|
|
347
|
-
const SS_KEY_COMPLETED = 'gsd-t.viewer.completedExpanded';
|
|
348
|
-
const SS_KEY_RIGHT_RAIL = 'gsd-t.viewer.rightRailCollapsed';
|
|
349
|
-
|
|
350
|
-
// M47 D1 — sessionStorage helpers: wrapped so private-mode (or test
|
|
351
|
-
// sandboxes that don't provide a sessionStorage shim) fall through to
|
|
352
|
-
// safe defaults instead of throwing during IIFE init.
|
|
353
|
-
function _ssGet(key) { try { return (typeof sessionStorage !== 'undefined') ? sessionStorage.getItem(key) : null; } catch { return null; } }
|
|
354
|
-
function _ssSet(key, value) { try { if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(key, value); } catch { /* private mode */ } }
|
|
355
|
-
|
|
356
|
-
// M47 D1 — splitter: restore persisted pct BEFORE first paint to avoid layout shift.
|
|
357
|
-
(function initSplitterPct() {
|
|
358
|
-
const raw = _ssGet(SS_KEY_SPLITTER);
|
|
359
|
-
let pct = 50;
|
|
360
|
-
const n = parseFloat(raw);
|
|
361
|
-
if (isFinite(n)) pct = Math.max(10, Math.min(90, n));
|
|
362
|
-
if (document.documentElement && document.documentElement.style) {
|
|
363
|
-
document.documentElement.style.setProperty('--main-pane-pct', String(pct));
|
|
364
|
-
}
|
|
365
|
-
})();
|
|
366
|
-
|
|
367
|
-
// M47 D1 — right-rail collapse: restore persisted state.
|
|
368
|
-
(function initRightRailCollapsed() {
|
|
369
|
-
const flag = _ssGet(SS_KEY_RIGHT_RAIL) === '1';
|
|
370
|
-
if (document.body && document.body.setAttribute) {
|
|
371
|
-
document.body.setAttribute('data-right-rail-collapsed', flag ? 'true' : 'false');
|
|
372
|
-
}
|
|
373
|
-
const panel = document.getElementById('spawn-plan-panel');
|
|
374
|
-
if (panel && panel.setAttribute) panel.setAttribute('data-collapsed', flag ? 'true' : 'false');
|
|
375
|
-
})();
|
|
376
|
-
|
|
377
|
-
// M47 D1 — completed section: restore persisted state.
|
|
378
|
-
(function initCompletedExpanded() {
|
|
379
|
-
const flagRaw = _ssGet(SS_KEY_COMPLETED);
|
|
380
|
-
const expanded = flagRaw === null ? true : flagRaw === '1';
|
|
381
|
-
const sec = (document.querySelector ? document.querySelector('section.rail-completed') : null);
|
|
382
|
-
if (sec && sec.setAttribute) sec.setAttribute('data-expanded', expanded ? 'true' : 'false');
|
|
383
|
-
})();
|
|
384
|
-
|
|
385
|
-
// M47 D1 — splitter wiring (mouse + keyboard).
|
|
386
|
-
(function wireSplitter() {
|
|
387
|
-
const splitter = document.getElementById('splitter');
|
|
388
|
-
if (!splitter || !splitter.addEventListener) return;
|
|
389
|
-
function setPct(pct) {
|
|
390
|
-
const clamped = Math.max(10, Math.min(90, pct));
|
|
391
|
-
if (document.documentElement && document.documentElement.style) {
|
|
392
|
-
document.documentElement.style.setProperty('--main-pane-pct', String(clamped));
|
|
393
|
-
}
|
|
394
|
-
_ssSet(SS_KEY_SPLITTER, String(clamped));
|
|
395
|
-
}
|
|
396
|
-
let dragging = false;
|
|
397
|
-
function onMove(ev) {
|
|
398
|
-
if (!dragging) return;
|
|
399
|
-
const main = document.querySelector ? document.querySelector('body > main') : null;
|
|
400
|
-
if (!main || !main.getBoundingClientRect) return;
|
|
401
|
-
const r = main.getBoundingClientRect();
|
|
402
|
-
if (r.height <= 0) return;
|
|
403
|
-
const pct = ((ev.clientY - r.top) / r.height) * 100;
|
|
404
|
-
setPct(pct);
|
|
405
|
-
}
|
|
406
|
-
function onUp() { dragging = false; if (document.body && document.body.style) document.body.style.cursor = ''; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }
|
|
407
|
-
splitter.addEventListener('mousedown', (ev) => {
|
|
408
|
-
ev.preventDefault();
|
|
409
|
-
dragging = true;
|
|
410
|
-
if (document.body && document.body.style) document.body.style.cursor = 'row-resize';
|
|
411
|
-
window.addEventListener('mousemove', onMove);
|
|
412
|
-
window.addEventListener('mouseup', onUp);
|
|
413
|
-
});
|
|
414
|
-
splitter.addEventListener('keydown', (ev) => {
|
|
415
|
-
let cur = 50;
|
|
416
|
-
if (typeof getComputedStyle === 'function' && document.documentElement) {
|
|
417
|
-
const v = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--main-pane-pct'));
|
|
418
|
-
if (isFinite(v)) cur = v;
|
|
419
|
-
}
|
|
420
|
-
if (ev.key === 'ArrowUp') { ev.preventDefault(); setPct(cur - 5); }
|
|
421
|
-
else if (ev.key === 'ArrowDown') { ev.preventDefault(); setPct(cur + 5); }
|
|
422
|
-
else if (ev.key === 'Home') { ev.preventDefault(); setPct(20); }
|
|
423
|
-
else if (ev.key === 'End') { ev.preventDefault(); setPct(80); }
|
|
424
|
-
});
|
|
425
|
-
})();
|
|
426
|
-
|
|
427
|
-
// M47 D1 — right-rail collapse toggle.
|
|
428
|
-
(function wireRightRailToggle() {
|
|
429
|
-
const btn = document.getElementById('spawn-panel-toggle');
|
|
430
|
-
const panel = document.getElementById('spawn-plan-panel');
|
|
431
|
-
if (!btn || !panel || !btn.addEventListener) return;
|
|
432
|
-
btn.addEventListener('click', () => {
|
|
433
|
-
const collapsed = panel.getAttribute && panel.getAttribute('data-collapsed') === 'true';
|
|
434
|
-
const next = !collapsed;
|
|
435
|
-
if (panel.setAttribute) panel.setAttribute('data-collapsed', next ? 'true' : 'false');
|
|
436
|
-
if (document.body && document.body.setAttribute) document.body.setAttribute('data-right-rail-collapsed', next ? 'true' : 'false');
|
|
437
|
-
_ssSet(SS_KEY_RIGHT_RAIL, next ? '1' : '0');
|
|
438
|
-
});
|
|
439
|
-
})();
|
|
440
|
-
|
|
441
|
-
// M47 D1 — completed section toggle.
|
|
442
|
-
(function wireCompletedToggle() {
|
|
443
|
-
const btn = document.getElementById('rail-completed-toggle');
|
|
444
|
-
const sec = document.querySelector ? document.querySelector('section.rail-completed') : null;
|
|
445
|
-
if (!btn || !sec || !btn.addEventListener) return;
|
|
446
|
-
btn.addEventListener('click', () => {
|
|
447
|
-
const expanded = sec.getAttribute && sec.getAttribute('data-expanded') !== 'false';
|
|
448
|
-
const next = !expanded;
|
|
449
|
-
if (sec.setAttribute) sec.setAttribute('data-expanded', next ? 'true' : 'false');
|
|
450
|
-
_ssSet(SS_KEY_COMPLETED, next ? '1' : '0');
|
|
451
|
-
});
|
|
452
|
-
})();
|
|
453
|
-
|
|
454
|
-
// Pair tool_result → tool_use by tool_use_id so the renderer can place
|
|
455
|
-
// the result next to the call even when they come as separate frames.
|
|
456
|
-
const toolUseById = new Map();
|
|
457
|
-
|
|
458
|
-
let autoScroll = true;
|
|
459
|
-
|
|
460
|
-
function setStatus(cls, text) {
|
|
461
|
-
statusEl.className = 'status ' + cls;
|
|
462
|
-
statusLabel.textContent = text;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
function atBottom() {
|
|
466
|
-
return window.innerHeight + window.scrollY >= document.body.offsetHeight - 40;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
window.addEventListener('scroll', () => {
|
|
470
|
-
if (atBottom()) { autoScroll = true; jumpBtn.classList.remove('visible'); }
|
|
471
|
-
else { autoScroll = false; jumpBtn.classList.add('visible'); }
|
|
472
|
-
});
|
|
473
|
-
|
|
474
|
-
jumpBtn.addEventListener('click', () => {
|
|
475
|
-
autoScroll = true;
|
|
476
|
-
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
|
477
|
-
jumpBtn.classList.remove('visible');
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
// Two-digit zero-pad for timestamp formatting.
|
|
481
|
-
function pad2(n) { return n < 10 ? '0' + n : '' + n; }
|
|
482
|
-
// Format a Date as HH:MM:SS — local time, monospace-friendly width.
|
|
483
|
-
function fmtTs(d) {
|
|
484
|
-
if (!(d instanceof Date) || isNaN(d.getTime())) d = new Date();
|
|
485
|
-
return pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
|
|
486
|
-
}
|
|
487
|
-
window.__gsdtFmtTs = fmtTs;
|
|
488
|
-
|
|
489
|
-
// Per-frame timestamp. We prefer the producer-side `frame.ts`
|
|
490
|
-
// (ISO string written by the conversation-capture hook) when it
|
|
491
|
-
// parses cleanly, and only fall back to SSE arrival time when no
|
|
492
|
-
// `ts` field is present. Without this, an initial-load replay of
|
|
493
|
-
// N frames all arrives within one millisecond and every row shows
|
|
494
|
-
// the same HH:MM:SS — useless for spotting stuck or stale streams.
|
|
495
|
-
//
|
|
496
|
-
// M47 D1: appendFrame writes to a module-scope `renderTarget`. The
|
|
497
|
-
// renderFrame entry point swaps it per-call so the same code paths
|
|
498
|
-
// render into either the top pane (#main-stream — main-conversation
|
|
499
|
-
// SSE) or the bottom pane (#stream inside #spawn-stream — selected
|
|
500
|
-
// spawn SSE).
|
|
501
|
-
function frameTs(frame, fallback) {
|
|
502
|
-
if (frame && typeof frame.ts === 'string') {
|
|
503
|
-
const d = new Date(frame.ts);
|
|
504
|
-
if (!isNaN(d.getTime())) return d;
|
|
505
|
-
}
|
|
506
|
-
return fallback instanceof Date ? fallback : new Date();
|
|
507
|
-
}
|
|
508
|
-
window.__gsdtFrameTs = frameTs;
|
|
509
|
-
let renderTarget = stream;
|
|
510
|
-
function appendFrame(el, arrivedAt) {
|
|
511
|
-
const ts = document.createElement('span');
|
|
512
|
-
ts.className = 'ts';
|
|
513
|
-
ts.textContent = fmtTs(arrivedAt instanceof Date ? arrivedAt : new Date());
|
|
514
|
-
ts.title = (arrivedAt instanceof Date ? arrivedAt : new Date()).toISOString();
|
|
515
|
-
// Insert as the first child so flex/inline layouts keep working.
|
|
516
|
-
if (el.firstChild) el.insertBefore(ts, el.firstChild);
|
|
517
|
-
else el.appendChild(ts);
|
|
518
|
-
(renderTarget || stream).appendChild(el);
|
|
519
|
-
// Auto-scroll only applies to the bottom pane (where the user is
|
|
520
|
-
// actively focused). The top pane scrolls within itself; we let the
|
|
521
|
-
// user's scrollbar manage it without yanking focus.
|
|
522
|
-
if (renderTarget === stream && autoScroll) {
|
|
523
|
-
requestAnimationFrame(() => window.scrollTo(0, document.body.scrollHeight));
|
|
524
|
-
} else if (renderTarget && renderTarget !== stream) {
|
|
525
|
-
// Auto-scroll the top pane only when its viewport is at-bottom (similar
|
|
526
|
-
// logic to atBottom() but pane-local).
|
|
527
|
-
const pane = renderTarget.parentElement || renderTarget;
|
|
528
|
-
if (pane && pane.scrollHeight - pane.scrollTop - pane.clientHeight < 60) {
|
|
529
|
-
requestAnimationFrame(() => { pane.scrollTop = pane.scrollHeight; });
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
function renderSystem(frame, arrivedAt) {
|
|
535
|
-
const div = document.createElement('div');
|
|
536
|
-
div.className = 'frame system';
|
|
537
|
-
const sid = frame.session_id ? ' session=' + frame.session_id.slice(0, 8) : '';
|
|
538
|
-
const model = frame.model ? ' model=' + frame.model : '';
|
|
539
|
-
const subtype = frame.subtype ? ' ' + frame.subtype : '';
|
|
540
|
-
const txt = document.createElement('span');
|
|
541
|
-
txt.textContent = 'system' + subtype + sid + model;
|
|
542
|
-
div.appendChild(txt);
|
|
543
|
-
appendFrame(div, arrivedAt);
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
function renderUserMessage(content, arrivedAt) {
|
|
547
|
-
const div = document.createElement('div');
|
|
548
|
-
div.className = 'frame user';
|
|
549
|
-
const p = document.createElement('span');
|
|
550
|
-
p.className = 'prefix';
|
|
551
|
-
p.textContent = '>';
|
|
552
|
-
div.appendChild(p);
|
|
553
|
-
const span = document.createElement('span');
|
|
554
|
-
span.textContent = typeof content === 'string' ? content : JSON.stringify(content);
|
|
555
|
-
div.appendChild(span);
|
|
556
|
-
appendFrame(div, arrivedAt);
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
function renderAssistantText(text, arrivedAt) {
|
|
560
|
-
const div = document.createElement('div');
|
|
561
|
-
div.className = 'frame assistant-text';
|
|
562
|
-
const span = document.createElement('span');
|
|
563
|
-
span.textContent = text;
|
|
564
|
-
div.appendChild(span);
|
|
565
|
-
appendFrame(div, arrivedAt);
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
function renderThinking(text, arrivedAt) {
|
|
569
|
-
const d = document.createElement('details');
|
|
570
|
-
d.className = 'frame thinking';
|
|
571
|
-
const s = document.createElement('summary');
|
|
572
|
-
s.textContent = '✻ Thinking (' + text.length + ' chars)';
|
|
573
|
-
d.appendChild(s);
|
|
574
|
-
const pre = document.createElement('div');
|
|
575
|
-
pre.textContent = text;
|
|
576
|
-
d.appendChild(pre);
|
|
577
|
-
appendFrame(d, arrivedAt);
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
function toolUsePreview(input) {
|
|
581
|
-
if (!input || typeof input !== 'object') return '';
|
|
582
|
-
// Surface the most useful single arg: file_path, command, pattern, path, description
|
|
583
|
-
const keys = ['file_path', 'command', 'pattern', 'path', 'description', 'prompt'];
|
|
584
|
-
for (const k of keys) {
|
|
585
|
-
if (typeof input[k] === 'string') {
|
|
586
|
-
const v = input[k];
|
|
587
|
-
return k + '="' + (v.length > 80 ? v.slice(0, 77) + '…' : v) + '"';
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
const first = Object.keys(input)[0];
|
|
591
|
-
if (first) return first + '=…';
|
|
592
|
-
return '';
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
function renderToolUse(block, arrivedAt) {
|
|
596
|
-
const d = document.createElement('details');
|
|
597
|
-
d.className = 'frame tool-use';
|
|
598
|
-
const s = document.createElement('summary');
|
|
599
|
-
s.textContent = block.name + '(' + toolUsePreview(block.input) + ')';
|
|
600
|
-
d.appendChild(s);
|
|
601
|
-
const pre = document.createElement('pre');
|
|
602
|
-
try { pre.textContent = JSON.stringify(block.input, null, 2); }
|
|
603
|
-
catch { pre.textContent = String(block.input); }
|
|
604
|
-
d.appendChild(pre);
|
|
605
|
-
toolUseById.set(block.id, { name: block.name, el: d });
|
|
606
|
-
appendFrame(d, arrivedAt);
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
function renderToolResult(block, arrivedAt) {
|
|
610
|
-
const div = document.createElement('div');
|
|
611
|
-
div.className = 'frame tool-result';
|
|
612
|
-
const pre = document.createElement('pre');
|
|
613
|
-
let text;
|
|
614
|
-
if (typeof block.content === 'string') text = block.content;
|
|
615
|
-
else if (Array.isArray(block.content)) {
|
|
616
|
-
text = block.content.map(c => typeof c === 'string' ? c : (c.text || JSON.stringify(c))).join('\n');
|
|
617
|
-
} else text = JSON.stringify(block.content || '');
|
|
618
|
-
pre.textContent = text;
|
|
619
|
-
div.appendChild(pre);
|
|
620
|
-
if (text.length > 800) {
|
|
621
|
-
const more = document.createElement('span');
|
|
622
|
-
more.className = 'show-more';
|
|
623
|
-
more.textContent = 'show more';
|
|
624
|
-
more.addEventListener('click', () => {
|
|
625
|
-
div.classList.toggle('expanded');
|
|
626
|
-
more.textContent = div.classList.contains('expanded') ? 'show less' : 'show more';
|
|
627
|
-
});
|
|
628
|
-
div.appendChild(more);
|
|
629
|
-
}
|
|
630
|
-
appendFrame(div, arrivedAt);
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
function renderBoundary(frame, arrivedAt) {
|
|
634
|
-
const div = document.createElement('div');
|
|
635
|
-
div.className = 'frame boundary ' + (frame.state || '');
|
|
636
|
-
const label = document.createElement('span');
|
|
637
|
-
label.className = 'label';
|
|
638
|
-
label.textContent = '▸ task ' + (frame.state || '?');
|
|
639
|
-
div.appendChild(label);
|
|
640
|
-
const meta = document.createElement('span');
|
|
641
|
-
meta.className = 'meta';
|
|
642
|
-
const bits = [];
|
|
643
|
-
if (frame.taskId) bits.push('id=' + frame.taskId);
|
|
644
|
-
if (frame.domain) bits.push('domain=' + frame.domain);
|
|
645
|
-
if (frame.wave != null) bits.push('wave=' + frame.wave);
|
|
646
|
-
if (frame.exitCode != null) bits.push('exit=' + frame.exitCode);
|
|
647
|
-
if (frame.durationMs != null) bits.push('duration=' + Math.round(frame.durationMs/1000) + 's');
|
|
648
|
-
meta.textContent = bits.join(' ');
|
|
649
|
-
div.appendChild(meta);
|
|
650
|
-
appendFrame(div, arrivedAt);
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
function renderRaw(line, arrivedAt) {
|
|
654
|
-
const div = document.createElement('div');
|
|
655
|
-
div.className = 'frame raw';
|
|
656
|
-
const span = document.createElement('span');
|
|
657
|
-
span.textContent = line;
|
|
658
|
-
div.appendChild(span);
|
|
659
|
-
appendFrame(div, arrivedAt);
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
// M48 — chat-bubble renderers for the in-session conversation NDJSON.
|
|
663
|
-
// The conversation-capture hook emits frames of type
|
|
664
|
-
// `user_turn`, `assistant_turn`, `session_start`, and `tool_use`.
|
|
665
|
-
// Without explicit handling these fall through to renderRaw and the
|
|
666
|
-
// user sees a JSON.stringify dump per row.
|
|
667
|
-
function _appendTruncatedTag(div, frame) {
|
|
668
|
-
if (!frame || !frame.truncated) return;
|
|
669
|
-
const tag = document.createElement('span');
|
|
670
|
-
tag.className = 'truncated-tag';
|
|
671
|
-
tag.textContent = '(truncated)';
|
|
672
|
-
div.appendChild(tag);
|
|
673
|
-
}
|
|
674
|
-
function renderUserTurn(frame, arrivedAt) {
|
|
675
|
-
const div = document.createElement('div');
|
|
676
|
-
div.className = 'frame user user-turn';
|
|
677
|
-
const p = document.createElement('span');
|
|
678
|
-
p.className = 'prefix';
|
|
679
|
-
p.textContent = '>';
|
|
680
|
-
div.appendChild(p);
|
|
681
|
-
const body = document.createElement('span');
|
|
682
|
-
body.className = 'body';
|
|
683
|
-
body.textContent = (frame && typeof frame.content === 'string') ? frame.content : '';
|
|
684
|
-
div.appendChild(body);
|
|
685
|
-
_appendTruncatedTag(div, frame);
|
|
686
|
-
appendFrame(div, arrivedAt);
|
|
687
|
-
}
|
|
688
|
-
function renderAssistantTurn(frame, arrivedAt) {
|
|
689
|
-
const div = document.createElement('div');
|
|
690
|
-
div.className = 'frame assistant-turn';
|
|
691
|
-
const p = document.createElement('span');
|
|
692
|
-
p.className = 'prefix';
|
|
693
|
-
p.textContent = '⏺';
|
|
694
|
-
div.appendChild(p);
|
|
695
|
-
const body = document.createElement('span');
|
|
696
|
-
body.className = 'body';
|
|
697
|
-
body.textContent = (frame && typeof frame.content === 'string') ? frame.content : '';
|
|
698
|
-
div.appendChild(body);
|
|
699
|
-
_appendTruncatedTag(div, frame);
|
|
700
|
-
appendFrame(div, arrivedAt);
|
|
701
|
-
}
|
|
702
|
-
function renderSessionStart(frame, arrivedAt) {
|
|
703
|
-
const div = document.createElement('div');
|
|
704
|
-
div.className = 'frame session-start';
|
|
705
|
-
const badge = document.createElement('span');
|
|
706
|
-
badge.className = 'badge';
|
|
707
|
-
badge.textContent = '◆ session';
|
|
708
|
-
div.appendChild(badge);
|
|
709
|
-
if (frame && typeof frame.session_id === 'string') {
|
|
710
|
-
const sid = document.createElement('span');
|
|
711
|
-
sid.textContent = frame.session_id.slice(0, 8);
|
|
712
|
-
div.appendChild(sid);
|
|
713
|
-
}
|
|
714
|
-
appendFrame(div, arrivedAt);
|
|
715
|
-
}
|
|
716
|
-
function renderToolUseLine(frame, arrivedAt) {
|
|
717
|
-
const div = document.createElement('div');
|
|
718
|
-
div.className = 'frame tool-call-line';
|
|
719
|
-
const span = document.createElement('span');
|
|
720
|
-
const name = (frame && typeof frame.name === 'string') ? frame.name : 'tool';
|
|
721
|
-
span.textContent = '⎿ ' + name + '()';
|
|
722
|
-
div.appendChild(span);
|
|
723
|
-
appendFrame(div, arrivedAt);
|
|
724
|
-
}
|
|
725
|
-
window.__gsdtRenderUserTurn = renderUserTurn;
|
|
726
|
-
window.__gsdtRenderAssistantTurn = renderAssistantTurn;
|
|
727
|
-
window.__gsdtRenderSessionStart = renderSessionStart;
|
|
728
|
-
window.__gsdtRenderToolUseLine = renderToolUseLine;
|
|
729
|
-
|
|
730
|
-
function renderCompactMarker(frame, arrivedAt) {
|
|
731
|
-
const div = document.createElement('div');
|
|
732
|
-
div.className = 'frame compact-marker';
|
|
733
|
-
|
|
734
|
-
const lineL = document.createElement('span');
|
|
735
|
-
lineL.className = 'cm-line';
|
|
736
|
-
|
|
737
|
-
const badge = document.createElement('span');
|
|
738
|
-
badge.className = 'cm-badge';
|
|
739
|
-
badge.textContent = '⚡ CW boundary';
|
|
740
|
-
|
|
741
|
-
// Build tooltip text from available fields.
|
|
742
|
-
const tipParts = [];
|
|
743
|
-
if (frame.ts) tipParts.push('ts: ' + frame.ts);
|
|
744
|
-
if (frame.trigger) tipParts.push('trigger: ' + frame.trigger);
|
|
745
|
-
if (frame.prior_session_id) tipParts.push('prior: ' + frame.prior_session_id.slice(0, 12));
|
|
746
|
-
if (frame.session_id) tipParts.push('new: ' + frame.session_id.slice(0, 12));
|
|
747
|
-
if (frame.preTokens != null) tipParts.push('pre: ' + frame.preTokens.toLocaleString());
|
|
748
|
-
if (frame.postTokens != null) tipParts.push('post: ' + frame.postTokens.toLocaleString());
|
|
749
|
-
badge.title = tipParts.join('\n') || 'Context Window boundary';
|
|
750
|
-
|
|
751
|
-
const lineR = document.createElement('span');
|
|
752
|
-
lineR.className = 'cm-line';
|
|
753
|
-
|
|
754
|
-
div.appendChild(lineL);
|
|
755
|
-
div.appendChild(badge);
|
|
756
|
-
div.appendChild(lineR);
|
|
757
|
-
appendFrame(div, arrivedAt);
|
|
758
|
-
}
|
|
759
|
-
window.__gsdtRenderCompactMarker = renderCompactMarker;
|
|
760
|
-
|
|
761
|
-
// M47 D1: optional `target` element switches render destination for the
|
|
762
|
-
// duration of this call (top pane = #main-stream, bottom pane = #stream).
|
|
763
|
-
function renderFrame(frame, arrivedAt, target) {
|
|
764
|
-
if (!frame || typeof frame !== 'object') return;
|
|
765
|
-
const prev = renderTarget;
|
|
766
|
-
if (target) renderTarget = target;
|
|
767
|
-
try { return renderFrameInner(frame, arrivedAt); }
|
|
768
|
-
finally { renderTarget = prev; }
|
|
769
|
-
}
|
|
770
|
-
function renderFrameInner(frame, arrivedAt) {
|
|
771
|
-
if (!frame || typeof frame !== 'object') return;
|
|
772
|
-
// Defense in depth: if a caller passes a non-Date `arrivedAt`, still
|
|
773
|
-
// try to derive a real timestamp from frame.ts before falling back
|
|
774
|
-
// to "now". Without this guard, every renderFrame() call from a
|
|
775
|
-
// non-SSE path (e.g. tests, manual dispatch) collapses to one Date.
|
|
776
|
-
const ts = (arrivedAt instanceof Date && !isNaN(arrivedAt.getTime()))
|
|
777
|
-
? arrivedAt
|
|
778
|
-
: frameTs(frame, new Date());
|
|
779
|
-
const type = frame.type;
|
|
780
|
-
if (type === 'compact_marker') { renderCompactMarker(frame, ts); return; }
|
|
781
|
-
if (type === 'system') { renderSystem(frame, ts); return; }
|
|
782
|
-
if (type === 'task-boundary') { renderBoundary(frame, ts); return; }
|
|
783
|
-
if (type === 'raw') { renderRaw(frame.line || '', ts); return; }
|
|
784
|
-
// M48 — in-session conversation-capture frame types.
|
|
785
|
-
if (type === 'session_start') { renderSessionStart(frame, ts); return; }
|
|
786
|
-
if (type === 'user_turn') { renderUserTurn(frame, ts); return; }
|
|
787
|
-
if (type === 'assistant_turn') { renderAssistantTurn(frame, ts); return; }
|
|
788
|
-
if (type === 'tool_use') { renderToolUseLine(frame, ts); return; }
|
|
789
|
-
if (type === 'assistant' && frame.message && Array.isArray(frame.message.content)) {
|
|
790
|
-
for (const b of frame.message.content) {
|
|
791
|
-
if (b.type === 'text') renderAssistantText(b.text || '', ts);
|
|
792
|
-
else if (b.type === 'thinking') renderThinking(b.thinking || b.text || '', ts);
|
|
793
|
-
else if (b.type === 'tool_use') renderToolUse(b, ts);
|
|
794
|
-
else renderRaw(JSON.stringify(b), ts);
|
|
795
|
-
}
|
|
796
|
-
return;
|
|
797
|
-
}
|
|
798
|
-
if (type === 'user' && frame.message && Array.isArray(frame.message.content)) {
|
|
799
|
-
for (const b of frame.message.content) {
|
|
800
|
-
if (b.type === 'tool_result') renderToolResult(b, ts);
|
|
801
|
-
else if (b.type === 'text') renderUserMessage(b.text || '', ts);
|
|
802
|
-
else renderRaw(JSON.stringify(b), ts);
|
|
803
|
-
}
|
|
804
|
-
return;
|
|
805
|
-
}
|
|
806
|
-
if (type === 'user' && typeof frame.message === 'string') {
|
|
807
|
-
renderUserMessage(frame.message, ts);
|
|
808
|
-
return;
|
|
809
|
-
}
|
|
810
|
-
if (type === 'result') {
|
|
811
|
-
const d = document.createElement('div');
|
|
812
|
-
d.className = 'frame system';
|
|
813
|
-
const span = document.createElement('span');
|
|
814
|
-
span.textContent = 'result subtype=' + (frame.subtype || '?') + ' duration=' + (frame.duration_ms || 0) + 'ms';
|
|
815
|
-
d.appendChild(span);
|
|
816
|
-
appendFrame(d, ts);
|
|
817
|
-
return;
|
|
818
|
-
}
|
|
819
|
-
renderRaw(JSON.stringify(frame), ts);
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
// ── Sidebar ─────────────────────────────────────────────────────────
|
|
823
|
-
//
|
|
824
|
-
// Poll /transcripts every 3s, build a parent-indented tree, render.
|
|
825
|
-
// Exposed as window.__gsdtBuildTree for unit-testable tree-build logic.
|
|
826
|
-
|
|
827
|
-
function buildTree(spawns) {
|
|
828
|
-
// Map spawnId → node, then link children to parents. Orphans float
|
|
829
|
-
// to the root level.
|
|
830
|
-
const byId = new Map();
|
|
831
|
-
const roots = [];
|
|
832
|
-
for (const s of spawns) byId.set(s.spawnId, { ...s, children: [] });
|
|
833
|
-
for (const node of byId.values()) {
|
|
834
|
-
if (node.parentId && byId.has(node.parentId)) {
|
|
835
|
-
byId.get(node.parentId).children.push(node);
|
|
836
|
-
} else {
|
|
837
|
-
roots.push(node);
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
// Sort each level newest-first
|
|
841
|
-
const ts = (a, b) => (Date.parse(b.startedAt) || 0) - (Date.parse(a.startedAt) || 0);
|
|
842
|
-
roots.sort(ts);
|
|
843
|
-
for (const n of byId.values()) n.children.sort(ts);
|
|
844
|
-
return roots;
|
|
845
|
-
}
|
|
846
|
-
window.__gsdtBuildTree = buildTree;
|
|
847
|
-
|
|
848
|
-
function statusClass(s) {
|
|
849
|
-
if (s.status === 'running') return 'running';
|
|
850
|
-
if (s.status === 'failed') return 'failed';
|
|
851
|
-
if (s.status === 'stopped') return 'stopped';
|
|
852
|
-
return '';
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
function renderTree(roots) {
|
|
856
|
-
const tree = document.getElementById('tree');
|
|
857
|
-
tree.innerHTML = '';
|
|
858
|
-
if (!roots.length) {
|
|
859
|
-
const e = document.createElement('div');
|
|
860
|
-
e.className = 'empty';
|
|
861
|
-
e.textContent = 'No spawns yet.';
|
|
862
|
-
tree.appendChild(e);
|
|
863
|
-
return;
|
|
864
|
-
}
|
|
865
|
-
const currentId = (location.hash || '#').slice(1) || spawnId;
|
|
866
|
-
// M45 D2: in-session conversation NDJSONs are distinguished by the
|
|
867
|
-
// `in-session-` spawn-id prefix. The viewer labels them with
|
|
868
|
-
// `💬 conversation` instead of the default `▶ spawn`. This is a
|
|
869
|
-
// front-end-only discriminator — no server-side type field is
|
|
870
|
-
// required; the filename prefix (== spawn-id) is the contract.
|
|
871
|
-
function isInSession(node) {
|
|
872
|
-
return typeof node.spawnId === 'string' && node.spawnId.indexOf('in-session-') === 0;
|
|
873
|
-
}
|
|
874
|
-
function render(node, depth) {
|
|
875
|
-
const el = document.createElement('div');
|
|
876
|
-
el.className = 'node ' + statusClass(node);
|
|
877
|
-
if (isInSession(node)) el.classList.add('in-session');
|
|
878
|
-
if (node.spawnId === currentId) el.classList.add('active');
|
|
879
|
-
el.style.paddingLeft = (12 + depth * 14) + 'px';
|
|
880
|
-
const dot = document.createElement('span'); dot.className = 'dot';
|
|
881
|
-
const name = document.createElement('span'); name.className = 'name';
|
|
882
|
-
if (isInSession(node)) {
|
|
883
|
-
const badge = document.createElement('span');
|
|
884
|
-
badge.className = 'label-in-session';
|
|
885
|
-
badge.textContent = '💬 conversation';
|
|
886
|
-
name.appendChild(badge);
|
|
887
|
-
const tail = document.createElement('span');
|
|
888
|
-
tail.textContent = ' · ' + node.spawnId.slice(-8);
|
|
889
|
-
name.appendChild(tail);
|
|
890
|
-
} else {
|
|
891
|
-
name.textContent = (node.command || 'spawn') + ' · ' + node.spawnId.slice(-8);
|
|
892
|
-
}
|
|
893
|
-
name.title = (node.description || node.spawnId) + '\n' + (node.startedAt || '');
|
|
894
|
-
const kill = document.createElement('button');
|
|
895
|
-
kill.className = 'kill';
|
|
896
|
-
kill.textContent = 'kill';
|
|
897
|
-
kill.disabled = node.status !== 'running' || !node.workerPid;
|
|
898
|
-
kill.addEventListener('click', (ev) => {
|
|
899
|
-
ev.stopPropagation();
|
|
900
|
-
if (!confirm('SIGTERM spawn ' + node.spawnId + ' (pid ' + node.workerPid + ')?')) return;
|
|
901
|
-
fetch('/transcript/' + encodeURIComponent(node.spawnId) + '/kill', { method: 'POST' })
|
|
902
|
-
.then((r) => r.json())
|
|
903
|
-
.then((j) => { kill.textContent = j.status || 'killed'; })
|
|
904
|
-
.catch(() => { kill.textContent = 'err'; });
|
|
905
|
-
});
|
|
906
|
-
el.appendChild(dot); el.appendChild(name); el.appendChild(kill);
|
|
907
|
-
el.addEventListener('click', () => {
|
|
908
|
-
// M52 — symmetric with renderRailEntry: only the LIVE main
|
|
909
|
-
// session id is blocked from the bottom pane. Historical /
|
|
910
|
-
// additional in-session entries route normally.
|
|
911
|
-
if (isInSession(node) && node.spawnId === ('in-session-' + window.__mainSessionId)) return;
|
|
912
|
-
if (node.spawnId === currentId) return;
|
|
913
|
-
location.hash = node.spawnId;
|
|
914
|
-
});
|
|
915
|
-
document.getElementById('tree').appendChild(el);
|
|
916
|
-
for (const c of node.children) render(c, depth + 1);
|
|
917
|
-
}
|
|
918
|
-
for (const r of roots) render(r, 0);
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
// ── Auto-follow latest spawn ────────────────────────────────────────
|
|
922
|
-
//
|
|
923
|
-
// Default ON. Persisted in localStorage under 'gsdt.autoFollow' so a
|
|
924
|
-
// user's per-tab decision survives reloads. When ON, pollSpawns()
|
|
925
|
-
// checks for a newer running spawn than the currently-focused one and
|
|
926
|
-
// immediately snaps focus to it via location.hash. The new spawn's
|
|
927
|
-
// sidebar node gets a 10s 'pulsing' class so the eye lands on it even
|
|
928
|
-
// after the SSE reconnect repaints the main pane.
|
|
929
|
-
|
|
930
|
-
const AUTOFOLLOW_KEY = 'gsdt.autoFollow';
|
|
931
|
-
const autoFollowEl = document.getElementById('auto-follow');
|
|
932
|
-
const stored = localStorage.getItem(AUTOFOLLOW_KEY);
|
|
933
|
-
autoFollowEl.checked = stored === null ? true : stored === '1';
|
|
934
|
-
autoFollowEl.addEventListener('change', () => {
|
|
935
|
-
localStorage.setItem(AUTOFOLLOW_KEY, autoFollowEl.checked ? '1' : '0');
|
|
936
|
-
});
|
|
937
|
-
|
|
938
|
-
// Track which spawnIds we've already seen-live so we only treat the
|
|
939
|
-
// FIRST appearance of a newly-live spawn as a follow event. Without
|
|
940
|
-
// this guard the tree poll would re-trigger follow every 3s while the
|
|
941
|
-
// spawn stayed running (no-op since the id would equal currentId, but
|
|
942
|
-
// also no-op when the user has manually navigated away).
|
|
943
|
-
const seenLive = new Set();
|
|
944
|
-
|
|
945
|
-
function pulseNode(spawnId) {
|
|
946
|
-
// Defer one frame so the tree DOM exists.
|
|
947
|
-
requestAnimationFrame(() => {
|
|
948
|
-
const nodes = document.querySelectorAll('aside .node');
|
|
949
|
-
for (const n of nodes) {
|
|
950
|
-
const nm = n.querySelector('.name');
|
|
951
|
-
if (nm && nm.textContent.endsWith(spawnId.slice(-8))) {
|
|
952
|
-
n.classList.add('pulsing');
|
|
953
|
-
setTimeout(() => n.classList.remove('pulsing'), 10000);
|
|
954
|
-
break;
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
});
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
function maybeAutoFollow(spawns) {
|
|
961
|
-
if (!autoFollowEl.checked) return;
|
|
962
|
-
const currentId = (location.hash || '').slice(1) || spawnId;
|
|
963
|
-
// M48 — auto-follow drives the BOTTOM pane (selected-spawn). The
|
|
964
|
-
// top pane is owned by /api/main-session and shows in-session-*
|
|
965
|
-
// entries automatically. Excluding them here prevents the auto-
|
|
966
|
-
// follow loop from also pinning them into the bottom pane.
|
|
967
|
-
// Most recent running spawn by startedAt (descending).
|
|
968
|
-
const running = spawns
|
|
969
|
-
.filter((s) => s.status === 'running')
|
|
970
|
-
.filter((s) => !(typeof s.spawnId === 'string' && s.spawnId.indexOf('in-session-') === 0))
|
|
971
|
-
.sort((a, b) => (Date.parse(b.startedAt) || 0) - (Date.parse(a.startedAt) || 0));
|
|
972
|
-
if (!running.length) return;
|
|
973
|
-
const latest = running[0];
|
|
974
|
-
if (latest.spawnId === currentId) {
|
|
975
|
-
seenLive.add(latest.spawnId);
|
|
976
|
-
return;
|
|
977
|
-
}
|
|
978
|
-
// Only follow if this is a NEWLY-live spawn — i.e., we haven't seen
|
|
979
|
-
// it as live in a previous poll. This stops us from yanking focus
|
|
980
|
-
// back every 3s when the user has manually navigated to an older
|
|
981
|
-
// spawn whose sibling is still running.
|
|
982
|
-
if (seenLive.has(latest.spawnId)) return;
|
|
983
|
-
seenLive.add(latest.spawnId);
|
|
984
|
-
location.hash = latest.spawnId;
|
|
985
|
-
pulseNode(latest.spawnId);
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
// M47 D1 — render a single rail entry (used by bucketing).
|
|
989
|
-
function renderRailEntry(node, container, opts) {
|
|
990
|
-
const isInSession = typeof node.spawnId === 'string' && node.spawnId.indexOf('in-session-') === 0;
|
|
991
|
-
const currentId = (location.hash || '').slice(1) || spawnId;
|
|
992
|
-
const el = document.createElement('div');
|
|
993
|
-
el.className = 'node ' + statusClass(node);
|
|
994
|
-
if (isInSession) el.classList.add('in-session');
|
|
995
|
-
if (node.spawnId === currentId) el.classList.add('active');
|
|
996
|
-
el.style.paddingLeft = '12px';
|
|
997
|
-
const dot = document.createElement('span'); dot.className = 'dot';
|
|
998
|
-
const name = document.createElement('span'); name.className = 'name';
|
|
999
|
-
// M47 D1 — status badge (gated to completed-section entries; others
|
|
1000
|
-
// already use the running/stopped/failed `dot` for state).
|
|
1001
|
-
if (opts && opts.showBadge) {
|
|
1002
|
-
const badgeKind = (function () {
|
|
1003
|
-
const s = node.status;
|
|
1004
|
-
if (s === 'success' || s === 'failed' || s === 'killed') return s;
|
|
1005
|
-
return 'completed'; // neutral fallback for any other terminal value
|
|
1006
|
-
})();
|
|
1007
|
-
const badge = document.createElement('span');
|
|
1008
|
-
badge.className = 'badge-status ' + badgeKind;
|
|
1009
|
-
badge.textContent = badgeKind;
|
|
1010
|
-
name.appendChild(badge);
|
|
1011
|
-
}
|
|
1012
|
-
if (isInSession) {
|
|
1013
|
-
const tag = document.createElement('span');
|
|
1014
|
-
tag.className = 'label-in-session';
|
|
1015
|
-
tag.textContent = '💬 conversation';
|
|
1016
|
-
name.appendChild(tag);
|
|
1017
|
-
const tail = document.createElement('span');
|
|
1018
|
-
tail.textContent = ' · ' + node.spawnId.slice(-8);
|
|
1019
|
-
name.appendChild(tail);
|
|
1020
|
-
} else {
|
|
1021
|
-
const tail = document.createElement('span');
|
|
1022
|
-
tail.textContent = (node.command || 'spawn') + ' · ' + node.spawnId.slice(-8);
|
|
1023
|
-
name.appendChild(tail);
|
|
1024
|
-
}
|
|
1025
|
-
name.title = (node.description || node.spawnId) + '\n' + (node.startedAt || '');
|
|
1026
|
-
const kill = document.createElement('button');
|
|
1027
|
-
kill.className = 'kill';
|
|
1028
|
-
kill.textContent = 'kill';
|
|
1029
|
-
kill.disabled = node.status !== 'running' || !node.workerPid;
|
|
1030
|
-
kill.addEventListener('click', (ev) => {
|
|
1031
|
-
ev.stopPropagation();
|
|
1032
|
-
if (!confirm('SIGTERM spawn ' + node.spawnId + ' (pid ' + node.workerPid + ')?')) return;
|
|
1033
|
-
fetch('/transcript/' + encodeURIComponent(node.spawnId) + '/kill', { method: 'POST' })
|
|
1034
|
-
.then((r) => r.json())
|
|
1035
|
-
.then((j) => { kill.textContent = j.status || 'killed'; })
|
|
1036
|
-
.catch(() => { kill.textContent = 'err'; });
|
|
1037
|
-
});
|
|
1038
|
-
el.appendChild(dot); el.appendChild(name); el.appendChild(kill);
|
|
1039
|
-
el.addEventListener('click', () => {
|
|
1040
|
-
// M52 — narrow the M48 Bug 4 guard. The TOP pane streams the LIVE
|
|
1041
|
-
// main session via /api/main-session; only THAT specific in-session
|
|
1042
|
-
// id should be blocked from the bottom pane (otherwise both panes
|
|
1043
|
-
// would show identical content). Historical in-session conversations
|
|
1044
|
-
// (the entire COMPLETED rail) are tagged in-session-* but are NOT
|
|
1045
|
-
// the live main session — they must load into the bottom pane.
|
|
1046
|
-
if (isInSession && node.spawnId === ('in-session-' + window.__mainSessionId)) return;
|
|
1047
|
-
if (node.spawnId === currentId) return;
|
|
1048
|
-
_ssSet(SS_KEY_SELECTED, node.spawnId);
|
|
1049
|
-
location.hash = node.spawnId;
|
|
1050
|
-
});
|
|
1051
|
-
container.appendChild(el);
|
|
1052
|
-
}
|
|
1053
|
-
window.__gsdtRenderRailEntry = renderRailEntry;
|
|
1054
|
-
|
|
1055
|
-
const COMPLETED_RAIL_CAP = 100;
|
|
1056
|
-
// M47 D1 — top-level bucketing into the 3 rail sections.
|
|
1057
|
-
// Bucketing rules (per m47-integration-points.md):
|
|
1058
|
-
// - in-session-* AND most-recent → Main Session (★)
|
|
1059
|
-
// - status === 'active' → Live Spawns
|
|
1060
|
-
// - any other (default 'completed') → Completed (capped 100)
|
|
1061
|
-
function bucketAndRender(spawns) {
|
|
1062
|
-
const mainBody = document.getElementById('rail-main-body');
|
|
1063
|
-
const liveTree = document.getElementById('tree');
|
|
1064
|
-
const completedBody = document.getElementById('rail-completed-body');
|
|
1065
|
-
if (!mainBody || !liveTree || !completedBody) return;
|
|
1066
|
-
const sortedByMtime = spawns.slice().sort(
|
|
1067
|
-
(a, b) => (Date.parse(b.lastUpdatedAt || b.startedAt) || 0) - (Date.parse(a.lastUpdatedAt || a.startedAt) || 0)
|
|
1068
|
-
);
|
|
1069
|
-
const inSessionSorted = sortedByMtime.filter((s) => typeof s.spawnId === 'string' && s.spawnId.indexOf('in-session-') === 0);
|
|
1070
|
-
const mainEntry = inSessionSorted[0] || null;
|
|
1071
|
-
const others = spawns.filter((s) => s !== mainEntry);
|
|
1072
|
-
// Live: status === 'active' OR (status absent AND not terminal — preserve
|
|
1073
|
-
// legacy spawn-index entries that never had a status field).
|
|
1074
|
-
const live = [];
|
|
1075
|
-
const completed = [];
|
|
1076
|
-
for (const s of others) {
|
|
1077
|
-
if (s.status === 'completed' || s.status === 'success' || s.status === 'failed' || s.status === 'killed') {
|
|
1078
|
-
completed.push(s);
|
|
1079
|
-
} else {
|
|
1080
|
-
live.push(s);
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
// Main rail body
|
|
1084
|
-
mainBody.innerHTML = '';
|
|
1085
|
-
if (mainEntry) {
|
|
1086
|
-
renderRailEntry(mainEntry, mainBody, { showBadge: false });
|
|
1087
|
-
} else {
|
|
1088
|
-
const e = document.createElement('div'); e.className = 'empty';
|
|
1089
|
-
e.textContent = 'No in-session conversation captured yet.';
|
|
1090
|
-
mainBody.appendChild(e);
|
|
1091
|
-
}
|
|
1092
|
-
// Live rail (legacy #tree mount — preserves existing tests / selectors).
|
|
1093
|
-
// Build a parent-indented tree from `live` only so completed-only
|
|
1094
|
-
// entries don't duplicate.
|
|
1095
|
-
renderTree(buildTree(live));
|
|
1096
|
-
// Completed rail (capped 100, newest first by startedAt desc).
|
|
1097
|
-
completedBody.innerHTML = '';
|
|
1098
|
-
const completedSorted = completed
|
|
1099
|
-
.slice()
|
|
1100
|
-
.sort((a, b) => (Date.parse(b.startedAt) || 0) - (Date.parse(a.startedAt) || 0))
|
|
1101
|
-
.slice(0, COMPLETED_RAIL_CAP);
|
|
1102
|
-
if (completedSorted.length === 0) {
|
|
1103
|
-
const e = document.createElement('div'); e.className = 'empty';
|
|
1104
|
-
e.textContent = 'No completed spawns yet.';
|
|
1105
|
-
completedBody.appendChild(e);
|
|
1106
|
-
} else {
|
|
1107
|
-
for (const s of completedSorted) renderRailEntry(s, completedBody, { showBadge: true });
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
window.__gsdtBucketAndRender = bucketAndRender;
|
|
1111
|
-
|
|
1112
|
-
let pollTimer = null;
|
|
1113
|
-
function pollSpawns() {
|
|
1114
|
-
fetch('/transcripts')
|
|
1115
|
-
.then((r) => r.json())
|
|
1116
|
-
.then((j) => {
|
|
1117
|
-
const spawns = j.spawns || [];
|
|
1118
|
-
bucketAndRender(spawns);
|
|
1119
|
-
maybeAutoFollow(spawns);
|
|
1120
|
-
})
|
|
1121
|
-
.catch(() => { /* keep last render */ });
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
// ── M43 D6 — Tool Cost panel ────────────────────────────────────────
|
|
1125
|
-
//
|
|
1126
|
-
// Fetches /transcript/:id/tool-cost on render + on turn_complete SSE
|
|
1127
|
-
// events (debounced 2s). Shows top-N tools by attributed tokens, or
|
|
1128
|
-
// a friendly "not yet available" message when D2 library is 503.
|
|
1129
|
-
|
|
1130
|
-
const TOOL_COST_TOP_N = 8;
|
|
1131
|
-
let toolCostTimer = null;
|
|
1132
|
-
|
|
1133
|
-
function formatTokens(n) {
|
|
1134
|
-
if (n == null || isNaN(n)) return '—';
|
|
1135
|
-
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
|
|
1136
|
-
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
|
|
1137
|
-
return String(n);
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
function renderToolCostPanel(tools) {
|
|
1141
|
-
const body = document.getElementById('tool-cost-body');
|
|
1142
|
-
if (!body) return;
|
|
1143
|
-
body.innerHTML = '';
|
|
1144
|
-
if (!Array.isArray(tools) || tools.length === 0) {
|
|
1145
|
-
const e = document.createElement('div');
|
|
1146
|
-
e.className = 'tool-empty';
|
|
1147
|
-
e.textContent = 'No tool activity yet.';
|
|
1148
|
-
body.appendChild(e);
|
|
1149
|
-
return;
|
|
1150
|
-
}
|
|
1151
|
-
// Sort by attributed tokens (input + output) desc, then slice top-N.
|
|
1152
|
-
const ranked = tools
|
|
1153
|
-
.map((t) => {
|
|
1154
|
-
const tokens = (t.inputTokens || 0) + (t.outputTokens || 0)
|
|
1155
|
-
|| t.tokens || t.attributedTokens || 0;
|
|
1156
|
-
return { name: t.tool || t.name || 'unknown', tokens };
|
|
1157
|
-
})
|
|
1158
|
-
.sort((a, b) => b.tokens - a.tokens)
|
|
1159
|
-
.slice(0, TOOL_COST_TOP_N);
|
|
1160
|
-
for (const r of ranked) {
|
|
1161
|
-
const row = document.createElement('div');
|
|
1162
|
-
row.className = 'tool-row';
|
|
1163
|
-
const n = document.createElement('span'); n.className = 'tool-name'; n.textContent = r.name;
|
|
1164
|
-
const v = document.createElement('span'); v.className = 'tool-tokens'; v.textContent = formatTokens(r.tokens);
|
|
1165
|
-
row.appendChild(n); row.appendChild(v);
|
|
1166
|
-
body.appendChild(row);
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
function renderToolCostError(message) {
|
|
1171
|
-
const body = document.getElementById('tool-cost-body');
|
|
1172
|
-
if (!body) return;
|
|
1173
|
-
body.innerHTML = '';
|
|
1174
|
-
const e = document.createElement('div');
|
|
1175
|
-
e.className = 'tool-error';
|
|
1176
|
-
e.textContent = message || 'Tool attribution not yet available';
|
|
1177
|
-
body.appendChild(e);
|
|
1178
|
-
}
|
|
1179
|
-
window.__gsdtRenderToolCostPanel = renderToolCostPanel;
|
|
1180
|
-
window.__gsdtRenderToolCostError = renderToolCostError;
|
|
1181
|
-
|
|
1182
|
-
function fetchToolCost(id) {
|
|
1183
|
-
if (!id) return;
|
|
1184
|
-
fetch('/transcript/' + encodeURIComponent(id) + '/tool-cost')
|
|
1185
|
-
.then((r) => {
|
|
1186
|
-
if (r.status === 503) {
|
|
1187
|
-
renderToolCostError('Tool attribution not yet available');
|
|
1188
|
-
return null;
|
|
1189
|
-
}
|
|
1190
|
-
if (!r.ok) {
|
|
1191
|
-
renderToolCostError('Tool cost unavailable (HTTP ' + r.status + ')');
|
|
1192
|
-
return null;
|
|
1193
|
-
}
|
|
1194
|
-
return r.json();
|
|
1195
|
-
})
|
|
1196
|
-
.then((j) => {
|
|
1197
|
-
if (!j) return;
|
|
1198
|
-
renderToolCostPanel(j.tools || []);
|
|
1199
|
-
})
|
|
1200
|
-
.catch(() => { renderToolCostError('Tool cost fetch failed'); });
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
function setToolCostLive(on) {
|
|
1204
|
-
const badge = document.getElementById('tool-cost-live');
|
|
1205
|
-
if (!badge) return;
|
|
1206
|
-
badge.textContent = on ? 'live' : 'off';
|
|
1207
|
-
badge.classList.toggle('on', !!on);
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
function scheduleToolCostRefresh(id) {
|
|
1211
|
-
if (toolCostTimer) clearTimeout(toolCostTimer);
|
|
1212
|
-
toolCostTimer = setTimeout(() => { fetchToolCost(id); }, 2000);
|
|
1213
|
-
}
|
|
1214
|
-
window.__gsdtFetchToolCost = fetchToolCost;
|
|
1215
|
-
|
|
1216
|
-
// ── SSE connection (reconnectable on hash change) ───────────────────
|
|
1217
|
-
//
|
|
1218
|
-
// M47 D1: dual-pane wiring.
|
|
1219
|
-
// - connect(id) → bottom pane (#stream inside #spawn-stream)
|
|
1220
|
-
// - connectMain(sid) → top pane (#main-stream)
|
|
1221
|
-
//
|
|
1222
|
-
// The two channels are independent — each owns its own EventSource. The
|
|
1223
|
-
// bottom pane preserves all M42/M43 behavior (auto-follow, hash
|
|
1224
|
-
// bookmarks, tool-cost refresh). The top pane is read-only-style: it
|
|
1225
|
-
// renders the in-session conversation NDJSON via /transcript/in-session-{sid}/stream.
|
|
1226
|
-
|
|
1227
|
-
let src = null;
|
|
1228
|
-
function connect(id) {
|
|
1229
|
-
if (!id) {
|
|
1230
|
-
// Empty bottom pane — show the click-to-focus empty state.
|
|
1231
|
-
if (src) { try { src.close(); } catch { /* gone */ } src = null; }
|
|
1232
|
-
stream.innerHTML = '<div class="pane-empty">Click any spawn in the rail to focus it here.</div>';
|
|
1233
|
-
toolUseById.clear();
|
|
1234
|
-
document.getElementById('hdr-spawn-id').textContent = '';
|
|
1235
|
-
setStatus('', 'idle');
|
|
1236
|
-
return;
|
|
1237
|
-
}
|
|
1238
|
-
if (src) { try { src.close(); } catch { /* gone */ } src = null; }
|
|
1239
|
-
stream.innerHTML = '';
|
|
1240
|
-
toolUseById.clear();
|
|
1241
|
-
autoScroll = true;
|
|
1242
|
-
jumpBtn.classList.remove('visible');
|
|
1243
|
-
document.getElementById('hdr-spawn-id').textContent = id;
|
|
1244
|
-
setStatus('', 'connecting…');
|
|
1245
|
-
// M47 D1 — persist selection across reload.
|
|
1246
|
-
_ssSet(SS_KEY_SELECTED, id);
|
|
1247
|
-
// M43 D6 — kick off an initial tool-cost fetch for this spawn.
|
|
1248
|
-
fetchToolCost(id);
|
|
1249
|
-
setToolCostLive(false);
|
|
1250
|
-
src = new EventSource('/transcript/' + encodeURIComponent(id) + '/stream');
|
|
1251
|
-
src.onopen = () => { setStatus('connected', 'live'); setToolCostLive(true); };
|
|
1252
|
-
src.onerror = () => { setStatus('error', 'disconnected'); setToolCostLive(false); };
|
|
1253
|
-
src.onmessage = (ev) => {
|
|
1254
|
-
if (!ev.data) return;
|
|
1255
|
-
// Arrival time is the FALLBACK; we prefer the producer-side
|
|
1256
|
-
// frame.ts so a 200-frame initial replay shows actual event
|
|
1257
|
-
// times (spread out over minutes), not the same millisecond.
|
|
1258
|
-
const arrivedAt = new Date();
|
|
1259
|
-
try {
|
|
1260
|
-
const frame = JSON.parse(ev.data);
|
|
1261
|
-
const renderAt = frameTs(frame, arrivedAt);
|
|
1262
|
-
// Bottom pane = default renderTarget = #stream.
|
|
1263
|
-
renderFrame(frame, renderAt);
|
|
1264
|
-
// M43 D6 — refresh tool-cost on turn-complete frames (debounced).
|
|
1265
|
-
// Various producers emit different turn-complete markers; accept
|
|
1266
|
-
// any of them.
|
|
1267
|
-
if (frame && (
|
|
1268
|
-
frame.type === 'turn_complete' ||
|
|
1269
|
-
frame.subtype === 'turn_complete' ||
|
|
1270
|
-
frame.event_type === 'turn_complete' ||
|
|
1271
|
-
frame.type === 'result' // `result` frames mark end-of-turn in stream-json
|
|
1272
|
-
)) {
|
|
1273
|
-
scheduleToolCostRefresh(id);
|
|
1274
|
-
}
|
|
1275
|
-
} catch { renderRaw(ev.data, arrivedAt); }
|
|
1276
|
-
};
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
// M47 D1 — top pane: main in-session conversation stream. Independent
|
|
1280
|
-
// from the bottom pane's `connect()`. Uses its own EventSource and
|
|
1281
|
-
// renders into #main-stream via the renderTarget swap inside renderFrame.
|
|
1282
|
-
let mainSrc = null;
|
|
1283
|
-
const mainStreamEl = document.getElementById('main-stream');
|
|
1284
|
-
function connectMain(sessionId) {
|
|
1285
|
-
if (mainSrc) { try { mainSrc.close(); } catch { /* gone */ } mainSrc = null; }
|
|
1286
|
-
// M52 — expose the active main session id so the bottom-pane click +
|
|
1287
|
-
// hashchange handlers can distinguish the LIVE main session (must stay
|
|
1288
|
-
// top-only) from HISTORICAL in-session conversations (loadable below).
|
|
1289
|
-
window.__mainSessionId = sessionId || null;
|
|
1290
|
-
if (!mainStreamEl) return;
|
|
1291
|
-
mainStreamEl.innerHTML = '';
|
|
1292
|
-
if (!sessionId) {
|
|
1293
|
-
mainStreamEl.innerHTML = '<div class="pane-empty">No in-session conversation captured yet.</div>';
|
|
1294
|
-
return;
|
|
1295
|
-
}
|
|
1296
|
-
const spawnId = 'in-session-' + sessionId;
|
|
1297
|
-
try {
|
|
1298
|
-
mainSrc = new EventSource('/transcript/' + encodeURIComponent(spawnId) + '/stream');
|
|
1299
|
-
mainSrc.onmessage = (ev) => {
|
|
1300
|
-
if (!ev.data) return;
|
|
1301
|
-
const arrivedAt = new Date();
|
|
1302
|
-
try {
|
|
1303
|
-
const frame = JSON.parse(ev.data);
|
|
1304
|
-
const renderAt = frameTs(frame, arrivedAt);
|
|
1305
|
-
renderFrame(frame, renderAt, mainStreamEl);
|
|
1306
|
-
} catch {
|
|
1307
|
-
const prev = renderTarget; renderTarget = mainStreamEl;
|
|
1308
|
-
try { renderRaw(ev.data, arrivedAt); } finally { renderTarget = prev; }
|
|
1309
|
-
}
|
|
1310
|
-
};
|
|
1311
|
-
// Errors are silent — the top pane is best-effort; bottom pane drives status.
|
|
1312
|
-
} catch { /* EventSource unsupported */ }
|
|
1313
|
-
}
|
|
1314
|
-
window.__gsdtConnectMain = connectMain;
|
|
1315
|
-
|
|
1316
|
-
function fetchMainSession() {
|
|
1317
|
-
const t0 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
|
|
1318
|
-
return fetch('/api/main-session')
|
|
1319
|
-
.then((r) => r.ok ? r.json() : null)
|
|
1320
|
-
.then((j) => {
|
|
1321
|
-
if (j && j.sessionId) {
|
|
1322
|
-
connectMain(j.sessionId);
|
|
1323
|
-
// M52 — if the bottom pane was seeded with the live main
|
|
1324
|
-
// session's id (e.g., from sessionStorage on reload), clear it
|
|
1325
|
-
// out to prevent the M48 Bug 4 dual-pane mirror.
|
|
1326
|
-
const liveId = 'in-session-' + j.sessionId;
|
|
1327
|
-
const seededId = _ssGet(SS_KEY_SELECTED) || '';
|
|
1328
|
-
if (seededId === liveId) {
|
|
1329
|
-
_ssSet(SS_KEY_SELECTED, '');
|
|
1330
|
-
if ((location.hash || '').slice(1) === liveId) {
|
|
1331
|
-
try { history.replaceState(null, '', location.pathname + location.search); } catch { /* ok */ }
|
|
1332
|
-
}
|
|
1333
|
-
connect('');
|
|
1334
|
-
}
|
|
1335
|
-
const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
|
|
1336
|
-
try { console.debug('[m47] top-pane connected in', Math.round(t1 - t0), 'ms'); } catch { /* ok */ }
|
|
1337
|
-
} else {
|
|
1338
|
-
connectMain(null);
|
|
1339
|
-
}
|
|
1340
|
-
})
|
|
1341
|
-
.catch(() => { connectMain(null); });
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
window.addEventListener('hashchange', () => {
|
|
1345
|
-
const id = (location.hash || '').slice(1);
|
|
1346
|
-
// M52 — narrow the M48 Bug 4 guard. Only the LIVE main session's
|
|
1347
|
-
// in-session id is blocked; historical in-session-* hashes load into
|
|
1348
|
-
// the bottom pane normally (connect() already handles the SSE).
|
|
1349
|
-
if (id && id === ('in-session-' + window.__mainSessionId)) { return; }
|
|
1350
|
-
if (id) { connect(id); pollSpawns(); }
|
|
1351
|
-
});
|
|
1352
|
-
|
|
1353
|
-
// M47 D1 — initial bottom-pane resolution:
|
|
1354
|
-
// 1. data-spawn-id non-empty → connect that (bookmark flow)
|
|
1355
|
-
// 2. else sessionStorage.selectedSpawnId → connect that
|
|
1356
|
-
// 3. else show empty state
|
|
1357
|
-
// M52 — historical in-session-* ids may seed the bottom pane (the M48
|
|
1358
|
-
// Bug 4 case is now narrowed to the LIVE main session only, handled by
|
|
1359
|
-
// the click + hashchange + fetchMainSession callback).
|
|
1360
|
-
let initialBottomId = '';
|
|
1361
|
-
if (spawnId) {
|
|
1362
|
-
initialBottomId = spawnId;
|
|
1363
|
-
} else {
|
|
1364
|
-
initialBottomId = _ssGet(SS_KEY_SELECTED) || '';
|
|
1365
|
-
}
|
|
1366
|
-
if (initialBottomId && !location.hash) location.hash = initialBottomId;
|
|
1367
|
-
connect(initialBottomId);
|
|
1368
|
-
|
|
1369
|
-
// M47 D1 — top pane: always populate via /api/main-session.
|
|
1370
|
-
fetchMainSession();
|
|
1371
|
-
|
|
1372
|
-
pollSpawns();
|
|
1373
|
-
pollTimer = setInterval(pollSpawns, 3000);
|
|
1374
|
-
|
|
1375
|
-
// ── M44 D8 — Spawn Plan panel ───────────────────────────────────────
|
|
1376
|
-
//
|
|
1377
|
-
// Two-layer right-side panel. Layer 1 shows project-wide task status
|
|
1378
|
-
// (derived from partition + tasks.md); Layer 2 shows the currently-
|
|
1379
|
-
// active spawn's task slice. Both flip live via the SSE channel
|
|
1380
|
-
// `/api/spawn-plans/stream` and the initial GET `/api/spawn-plans`.
|
|
1381
|
-
//
|
|
1382
|
-
// Token cells render as `in=12.5k out=1.7k $0.42` (k-suffix above
|
|
1383
|
-
// 1000, 2-decimal USD). `—` when null, per the "zero is a
|
|
1384
|
-
// measurement, dash is acknowledged gap" rule.
|
|
1385
|
-
|
|
1386
|
-
const STATUS_ICON = { pending: '☐', in_progress: '◐', done: '✓' };
|
|
1387
|
-
|
|
1388
|
-
function fmtK(n) {
|
|
1389
|
-
if (n == null || isNaN(n)) return '0';
|
|
1390
|
-
const num = Number(n);
|
|
1391
|
-
if (Math.abs(num) >= 1000) return (num / 1000).toFixed(1) + 'k';
|
|
1392
|
-
return String(num);
|
|
1393
|
-
}
|
|
1394
|
-
window.__gsdtFmtK = fmtK;
|
|
1395
|
-
|
|
1396
|
-
function fmtTokens(tokens) {
|
|
1397
|
-
if (!tokens || typeof tokens !== 'object') return '—';
|
|
1398
|
-
const ins = fmtK(tokens.in);
|
|
1399
|
-
const out = fmtK(tokens.out);
|
|
1400
|
-
const cost = (typeof tokens.cost_usd === 'number') ? '$' + tokens.cost_usd.toFixed(2) : '';
|
|
1401
|
-
return ['in=' + ins, 'out=' + out, cost].filter(Boolean).join(' ');
|
|
1402
|
-
}
|
|
1403
|
-
window.__gsdtFmtTokens = fmtTokens;
|
|
1404
|
-
|
|
1405
|
-
function sumTokens(tasks) {
|
|
1406
|
-
const acc = { in: 0, out: 0, cr: 0, cc: 0, cost_usd: 0 };
|
|
1407
|
-
let hit = false;
|
|
1408
|
-
for (const t of (tasks || [])) {
|
|
1409
|
-
if (!t || t.status !== 'done' || !t.tokens) continue;
|
|
1410
|
-
acc.in += Number(t.tokens.in || 0);
|
|
1411
|
-
acc.out += Number(t.tokens.out || 0);
|
|
1412
|
-
acc.cr += Number(t.tokens.cr || 0);
|
|
1413
|
-
acc.cc += Number(t.tokens.cc || 0);
|
|
1414
|
-
acc.cost_usd += Number(t.tokens.cost_usd || 0);
|
|
1415
|
-
hit = true;
|
|
1416
|
-
}
|
|
1417
|
-
if (!hit) return null;
|
|
1418
|
-
acc.cost_usd = Math.round(acc.cost_usd * 100) / 100;
|
|
1419
|
-
return acc;
|
|
1420
|
-
}
|
|
1421
|
-
window.__gsdtSumTokens = sumTokens;
|
|
1422
|
-
|
|
1423
|
-
function renderTaskList(container, tasks) {
|
|
1424
|
-
container.innerHTML = '';
|
|
1425
|
-
if (!Array.isArray(tasks) || tasks.length === 0) {
|
|
1426
|
-
const e = document.createElement('div');
|
|
1427
|
-
e.className = 'empty';
|
|
1428
|
-
e.textContent = 'no tasks';
|
|
1429
|
-
container.appendChild(e);
|
|
1430
|
-
return;
|
|
1431
|
-
}
|
|
1432
|
-
for (const t of tasks) {
|
|
1433
|
-
if (!t || !t.id) continue;
|
|
1434
|
-
const row = document.createElement('div');
|
|
1435
|
-
row.className = 'task-row status-' + (t.status || 'pending').replace('_', '-');
|
|
1436
|
-
const icon = document.createElement('span');
|
|
1437
|
-
icon.className = 'icon';
|
|
1438
|
-
icon.textContent = STATUS_ICON[t.status] || '☐';
|
|
1439
|
-
const title = document.createElement('span');
|
|
1440
|
-
title.className = 'title';
|
|
1441
|
-
title.textContent = t.id + ' — ' + (t.title || '');
|
|
1442
|
-
title.title = (t.commit ? 'commit ' + t.commit + ' · ' : '') + (t.title || '');
|
|
1443
|
-
const tok = document.createElement('span');
|
|
1444
|
-
tok.className = 'tokens';
|
|
1445
|
-
tok.textContent = (t.status === 'done') ? fmtTokens(t.tokens) : '';
|
|
1446
|
-
row.appendChild(icon);
|
|
1447
|
-
row.appendChild(title);
|
|
1448
|
-
row.appendChild(tok);
|
|
1449
|
-
container.appendChild(row);
|
|
1450
|
-
}
|
|
1451
|
-
}
|
|
1452
|
-
window.__gsdtRenderTaskList = renderTaskList;
|
|
1453
|
-
|
|
1454
|
-
// Group tasks by wave prefix embedded in the plan (optional); fall
|
|
1455
|
-
// back to a single flat list when no wave is set.
|
|
1456
|
-
function renderProjectLayer(plan) {
|
|
1457
|
-
const titleEl = document.getElementById('project-title');
|
|
1458
|
-
const subEl = document.getElementById('project-sub');
|
|
1459
|
-
const totalsEl = document.getElementById('project-totals');
|
|
1460
|
-
const tasksEl = document.getElementById('project-tasks');
|
|
1461
|
-
if (!plan || !plan.tasks) {
|
|
1462
|
-
titleEl.textContent = 'Project';
|
|
1463
|
-
subEl.textContent = '—';
|
|
1464
|
-
totalsEl.textContent = '—';
|
|
1465
|
-
tasksEl.innerHTML = '<div class="empty">no plan yet</div>';
|
|
1466
|
-
return;
|
|
1467
|
-
}
|
|
1468
|
-
const done = plan.tasks.filter((t) => t && t.status === 'done').length;
|
|
1469
|
-
const total = plan.tasks.length;
|
|
1470
|
-
titleEl.textContent = (plan.milestone || 'Project');
|
|
1471
|
-
subEl.textContent = (plan.wave ? plan.wave + ' · ' : '') + done + '/' + total;
|
|
1472
|
-
const totals = sumTokens(plan.tasks);
|
|
1473
|
-
totalsEl.textContent = totals ? fmtTokens(totals) : '—';
|
|
1474
|
-
renderTaskList(tasksEl, plan.tasks);
|
|
1475
|
-
}
|
|
1476
|
-
window.__gsdtRenderProjectLayer = renderProjectLayer;
|
|
1477
|
-
|
|
1478
|
-
function renderActiveLayer(plan) {
|
|
1479
|
-
const section = document.getElementById('spawn-layer-active');
|
|
1480
|
-
const titleEl = document.getElementById('active-title');
|
|
1481
|
-
const subEl = document.getElementById('active-sub');
|
|
1482
|
-
const totalsEl = document.getElementById('active-totals');
|
|
1483
|
-
const tasksEl = document.getElementById('active-tasks');
|
|
1484
|
-
if (!plan) {
|
|
1485
|
-
section.classList.add('dimmed');
|
|
1486
|
-
titleEl.textContent = 'Active Spawn';
|
|
1487
|
-
subEl.textContent = '—';
|
|
1488
|
-
totalsEl.textContent = '—';
|
|
1489
|
-
tasksEl.innerHTML = '<div class="empty">no active spawn plan</div>';
|
|
1490
|
-
return;
|
|
1491
|
-
}
|
|
1492
|
-
const isActive = plan.endedAt == null;
|
|
1493
|
-
section.classList.toggle('dimmed', !isActive);
|
|
1494
|
-
const done = (plan.tasks || []).filter((t) => t && t.status === 'done').length;
|
|
1495
|
-
const total = (plan.tasks || []).length;
|
|
1496
|
-
titleEl.textContent = (plan.kind || 'spawn');
|
|
1497
|
-
subEl.textContent = (plan.spawnId || '').slice(-10) + ' · ' + done + '/' + total;
|
|
1498
|
-
const totals = sumTokens(plan.tasks || []);
|
|
1499
|
-
totalsEl.textContent = totals ? fmtTokens(totals) : '—';
|
|
1500
|
-
renderTaskList(tasksEl, plan.tasks || []);
|
|
1501
|
-
}
|
|
1502
|
-
window.__gsdtRenderActiveLayer = renderActiveLayer;
|
|
1503
|
-
|
|
1504
|
-
// Project layer is derived from the most-recent active plan that has
|
|
1505
|
-
// any tasks (preferring unattended-worker kind when multiple exist).
|
|
1506
|
-
// Active layer is the same plan — both layers share data for now;
|
|
1507
|
-
// future versions may split project layer off a dedicated endpoint.
|
|
1508
|
-
let spawnPlanState = { plans: [] };
|
|
1509
|
-
function updateSpawnPlanPanel() {
|
|
1510
|
-
const plans = spawnPlanState.plans || [];
|
|
1511
|
-
const byRecency = plans.slice().sort((a, b) => (Date.parse(b.startedAt) || 0) - (Date.parse(a.startedAt) || 0));
|
|
1512
|
-
const active = byRecency.find((p) => p && p.endedAt == null) || null;
|
|
1513
|
-
// Layer 1: use the active plan if available; else the most recent plan.
|
|
1514
|
-
renderProjectLayer(active || byRecency[0] || null);
|
|
1515
|
-
// Layer 2: only the active one.
|
|
1516
|
-
renderActiveLayer(active);
|
|
1517
|
-
}
|
|
1518
|
-
window.__gsdtUpdateSpawnPlanPanel = updateSpawnPlanPanel;
|
|
1519
|
-
|
|
1520
|
-
function applyPlanUpdate(plan) {
|
|
1521
|
-
if (!plan || !plan.spawnId) return;
|
|
1522
|
-
const plans = spawnPlanState.plans || [];
|
|
1523
|
-
const idx = plans.findIndex((p) => p && p.spawnId === plan.spawnId);
|
|
1524
|
-
if (idx >= 0) plans[idx] = plan;
|
|
1525
|
-
else plans.push(plan);
|
|
1526
|
-
spawnPlanState.plans = plans;
|
|
1527
|
-
updateSpawnPlanPanel();
|
|
1528
|
-
}
|
|
1529
|
-
window.__gsdtApplyPlanUpdate = applyPlanUpdate;
|
|
1530
|
-
|
|
1531
|
-
function fetchInitialPlans() {
|
|
1532
|
-
fetch('/api/spawn-plans')
|
|
1533
|
-
.then((r) => r.ok ? r.json() : null)
|
|
1534
|
-
.then((j) => {
|
|
1535
|
-
if (!j) return;
|
|
1536
|
-
spawnPlanState.plans = Array.isArray(j.plans) ? j.plans : [];
|
|
1537
|
-
updateSpawnPlanPanel();
|
|
1538
|
-
})
|
|
1539
|
-
.catch(() => { /* leave empty state */ });
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
function subscribePlanUpdates() {
|
|
1543
|
-
try {
|
|
1544
|
-
const es = new EventSource('/api/spawn-plans/stream');
|
|
1545
|
-
es.onmessage = (ev) => {
|
|
1546
|
-
if (!ev.data) return;
|
|
1547
|
-
try {
|
|
1548
|
-
const msg = JSON.parse(ev.data);
|
|
1549
|
-
if (msg && msg.plan) applyPlanUpdate(msg.plan);
|
|
1550
|
-
} catch { /* ignore */ }
|
|
1551
|
-
};
|
|
1552
|
-
es.onerror = () => { /* auto-reconnect by browser */ };
|
|
1553
|
-
} catch { /* EventSource unsupported; fall back to polling below */ }
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
fetchInitialPlans();
|
|
1557
|
-
subscribePlanUpdates();
|
|
1558
|
-
// Light poll every 10s as a safety net if SSE is disconnected.
|
|
1559
|
-
setInterval(fetchInitialPlans, 10000);
|
|
1560
|
-
|
|
1561
|
-
// ── M44 D9 — Parallelism panel ─────────────────────────────────────────
|
|
1562
|
-
// Polls /api/parallelism every 5s. Color border reflects color_state.
|
|
1563
|
-
// Full Report button fetches /api/parallelism/report and downloads the
|
|
1564
|
-
// markdown. Stop Supervisor POSTs /api/unattended-stop (writes sentinel).
|
|
1565
|
-
(function wireParallelismPanel() {
|
|
1566
|
-
const panel = document.getElementById('parallelism-panel');
|
|
1567
|
-
if (!panel) return;
|
|
1568
|
-
const dot = document.getElementById('pp-dot');
|
|
1569
|
-
const modeEl = document.getElementById('pp-mode');
|
|
1570
|
-
const activeEl = document.getElementById('pp-active');
|
|
1571
|
-
const readyEl = document.getElementById('pp-ready');
|
|
1572
|
-
const factorEl = document.getElementById('pp-factor');
|
|
1573
|
-
const oldestEl = document.getElementById('pp-oldest');
|
|
1574
|
-
const gatesEl = document.getElementById('pp-gates');
|
|
1575
|
-
const reportBtn = document.getElementById('pp-report-btn');
|
|
1576
|
-
const stopBtn = document.getElementById('pp-stop-btn');
|
|
1577
|
-
|
|
1578
|
-
function fmtAge(s) {
|
|
1579
|
-
if (s == null || !isFinite(s)) return '—';
|
|
1580
|
-
if (s < 60) return s + 's';
|
|
1581
|
-
const m = Math.floor(s / 60), rs = s % 60;
|
|
1582
|
-
if (m < 60) return m + 'm ' + rs + 's';
|
|
1583
|
-
const h = Math.floor(m / 60), rm = m % 60;
|
|
1584
|
-
return h + 'h ' + rm + 'm';
|
|
1585
|
-
}
|
|
1586
|
-
|
|
1587
|
-
function applyColor(state) {
|
|
1588
|
-
const colors = ['color-green', 'color-yellow', 'color-red', 'color-dimmed'];
|
|
1589
|
-
const dots = ['dot-green', 'dot-yellow', 'dot-red', 'dot-dimmed'];
|
|
1590
|
-
for (const c of colors) panel.classList.remove(c);
|
|
1591
|
-
for (const d of dots) dot.classList.remove(d);
|
|
1592
|
-
const cls = 'color-' + (state || 'dimmed');
|
|
1593
|
-
const dcls = 'dot-' + (state || 'dimmed');
|
|
1594
|
-
panel.classList.add(cls);
|
|
1595
|
-
dot.classList.add(dcls);
|
|
1596
|
-
}
|
|
1597
|
-
|
|
1598
|
-
function renderGates(g) {
|
|
1599
|
-
if (!g) { gatesEl.textContent = 'gates: —'; return; }
|
|
1600
|
-
const dep = g.dep_gate_veto || { count: 0 };
|
|
1601
|
-
const dis = g.disjointness_fallback || { count: 0 };
|
|
1602
|
-
const eco = g.economics_decision || { count: 0 };
|
|
1603
|
-
function tally(c) { return c === 0 ? '✓ 0' : (c <= 3 ? '⚠ ' + c : '❌ ' + c); }
|
|
1604
|
-
gatesEl.innerHTML = '<span>dep: ' + tally(dep.count) + '</span>' +
|
|
1605
|
-
'<span>disj: ' + tally(dis.count) + '</span>' +
|
|
1606
|
-
'<span>eco: ' + eco.count + '</span>';
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
|
-
async function poll() {
|
|
1610
|
-
try {
|
|
1611
|
-
const r = await fetch('/api/parallelism');
|
|
1612
|
-
if (!r.ok) return;
|
|
1613
|
-
const m = await r.json();
|
|
1614
|
-
applyColor(m.color_state);
|
|
1615
|
-
modeEl.textContent = m.parallelism_factor_mode || 'idle';
|
|
1616
|
-
activeEl.textContent = String(m.activeWorkers);
|
|
1617
|
-
readyEl.textContent = String(m.readyTasks);
|
|
1618
|
-
factorEl.textContent = (typeof m.parallelism_factor === 'number' ? m.parallelism_factor.toFixed(1) : '—') + '×';
|
|
1619
|
-
const oldest = (m.activeSpawnAges_s && m.activeSpawnAges_s.length) ? m.activeSpawnAges_s[0] : null;
|
|
1620
|
-
oldestEl.textContent = fmtAge(oldest);
|
|
1621
|
-
renderGates(m.gate_decisions);
|
|
1622
|
-
} catch { /* keep last state on transient failure */ }
|
|
1623
|
-
}
|
|
1624
|
-
|
|
1625
|
-
async function downloadReport() {
|
|
1626
|
-
try {
|
|
1627
|
-
const r = await fetch('/api/parallelism/report');
|
|
1628
|
-
if (!r.ok) return;
|
|
1629
|
-
const md = await r.text();
|
|
1630
|
-
const blob = new Blob([md], { type: 'text/markdown' });
|
|
1631
|
-
const url = URL.createObjectURL(blob);
|
|
1632
|
-
const a = document.createElement('a');
|
|
1633
|
-
a.href = url;
|
|
1634
|
-
a.download = 'parallelism-report-' + new Date().toISOString().slice(0, 19).replace(/:/g, '-') + '.md';
|
|
1635
|
-
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
|
1636
|
-
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
1637
|
-
} catch { /* swallow */ }
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
async function stopSupervisor() {
|
|
1641
|
-
if (!confirm('Write .gsd-t/.unattended/stop sentinel? Active supervisor will exit on next poll.')) return;
|
|
1642
|
-
try {
|
|
1643
|
-
const r = await fetch('/api/unattended-stop', { method: 'POST' });
|
|
1644
|
-
if (r.ok) { stopBtn.textContent = 'Stop sent ✓'; setTimeout(() => { stopBtn.textContent = 'Stop Supervisor'; }, 3000); }
|
|
1645
|
-
} catch { /* swallow */ }
|
|
1646
|
-
}
|
|
1647
|
-
|
|
1648
|
-
if (reportBtn) reportBtn.addEventListener('click', downloadReport);
|
|
1649
|
-
if (stopBtn) stopBtn.addEventListener('click', stopSupervisor);
|
|
1650
|
-
poll();
|
|
1651
|
-
setInterval(poll, 5000);
|
|
1652
|
-
})();
|
|
1653
|
-
|
|
1654
|
-
// ── M54 D2 T2 — Live Activity Rail ────────────────────────────────────
|
|
1655
|
-
// Polls /api/live-activity every 5s (same cadence as /api/parallelism).
|
|
1656
|
-
// Appends entries to #la-list, applies .la-pulsing on new entries,
|
|
1657
|
-
// removes entries when they disappear from the API response.
|
|
1658
|
-
// Click handler: stopPulse + loadTailUrl (NO auto-switch on arrival).
|
|
1659
|
-
// Additive only — no existing function renamed or removed.
|
|
1660
|
-
(function wireLiveActivity() {
|
|
1661
|
-
const laList = document.getElementById('la-list');
|
|
1662
|
-
const laEmpty = document.getElementById('la-empty-msg');
|
|
1663
|
-
if (!laList) return; // section not present in this page variant
|
|
1664
|
-
|
|
1665
|
-
// Map of id → { element, startedAt, stopPulseTimer }
|
|
1666
|
-
const _current = new Map();
|
|
1667
|
-
|
|
1668
|
-
// Format duration in seconds as human-readable string
|
|
1669
|
-
function fmtDuration(ms) {
|
|
1670
|
-
const s = Math.floor(ms / 1000);
|
|
1671
|
-
if (s < 60) return s + 's';
|
|
1672
|
-
const m = Math.floor(s / 60), rs = s % 60;
|
|
1673
|
-
if (m < 60) return m + 'm ' + rs + 's';
|
|
1674
|
-
const h = Math.floor(m / 60), rm = m % 60;
|
|
1675
|
-
return h + 'h ' + rm + 'm';
|
|
1676
|
-
}
|
|
1677
|
-
|
|
1678
|
-
// Kind icon mapping
|
|
1679
|
-
const KIND_ICONS = { bash: '$', monitor: '👁', tool: '🔧', spawn: '↳' };
|
|
1680
|
-
|
|
1681
|
-
// Load the tailUrl content into the bottom pane (mirrors existing bottom-pane loaders)
|
|
1682
|
-
function loadTailUrl(tailUrl) {
|
|
1683
|
-
const streamEl = document.getElementById('stream');
|
|
1684
|
-
if (!streamEl) return;
|
|
1685
|
-
fetch(tailUrl)
|
|
1686
|
-
.then(function(r) { return r.text(); })
|
|
1687
|
-
.then(function(text) {
|
|
1688
|
-
streamEl.innerHTML = '<pre style="margin:0;padding:12px;font-family:var(--mono);font-size:12px;white-space:pre-wrap;word-break:break-word;">' + _esc(text) + '</pre>';
|
|
1689
|
-
})
|
|
1690
|
-
.catch(function(err) {
|
|
1691
|
-
console.log('[la] tail load error:', err && err.message);
|
|
1692
|
-
});
|
|
1693
|
-
}
|
|
1694
|
-
|
|
1695
|
-
function _esc(s) {
|
|
1696
|
-
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
1697
|
-
}
|
|
1698
|
-
|
|
1699
|
-
// Remove .la-pulsing from entry element
|
|
1700
|
-
function stopPulse(id) {
|
|
1701
|
-
const item = _current.get(id);
|
|
1702
|
-
if (!item || !item.element) return;
|
|
1703
|
-
item.element.classList.remove('la-pulsing');
|
|
1704
|
-
if (item.stopPulseTimer) { clearTimeout(item.stopPulseTimer); item.stopPulseTimer = null; }
|
|
1705
|
-
}
|
|
1706
|
-
|
|
1707
|
-
// Append a new .la-entry to #la-list
|
|
1708
|
-
function appendActivity(entry) {
|
|
1709
|
-
const li = document.createElement('li');
|
|
1710
|
-
li.className = 'la-entry la-pulsing';
|
|
1711
|
-
li.setAttribute('data-id', entry.id);
|
|
1712
|
-
li.setAttribute('data-kind', entry.kind);
|
|
1713
|
-
li.setAttribute('data-tail-url', entry.tailUrl || '');
|
|
1714
|
-
|
|
1715
|
-
const dot = document.createElement('span');
|
|
1716
|
-
dot.className = 'la-dot la-dot-running';
|
|
1717
|
-
li.appendChild(dot);
|
|
1718
|
-
|
|
1719
|
-
const icon = document.createElement('span');
|
|
1720
|
-
icon.className = 'la-icon la-icon-' + entry.kind;
|
|
1721
|
-
icon.textContent = KIND_ICONS[entry.kind] || '?';
|
|
1722
|
-
li.appendChild(icon);
|
|
1723
|
-
|
|
1724
|
-
const label = document.createElement('span');
|
|
1725
|
-
label.className = 'la-label';
|
|
1726
|
-
const labelText = String(entry.label || '').slice(0, 40);
|
|
1727
|
-
label.textContent = labelText;
|
|
1728
|
-
label.title = entry.label || '';
|
|
1729
|
-
li.appendChild(label);
|
|
1730
|
-
|
|
1731
|
-
const dur = document.createElement('span');
|
|
1732
|
-
dur.className = 'la-duration';
|
|
1733
|
-
dur.textContent = fmtDuration(entry.durationMs || 0);
|
|
1734
|
-
li.appendChild(dur);
|
|
1735
|
-
|
|
1736
|
-
li.addEventListener('click', function() {
|
|
1737
|
-
stopPulse(entry.id);
|
|
1738
|
-
if (entry.tailUrl) loadTailUrl(entry.tailUrl);
|
|
1739
|
-
});
|
|
1740
|
-
|
|
1741
|
-
laList.appendChild(li);
|
|
1742
|
-
|
|
1743
|
-
// Schedule pulse stop after 30s (condition c)
|
|
1744
|
-
const timer = setTimeout(function() { stopPulse(entry.id); }, 30000);
|
|
1745
|
-
_current.set(entry.id, { element: li, startedAt: entry.startedAt, stopPulseTimer: timer });
|
|
1746
|
-
|
|
1747
|
-
if (laEmpty) laEmpty.style.display = 'none';
|
|
1748
|
-
}
|
|
1749
|
-
|
|
1750
|
-
// Remove a .la-entry from #la-list
|
|
1751
|
-
function removeActivity(id) {
|
|
1752
|
-
const item = _current.get(id);
|
|
1753
|
-
if (!item) return;
|
|
1754
|
-
if (item.stopPulseTimer) clearTimeout(item.stopPulseTimer);
|
|
1755
|
-
if (item.element && item.element.parentNode) item.element.parentNode.removeChild(item.element);
|
|
1756
|
-
_current.delete(id);
|
|
1757
|
-
if (laEmpty && _current.size === 0) laEmpty.style.display = '';
|
|
1758
|
-
}
|
|
1759
|
-
|
|
1760
|
-
// Update the duration counter for an existing entry
|
|
1761
|
-
function updateDuration(id, startedAt) {
|
|
1762
|
-
const item = _current.get(id);
|
|
1763
|
-
if (!item || !item.element) return;
|
|
1764
|
-
const dur = item.element.querySelector('.la-duration');
|
|
1765
|
-
if (!dur) return;
|
|
1766
|
-
const start = startedAt ? new Date(startedAt).getTime() : 0;
|
|
1767
|
-
const elapsed = start ? Math.max(0, Date.now() - start) : 0;
|
|
1768
|
-
dur.textContent = fmtDuration(elapsed);
|
|
1769
|
-
}
|
|
1770
|
-
|
|
1771
|
-
// Fetch /api/live-activity, return envelope (or {activities:[]} on error)
|
|
1772
|
-
async function fetchLiveActivity() {
|
|
1773
|
-
try {
|
|
1774
|
-
const r = await fetch('/api/live-activity');
|
|
1775
|
-
if (!r.ok) {
|
|
1776
|
-
console.log('[la] /api/live-activity returned', r.status);
|
|
1777
|
-
return { activities: [] };
|
|
1778
|
-
}
|
|
1779
|
-
return await r.json();
|
|
1780
|
-
} catch (err) {
|
|
1781
|
-
console.log('[la] fetch error:', err && err.message);
|
|
1782
|
-
return { activities: [] };
|
|
1783
|
-
}
|
|
1784
|
-
}
|
|
1785
|
-
|
|
1786
|
-
// Reconcile current DOM state with new API response
|
|
1787
|
-
function reconcile(activities) {
|
|
1788
|
-
// Build a Map of new activities by id
|
|
1789
|
-
const newById = new Map();
|
|
1790
|
-
for (const entry of (activities || [])) {
|
|
1791
|
-
newById.set(entry.id, entry);
|
|
1792
|
-
}
|
|
1793
|
-
|
|
1794
|
-
// Remove entries no longer present
|
|
1795
|
-
for (const id of Array.from(_current.keys())) {
|
|
1796
|
-
if (!newById.has(id)) removeActivity(id);
|
|
1797
|
-
}
|
|
1798
|
-
|
|
1799
|
-
// Add new entries; update durations for existing ones
|
|
1800
|
-
for (const [id, entry] of newById) {
|
|
1801
|
-
if (_current.has(id)) {
|
|
1802
|
-
updateDuration(id, entry.startedAt);
|
|
1803
|
-
} else {
|
|
1804
|
-
appendActivity(entry);
|
|
1805
|
-
}
|
|
1806
|
-
}
|
|
1807
|
-
}
|
|
1808
|
-
|
|
1809
|
-
// Initial poll + 5s interval (same cadence as /api/parallelism)
|
|
1810
|
-
async function poll() {
|
|
1811
|
-
const data = await fetchLiveActivity();
|
|
1812
|
-
reconcile(data.activities);
|
|
1813
|
-
}
|
|
1814
|
-
|
|
1815
|
-
poll();
|
|
1816
|
-
setInterval(poll, 5000);
|
|
1817
|
-
})();
|
|
1818
|
-
})();
|
|
1819
|
-
</script>
|
|
1820
|
-
</body>
|
|
1821
|
-
</html>
|