From 99b32687afee76b0ef14a25949ad760d379993ef Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:36:27 -0400 Subject: [PATCH 01/10] feat(planning): link bare issue refs on the interview page via meta.repo The page links owner/repo#N to that repo's issue and a bare #N to meta.repo's, leaving [text](url) links, Qn refs and code spans as written. meta.repo is an allowed meta key in the file and the meta op. round.py warns, without blocking, when question or op text carries a bare #N and meta.repo is unset. Refs: #5569 Co-Authored-By: Claude Sonnet 5.5 --- .../skills/interview/context/surface.md | 4 +-- plugins/planning/surface/README.md | 2 +- plugins/planning/surface/index.html | 4 +++ plugins/planning/surface/round.py | 33 +++++++++++++++++-- .../planning/surface/schema/ops.schema.json | 3 +- .../surface/schema/questions.schema.json | 1 + plugins/planning/surface/test_round.py | 25 ++++++++++++++ plugins/planning/surface/test_schema.py | 12 +++++++ plugins/planning/surface/tests/round-d3.json | 3 +- plugins/planning/surface/tests/ui_a.js | 2 ++ 10 files changed, 82 insertions(+), 7 deletions(-) diff --git a/plugins/planning/skills/interview/context/surface.md b/plugins/planning/skills/interview/context/surface.md index b6f4dd66f1..d142282997 100644 --- a/plugins/planning/skills/interview/context/surface.md +++ b/plugins/planning/skills/interview/context/surface.md @@ -25,7 +25,7 @@ The page is the input surface SKILL.md "Question surface: the page" selects. The - **Start** with the command in SKILL.md (it carries the configured emoji setting). It prints the page URL; give that URL to the user. A missing prerequisite exits non-zero with its name: take the degrade below. - **Ids.** Page question ids are `Q` on the session's one continuous counter, so the register rows written at ask-time match what `export-ledger` emits (it renumbers any other id set). When the register already has rows as the page starts (earlier terminal rounds, or a resumed topic whose data dir was discarded), seed the still-empty data dir with `round.sh import-ledger --ledger '//interview-checklist.md'` before the first `add-round`; it keeps each row's `Q` and decision. `export-ledger` writes each row's resolution as escaped named fields in a fixed order (`hold`, `proposal`, `was`, `answer`, `note`, `aside`, `commitments`; the grammar is in `surface/exporters.py`), and import restores them: the hold, a superseded-by-plan row's proposal and displaced answer, the decision on any status (an accept or a defer on a superseded-by-plan row too), the note, a decision a user hold set aside (restored still set aside), and every commitment with its confirmed or unconfirmed mark. An unknown field or a contradictory pair is refused, naming the field. Every older grammar still imports, read as it always was: `waits on::`, `awaiting user::`, `confirmed::`, `plan proposes::` and `answer::` rows, a settled row's `; confirmed::` tail, and the unescaped `waits on:`, `confirmed:` and `; confirmed:` forms. A hand-written resolution imports as the row's own text. - **A round.** Write the frontier to `'/round-.json'` (`{"meta": {...}, "groups": [...], "questions": [...], "visuals": [...]}`), run `round.sh add-round --file '/round-.json' --round `, and write the register's `open` rows in the same step. Each question carries `recommendation` (one line), `basis` (2-3 sentences, shown behind Why), at least two `alternatives` (`{key, text}`), `commits` (what accepting commits the user to, or `[]`), and `dependsOn` for its prerequisites. Send the round's closing constraint probe with a `note-reply` op (no `seq`) so it lands in Notes to Claude, or as a Claude thread line on the round's first question. `add` and `add-round` refuse a question without `commits` or with fewer than two alternatives. -- **Meta.** `meta` takes `title`, `eyebrow`, `stages` and `next`. `stages` is an object mapping each stage key to its label, for example `"stages": {"frame": "Frame", "decide": "Decide"}`; a list is refused (`$.meta.stages: expected object, got list`). Set with `add-round`'s `meta` object or the `meta` op; any other key is refused. `meta.next` is what Claude does after wrap-up, shown on the finished screen. Keep round numbers out of `meta.eyebrow`: the page derives the round label (` round N`) from the questions and shows it beside the eyebrow, so a number in the eyebrow goes stale at the next round. +- **Meta.** `meta` takes `title`, `eyebrow`, `stages`, `next` and `repo`. `stages` is an object mapping each stage key to its label, for example `"stages": {"frame": "Frame", "decide": "Decide"}`; a list is refused (`$.meta.stages: expected object, got list`). Set with `add-round`'s `meta` object or the `meta` op; any other key is refused. `meta.next` is what Claude does after wrap-up, shown on the finished screen. `meta.repo` (`owner/repo`) makes the page link a bare `#N` in any markdown field to that repo's issue; `owner/repo#N` always links to its own repo, and `[text](url)` links, `Qn` refs and code spans are left as written. A bare `#N` with `meta.repo` unset stays plain text and `add`, `add-round` and `apply` warn (non-blocking). Keep round numbers out of `meta.eyebrow`: the page derives the round label (` round N`) from the questions and shows it beside the eyebrow, so a number in the eyebrow goes stale at the next round. - **Visuals** are declared by format (`svg`, `mermaid`, `image`, `markdown`, `html`, `chart`) with a `scope` (`question:`, `group:`, `round::`, `all`) and inline `content` or a data-dir-relative `file`. `label` is its tab name (short; the page falls back to `title`, then `id`). `group`, `order` and `primary` arrange visuals: those sharing a `group` are versions or alternatives of one another, `order` sorts them, and at most one live `primary` is allowed per `group` within a `scope`. Change a visual with `replace-visual` (a full object with the same id) or retire it with `archive-visual`; an archived visual stays in `questions.json` and the page and report never show it. `add-round` refuses an id that already exists. Describe what a visual shows; leave out the tool or skill that made it. An `html` visual runs its scripts on the page, in an opaque-origin sandbox that cannot reach the page, so an interactive prototype or an inlined chart library works; the page CSP still blocks remote content. The exported report runs no scripts: when the decision rests on what a scripted visual shows, attach an `image` of it as well. Two or more `image` visuals on a question also get a Gallery tab (one more per group holding a smaller set) with a thumbnail strip, arrow-key flip and a side-by-side compare, in full screen too. Any visual opens in a new tab from the panel or full screen, still sandboxed. - **Arm** the watcher as a background Bash task (`run_in_background`): `bash '/watch.sh' ''`. - **One watcher.** One session watches an interview at a time: the first watcher holds the server's lease, and `watch.sh` exits 3 naming the holder, since when and its last poll when another session holds it. Do not re-arm. When the holder is another Claude session, coordinate with it through the cross-session messaging tooling this session provides (discover what is available; assume no particular tool) and agree which session runs the interview, or ask it to hand over with `round.sh lease --release`. When it cannot be reached, tell the user which session holds the lease and since when; the lease frees itself once the holder stops polling for `leaseTimeout` seconds (default 600). `round.sh lease` prints the current holder. `watch.sh` also exits 3 when this session's lease was released while it waited: run `round.sh lease`, and re-arm only when this session should still watch. When it exits 2 with "no watcher id" (no session id is exported and the parent pid is 1), export `WATCH_ID` with a name for this session and re-arm. @@ -67,7 +67,7 @@ When the first wake prompts for permission, offer the user one allow rule, `Bash | `revise` | `id`, `title`, `short`, `facts`, `basis`, `rec`, `why`, `text`, `alternatives`, `commits`, `seq`, `affects`, `force` | Reword a question; `commits` replaces the list and resets its confirmations | | `note-reply` | `text`, `seq` | Answer a note in Notes to Claude; with no `seq`, post a closing probe there | | `add`, `add-round`, `group` | `question`; `round`, `meta`, `groups`, `questions`, `visuals`; `id`, `title`, `summary`, `dependsOn` | New questions and groups; writing a `summary` records the group's current question ids as `summaryOf`, and the page marks the summary Stale once the members differ, so rewrite the summary after adding questions | -| `meta` | `set` (`title`, `eyebrow`, `stages`, `next`) | Merge into `meta`; other meta keys stay | +| `meta` | `set` (`title`, `eyebrow`, `stages`, `next`, `repo`) | Merge into `meta`; other meta keys stay | | `archive` | `ids`, `why` | Take off-path questions out of the open count | | `replace-visual` | `visual` | Swap in a full visual object for the top-level visual with the same id; an unknown id is refused | | `archive-visual` | `ids`, `why` | Hide top-level visuals from the page and report; they stay in `questions.json` | diff --git a/plugins/planning/surface/README.md b/plugins/planning/surface/README.md index 93590a0b5c..b8dfda4345 100644 --- a/plugins/planning/surface/README.md +++ b/plugins/planning/surface/README.md @@ -44,7 +44,7 @@ Every command needs `--dir ''`; there is no default. Every write valid |---|---| | `ensure-running`, `stop` | Server lifecycle, as above | | `add` | One question from `--file` or flags. Refuses a question without `commits` (`--commit none` is an explicit empty list) or with fewer than two alternatives | -| `add-round --file F [--round N]` | Meta, groups, questions and visuals in one write; any error writes nothing. The file's `meta` object takes `title`, `eyebrow`, `stages` and `next` (what Claude does after wrap-up, shown on the finished screen) and refuses other keys | +| `add-round --file F [--round N]` | Meta, groups, questions and visuals in one write; any error writes nothing. The file's `meta` object takes `title`, `eyebrow`, `stages`, `next` (what Claude does after wrap-up, shown on the finished screen) and `repo` (`owner/repo`, links a bare `#N` on the page) and refuses other keys | | `meta` op | `{"op": "meta", "set": {...}}` merges the same four keys into `meta`; other meta keys, such as `emojiMarkers`, stay | | `group ` | Add or update a group; `--depends` names prerequisite groups | | `reply` op | A Claude line on the question's thread; `seq` marks that event handled; `rec` revises the recommendation and needs `affects` | diff --git a/plugins/planning/surface/index.html b/plugins/planning/surface/index.html index e9b94ec6a7..4d46e0745d 100644 --- a/plugins/planning/surface/index.html +++ b/plugins/planning/surface/index.html @@ -476,6 +476,10 @@

