diff --git a/plugins/planning/.claude-plugin/plugin.json b/plugins/planning/.claude-plugin/plugin.json index 8c3093e134..fb94f0501b 100644 --- a/plugins/planning/.claude-plugin/plugin.json +++ b/plugins/planning/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "planning", - "version": "0.49.2", + "version": "0.49.3", "userConfig": { "surface": { "type": "string", diff --git a/plugins/planning/CHANGELOG.md b/plugins/planning/CHANGELOG.md index 63675633c1..9b5fe2bb7e 100644 --- a/plugins/planning/CHANGELOG.md +++ b/plugins/planning/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `planning` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.49.3] - 2026-09-30 + +### Added + +- **The interview page badges its title while the tab is hidden.** The title shows a count of new activity and notes that land while the tab is hidden, and the count clears when the tab is shown ([#5473](https://github.com/melodic-software/claude-code-plugins/issues/5473)). + ## [0.49.2] - 2026-09-30 ### Changed diff --git a/plugins/planning/surface/README.md b/plugins/planning/surface/README.md index 957d6f9ca6..f6a6d19d25 100644 --- a/plugins/planning/surface/README.md +++ b/plugins/planning/surface/README.md @@ -30,7 +30,7 @@ bash round.sh --dir '' stop ## Watcher protocol -`bash watch.sh ''` long-polls `/api/wait?after=handled&replayed=` with the token, where `` comes from `.watch-replay`. When there are unhandled events it prints one JSON line (`seq`, `timedOut`, `events`, `note`, `dataDir`, `next`) and exits 0; `next` is the exact re-arm command, `bash '/wake.sh' ''`, with absolute paths in single quotes. Events a dead turn never handled come back at once on the next arm; after that re-delivery an arm waits for a new event. The page's header reads "Claude is working on Qn" while a delivered event is unhandled. After ten minutes with no watcher waiting, it reads "Claude has not handled Qn yet" and its connection word reads "Not listening: type next". The event stream sends a `ping` every 15 seconds while idle, and at most 8 streams run at once (one more gets 503); the page re-fetches `/api/state` when a tab becomes visible, when the stream reconnects, and after two missed pings (a test can shorten that window, never lengthen it, with `?silentMs=<1000 to 35000>` on the page URL). The watcher exits 2 when curl is missing, when the token is rejected, or when the server stays unreachable. +`bash watch.sh ''` long-polls `/api/wait?after=handled&replayed=` with the token, where `` comes from `.watch-replay`. When there are unhandled events it prints one JSON line (`seq`, `timedOut`, `events`, `note`, `dataDir`, `next`) and exits 0; `next` is the exact re-arm command, `bash '/wake.sh' ''`, with absolute paths in single quotes. Events a dead turn never handled come back at once on the next arm; after that re-delivery an arm waits for a new event. The page's header reads "Claude is working on Qn" while a delivered event is unhandled. After ten minutes with no watcher waiting, it reads "Claude has not handled Qn yet" and its connection word reads "Not listening: type next". The event stream sends a `ping` every 15 seconds while idle, and at most 8 streams run at once (one more gets 503); the page re-fetches `/api/state` when a tab becomes visible, when the stream reconnects, and after two missed pings (a test can shorten that window, never lengthen it, with `?silentMs=<1000 to 35000>` on the page URL). While the tab is hidden, the page title shows a count of new activity and notes, and the count clears when the tab is shown. The watcher exits 2 when curl is missing, when the token is rejected, or when the server stays unreachable. One watcher per data dir: every poll sends `&watcher=`, where the id is `WATCH_ID`, else `CLAUDE_CODE_SESSION_ID` (Claude Code exports it to every shell a session runs, so every re-arm shares it), else `-` (from a plain terminal that is the interactive shell's pid, so re-arm from the same shell or export `WATCH_ID`); it is never written to the data dir. A parent pid of 1, which a Claude Code Bash shell on Windows reports for every session, forms no id: `watch.sh` then exits 2 asking for `WATCH_ID` before it polls. The first id holds an in-memory lease, so a stop or crash frees it. A poll from another id gets 409 `{"error": "lease held", "holder", "since", "lastWaitAt", "expiresAt"}` and does not block; `watch.sh` then prints the holder to stderr and exits 3 without retrying. The lease expires when its holder has no wait in flight and its last wait ended more than `leaseTimeout` seconds ago; while the holder waits, `expiresAt` is the earliest expiry. An expired lease shows as no holder at once, before any watcher claims it. `round.sh lease` prints the holder or `no lease`, `round.sh lease --release` hands it over (`POST /api/lease {"action": "release"}`, token required; a wait the old holder has in flight then ends with 409 `{"error": "lease released"}` before it delivers anything, so the next event reaches the next holder only, and `watch.sh` exits 3), and `/api/state` shows it as `listener.lease` (`{watcher, since, lastWaitAt, waiting}` or null). A wait with no `watcher` parameter takes no part in leasing: it is neither refused nor a holder. diff --git a/plugins/planning/surface/index.html b/plugins/planning/surface/index.html index 52fc4140d9..cecb4191b4 100644 --- a/plugins/planning/surface/index.html +++ b/plugins/planning/surface/index.html @@ -456,7 +456,7 @@

Interview

const S = {doc: {meta: {}, groups: [], questions: [], visuals: [], notes: []}, resp: {seq: 0, responses: {}, history: {}, events: []}, listener: {state: "idle"}, online: false, session: "", sel: null, busy: false, toast: "", toastBad: false, toastUndo: false, view: "groups", filter: "all", fly: null, pinned: false, railHidden: false, vtab: null, api: 1, settings: {}, watchSeq: 0, stateAt: 0, - armed: null, snap: {}, conflict: null, undo: null, revs: null, themeSig: "", loaded: false, replies: {}, noteSeen: 0, vfiles: {}, gal: {a: null, b: null, on: false}}; + armed: null, snap: {}, conflict: null, undo: null, revs: null, themeSig: "", loaded: false, replies: {}, noteSeen: 0, hiddenBase: null, vfiles: {}, gal: {a: null, b: null, on: false}}; let Q = {}; // ---------- storage (every access guarded) ---------- @@ -599,6 +599,7 @@

