Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
65d666c
feat(planning): badge the tab title with activity that lands while hi…
kyle-sexton Sep 30, 2026
737ff5c
Merge remote-tracking branch 'origin/main' into feat/5473-hidden-tab-…
kyle-sexton Sep 30, 2026
e5078ff
feat(planning): document the hidden-tab title badge and release 0.48.0
kyle-sexton Sep 30, 2026
99d0957
Merge remote-tracking branch 'origin/main' into feat/5473-hidden-tab-…
kyle-sexton Sep 30, 2026
5bd7292
Merge remote-tracking branch 'origin/main' into feat/5473-hidden-tab-…
kyle-sexton Sep 30, 2026
1226de4
fix(planning): blank line between changelog entries
kyle-sexton Sep 30, 2026
a356b56
fix(planning): title badge counts arrivals independent of flyout seen…
kyle-sexton Sep 30, 2026
fdffbb1
Merge origin/main into feat/5473-hidden-tab-title-badge
kyle-sexton Sep 30, 2026
33e1f38
fix(planning): drop duplicated version key from merge
kyle-sexton Sep 30, 2026
eb9d803
test(planning): cover the title badge on a page loaded hidden
kyle-sexton Sep 30, 2026
81cdc39
chore(planning): retrigger CI
kyle-sexton Sep 30, 2026
a05ae54
Merge origin/main into feat/5473-hidden-tab-title-badge
kyle-sexton Sep 30, 2026
68eb59e
merge: origin/main into feat/5473-hidden-tab-title-badge
kyle-sexton Sep 30, 2026
e68576d
merge: origin/main into feat/5473-hidden-tab-title-badge
kyle-sexton Sep 30, 2026
8eafe22
merge: origin/main into feat/5473-hidden-tab-title-badge
kyle-sexton Sep 30, 2026
bd7d394
merge: origin/main into feat/5473-hidden-tab-title-badge
kyle-sexton Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/planning/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 6 additions & 0 deletions plugins/planning/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion plugins/planning/surface/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ bash round.sh --dir '<data_dir>' stop

## Watcher protocol

`bash watch.sh '<data_dir>'` long-polls `/api/wait?after=handled&replayed=<n>` with the token, where `<n>` 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 '<here>/wake.sh' '<data_dir>'`, 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 '<data_dir>'` long-polls `/api/wait?after=handled&replayed=<n>` with the token, where `<n>` 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 '<here>/wake.sh' '<data_dir>'`, 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=<id>`, 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 `<hostname>-<parent pid>` (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.

Expand Down
13 changes: 10 additions & 3 deletions plugins/planning/surface/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@ <h1 id="title">Interview</h1>
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}};
Comment thread
kyle-sexton marked this conversation as resolved.
let Q = {};

// ---------- storage (every access guarded) ----------
Expand Down Expand Up @@ -599,6 +599,7 @@ <h1 id="title">Interview</h1>
}
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]);

Expand Down Expand Up @@ -755,7 +756,6 @@ <h1 id="title">Interview</h1>
$("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%";
Expand All @@ -776,6 +776,8 @@ <h1 id="title">Interview</h1>
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;
}
Expand Down Expand Up @@ -1597,6 +1599,7 @@ <h1 id="title">Interview</h1>
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
Comment thread
kyle-sexton marked this conversation as resolved.
renderHeader(); renderFly();
return;
}
Expand Down Expand Up @@ -1639,7 +1642,11 @@ <h1 id="title">Interview</h1>
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 => {
Expand Down
20 changes: 15 additions & 5 deletions plugins/planning/surface/surface.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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"
Expand Down
41 changes: 41 additions & 0 deletions plugins/planning/surface/tests/ui_journey.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(" | ")); }
Expand Down
Loading