Interview

if (i % 2) return "" + esc(part.slice(1, -1)) + ""; const keep = []; let e = esc(part).replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (m, t, u) => { keep.push('' + t + ''); return "\u0000" + (keep.length - 1) + "\u0000"; }); + const link = (t, repo, n) => { keep.push('' + t + ''); return "\u0000" + (keep.length - 1) + "\u0000"; }; + e = e.replace(/(^|[^\w/.&#-])([\w.-]+\/[\w.-]+)#(\d+)\b/g, (m, pre, repo, n) => pre + link(repo + "#" + n, repo, n)); + const base = /^[\w.-]+\/[\w.-]+$/.test(S.doc.meta.repo || "") ? S.doc.meta.repo : ""; + if (base) e = e.replace(/(^|[^\w/&#-])#(\d+)\b/g, (m, pre, n) => pre + link("#" + n, base, n)); e = e.replace(/\*\*([^*]+)\*\*/g, "$1").replace(/(^|[^*\w])\*([^*\s][^*]*?)\*(?!\w)/g, "$1$2"); e = e.replace(/\b([A-Z]{1,3}\d{1,4})\b/g, (m, id) => Q[id] ? refHtml(id) : m); return e.replace(/\u0000(\d+)\u0000/g, (m, n) => keep[+n]); diff --git a/plugins/planning/surface/round.py b/plugins/planning/surface/round.py index 62967323f4..ebb9724871 100644 --- a/plugins/planning/surface/round.py +++ b/plugins/planning/surface/round.py @@ -113,6 +113,8 @@ ID_TOKEN = re.compile(r"\b[A-Z]+[0-9]+\b") VERSION_LABEL = re.compile(r"V[0-9]+") SENTENCE_BREAK = re.compile(r"[.!?](\s|$)") +BARE_ISSUE_REF = re.compile(r"(? { // dictation: plain Enter, digits while typing, Ctrl+Enter unarmed await page.click('.qbtn[data-q="N1"]'); await page.waitForTimeout(200); + const refs = await page.evaluate(() => [...document.querySelectorAll("#qhead a, .blk a")].map(a => a.textContent + " " + a.getAttribute("href"))); + ok("bare #N and owner/repo#N in a title and in facts render as issue links", ["#123 https://github.com/o/r/issues/123", "x/y#4 https://github.com/x/y/issues/4"].every(r => refs.filter(x => x === r).length === 2) && !refs.some(r => /#9\b/.test(r)), refs.join(" | ")); const n0 = (await events()).length; await page.click("#note"); await page.keyboard.type("hello"); await page.keyboard.press("Enter"); await page.keyboard.type("world 1"); await page.waitForTimeout(300); From 8fefd5d54591fa908aff2fc2ad461cbac80f3cdb Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:42:50 -0400 Subject: [PATCH 02/10] fix(planning): mark a question revising only from its own unhandled event Refs: #5569 Co-Authored-By: Claude Opus 5.5 --- plugins/planning/surface/index.html | 8 ++------ plugins/planning/surface/server.py | 7 +++---- plugins/planning/surface/test_server.py | 8 ++++---- plugins/planning/surface/tests/ui_c.js | 3 +-- 4 files changed, 10 insertions(+), 16 deletions(-) diff --git a/plugins/planning/surface/index.html b/plugins/planning/surface/index.html index 4d46e0745d..a2f1e4f052 100644 --- a/plugins/planning/surface/index.html +++ b/plugins/planning/surface/index.html @@ -548,10 +548,6 @@

Interview

function pending(){ return events().filter(e => !isHandled(e.seq) && !e.withdrawn); } function lastEventSeq(id){ let s = 0; for (const e of events()) if (e.id === id && e.seq > s) s = e.seq; return s; } const answered = (q, seq) => isHandled(seq) || (q.history || []).some(h => h.by === "claude" && h.replyTo >= seq); // a later reply or revise to this event (or a newer one) answers it - function upstreamChanged(q){ // the prerequisites behind a delivered, unhandled decision; the server marks their direct dependents `revising` - return (q.dependsOn || []).filter(p => Q[p] && events().some(e => e.id === p && DEC.has(e.kind) && e.deliveredAt && !e.withdrawn && !isHandled(e.seq))); - } - const upstreamText = q => { const u = upstreamChanged(q); return u.length ? "Upstream " + u.join(", ") + " changed" : "Upstream changed"; }; function lastDecisionSeq(id){ let s = 0; for (const e of events()) if (e.id === id && DEC.has(e.kind) && !e.withdrawn && e.seq > s) s = e.seq; return s; } function staleBy(q){ // the prerequisites decided after this question was const ps = (q.dependsOn || []).filter(p => Q[p]), mine = lastDecisionSeq(q.id), c = ps.filter(p => lastDecisionSeq(p) > mine); @@ -677,7 +673,7 @@

Interview

else chips.push('' + LABEL[st] + (st === "alt" && d ? " (" + esc(d.alt) + ")" : "") + ""); if (isStale(q)) chips.push('Stale'); if (up) chips.push('Waiting on ' + esc(upOf(q).join(", ")) + ""); - if (q.revising && st !== "arch") chips.push('' + esc(upstreamText(q)) + ""); + if (q.revising && st !== "arch") chips.push('Answer not handled yet'); if (carry[q.id]) chips.push(' 1 ? " rounds" : " round") + " before this stage's latest round and still unanswered.") + '">carried ' + carry[q.id] + (carry[q.id] > 1 ? " rounds" : " round") + ""); if (q.revised && st === "open") chips.push('Revised'); const aa = live(q) && afterAnswer(q); @@ -959,7 +955,7 @@

Interview

else if (q.state === "upstream-pending" && st !== "arch") h += '
Waiting on ' + upOf(q).map(refHtml).join(" ") + ": an earlier answer on this path changed. You can still answer.
"; if (st === "wait") h += '
Pending research: ' + inline(q.waitsOn) + ". You can answer anyway; it counts once the research returns.
"; if (st === "need") h += '
Needs your answer: ' + inline(q.waitsOn) + ".
"; - if (q.revising && st !== "arch") h += '"; + if (q.revising && st !== "arch") h += ''; if (lock.length) h += '
Locked: opens after ' + lock.map(x => esc(groupTitle(x))).join(", ") + ". You can still answer.
"; if (q.supersededBy) h += '
Superseded by ' + refHtml(q.supersededBy) + ". Answer that one instead.
"; if (q.revised) h += '"; const kids = S.doc.questions.filter(x => x.followUpOf === q.id).map(x => x.id); const unblocks = S.doc.questions.filter(x => (x.dependsOn || []).includes(q.id)).map(x => x.id); const link = (label, ids) => ids.length ? "" + label + " " + ids.map(refHtml).join(" ") + "" : ""; @@ -1455,9 +1457,22 @@

Interview

if (!c && S.armed) { S.armed = null; toast("Pick a choice again: the choices changed.", true); renderAnswer(); return; } if (!c) { toast("Pick a choice first (1 to " + choicesOf(q).length + ").", true); const el = $("choices"); el.classList.remove("nudge"); void el.offsetWidth; el.classList.add("nudge"); return; } const keep = keptText(c); + if (c.kind === "accept" && waitingReply(q) && !await confirmAccept()) return; if (c.kind === "own" && keep === undefined && !$("note").value.trim()) { toast("Own answer needs a note.", true); $("note").focus(); return; } await send(c.kind, c.alt, undefined, keep, withNote(q, c)); } + function confirmAccept(){ // Claude has not answered your ask yet, so the recommendation may still change + const dlg = $("dlg"); + $("dlgTitle").textContent = "Accept current recommendation anyway?"; + $("dlgBody").innerHTML = "

Claude has not replied to your question yet. Its reply may change the recommendation.

"; + $("dlgOk").textContent = "Accept anyway"; $("dlgOk").disabled = false; + return new Promise(res => { + dlg.onclose = () => res(dlg.returnValue === "ok"); + $("dlgOk").onclick = () => dlg.close("ok"); + $("dlgCancel").onclick = () => dlg.close("cancel"); + dlg.showModal(); $("dlgCancel").focus(); + }); + } async function send(kind, alt, force, keep, noted){ const q = Q[S.sel]; if (!q || S.busy) return; const typing = document.activeElement === $("note"), held = stateOf(q) === "wait"; @@ -1585,8 +1600,8 @@

Interview

(held.length ? '

Left out, ' + (audit ? "they carry a note, which this button does not send. Accept them one at a time: " : "their note challenges a commitment: ") + held.map(q => esc(q.id)).join(", ") + ".

" : "") + (unopened ? '

Left out: ' + unopened + " open " + (unopened === 1 ? "question" : "questions") + " you have not opened.

" : "") + "

This set cannot be undone as one step: Reopen each question to change it.

"; - $("dlgOk").disabled = !items.length; - const dlg = $("dlg"); + $("dlgOk").disabled = !items.length; $("dlgOk").textContent = "Accept these"; + const dlg = $("dlg"); dlg.onclose = null; $("dlgOk").onclick = async () => { dlg.close(); if (S.busy) return; if (wrapFreeze() > 0) { toast("Wrapping up. Saves resume once Claude handles the wrap-up.", true); return; } diff --git a/plugins/planning/surface/tests/ui_journey.js b/plugins/planning/surface/tests/ui_journey.js index f92273d8a4..f48aadbf7d 100644 --- a/plugins/planning/surface/tests/ui_journey.js +++ b/plugins/planning/surface/tests/ui_journey.js @@ -76,6 +76,12 @@ async page => { // the user journey in order on one page, no reload after phase ok("Clear empties the note and disables itself", (await page.inputValue("#note")) === "" && await page.$eval("[data-clear]", b => b.disabled)); await page.request.get(base + "api/wait?after=0&timeout=2", {headers: {"X-Interview-Token": await token()}}); await page.waitForTimeout(900); ok("the Claude line reads Claude is working on", /^Claude is working on Q\d/.test(await text("#claudeLine")), await text("#claudeLine")); + ok("an unanswered ask shows the Waiting for Claude's reply chip on Q4", /Waiting for Claude's reply/.test(await text('.qbtn[data-q="Q4"]')), await text('.qbtn[data-q="Q4"]')); + await pick("Q4"); await arm("a"); await tap("[data-save]", 300); + const nb = (await events()).length; + ok("Accept on a question still waiting for Claude asks first", /Accept current recommendation anyway\?/.test(await text("#dlgTitle")), await text("#dlgTitle")); + await tap("#dlgCancel", 300); + ok("cancelling the prompt sends nothing", (await events()).length === nb, String(nb)); // own answer that is a question const askToast = await text("#toast"); From daebbcaceee8ecae867de51073bc6153aedd91a5 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:58:54 -0400 Subject: [PATCH 06/10] test(planning): check a long own answer and ask reach responses.json whole Co-Authored-By: Claude Opus 5.5 --- plugins/planning/surface/test_server.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/plugins/planning/surface/test_server.py b/plugins/planning/surface/test_server.py index 0289769349..15386bea2a 100644 --- a/plugins/planning/surface/test_server.py +++ b/plugins/planning/surface/test_server.py @@ -419,6 +419,25 @@ def test_28_ac31_both_files_validate_after_the_suite(self): self.assertEqual(rc, 0, out) +class TestLongText(ServerCase): + """A long own answer and a long ask are stored whole, not cut at any length limit.""" + + fixtures = True + + def test_long_own_answer_and_ask_reach_responses_json_whole(self): + filler = "Sentence of filler text that keeps going. " * 145 + long_text = filler + "There are more questions here, but the rest is for round two and then it ends" + self.assertGreaterEqual(len(long_text), 6000) + for qid, kind in (("Q5", "own"), ("Q6", "ask")): + code, _ = self.post({"id": qid, "kind": kind, "text": long_text}) + self.assertEqual(code, 200) + saved = json.loads((self.dir / "responses.json").read_text(encoding="utf-8")) + texts = {e["kind"]: e["text"] for e in saved["events"] if e["id"] in ("Q5", "Q6")} + self.assertEqual(texts["own"], long_text) + self.assertEqual(texts["ask"], long_text) + self.assertEqual(saved["responses"]["Q5"]["text"], long_text) + + class TestEnsureRunning(ServerCase): """AC2: a fresh data dir gets a server and its URL within 3 s; a second call reuses it.""" From 6f6c67eecf1ae4e384a2d8e69c98f7246ff5d080 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:07:56 -0400 Subject: [PATCH 07/10] fix(planning): cap the decide area on short screens and relax the phone layout Cap the decide fieldset at 40% of the viewport height under 800px tall. At phone width the question rail no longer scrolls inside itself, the header is compact, and the status line wraps. Add ui checks for both. Co-Authored-By: Claude Opus 5.5 --- plugins/planning/surface/index.html | 7 ++++++- plugins/planning/surface/tests/ui_b.js | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/plugins/planning/surface/index.html b/plugins/planning/surface/index.html index 4a7cdc2224..d00c443727 100644 --- a/plugins/planning/surface/index.html +++ b/plugins/planning/surface/index.html @@ -333,10 +333,15 @@ .app{height:auto;min-height:100vh} .layout,.layout.pinned,.layout.norail,.layout.norail.pinned{grid-template-columns:minmax(0,1fr)} .strip{flex-direction:row;order:-1} - nav.rail{max-height:45vh} + nav.rail{max-height:none} + .rail-list{overflow:visible} + h1{font-size:20px;margin-top:2px} + header.top{gap:4px;padding-bottom:4px} + .cline{white-space:normal;overflow:visible;text-overflow:clip} .notice{max-width:100%} aside.fly.open{position:fixed;inset:0;width:auto;border-radius:0} } +@media (max-height:800px){ .choices{max-height:calc(40vh - 90px)} fieldset.decide{max-height:40vh;overflow-y:auto} } @media (prefers-reduced-motion: reduce){*,*::before{animation:none!important;transition:none!important}} diff --git a/plugins/planning/surface/tests/ui_b.js b/plugins/planning/surface/tests/ui_b.js index 2d59695c40..283c2aa173 100644 --- a/plugins/planning/surface/tests/ui_b.js +++ b/plugins/planning/surface/tests/ui_b.js @@ -25,6 +25,13 @@ async page => { await page.request.get(base + "api/wait?after=" + (ev[ev.length - 1].seq - 1) + "&timeout=2", {headers: {"X-Interview-Token": token}}); await page.waitForTimeout(900); ok("Claude line reads 'Claude is working on ...'", /^Claude is working on /.test(await page.textContent("#claudeLine")), await page.textContent("#claudeLine")); + await page.setViewportSize({width: 1280, height: 720}); await page.waitForTimeout(200); + const dh = await page.evaluate(() => ({h: document.querySelector("fieldset.decide").getBoundingClientRect().height, vh: innerHeight})); + ok("at 1280x720 the decide fieldset is at most 40% of the viewport", dh.h <= dh.vh * 0.4 + 1, JSON.stringify(dh)); + await page.setViewportSize({width: 390, height: 844}); await page.waitForTimeout(200); + const ph = await page.evaluate(() => { const c = document.getElementById("claudeLine"); return {hs: document.documentElement.scrollWidth - innerWidth, cut: c.scrollWidth > c.clientWidth + 1 || c.scrollHeight > c.clientHeight + 1, shown: !c.hidden}; }); + ok("at 390x844 there is no horizontal page scroll and the status line is not truncated", ph.hs <= 0 && ph.shown && !ph.cut, JSON.stringify(ph)); + await page.setViewportSize({width: 1400, height: 860}); await page.waitForTimeout(200); if (await page.$eval('.qbtn[data-q="N2"]', el => !el.offsetParent)) await page.click('.sec[data-key="g:g9"] .sec-h'); await page.click('.qbtn[data-q="N2"]'); await page.waitForTimeout(200); ok("receipt shows Saved and Delivered with times", /Saved \d.*Delivered \d/.test(await page.textContent("#cur")), await page.textContent("#cur")); From ac48e461ca34a8e06d185ab22b31f07931b0a257 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:48:11 -0400 Subject: [PATCH 08/10] chore(planning): release 0.53.1 and wrap a long Claude line at phone width Bump planning to 0.53.1 with its changelog entry. At phone width the Claude line wraps anywhere, so an unbroken run no longer widens the page. Co-Authored-By: Claude Opus 5.5 --- plugins/planning/.claude-plugin/plugin.json | 2 +- plugins/planning/CHANGELOG.md | 6 ++++++ plugins/planning/surface/index.html | 2 +- plugins/planning/surface/test_server.py | 9 +++++++-- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/plugins/planning/.claude-plugin/plugin.json b/plugins/planning/.claude-plugin/plugin.json index bfe365b93d..55a89d4f00 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.53.0", + "version": "0.53.1", "userConfig": { "surface": { "type": "string", diff --git a/plugins/planning/CHANGELOG.md b/plugins/planning/CHANGELOG.md index b736ba3564..2bd8a1faee 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.53.1] - 2026-09-30 + +### Fixed + +- **The interview page finishes the remaining issue #5569 gaps.** A question shows the revising chip only while its own revise event is unhandled. A bare `#N` in question text links to the issue in `meta.repo`, and `round.py` warns, without blocking, when text carries one and `meta.repo` is unset. The summary's Confirm button sits clear of the What-is-off box. A question has Research this and Cancel research buttons that post generic `research` and `cancel-research` events, and a held card reads "Research in progress, started