diff --git a/.changes/planning/work-resolve-issue-issue-11843-5b1e9c3d.md b/.changes/planning/work-resolve-issue-issue-11843-5b1e9c3d.md new file mode 100644 index 00000000000..70e572377a7 --- /dev/null +++ b/.changes/planning/work-resolve-issue-issue-11843-5b1e9c3d.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Fixed + +- The interview page's Notes panel keeps its send message (for example "Queued, Claude is busy") when a state push redraws the panel. Before, the message went blank as soon as the next push arrived, often before it could be read. "Queued, Claude is busy" drops off once Claude is no longer busy with the earlier wake. A failed send clears the message, and so does leaving the Notes panel. diff --git a/plugins/planning/surface/index.html b/plugins/planning/surface/index.html index 60b49e0b48f..c893a20eb0f 100644 --- a/plugins/planning/surface/index.html +++ b/plugins/planning/surface/index.html @@ -621,7 +621,7 @@

Interview

listener: {state: "idle"}, online: false, session: "", sel: null, pick: new Set(), busy: false, toast: "", toastBad: false, toastUndo: false, view: "groups", filter: "all", search: "", fly: null, pinned: null, railHidden: false, vtab: null, api: 1, settings: {}, watchSeq: 0, stateAt: 0, instance: "", restarted: false, finished: null, need: null, actKeys: null, - armed: null, snap: {}, conflict: null, undo: null, propEdit: null, propDraft: null, propMsg: null, revs: null, themeSig: "", loaded: false, replies: {}, noteSeen: 0, noteOpen: new Set(), moreShut: new Set(), hiddenBase: null, vfiles: {}, gal: {a: null, b: null, on: false}}; + armed: null, snap: {}, conflict: null, undo: null, propEdit: null, propDraft: null, propMsg: null, revs: null, themeSig: "", loaded: false, replies: {}, noteSeen: 0, noteToast: "", noteQueued: [], noteOpen: new Set(), moreShut: new Set(), hiddenBase: null, vfiles: {}, gal: {a: null, b: null, on: false}}; let Q = {}; // ---------- storage (every access guarded) ---------- @@ -1823,6 +1823,7 @@

Interview

