From 3ab53202dd3d19cc4cdba85c6abee975d65e3d4d Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Fri, 11 Sep 2026 22:52:59 +0200 Subject: [PATCH] (session-state): local-transcript adapter for sessions launched outside the app Step 4 of #246. The main process turns transcript growth seen by the existing ~/.claude/projects watcher into a coalesced session-transcript-activity event for sessions that have no PTY in the app; the renderer adapter feeds a local-transcript state (busy on event, decay without arming response-ready, liveness and status from the CLI descriptor) into the shared projection, and hands the row to the local-pty path as soon as the user opens it. --- .ai/contexts/ipc-bridge.md | 3 +- .ai/contexts/session-cache.md | 1 + .ai/contexts/session-state.md | 88 ++++++++++-- eslint.config.js | 3 + local-transcript-activity.js | 38 ++++++ main.js | 16 +++ preload.js | 3 + public/app.js | 8 +- public/index.html | 1 + public/local-transcript-adapter.js | 84 ++++++++++++ test/dom-setup.js | 11 +- test/dom-sidebar-local-transcript.test.js | 83 +++++++++++ test/local-transcript-activity.test.js | 78 +++++++++++ test/local-transcript-adapter.test.js | 159 ++++++++++++++++++++++ test/main-local-transcript-wiring.test.js | 55 ++++++++ 15 files changed, 618 insertions(+), 13 deletions(-) create mode 100644 local-transcript-activity.js create mode 100644 public/local-transcript-adapter.js create mode 100644 test/dom-sidebar-local-transcript.test.js create mode 100644 test/local-transcript-activity.test.js create mode 100644 test/local-transcript-adapter.test.js create mode 100644 test/main-local-transcript-wiring.test.js diff --git a/.ai/contexts/ipc-bridge.md b/.ai/contexts/ipc-bridge.md index 16c700a3..d84d1178 100644 --- a/.ai/contexts/ipc-bridge.md +++ b/.ai/contexts/ipc-bridge.md @@ -100,9 +100,10 @@ This file is the **canonical inventory** of the IPC surface. When you add a new ### Events (main → renderer) -`terminal-data`, `session-detected`, `process-exited`, `terminal-notification`, `cli-busy-state`, `session-forked`, `subagent-spawned`, `subagent-completed`, `subagent-watch-event`, `projects-changed`, `status-update`, `indexing-progress`, `file-changed`, `mcp-open-diff`, `mcp-open-file`, `mcp-close-all-diffs`, `mcp-close-tab`, `updater-event` +`terminal-data`, `session-detected`, `process-exited`, `terminal-notification`, `cli-busy-state`, `session-forked`, `subagent-spawned`, `subagent-completed`, `subagent-watch-event`, `projects-changed`, `status-update`, `indexing-progress`, `file-changed`, `mcp-open-diff`, `mcp-open-file`, `mcp-close-all-diffs`, `mcp-close-tab`, `updater-event`, `session-transcript-activity` - **`indexing-progress`**: `{coldStart, current, total, sessionsSoFar, done, error?}`. Fired only from `populateCacheViaWorker()` when the `initial_scan_complete` marker was absent at call time (a genuine first launch, a post-migration reset, or the resume of an interrupted first scan) — never on a routine warm-start rebuild. Throttled to ~4 events/s; the first event and the final `done:true` always pass. `done:true` with `error` means the scan failed and the renderer shows the failure in the banner instead of hiding it. Drives the renderer's dismissible first-run banner (`public/app.js`'s `updateIndexingBanner`); see `.ai/contexts/session-cache.md`. +- **`session-transcript-activity`**: `{sessionId, at}`. Fired from inside the raw `fs.watch(PROJECTS_DIR, ...)` callback in `startProjectsWatcher()` — deliberately **not** routed through the debounced `flushChanges()` / `notifyRendererProjectsChanged()` path that also lives there, so it reaches the renderer well before the 500ms debounce plus the cache refresh it triggers. Only fires for a top-level session transcript (`/.jsonl`, two path segments — a subagent leg is out of scope, see `.ai/contexts/session-state.md` ports table) whose sessionId has no live PTY in `activeSessions` (`sessionHasPty()`, main.js — the OSC busy/attention path already owns a PTY-backed row). Coalesced to at most one emission per second per session by `local-transcript-activity.js`'s `createLocalTranscriptTracker()`. Feeds `public/local-transcript-adapter.js` (`window.api.onSessionTranscriptActivity`) — the `local-transcript` adapter in `.ai/contexts/session-state.md`. ## Invariants diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 718056b6..f305b048 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -51,6 +51,7 @@ From `derive-project-path.js`: `deriveProjectPath(folderPath)`, `resolveWorktree - **`resolveWorktreePath` collapses `/.worktrees/` → ``** when the parent dir exists. Consequence: many `~/.claude/projects/-home-...workspace-myproject--worktrees-X` folders derive to the same projectPath. Callers must dedupe (see `get-work-files` IPC for the pattern). - **Two-table sidebar payload**: projects are aggregated, but each session row has its own `subagentType` field. A `null`/empty `subagentType` means it's a parent session; anything else (e.g. `'general-purpose'`, `'researcher'`) marks a subagent. - **`fs.watch` debouncing**: the watcher batches per-folder events in a `pendingChanges = Map | true>` for ~200 ms before flushing to `refreshFolder`. A `true` value means "full walk needed" (rare path). +- **The same raw watcher callback also feeds a second, lighter-weight signal that bypasses this debounce entirely** (issue #246 step 4): for a top-level session transcript with no live PTY in `activeSessions`, it sends `session-transcript-activity` straight to the renderer, coalesced to ≤1/s per session by `local-transcript-activity.js`. This exists so a session launched outside Switchboard gets a busy indicator without waiting on the cache refresh — see `.ai/contexts/session-state.md` ("The local-transcript adapter") and `.ai/contexts/ipc-bridge.md`. - **A session's title comes from its first *real* user turn, and a transcript without one is not indexed.** `classifyUserText()` in `read-session-file.js` sorts each user record into `prompt` / `command` / `skip`. `skip` is local-command bookkeeping (``, ``, ``, `` — the CLI writes a command's own output back as a `user` record too); `command` is a bare slash-command record, recognised by a `` tag next to a `` or `` one — the CLI writes both orders (`` first for `/clear`, `` first for `/auto-compact` and `/pre-compact`), so neither tag can be required to come first. The `skip` test is anchored to the start of the record: a real prompt that *quotes* `` (a pasted transcript excerpt) is a turn, and skipping it can leave a session with no indexable prompt at all. This matters because **`/clear` opens a NEW jsonl and writes only that bookkeeping into it**; `/model`, by contrast, is written into the transcript that is already open, so it is a summary candidate only when it lands before any real prompt. Taking a `command` record as the summary therefore (a) titled every session started by `/clear` "`/clear clear `, `` or `` — from `session_cache`, the three search tables and `session_metrics` (a phantom's file is never re-read, so its metrics would inflate the heatmap and the totals forever) — plus the `cache_meta` gate of their folders, which makes the next reconcile re-read exactly those files. The whole purge runs in one transaction: it cannot be resumed, since the relaunch that follows an interrupted run is already at db_version 9 and no longer matches the rows it dropped. - **Stats `firstSessionDate`** is computed from `MIN(modified)`, not `MIN(created)`. Old sessions touched by recent reads keep their original `created` but their `modified` reflects the latest indexing — by design (the heatmap measures activity, not creation). diff --git a/.ai/contexts/session-state.md b/.ai/contexts/session-state.md index 2972401e..2ad5f698 100644 --- a/.ai/contexts/session-state.md +++ b/.ai/contexts/session-state.md @@ -14,11 +14,14 @@ what actually shipped, not the whole plan. `.session-icon` slot per sidebar row, replacing `.session-status-dot` for session/subagent rows) shipped separately — see "The icon slot (step 3b)" below. -- **Steps 4/5: pending.** There is no `local-transcript` adapter (step 4). - Subagent attribution is not routed through `session-state.js` (`agentsBusy` - exists in the model but nothing local-pty feeds it yet — sidebar.js's - `has-busy-agents` row class is still computed by `parentHasActiveSubagent()`, - independent of the domain module) (step 5). +- **Step 4: done.** The `local-transcript` adapter (`public/local-transcript-adapter.js`) + gives a session launched outside Switchboard (no PTY in this app) a busy + signal from transcript growth — see "The local-transcript adapter (step 4)" + below. +- **Step 5: pending.** Subagent attribution is not routed through + `session-state.js` (`agentsBusy` exists in the model but nothing local-pty + feeds it yet — sidebar.js's `has-busy-agents` row class is still computed by + `parentHasActiveSubagent()`, independent of the domain module). ### The remote-ssh adapter (step 3) @@ -66,6 +69,70 @@ Both are driven by the same `active`/`armReady` inputs as the adapter, so the two projections never disagree in practice; the dual-feed is a known, temporary duplication, not a race. +### The local-transcript adapter (step 4) + +`public/local-transcript-adapter.js` keeps one persistent +`createSessionState('local-transcript')` per session id, in +`localTranscriptStates` — same shape as `remoteSessionStates` above, pruned +by `pruneLocalTranscriptTimers()` (called from `refreshSidebar()` alongside +`pruneRemoteActivityTimers()`). Unlike the remote-ssh adapter it has no +watch-channel descriptor list to poll; its only inputs are: + +- **`session-transcript-activity`** (`window.api.onSessionTranscriptActivity`, + main.js's raw watcher callback — see "The `~/.claude/projects` watcher…" + in `.ai/contexts/session-cache.md` and the channel doc in + `.ai/contexts/ipc-bridge.md`): `transcriptTouched` + `busy: true`, then a + 20s decay timer (the same constant as the remote-ssh adapter, + `PIP_DECAY_MS`/`LOCAL_TRANSCRIPT_DECAY_MS`, duplicated rather than shared + across the two files — no common module currently holds cross-adapter + constants) applies `busy: false, armReady: false` — never + `responseReady`, exactly like the remote-ssh decay, because this adapter + has no PTY to confirm a turn actually ended either. +- **The session object's own `status`/`statusUpdatedAt`** + (`seedLocalTranscriptDescriptor`, called from inside the activity handler, + reading `sessionMap.get(sessionId)`): `liveness: 'alive'` + + `descriptorStatus(status, at)`, the same two calls `applyRemoteDescriptor` + makes for remote-ssh. **This is a deliberate widening of the issue's + original ports table**, which listed `descriptorStatus`/`liveness` as "no + (no live CLI)" for `local-transcript` — written before `cli-session-state.js` + (issue #245) established that a local session without a PTY *in this app* + can still be a live CLI process elsewhere, discoverable via + `~/.claude/sessions/.json` exactly the way `snapshotForLocal` already + reads it for the local-pty kind. Feeding it here keeps the two kinds' + snapshots consistent when a row transitions between them; neither field is + consumed by `renderSessionIcon`'s priority ladder yet (see "Ports table" + below), so this has no visible effect today beyond that consistency. +- **Nothing else.** No `attention`, no `waitingForInput` claimed from a + completion signal — see the ports-table note below the table. + +**The main-process half has its own two guards, in `local-transcript-activity.js` +(a pure factory, `createLocalTranscriptTracker`, unit-tested without Electron — +same pattern as `remote-activity.js`).** `sessionIdFromWatchParts(parts)` +only resolves a session id for a top-level transcript — exactly +`/.jsonl`, two path segments; a subagent leg +(`/subagents/agent-X.jsonl`, or the legacy `/agent-X.jsonl`) +has three-plus and is out of scope here (step 5, a separate issue). The +injected `hasPty(sessionId)` (main.js wires in `sessionHasPty`, which walks +`activeSessions` the same way `cli-session-state.js`'s `findSession` does — +skip `exited`, match `session.realSessionId || key`) skips a session the OSC +path already owns, mirroring the renderer-side guard below one layer down. + +**Guarded against a PTY takeover in both directions**: +`onLocalTranscriptActivity` checks `activePtyIds.has(sessionId)` and refuses +to even allocate state for a session already carrying a PTY in this app (the +OSC path owns it — see `session-activity-dom.js`'s `applyActivityClassesToElement`, +fed by `main.js`'s OSC 0/9 parsing, not this adapter). Going the other way, +`app.js`'s `updateRunningIndicators()` calls `localTranscriptPtyTakeover(id)` +for a non-remote row the instant it transitions into `activePtyIds` (the user +opened it), which clears the pending decay timer and deletes the row's +adapter state — a stale decay firing later must not repaint a row the +local-pty path now owns. `updateRunningIndicators()` also force-repaints that +row via `paintSessionIcon()` in the same pass, so the icon slot doesn't wait +for the next OSC event to reflect the handoff. + +Same non-writer discipline as remote-ssh: every path ends in +`projectLocalTranscriptState(sessionId)` → `applyStateClasses()`. + ## Shape Three files, one direction of dependency for data, the reverse for rendering: @@ -295,12 +362,13 @@ the others assert on the dot/slot element itself, only on row classes and | event | local-pty | local-transcript | remote-ssh | |---|---|---|---| | `busy` / `attention` (OSC 0 / 9) | yes | never | wired via the watch channel (transcript writes), not OSC — OSC-while-attached is not wired | -| `transcriptTouched(at)` | yes | yes (only signal) | yes — `onRemoteActivityEvent`/`markRemoteBusy` | -| `descriptorStatus(status, at)` / `liveness` | yes | no (no live CLI) | yes (`main.js:539` → `applyRemoteDescriptor`) | +| `transcriptTouched(at)` | yes | yes (only signal) — `onLocalTranscriptActivity` | yes — `onRemoteActivityEvent`/`markRemoteBusy` | +| `descriptorStatus(status, at)` / `liveness` | yes | yes — `seedLocalTranscriptDescriptor`, from `sessionMap`'s `status`/`statusUpdatedAt` (see "The local-transcript adapter" above for why this widens the issue's original "no (no live CLI)") | yes (`main.js:539` → `applyRemoteDescriptor`) | | `attached` | reserved, unused | reserved, unused | yes — `setRemoteAttached`, driven by the per-row `activePtyIds` transition | | `subagentSpawned` / `subagentCompleted` | yes | no | no today | An adapter without a PTY must never claim `waitingForInput` or `responseReady` -from a completion signal it cannot verify — that is why the remote-ssh -`busy: false` transition always passes `armReady: false` (see "The remote-ssh -adapter" above), not a tri-state `busy: unknown`. +from a completion signal it cannot verify — that is why the remote-ssh and +local-transcript `busy: false` transitions always pass `armReady: false` (see +"The remote-ssh adapter" and "The local-transcript adapter" above), not a +tri-state `busy: unknown`. diff --git a/eslint.config.js b/eslint.config.js index 6b5580c4..9ce66ef2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -165,6 +165,9 @@ const rendererCrossFileGlobals = { paintSessionIcon: 'readonly', // public/remote-activity-ui.js (remote-ssh adapter, see .ai/contexts/session-state.md) setRemoteAttached: 'readonly', + // public/local-transcript-adapter.js (local-transcript adapter, see .ai/contexts/session-state.md) + localTranscriptPtyTakeover: 'readonly', + pruneLocalTranscriptTimers: 'readonly', // public/sidebar.js, consumed by session-activity-dom.js's snapshotForLocal // (see .ai/contexts/session-state.md, "The icon slot (step 3b)") parentHasActiveSubagent: 'readonly', diff --git a/local-transcript-activity.js b/local-transcript-activity.js new file mode 100644 index 00000000..7c9975ab --- /dev/null +++ b/local-transcript-activity.js @@ -0,0 +1,38 @@ +// see .ai/contexts/session-state.md +'use strict'; + +const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const DEFAULT_IPC_MIN_MS = 1000; + +// top-level transcript only, a subagent leg is out of scope — see .ai/contexts/session-state.md +function sessionIdFromWatchParts(parts) { + if (!Array.isArray(parts) || parts.length !== 2) return null; + const basename = parts[1]; + if (typeof basename !== 'string' || !basename.endsWith('.jsonl')) return null; + const sessionId = basename.slice(0, -'.jsonl'.length); + return SESSION_ID_RE.test(sessionId) ? sessionId : null; +} + +// hasPty() gates out a row the OSC path already owns — see .ai/contexts/session-state.md +function createLocalTranscriptTracker(opts = {}) { + const ipcMinMs = opts.ipcMinMs || DEFAULT_IPC_MIN_MS; + const now = opts.now || Date.now; + const hasPty = typeof opts.hasPty === 'function' ? opts.hasPty : () => false; + + const ipcAt = new Map(); + + function record(parts) { + const sessionId = sessionIdFromWatchParts(parts); + if (!sessionId) return null; + if (hasPty(sessionId)) return null; + const t = now(); + const last = ipcAt.has(sessionId) ? ipcAt.get(sessionId) : -Infinity; + if (t - last < ipcMinMs) return null; + ipcAt.set(sessionId, t); + return { sessionId, at: t }; + } + + return { record }; +} + +module.exports = { createLocalTranscriptTracker, sessionIdFromWatchParts, SESSION_ID_RE }; diff --git a/main.js b/main.js index ded96287..1694b130 100644 --- a/main.js +++ b/main.js @@ -465,6 +465,7 @@ const { createSshTransport } = require('./remote-transport'); const { createRemoteIndexer } = require('./remote-index'); const { createRemoteWatcher } = require('./remote-watch'); const { createRemoteActivityTracker } = require('./remote-activity'); +const { createLocalTranscriptTracker } = require('./local-transcript-activity'); const remoteTransport = createSshTransport({ log }); const remoteIndexer = createRemoteIndexer({ @@ -2570,6 +2571,16 @@ cliSessionState.init({ }, }); +// a session with a live PTY is owned by the OSC path — see .ai/contexts/session-state.md +function sessionHasPty(sessionId) { + for (const [key, session] of activeSessions) { + if (!session || session.exited) continue; + if ((session.realSessionId || key) === sessionId) return true; + } + return false; +} +const localTranscriptTracker = createLocalTranscriptTracker({ hasPty: sessionHasPty }); + // --- fs.watch on projects directory --- let projectsWatcher = null; @@ -2643,6 +2654,11 @@ function startProjectsWatcher() { // Specific .jsonl changed — targeted refresh on just this file const rel = parts.slice(1).join(path.sep); recordChange(folder, rel); + // out-of-band signal for the local-transcript adapter — see .ai/contexts/session-state.md + const activity = localTranscriptTracker.record(parts); + if (activity && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('session-transcript-activity', activity); + } } else { return; } diff --git a/preload.js b/preload.js index 821287b3..3a65a9cf 100644 --- a/preload.js +++ b/preload.js @@ -103,6 +103,9 @@ contextBridge.exposeInMainWorld('api', { onRemoteActivity: (callback) => { ipcRenderer.on('remote-activity', (_event, payload) => callback(payload)); }, + onSessionTranscriptActivity: (callback) => { + ipcRenderer.on('session-transcript-activity', (_event, payload) => callback(payload)); + }, onStatusUpdate: (callback) => { ipcRenderer.on('status-update', (_event, text, type) => callback(text, type)); }, diff --git a/public/app.js b/public/app.js index 0757a036..dd72706c 100644 --- a/public/app.js +++ b/public/app.js @@ -507,6 +507,7 @@ function refreshSidebar({ resort = false } = {}) { renderProjects(projects, resort); pruneRemoteActivityTimers(); + pruneLocalTranscriptTimers(); } // --- Archive toggle --- @@ -822,8 +823,13 @@ function updateRunningIndicators() { clearActiveSubagentsFor(id); } if (item.dataset.remoteAlias) setRemoteAttached(id, running); + // local-pty takes over a row the user just opened — see .ai/contexts/session-state.md + if (running && !item.dataset.remoteAlias) localTranscriptPtyTakeover(id); const icon = item.querySelector('.session-icon'); - if (icon) icon.classList.toggle('running', running); + if (icon) { + icon.classList.toggle('running', running); + if (running && !item.dataset.remoteAlias) paintSessionIcon(icon, id); + } if (window.ATRACE) window.atrace('class.toggle', id, { el: item.id || null, cls: 'has-running-pty', on: running, icon: !!icon, fn: 'updateRunningIndicators' }); }); // Update slug group running dots diff --git a/public/index.html b/public/index.html index d7b699b8..8390f3c6 100644 --- a/public/index.html +++ b/public/index.html @@ -140,6 +140,7 @@ + diff --git a/public/local-transcript-adapter.js b/public/local-transcript-adapter.js new file mode 100644 index 00000000..fc2030c5 --- /dev/null +++ b/public/local-transcript-adapter.js @@ -0,0 +1,84 @@ +// local-transcript adapter — see .ai/contexts/session-state.md + +// shares the remote-ssh adapter's 20s decay window — see .ai/contexts/session-state.md +const LOCAL_TRANSCRIPT_DECAY_MS = 20000; +const localTranscriptDecayTimers = new Map(); + +// one persistent state per session id, same shape as remoteSessionStates — see .ai/contexts/session-state.md +const localTranscriptStates = new Map(); + +function localTranscriptState(sessionId) { + let state = localTranscriptStates.get(sessionId); + if (!state) { + state = createSessionState('local-transcript'); + localTranscriptStates.set(sessionId, state); + } + return state; +} + +function projectLocalTranscriptState(sessionId) { + applyStateClasses(sessionId, localTranscriptState(sessionId).snapshot()); +} + +function clearLocalTranscriptTimer(sessionId) { + const t = localTranscriptDecayTimers.get(sessionId); + if (t) { + clearTimeout(t); + localTranscriptDecayTimers.delete(sessionId); + } +} + +// descriptorStatus/liveness from session.status — see .ai/contexts/session-state.md +function seedLocalTranscriptDescriptor(sessionId) { + const session = typeof sessionMap !== 'undefined' && sessionMap.get(sessionId); + if (!session || session.status === undefined) return; + const state = localTranscriptState(sessionId); + state.apply({ type: 'liveness', value: 'alive' }); + state.apply({ type: 'descriptorStatus', status: session.status, at: session.statusUpdatedAt }); +} + +// silence means stopped writing, not response ready — see .ai/contexts/session-state.md +function decayLocalTranscriptBusy(sessionId) { + const state = localTranscriptState(sessionId); + state.apply({ type: 'busy', active: false, armReady: false }); + projectLocalTranscriptState(sessionId); +} + +function armLocalTranscriptDecayTimer(sessionId) { + clearLocalTranscriptTimer(sessionId); + localTranscriptDecayTimers.set(sessionId, setTimeout(() => { + localTranscriptDecayTimers.delete(sessionId); + decayLocalTranscriptBusy(sessionId); + }, LOCAL_TRANSCRIPT_DECAY_MS)); +} + +// never claims waitingForInput/attention — see .ai/contexts/session-state.md +function onLocalTranscriptActivity(payload) { + const sessionId = payload && payload.sessionId; + if (typeof sessionId !== 'string' || !sessionId) return; + if (activePtyIds.has(sessionId)) return; + const state = localTranscriptState(sessionId); + state.apply({ type: 'transcriptTouched', at: payload.at || Date.now(), source: 'local-transcript' }); + state.apply({ type: 'busy', active: true }); + seedLocalTranscriptDescriptor(sessionId); + projectLocalTranscriptState(sessionId); + armLocalTranscriptDecayTimer(sessionId); +} + +// called once a row gains a PTY; the local-pty path takes over from here — see .ai/contexts/session-state.md +function localTranscriptPtyTakeover(sessionId) { + if (!localTranscriptStates.has(sessionId)) return; + clearLocalTranscriptTimer(sessionId); + localTranscriptStates.delete(sessionId); +} + +function pruneLocalTranscriptTimers() { + for (const sessionId of localTranscriptDecayTimers.keys()) { + if (!sessionItemEl(sessionId)) clearLocalTranscriptTimer(sessionId); + } + for (const sessionId of localTranscriptStates.keys()) { + if (!sessionItemEl(sessionId)) localTranscriptStates.delete(sessionId); + } +} + +window.api.onSessionTranscriptActivity(onLocalTranscriptActivity); diff --git a/test/dom-setup.js b/test/dom-setup.js index 478d14da..2a34c5e1 100644 --- a/test/dom-setup.js +++ b/test/dom-setup.js @@ -51,6 +51,9 @@ function setupSidebarDom() { const apiTarget = { onSubagentSpawned: (cb) => { apiTarget._subagentSpawnedCb = cb; }, onSubagentCompleted: (cb) => { apiTarget._subagentCompletedCb = cb; }, + // local-transcript-adapter.js registers this once at eval time — see + // .ai/contexts/session-state.md (migration step 4). + onSessionTranscriptActivity: (cb) => { apiTarget._sessionTranscriptActivityCb = cb; }, // Manual remote reconnect (issue #252) — explicit defaults so a test that // doesn't care about these calls still gets a resolved promise; a test // that does override them per-call, the same way archiveSession etc. do. @@ -136,9 +139,10 @@ function setupSidebarDom() { evalInWindow(dom, path.join(PUBLIC_DIR, 'session-activity.js')); // sidebar.js, then remote-activity-ui.js (seedRemoteActivity, called from - // renderProjects). + // renderProjects) and local-transcript-adapter.js (onSessionTranscriptActivity). evalInWindow(dom, path.join(PUBLIC_DIR, 'sidebar.js')); evalInWindow(dom, path.join(PUBLIC_DIR, 'remote-activity-ui.js')); + evalInWindow(dom, path.join(PUBLIC_DIR, 'local-transcript-adapter.js')); const ctx = dom.getInternalVMContext(); const read = (expr) => vm.runInContext(expr, ctx); @@ -168,6 +172,11 @@ function setupSidebarDom() { emitSubagentCompleted(payload) { if (typeof apiTarget._subagentCompletedCb === 'function') apiTarget._subagentCompletedCb(payload); }, + // Simulate the main process emitting session-transcript-activity + // (local-transcript-adapter.js) — see .ai/contexts/session-state.md. + emitSessionTranscriptActivity(payload) { + if (typeof apiTarget._sessionTranscriptActivityCb === 'function') apiTarget._sessionTranscriptActivityCb(payload); + }, destroy() { window.close(); }, diff --git a/test/dom-sidebar-local-transcript.test.js b/test/dom-sidebar-local-transcript.test.js new file mode 100644 index 00000000..b010cc40 --- /dev/null +++ b/test/dom-sidebar-local-transcript.test.js @@ -0,0 +1,83 @@ +// Issue #246 step 4: a local session launched outside Switchboard (no PTY in +// this app) gets liveness/activity from the local-transcript adapter instead +// of showing nothing. See .ai/contexts/session-state.md. + +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { setupSidebarDom, makeSampleProject } = require('./dom-setup'); + +const LOCAL_SESSION = { + sessionId: 'local-1', + summary: 'a session running outside switchboard', + modified: '2026-09-06T10:00:00.000Z', + starred: false, + archived: 0, + messageCount: 4, + status: 'busy', + statusUpdatedAt: Date.parse('2026-09-06T10:00:00.000Z'), +}; + +function projectWithLocalSession() { + return makeSampleProject({ + projectPath: '/home/dev/local-only', + sessions: [LOCAL_SESSION], + }); +} + +// rebindSidebarEvents/the adapter's descriptor seeding read sessionMap — app.js +// normally fills it; same pattern as test/dom-sidebar-remote-session.test.js. +function register(ctx, sessions) { + for (const s of sessions) ctx.window.sessionMap.set(s.sessionId, s); +} + +test('a local row without a PTY shows the busy slot after a transcript-activity event, and the age with no response-ready after decay', () => { + const ctx = setupSidebarDom(); + try { + register(ctx, [LOCAL_SESSION]); + ctx.sidebar.renderProjects([projectWithLocalSession()], true); + + const item = ctx.document.getElementById('si-local-1'); + assert.ok(item, 'the local session must be rendered'); + assert.equal(item.dataset.remoteAlias, undefined, 'this is a local row, not a remote one'); + assert.ok(!item.classList.contains('cli-busy'), 'precondition: no transcript-activity event has arrived yet'); + + ctx.emitSessionTranscriptActivity({ sessionId: 'local-1', at: Date.now() }); + assert.ok(item.classList.contains('cli-busy'), 'a transcript-activity event must light the busy indicator for a PTY-less row'); + const icon = item.querySelector('.session-icon'); + assert.ok(icon.classList.contains('session-icon--busy'), 'the icon slot must reflect the busy rung'); + + // Drive the 20s decay by hand (real timers are not worth the wall-clock + // cost here; the timer itself is proven in test/local-transcript-adapter.test.js). + ctx.window.decayLocalTranscriptBusy('local-1'); + assert.ok(!item.classList.contains('cli-busy'), 'decay clears the busy indicator'); + assert.ok(!item.classList.contains('response-ready'), 'decay must never claim a finished turn — no PTY to confirm one'); + + const statusEl = item.querySelector('.session-status'); + assert.ok(statusEl, 'the state+age line is rendered independently of the icon slot'); + assert.match(statusEl.textContent, /^busy/, 'the age line survives the icon decay unaffected — it reads session.status directly'); + } finally { ctx.destroy(); } +}); + +test('once the row gains a PTY, a later transcript-activity event no longer paints it', () => { + const ctx = setupSidebarDom(); + try { + register(ctx, [LOCAL_SESSION]); + ctx.sidebar.renderProjects([projectWithLocalSession()], true); + const item = ctx.document.getElementById('si-local-1'); + + ctx.emitSessionTranscriptActivity({ sessionId: 'local-1', at: Date.now() }); + assert.ok(item.classList.contains('cli-busy'), 'precondition: the adapter is painting this row'); + + // Simulate app.js's updateRunningIndicators(): the row gains a PTY, and + // the local-transcript adapter is handed off — see .ai/contexts/session-state.md. + ctx.window.localTranscriptPtyTakeover('local-1'); + ctx.window.decayLocalTranscriptBusy('local-1'); // any timer that was still pending must be inert now + ctx.window.activePtyIds.add('local-1'); + + ctx.emitSessionTranscriptActivity({ sessionId: 'local-1', at: Date.now() + 25000 }); + assert.ok(!item.classList.contains('cli-busy'), + 'once a PTY exists, the local-transcript adapter must stay silent — the OSC path owns the row now'); + } finally { ctx.destroy(); } +}); diff --git a/test/local-transcript-activity.test.js b/test/local-transcript-activity.test.js new file mode 100644 index 00000000..a9c9fce3 --- /dev/null +++ b/test/local-transcript-activity.test.js @@ -0,0 +1,78 @@ +'use strict'; + +// local-transcript-activity.js: the pure factory behind the local-transcript +// adapter's main-process signal — see .ai/contexts/session-state.md +// (migration step 4). Properties proven here: +// 1. A watch `filename` split that isn't exactly `/.jsonl` +// (a subagent leg, a non-.jsonl file, a non-UUID name) never resolves to a +// session id. +// 2. A session id resolved from the watch event, but reported by hasPty() as +// already carrying a PTY in this app, is skipped — the OSC path owns it. +// 3. A first sighting of an eligible session is always forwarded. +// 4. A second sighting inside the 1s coalescing window is swallowed. +// 5. Once the window has passed, the next sighting is forwarded again. +// 6. Two distinct sessions never share coalescing state. + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { createLocalTranscriptTracker, sessionIdFromWatchParts } = require('../local-transcript-activity'); + +const UUID_A = '11111111-1111-4111-8111-111111111111'; +const UUID_B = '22222222-2222-4222-8222-222222222222'; + +function clock(startAt = 1000) { + let t = startAt; + return { now: () => t, advance: (ms) => { t += ms; } }; +} + +test('sessionIdFromWatchParts accepts a bare top-level transcript, rejects a subagent leg and non-transcripts', () => { + assert.equal(sessionIdFromWatchParts(['folder-name', `${UUID_A}.jsonl`]), UUID_A); + assert.equal(sessionIdFromWatchParts(['folder-name', UUID_A, 'subagents', 'agent-7.jsonl']), null, + 'a subagent leg has more than two segments — out of scope for step 4'); + assert.equal(sessionIdFromWatchParts(['folder-name', UUID_A, 'agent-7.jsonl']), null, + 'the legacy nested subagent layout also has more than two segments'); + assert.equal(sessionIdFromWatchParts(['folder-name', 'sessions-index.json']), null, 'not a .jsonl file'); + assert.equal(sessionIdFromWatchParts(['folder-name', 'not-a-uuid.jsonl']), null, 'basename is not a session id'); + assert.equal(sessionIdFromWatchParts(['folder-name']), null, 'a bare top-level folder event, not a file'); + assert.equal(sessionIdFromWatchParts(null), null); +}); + +test('record() forwards the first sighting of an eligible session', () => { + const c = clock(5000); + const tracker = createLocalTranscriptTracker({ now: c.now }); + const result = tracker.record(['folder-name', `${UUID_A}.jsonl`]); + assert.deepEqual(result, { sessionId: UUID_A, at: 5000 }); +}); + +test('record() never forwards for a session that already has a PTY in this app', () => { + const c = clock(0); + const tracker = createLocalTranscriptTracker({ now: c.now, hasPty: (id) => id === UUID_A }); + assert.equal(tracker.record(['folder-name', `${UUID_A}.jsonl`]), null, + 'the OSC path already owns a row with a live PTY'); + assert.ok(tracker.record(['folder-name', `${UUID_B}.jsonl`]), + 'a different session with no PTY is unaffected'); +}); + +test('record() coalesces a second sighting inside the 1s window, then forwards again once it has elapsed', () => { + const c = clock(0); + const tracker = createLocalTranscriptTracker({ now: c.now, ipcMinMs: 1000 }); + const parts = ['folder-name', `${UUID_A}.jsonl`]; + + assert.ok(tracker.record(parts), 'first sighting always forwards'); + c.advance(400); + assert.equal(tracker.record(parts), null, 'a sighting inside the coalescing window must not forward again'); + + c.advance(700); // total 1100ms since the first forward + const third = tracker.record(parts); + assert.ok(third, 'once ipcMinMs has elapsed since the last forward, the next sighting forwards again'); + assert.equal(third.at, 1100); +}); + +test('two sessions never share coalescing state', () => { + const c = clock(0); + const tracker = createLocalTranscriptTracker({ now: c.now, ipcMinMs: 1000 }); + assert.ok(tracker.record(['folder-name', `${UUID_A}.jsonl`])); + assert.ok(tracker.record(['folder-name', `${UUID_B}.jsonl`]), + 'session B has never been seen — its own throttle window must be independent of A'); +}); diff --git a/test/local-transcript-adapter.test.js b/test/local-transcript-adapter.test.js new file mode 100644 index 00000000..c4d4bd83 --- /dev/null +++ b/test/local-transcript-adapter.test.js @@ -0,0 +1,159 @@ +// Tests for the local-transcript adapter in public/local-transcript-adapter.js +// — see .ai/contexts/session-state.md (migration step 4). Same eval-in-jsdom +// technique as test/remote-session-adapter.test.js: the real session-state.js +// / session-activity-dom.js / session-activity.js / local-transcript-adapter.js, +// loaded in index.html order, with setTimeout/clearTimeout stubbed so the 20s +// decay is driven by hand. + +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { JSDOM } = require('jsdom'); + +const STATE_SRC = path.join(__dirname, '..', 'public', 'session-state.js'); +const DOM_SRC = path.join(__dirname, '..', 'public', 'session-activity-dom.js'); +const ACTIVITY_SRC = path.join(__dirname, '..', 'public', 'session-activity.js'); +const SRC = path.join(__dirname, '..', 'public', 'local-transcript-adapter.js'); + +function setup(sessionIds = ['s1']) { + const items = sessionIds + .map(id => `
`) + .join(''); + const dom = new JSDOM(`${items}`, + { url: 'http://localhost/', runScripts: 'outside-only' }); + const { window } = dom; + + Object.defineProperty(window, 'activeSessionId', { value: null, writable: true, configurable: true }); + Object.defineProperty(window, 'activePtyIds', { value: new Set(), writable: true, configurable: true }); + Object.defineProperty(window, 'sessionMap', { value: new Map(), writable: true, configurable: true }); + + let onActivityCb = null; + Object.defineProperty(window, 'api', { + value: { onSessionTranscriptActivity: (cb) => { onActivityCb = cb; } }, + writable: true, configurable: true, + }); + + const scheduled = []; + let nextId = 1; + Object.defineProperty(window, 'setTimeout', { + value: (fn, ms) => { + const handle = { id: nextId++, fn, ms, cleared: false }; + scheduled.push(handle); + return handle.id; + }, + writable: true, configurable: true, + }); + Object.defineProperty(window, 'clearTimeout', { + value: (id) => { + const h = scheduled.find(s => s.id === id); + if (h) h.cleared = true; + }, + writable: true, configurable: true, + }); + + const ctx = dom.getInternalVMContext(); + vm.runInContext(fs.readFileSync(STATE_SRC, 'utf8'), ctx, { filename: STATE_SRC }); + vm.runInContext(fs.readFileSync(DOM_SRC, 'utf8'), ctx, { filename: DOM_SRC }); + vm.runInContext(fs.readFileSync(ACTIVITY_SRC, 'utf8'), ctx, { filename: ACTIVITY_SRC }); + vm.runInContext(fs.readFileSync(SRC, 'utf8'), ctx, { filename: SRC }); + + const call = (fnName, ...args) => vm.runInContext( + `${fnName}(${args.map((a) => JSON.stringify(a)).join(',')})`, ctx + ); + + return { + window, + document: window.document, + item: (id) => window.document.querySelector(`.session-item[data-session-id="${id}"]`), + emit: (payload) => onActivityCb(payload), + snapshot: (id) => vm.runInContext(`localTranscriptState(${JSON.stringify(id)}).snapshot()`, ctx), + hasState: (id) => vm.runInContext(`localTranscriptStates.has(${JSON.stringify(id)})`, ctx), + setSessionStatus: (id, status, at) => { window.sessionMap.set(id, { status, statusUpdatedAt: at }); }, + setPty: (id, has) => { if (has) window.activePtyIds.add(id); else window.activePtyIds.delete(id); }, + ptyTakeover: (id) => call('localTranscriptPtyTakeover', id), + scheduled, + pending: () => scheduled.filter(h => !h.cleared), + destroy: () => window.close(), + }; +} + +test('a session-transcript-activity event drives the adapter busy, then decay clears busy without arming waitingForInput/attention/response-ready as ready', () => { + const t = setup(['s1']); + let snap = t.snapshot('s1'); + assert.equal(snap.busy, false, 'precondition: idle'); + + t.emit({ sessionId: 's1', at: Date.now() }); + snap = t.snapshot('s1'); + assert.equal(snap.busy, true, 'the transcript-activity event marks the adapter busy'); + assert.equal(snap.attention, false, 'no PTY exists to ever justify attention'); + assert.ok(t.item('s1').classList.contains('cli-busy'), 'projected onto the row'); + + const timer = t.pending()[0]; + assert.ok(timer, 'a decay timer must be scheduled'); + timer.fn(); // simulate the 20s elapsing + + snap = t.snapshot('s1'); + assert.equal(snap.busy, false, 'decay clears busy'); + assert.equal(snap.waitingForInput, true); + assert.equal(snap.responseReady, false, 'decay must never arm response-ready — no PTY to confirm a turn ended'); + assert.ok(!t.item('s1').classList.contains('cli-busy')); + assert.ok(!t.item('s1').classList.contains('response-ready')); + t.destroy(); +}); + +test('an event with no sessionMap entry leaves liveness unknown (no live-CLI signal to borrow)', () => { + const t = setup(['s1']); + t.emit({ sessionId: 's1', at: Date.now() }); + assert.equal(t.snapshot('s1').liveness, 'unknown'); + t.destroy(); +}); + +test('an event seeds liveness/descriptorStatus from the session object when session.status is present', () => { + const t = setup(['s1']); + t.setSessionStatus('s1', 'idle', 12345); + t.emit({ sessionId: 's1' }); + + const snap = t.snapshot('s1'); + assert.equal(snap.liveness, 'alive'); + assert.equal(snap.lastActivitySource, 'descriptor'); + t.destroy(); +}); + +test('an event is ignored outright for a session that already has a PTY in this app', () => { + const t = setup(['s1']); + t.setPty('s1', true); + t.emit({ sessionId: 's1', at: Date.now() }); + assert.equal(t.hasState('s1'), false, 'the OSC path owns this row; the adapter must not even allocate state for it'); + assert.ok(!t.item('s1').classList.contains('cli-busy')); + t.destroy(); +}); + +test('PTY takeover clears the pending decay timer and drops the adapter state, so a later event (once a PTY exists) is a no-op', () => { + const t = setup(['s1']); + t.emit({ sessionId: 's1', at: Date.now() }); + assert.equal(t.hasState('s1'), true); + assert.equal(t.pending().length, 1); + + t.ptyTakeover('s1'); + assert.equal(t.hasState('s1'), false, 'the local-pty path now owns this row'); + assert.equal(t.pending().length, 0, 'the decay timer must not fire after takeover and repaint a stale snapshot'); + + t.setPty('s1', true); + t.emit({ sessionId: 's1', at: Date.now() }); + assert.equal(t.hasState('s1'), false, 'once the row has a PTY, the adapter must stay silent for it'); + t.destroy(); +}); + +test('a mutant decay that arms response-ready would be caught here', () => { + // Mutation proof for the brief's required check: flip armReady to true on + // decay and this test goes red — see HANDOFF for the executed proof. + const t = setup(['s1']); + t.emit({ sessionId: 's1', at: Date.now() }); + const timer = t.pending()[0]; + timer.fn(); + assert.equal(t.snapshot('s1').responseReady, false); + t.destroy(); +}); diff --git a/test/main-local-transcript-wiring.test.js b/test/main-local-transcript-wiring.test.js new file mode 100644 index 00000000..f09fcfff --- /dev/null +++ b/test/main-local-transcript-wiring.test.js @@ -0,0 +1,55 @@ +// test/main-local-transcript-wiring.test.js — reads main.js as TEXT and fails +// when the local-transcript wiring (issue #246 step 4) disappears from it. +// +// This proves NOTHING about runtime behaviour beyond the shape of the source: +// the tracker's own coalescing/hasPty properties are exercised in +// test/local-transcript-activity.test.js; this file only pins that main.js +// actually wires the real activeSessions-backed guard into it, on the +// out-of-band path (not the debounced flushChanges()/projects-changed one). +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +// Normalised to LF: this file's own balanced-brace slicing assumes '\n', +// and main.js is checked out with CRLF line endings on Windows. +const mainSrc = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8').replace(/\r\n/g, '\n'); + +test('main.js requires local-transcript-activity and wires the real activeSessions-backed PTY guard', () => { + assert.match(mainSrc, /require\('\.\/local-transcript-activity'\)/, + 'main.js must require ./local-transcript-activity'); + assert.match( + mainSrc, + /createLocalTranscriptTracker\(\s*\{\s*hasPty:\s*sessionHasPty\s*\}\s*\)/, + 'the tracker must be constructed with the real sessionHasPty guard, not a stub', + ); +}); + +test('sessionHasPty checks activeSessions for a live, non-exited match on realSessionId or the map key', () => { + const at = mainSrc.indexOf('function sessionHasPty(sessionId)'); + assert.notEqual(at, -1, 'sessionHasPty must be defined'); + const body = mainSrc.slice(at, mainSrc.indexOf('\n}\n', at) + 3); + assert.match(body, /for \(const \[key, session\] of activeSessions\)/); + assert.match(body, /session\.exited/, 'must skip an exited session — a dead PTY is not a live PTY'); + assert.match(body, /session\.realSessionId \|\| key/, 'must match a forked/resumed session by its real id, same as cli-session-state.js findSession()'); +}); + +test('the fs.watch callback emits session-transcript-activity out-of-band, not through the debounced flush', () => { + const watchAt = mainSrc.indexOf("fs.watch(PROJECTS_DIR, { recursive: true }"); + assert.notEqual(watchAt, -1, 'the projects watcher call site must still exist'); + const flushAt = mainSrc.indexOf('function flushChanges()'); + assert.notEqual(flushAt, -1); + const flushBody = mainSrc.slice(flushAt, mainSrc.indexOf('\n }\n', flushAt)); + assert.doesNotMatch(flushBody, /localTranscriptTracker/, + 'the heavy debounced flush must not carry the lightweight per-session signal'); + + const callbackEnd = mainSrc.indexOf('projectsWatcher.on(\'error\'', watchAt); + const callbackBody = mainSrc.slice(watchAt, callbackEnd); + assert.match(callbackBody, /localTranscriptTracker\.record\(parts\)/, + 'the raw watcher callback must feed the tracker directly, before the 500ms debounce'); + assert.match(callbackBody, /mainWindow\.webContents\.send\('session-transcript-activity', activity\)/); + assert.match(callbackBody, /mainWindow && !mainWindow\.isDestroyed\(\)/, + 'must guard the send exactly like the other IPC emitters in this file (e.g. onRemoteWatchActivity)'); +});