Interview

} const unseenIds = () => new Set(unseenActs().flatMap(e => e.ids || [])); const landedIds = () => new Set(unseenActs().filter(e => e.added).flatMap(e => e.ids || [])); + const arrivalKeys = () => new Set(acts().map(actKey).concat(noteThread().filter(n => n.by === "claude").map(n => "n" + n.at + "|" + n.text))); function latestClaude(q){ const h = historyOf(q)[0]; return h && h.by === "claude" && String(h.text).trim() !== "Asked." ? h : null; } const upOf = q => (q.dependsOn || []).filter(p => Q[p]); @@ -755,7 +756,6 @@

Interview

$("eyebrow").textContent = m.eyebrow || (headRound() ? "" : "Interview"); $("roundLbl").textContent = headRound(); $("title").textContent = m.title || "Interview"; - document.title = m.title || "Interview"; const l = S.doc.questions.filter(live), done = l.filter(q => isAnswered(stateOf(q))).length, wn = l.filter(q => stateOf(q) === "wait").length; $("meterText").textContent = done + " of " + l.length + " answered"; $("meterBar").style.width = l.length ? (100 * done / l.length) + "%" : "0%"; @@ -776,6 +776,8 @@

Interview

nb.hidden = !unread || S.fly === "notes"; nb.textContent = unread; const un = unseenActs().length, ab = $("actBadge"); ab.hidden = !un || S.fly === "activity"; ab.textContent = un; + const away = document.hidden && S.hiddenBase ? [...arrivalKeys()].filter(k => !S.hiddenBase.has(k)).length : 0; // what landed since the tab was hidden, whether or not a flyout marked it seen + document.title = (away > 0 ? "(" + away + ") " : "") + (m.title || "Interview"); const vb = $("visBadge"), vn = S.sel && S.sel !== SUMMARY && Q[S.sel] ? visualsFor(Q[S.sel]).length : 0; vb.hidden = !vn || S.fly === "visuals"; vb.textContent = vn; } @@ -1597,6 +1599,7 @@