(log.length ? '
Log (' + log.length + ")" + rows(log) + "
" : ""); if (un.size) { markSeen(); renderHeader(); renderRail(); } } + const noteToastText = () => S.noteToast + (S.noteQueued.some(s => !isHandled(s)) ? queued() : ""); // the busy note lasts only while the wake it reported is in flight: S.noteQueued holds the seqs that wake was handling when the note was sent function renderNotes(body){ const nt = noteThread(), draft = lsGet("noteDraft", ""); S.noteSeen = Date.now(); lsSet("noteSeen", S.noteSeen); @@ -1841,7 +1842,7 @@

Interview

(fold ? '
' : "") + '
' + (nt.length ? nt.map(noteHtml).join("") : '

No notes yet.

') + "
" + '" + - '
' + (S.api < 2 ? RESTART : closed() ? closedWhy() : QuestionShape.keyHint("send")) + '
'; + '
' + (S.api < 2 ? RESTART : closed() ? closedWhy() : QuestionShape.keyHint("send")) + '
' + esc(noteToastText()) + "
"; // held in state, so a re-render on a state push keeps it const ta = $("noteText"), nk = noteBox(ta, "send"); ta.nextElementSibling.append(nk.count); Kit.grow(ta); const th = body.querySelector(".nthread"); if (th.lastElementChild) th.lastElementChild.scrollIntoView({block: "nearest"}); } @@ -1880,7 +1881,7 @@

Interview

$("keysBody").innerHTML = KEYS.map(([k, t]) => "" + esc(k) + "" + esc(t) + "").join(""); $("keysDlg").showModal(); } - function showFly(p){ S.fly = p; lsSet("fly", p); renderFly(); renderHeader(); } + function showFly(p){ if (p !== "notes") { S.noteToast = ""; S.noteQueued = []; } S.fly = p; lsSet("fly", p); renderFly(); renderHeader(); } const openPanel = p => showFly(S.fly === p ? null : p); function focusHead(){ const h = $("qhead"); if (h) { h.tabIndex = -1; h.focus(); } } @@ -2298,8 +2299,11 @@

Interview

async function sendNote(){ const ta = $("noteText"); if (!ta) return; const text = ta.value.trim(); - if (!text) { $("noteToast").textContent = "Write a note first."; return; } - if (await sendFree("note", text)) { lsSet("noteDraft", null); if (S.fly === "notes") renderFly(); const t = $("noteToast"); if (t) t.textContent = "Sent. Claude's reply lands here." + queued(); } + const say = (msg, q) => { if (S.fly !== "notes") { msg = ""; q = []; } S.noteToast = msg; S.noteQueued = q || []; const t = $("noteToast"); if (t) t.textContent = noteToastText(); }; // a send that finishes after the panel closed holds nothing + if (!text) { say("Write a note first."); return; } + const inFlight = pending().filter(delivered).map(e => e.seq); // the wake in flight before this note, never the note itself + if (await sendFree("note", text)) { lsSet("noteDraft", null); if (S.fly === "notes") renderFly(); say("Sent. Claude's reply lands here.", queued() ? inFlight : []); } + else say(""); // a failed send must not leave the last send's toast standing } const carried = q => String(lsGet("draft:" + q.id, null) || "").trim(); // what a single Accept would send: the note typed on it function openedRev(q){ // the revision the user last opened q at, with the decisions made since diff --git a/plugins/planning/surface/surface-checks.sh b/plugins/planning/surface/surface-checks.sh index 69381b88b9f..d648e6654c4 100755 --- a/plugins/planning/surface/surface-checks.sh +++ b/plugins/planning/surface/surface-checks.sh @@ -25,7 +25,7 @@ bad() { echo "FAIL: $1"; fail=$((fail + 1)); } # The browser groups of each part, and each group's check count for the SKIP line. declare -A PARTS=([main]="ab e live" [page]="page" [ui-c]="c" [ui-c-listen]="c_listen" [ui-c-ledger]="c_ledger" [journey]="journey") -declare -A CHECKS=([ab]=97 [c]=176 [c_listen]=21 [c_ledger]=10 [e]=22 [journey]=235 [live]=18 [page]=122) +declare -A CHECKS=([ab]=97 [c]=176 [c_listen]=21 [c_ledger]=10 [e]=22 [journey]=236 [live]=18 [page]=122) part=main case "$#:${1:-}" in 0:) ;; diff --git a/plugins/planning/surface/tests/ui_journey.js b/plugins/planning/surface/tests/ui_journey.js index 07b7ff06b78..e9b347f54fb 100644 --- a/plugins/planning/surface/tests/ui_journey.js +++ b/plugins/planning/surface/tests/ui_journey.js @@ -723,6 +723,15 @@ async page => { // the user journey in order on one page, no reload after phase await page.fill("#noteText", "second note"); await page.click("[data-sendnote]"); ok("a save while a wake is in flight says queued, Claude is busy", await until(() => window.__noteToasts.some(t => /Queued, Claude is busy/.test(t)), 4000), JSON.stringify(await page.evaluate(() => window.__noteToasts))); await page.evaluate(() => window.__noteToastObserver.disconnect()); // the page lives on through phase 20 + const frame = await page.evaluate(() => window.__io.applied); + await page.request.get((await page.evaluate(() => location.origin)) + "/api/wait?after=0&timeout=1", {headers: {"X-Interview-Token": await token()}}); // delivers the second note, so the server pushes a state frame + const pushed = await page.waitForFunction(f => window.__io.applied !== f, frame, {timeout: 5000, polling: 50}).then(() => true).catch(() => false); + ok("the Notes send toast outlasts the next state push", pushed && /Queued, Claude is busy/.test(await text("#noteToast")), (pushed ? "" : "no frame; ") + await text("#noteToast")); + await page.click("#flyClose", {timeout: 2000}).catch(() => {}); + // Leaving Notes clears the send toast and its busy note together, so coming back while that wake is still in flight shows neither. + const closed = !(await page.$eval("#fly", el => el.classList.contains("open"))), busy = /^Claude is working on /.test(await text("#workLine")); + await page.evaluate(() => document.querySelector('[data-panel="notes"]').click()); await settle(300); + ok("reopening Notes while a wake is in flight shows no busy note on its own", closed && busy && await text("#noteToast") === "", (closed ? "" : "Notes did not close; ") + (busy ? "" : "no wake in flight; ") + JSON.stringify(await text("#noteToast"))); await page.click("#flyClose", {timeout: 2000}).catch(() => {}); } if (PHASE === 17) { // after the wrap-up the user confirms the third restatement; the shell has written nothing since phase 16