@tekyzinc/gsd-t 3.20.13 → 3.21.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/commands/gsd-t-visualize.md +12 -15
- package/docs/architecture.md +12 -1
- package/docs/requirements.md +16 -0
- package/package.json +1 -1
- package/scripts/gsd-t-dashboard-server.js +42 -1
- package/scripts/gsd-t-transcript.html +421 -16
package/README.md
CHANGED
|
@@ -15,7 +15,8 @@ A methodology for reliable, parallelizable development using Claude Code with op
|
|
|
15
15
|
**Stack Rules Engine** — auto-detects project tech stack (React, TypeScript, Node API, Python, Go, Rust) from manifest files and injects mandatory best-practice rules into subagent prompts at execute-time. Universal security rules always apply; stack-specific rules layer on top. Includes **design-to-code** rules for pixel-perfect frontend implementation from Figma, screenshots, or design images — with Figma MCP integration, design token extraction, stack capability evaluation, and mandatory visual verification: every screen is rendered in a real browser, screenshotted at mobile/tablet/desktop, and compared pixel-by-pixel against the Figma design. Auto-bootstraps during partition when design references are detected. Extensible: drop a `.md` file in `templates/stacks/` to add a new stack.
|
|
16
16
|
**External Task Orchestrator + Streaming Watcher UI (M40, v3.14.10)** — JS orchestrator drives `claude -p` one task per spawn: short-lived, fresh context, architecturally compaction-free. Benchmarks 0.72× wall-clock vs in-session on 20-task/3-wave workloads. Paired with a zero-Claude-cost local streaming UI at `127.0.0.1:7842` that renders all workers' stream-json output as a continuous claude.ai-style feed — task/wave banners, duration + usage chips, token corner bar, localStorage filters, replay via `WS /feed?from=N`. Recovery: `--resume` reconciles interrupted runs using commit + progress.md evidence; ambiguous tasks (commit without progress entry) are flagged for operator triage, never silently claimed done. CLI: `gsd-t orchestrate`, `gsd-t benchmark-orchestrator`, `gsd-t stream-feed`. Contracts: `stream-json-sink-contract.md` v1.1.0, `wave-join-contract.md`, `completion-signal-contract.md`, `metrics-schema-contract.md`.
|
|
17
17
|
**Always-Headless Spawn (M43 D4, v3.16.x+) — Channel Separation** — every GSD-T command spawns detached, unconditionally. No `--watch`, no `--in-session`, no `--headless` opt-in, no context-meter threshold that reroutes. The dialog channel is reserved for human↔Claude conversation; every workflow turn is a detached headless child. Interactive session shows a launch banner + live-transcript URL + event-stream path, then exits — results surface via the read-back banner on the user's next message. Detached workers emit JSONL events to `.gsd-t/events/YYYY-MM-DD.jsonl` at every phase boundary — shared by dashboard and (historically) the watch command. The only in-session surface is the `/gsd` router (for dialog-only exploratory turns). See `.gsd-t/contracts/headless-default-contract.md` v2.0.0 and `unattended-event-stream-contract.md` v1.0.0.
|
|
18
|
-
**Live Transcript as Primary Surface (M43 D6, v3.16.13)** — every detached spawn prints a one-line banner (`▶ Live transcript: http://127.0.0.1:{port}/transcript/{id}`) pointing at a browser viewer that SSE-streams the child's stdout and renders a collapsible "Tool Cost" sidebar panel showing per-tool attributed tokens and cost (sourced from `/transcript/:id/tool-cost`, which proxies to the M43 D2 tool-attribution library). The dashboard server auto-starts (`scripts/gsd-t-dashboard-autostart.cjs`) idempotently on each spawn — a port probe backs off when a server is already running, otherwise a fork-detach writes `.gsd-t/.dashboard.pid`. Port is project-scoped via `projectScopedDefaultPort(projectDir)` so multi-project workflows don't clobber each other.
|
|
18
|
+
**Live Transcript as Primary Surface (M43 D6, v3.16.13 — extended in M47, v3.21.10)** — every detached spawn prints a one-line banner (`▶ Live transcript: http://127.0.0.1:{port}/transcript/{id}`) pointing at a browser viewer that SSE-streams the child's stdout and renders a collapsible "Tool Cost" sidebar panel showing per-tool attributed tokens and cost (sourced from `/transcript/:id/tool-cost`, which proxies to the M43 D2 tool-attribution library). The dashboard server auto-starts (`scripts/gsd-t-dashboard-autostart.cjs`) idempotently on each spawn — a port probe backs off when a server is already running, otherwise a fork-detach writes `.gsd-t/.dashboard.pid`. Port is project-scoped via `projectScopedDefaultPort(projectDir)` so multi-project workflows don't clobber each other.
|
|
19
|
+
**Focused Visualizer Redesign (M47, v3.21.10)** — `/transcripts` opens directly to a dual-pane focused view: the **top pane** auto-streams the orchestrator's main in-session conversation (zero clicks — fetched via new `GET /api/main-session`), and the **bottom pane** streams whichever spawn the user clicks. A keyboard- and mouse-resizable splitter sits between them, with position persisted in `sessionStorage` (along with selection, completed-section toggle, and right-rail collapsed state). The left rail splits into three sections — `★ Main Session`, `Live Spawns`, and `Completed` (last 100 spawns, newest first, status-badged, collapsible). When a spawn transitions running → completed it moves rail sections live without a full reload, and stays selected if focused. The right rail (Spawn Plan / Parallelism / Tool Cost) is preserved under a new collapsible toggle. Bookmarks to `/transcript/:spawnId` continue to land that spawn pre-selected in the bottom pane. Contract: `dashboard-server-contract.md` v1.3.0 (additive — `status: 'active' \| 'completed'` field on in-session entries, derived from a 30s mtime window; `/api/main-session` endpoint with path-traversal guard + `Cache-Control: no-store`).
|
|
19
20
|
- **Surgical model selection** — `bin/model-selector.js` assigns haiku/sonnet/opus per phase via a declarative rules table; `/advisor` escalation path with convention-based fallback.
|
|
20
21
|
- **Per-spawn token telemetry** — `.gsd-t/token-metrics.jsonl` records one 18-field row per Task subagent spawn.
|
|
21
22
|
**Context Meter (M34/M38/M43 D4) — Observational Only** — PostToolUse hook writes `.gsd-t/.context-meter-state.json` via local token estimation. Under M43 D4 (channel-separation inversion, `headless-default-contract.md` v2.0.0) the meter is OBSERVATIONAL ONLY: the pct is recorded into the token-log `Ctx%` column on the next spawn, but no threshold gates any routing decision — every command spawns detached regardless. The `context-meter-contract.md` single-band model is preserved for the value itself; it no longer drives in-flight pauses or spawn-time rerouting.
|
|
@@ -55,52 +55,49 @@ If `.gsd-t/graph/index.json` exists, the dashboard can render entity-relationshi
|
|
|
55
55
|
|
|
56
56
|
If `$ARGUMENTS` contains "stop", skip to **Step 5**. Otherwise continue to Step 3.
|
|
57
57
|
|
|
58
|
-
## Step 3:
|
|
59
|
-
|
|
60
|
-
The dashboard server binds to a **per-project hashed port** in `[7433, 7532]` (see `projectScopedDefaultPort` in `gsd-t-dashboard-server.js`). Resolve it once and reuse:
|
|
58
|
+
## Step 3: Check if Server is Already Running
|
|
61
59
|
|
|
60
|
+
Run via Bash:
|
|
62
61
|
```bash
|
|
63
|
-
PORT=$(node -e "console.log(require('$HOME/.claude/scripts/gsd-t-dashboard-server.js').resolvePort({ projectDir: process.cwd() }))")
|
|
64
62
|
if [ -f .gsd-t/dashboard.pid ]; then
|
|
65
63
|
PID=$(cat .gsd-t/dashboard.pid)
|
|
66
|
-
curl -sf http://localhost
|
|
64
|
+
curl -sf http://localhost:7433/ping 2>/dev/null | grep -q '"ok"' && echo "SERVER_RUNNING=true" || echo "SERVER_RUNNING=false"
|
|
67
65
|
else
|
|
68
66
|
echo "SERVER_RUNNING=false"
|
|
69
67
|
fi
|
|
70
68
|
```
|
|
71
69
|
|
|
72
|
-
If output is `SERVER_RUNNING=true`, skip Step 3a and go directly to Step 4.
|
|
70
|
+
If output is `SERVER_RUNNING=true`, skip Step 3a and go directly to Step 4.
|
|
73
71
|
|
|
74
72
|
### Step 3a: Start Server if Not Running
|
|
75
73
|
|
|
76
|
-
Run via Bash
|
|
74
|
+
Run via Bash:
|
|
77
75
|
```bash
|
|
78
76
|
node ~/.claude/scripts/gsd-t-dashboard-server.js --detach || true
|
|
79
77
|
for i in 1 2 3 4 5; do
|
|
80
|
-
curl -sf http://localhost
|
|
78
|
+
curl -sf http://localhost:7433/ping 2>/dev/null | grep -q '"ok"' && break
|
|
81
79
|
sleep 1
|
|
82
80
|
done
|
|
83
81
|
```
|
|
84
82
|
|
|
85
83
|
## Step 4: Open Browser
|
|
86
84
|
|
|
87
|
-
Run via Bash
|
|
85
|
+
Run via Bash:
|
|
88
86
|
```bash
|
|
89
|
-
|
|
90
|
-
node -e "const {execFileSync}=require('child_process'); const url=process.argv[1]; try { if(process.platform==='win32'){execFileSync('cmd',['/c','start','',url],{stdio:'ignore'})}else{execFileSync(process.platform==='darwin'?'open':'xdg-open',[url],{stdio:'ignore'})} } catch(e) { console.error('Could not open browser:', e.message); }" "$URL" || true
|
|
87
|
+
node -e "const {execFileSync}=require('child_process'); const url='http://localhost:7433'; try { if(process.platform==='win32'){execFileSync('cmd',['/c','start','',url],{stdio:'ignore'})}else{execFileSync(process.platform==='darwin'?'open':'xdg-open',[url],{stdio:'ignore'})} } catch(e) { console.error('Could not open browser:', e.message); }" || true
|
|
91
88
|
```
|
|
92
89
|
|
|
93
|
-
Report to the user: "Dashboard is running at
|
|
90
|
+
Report to the user: "Dashboard is running at http://localhost:7433 — browser opened."
|
|
94
91
|
|
|
95
92
|
## Step 5: Stop Handler
|
|
96
93
|
|
|
97
|
-
Run only when `$ARGUMENTS` contains "stop".
|
|
94
|
+
Run only when `$ARGUMENTS` contains "stop".
|
|
98
95
|
|
|
96
|
+
Run via Bash:
|
|
99
97
|
```bash
|
|
100
|
-
PORT=$(node -e "console.log(require('$HOME/.claude/scripts/gsd-t-dashboard-server.js').resolvePort({ projectDir: process.cwd() }))")
|
|
101
98
|
if [ -f .gsd-t/dashboard.pid ]; then
|
|
102
99
|
PID=$(cat .gsd-t/dashboard.pid)
|
|
103
|
-
curl -sf http://localhost
|
|
100
|
+
curl -sf http://localhost:7433/stop 2>/dev/null || kill $PID 2>/dev/null || true
|
|
104
101
|
rm -f .gsd-t/dashboard.pid
|
|
105
102
|
echo "Dashboard server stopped"
|
|
106
103
|
else
|
package/docs/architecture.md
CHANGED
|
@@ -75,9 +75,20 @@ The framework has no runtime — it is consumed entirely by Claude Code's slash
|
|
|
75
75
|
- **Transcript viewer panel** (`scripts/gsd-t-transcript.html`): collapsible "Tool Cost" sidebar panel that fetches `/transcript/:id/tool-cost` on viewer load and debounces a 2s refresh on each SSE `turn_complete` / `result` frame. Renders top-N tools sorted by attributed tokens with name, call count, tokens, and USD cost. Live badge green while SSE is open, muted otherwise. 503 → friendly "tool attribution not yet wired" row. `window.__gsdtRenderToolCostPanel` exposed for DOM tests.
|
|
76
76
|
- **URL banner** (`bin/headless-auto-spawn.cjs`): every detached spawn prints `▶ Live transcript: http://127.0.0.1:{port}/transcript/{spawn-id}` on stdout. Port sourced from `ensureDashboardRunning().port` with `projectScopedDefaultPort(projectDir)` fallback. Best-effort — banner failure never crashes the spawn.
|
|
77
77
|
- **Dashboard autostart** (`scripts/gsd-t-dashboard-autostart.cjs`, ~160 lines, zero deps): `ensureDashboardRunning({projectDir, port?})` probes the port synchronously via a short-lived subprocess (`_isPortBusySync` issues `net.createServer().listen(port)` host-less — matches the server's IPv6-wildcard bind on macOS dual-stack; specifying `127.0.0.1` would falsely report free). If free, fork-detaches the server with `spawn(…, {detached:true, stdio:'ignore'})` + `child.unref()` + writes `.gsd-t/.dashboard.pid` (hyphen → dot distinguishes this lifecycle from M38's `.gsd-t/dashboard.pid`). Idempotent on repeated invocation. Called at the top of `autoSpawnHeadless` so the banner printed immediately after resolves to a live listener.
|
|
78
|
-
- **Contract**: `.gsd-t/contracts/dashboard-server-contract.md` v1.2.0 — new §HTTP Endpoints entries, §Banner Format, §Autostart sections.
|
|
78
|
+
- **Contract**: `.gsd-t/contracts/dashboard-server-contract.md` v1.2.0 — new §HTTP Endpoints entries, §Banner Format, §Autostart sections. (Bumped to v1.3.0 in M47 — see Focused Visualizer Redesign below.)
|
|
79
79
|
- **Tests**: `test/m43-dashboard-tool-cost-route.test.js` (9), `test/m43-transcript-panel.test.js` (12), `test/m43-dashboard-autostart.test.js` (6), `test/m43-url-banner.test.js` (3).
|
|
80
80
|
|
|
81
|
+
### Focused Visualizer Redesign (M47, v3.21.10)
|
|
82
|
+
- **Server endpoint** (`scripts/gsd-t-dashboard-server.js::handleMainSession`): new `GET /api/main-session` returns `{ filename, sessionId, mtimeMs }` for the most-recently-modified `in-session-*.ndjson` file in `transcripts/`, or `{ null, null, null }` when none exist. Path-traversal guarded by reusing `isValidSpawnId` on each candidate filename. Sets `Cache-Control: no-store` (the viewer hits this on every page load, never cached). Wired alongside the existing `/transcripts` route.
|
|
83
|
+
- **Status field derivation** (`listInSessionTranscripts`): each in-session entry now carries `status: 'active' | 'completed'` derived from a 30-second mtime window (`Date.now() - stat.mtimeMs < 30_000` → `active`). Replaces the prior hardcoded `"active"` literal. Propagates through `handleTranscriptsList` to the merged `/transcripts` JSON. Future `success | failed | killed` taxonomy is a one-file change here — the viewer code branches on the field, neutral fallback for unrecognized values.
|
|
84
|
+
- **Dual-pane viewer** (`scripts/gsd-t-transcript.html`): `<main>` becomes a vertical flex container holding `<section id="main-stream">` (top — auto-streams the orchestrator's main in-session conversation via `/api/main-session` + `/transcript/in-session-{sid}/stream`) + `<div class="splitter" role="separator">` (keyboard- and mouse-resizable, ArrowUp/Down ±5%, Home/End snap 20/80, position persisted in `sessionStorage` under `gsd-t.viewer.splitterPct`) + `<section id="spawn-stream">` (bottom — the user-selected spawn; legacy `<div id="stream">` lives inside, preserving all existing renderer code paths). Shared frame renderer threads an optional target via a module-scope `renderTarget` swap inside `renderFrame(frame, arrivedAt, target)`.
|
|
85
|
+
- **3-section left rail**: `★ Main Session` / `Live Spawns` / `Completed` (last 100 newest first, status-badged, collapsible). Bucketing in `bucketAndRender(spawns)` consumes D2's `status` field — D1 never computes status itself. Live → Completed transition is reactive (DOM diff via `data-spawn-id` lookups); selected spawn stays selected if it transitions sections.
|
|
86
|
+
- **sessionStorage persistence** (4 keys): `gsd-t.viewer.selectedSpawnId`, `gsd-t.viewer.splitterPct`, `gsd-t.viewer.completedExpanded`, `gsd-t.viewer.rightRailCollapsed`. `_ssGet`/`_ssSet` wrappers fail-soft so the IIFE init survives DOM-shim test sandboxes that don't provide a `sessionStorage` shim.
|
|
87
|
+
- **Right-rail collapse**: `<aside class="spawn-panel" data-collapsed>` toggle button flips `data-right-rail-collapsed` on `<body>`, which collapses the third grid track via `--right-rail-w: 0px` rule.
|
|
88
|
+
- **Contract**: `.gsd-t/contracts/dashboard-server-contract.md` **v1.3.0** — additive §`GET /api/main-session` and §`In-Session Entry Status Field` sections; `handleMainSession` added to Module Exports.
|
|
89
|
+
- **Integration contract**: `.gsd-t/contracts/m47-integration-points.md` — D1↔D2 wiring, dependency graph, single-wave parallel execution plan.
|
|
90
|
+
- **Tests**: `test/dashboard-server.test.js` — 13 new assertions across 3 describe blocks (4 status-field tests, 5 `/api/main-session` tests, 4 HTML structural-marker / sessionStorage-key / CSS toggle tests). Suite total 2058 / 2060 (M47 +13/+13; 2 pre-existing flakes preserved). Existing 5 viewer-route tests updated for new structure (regex relaxation: `grid-template-columns` allows `var(--right-rail-w)`; `<main id="stream">` allows `id="stream"` regardless of element).
|
|
91
|
+
|
|
81
92
|
### Headless Mode (M23 — complete)
|
|
82
93
|
- **doHeadless(args)**: Dispatch function for the `headless` CLI subcommand.
|
|
83
94
|
- **doHeadlessExec(command, cmdArgs, flags)**: Wraps `claude -p "/gsd-t-{command}"` via `execFileSync`. Verifies claude CLI availability, enforces timeout, writes log file if `--log` requested. Returns structured JSON if `--json` flag set. (M36 Phase 0: prompt form is `/gsd-t-X`, NOT `/gsd-t-X` — non-interactive mode rejects the `/` namespace prefix.)
|
package/docs/requirements.md
CHANGED
|
@@ -684,3 +684,19 @@ Acceptance:
|
|
|
684
684
|
- Tests in `test/m46-d1-iter-parallel.test.js` pass (serial fallback, parallel batch, mode-safety gate, error isolation, state reconciliation).
|
|
685
685
|
- Proof speedup ≥ **3.0×** recorded in `.gsd-t/metrics/m46-iter-proof.json` — a synthetic `batchSize = 4` measurement of the `_runIterParallel` driver, not the production main loop (which remains serial until backlog #24 lands).
|
|
686
686
|
- `.gsd-t/contracts/iter-parallel-contract.md` v1.0.0 present as the locked source of truth.
|
|
687
|
+
|
|
688
|
+
## M47 Focused Visualizer Redesign (executed — 2026-05-06)
|
|
689
|
+
|
|
690
|
+
| REQ-ID | Requirement Summary | Domain | Task(s) | Status |
|
|
691
|
+
|--------|---------------------|--------|---------|--------|
|
|
692
|
+
| REQ-M47-D1-01 | Default `/transcripts` landing shows the main in-session conversation streaming in the top pane within 3s, zero clicks (success criterion 1). | m47-d1-viewer-redesign | T2, T4 | done |
|
|
693
|
+
| REQ-M47-D1-02 | Click any rail entry → loads it into the bottom pane within 1s; `gsd-t.viewer.selectedSpawnId` sessionStorage key persists selection across reload (success criterion 2). | m47-d1-viewer-redesign | T3, T5 | done |
|
|
694
|
+
| REQ-M47-D1-03 | Reactive Live → Completed transition without full reload; if the user is currently focused on the transitioning spawn, focus stays (no auto-revert) (success criterion 3). | m47-d1-viewer-redesign | T5 | done |
|
|
695
|
+
| REQ-M47-D1-04 | Completed section displays at least 100 historical spawns capped, sorted newest-first, with status badges; toggle collapses/expands; `gsd-t.viewer.completedExpanded` persists state (success criterion 4). | m47-d1-viewer-redesign | T3, T5 | done |
|
|
696
|
+
| REQ-M47-D1-05 | Splitter is mouse-draggable + keyboard-accessible (ArrowUp/Down ±5%, Home/End snap to 20/80); position persists in `gsd-t.viewer.splitterPct` sessionStorage. | m47-d1-viewer-redesign | T2, T6 | done |
|
|
697
|
+
| REQ-M47-D1-06 | Right rail (Spawn Plan / Parallelism / Tool Cost) preserved under collapsible toggle; `gsd-t.viewer.rightRailCollapsed` sessionStorage key. | m47-d1-viewer-redesign | T2 | done |
|
|
698
|
+
| REQ-M47-D1-07 | Back-compat: `data-spawn-id="__SPAWN_ID__"` server-side substitution preserved; bookmarks to `/transcript/:spawnId` land with that spawn pre-selected in the bottom pane. Existing 7 viewer-route/HTML tests stay green. | m47-d1-viewer-redesign | T2, T7 | done |
|
|
699
|
+
| REQ-M47-D2-01 | `listInSessionTranscripts` (and the merged `handleTranscriptsList` payload) returns each in-session entry with `status: 'active' \| 'completed'` derived from a 30s mtime window. | m47-d2-server-helpers | T1, T4 | done |
|
|
700
|
+
| REQ-M47-D2-02 | New `GET /api/main-session` endpoint returns `{ filename, sessionId, mtimeMs }` for the most-recently-modified `in-session-*.ndjson` (or `{ null, null, null }` when none exist); path-traversal-guarded; no caching. | m47-d2-server-helpers | T2, T5 | done |
|
|
701
|
+
| REQ-M47-D2-03 | `dashboard-server-contract.md` bumped to v1.3.0 documenting the additive `status` field semantics + `/api/main-session` schema; module exports updated. | m47-d2-server-helpers | T3 | done |
|
|
702
|
+
| REQ-M47-D2-04 | Test suite passes baseline 2045/2047 + new M47 tests (D1 + D2 net add); no NEW regressions in the 7 existing viewer-route/HTML tests (success criterion 5). | m47-d1-viewer-redesign + m47-d2-server-helpers | D1 T7, D2 T4–T5 | done |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekyzinc/gsd-t",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.21.10",
|
|
4
4
|
"description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
|
|
5
5
|
"author": "Tekyz, Inc.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -184,10 +184,18 @@ function isValidSpawnId(id) {
|
|
|
184
184
|
// NDJSONs are on disk. Synthesizes a spawn-shaped entry per in-session file
|
|
185
185
|
// using filesystem timestamps; the viewer's `in-session-` prefix detection
|
|
186
186
|
// then labels it as `💬 conversation`.
|
|
187
|
+
//
|
|
188
|
+
// M47 D2: `status` is derived per-entry from mtime. A file modified within the
|
|
189
|
+
// last 30 seconds is `active` (the conversation hook is still appending);
|
|
190
|
+
// otherwise `completed`. The viewer (M47 D1) buckets entries by this field
|
|
191
|
+
// into Live vs Completed rail sections. The `success | failed | killed`
|
|
192
|
+
// taxonomy is intentionally out of scope here — see contract v1.3.0.
|
|
193
|
+
const IN_SESSION_ACTIVE_WINDOW_MS = 30_000;
|
|
187
194
|
function listInSessionTranscripts(projectDir) {
|
|
188
195
|
const dir = transcriptsDir(projectDir);
|
|
189
196
|
let files;
|
|
190
197
|
try { files = fs.readdirSync(dir); } catch { return []; }
|
|
198
|
+
const now = Date.now();
|
|
191
199
|
const out = [];
|
|
192
200
|
for (const f of files) {
|
|
193
201
|
if (!f.startsWith("in-session-") || !f.endsWith(".ndjson")) continue;
|
|
@@ -195,18 +203,48 @@ function listInSessionTranscripts(projectDir) {
|
|
|
195
203
|
if (!isValidSpawnId(spawnId)) continue;
|
|
196
204
|
let stat;
|
|
197
205
|
try { stat = fs.statSync(path.join(dir, f)); } catch { continue; }
|
|
206
|
+
const status = (now - stat.mtimeMs) < IN_SESSION_ACTIVE_WINDOW_MS ? "active" : "completed";
|
|
198
207
|
out.push({
|
|
199
208
|
spawnId,
|
|
200
209
|
command: "in-session conversation",
|
|
201
210
|
startedAt: stat.birthtime ? stat.birthtime.toISOString() : stat.mtime.toISOString(),
|
|
202
211
|
lastUpdatedAt: stat.mtime.toISOString(),
|
|
203
|
-
status
|
|
212
|
+
status,
|
|
204
213
|
kind: "in-session",
|
|
205
214
|
});
|
|
206
215
|
}
|
|
207
216
|
return out;
|
|
208
217
|
}
|
|
209
218
|
|
|
219
|
+
// M47 D2: Resolve the most-recently-modified `in-session-*.ndjson` file. Used
|
|
220
|
+
// by the viewer's top-pane default load (zero-click main-conversation stream).
|
|
221
|
+
// Reuses `isValidSpawnId` for path-traversal safety.
|
|
222
|
+
function handleMainSession(req, res, projectDir) {
|
|
223
|
+
const dir = transcriptsDir(projectDir);
|
|
224
|
+
let files;
|
|
225
|
+
try { files = fs.readdirSync(dir); } catch { files = []; }
|
|
226
|
+
let best = null;
|
|
227
|
+
for (const f of files) {
|
|
228
|
+
if (!f.startsWith("in-session-") || !f.endsWith(".ndjson")) continue;
|
|
229
|
+
const spawnId = f.slice(0, -".ndjson".length);
|
|
230
|
+
if (!isValidSpawnId(spawnId)) continue;
|
|
231
|
+
let stat;
|
|
232
|
+
try { stat = fs.statSync(path.join(dir, f)); } catch { continue; }
|
|
233
|
+
if (!best || stat.mtimeMs > best.mtimeMs) {
|
|
234
|
+
best = { filename: f, sessionId: f.slice("in-session-".length, -".ndjson".length), mtimeMs: stat.mtimeMs };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
res.writeHead(200, {
|
|
238
|
+
"Content-Type": "application/json",
|
|
239
|
+
"Cache-Control": "no-store",
|
|
240
|
+
});
|
|
241
|
+
if (!best) {
|
|
242
|
+
res.end(JSON.stringify({ filename: null, sessionId: null, mtimeMs: null }));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
res.end(JSON.stringify(best));
|
|
246
|
+
}
|
|
247
|
+
|
|
210
248
|
function handleTranscriptsList(req, res, projectDir, transcriptHtmlPath) {
|
|
211
249
|
const idx = readTranscriptsIndex(projectDir);
|
|
212
250
|
|
|
@@ -721,6 +759,8 @@ function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath)
|
|
|
721
759
|
if (url === "/ping") return handlePing(req, res, port);
|
|
722
760
|
if (url === "/stop") return handleStop(req, res, server);
|
|
723
761
|
if (url === "/transcripts") return handleTranscriptsList(req, res, projDir, tHtmlPath);
|
|
762
|
+
// M47 D2 — most-recent in-session NDJSON for the viewer top-pane default load
|
|
763
|
+
if (url === "/api/main-session") return handleMainSession(req, res, projDir);
|
|
724
764
|
// M44 D8 — spawn plans: GET list + SSE change stream
|
|
725
765
|
if (url === "/api/spawn-plans") return handleSpawnPlans(req, res, projDir);
|
|
726
766
|
if (url === "/api/spawn-plans/stream") return handleSpawnPlanUpdates(req, res, projDir);
|
|
@@ -762,6 +802,7 @@ module.exports = {
|
|
|
762
802
|
readIndexEntry,
|
|
763
803
|
isValidSpawnId,
|
|
764
804
|
listInSessionTranscripts,
|
|
805
|
+
handleMainSession,
|
|
765
806
|
handleTranscriptsList,
|
|
766
807
|
handleTranscriptStream,
|
|
767
808
|
handleTranscriptPage,
|
|
@@ -22,10 +22,46 @@
|
|
|
22
22
|
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
23
23
|
}
|
|
24
24
|
* { box-sizing: border-box; }
|
|
25
|
-
|
|
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; }
|
|
26
34
|
body > header { grid-column: 1 / -1; }
|
|
27
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; }
|
|
28
|
-
|
|
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); }
|
|
29
65
|
/* M44 D8 — right-side spawn-plan panel (additive; does not alter main stream) */
|
|
30
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; }
|
|
31
67
|
.spawn-panel h2 { margin: 0 0 6px 0; font-size: 11px; text-transform: uppercase; color: var(--fg-xdim); letter-spacing: 0.08em; }
|
|
@@ -121,7 +157,8 @@
|
|
|
121
157
|
header .status.ended .dot { background: var(--fg-xdim); }
|
|
122
158
|
header .status.error .dot { background: var(--red); }
|
|
123
159
|
|
|
124
|
-
|
|
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. */
|
|
125
162
|
.frame { margin: 8px 0; }
|
|
126
163
|
.frame.system { color: var(--fg-xdim); font-style: italic; font-size: 12px; padding: 2px 0; }
|
|
127
164
|
.frame.user { border-left: 3px solid var(--accent); padding: 6px 12px; background: var(--bg-raised); border-radius: 0 4px 4px 0; }
|
|
@@ -171,8 +208,25 @@
|
|
|
171
208
|
<div class="status" id="hdr-status"><span class="dot"></span><span class="label">connecting…</span></div>
|
|
172
209
|
</header>
|
|
173
210
|
<aside class="left-rail">
|
|
174
|
-
|
|
175
|
-
|
|
211
|
+
<!-- M47 D1 — three pre-rendered sections. Bucketing logic in Task 5
|
|
212
|
+
consumes D2's `status` field; until then, in-session entries land
|
|
213
|
+
in Main Session and everything else falls through to Live Spawns. -->
|
|
214
|
+
<section class="rail-main" data-rail-section="main">
|
|
215
|
+
<div class="rail-header"><span>★ Main Session</span></div>
|
|
216
|
+
<div class="rail-body" id="rail-main-body"><div class="empty">No in-session conversation captured yet.</div></div>
|
|
217
|
+
</section>
|
|
218
|
+
<section class="rail-live" data-rail-section="live">
|
|
219
|
+
<div class="rail-header"><span>Live Spawns</span></div>
|
|
220
|
+
<div class="rail-body">
|
|
221
|
+
<!-- Legacy mount point: existing renderTree() writes into #tree as a
|
|
222
|
+
no-op fallback while Task 5 bucketing rolls out. -->
|
|
223
|
+
<div class="tree" id="tree"></div>
|
|
224
|
+
</div>
|
|
225
|
+
</section>
|
|
226
|
+
<section class="rail-completed" data-rail-section="completed" data-expanded="true">
|
|
227
|
+
<div class="rail-header"><span>Completed</span><button class="chevron" id="rail-completed-toggle" aria-label="Toggle completed section">▾</button></div>
|
|
228
|
+
<div class="rail-body" id="rail-completed-body"><div class="empty">No completed spawns yet.</div></div>
|
|
229
|
+
</section>
|
|
176
230
|
<details class="panel" id="tool-cost-panel" open>
|
|
177
231
|
<summary>Tool Cost <span class="live-badge" id="tool-cost-live">off</span></summary>
|
|
178
232
|
<div class="panel-body" id="tool-cost-body">
|
|
@@ -180,11 +234,28 @@
|
|
|
180
234
|
</div>
|
|
181
235
|
</details>
|
|
182
236
|
</aside>
|
|
183
|
-
<main
|
|
237
|
+
<main>
|
|
238
|
+
<!-- M47 D1 — top pane: main in-session conversation (zero-click default).
|
|
239
|
+
Wired by Task 4 to /api/main-session + /transcript/in-session-:id/stream. -->
|
|
240
|
+
<section id="main-stream" aria-label="Main session conversation">
|
|
241
|
+
<div class="pane-empty">No in-session conversation captured yet.</div>
|
|
242
|
+
</section>
|
|
243
|
+
<!-- Splitter: keyboard accessible (ArrowUp/ArrowDown nudge ±5%, Home/End snap). -->
|
|
244
|
+
<div class="splitter" id="splitter" role="separator" tabindex="0" aria-orientation="horizontal" aria-label="Resize main vs spawn pane"></div>
|
|
245
|
+
<!-- Bottom pane: user-selected spawn. Existing #stream id preserved so
|
|
246
|
+
legacy selectors keep working — it now lives inside #spawn-stream. -->
|
|
247
|
+
<section id="spawn-stream" aria-label="Selected spawn transcript">
|
|
248
|
+
<div id="stream"></div>
|
|
249
|
+
</section>
|
|
250
|
+
</main>
|
|
184
251
|
<!-- M44 D8 — Spawn Plan panel: Layer 1 (project) + Layer 2 (active spawn).
|
|
185
|
-
Additive; does not change the transcript stream rendering.
|
|
186
|
-
|
|
187
|
-
|
|
252
|
+
Additive; does not change the transcript stream rendering.
|
|
253
|
+
M47 D1 — wrapped in <aside class="spawn-panel" data-collapsed> with a toggle. -->
|
|
254
|
+
<aside class="spawn-panel" id="spawn-plan-panel" data-collapsed="false">
|
|
255
|
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
|
|
256
|
+
<h2 style="margin:0;">Spawn Plan</h2>
|
|
257
|
+
<button class="panel-toggle" id="spawn-panel-toggle" aria-label="Collapse right rail">⟶</button>
|
|
258
|
+
</div>
|
|
188
259
|
<section id="spawn-layer-project">
|
|
189
260
|
<div class="sec-head"><span class="sec-title" id="project-title">Project</span><span class="sec-sub" id="project-sub">loading…</span></div>
|
|
190
261
|
<div class="sec-totals" id="project-totals">—</div>
|
|
@@ -224,6 +295,116 @@
|
|
|
224
295
|
const statusLabel = statusEl.querySelector('.label');
|
|
225
296
|
const jumpBtn = document.getElementById('jump-btn');
|
|
226
297
|
|
|
298
|
+
// M47 D1 — sessionStorage keys (centralized so tests can grep them).
|
|
299
|
+
const SS_KEY_SELECTED = 'gsd-t.viewer.selectedSpawnId';
|
|
300
|
+
const SS_KEY_SPLITTER = 'gsd-t.viewer.splitterPct';
|
|
301
|
+
const SS_KEY_COMPLETED = 'gsd-t.viewer.completedExpanded';
|
|
302
|
+
const SS_KEY_RIGHT_RAIL = 'gsd-t.viewer.rightRailCollapsed';
|
|
303
|
+
|
|
304
|
+
// M47 D1 — sessionStorage helpers: wrapped so private-mode (or test
|
|
305
|
+
// sandboxes that don't provide a sessionStorage shim) fall through to
|
|
306
|
+
// safe defaults instead of throwing during IIFE init.
|
|
307
|
+
function _ssGet(key) { try { return (typeof sessionStorage !== 'undefined') ? sessionStorage.getItem(key) : null; } catch { return null; } }
|
|
308
|
+
function _ssSet(key, value) { try { if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(key, value); } catch { /* private mode */ } }
|
|
309
|
+
|
|
310
|
+
// M47 D1 — splitter: restore persisted pct BEFORE first paint to avoid layout shift.
|
|
311
|
+
(function initSplitterPct() {
|
|
312
|
+
const raw = _ssGet(SS_KEY_SPLITTER);
|
|
313
|
+
let pct = 50;
|
|
314
|
+
const n = parseFloat(raw);
|
|
315
|
+
if (isFinite(n)) pct = Math.max(10, Math.min(90, n));
|
|
316
|
+
if (document.documentElement && document.documentElement.style) {
|
|
317
|
+
document.documentElement.style.setProperty('--main-pane-pct', String(pct));
|
|
318
|
+
}
|
|
319
|
+
})();
|
|
320
|
+
|
|
321
|
+
// M47 D1 — right-rail collapse: restore persisted state.
|
|
322
|
+
(function initRightRailCollapsed() {
|
|
323
|
+
const flag = _ssGet(SS_KEY_RIGHT_RAIL) === '1';
|
|
324
|
+
if (document.body && document.body.setAttribute) {
|
|
325
|
+
document.body.setAttribute('data-right-rail-collapsed', flag ? 'true' : 'false');
|
|
326
|
+
}
|
|
327
|
+
const panel = document.getElementById('spawn-plan-panel');
|
|
328
|
+
if (panel && panel.setAttribute) panel.setAttribute('data-collapsed', flag ? 'true' : 'false');
|
|
329
|
+
})();
|
|
330
|
+
|
|
331
|
+
// M47 D1 — completed section: restore persisted state.
|
|
332
|
+
(function initCompletedExpanded() {
|
|
333
|
+
const flagRaw = _ssGet(SS_KEY_COMPLETED);
|
|
334
|
+
const expanded = flagRaw === null ? true : flagRaw === '1';
|
|
335
|
+
const sec = (document.querySelector ? document.querySelector('section.rail-completed') : null);
|
|
336
|
+
if (sec && sec.setAttribute) sec.setAttribute('data-expanded', expanded ? 'true' : 'false');
|
|
337
|
+
})();
|
|
338
|
+
|
|
339
|
+
// M47 D1 — splitter wiring (mouse + keyboard).
|
|
340
|
+
(function wireSplitter() {
|
|
341
|
+
const splitter = document.getElementById('splitter');
|
|
342
|
+
if (!splitter || !splitter.addEventListener) return;
|
|
343
|
+
function setPct(pct) {
|
|
344
|
+
const clamped = Math.max(10, Math.min(90, pct));
|
|
345
|
+
if (document.documentElement && document.documentElement.style) {
|
|
346
|
+
document.documentElement.style.setProperty('--main-pane-pct', String(clamped));
|
|
347
|
+
}
|
|
348
|
+
_ssSet(SS_KEY_SPLITTER, String(clamped));
|
|
349
|
+
}
|
|
350
|
+
let dragging = false;
|
|
351
|
+
function onMove(ev) {
|
|
352
|
+
if (!dragging) return;
|
|
353
|
+
const main = document.querySelector ? document.querySelector('body > main') : null;
|
|
354
|
+
if (!main || !main.getBoundingClientRect) return;
|
|
355
|
+
const r = main.getBoundingClientRect();
|
|
356
|
+
if (r.height <= 0) return;
|
|
357
|
+
const pct = ((ev.clientY - r.top) / r.height) * 100;
|
|
358
|
+
setPct(pct);
|
|
359
|
+
}
|
|
360
|
+
function onUp() { dragging = false; if (document.body && document.body.style) document.body.style.cursor = ''; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }
|
|
361
|
+
splitter.addEventListener('mousedown', (ev) => {
|
|
362
|
+
ev.preventDefault();
|
|
363
|
+
dragging = true;
|
|
364
|
+
if (document.body && document.body.style) document.body.style.cursor = 'row-resize';
|
|
365
|
+
window.addEventListener('mousemove', onMove);
|
|
366
|
+
window.addEventListener('mouseup', onUp);
|
|
367
|
+
});
|
|
368
|
+
splitter.addEventListener('keydown', (ev) => {
|
|
369
|
+
let cur = 50;
|
|
370
|
+
if (typeof getComputedStyle === 'function' && document.documentElement) {
|
|
371
|
+
const v = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--main-pane-pct'));
|
|
372
|
+
if (isFinite(v)) cur = v;
|
|
373
|
+
}
|
|
374
|
+
if (ev.key === 'ArrowUp') { ev.preventDefault(); setPct(cur - 5); }
|
|
375
|
+
else if (ev.key === 'ArrowDown') { ev.preventDefault(); setPct(cur + 5); }
|
|
376
|
+
else if (ev.key === 'Home') { ev.preventDefault(); setPct(20); }
|
|
377
|
+
else if (ev.key === 'End') { ev.preventDefault(); setPct(80); }
|
|
378
|
+
});
|
|
379
|
+
})();
|
|
380
|
+
|
|
381
|
+
// M47 D1 — right-rail collapse toggle.
|
|
382
|
+
(function wireRightRailToggle() {
|
|
383
|
+
const btn = document.getElementById('spawn-panel-toggle');
|
|
384
|
+
const panel = document.getElementById('spawn-plan-panel');
|
|
385
|
+
if (!btn || !panel || !btn.addEventListener) return;
|
|
386
|
+
btn.addEventListener('click', () => {
|
|
387
|
+
const collapsed = panel.getAttribute && panel.getAttribute('data-collapsed') === 'true';
|
|
388
|
+
const next = !collapsed;
|
|
389
|
+
if (panel.setAttribute) panel.setAttribute('data-collapsed', next ? 'true' : 'false');
|
|
390
|
+
if (document.body && document.body.setAttribute) document.body.setAttribute('data-right-rail-collapsed', next ? 'true' : 'false');
|
|
391
|
+
_ssSet(SS_KEY_RIGHT_RAIL, next ? '1' : '0');
|
|
392
|
+
});
|
|
393
|
+
})();
|
|
394
|
+
|
|
395
|
+
// M47 D1 — completed section toggle.
|
|
396
|
+
(function wireCompletedToggle() {
|
|
397
|
+
const btn = document.getElementById('rail-completed-toggle');
|
|
398
|
+
const sec = document.querySelector ? document.querySelector('section.rail-completed') : null;
|
|
399
|
+
if (!btn || !sec || !btn.addEventListener) return;
|
|
400
|
+
btn.addEventListener('click', () => {
|
|
401
|
+
const expanded = sec.getAttribute && sec.getAttribute('data-expanded') !== 'false';
|
|
402
|
+
const next = !expanded;
|
|
403
|
+
if (sec.setAttribute) sec.setAttribute('data-expanded', next ? 'true' : 'false');
|
|
404
|
+
_ssSet(SS_KEY_COMPLETED, next ? '1' : '0');
|
|
405
|
+
});
|
|
406
|
+
})();
|
|
407
|
+
|
|
227
408
|
// Pair tool_result → tool_use by tool_use_id so the renderer can place
|
|
228
409
|
// the result next to the call even when they come as separate frames.
|
|
229
410
|
const toolUseById = new Map();
|
|
@@ -264,6 +445,13 @@
|
|
|
264
445
|
// to "now" so manually-rendered frames stay visible too. Spotting a
|
|
265
446
|
// stuck stream is then trivial — adjacent frames will show identical
|
|
266
447
|
// or far-apart timestamps in the left-margin pill.
|
|
448
|
+
//
|
|
449
|
+
// M47 D1: appendFrame writes to a module-scope `renderTarget`. The
|
|
450
|
+
// renderFrame entry point swaps it per-call so the same code paths
|
|
451
|
+
// render into either the top pane (#main-stream — main-conversation
|
|
452
|
+
// SSE) or the bottom pane (#stream inside #spawn-stream — selected
|
|
453
|
+
// spawn SSE).
|
|
454
|
+
let renderTarget = stream;
|
|
267
455
|
function appendFrame(el, arrivedAt) {
|
|
268
456
|
const ts = document.createElement('span');
|
|
269
457
|
ts.className = 'ts';
|
|
@@ -272,9 +460,19 @@
|
|
|
272
460
|
// Insert as the first child so flex/inline layouts keep working.
|
|
273
461
|
if (el.firstChild) el.insertBefore(ts, el.firstChild);
|
|
274
462
|
else el.appendChild(ts);
|
|
275
|
-
stream.appendChild(el);
|
|
276
|
-
|
|
463
|
+
(renderTarget || stream).appendChild(el);
|
|
464
|
+
// Auto-scroll only applies to the bottom pane (where the user is
|
|
465
|
+
// actively focused). The top pane scrolls within itself; we let the
|
|
466
|
+
// user's scrollbar manage it without yanking focus.
|
|
467
|
+
if (renderTarget === stream && autoScroll) {
|
|
277
468
|
requestAnimationFrame(() => window.scrollTo(0, document.body.scrollHeight));
|
|
469
|
+
} else if (renderTarget && renderTarget !== stream) {
|
|
470
|
+
// Auto-scroll the top pane only when its viewport is at-bottom (similar
|
|
471
|
+
// logic to atBottom() but pane-local).
|
|
472
|
+
const pane = renderTarget.parentElement || renderTarget;
|
|
473
|
+
if (pane && pane.scrollHeight - pane.scrollTop - pane.clientHeight < 60) {
|
|
474
|
+
requestAnimationFrame(() => { pane.scrollTop = pane.scrollHeight; });
|
|
475
|
+
}
|
|
278
476
|
}
|
|
279
477
|
}
|
|
280
478
|
|
|
@@ -437,7 +635,16 @@
|
|
|
437
635
|
}
|
|
438
636
|
window.__gsdtRenderCompactMarker = renderCompactMarker;
|
|
439
637
|
|
|
440
|
-
|
|
638
|
+
// M47 D1: optional `target` element switches render destination for the
|
|
639
|
+
// duration of this call (top pane = #main-stream, bottom pane = #stream).
|
|
640
|
+
function renderFrame(frame, arrivedAt, target) {
|
|
641
|
+
if (!frame || typeof frame !== 'object') return;
|
|
642
|
+
const prev = renderTarget;
|
|
643
|
+
if (target) renderTarget = target;
|
|
644
|
+
try { return renderFrameInner(frame, arrivedAt); }
|
|
645
|
+
finally { renderTarget = prev; }
|
|
646
|
+
}
|
|
647
|
+
function renderFrameInner(frame, arrivedAt) {
|
|
441
648
|
if (!frame || typeof frame !== 'object') return;
|
|
442
649
|
const ts = arrivedAt instanceof Date ? arrivedAt : new Date();
|
|
443
650
|
const type = frame.type;
|
|
@@ -635,13 +842,130 @@
|
|
|
635
842
|
pulseNode(latest.spawnId);
|
|
636
843
|
}
|
|
637
844
|
|
|
845
|
+
// M47 D1 — render a single rail entry (used by bucketing).
|
|
846
|
+
function renderRailEntry(node, container, opts) {
|
|
847
|
+
const isInSession = typeof node.spawnId === 'string' && node.spawnId.indexOf('in-session-') === 0;
|
|
848
|
+
const currentId = (location.hash || '').slice(1) || spawnId;
|
|
849
|
+
const el = document.createElement('div');
|
|
850
|
+
el.className = 'node ' + statusClass(node);
|
|
851
|
+
if (isInSession) el.classList.add('in-session');
|
|
852
|
+
if (node.spawnId === currentId) el.classList.add('active');
|
|
853
|
+
el.style.paddingLeft = '12px';
|
|
854
|
+
const dot = document.createElement('span'); dot.className = 'dot';
|
|
855
|
+
const name = document.createElement('span'); name.className = 'name';
|
|
856
|
+
// M47 D1 — status badge (gated to completed-section entries; others
|
|
857
|
+
// already use the running/stopped/failed `dot` for state).
|
|
858
|
+
if (opts && opts.showBadge) {
|
|
859
|
+
const badgeKind = (function () {
|
|
860
|
+
const s = node.status;
|
|
861
|
+
if (s === 'success' || s === 'failed' || s === 'killed') return s;
|
|
862
|
+
return 'completed'; // neutral fallback for any other terminal value
|
|
863
|
+
})();
|
|
864
|
+
const badge = document.createElement('span');
|
|
865
|
+
badge.className = 'badge-status ' + badgeKind;
|
|
866
|
+
badge.textContent = badgeKind;
|
|
867
|
+
name.appendChild(badge);
|
|
868
|
+
}
|
|
869
|
+
if (isInSession) {
|
|
870
|
+
const tag = document.createElement('span');
|
|
871
|
+
tag.className = 'label-in-session';
|
|
872
|
+
tag.textContent = '💬 conversation';
|
|
873
|
+
name.appendChild(tag);
|
|
874
|
+
const tail = document.createElement('span');
|
|
875
|
+
tail.textContent = ' · ' + node.spawnId.slice(-8);
|
|
876
|
+
name.appendChild(tail);
|
|
877
|
+
} else {
|
|
878
|
+
const tail = document.createElement('span');
|
|
879
|
+
tail.textContent = (node.command || 'spawn') + ' · ' + node.spawnId.slice(-8);
|
|
880
|
+
name.appendChild(tail);
|
|
881
|
+
}
|
|
882
|
+
name.title = (node.description || node.spawnId) + '\n' + (node.startedAt || '');
|
|
883
|
+
const kill = document.createElement('button');
|
|
884
|
+
kill.className = 'kill';
|
|
885
|
+
kill.textContent = 'kill';
|
|
886
|
+
kill.disabled = node.status !== 'running' || !node.workerPid;
|
|
887
|
+
kill.addEventListener('click', (ev) => {
|
|
888
|
+
ev.stopPropagation();
|
|
889
|
+
if (!confirm('SIGTERM spawn ' + node.spawnId + ' (pid ' + node.workerPid + ')?')) return;
|
|
890
|
+
fetch('/transcript/' + encodeURIComponent(node.spawnId) + '/kill', { method: 'POST' })
|
|
891
|
+
.then((r) => r.json())
|
|
892
|
+
.then((j) => { kill.textContent = j.status || 'killed'; })
|
|
893
|
+
.catch(() => { kill.textContent = 'err'; });
|
|
894
|
+
});
|
|
895
|
+
el.appendChild(dot); el.appendChild(name); el.appendChild(kill);
|
|
896
|
+
el.addEventListener('click', () => {
|
|
897
|
+
if (node.spawnId === currentId) return;
|
|
898
|
+
_ssSet(SS_KEY_SELECTED, node.spawnId);
|
|
899
|
+
location.hash = node.spawnId;
|
|
900
|
+
});
|
|
901
|
+
container.appendChild(el);
|
|
902
|
+
}
|
|
903
|
+
window.__gsdtRenderRailEntry = renderRailEntry;
|
|
904
|
+
|
|
905
|
+
const COMPLETED_RAIL_CAP = 100;
|
|
906
|
+
// M47 D1 — top-level bucketing into the 3 rail sections.
|
|
907
|
+
// Bucketing rules (per m47-integration-points.md):
|
|
908
|
+
// - in-session-* AND most-recent → Main Session (★)
|
|
909
|
+
// - status === 'active' → Live Spawns
|
|
910
|
+
// - any other (default 'completed') → Completed (capped 100)
|
|
911
|
+
function bucketAndRender(spawns) {
|
|
912
|
+
const mainBody = document.getElementById('rail-main-body');
|
|
913
|
+
const liveTree = document.getElementById('tree');
|
|
914
|
+
const completedBody = document.getElementById('rail-completed-body');
|
|
915
|
+
if (!mainBody || !liveTree || !completedBody) return;
|
|
916
|
+
const sortedByMtime = spawns.slice().sort(
|
|
917
|
+
(a, b) => (Date.parse(b.lastUpdatedAt || b.startedAt) || 0) - (Date.parse(a.lastUpdatedAt || a.startedAt) || 0)
|
|
918
|
+
);
|
|
919
|
+
const inSessionSorted = sortedByMtime.filter((s) => typeof s.spawnId === 'string' && s.spawnId.indexOf('in-session-') === 0);
|
|
920
|
+
const mainEntry = inSessionSorted[0] || null;
|
|
921
|
+
const others = spawns.filter((s) => s !== mainEntry);
|
|
922
|
+
// Live: status === 'active' OR (status absent AND not terminal — preserve
|
|
923
|
+
// legacy spawn-index entries that never had a status field).
|
|
924
|
+
const live = [];
|
|
925
|
+
const completed = [];
|
|
926
|
+
for (const s of others) {
|
|
927
|
+
if (s.status === 'completed' || s.status === 'success' || s.status === 'failed' || s.status === 'killed') {
|
|
928
|
+
completed.push(s);
|
|
929
|
+
} else {
|
|
930
|
+
live.push(s);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
// Main rail body
|
|
934
|
+
mainBody.innerHTML = '';
|
|
935
|
+
if (mainEntry) {
|
|
936
|
+
renderRailEntry(mainEntry, mainBody, { showBadge: false });
|
|
937
|
+
} else {
|
|
938
|
+
const e = document.createElement('div'); e.className = 'empty';
|
|
939
|
+
e.textContent = 'No in-session conversation captured yet.';
|
|
940
|
+
mainBody.appendChild(e);
|
|
941
|
+
}
|
|
942
|
+
// Live rail (legacy #tree mount — preserves existing tests / selectors).
|
|
943
|
+
// Build a parent-indented tree from `live` only so completed-only
|
|
944
|
+
// entries don't duplicate.
|
|
945
|
+
renderTree(buildTree(live));
|
|
946
|
+
// Completed rail (capped 100, newest first by startedAt desc).
|
|
947
|
+
completedBody.innerHTML = '';
|
|
948
|
+
const completedSorted = completed
|
|
949
|
+
.slice()
|
|
950
|
+
.sort((a, b) => (Date.parse(b.startedAt) || 0) - (Date.parse(a.startedAt) || 0))
|
|
951
|
+
.slice(0, COMPLETED_RAIL_CAP);
|
|
952
|
+
if (completedSorted.length === 0) {
|
|
953
|
+
const e = document.createElement('div'); e.className = 'empty';
|
|
954
|
+
e.textContent = 'No completed spawns yet.';
|
|
955
|
+
completedBody.appendChild(e);
|
|
956
|
+
} else {
|
|
957
|
+
for (const s of completedSorted) renderRailEntry(s, completedBody, { showBadge: true });
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
window.__gsdtBucketAndRender = bucketAndRender;
|
|
961
|
+
|
|
638
962
|
let pollTimer = null;
|
|
639
963
|
function pollSpawns() {
|
|
640
964
|
fetch('/transcripts')
|
|
641
965
|
.then((r) => r.json())
|
|
642
966
|
.then((j) => {
|
|
643
967
|
const spawns = j.spawns || [];
|
|
644
|
-
|
|
968
|
+
bucketAndRender(spawns);
|
|
645
969
|
maybeAutoFollow(spawns);
|
|
646
970
|
})
|
|
647
971
|
.catch(() => { /* keep last render */ });
|
|
@@ -740,9 +1064,27 @@
|
|
|
740
1064
|
window.__gsdtFetchToolCost = fetchToolCost;
|
|
741
1065
|
|
|
742
1066
|
// ── SSE connection (reconnectable on hash change) ───────────────────
|
|
1067
|
+
//
|
|
1068
|
+
// M47 D1: dual-pane wiring.
|
|
1069
|
+
// - connect(id) → bottom pane (#stream inside #spawn-stream)
|
|
1070
|
+
// - connectMain(sid) → top pane (#main-stream)
|
|
1071
|
+
//
|
|
1072
|
+
// The two channels are independent — each owns its own EventSource. The
|
|
1073
|
+
// bottom pane preserves all M42/M43 behavior (auto-follow, hash
|
|
1074
|
+
// bookmarks, tool-cost refresh). The top pane is read-only-style: it
|
|
1075
|
+
// renders the in-session conversation NDJSON via /transcript/in-session-{sid}/stream.
|
|
743
1076
|
|
|
744
1077
|
let src = null;
|
|
745
1078
|
function connect(id) {
|
|
1079
|
+
if (!id) {
|
|
1080
|
+
// Empty bottom pane — show the click-to-focus empty state.
|
|
1081
|
+
if (src) { try { src.close(); } catch { /* gone */ } src = null; }
|
|
1082
|
+
stream.innerHTML = '<div class="pane-empty">Click any spawn in the rail to focus it here.</div>';
|
|
1083
|
+
toolUseById.clear();
|
|
1084
|
+
document.getElementById('hdr-spawn-id').textContent = '';
|
|
1085
|
+
setStatus('', 'idle');
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
746
1088
|
if (src) { try { src.close(); } catch { /* gone */ } src = null; }
|
|
747
1089
|
stream.innerHTML = '';
|
|
748
1090
|
toolUseById.clear();
|
|
@@ -750,6 +1092,8 @@
|
|
|
750
1092
|
jumpBtn.classList.remove('visible');
|
|
751
1093
|
document.getElementById('hdr-spawn-id').textContent = id;
|
|
752
1094
|
setStatus('', 'connecting…');
|
|
1095
|
+
// M47 D1 — persist selection across reload.
|
|
1096
|
+
_ssSet(SS_KEY_SELECTED, id);
|
|
753
1097
|
// M43 D6 — kick off an initial tool-cost fetch for this spawn.
|
|
754
1098
|
fetchToolCost(id);
|
|
755
1099
|
setToolCostLive(false);
|
|
@@ -764,6 +1108,7 @@
|
|
|
764
1108
|
const arrivedAt = new Date();
|
|
765
1109
|
try {
|
|
766
1110
|
const frame = JSON.parse(ev.data);
|
|
1111
|
+
// Bottom pane = default renderTarget = #stream.
|
|
767
1112
|
renderFrame(frame, arrivedAt);
|
|
768
1113
|
// M43 D6 — refresh tool-cost on turn-complete frames (debounced).
|
|
769
1114
|
// Various producers emit different turn-complete markers; accept
|
|
@@ -780,14 +1125,74 @@
|
|
|
780
1125
|
};
|
|
781
1126
|
}
|
|
782
1127
|
|
|
1128
|
+
// M47 D1 — top pane: main in-session conversation stream. Independent
|
|
1129
|
+
// from the bottom pane's `connect()`. Uses its own EventSource and
|
|
1130
|
+
// renders into #main-stream via the renderTarget swap inside renderFrame.
|
|
1131
|
+
let mainSrc = null;
|
|
1132
|
+
const mainStreamEl = document.getElementById('main-stream');
|
|
1133
|
+
function connectMain(sessionId) {
|
|
1134
|
+
if (mainSrc) { try { mainSrc.close(); } catch { /* gone */ } mainSrc = null; }
|
|
1135
|
+
if (!mainStreamEl) return;
|
|
1136
|
+
mainStreamEl.innerHTML = '';
|
|
1137
|
+
if (!sessionId) {
|
|
1138
|
+
mainStreamEl.innerHTML = '<div class="pane-empty">No in-session conversation captured yet.</div>';
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
const spawnId = 'in-session-' + sessionId;
|
|
1142
|
+
try {
|
|
1143
|
+
mainSrc = new EventSource('/transcript/' + encodeURIComponent(spawnId) + '/stream');
|
|
1144
|
+
mainSrc.onmessage = (ev) => {
|
|
1145
|
+
if (!ev.data) return;
|
|
1146
|
+
const arrivedAt = new Date();
|
|
1147
|
+
try {
|
|
1148
|
+
const frame = JSON.parse(ev.data);
|
|
1149
|
+
renderFrame(frame, arrivedAt, mainStreamEl);
|
|
1150
|
+
} catch {
|
|
1151
|
+
const prev = renderTarget; renderTarget = mainStreamEl;
|
|
1152
|
+
try { renderRaw(ev.data, arrivedAt); } finally { renderTarget = prev; }
|
|
1153
|
+
}
|
|
1154
|
+
};
|
|
1155
|
+
// Errors are silent — the top pane is best-effort; bottom pane drives status.
|
|
1156
|
+
} catch { /* EventSource unsupported */ }
|
|
1157
|
+
}
|
|
1158
|
+
window.__gsdtConnectMain = connectMain;
|
|
1159
|
+
|
|
1160
|
+
function fetchMainSession() {
|
|
1161
|
+
const t0 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
|
|
1162
|
+
return fetch('/api/main-session')
|
|
1163
|
+
.then((r) => r.ok ? r.json() : null)
|
|
1164
|
+
.then((j) => {
|
|
1165
|
+
if (j && j.sessionId) {
|
|
1166
|
+
connectMain(j.sessionId);
|
|
1167
|
+
const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
|
|
1168
|
+
try { console.debug('[m47] top-pane connected in', Math.round(t1 - t0), 'ms'); } catch { /* ok */ }
|
|
1169
|
+
} else {
|
|
1170
|
+
connectMain(null);
|
|
1171
|
+
}
|
|
1172
|
+
})
|
|
1173
|
+
.catch(() => { connectMain(null); });
|
|
1174
|
+
}
|
|
1175
|
+
|
|
783
1176
|
window.addEventListener('hashchange', () => {
|
|
784
1177
|
const id = (location.hash || '').slice(1);
|
|
785
1178
|
if (id) { connect(id); pollSpawns(); }
|
|
786
1179
|
});
|
|
787
1180
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
connect
|
|
1181
|
+
// M47 D1 — initial bottom-pane resolution:
|
|
1182
|
+
// 1. data-spawn-id non-empty → connect that (bookmark flow)
|
|
1183
|
+
// 2. else sessionStorage.selectedSpawnId → connect that
|
|
1184
|
+
// 3. else show empty state
|
|
1185
|
+
let initialBottomId = '';
|
|
1186
|
+
if (spawnId) {
|
|
1187
|
+
initialBottomId = spawnId;
|
|
1188
|
+
} else {
|
|
1189
|
+
initialBottomId = _ssGet(SS_KEY_SELECTED) || '';
|
|
1190
|
+
}
|
|
1191
|
+
if (initialBottomId && !location.hash) location.hash = initialBottomId;
|
|
1192
|
+
connect(initialBottomId);
|
|
1193
|
+
|
|
1194
|
+
// M47 D1 — top pane: always populate via /api/main-session.
|
|
1195
|
+
fetchMainSession();
|
|
791
1196
|
|
|
792
1197
|
pollSpawns();
|
|
793
1198
|
pollTimer = setInterval(pollSpawns, 3000);
|