Interview

const saved = lsGet("sel", null); const pick = saved === SUMMARY || (saved && Q[saved]) ? saved : allDone() ? SUMMARY : (railOrder().find(id => isOpen(stateOf(Q[id]))) || railOrder()[0] || null); if (pick) select(pick); else { renderRail(); renderDetail(); } + if (document.hidden) S.hiddenBase = arrivalKeys(); // a tab opened in the background fires no visibilitychange renderHeader(); renderFly(); return; } @@ -1639,7 +1642,11 @@

Interview

function startPoll(){ if (pollTimer) return; pollTimer = setInterval(() => refresh().then(() => { setOnline(true); if (!es || es.readyState === 2) connect(); }).catch(() => setOnline(false)), 3000); } function stopPoll(){ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } } setInterval(() => { if (es && Date.now() - lastFrame > SILENT_MS) { es.close(); es = null; startPoll(); } }, 5000); - document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") catchUp(); }); + document.addEventListener("visibilitychange", () => { + if (document.hidden) { S.hiddenBase = arrivalKeys(); return; } + S.hiddenBase = null; renderHeader(); + if (document.visibilityState === "visible") catchUp(); + }); // ---------- events ---------- document.addEventListener("click", e => { diff --git a/plugins/planning/surface/surface.test.sh b/plugins/planning/surface/surface.test.sh index 93d5d303de..5c85b50798 100755 --- a/plugins/planning/surface/surface.test.sh +++ b/plugins/planning/surface/surface.test.sh @@ -187,13 +187,13 @@ if command -v playwright-cli >/dev/null 2>&1; then pw run-code --filename "$(script_path "$tmp/ui_c5.js")" >"$tmp/ui_c5.out" 2>&1 # The journey runs against a fifth server seeded with an empty interview. It walks the whole - # flow on one page in seven phases; the shell writes as Claude between them. + # flow on one page in thirteen phases; the shell writes as Claude between them. mkdir -p "$j/ops" cp tests/fixtures/journey/questions.json tests/fixtures/journey/responses.json "$j/" bash "$here/round.sh" --dir "$j" add-round --file tests/fixtures/journey/round1.json --round 1 >/dev/null bash "$here/round.sh" --dir "$j" ensure-running --port 0 >/dev/null jport=$(sed -n 's/^PORT=//p' "$j/.interview-session.env" | tr -d '\r') - for n in 1 2 3 4 5 6 7; do + for n in 1 2 3 4 5 6 7 8 9 10 11 12 13; do sed "s/__PORT__/$jport/; s/__PHASE__/$n/" tests/ui_journey.js >"$tmp/uj$n.js" done jhandle() { @@ -252,13 +252,23 @@ if command -v playwright-cli >/dev/null 2>&1; then jrun 6 japply h '{"ops": [{"op": "wait", "id": "Q3", "clear": true}, {"op": "set-status", "clear": true}]}' jrun 7 + japply i '{"ops": [{"op": "activity", "text": "Checked the cache key while you were looking"}]}' + jrun 8 + japply j '{"ops": [{"op": "activity", "text": "Checked the cache key again while the tab was hidden"}]}' + jrun 9 + jrun 10 + japply k '{"ops": [{"op": "activity", "text": "Checked the cache key with the panel open"}]}' + jrun 11 + jrun 12 + japply l '{"ops": [{"op": "activity", "text": "Checked the cache key after the page loaded hidden"}]}' + jrun 13 grade ui_a "$tmp/ui_a.out" grade ui_b "$tmp/ui_b.out" for n in 1 2 3 4 5; do grade "ui_c.$n" "$tmp/ui_c$n.out"; done - for n in 1 2 3 4 5 6 7; do grade "ui_journey.$n" "$tmp/uj$n.out"; done + for n in 1 2 3 4 5 6 7 8 9 10 11 12 13; do grade "ui_journey.$n" "$tmp/uj$n.out"; done else - echo "SKIP: 284 browser checks not run, 100 of them the journey (playwright-cli not found)" # silent-skip-ok: browser checks need a local playwright-cli # discriminating-skip-ok: the API, watcher and hygiene checks above still grade this suite - skip=$((skip + 284)) + echo "SKIP: 298 browser checks not run, 114 of them the journey (playwright-cli not found)" # silent-skip-ok: browser checks need a local playwright-cli # discriminating-skip-ok: the API, watcher and hygiene checks above still grade this suite + skip=$((skip + 298)) fi echo "PASS=$pass FAIL=$fail SKIP=$skip" diff --git a/plugins/planning/surface/tests/ui_journey.js b/plugins/planning/surface/tests/ui_journey.js index f75ecfa240..646fd783b1 100644 --- a/plugins/planning/surface/tests/ui_journey.js +++ b/plugins/planning/surface/tests/ui_journey.js @@ -317,6 +317,47 @@ async page => { // the user journey in order on one page, no reload after phase const prompt = await until(() => document.getElementById("pill").textContent === "Not listening: type next", 5000); ok("once an event waits on Claude the pill says Not listening: type next", prompt && await page.$eval("#pill", el => el.className === "pill idle"), await text("#pill")); } + const setHidden = h => page.evaluate(h => { + for (const [k, v] of [["hidden", h], ["visibilityState", h ? "hidden" : "visible"]]) Object.defineProperty(document, k, {configurable: true, get: () => v}); + document.dispatchEvent(new Event("visibilitychange")); + }, h); + if (PHASE === 8) { // the shell added activity while the tab was visible + await page.waitForTimeout(900); + const base = await text("#title"); + ok("on a visible tab new activity leaves the title unchanged", (await badge()) > 0 && await page.title() === base, await page.title()); + await setHidden(true); await page.waitForTimeout(200); + ok("hiding the tab adds no badge for activity already waiting", await page.title() === base, await page.title()); + } + if (PHASE === 9) { // the shell added one activity entry while the tab was hidden + await page.waitForTimeout(900); + const base = await text("#title"); + ok("activity that lands while the tab is hidden prefixes the title with a count", await page.title() === "(1) " + base, await page.title()); + await setHidden(false); await page.waitForTimeout(200); + ok("showing the tab again restores the plain title", await page.title() === base, await page.title()); + } + if (PHASE === 10) { // Activity panel left open, then the tab is hidden + await page.click("#title"); await page.keyboard.press("l"); await page.waitForTimeout(300); + const base = await text("#title"); + await setHidden(true); await page.waitForTimeout(200); + ok("hiding the tab with the Activity panel open adds no badge", await page.title() === base && await page.$eval("#fly", el => el.classList.contains("open")), await page.title()); + } + if (PHASE === 11) { // the shell added activity while the tab was hidden and the panel open + await page.waitForTimeout(4000); + const base = await text("#title"); + ok("an open Activity panel marking entries seen does not hide the title count", await page.title() === "(1) " + base, await page.title()); + await setHidden(false); await page.waitForTimeout(200); + } + if (PHASE === 12) { // reload so the page loads already hidden: no visibilitychange fires + await page.addInitScript(() => { for (const [k, v] of [["hidden", true], ["visibilityState", "hidden"]]) Object.defineProperty(document, k, {configurable: true, get: () => v}); }); + await page.reload(); await page.waitForSelector(".qbtn", {state: "attached"}); await page.waitForTimeout(900); + ok("a page loaded hidden shows no badge for activity already waiting", await page.title() === await text("#title"), await page.title()); + } + if (PHASE === 13) { // the shell added one activity entry after the page loaded hidden + await page.waitForTimeout(4000); + const base = await text("#title"); + ok("activity landing on a page loaded hidden prefixes the title with a count", await page.title() === "(1) " + base, await page.title()); + await setHidden(false); await page.waitForTimeout(200); + } const real = errors.filter(e => !/status of 409 \(Conflict\)/.test(e) && !/ERR_INTERNET_DISCONNECTED/.test(e)); ok("zero console errors in journey phase " + PHASE + " (besides the network lines for an intended 409 and the offline step)", real.length === 0, errors.join(" | ")); } catch (e) { R.push("ERROR " + e.message.split("\n").slice(0, 3).join(" | ")); }