@tekyzinc/gsd-t 3.20.13 → 3.21.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- 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 +71 -5
- package/scripts/gsd-t-transcript.html +562 -28
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [3.21.11] - 2026-05-06
|
|
6
|
+
|
|
7
|
+
### Fixed — viewer: 4 rendering regressions surfaced post-M47
|
|
8
|
+
|
|
9
|
+
The M47 viewer redesign shipped four user-visible rendering bugs that only became apparent when a project's in-session conversation was actually being viewed against a non-GSD-T project. Discovered when the dashboard for `Move-Zoom-Recordings-to-GDrive` showed three captured `in-session-*.ndjson` files but rendered them with a hardcoded "GSD-T Transcript" header, identical timestamps on every frame, raw `JSON.stringify` dumps in place of chat turns, and the same content in both top and bottom panes.
|
|
10
|
+
|
|
11
|
+
**Changes:**
|
|
12
|
+
- `scripts/gsd-t-dashboard-server.js`: `<title>` and `.title` div now carry a `__PROJECT_NAME__` placeholder substituted server-side via `path.basename(path.resolve(projectDir))` in both `handleTranscriptsList` and `handleTranscriptPage`. New `_escapeHtml()` helper escapes `<` / `&` / `"` in basenames; the substitution uses the function form of `replace` to defuse `$&` / `$1` / `$$` backreference patterns in basenames (Red Team BUG-1).
|
|
13
|
+
- `scripts/gsd-t-transcript.html`:
|
|
14
|
+
- `frameTs(frame, fallback)` parses each frame's ISO `ts` field and only falls back to the SSE-handler-captured `arrivedAt` when absent or invalid. `connect()` and `connectMain()` now thread `renderAt = frameTs(frame, arrivedAt)` to `renderFrame`. Initial-replay batches no longer collapse 200 distinct timestamps into one.
|
|
15
|
+
- 4 new render helpers (`renderUserTurn` / `renderAssistantTurn` / `renderSessionStart` / `renderToolUseLine`) plus dispatch arms in `renderFrameInner` BEFORE the `JSON.stringify` fallback. New CSS for `.frame.assistant-turn` (green border-left), `.frame.session-start` (small inline badge), `.frame.tool-call-line`, `.frame.truncated-tag`. `user_turn` reuses `.frame.user` bubble styling. Truncated content gets a "(truncated)" tag.
|
|
16
|
+
- 5 separate guards keep `in-session-*` ids out of the bottom pane: `renderRailEntry` click handler returns early on `isInSession`; initial bottom-pane resolution scrubs `in-session-*` from `SS_KEY_SELECTED` sessionStorage before `connect()`; `hashchange` handler returns early; `maybeAutoFollow` filters in-session spawns out; legacy `renderTree` click handler in the live-bucket fallback path also gets the guard (Red Team BUG-2).
|
|
17
|
+
- `test/m48-viewer-rendering-fixes.test.js`: 23 new regression tests — 5 Bug-1, 5 Bug-2 (incl. functional `frameTs` eval-extract probe), 7 Bug-3, 5 Bug-4, 1 functional probe (Red Team test-quality concern). Includes explicit `$&` and `$1` regression tests for the BUG-1 fix.
|
|
18
|
+
- `test/m44-transcript-timestamp.test.js`: updated for the `renderAt` / `arrivedAt` rename — semantics preserved (`arrivedAt` is now the fallback layer beneath parsed `frame.ts`).
|
|
19
|
+
|
|
20
|
+
**Migration:** existing dashboards pick up the new code on next refresh after `gsd-t update-all` propagates the package; the per-project transcript page reflects the project's directory basename automatically. No state migration.
|
|
21
|
+
|
|
22
|
+
**Suite:** 2083/2083 pass — both pre-existing M47-baseline flakes resolved on the release run.
|
|
23
|
+
|
|
24
|
+
**Red Team adversarial QA (opus):** initial sweep found 1 MEDIUM (`$&`-corruption in basename → fixed via function-form replace) + 1 LOW (legacy `renderTree` click handler → fixed via `isInSession` guard) + 1 test-quality recommendation (addressed via functional `frameTs` probe). Re-verification: GRUDGING PASS — no new bugs introduced.
|
|
25
|
+
|
|
5
26
|
## [3.20.13] - 2026-05-05
|
|
6
27
|
|
|
7
28
|
### Fixed — visualizer: surface in-session NDJSONs when `.index.json` is empty
|
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.11",
|
|
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
|
|
|
@@ -235,7 +273,14 @@ function handleTranscriptsList(req, res, projectDir, transcriptHtmlPath) {
|
|
|
235
273
|
// viewer's initialId logic falls through to location.hash (also empty)
|
|
236
274
|
// and connect('') is a no-op beyond a 404 SSE attempt — harmless, since
|
|
237
275
|
// the left rail polls /api/spawns-index independently.
|
|
238
|
-
const
|
|
276
|
+
const projectName = path.basename(path.resolve(projectDir || "."));
|
|
277
|
+
// Function-form replacement: a string replacement would interpret
|
|
278
|
+
// `$&`, `$1`, `$$`, etc. in the project basename as backreferences,
|
|
279
|
+
// re-injecting the placeholder or fragments of it (Red Team BUG-1).
|
|
280
|
+
const escapedName = _escapeHtml(projectName);
|
|
281
|
+
const html = data.toString("utf8")
|
|
282
|
+
.replace(/__SPAWN_ID__/g, () => "")
|
|
283
|
+
.replace(/__PROJECT_NAME__/g, () => escapedName);
|
|
239
284
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
240
285
|
res.end(html);
|
|
241
286
|
});
|
|
@@ -245,18 +290,36 @@ function handleTranscriptsList(req, res, projectDir, transcriptHtmlPath) {
|
|
|
245
290
|
res.end(JSON.stringify({ spawns: sorted }));
|
|
246
291
|
}
|
|
247
292
|
|
|
248
|
-
function handleTranscriptPage(req, res, spawnId, transcriptHtmlPath) {
|
|
293
|
+
function handleTranscriptPage(req, res, spawnId, transcriptHtmlPath, projectDir) {
|
|
249
294
|
if (!isValidSpawnId(spawnId)) { res.writeHead(400); res.end("Invalid spawn id"); return; }
|
|
250
295
|
fs.readFile(transcriptHtmlPath, (err, data) => {
|
|
251
296
|
if (err) { res.writeHead(404); res.end("Transcript UI not found"); return; }
|
|
252
297
|
// Inject the spawn-id as a data attribute on <body> by string replacement;
|
|
253
298
|
// the HTML ships with a placeholder `data-spawn-id="__SPAWN_ID__"`.
|
|
254
|
-
const
|
|
299
|
+
const projectName = path.basename(path.resolve(projectDir || "."));
|
|
300
|
+
// Function-form replacement: see comment in handleTranscriptsList. Even
|
|
301
|
+
// though isValidSpawnId guards spawnId against `$`, defence in depth.
|
|
302
|
+
const escapedName = _escapeHtml(projectName);
|
|
303
|
+
const html = data.toString("utf8")
|
|
304
|
+
.replace(/__SPAWN_ID__/g, () => spawnId)
|
|
305
|
+
.replace(/__PROJECT_NAME__/g, () => escapedName);
|
|
255
306
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
256
307
|
res.end(html);
|
|
257
308
|
});
|
|
258
309
|
}
|
|
259
310
|
|
|
311
|
+
// HTML-escape just enough to make a directory basename safe in <title> and
|
|
312
|
+
// <div class="title">. Project basenames effectively never contain quotes or
|
|
313
|
+
// angle brackets, but we still escape to keep the surface tight.
|
|
314
|
+
function _escapeHtml(s) {
|
|
315
|
+
return String(s == null ? "" : s)
|
|
316
|
+
.replace(/&/g, "&")
|
|
317
|
+
.replace(/</g, "<")
|
|
318
|
+
.replace(/>/g, ">")
|
|
319
|
+
.replace(/"/g, """)
|
|
320
|
+
.replace(/'/g, "'");
|
|
321
|
+
}
|
|
322
|
+
|
|
260
323
|
function tailTranscriptFile(filePath, callback) {
|
|
261
324
|
let offset = 0;
|
|
262
325
|
let buf = "";
|
|
@@ -721,6 +784,8 @@ function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath)
|
|
|
721
784
|
if (url === "/ping") return handlePing(req, res, port);
|
|
722
785
|
if (url === "/stop") return handleStop(req, res, server);
|
|
723
786
|
if (url === "/transcripts") return handleTranscriptsList(req, res, projDir, tHtmlPath);
|
|
787
|
+
// M47 D2 — most-recent in-session NDJSON for the viewer top-pane default load
|
|
788
|
+
if (url === "/api/main-session") return handleMainSession(req, res, projDir);
|
|
724
789
|
// M44 D8 — spawn plans: GET list + SSE change stream
|
|
725
790
|
if (url === "/api/spawn-plans") return handleSpawnPlans(req, res, projDir);
|
|
726
791
|
if (url === "/api/spawn-plans/stream") return handleSpawnPlanUpdates(req, res, projDir);
|
|
@@ -743,7 +808,7 @@ function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath)
|
|
|
743
808
|
if (streamMatch) return handleTranscriptStream(req, res, decodeURIComponent(streamMatch[1]), projDir);
|
|
744
809
|
// /transcript/:spawnId — HTML viewer page
|
|
745
810
|
const pageMatch = url.match(/^\/transcript\/([^/]+)$/);
|
|
746
|
-
if (pageMatch) return handleTranscriptPage(req, res, decodeURIComponent(pageMatch[1]), tHtmlPath);
|
|
811
|
+
if (pageMatch) return handleTranscriptPage(req, res, decodeURIComponent(pageMatch[1]), tHtmlPath, projDir);
|
|
747
812
|
res.writeHead(404); res.end("Not found");
|
|
748
813
|
});
|
|
749
814
|
server.listen(port);
|
|
@@ -762,6 +827,7 @@ module.exports = {
|
|
|
762
827
|
readIndexEntry,
|
|
763
828
|
isValidSpawnId,
|
|
764
829
|
listInSessionTranscripts,
|
|
830
|
+
handleMainSession,
|
|
765
831
|
handleTranscriptsList,
|
|
766
832
|
handleTranscriptStream,
|
|
767
833
|
handleTranscriptPage,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
-
<title>
|
|
6
|
+
<title>__PROJECT_NAME__</title>
|
|
7
7
|
<style>
|
|
8
8
|
:root {
|
|
9
9
|
--bg: #0d1117;
|
|
@@ -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; }
|
|
@@ -146,6 +183,17 @@
|
|
|
146
183
|
.frame.boundary.done .label { color: var(--green); }
|
|
147
184
|
.frame.boundary.failed .label { color: var(--red); }
|
|
148
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; }
|
|
149
197
|
|
|
150
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; }
|
|
151
199
|
.jump-to-live.visible { display: block; }
|
|
@@ -165,14 +213,31 @@
|
|
|
165
213
|
</head>
|
|
166
214
|
<body data-spawn-id="__SPAWN_ID__">
|
|
167
215
|
<header>
|
|
168
|
-
<div class="title">
|
|
216
|
+
<div class="title">__PROJECT_NAME__</div>
|
|
169
217
|
<div class="spawn-id" id="hdr-spawn-id"></div>
|
|
170
218
|
<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>
|
|
171
219
|
<div class="status" id="hdr-status"><span class="dot"></span><span class="label">connecting…</span></div>
|
|
172
220
|
</header>
|
|
173
221
|
<aside class="left-rail">
|
|
174
|
-
|
|
175
|
-
|
|
222
|
+
<!-- M47 D1 — three pre-rendered sections. Bucketing logic in Task 5
|
|
223
|
+
consumes D2's `status` field; until then, in-session entries land
|
|
224
|
+
in Main Session and everything else falls through to Live Spawns. -->
|
|
225
|
+
<section class="rail-main" data-rail-section="main">
|
|
226
|
+
<div class="rail-header"><span>★ Main Session</span></div>
|
|
227
|
+
<div class="rail-body" id="rail-main-body"><div class="empty">No in-session conversation captured yet.</div></div>
|
|
228
|
+
</section>
|
|
229
|
+
<section class="rail-live" data-rail-section="live">
|
|
230
|
+
<div class="rail-header"><span>Live Spawns</span></div>
|
|
231
|
+
<div class="rail-body">
|
|
232
|
+
<!-- Legacy mount point: existing renderTree() writes into #tree as a
|
|
233
|
+
no-op fallback while Task 5 bucketing rolls out. -->
|
|
234
|
+
<div class="tree" id="tree"></div>
|
|
235
|
+
</div>
|
|
236
|
+
</section>
|
|
237
|
+
<section class="rail-completed" data-rail-section="completed" data-expanded="true">
|
|
238
|
+
<div class="rail-header"><span>Completed</span><button class="chevron" id="rail-completed-toggle" aria-label="Toggle completed section">▾</button></div>
|
|
239
|
+
<div class="rail-body" id="rail-completed-body"><div class="empty">No completed spawns yet.</div></div>
|
|
240
|
+
</section>
|
|
176
241
|
<details class="panel" id="tool-cost-panel" open>
|
|
177
242
|
<summary>Tool Cost <span class="live-badge" id="tool-cost-live">off</span></summary>
|
|
178
243
|
<div class="panel-body" id="tool-cost-body">
|
|
@@ -180,11 +245,28 @@
|
|
|
180
245
|
</div>
|
|
181
246
|
</details>
|
|
182
247
|
</aside>
|
|
183
|
-
<main
|
|
248
|
+
<main>
|
|
249
|
+
<!-- M47 D1 — top pane: main in-session conversation (zero-click default).
|
|
250
|
+
Wired by Task 4 to /api/main-session + /transcript/in-session-:id/stream. -->
|
|
251
|
+
<section id="main-stream" aria-label="Main session conversation">
|
|
252
|
+
<div class="pane-empty">No in-session conversation captured yet.</div>
|
|
253
|
+
</section>
|
|
254
|
+
<!-- Splitter: keyboard accessible (ArrowUp/ArrowDown nudge ±5%, Home/End snap). -->
|
|
255
|
+
<div class="splitter" id="splitter" role="separator" tabindex="0" aria-orientation="horizontal" aria-label="Resize main vs spawn pane"></div>
|
|
256
|
+
<!-- Bottom pane: user-selected spawn. Existing #stream id preserved so
|
|
257
|
+
legacy selectors keep working — it now lives inside #spawn-stream. -->
|
|
258
|
+
<section id="spawn-stream" aria-label="Selected spawn transcript">
|
|
259
|
+
<div id="stream"></div>
|
|
260
|
+
</section>
|
|
261
|
+
</main>
|
|
184
262
|
<!-- M44 D8 — Spawn Plan panel: Layer 1 (project) + Layer 2 (active spawn).
|
|
185
|
-
Additive; does not change the transcript stream rendering.
|
|
186
|
-
|
|
187
|
-
|
|
263
|
+
Additive; does not change the transcript stream rendering.
|
|
264
|
+
M47 D1 — wrapped in <aside class="spawn-panel" data-collapsed> with a toggle. -->
|
|
265
|
+
<aside class="spawn-panel" id="spawn-plan-panel" data-collapsed="false">
|
|
266
|
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
|
|
267
|
+
<h2 style="margin:0;">Spawn Plan</h2>
|
|
268
|
+
<button class="panel-toggle" id="spawn-panel-toggle" aria-label="Collapse right rail">⟶</button>
|
|
269
|
+
</div>
|
|
188
270
|
<section id="spawn-layer-project">
|
|
189
271
|
<div class="sec-head"><span class="sec-title" id="project-title">Project</span><span class="sec-sub" id="project-sub">loading…</span></div>
|
|
190
272
|
<div class="sec-totals" id="project-totals">—</div>
|
|
@@ -224,6 +306,116 @@
|
|
|
224
306
|
const statusLabel = statusEl.querySelector('.label');
|
|
225
307
|
const jumpBtn = document.getElementById('jump-btn');
|
|
226
308
|
|
|
309
|
+
// M47 D1 — sessionStorage keys (centralized so tests can grep them).
|
|
310
|
+
const SS_KEY_SELECTED = 'gsd-t.viewer.selectedSpawnId';
|
|
311
|
+
const SS_KEY_SPLITTER = 'gsd-t.viewer.splitterPct';
|
|
312
|
+
const SS_KEY_COMPLETED = 'gsd-t.viewer.completedExpanded';
|
|
313
|
+
const SS_KEY_RIGHT_RAIL = 'gsd-t.viewer.rightRailCollapsed';
|
|
314
|
+
|
|
315
|
+
// M47 D1 — sessionStorage helpers: wrapped so private-mode (or test
|
|
316
|
+
// sandboxes that don't provide a sessionStorage shim) fall through to
|
|
317
|
+
// safe defaults instead of throwing during IIFE init.
|
|
318
|
+
function _ssGet(key) { try { return (typeof sessionStorage !== 'undefined') ? sessionStorage.getItem(key) : null; } catch { return null; } }
|
|
319
|
+
function _ssSet(key, value) { try { if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(key, value); } catch { /* private mode */ } }
|
|
320
|
+
|
|
321
|
+
// M47 D1 — splitter: restore persisted pct BEFORE first paint to avoid layout shift.
|
|
322
|
+
(function initSplitterPct() {
|
|
323
|
+
const raw = _ssGet(SS_KEY_SPLITTER);
|
|
324
|
+
let pct = 50;
|
|
325
|
+
const n = parseFloat(raw);
|
|
326
|
+
if (isFinite(n)) pct = Math.max(10, Math.min(90, n));
|
|
327
|
+
if (document.documentElement && document.documentElement.style) {
|
|
328
|
+
document.documentElement.style.setProperty('--main-pane-pct', String(pct));
|
|
329
|
+
}
|
|
330
|
+
})();
|
|
331
|
+
|
|
332
|
+
// M47 D1 — right-rail collapse: restore persisted state.
|
|
333
|
+
(function initRightRailCollapsed() {
|
|
334
|
+
const flag = _ssGet(SS_KEY_RIGHT_RAIL) === '1';
|
|
335
|
+
if (document.body && document.body.setAttribute) {
|
|
336
|
+
document.body.setAttribute('data-right-rail-collapsed', flag ? 'true' : 'false');
|
|
337
|
+
}
|
|
338
|
+
const panel = document.getElementById('spawn-plan-panel');
|
|
339
|
+
if (panel && panel.setAttribute) panel.setAttribute('data-collapsed', flag ? 'true' : 'false');
|
|
340
|
+
})();
|
|
341
|
+
|
|
342
|
+
// M47 D1 — completed section: restore persisted state.
|
|
343
|
+
(function initCompletedExpanded() {
|
|
344
|
+
const flagRaw = _ssGet(SS_KEY_COMPLETED);
|
|
345
|
+
const expanded = flagRaw === null ? true : flagRaw === '1';
|
|
346
|
+
const sec = (document.querySelector ? document.querySelector('section.rail-completed') : null);
|
|
347
|
+
if (sec && sec.setAttribute) sec.setAttribute('data-expanded', expanded ? 'true' : 'false');
|
|
348
|
+
})();
|
|
349
|
+
|
|
350
|
+
// M47 D1 — splitter wiring (mouse + keyboard).
|
|
351
|
+
(function wireSplitter() {
|
|
352
|
+
const splitter = document.getElementById('splitter');
|
|
353
|
+
if (!splitter || !splitter.addEventListener) return;
|
|
354
|
+
function setPct(pct) {
|
|
355
|
+
const clamped = Math.max(10, Math.min(90, pct));
|
|
356
|
+
if (document.documentElement && document.documentElement.style) {
|
|
357
|
+
document.documentElement.style.setProperty('--main-pane-pct', String(clamped));
|
|
358
|
+
}
|
|
359
|
+
_ssSet(SS_KEY_SPLITTER, String(clamped));
|
|
360
|
+
}
|
|
361
|
+
let dragging = false;
|
|
362
|
+
function onMove(ev) {
|
|
363
|
+
if (!dragging) return;
|
|
364
|
+
const main = document.querySelector ? document.querySelector('body > main') : null;
|
|
365
|
+
if (!main || !main.getBoundingClientRect) return;
|
|
366
|
+
const r = main.getBoundingClientRect();
|
|
367
|
+
if (r.height <= 0) return;
|
|
368
|
+
const pct = ((ev.clientY - r.top) / r.height) * 100;
|
|
369
|
+
setPct(pct);
|
|
370
|
+
}
|
|
371
|
+
function onUp() { dragging = false; if (document.body && document.body.style) document.body.style.cursor = ''; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }
|
|
372
|
+
splitter.addEventListener('mousedown', (ev) => {
|
|
373
|
+
ev.preventDefault();
|
|
374
|
+
dragging = true;
|
|
375
|
+
if (document.body && document.body.style) document.body.style.cursor = 'row-resize';
|
|
376
|
+
window.addEventListener('mousemove', onMove);
|
|
377
|
+
window.addEventListener('mouseup', onUp);
|
|
378
|
+
});
|
|
379
|
+
splitter.addEventListener('keydown', (ev) => {
|
|
380
|
+
let cur = 50;
|
|
381
|
+
if (typeof getComputedStyle === 'function' && document.documentElement) {
|
|
382
|
+
const v = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--main-pane-pct'));
|
|
383
|
+
if (isFinite(v)) cur = v;
|
|
384
|
+
}
|
|
385
|
+
if (ev.key === 'ArrowUp') { ev.preventDefault(); setPct(cur - 5); }
|
|
386
|
+
else if (ev.key === 'ArrowDown') { ev.preventDefault(); setPct(cur + 5); }
|
|
387
|
+
else if (ev.key === 'Home') { ev.preventDefault(); setPct(20); }
|
|
388
|
+
else if (ev.key === 'End') { ev.preventDefault(); setPct(80); }
|
|
389
|
+
});
|
|
390
|
+
})();
|
|
391
|
+
|
|
392
|
+
// M47 D1 — right-rail collapse toggle.
|
|
393
|
+
(function wireRightRailToggle() {
|
|
394
|
+
const btn = document.getElementById('spawn-panel-toggle');
|
|
395
|
+
const panel = document.getElementById('spawn-plan-panel');
|
|
396
|
+
if (!btn || !panel || !btn.addEventListener) return;
|
|
397
|
+
btn.addEventListener('click', () => {
|
|
398
|
+
const collapsed = panel.getAttribute && panel.getAttribute('data-collapsed') === 'true';
|
|
399
|
+
const next = !collapsed;
|
|
400
|
+
if (panel.setAttribute) panel.setAttribute('data-collapsed', next ? 'true' : 'false');
|
|
401
|
+
if (document.body && document.body.setAttribute) document.body.setAttribute('data-right-rail-collapsed', next ? 'true' : 'false');
|
|
402
|
+
_ssSet(SS_KEY_RIGHT_RAIL, next ? '1' : '0');
|
|
403
|
+
});
|
|
404
|
+
})();
|
|
405
|
+
|
|
406
|
+
// M47 D1 — completed section toggle.
|
|
407
|
+
(function wireCompletedToggle() {
|
|
408
|
+
const btn = document.getElementById('rail-completed-toggle');
|
|
409
|
+
const sec = document.querySelector ? document.querySelector('section.rail-completed') : null;
|
|
410
|
+
if (!btn || !sec || !btn.addEventListener) return;
|
|
411
|
+
btn.addEventListener('click', () => {
|
|
412
|
+
const expanded = sec.getAttribute && sec.getAttribute('data-expanded') !== 'false';
|
|
413
|
+
const next = !expanded;
|
|
414
|
+
if (sec.setAttribute) sec.setAttribute('data-expanded', next ? 'true' : 'false');
|
|
415
|
+
_ssSet(SS_KEY_COMPLETED, next ? '1' : '0');
|
|
416
|
+
});
|
|
417
|
+
})();
|
|
418
|
+
|
|
227
419
|
// Pair tool_result → tool_use by tool_use_id so the renderer can place
|
|
228
420
|
// the result next to the call even when they come as separate frames.
|
|
229
421
|
const toolUseById = new Map();
|
|
@@ -259,11 +451,27 @@
|
|
|
259
451
|
}
|
|
260
452
|
window.__gsdtFmtTs = fmtTs;
|
|
261
453
|
|
|
262
|
-
// Per-frame
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
454
|
+
// Per-frame timestamp. We prefer the producer-side `frame.ts`
|
|
455
|
+
// (ISO string written by the conversation-capture hook) when it
|
|
456
|
+
// parses cleanly, and only fall back to SSE arrival time when no
|
|
457
|
+
// `ts` field is present. Without this, an initial-load replay of
|
|
458
|
+
// N frames all arrives within one millisecond and every row shows
|
|
459
|
+
// the same HH:MM:SS — useless for spotting stuck or stale streams.
|
|
460
|
+
//
|
|
461
|
+
// M47 D1: appendFrame writes to a module-scope `renderTarget`. The
|
|
462
|
+
// renderFrame entry point swaps it per-call so the same code paths
|
|
463
|
+
// render into either the top pane (#main-stream — main-conversation
|
|
464
|
+
// SSE) or the bottom pane (#stream inside #spawn-stream — selected
|
|
465
|
+
// spawn SSE).
|
|
466
|
+
function frameTs(frame, fallback) {
|
|
467
|
+
if (frame && typeof frame.ts === 'string') {
|
|
468
|
+
const d = new Date(frame.ts);
|
|
469
|
+
if (!isNaN(d.getTime())) return d;
|
|
470
|
+
}
|
|
471
|
+
return fallback instanceof Date ? fallback : new Date();
|
|
472
|
+
}
|
|
473
|
+
window.__gsdtFrameTs = frameTs;
|
|
474
|
+
let renderTarget = stream;
|
|
267
475
|
function appendFrame(el, arrivedAt) {
|
|
268
476
|
const ts = document.createElement('span');
|
|
269
477
|
ts.className = 'ts';
|
|
@@ -272,9 +480,19 @@
|
|
|
272
480
|
// Insert as the first child so flex/inline layouts keep working.
|
|
273
481
|
if (el.firstChild) el.insertBefore(ts, el.firstChild);
|
|
274
482
|
else el.appendChild(ts);
|
|
275
|
-
stream.appendChild(el);
|
|
276
|
-
|
|
483
|
+
(renderTarget || stream).appendChild(el);
|
|
484
|
+
// Auto-scroll only applies to the bottom pane (where the user is
|
|
485
|
+
// actively focused). The top pane scrolls within itself; we let the
|
|
486
|
+
// user's scrollbar manage it without yanking focus.
|
|
487
|
+
if (renderTarget === stream && autoScroll) {
|
|
277
488
|
requestAnimationFrame(() => window.scrollTo(0, document.body.scrollHeight));
|
|
489
|
+
} else if (renderTarget && renderTarget !== stream) {
|
|
490
|
+
// Auto-scroll the top pane only when its viewport is at-bottom (similar
|
|
491
|
+
// logic to atBottom() but pane-local).
|
|
492
|
+
const pane = renderTarget.parentElement || renderTarget;
|
|
493
|
+
if (pane && pane.scrollHeight - pane.scrollTop - pane.clientHeight < 60) {
|
|
494
|
+
requestAnimationFrame(() => { pane.scrollTop = pane.scrollHeight; });
|
|
495
|
+
}
|
|
278
496
|
}
|
|
279
497
|
}
|
|
280
498
|
|
|
@@ -406,6 +624,74 @@
|
|
|
406
624
|
appendFrame(div, arrivedAt);
|
|
407
625
|
}
|
|
408
626
|
|
|
627
|
+
// M48 — chat-bubble renderers for the in-session conversation NDJSON.
|
|
628
|
+
// The conversation-capture hook emits frames of type
|
|
629
|
+
// `user_turn`, `assistant_turn`, `session_start`, and `tool_use`.
|
|
630
|
+
// Without explicit handling these fall through to renderRaw and the
|
|
631
|
+
// user sees a JSON.stringify dump per row.
|
|
632
|
+
function _appendTruncatedTag(div, frame) {
|
|
633
|
+
if (!frame || !frame.truncated) return;
|
|
634
|
+
const tag = document.createElement('span');
|
|
635
|
+
tag.className = 'truncated-tag';
|
|
636
|
+
tag.textContent = '(truncated)';
|
|
637
|
+
div.appendChild(tag);
|
|
638
|
+
}
|
|
639
|
+
function renderUserTurn(frame, arrivedAt) {
|
|
640
|
+
const div = document.createElement('div');
|
|
641
|
+
div.className = 'frame user user-turn';
|
|
642
|
+
const p = document.createElement('span');
|
|
643
|
+
p.className = 'prefix';
|
|
644
|
+
p.textContent = '>';
|
|
645
|
+
div.appendChild(p);
|
|
646
|
+
const body = document.createElement('span');
|
|
647
|
+
body.className = 'body';
|
|
648
|
+
body.textContent = (frame && typeof frame.content === 'string') ? frame.content : '';
|
|
649
|
+
div.appendChild(body);
|
|
650
|
+
_appendTruncatedTag(div, frame);
|
|
651
|
+
appendFrame(div, arrivedAt);
|
|
652
|
+
}
|
|
653
|
+
function renderAssistantTurn(frame, arrivedAt) {
|
|
654
|
+
const div = document.createElement('div');
|
|
655
|
+
div.className = 'frame assistant-turn';
|
|
656
|
+
const p = document.createElement('span');
|
|
657
|
+
p.className = 'prefix';
|
|
658
|
+
p.textContent = '⏺';
|
|
659
|
+
div.appendChild(p);
|
|
660
|
+
const body = document.createElement('span');
|
|
661
|
+
body.className = 'body';
|
|
662
|
+
body.textContent = (frame && typeof frame.content === 'string') ? frame.content : '';
|
|
663
|
+
div.appendChild(body);
|
|
664
|
+
_appendTruncatedTag(div, frame);
|
|
665
|
+
appendFrame(div, arrivedAt);
|
|
666
|
+
}
|
|
667
|
+
function renderSessionStart(frame, arrivedAt) {
|
|
668
|
+
const div = document.createElement('div');
|
|
669
|
+
div.className = 'frame session-start';
|
|
670
|
+
const badge = document.createElement('span');
|
|
671
|
+
badge.className = 'badge';
|
|
672
|
+
badge.textContent = '◆ session';
|
|
673
|
+
div.appendChild(badge);
|
|
674
|
+
if (frame && typeof frame.session_id === 'string') {
|
|
675
|
+
const sid = document.createElement('span');
|
|
676
|
+
sid.textContent = frame.session_id.slice(0, 8);
|
|
677
|
+
div.appendChild(sid);
|
|
678
|
+
}
|
|
679
|
+
appendFrame(div, arrivedAt);
|
|
680
|
+
}
|
|
681
|
+
function renderToolUseLine(frame, arrivedAt) {
|
|
682
|
+
const div = document.createElement('div');
|
|
683
|
+
div.className = 'frame tool-call-line';
|
|
684
|
+
const span = document.createElement('span');
|
|
685
|
+
const name = (frame && typeof frame.name === 'string') ? frame.name : 'tool';
|
|
686
|
+
span.textContent = '⎿ ' + name + '()';
|
|
687
|
+
div.appendChild(span);
|
|
688
|
+
appendFrame(div, arrivedAt);
|
|
689
|
+
}
|
|
690
|
+
window.__gsdtRenderUserTurn = renderUserTurn;
|
|
691
|
+
window.__gsdtRenderAssistantTurn = renderAssistantTurn;
|
|
692
|
+
window.__gsdtRenderSessionStart = renderSessionStart;
|
|
693
|
+
window.__gsdtRenderToolUseLine = renderToolUseLine;
|
|
694
|
+
|
|
409
695
|
function renderCompactMarker(frame, arrivedAt) {
|
|
410
696
|
const div = document.createElement('div');
|
|
411
697
|
div.className = 'frame compact-marker';
|
|
@@ -437,14 +723,34 @@
|
|
|
437
723
|
}
|
|
438
724
|
window.__gsdtRenderCompactMarker = renderCompactMarker;
|
|
439
725
|
|
|
440
|
-
|
|
726
|
+
// M47 D1: optional `target` element switches render destination for the
|
|
727
|
+
// duration of this call (top pane = #main-stream, bottom pane = #stream).
|
|
728
|
+
function renderFrame(frame, arrivedAt, target) {
|
|
441
729
|
if (!frame || typeof frame !== 'object') return;
|
|
442
|
-
const
|
|
730
|
+
const prev = renderTarget;
|
|
731
|
+
if (target) renderTarget = target;
|
|
732
|
+
try { return renderFrameInner(frame, arrivedAt); }
|
|
733
|
+
finally { renderTarget = prev; }
|
|
734
|
+
}
|
|
735
|
+
function renderFrameInner(frame, arrivedAt) {
|
|
736
|
+
if (!frame || typeof frame !== 'object') return;
|
|
737
|
+
// Defense in depth: if a caller passes a non-Date `arrivedAt`, still
|
|
738
|
+
// try to derive a real timestamp from frame.ts before falling back
|
|
739
|
+
// to "now". Without this guard, every renderFrame() call from a
|
|
740
|
+
// non-SSE path (e.g. tests, manual dispatch) collapses to one Date.
|
|
741
|
+
const ts = (arrivedAt instanceof Date && !isNaN(arrivedAt.getTime()))
|
|
742
|
+
? arrivedAt
|
|
743
|
+
: frameTs(frame, new Date());
|
|
443
744
|
const type = frame.type;
|
|
444
745
|
if (type === 'compact_marker') { renderCompactMarker(frame, ts); return; }
|
|
445
746
|
if (type === 'system') { renderSystem(frame, ts); return; }
|
|
446
747
|
if (type === 'task-boundary') { renderBoundary(frame, ts); return; }
|
|
447
748
|
if (type === 'raw') { renderRaw(frame.line || '', ts); return; }
|
|
749
|
+
// M48 — in-session conversation-capture frame types.
|
|
750
|
+
if (type === 'session_start') { renderSessionStart(frame, ts); return; }
|
|
751
|
+
if (type === 'user_turn') { renderUserTurn(frame, ts); return; }
|
|
752
|
+
if (type === 'assistant_turn') { renderAssistantTurn(frame, ts); return; }
|
|
753
|
+
if (type === 'tool_use') { renderToolUseLine(frame, ts); return; }
|
|
448
754
|
if (type === 'assistant' && frame.message && Array.isArray(frame.message.content)) {
|
|
449
755
|
for (const b of frame.message.content) {
|
|
450
756
|
if (b.type === 'text') renderAssistantText(b.text || '', ts);
|
|
@@ -564,6 +870,14 @@
|
|
|
564
870
|
});
|
|
565
871
|
el.appendChild(dot); el.appendChild(name); el.appendChild(kill);
|
|
566
872
|
el.addEventListener('click', () => {
|
|
873
|
+
// M48 — symmetric with renderRailEntry: in-session entries
|
|
874
|
+
// belong to the TOP pane only. Without this guard the legacy
|
|
875
|
+
// renderTree path (called for the `live` bucket when ≥2
|
|
876
|
+
// in-session NDJSONs exist) would mutate location.hash to an
|
|
877
|
+
// in-session-* value, polluting the URL and the rail's active
|
|
878
|
+
// highlight even though the hashchange handler now blocks
|
|
879
|
+
// bottom-pane SSE pinning.
|
|
880
|
+
if (isInSession(node)) return;
|
|
567
881
|
if (node.spawnId === currentId) return;
|
|
568
882
|
location.hash = node.spawnId;
|
|
569
883
|
});
|
|
@@ -615,9 +929,14 @@
|
|
|
615
929
|
function maybeAutoFollow(spawns) {
|
|
616
930
|
if (!autoFollowEl.checked) return;
|
|
617
931
|
const currentId = (location.hash || '').slice(1) || spawnId;
|
|
932
|
+
// M48 — auto-follow drives the BOTTOM pane (selected-spawn). The
|
|
933
|
+
// top pane is owned by /api/main-session and shows in-session-*
|
|
934
|
+
// entries automatically. Excluding them here prevents the auto-
|
|
935
|
+
// follow loop from also pinning them into the bottom pane.
|
|
618
936
|
// Most recent running spawn by startedAt (descending).
|
|
619
937
|
const running = spawns
|
|
620
938
|
.filter((s) => s.status === 'running')
|
|
939
|
+
.filter((s) => !(typeof s.spawnId === 'string' && s.spawnId.indexOf('in-session-') === 0))
|
|
621
940
|
.sort((a, b) => (Date.parse(b.startedAt) || 0) - (Date.parse(a.startedAt) || 0));
|
|
622
941
|
if (!running.length) return;
|
|
623
942
|
const latest = running[0];
|
|
@@ -635,13 +954,136 @@
|
|
|
635
954
|
pulseNode(latest.spawnId);
|
|
636
955
|
}
|
|
637
956
|
|
|
957
|
+
// M47 D1 — render a single rail entry (used by bucketing).
|
|
958
|
+
function renderRailEntry(node, container, opts) {
|
|
959
|
+
const isInSession = typeof node.spawnId === 'string' && node.spawnId.indexOf('in-session-') === 0;
|
|
960
|
+
const currentId = (location.hash || '').slice(1) || spawnId;
|
|
961
|
+
const el = document.createElement('div');
|
|
962
|
+
el.className = 'node ' + statusClass(node);
|
|
963
|
+
if (isInSession) el.classList.add('in-session');
|
|
964
|
+
if (node.spawnId === currentId) el.classList.add('active');
|
|
965
|
+
el.style.paddingLeft = '12px';
|
|
966
|
+
const dot = document.createElement('span'); dot.className = 'dot';
|
|
967
|
+
const name = document.createElement('span'); name.className = 'name';
|
|
968
|
+
// M47 D1 — status badge (gated to completed-section entries; others
|
|
969
|
+
// already use the running/stopped/failed `dot` for state).
|
|
970
|
+
if (opts && opts.showBadge) {
|
|
971
|
+
const badgeKind = (function () {
|
|
972
|
+
const s = node.status;
|
|
973
|
+
if (s === 'success' || s === 'failed' || s === 'killed') return s;
|
|
974
|
+
return 'completed'; // neutral fallback for any other terminal value
|
|
975
|
+
})();
|
|
976
|
+
const badge = document.createElement('span');
|
|
977
|
+
badge.className = 'badge-status ' + badgeKind;
|
|
978
|
+
badge.textContent = badgeKind;
|
|
979
|
+
name.appendChild(badge);
|
|
980
|
+
}
|
|
981
|
+
if (isInSession) {
|
|
982
|
+
const tag = document.createElement('span');
|
|
983
|
+
tag.className = 'label-in-session';
|
|
984
|
+
tag.textContent = '💬 conversation';
|
|
985
|
+
name.appendChild(tag);
|
|
986
|
+
const tail = document.createElement('span');
|
|
987
|
+
tail.textContent = ' · ' + node.spawnId.slice(-8);
|
|
988
|
+
name.appendChild(tail);
|
|
989
|
+
} else {
|
|
990
|
+
const tail = document.createElement('span');
|
|
991
|
+
tail.textContent = (node.command || 'spawn') + ' · ' + node.spawnId.slice(-8);
|
|
992
|
+
name.appendChild(tail);
|
|
993
|
+
}
|
|
994
|
+
name.title = (node.description || node.spawnId) + '\n' + (node.startedAt || '');
|
|
995
|
+
const kill = document.createElement('button');
|
|
996
|
+
kill.className = 'kill';
|
|
997
|
+
kill.textContent = 'kill';
|
|
998
|
+
kill.disabled = node.status !== 'running' || !node.workerPid;
|
|
999
|
+
kill.addEventListener('click', (ev) => {
|
|
1000
|
+
ev.stopPropagation();
|
|
1001
|
+
if (!confirm('SIGTERM spawn ' + node.spawnId + ' (pid ' + node.workerPid + ')?')) return;
|
|
1002
|
+
fetch('/transcript/' + encodeURIComponent(node.spawnId) + '/kill', { method: 'POST' })
|
|
1003
|
+
.then((r) => r.json())
|
|
1004
|
+
.then((j) => { kill.textContent = j.status || 'killed'; })
|
|
1005
|
+
.catch(() => { kill.textContent = 'err'; });
|
|
1006
|
+
});
|
|
1007
|
+
el.appendChild(dot); el.appendChild(name); el.appendChild(kill);
|
|
1008
|
+
el.addEventListener('click', () => {
|
|
1009
|
+
// M48 — in-session conversation entries belong in the TOP pane only.
|
|
1010
|
+
// The top pane is wired to /api/main-session and streams the
|
|
1011
|
+
// current orchestrator session. Routing them through location.hash
|
|
1012
|
+
// would also load them into the bottom pane (the SELECTED-SPAWN
|
|
1013
|
+
// pane), making both panes show identical content.
|
|
1014
|
+
if (isInSession) return;
|
|
1015
|
+
if (node.spawnId === currentId) return;
|
|
1016
|
+
_ssSet(SS_KEY_SELECTED, node.spawnId);
|
|
1017
|
+
location.hash = node.spawnId;
|
|
1018
|
+
});
|
|
1019
|
+
container.appendChild(el);
|
|
1020
|
+
}
|
|
1021
|
+
window.__gsdtRenderRailEntry = renderRailEntry;
|
|
1022
|
+
|
|
1023
|
+
const COMPLETED_RAIL_CAP = 100;
|
|
1024
|
+
// M47 D1 — top-level bucketing into the 3 rail sections.
|
|
1025
|
+
// Bucketing rules (per m47-integration-points.md):
|
|
1026
|
+
// - in-session-* AND most-recent → Main Session (★)
|
|
1027
|
+
// - status === 'active' → Live Spawns
|
|
1028
|
+
// - any other (default 'completed') → Completed (capped 100)
|
|
1029
|
+
function bucketAndRender(spawns) {
|
|
1030
|
+
const mainBody = document.getElementById('rail-main-body');
|
|
1031
|
+
const liveTree = document.getElementById('tree');
|
|
1032
|
+
const completedBody = document.getElementById('rail-completed-body');
|
|
1033
|
+
if (!mainBody || !liveTree || !completedBody) return;
|
|
1034
|
+
const sortedByMtime = spawns.slice().sort(
|
|
1035
|
+
(a, b) => (Date.parse(b.lastUpdatedAt || b.startedAt) || 0) - (Date.parse(a.lastUpdatedAt || a.startedAt) || 0)
|
|
1036
|
+
);
|
|
1037
|
+
const inSessionSorted = sortedByMtime.filter((s) => typeof s.spawnId === 'string' && s.spawnId.indexOf('in-session-') === 0);
|
|
1038
|
+
const mainEntry = inSessionSorted[0] || null;
|
|
1039
|
+
const others = spawns.filter((s) => s !== mainEntry);
|
|
1040
|
+
// Live: status === 'active' OR (status absent AND not terminal — preserve
|
|
1041
|
+
// legacy spawn-index entries that never had a status field).
|
|
1042
|
+
const live = [];
|
|
1043
|
+
const completed = [];
|
|
1044
|
+
for (const s of others) {
|
|
1045
|
+
if (s.status === 'completed' || s.status === 'success' || s.status === 'failed' || s.status === 'killed') {
|
|
1046
|
+
completed.push(s);
|
|
1047
|
+
} else {
|
|
1048
|
+
live.push(s);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
// Main rail body
|
|
1052
|
+
mainBody.innerHTML = '';
|
|
1053
|
+
if (mainEntry) {
|
|
1054
|
+
renderRailEntry(mainEntry, mainBody, { showBadge: false });
|
|
1055
|
+
} else {
|
|
1056
|
+
const e = document.createElement('div'); e.className = 'empty';
|
|
1057
|
+
e.textContent = 'No in-session conversation captured yet.';
|
|
1058
|
+
mainBody.appendChild(e);
|
|
1059
|
+
}
|
|
1060
|
+
// Live rail (legacy #tree mount — preserves existing tests / selectors).
|
|
1061
|
+
// Build a parent-indented tree from `live` only so completed-only
|
|
1062
|
+
// entries don't duplicate.
|
|
1063
|
+
renderTree(buildTree(live));
|
|
1064
|
+
// Completed rail (capped 100, newest first by startedAt desc).
|
|
1065
|
+
completedBody.innerHTML = '';
|
|
1066
|
+
const completedSorted = completed
|
|
1067
|
+
.slice()
|
|
1068
|
+
.sort((a, b) => (Date.parse(b.startedAt) || 0) - (Date.parse(a.startedAt) || 0))
|
|
1069
|
+
.slice(0, COMPLETED_RAIL_CAP);
|
|
1070
|
+
if (completedSorted.length === 0) {
|
|
1071
|
+
const e = document.createElement('div'); e.className = 'empty';
|
|
1072
|
+
e.textContent = 'No completed spawns yet.';
|
|
1073
|
+
completedBody.appendChild(e);
|
|
1074
|
+
} else {
|
|
1075
|
+
for (const s of completedSorted) renderRailEntry(s, completedBody, { showBadge: true });
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
window.__gsdtBucketAndRender = bucketAndRender;
|
|
1079
|
+
|
|
638
1080
|
let pollTimer = null;
|
|
639
1081
|
function pollSpawns() {
|
|
640
1082
|
fetch('/transcripts')
|
|
641
1083
|
.then((r) => r.json())
|
|
642
1084
|
.then((j) => {
|
|
643
1085
|
const spawns = j.spawns || [];
|
|
644
|
-
|
|
1086
|
+
bucketAndRender(spawns);
|
|
645
1087
|
maybeAutoFollow(spawns);
|
|
646
1088
|
})
|
|
647
1089
|
.catch(() => { /* keep last render */ });
|
|
@@ -740,9 +1182,27 @@
|
|
|
740
1182
|
window.__gsdtFetchToolCost = fetchToolCost;
|
|
741
1183
|
|
|
742
1184
|
// ── SSE connection (reconnectable on hash change) ───────────────────
|
|
1185
|
+
//
|
|
1186
|
+
// M47 D1: dual-pane wiring.
|
|
1187
|
+
// - connect(id) → bottom pane (#stream inside #spawn-stream)
|
|
1188
|
+
// - connectMain(sid) → top pane (#main-stream)
|
|
1189
|
+
//
|
|
1190
|
+
// The two channels are independent — each owns its own EventSource. The
|
|
1191
|
+
// bottom pane preserves all M42/M43 behavior (auto-follow, hash
|
|
1192
|
+
// bookmarks, tool-cost refresh). The top pane is read-only-style: it
|
|
1193
|
+
// renders the in-session conversation NDJSON via /transcript/in-session-{sid}/stream.
|
|
743
1194
|
|
|
744
1195
|
let src = null;
|
|
745
1196
|
function connect(id) {
|
|
1197
|
+
if (!id) {
|
|
1198
|
+
// Empty bottom pane — show the click-to-focus empty state.
|
|
1199
|
+
if (src) { try { src.close(); } catch { /* gone */ } src = null; }
|
|
1200
|
+
stream.innerHTML = '<div class="pane-empty">Click any spawn in the rail to focus it here.</div>';
|
|
1201
|
+
toolUseById.clear();
|
|
1202
|
+
document.getElementById('hdr-spawn-id').textContent = '';
|
|
1203
|
+
setStatus('', 'idle');
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
746
1206
|
if (src) { try { src.close(); } catch { /* gone */ } src = null; }
|
|
747
1207
|
stream.innerHTML = '';
|
|
748
1208
|
toolUseById.clear();
|
|
@@ -750,6 +1210,8 @@
|
|
|
750
1210
|
jumpBtn.classList.remove('visible');
|
|
751
1211
|
document.getElementById('hdr-spawn-id').textContent = id;
|
|
752
1212
|
setStatus('', 'connecting…');
|
|
1213
|
+
// M47 D1 — persist selection across reload.
|
|
1214
|
+
_ssSet(SS_KEY_SELECTED, id);
|
|
753
1215
|
// M43 D6 — kick off an initial tool-cost fetch for this spawn.
|
|
754
1216
|
fetchToolCost(id);
|
|
755
1217
|
setToolCostLive(false);
|
|
@@ -758,13 +1220,15 @@
|
|
|
758
1220
|
src.onerror = () => { setStatus('error', 'disconnected'); setToolCostLive(false); };
|
|
759
1221
|
src.onmessage = (ev) => {
|
|
760
1222
|
if (!ev.data) return;
|
|
761
|
-
//
|
|
762
|
-
//
|
|
763
|
-
//
|
|
1223
|
+
// Arrival time is the FALLBACK; we prefer the producer-side
|
|
1224
|
+
// frame.ts so a 200-frame initial replay shows actual event
|
|
1225
|
+
// times (spread out over minutes), not the same millisecond.
|
|
764
1226
|
const arrivedAt = new Date();
|
|
765
1227
|
try {
|
|
766
1228
|
const frame = JSON.parse(ev.data);
|
|
767
|
-
|
|
1229
|
+
const renderAt = frameTs(frame, arrivedAt);
|
|
1230
|
+
// Bottom pane = default renderTarget = #stream.
|
|
1231
|
+
renderFrame(frame, renderAt);
|
|
768
1232
|
// M43 D6 — refresh tool-cost on turn-complete frames (debounced).
|
|
769
1233
|
// Various producers emit different turn-complete markers; accept
|
|
770
1234
|
// any of them.
|
|
@@ -780,14 +1244,84 @@
|
|
|
780
1244
|
};
|
|
781
1245
|
}
|
|
782
1246
|
|
|
1247
|
+
// M47 D1 — top pane: main in-session conversation stream. Independent
|
|
1248
|
+
// from the bottom pane's `connect()`. Uses its own EventSource and
|
|
1249
|
+
// renders into #main-stream via the renderTarget swap inside renderFrame.
|
|
1250
|
+
let mainSrc = null;
|
|
1251
|
+
const mainStreamEl = document.getElementById('main-stream');
|
|
1252
|
+
function connectMain(sessionId) {
|
|
1253
|
+
if (mainSrc) { try { mainSrc.close(); } catch { /* gone */ } mainSrc = null; }
|
|
1254
|
+
if (!mainStreamEl) return;
|
|
1255
|
+
mainStreamEl.innerHTML = '';
|
|
1256
|
+
if (!sessionId) {
|
|
1257
|
+
mainStreamEl.innerHTML = '<div class="pane-empty">No in-session conversation captured yet.</div>';
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
const spawnId = 'in-session-' + sessionId;
|
|
1261
|
+
try {
|
|
1262
|
+
mainSrc = new EventSource('/transcript/' + encodeURIComponent(spawnId) + '/stream');
|
|
1263
|
+
mainSrc.onmessage = (ev) => {
|
|
1264
|
+
if (!ev.data) return;
|
|
1265
|
+
const arrivedAt = new Date();
|
|
1266
|
+
try {
|
|
1267
|
+
const frame = JSON.parse(ev.data);
|
|
1268
|
+
const renderAt = frameTs(frame, arrivedAt);
|
|
1269
|
+
renderFrame(frame, renderAt, mainStreamEl);
|
|
1270
|
+
} catch {
|
|
1271
|
+
const prev = renderTarget; renderTarget = mainStreamEl;
|
|
1272
|
+
try { renderRaw(ev.data, arrivedAt); } finally { renderTarget = prev; }
|
|
1273
|
+
}
|
|
1274
|
+
};
|
|
1275
|
+
// Errors are silent — the top pane is best-effort; bottom pane drives status.
|
|
1276
|
+
} catch { /* EventSource unsupported */ }
|
|
1277
|
+
}
|
|
1278
|
+
window.__gsdtConnectMain = connectMain;
|
|
1279
|
+
|
|
1280
|
+
function fetchMainSession() {
|
|
1281
|
+
const t0 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
|
|
1282
|
+
return fetch('/api/main-session')
|
|
1283
|
+
.then((r) => r.ok ? r.json() : null)
|
|
1284
|
+
.then((j) => {
|
|
1285
|
+
if (j && j.sessionId) {
|
|
1286
|
+
connectMain(j.sessionId);
|
|
1287
|
+
const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
|
|
1288
|
+
try { console.debug('[m47] top-pane connected in', Math.round(t1 - t0), 'ms'); } catch { /* ok */ }
|
|
1289
|
+
} else {
|
|
1290
|
+
connectMain(null);
|
|
1291
|
+
}
|
|
1292
|
+
})
|
|
1293
|
+
.catch(() => { connectMain(null); });
|
|
1294
|
+
}
|
|
1295
|
+
|
|
783
1296
|
window.addEventListener('hashchange', () => {
|
|
784
1297
|
const id = (location.hash || '').slice(1);
|
|
1298
|
+
// M48 — keep in-session-* ids out of the bottom pane (top pane only).
|
|
1299
|
+
if (id && id.indexOf('in-session-') === 0) { return; }
|
|
785
1300
|
if (id) { connect(id); pollSpawns(); }
|
|
786
1301
|
});
|
|
787
1302
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
connect
|
|
1303
|
+
// M47 D1 — initial bottom-pane resolution:
|
|
1304
|
+
// 1. data-spawn-id non-empty → connect that (bookmark flow)
|
|
1305
|
+
// 2. else sessionStorage.selectedSpawnId → connect that
|
|
1306
|
+
// 3. else show empty state
|
|
1307
|
+
// M48 — never seed the bottom pane with an in-session-* id; the top
|
|
1308
|
+
// pane already owns the main session, and showing it in both panes
|
|
1309
|
+
// is one of the regressions Bug 4 fixes.
|
|
1310
|
+
let initialBottomId = '';
|
|
1311
|
+
if (spawnId) {
|
|
1312
|
+
initialBottomId = spawnId;
|
|
1313
|
+
} else {
|
|
1314
|
+
initialBottomId = _ssGet(SS_KEY_SELECTED) || '';
|
|
1315
|
+
}
|
|
1316
|
+
if (typeof initialBottomId === 'string' && initialBottomId.indexOf('in-session-') === 0) {
|
|
1317
|
+
initialBottomId = '';
|
|
1318
|
+
_ssSet(SS_KEY_SELECTED, '');
|
|
1319
|
+
}
|
|
1320
|
+
if (initialBottomId && !location.hash) location.hash = initialBottomId;
|
|
1321
|
+
connect(initialBottomId);
|
|
1322
|
+
|
|
1323
|
+
// M47 D1 — top pane: always populate via /api/main-session.
|
|
1324
|
+
fetchMainSession();
|
|
791
1325
|
|
|
792
1326
|
pollSpawns();
|
|
793
1327
|
pollTimer = setInterval(pollSpawns, 3000);
|