Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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,6 +1,6 @@
{
"name": "planning",
"version": "0.65.1",
"version": "0.65.2",
"userConfig": {
"surface": {
"type": "string",
Expand Down
12 changes: 12 additions & 0 deletions plugins/planning/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@
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.65.2] - 2026-10-02

### Fixed

- The interview page's **Show: Open** lists only unanswered questions. Answered questions that
Claude revised or replied to after the answer move to a new **Review** filter, and the group
counter reads `N open / M, K to review`.
- Tree view drops a filtered-out question and moves its matching descendants up a level instead of
keeping every answered question that has dependents.
- After an answer, the page moves to the next open question after the one just answered instead of
the first in the list.

## [0.65.1] - 2026-10-02

### Changed
Expand Down
28 changes: 15 additions & 13 deletions plugins/planning/surface/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ <h1 id="title">Interview</h1>
<button data-view="groups">Groups</button><button data-view="rounds">Rounds</button><button data-view="tree">Tree</button>
</div>
<div class="trow">
<label>Show <select id="filter" aria-keyshortcuts="/"><option value="all">All</option><option value="open">Open</option><option value="pending">Pending</option><option value="answered">Answered</option></select></label>
<label>Show <select id="filter" aria-keyshortcuts="/"><option value="all">All</option><option value="open">Open</option><option value="review">Review</option><option value="pending">Pending</option><option value="answered">Answered</option></select></label>
<span class="order-note" id="orderNote"></span>
</div>
</div>
Expand Down Expand Up @@ -659,7 +659,7 @@ <h1 id="title">Interview</h1>
}
function passes(q){
const st = stateOf(q);
return S.filter === "all" || (S.filter === "open" && needsYou(q)) || (S.filter === "pending" && (st === "wait" || st === "need" || q.state === "upstream-pending" || waitingReply(q))) || (S.filter === "answered" && isAnswered(st));
return S.filter === "all" || (S.filter === "open" && needsYou(q) && !isAnswered(st)) || (S.filter === "review" && needsYou(q) && isAnswered(st)) || (S.filter === "pending" && (st === "wait" || st === "need" || q.state === "upstream-pending" || waitingReply(q))) || (S.filter === "answered" && isAnswered(st));
}
const opened = () => new Set(lsGet("opened", []));

Expand Down Expand Up @@ -700,8 +700,9 @@ <h1 id="title">Interview</h1>
}
const landed = landedIds();
secs.forEach(s => { // open or waiting sections start expanded; a stored toggle applies only while its default holds; a new round's section opens
s.open = s.items.filter(q => stateOf(q) === "wait" || needsYou(q)).length;
s.done = s.items.length > 0 && s.open === 0;
s.open = s.items.filter(q => !isAnswered(stateOf(q)) && (stateOf(q) === "wait" || needsYou(q))).length;
s.review = s.items.filter(q => isAnswered(stateOf(q)) && needsYou(q)).length;
s.done = s.items.length > 0 && s.open === 0 && s.review === 0;
s.fresh = s.items.some(q => landed.has(q.id));
const o = lsGet("col:" + s.key, null), dflt = s.done;
s.collapsed = s.fresh ? false : o && o.d === dflt ? o.c : dflt;
Expand Down Expand Up @@ -755,12 +756,12 @@ <h1 id="title">Interview</h1>
const t = treeData(), on = pathOf(t, S.sel), seen = new Set(), fresh = unseenIds();
const node = id => {
if (seen.has(id)) return ""; seen.add(id);
const q = Q[id]; if (!passes(q) && S.filter !== "all" && !(t.kids[id] || []).length) return "";
const q = Q[id], kids = (t.kids[id] || []).map(node).join("");
if (!passes(q) && S.filter !== "all") return kids; // a filtered-out node drops out and its matching descendants move up a level
let h = railItem(q, null, {}, fresh);
h = h.replace('class="qbtn', 'class="qbtn' + (on.has(id) && S.sel && S.sel !== SUMMARY ? " onpath" : ""));
if (!clickable) h = h.replace("<button", '<button tabindex="-1"');
const also = (t.extra[id] || []).length ? '<div class="also">also after ' + t.extra[id].map(esc).join(", ") + "</div>" : "";
const kids = (t.kids[id] || []).map(node).join("");
return "<li>" + h + also + (kids ? '<ul class="tree">' + kids + "</ul>" : "") + "</li>";
};
return '<ul class="tree">' + t.roots.map(node).join("") + "</ul>";
Expand All @@ -780,7 +781,7 @@ <h1 id="title">Interview</h1>
if (!items.length && S.filter !== "all") continue;
const elig = s.items.filter(q => op.has(q.id) && eligible(q)), aa = s.gid ? 'data-acceptall="' + esc(s.gid) + '"' : s.round ? 'data-acceptround="' + esc(s.round) + '"' : "";
html += '<div class="sec' + (s.pin ? " pin" : "") + (s.lock.length ? " locked" : "") + (s.fresh ? " fresh" : "") + '" data-key="' + esc(s.key) + '" data-collapsed="' + s.collapsed + '" data-dflt="' + s.done + '">' +
'<div class="sec-top"><button class="sec-h" aria-expanded="' + !s.collapsed + '"><span class="caret">&#9662;</span><h2>' + esc(s.title) + '</h2><span class="cnt">' + s.open + " open / " + s.items.length + "</span></button>" +
'<div class="sec-top"><button class="sec-h" aria-expanded="' + !s.collapsed + '"><span class="caret">&#9662;</span><h2>' + esc(s.title) + '</h2><span class="cnt">' + s.open + " open / " + s.items.length + (s.review ? ", " + s.review + " to review" : "") + "</span></button>" +
(elig.length && aa ? '<button class="tbtn small" ' + aa + ' data-submit="1" title="Accept the recommendation on the open questions you have opened here">Accept all (' + elig.length + ")</button>" : "") +
(elig.length && s.round && !s.gid ? '<button class="tbtn small" data-auditround="' + esc(s.round) + '" data-submit="1" title="Accept the recommendation on the open questions you have opened here and have agents check the answers">Accept all and have agents check them</button>' : "") + "</div>" +
(s.lock.length ? '<div class="lock">Locked: opens after ' + s.lock.map(g => esc(groupTitle(g))).join(", ") + "</div>" : "") +
Expand All @@ -793,9 +794,10 @@ <h1 id="title">Interview</h1>
list.scrollTop = top;
(flashIds || []).forEach(id => list.querySelectorAll('.qbtn[data-q="' + CSS.escape(id) + '"]').forEach(el => el.classList.add("flash")));
}
function railOrder(){
if (S.view === "tree") { const t = treeData(), out = [], walk = id => { if (out.includes(id)) return; out.push(id); t.kids[id].forEach(walk); }; t.roots.forEach(walk); return out.filter(id => passes(Q[id])); }
return sections().secs.flatMap(s => s.items.filter(passes).map(q => q.id));
function railOrder(all){ // all: every question in rail order, so a just-answered question still marks the position
const keep = id => all || passes(Q[id]);
if (S.view === "tree") { const t = treeData(), out = [], walk = id => { if (out.includes(id)) return; out.push(id); t.kids[id].forEach(walk); }; t.roots.forEach(walk); return out.filter(keep); }
return sections().secs.flatMap(s => s.items.map(q => q.id)).filter(keep);
}
function revealInRail(id){
const list = $("railList"), el = list.querySelector(id === SUMMARY ? ".sumbtn" : '.qbtn[data-q="' + CSS.escape(id) + '"]');
Expand Down Expand Up @@ -1551,8 +1553,8 @@ <h1 id="title">Interview</h1>
return need.find(id => !blocked(Q[id])) || need[0] || order[0] || null;
}
function nextNeeding(dir){ // next (1) or previous (-1) item needing you, in rail order
const order = railOrder(), len = order.length, i = order.indexOf(S.sel);
for (let k = 1; k <= len; k++) { const id = order[(((i < 0 && dir < 0 ? len : i) + dir * k) % len + len) % len]; if (id !== S.sel && needsYou(Q[id])) return id; }
const order = railOrder(true), len = order.length, i = order.indexOf(S.sel);
for (let k = 1; k <= len; k++) { const id = order[(((i < 0 && dir < 0 ? len : i) + dir * k) % len + len) % len]; if (id !== S.sel && needsYou(Q[id]) && passes(Q[id])) return id; }
return null;
}
function armKind(kind){ // a arms Reconfirm when there is one, else Accept
Expand Down Expand Up @@ -1874,7 +1876,7 @@ <h1 id="title">Interview</h1>
if (first) {
S.loaded = true;
S.view = lsGet("view", "groups"); if (!["groups", "rounds", "tree"].includes(S.view)) S.view = "groups";
S.filter = lsGet("filter", "all"); if (!["all", "open", "pending", "answered"].includes(S.filter)) S.filter = "all";
S.filter = lsGet("filter", "all"); if (!["all", "open", "review", "pending", "answered"].includes(S.filter)) S.filter = "all";
S.fly = lsGet("fly", null); if (!["visuals", "notes", "activity", "settings"].includes(S.fly)) S.fly = null;
S.pinned = lsGet("pinned", false); S.railHidden = lsGet("railHidden", false); S.noteSeen = lsGet("noteSeen", 0);
if (lsGet("actSeen", null) === null) markSeen(); // a new browser starts with everything seen
Expand Down
9 changes: 6 additions & 3 deletions plugins/planning/surface/tests/ui_c.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,14 +162,17 @@ async page => {
await page.keyboard.press("Shift+N");
ok("AC16: Shift+N goes back (P2)", await sel() === "P2", await sel());

// R1 holds an accept followed by Claude's reply to an ask: it still needs you, so Show: Open lists it and the group counts it
// R1 holds an accept followed by Claude's reply to an ask: it is answered, so Show: Open leaves it out and Show: Review lists it
await page.selectOption("#filter", "open"); await page.waitForTimeout(200);
const openIds = await page.$$eval(".rail-list .qbtn", els => els.map(e => e.dataset.q));
ok("Show: Open lists an accepted question with an unanswered Claude reply", openIds.includes("R1"), openIds.join(","));
ok("Show: Open leaves out an accepted question with an unanswered Claude reply", !openIds.includes("R1"), openIds.join(","));
ok("Show: Open leaves out a settled question", !openIds.includes("P1"), openIds.join(","));
ok("Show: Open leaves out a question held for research, whose newest Claude line is the hold", !openIds.includes("H1"), openIds.join(","));
await page.selectOption("#filter", "review"); await page.waitForTimeout(200);
const reviewIds = await page.$$eval(".rail-list .qbtn", els => els.map(e => e.dataset.q));
ok("Show: Review lists the accepted question with an unanswered Claude reply and no unanswered one", reviewIds.includes("R1") && !reviewIds.includes("P1"), reviewIds.join(","));
ok("no Sent to Claude chip once a reply carries replyTo at or past the last event", !/Sent to Claude/.test(await page.textContent('.qbtn[data-q="R1"]')), await page.textContent('.qbtn[data-q="R1"]'));
ok("the group counter counts the unanswered reply", /^1 open \//.test(await page.textContent('.sec[data-key="g:talk"] .cnt')), await page.textContent('.sec[data-key="g:talk"] .cnt'));
ok("the group counter counts the unanswered reply as to review, not open", /^0 open \/ \d+, 1 to review/.test(await page.textContent('.sec[data-key="g:talk"] .cnt')), await page.textContent('.sec[data-key="g:talk"] .cnt'));
ok("a reply after the accept puts the after-answer chip on the card", /Replied after your answer/.test(await page.textContent('.qbtn[data-q="R1"]')), await page.textContent('.qbtn[data-q="R1"]'));
await pick("R1");
ok("an accepted question has input:checked on the recommended row, labeled Your answer", await page.$eval("#choices .choice.rec", el => el.querySelector("input:checked") !== null && /Your answer/.test(el.textContent)), await page.textContent("#choices"));
Expand Down
4 changes: 3 additions & 1 deletion plugins/planning/surface/tests/ui_journey.js
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,11 @@ async page => { // the user journey in order on one page, no reload after phase
const f4 = await page.$eval("#filter", el => el.value);
await page.selectOption("#filter", "open"); await page.waitForTimeout(200);
const open4 = (await page.$$eval(".rail-list .qbtn", els => els.map(e => e.dataset.q))).join(",");
await page.selectOption("#filter", "review"); await page.waitForTimeout(200);
const review4 = (await page.$$eval(".rail-list .qbtn", els => els.map(e => e.dataset.q))).join(",");
await page.selectOption("#filter", f4); await page.waitForTimeout(150);
const cards4 = await text('.qbtn[data-q="Q2"]') + await text('.qbtn[data-q="Q4"]');
ok("Claude's confirm-commitments line after an answer is not a reply: Q2 and Q4 stay settled, off Show: Open and without the after-answer chip", !/Q[24]/.test(open4) && !/after your answer/.test(cards4) && /Q3/.test(open4) && (await text("#meterText")) === "4 of 7 answered", open4 + " / " + cards4 + " / " + await text("#meterText"));
ok("Claude's confirm-commitments line after an answer is not a reply: Q2 and Q4 stay settled, off Show: Open and Show: Review and without the after-answer chip; Q3, answered with a later reply, is under Review", !/Q[24]/.test(open4 + review4) && !/after your answer/.test(cards4) && /Q3/.test(review4) && !/Q3/.test(open4) && (await text("#meterText")) === "4 of 7 answered", open4 + " / " + review4 + " / " + cards4 + " / " + await text("#meterText"));
ok("Wrap up warns while assumptions are open and stays enabled", /^3 assumptions not confirmed yet\.$/.test(await text("#openAssumeWarn")) && !(await page.$eval('[data-wrapup="1"]', el => el.disabled)), await text("#openAssumeWarn"));
const n1 = (await events()).length;
await tap("[data-confirmall]", 1500);
Expand Down
Loading