diff --git a/plugins/planning/.claude-plugin/plugin.json b/plugins/planning/.claude-plugin/plugin.json index e49d4eade9..5729f3bd89 100644 --- a/plugins/planning/.claude-plugin/plugin.json +++ b/plugins/planning/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "planning", - "version": "0.65.1", + "version": "0.65.2", "userConfig": { "surface": { "type": "string", diff --git a/plugins/planning/CHANGELOG.md b/plugins/planning/CHANGELOG.md index bbdd9e27e5..532901eda7 100644 --- a/plugins/planning/CHANGELOG.md +++ b/plugins/planning/CHANGELOG.md @@ -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 diff --git a/plugins/planning/surface/index.html b/plugins/planning/surface/index.html index eab8646316..3fa1ff7f45 100644 --- a/plugins/planning/surface/index.html +++ b/plugins/planning/surface/index.html @@ -403,7 +403,7 @@

Interview

- +
@@ -659,7 +659,7 @@

Interview

} 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", [])); @@ -700,8 +700,9 @@

Interview

} 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; @@ -755,12 +756,12 @@

Interview

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("also after ' + t.extra[id].map(esc).join(", ") + "" : ""; - const kids = (t.kids[id] || []).map(node).join(""); return "
  • " + h + also + (kids ? '" : "") + "
  • "; }; return '"; @@ -780,7 +781,7 @@

    Interview

    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 += '
    ' + - '
    " + + '
    " + (elig.length && aa ? '" : "") + (elig.length && s.round && !s.gid ? '' : "") + "
    " + (s.lock.length ? '
    Locked: opens after ' + s.lock.map(g => esc(groupTitle(g))).join(", ") + "
    " : "") + @@ -793,9 +794,10 @@

    Interview

    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) + '"]'); @@ -1551,8 +1553,8 @@

    Interview

    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 @@ -1874,7 +1876,7 @@

    Interview

    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 diff --git a/plugins/planning/surface/tests/ui_c.js b/plugins/planning/surface/tests/ui_c.js index 866de06037..5893c027a4 100644 --- a/plugins/planning/surface/tests/ui_c.js +++ b/plugins/planning/surface/tests/ui_c.js @@ -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")); diff --git a/plugins/planning/surface/tests/ui_journey.js b/plugins/planning/surface/tests/ui_journey.js index f2a1fdf970..af58f8c287 100644 --- a/plugins/planning/surface/tests/ui_journey.js +++ b/plugins/planning/surface/tests/ui_journey.js @@ -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);