From 65d666cbb16703b0fe6d80f0b3d2d0b7401df71e Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:23:31 -0400 Subject: [PATCH 1/6] feat(planning): badge the tab title with activity that lands while hidden Refs #5473 Co-Authored-By: Claude Opus 5.5 --- plugins/planning/surface/index.html | 11 ++++++++--- plugins/planning/surface/surface.test.sh | 14 +++++++++----- plugins/planning/surface/tests/ui_journey.js | 18 ++++++++++++++++++ 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/plugins/planning/surface/index.html b/plugins/planning/surface/index.html index c573fbac3b..726e8f75d0 100644 --- a/plugins/planning/surface/index.html +++ b/plugins/planning/surface/index.html @@ -455,7 +455,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) ---------- @@ -749,7 +749,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%"; @@ -770,6 +769,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 !== null ? un + unread - S.hiddenBase : 0; // only activity that lands while the tab is hidden + 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; } @@ -1632,7 +1633,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 = unseenActs().length + noteThread().filter(n => n.by === "claude" && Date.parse(n.at) > S.noteSeen).length; 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 391142b181..45590a9486 100755 --- a/plugins/planning/surface/surface.test.sh +++ b/plugins/planning/surface/surface.test.sh @@ -186,13 +186,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 nine 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; do sed "s/__PORT__/$jport/; s/__PHASE__/$n/" tests/ui_journey.js >"$tmp/uj$n.js" done jhandle() { @@ -251,13 +251,17 @@ 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 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; 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: 290 browser checks not run, 106 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 + 290)) 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 359fa1f618..0ab61e711f 100644 --- a/plugins/planning/surface/tests/ui_journey.js +++ b/plugins/planning/surface/tests/ui_journey.js @@ -313,6 +313,24 @@ 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()); + } 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(" | ")); } From e5078ff8e1769b32a4c7698b1868475a738a4d53 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:30:19 -0400 Subject: [PATCH 2/6] feat(planning): document the hidden-tab title badge and release 0.48.0 Co-Authored-By: Claude Opus 5.5 --- plugins/planning/.claude-plugin/plugin.json | 2 +- plugins/planning/CHANGELOG.md | 6 ++++++ plugins/planning/surface/README.md | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/plugins/planning/.claude-plugin/plugin.json b/plugins/planning/.claude-plugin/plugin.json index ce92e5f000..aa6af30bfc 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.47.3", + "version": "0.48.0", "userConfig": { "surface": { "type": "string", diff --git a/plugins/planning/CHANGELOG.md b/plugins/planning/CHANGELOG.md index 518e347026..55dad06d49 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.48.0] - 2026-09-29 + +### 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.47.3] - 2026-09-29 ### Added diff --git a/plugins/planning/surface/README.md b/plugins/planning/surface/README.md index 50b9c45bd0..5d69c84784 100644 --- a/plugins/planning/surface/README.md +++ b/plugins/planning/surface/README.md @@ -28,7 +28,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 with absolute paths, each 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 with absolute paths, each 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. From 1226de4fa47eb7cd7e7a5c512bafe8cd0a7fd159 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:56:24 -0400 Subject: [PATCH 3/6] fix(planning): blank line between changelog entries --- plugins/planning/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/planning/CHANGELOG.md b/plugins/planning/CHANGELOG.md index 9ff9233e9d..30e95ef6b2 100644 --- a/plugins/planning/CHANGELOG.md +++ b/plugins/planning/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to the `planning` plugin are documented here. Format follows ### 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.47.4] - 2026-09-29 ### Fixed From 33e1f38adf9688312b9b3e2a8c05a145ad906bce Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:14:07 -0400 Subject: [PATCH 4/6] fix(planning): drop duplicated version key from merge Co-Authored-By: Claude Opus 5.5 --- plugins/planning/.claude-plugin/plugin.json | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/planning/.claude-plugin/plugin.json b/plugins/planning/.claude-plugin/plugin.json index b6fd13d121..aa6af30bfc 100644 --- a/plugins/planning/.claude-plugin/plugin.json +++ b/plugins/planning/.claude-plugin/plugin.json @@ -2,7 +2,6 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "planning", "version": "0.48.0", - "version": "0.47.9", "userConfig": { "surface": { "type": "string", From eb9d803365cff5d165152154042285885e62fca3 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:09:57 -0400 Subject: [PATCH 5/6] test(planning): cover the title badge on a page loaded hidden Co-Authored-By: Claude Opus 5.5 --- plugins/planning/surface/surface.test.sh | 13 ++++++++----- plugins/planning/surface/tests/ui_journey.js | 11 +++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/plugins/planning/surface/surface.test.sh b/plugins/planning/surface/surface.test.sh index b443acbca5..b5127b00df 100755 --- a/plugins/planning/surface/surface.test.sh +++ b/plugins/planning/surface/surface.test.sh @@ -186,13 +186,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 eleven 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 8 9 10 11; 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() { @@ -258,13 +258,16 @@ if command -v playwright-cli >/dev/null 2>&1; then 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 8 9 10 11; 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: 294 browser checks not run, 110 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 + 294)) + 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 29540f45ec..b709b288fc 100644 --- a/plugins/planning/surface/tests/ui_journey.js +++ b/plugins/planning/surface/tests/ui_journey.js @@ -345,6 +345,17 @@ async page => { // the user journey in order on one page, no reload after phase 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(" | ")); } From 81cdc3912a3954e2dfd55f262db913853197f3ca Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:47:12 -0400 Subject: [PATCH 6/6] chore(planning): retrigger CI Co-Authored-By: Claude Opus 5.5