From 5166559c9dcf24e2188dc8a5bb5ab1c1a936661a Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Mon, 24 Aug 2026 20:30:35 -0400 Subject: [PATCH 1/5] fix(sidebar): preserve in-progress session rename across list rebroadcasts (lr-16b88d) --- lib/public/modules/sidebar-sessions.js | 112 ++++++++++++++++++++----- 1 file changed, 91 insertions(+), 21 deletions(-) diff --git a/lib/public/modules/sidebar-sessions.js b/lib/public/modules/sidebar-sessions.js index 7ebf316f..bf080e37 100644 --- a/lib/public/modules/sidebar-sessions.js +++ b/lib/public/modules/sidebar-sessions.js @@ -53,12 +53,18 @@ var armedDeleteTimer = null; // Active inline-rename tracking (session or loop). A full session-list // rebuild (innerHTML = "") detaches the rename from the DOM without -// firing a real user "blur" — the browser's synthetic blur-on-removal still -// invokes commitRename(), but it operates on a textSpan that renderSessionList -// is about to throw away, so the typed title is silently discarded. Tracking -// the active commit/cancel lets renderSessionList settle it cleanly (using the -// input's *current* value) before tearing the list down, instead of losing it. -var activeRename = null; // { commit, cancel } or null +// firing a real user "blur". Committing on every rebuild (the original +// design here) turned out to be wrong: an actively-streaming session +// broadcasts session_list many times per turn, so the rename committed +// within a keystroke or two, sending a partial title as a real +// rename_session (durable write + permanent titleManuallySet=true — see +// lr-16b88d). Instead, `suspend()` captures the in-progress edit (value + +// caret) without committing or cancelling it, so renderSessionList can +// preserve it across a real rebuild by re-opening the rename afterward +// (see the suspendedRename re-open block at the end of renderSessionList). +// Precedent: scheduler.js's cancelled short-circuit flag / detach-as-blur +// handling (lr-fb49-E/F family). +var activeRename = null; // { type, id, currentTitle, commit, cancel, suspend } or null export function openResumePicker() { openResumePickerModal(); @@ -747,7 +753,11 @@ function showLoopCtxMenu(anchorBtn, loopId, loopName, childCount) { // --- Inline rename --- -function startInlineRename(sessionId, currentTitle) { +// resume: optional { value, selectionStart, selectionEnd } captured by a +// prior suspend() — restores the in-progress edit instead of starting fresh +// from currentTitle. Passed by renderSessionList() when re-opening a rename +// after a real rebuild tore the previous down. +function startInlineRename(sessionId, currentTitle, resume) { var el = getSessionListEl().querySelector('.session-item[data-session-id="' + sessionId + '"]'); if (!el) return; var textSpan = el.querySelector(".session-item-text"); @@ -760,13 +770,17 @@ function startInlineRename(sessionId, currentTitle) { var input = document.createElement("input"); input.type = "text"; input.className = "session-rename-input"; - input.value = currentTitle || "New Session"; + input.value = resume ? resume.value : (currentTitle || "New Session"); var originalHtml = textSpan.innerHTML; textSpan.innerHTML = ""; textSpan.appendChild(input); input.focus(); - input.select(); + if (resume) { + input.setSelectionRange(resume.selectionStart, resume.selectionEnd); + } else { + input.select(); + } var settled = false; @@ -779,8 +793,7 @@ function startInlineRename(sessionId, currentTitle) { getWs().send(JSON.stringify({ type: "rename_session", id: sessionId, title: newTitle })); } // Restore text (server will send updated session_list). Guard against the - // textSpan already having been detached/replaced by a rebuild that ran - // this same settle path via activeRename.commit() at its top. + // textSpan already having been detached/replaced by a rebuild. if (getSessionListEl().contains(textSpan)) { textSpan.innerHTML = originalHtml; if (newTitle && newTitle !== currentTitle) { @@ -796,6 +809,16 @@ function startInlineRename(sessionId, currentTitle) { if (getSessionListEl().contains(textSpan)) textSpan.innerHTML = originalHtml; } + // Capture the live edit without settling it (no commit, no cancel, no WS + // send) — used by renderSessionList() ahead of a real rebuild so the edit + // can be re-opened afterward instead of force-committed. Does not flip + // `settled`: the DOM node this closure holds (input/textSpan) is about to + // be torn down by the rebuild regardless, so there is nothing left here + // for a stray blur/keydown to settle against once suspend() returns. + function suspendRename() { + return { value: input.value, selectionStart: input.selectionStart, selectionEnd: input.selectionEnd }; + } + input.addEventListener("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); commitRename(); } if (e.key === "Escape") { e.preventDefault(); cancelRename(); } @@ -803,10 +826,18 @@ function startInlineRename(sessionId, currentTitle) { input.addEventListener("blur", commitRename); input.addEventListener("click", function (e) { e.stopPropagation(); }); - activeRename = { commit: commitRename, cancel: cancelRename }; + activeRename = { + type: "session", + id: sessionId, + currentTitle: currentTitle, + commit: commitRename, + cancel: cancelRename, + suspend: suspendRename, + }; } -function startLoopInlineRename(loopId, currentName) { +// resume: see startInlineRename's matching parameter. +function startLoopInlineRename(loopId, currentName, resume) { var el = getSessionListEl().querySelector('.session-loop-group[data-loop-id="' + loopId + '"]'); if (!el) return; var textSpan = el.querySelector(".session-item-text"); @@ -817,13 +848,17 @@ function startLoopInlineRename(loopId, currentName) { var input = document.createElement("input"); input.type = "text"; input.className = "session-rename-input"; - input.value = currentName || "Loop"; + input.value = resume ? resume.value : (currentName || "Loop"); var originalHtml = textSpan.innerHTML; textSpan.innerHTML = ""; textSpan.appendChild(input); input.focus(); - input.select(); + if (resume) { + input.setSelectionRange(resume.selectionStart, resume.selectionEnd); + } else { + input.select(); + } var settled = false; @@ -852,6 +887,11 @@ function startLoopInlineRename(loopId, currentName) { if (getSessionListEl().contains(textSpan)) textSpan.innerHTML = originalHtml; } + // See startInlineRename's suspendRename — same non-settling capture. + function suspendRename() { + return { value: input.value, selectionStart: input.selectionStart, selectionEnd: input.selectionEnd }; + } + input.addEventListener("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); commitRename(); } if (e.key === "Escape") { e.preventDefault(); cancelRename(); } @@ -859,7 +899,14 @@ function startLoopInlineRename(loopId, currentName) { input.addEventListener("blur", commitRename); input.addEventListener("click", function (e) { e.stopPropagation(); }); - activeRename = { commit: commitRename, cancel: cancelRename }; + activeRename = { + type: "loop", + id: loopId, + currentTitle: currentName, + commit: commitRename, + cancel: cancelRename, + suspend: suspendRename, + }; } // --- Date grouping / highlighting --- @@ -1276,11 +1323,18 @@ export function renderSessionList(sessions) { if (sessions) cachedSessions = sessions; // A full rebuild below (innerHTML = "") detaches the in-progress rename - // / armed-delete "x" button without a real user action. Settle - // both cleanly first: commit the rename using its current value (matching - // existing blur-to-commit behavior) and clear the armed-delete affordance - // so the rebuilt button doesn't silently delete on the next single click. - if (activeRename) activeRename.commit(); + // from the DOM without a real user action. Force-committing it + // here (the original design) sent a partial title as a real rename_session + // on nearly every broadcast from an actively-streaming session — see + // lr-16b88d. Instead, capture the in-progress edit via suspend() (no + // commit, no cancel, no WS send) so it can be re-opened after the rebuild + // below, once we know whether one is actually going to run. + var suspendedRename = activeRename ? { + type: activeRename.type, + id: activeRename.id, + currentTitle: activeRename.currentTitle, + snapshot: activeRename.suspend(), + } : null; clearArmedSessionDelete(); // Skip full rebuild when session data hasn't changed. Server often @@ -1291,6 +1345,8 @@ export function renderSessionList(sessions) { var fp = _fingerprintSessions(cachedSessions, expandedLoopGroups, expandedLoopRuns) + "|sq:" + searchQuery + "|sm:" + (searchMatchIds ? searchMatchIds.size : "null"); if (fp === _sessionListFingerprint) { + // No-op render: nothing was torn down, so the suspended rename's input + // is still live in the DOM — leave it alone (activeRename is unchanged). // Still need to refresh mobile sheet and search UI on null calls if (refreshMobileChatSheet) refreshMobileChatSheet(); syncHeaderSearchUi(); @@ -1423,6 +1479,20 @@ export function renderSessionList(sessions) { // no-op when schedules are outside the 3-minute window or the timer is // already running, so calling it here is safe and low-cost. startCountdownTimer(); + + // Re-open the in-progress rename this rebuild just tore down, restoring + // the typed value + caret (lr-16b88d). If the target session/loop no + // longer exists post-rebuild (deleted, or filtered out by a new search), + // startInlineRename()/startLoopInlineRename() find no matching element and + // no-op, and activeRename is left null — the edit is silently dropped in + // that case, but it was already unaddressable, not force-committed. + if (suspendedRename) { + if (suspendedRename.type === "loop") { + startLoopInlineRename(suspendedRename.id, suspendedRename.currentTitle, suspendedRename.snapshot); + } else { + startInlineRename(suspendedRename.id, suspendedRename.currentTitle, suspendedRename.snapshot); + } + } } // --- Search results --- From 5d92fd6e0e5fdcae4919adb3ee1c9ed4d8b051e7 Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Mon, 24 Aug 2026 20:30:37 -0400 Subject: [PATCH 2/5] test(sidebar): assert renderSessionList suspends+resumes rename, not commits (lr-16b88d) --- ...frontend-state-correlation-lr-fb49.test.js | 70 +++++++++++++++++-- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/test/frontend-state-correlation-lr-fb49.test.js b/test/frontend-state-correlation-lr-fb49.test.js index 6d06f97d..89edd93b 100644 --- a/test/frontend-state-correlation-lr-fb49.test.js +++ b/test/frontend-state-correlation-lr-fb49.test.js @@ -229,18 +229,58 @@ test("sidebar-sessions.js: _fingerprintSessions includes favoriteOrder, unread, // --------------------------------------------------------------------------- // F — sidebar-sessions + scheduler: inline rename / armed-delete vs list rebuild +// +// SUPERSEDED by lr-16b88d (MILLER fnd-fcdaf1): the original fix here made +// renderSessionList() force-commit an in-progress rename on every rebuild. +// That traded silent data loss for a worse regression — an actively +// streaming session broadcasts session_list many times per turn, so the +// rename committed within a keystroke or two, sending a partial title as a +// real rename_session (durable write + permanent titleManuallySet=true). +// The two tests below now assert the corrected contract: renderSessionList +// SUSPENDS (captures, does not commit/cancel/send) an in-progress rename, +// and re-opens it with the captured value + caret after a real rebuild. +// clearArmedSessionDelete() is unaffected by lr-16b88d and still asserted. // --------------------------------------------------------------------------- -test("sidebar-sessions.js: renderSessionList settles an active rename and clears armed-delete before rebuilding", function () { +test("sidebar-sessions.js: renderSessionList suspends (not commits) an active rename before rebuilding, and clears armed-delete", function () { var idx = SIDEBAR_SESSIONS_JS.indexOf("export function renderSessionList"); assert.ok(idx !== -1); var block = SIDEBAR_SESSIONS_JS.slice(idx, idx + 900); - assert.match(block, /if\s*\(activeRename\)\s*activeRename\.commit\(\);/, "renderSessionList must settle an in-progress rename before the DOM teardown"); + assert.match( + block, + /activeRename\s*\?\s*\{[\s\S]*?snapshot:\s*activeRename\.suspend\(\),[\s\S]*?\}\s*:\s*null/, + "renderSessionList must SUSPEND (not commit) an in-progress rename before the DOM teardown — " + + "force-committing on every rebuild is the lr-16b88d regression (partial title sent as a real " + + "rename_session on nearly every broadcast from an actively-streaming session)" + ); + assert.doesNotMatch( + block, + /if\s*\(activeRename\)\s*activeRename\.commit\(\);/, + "renderSessionList must not unconditionally commit activeRename — that is the lr-16b88d regression" + ); assert.match(block, /clearArmedSessionDelete\(\);/, "renderSessionList must clear armed-delete state before the DOM teardown"); }); -test("sidebar-sessions.js: startInlineRename / startLoopInlineRename register activeRename and guard double-settle", function () { +test("sidebar-sessions.js: renderSessionList re-opens a suspended rename after a real rebuild, restoring value + caret", function () { + var idx = SIDEBAR_SESSIONS_JS.indexOf("export function renderSessionList"); + var endIdx = SIDEBAR_SESSIONS_JS.indexOf("// --- Search results ---", idx); + assert.ok(idx !== -1 && endIdx !== -1 && endIdx > idx); + var block = SIDEBAR_SESSIONS_JS.slice(idx, endIdx); + + assert.match( + block, + /if\s*\(suspendedRename\)\s*\{\s*if\s*\(suspendedRename\.type\s*===\s*"loop"\)\s*\{\s*startLoopInlineRename\(suspendedRename\.id,\s*suspendedRename\.currentTitle,\s*suspendedRename\.snapshot\);/, + "the end of renderSessionList must re-open a suspended loop rename via startLoopInlineRename() with the captured snapshot" + ); + assert.match( + block, + /startInlineRename\(suspendedRename\.id,\s*suspendedRename\.currentTitle,\s*suspendedRename\.snapshot\);/, + "the end of renderSessionList must re-open a suspended session rename via startInlineRename() with the captured snapshot" + ); +}); + +test("sidebar-sessions.js: startInlineRename / startLoopInlineRename register activeRename with a non-settling suspend(), and guard double-settle", function () { assert.match(SIDEBAR_SESSIONS_JS, /var activeRename = null;/, "expected module-level activeRename tracking"); var idx1 = SIDEBAR_SESSIONS_JS.indexOf("function startInlineRename"); @@ -248,14 +288,34 @@ test("sidebar-sessions.js: startInlineRename / startLoopInlineRename register ac assert.ok(idx1 !== -1 && idx1End !== -1 && idx1End > idx1); var block1 = SIDEBAR_SESSIONS_JS.slice(idx1, idx1End); assert.match(block1, /var settled = false;/); - assert.match(block1, /activeRename\s*=\s*\{\s*commit:\s*commitRename,\s*cancel:\s*cancelRename\s*\};/); + assert.match(block1, /function suspendRename\(\)\s*\{\s*return\s*\{\s*value:\s*input\.value,\s*selectionStart:\s*input\.selectionStart,\s*selectionEnd:\s*input\.selectionEnd\s*\};\s*\}/, + "startInlineRename must expose a suspendRename() that captures value + caret without touching `settled`"); + assert.match(block1, /type:\s*"session",/); + assert.match(block1, /suspend:\s*suspendRename,/); var idx2 = idx1End; var idx2End = SIDEBAR_SESSIONS_JS.indexOf("// --- Date grouping", idx2); assert.ok(idx2End !== -1 && idx2End > idx2); var block2 = SIDEBAR_SESSIONS_JS.slice(idx2, idx2End); assert.match(block2, /var settled = false;/); - assert.match(block2, /activeRename\s*=\s*\{\s*commit:\s*commitRename,\s*cancel:\s*cancelRename\s*\};/); + assert.match(block2, /function suspendRename\(\)\s*\{\s*return\s*\{\s*value:\s*input\.value,\s*selectionStart:\s*input\.selectionStart,\s*selectionEnd:\s*input\.selectionEnd\s*\};\s*\}/, + "startLoopInlineRename must expose a suspendRename() that captures value + caret without touching `settled`"); + assert.match(block2, /type:\s*"loop",/); + assert.match(block2, /suspend:\s*suspendRename,/); +}); + +test("sidebar-sessions.js: startInlineRename / startLoopInlineRename restore a resumed value + caret via setSelectionRange, not select()", function () { + var idx1 = SIDEBAR_SESSIONS_JS.indexOf("function startInlineRename"); + var idx1End = SIDEBAR_SESSIONS_JS.indexOf("function startLoopInlineRename"); + var block1 = SIDEBAR_SESSIONS_JS.slice(idx1, idx1End); + assert.match(block1, /input\.value\s*=\s*resume\s*\?\s*resume\.value\s*:\s*\(currentTitle\s*\|\|\s*"New Session"\);/); + assert.match(block1, /input\.setSelectionRange\(resume\.selectionStart,\s*resume\.selectionEnd\);/); + + var idx2 = idx1End; + var idx2End = SIDEBAR_SESSIONS_JS.indexOf("// --- Date grouping", idx2); + var block2 = SIDEBAR_SESSIONS_JS.slice(idx2, idx2End); + assert.match(block2, /input\.value\s*=\s*resume\s*\?\s*resume\.value\s*:\s*\(currentName\s*\|\|\s*"Loop"\);/); + assert.match(block2, /input\.setSelectionRange\(resume\.selectionStart,\s*resume\.selectionEnd\);/); }); test("scheduler.js: Escape during loop-name edit cancels rather than committing via blur-on-detach", function () { From 4db6059abc631a2cbc613865301dd81d30d2d262 Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Mon, 24 Aug 2026 20:57:33 -0400 Subject: [PATCH 3/5] fix(sidebar): settle suspended rename before detach so a rebuild-induced blur cannot commit it (lr-16b88d) --- lib/public/modules/sidebar-sessions.js | 135 ++++++++++++++++--------- 1 file changed, 88 insertions(+), 47 deletions(-) diff --git a/lib/public/modules/sidebar-sessions.js b/lib/public/modules/sidebar-sessions.js index bf080e37..f076f6f8 100644 --- a/lib/public/modules/sidebar-sessions.js +++ b/lib/public/modules/sidebar-sessions.js @@ -53,17 +53,22 @@ var armedDeleteTimer = null; // Active inline-rename tracking (session or loop). A full session-list // rebuild (innerHTML = "") detaches the rename from the DOM without -// firing a real user "blur". Committing on every rebuild (the original -// design here) turned out to be wrong: an actively-streaming session -// broadcasts session_list many times per turn, so the rename committed -// within a keystroke or two, sending a partial title as a real -// rename_session (durable write + permanent titleManuallySet=true — see -// lr-16b88d). Instead, `suspend()` captures the in-progress edit (value + -// caret) without committing or cancelling it, so renderSessionList can -// preserve it across a real rebuild by re-opening the rename afterward -// (see the suspendedRename re-open block at the end of renderSessionList). -// Precedent: scheduler.js's cancelled short-circuit flag / detach-as-blur -// handling (lr-fb49-E/F family). +// firing a real user "blur" — but the browser DOES synthesize a blur event +// on detach, and the input's own blur listener is still attached at that +// instant. Committing on every rebuild (the original design here) turned +// out to be wrong: an actively-streaming session broadcasts session_list +// many times per turn, so the rename committed within a keystroke or two, +// sending a partial title as a real rename_session (durable write + +// permanent titleManuallySet=true — see lr-16b88d). `suspend()` captures +// the in-progress edit (value + caret) AND settles it as suspended (same +// short-circuit `settled` flag commit/cancel use) — no commit, no cancel, +// no WS send — so the detach-induced synthetic blur that fires the instant +// the rebuild's innerHTML="" runs is a no-op on the old input. renderSessionList +// then reassigns activeRename to null and, if a real rebuild ran, re-opens +// the rename afterward with the captured snapshot (see the suspendedRename +// re-open block at the end of renderSessionList). Precedent: scheduler.js's +// `cancelled` short-circuit flag / detach-as-blur handling (lr-fb49-E/F +// family) — suspend() is that same pattern applied to preserve-not-drop. var activeRename = null; // { type, id, currentTitle, commit, cancel, suspend } or null export function openResumePicker() { @@ -757,6 +762,18 @@ function showLoopCtxMenu(anchorBtn, loopId, loopName, childCount) { // prior suspend() — restores the in-progress edit instead of starting fresh // from currentTitle. Passed by renderSessionList() when re-opening a rename // after a real rebuild tore the previous down. +// +// LIFECYCLE GUARD (lr-16b88d PEACHES finding): `settled` alone is not +// enough once suspend() exists. A rebuild's innerHTML="" detaches this +// without a real user blur, but the browser still SYNTHESIZES a +// blur event on detach — and that listener (registered below) is still +// attached at that moment. Without a guard, that synthetic blur fires +// commitRename() and ships the suspended partial edit as a real +// rename_session, exactly the bug this task exists to fix. suspend() must +// therefore flip `settled` itself (same short-circuit commit/cancel use), +// so the detach-induced blur that fires immediately afterward is a no-op — +// mirrors scheduler.js:609-621's `cancelled` short-circuit / detach-as-blur +// precedent. function startInlineRename(sessionId, currentTitle, resume) { var el = getSessionListEl().querySelector('.session-item[data-session-id="' + sessionId + '"]'); if (!el) return; @@ -764,7 +781,9 @@ function startInlineRename(sessionId, currentTitle, resume) { if (!textSpan) return; // Settle (not silently drop) any rename already in progress elsewhere in - // the list before starting a new one. + // the list before starting a new one. This is a real user-driven "start a + // different rename" action, not a rebuild-induced detach — commit here is + // correct (matches existing blur-to-commit semantics for switching target). if (activeRename) activeRename.commit(); var input = document.createElement("input"); @@ -787,7 +806,7 @@ function startInlineRename(sessionId, currentTitle, resume) { function commitRename() { if (settled) return; settled = true; - activeRename = null; + if (activeRename && activeRename.commit === commitRename) activeRename = null; var newTitle = input.value.trim(); if (newTitle && newTitle !== currentTitle && getWs() && store.get('connected')) { getWs().send(JSON.stringify({ type: "rename_session", id: sessionId, title: newTitle })); @@ -805,17 +824,20 @@ function startInlineRename(sessionId, currentTitle, resume) { function cancelRename() { if (settled) return; settled = true; - activeRename = null; + if (activeRename && activeRename.commit === commitRename) activeRename = null; if (getSessionListEl().contains(textSpan)) textSpan.innerHTML = originalHtml; } - // Capture the live edit without settling it (no commit, no cancel, no WS - // send) — used by renderSessionList() ahead of a real rebuild so the edit - // can be re-opened afterward instead of force-committed. Does not flip - // `settled`: the DOM node this closure holds (input/textSpan) is about to - // be torn down by the rebuild regardless, so there is nothing left here - // for a stray blur/keydown to settle against once suspend() returns. + // Capture the live edit AND settle it as suspended — no commit, no cancel, + // no WS send, but `settled` flips to true so the synthetic blur the + // upcoming innerHTML="" teardown fires against this exact input becomes a + // no-op (see the LIFECYCLE GUARD comment above). Does not clear + // module-level `activeRename` itself — the caller (renderSessionList) + // owns reassigning that slot once it knows whether a real rebuild ran and, + // if so, once the re-opened rename has installed its own activeRename. function suspendRename() { + if (settled) return null; + settled = true; return { value: input.value, selectionStart: input.selectionStart, selectionEnd: input.selectionEnd }; } @@ -836,7 +858,8 @@ function startInlineRename(sessionId, currentTitle, resume) { }; } -// resume: see startInlineRename's matching parameter. +// resume: see startInlineRename's matching parameter. See startInlineRename's +// LIFECYCLE GUARD comment — identical reasoning applies here. function startLoopInlineRename(loopId, currentName, resume) { var el = getSessionListEl().querySelector('.session-loop-group[data-loop-id="' + loopId + '"]'); if (!el) return; @@ -865,7 +888,7 @@ function startLoopInlineRename(loopId, currentName, resume) { function commitRename() { if (settled) return; settled = true; - activeRename = null; + if (activeRename && activeRename.commit === commitRename) activeRename = null; var newName = input.value.trim(); if (newName && newName !== currentName && getWs() && store.get('connected')) { getWs().send(JSON.stringify({ type: "loop_registry_rename", id: loopId, name: newName })); @@ -883,12 +906,14 @@ function startLoopInlineRename(loopId, currentName, resume) { function cancelRename() { if (settled) return; settled = true; - activeRename = null; + if (activeRename && activeRename.commit === commitRename) activeRename = null; if (getSessionListEl().contains(textSpan)) textSpan.innerHTML = originalHtml; } - // See startInlineRename's suspendRename — same non-settling capture. + // See startInlineRename's suspendRename — same settle-without-committing. function suspendRename() { + if (settled) return null; + settled = true; return { value: input.value, selectionStart: input.selectionStart, selectionEnd: input.selectionEnd }; } @@ -1322,32 +1347,21 @@ function _fingerprintSessions(list, expanded, expandedRuns) { export function renderSessionList(sessions) { if (sessions) cachedSessions = sessions; - // A full rebuild below (innerHTML = "") detaches the in-progress rename - // from the DOM without a real user action. Force-committing it - // here (the original design) sent a partial title as a real rename_session - // on nearly every broadcast from an actively-streaming session — see - // lr-16b88d. Instead, capture the in-progress edit via suspend() (no - // commit, no cancel, no WS send) so it can be re-opened after the rebuild - // below, once we know whether one is actually going to run. - var suspendedRename = activeRename ? { - type: activeRename.type, - id: activeRename.id, - currentTitle: activeRename.currentTitle, - snapshot: activeRename.suspend(), - } : null; - clearArmedSessionDelete(); - - // Skip full rebuild when session data hasn't changed. Server often - // re-broadcasts session_list after unrelated events (session switches, - // WS reconnects, presence updates). Each rebuild does innerHTML="" on the - // entire sidebar list + re-creates all elements + calls refreshIcons(). - // Include search state so filter changes always trigger a rebuild. + // Compute the fingerprint FIRST, before touching activeRename at all. + // suspend() settles the rename's input (see its LIFECYCLE GUARD comment) — + // it must only be called when a real rebuild is about to tear that input + // down. Calling it unconditionally (including on a no-op render, where the + // input stays live in the DOM untouched) would settle an edit that was + // never actually interrupted, silently disabling its real blur/Enter/ + // Escape handling. This ordering is the fix for the lr-16b88d PEACHES + // finding that the original version suspended before knowing whether a + // rebuild would run. var fp = _fingerprintSessions(cachedSessions, expandedLoopGroups, expandedLoopRuns) + "|sq:" + searchQuery + "|sm:" + (searchMatchIds ? searchMatchIds.size : "null"); if (fp === _sessionListFingerprint) { - // No-op render: nothing was torn down, so the suspended rename's input - // is still live in the DOM — leave it alone (activeRename is unchanged). - // Still need to refresh mobile sheet and search UI on null calls + // No-op render: nothing is going to be torn down, so the active rename's + // input stays live in the DOM untouched — leave activeRename alone. + // Still need to refresh mobile sheet and search UI on null calls. if (refreshMobileChatSheet) refreshMobileChatSheet(); syncHeaderSearchUi(); if (updatePageTitle) updatePageTitle(); @@ -1355,6 +1369,33 @@ export function renderSessionList(sessions) { } _sessionListFingerprint = fp; + // A real rebuild below (innerHTML = "") detaches the in-progress rename + // from the DOM without a real user action. Force-committing it + // here (the original design) sent a partial title as a real rename_session + // on nearly every broadcast from an actively-streaming session — see + // lr-16b88d. Instead, capture the in-progress edit via suspend(), which + // both snapshots {value, selectionStart, selectionEnd} AND settles the + // rename as suspended (no commit, no cancel, no WS send) so the detach- + // induced synthetic blur that fires the instant innerHTML="" runs below is + // a no-op on the OLD input. Clear the module-level activeRename slot + // immediately after: this rebuild is the only owner of the decision to + // re-open it, and no code between here and the re-open below may treat a + // stale reference to the just-torn-down rename as still committable. + var suspendedRename = null; + if (activeRename) { + var snapshot = activeRename.suspend(); + if (snapshot) { + suspendedRename = { + type: activeRename.type, + id: activeRename.id, + currentTitle: activeRename.currentTitle, + snapshot: snapshot, + }; + } + activeRename = null; + } + clearArmedSessionDelete(); + // If mobile chat sheet is open, refresh it if (refreshMobileChatSheet) refreshMobileChatSheet(); From 43d51c1baf836b4c01a956f24855dd34d957bcd3 Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Mon, 24 Aug 2026 20:57:36 -0400 Subject: [PATCH 4/5] test(sidebar): update source-shape checks for the settling suspend() guard (lr-16b88d) --- ...frontend-state-correlation-lr-fb49.test.js | 55 +++++++++++++++---- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/test/frontend-state-correlation-lr-fb49.test.js b/test/frontend-state-correlation-lr-fb49.test.js index 89edd93b..36d890fb 100644 --- a/test/frontend-state-correlation-lr-fb49.test.js +++ b/test/frontend-state-correlation-lr-fb49.test.js @@ -242,17 +242,31 @@ test("sidebar-sessions.js: _fingerprintSessions includes favoriteOrder, unread, // clearArmedSessionDelete() is unaffected by lr-16b88d and still asserted. // --------------------------------------------------------------------------- -test("sidebar-sessions.js: renderSessionList suspends (not commits) an active rename before rebuilding, and clears armed-delete", function () { +test("sidebar-sessions.js: renderSessionList suspends (not commits) an active rename before rebuilding, nulls the slot, and clears armed-delete", function () { var idx = SIDEBAR_SESSIONS_JS.indexOf("export function renderSessionList"); - assert.ok(idx !== -1); - var block = SIDEBAR_SESSIONS_JS.slice(idx, idx + 900); + var endIdx = SIDEBAR_SESSIONS_JS.indexOf("clearArmedSessionDelete();", idx); + assert.ok(idx !== -1 && endIdx !== -1 && endIdx > idx); + var block = SIDEBAR_SESSIONS_JS.slice(idx, endIdx + 100); + + // PEACHES follow-up (lr-16b88d PR #405 review): suspend() alone is not + // enough — the fingerprint must be computed BEFORE suspend() is ever + // called (so a no-op render never settles a rename that was never + // actually interrupted), and the module-level activeRename slot must be + // nulled immediately after suspending (so no stale closure can commit a + // torn-down rename, and the resume-open path's own + // "if (activeRename) activeRename.commit()" guard is a genuine no-op). + var fpIdx = block.indexOf("var fp = _fingerprintSessions"); + var suspendIdx = block.indexOf("activeRename.suspend()"); + assert.ok(fpIdx !== -1 && suspendIdx !== -1 && fpIdx < suspendIdx, + "the fingerprint must be computed before activeRename.suspend() is ever called"); assert.match( block, - /activeRename\s*\?\s*\{[\s\S]*?snapshot:\s*activeRename\.suspend\(\),[\s\S]*?\}\s*:\s*null/, - "renderSessionList must SUSPEND (not commit) an in-progress rename before the DOM teardown — " + - "force-committing on every rebuild is the lr-16b88d regression (partial title sent as a real " + - "rename_session on nearly every broadcast from an actively-streaming session)" + /if\s*\(activeRename\)\s*\{\s*var snapshot\s*=\s*activeRename\.suspend\(\);[\s\S]*?activeRename\s*=\s*null;\s*\}/, + "renderSessionList must SUSPEND (not commit) an in-progress rename before the DOM teardown, THEN null the " + + "module-level activeRename slot — force-committing on every rebuild is the lr-16b88d regression (partial " + + "title sent as a real rename_session on nearly every broadcast from an actively-streaming session); leaving " + + "a stale activeRename reference after suspending is the lr-16b88d PEACHES follow-up (defects #2/#3)" ); assert.doesNotMatch( block, @@ -280,28 +294,45 @@ test("sidebar-sessions.js: renderSessionList re-opens a suspended rename after a ); }); -test("sidebar-sessions.js: startInlineRename / startLoopInlineRename register activeRename with a non-settling suspend(), and guard double-settle", function () { +test("sidebar-sessions.js: startInlineRename / startLoopInlineRename register activeRename with a SETTLING suspend(), and guard double-settle", function () { assert.match(SIDEBAR_SESSIONS_JS, /var activeRename = null;/, "expected module-level activeRename tracking"); + // PEACHES follow-up (lr-16b88d PR #405 review, defect #1): suspend() must + // flip `settled` BEFORE returning, not merely capture a snapshot — a + // rebuild's innerHTML="" teardown synthesizes a real blur on the detached + // input, and that input's own blur listener (commitRename) is still + // attached at that instant. Without settling, that synthetic blur commits + // the suspended partial edit as a real rename_session — the exact defect + // this task exists to fix. See sidebar-sessions-rename-lifecycle-lr-16b88d.test.js + // for the runtime (not just source-shape) proof of this guard. var idx1 = SIDEBAR_SESSIONS_JS.indexOf("function startInlineRename"); var idx1End = SIDEBAR_SESSIONS_JS.indexOf("function startLoopInlineRename"); assert.ok(idx1 !== -1 && idx1End !== -1 && idx1End > idx1); var block1 = SIDEBAR_SESSIONS_JS.slice(idx1, idx1End); assert.match(block1, /var settled = false;/); - assert.match(block1, /function suspendRename\(\)\s*\{\s*return\s*\{\s*value:\s*input\.value,\s*selectionStart:\s*input\.selectionStart,\s*selectionEnd:\s*input\.selectionEnd\s*\};\s*\}/, - "startInlineRename must expose a suspendRename() that captures value + caret without touching `settled`"); + assert.match(block1, /function suspendRename\(\)\s*\{\s*if\s*\(settled\)\s*return null;\s*settled\s*=\s*true;\s*return\s*\{\s*value:\s*input\.value,\s*selectionStart:\s*input\.selectionStart,\s*selectionEnd:\s*input\.selectionEnd\s*\};\s*\}/, + "startInlineRename's suspendRename() must check-then-set `settled` BEFORE returning the snapshot, so a " + + "detach-synthesized blur firing immediately after suspend() is a no-op"); assert.match(block1, /type:\s*"session",/); assert.match(block1, /suspend:\s*suspendRename,/); + // commitRename/cancelRename must only null activeRename when they are + // still its current occupant (identity check) — otherwise a stale + // closure from an already-suspended-and-resumed rename could clobber a + // newer rename's activeRename slot (PEACHES defect #2/#3 follow-up). + var identityNullMatches1 = block1.match(/if\s*\(activeRename\s*&&\s*activeRename\.commit\s*===\s*commitRename\)\s*activeRename\s*=\s*null;/g) || []; + assert.ok(identityNullMatches1.length >= 2, "commitRename and cancelRename must both identity-check activeRename.commit before nulling it"); var idx2 = idx1End; var idx2End = SIDEBAR_SESSIONS_JS.indexOf("// --- Date grouping", idx2); assert.ok(idx2End !== -1 && idx2End > idx2); var block2 = SIDEBAR_SESSIONS_JS.slice(idx2, idx2End); assert.match(block2, /var settled = false;/); - assert.match(block2, /function suspendRename\(\)\s*\{\s*return\s*\{\s*value:\s*input\.value,\s*selectionStart:\s*input\.selectionStart,\s*selectionEnd:\s*input\.selectionEnd\s*\};\s*\}/, - "startLoopInlineRename must expose a suspendRename() that captures value + caret without touching `settled`"); + assert.match(block2, /function suspendRename\(\)\s*\{\s*if\s*\(settled\)\s*return null;\s*settled\s*=\s*true;\s*return\s*\{\s*value:\s*input\.value,\s*selectionStart:\s*input\.selectionStart,\s*selectionEnd:\s*input\.selectionEnd\s*\};\s*\}/, + "startLoopInlineRename's suspendRename() must have the identical settle-before-return guard"); assert.match(block2, /type:\s*"loop",/); assert.match(block2, /suspend:\s*suspendRename,/); + var identityNullMatches2 = block2.match(/if\s*\(activeRename\s*&&\s*activeRename\.commit\s*===\s*commitRename\)\s*activeRename\s*=\s*null;/g) || []; + assert.ok(identityNullMatches2.length >= 2, "startLoopInlineRename's commitRename and cancelRename must both identity-check activeRename.commit before nulling it"); }); test("sidebar-sessions.js: startInlineRename / startLoopInlineRename restore a resumed value + caret via setSelectionRange, not select()", function () { From 7952b50e134a871032deb2fc5aabc25016c19574 Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Mon, 24 Aug 2026 20:57:39 -0400 Subject: [PATCH 5/5] test(sidebar): drive real renderSessionList rename lifecycle end-to-end (lr-16b88d) --- ...essions-rename-lifecycle-lr-16b88d.test.js | 539 ++++++++++++++++++ 1 file changed, 539 insertions(+) create mode 100644 test/sidebar-sessions-rename-lifecycle-lr-16b88d.test.js diff --git a/test/sidebar-sessions-rename-lifecycle-lr-16b88d.test.js b/test/sidebar-sessions-rename-lifecycle-lr-16b88d.test.js new file mode 100644 index 00000000..f673a4dd --- /dev/null +++ b/test/sidebar-sessions-rename-lifecycle-lr-16b88d.test.js @@ -0,0 +1,539 @@ +// sidebar-sessions-rename-lifecycle-lr-16b88d.test.js +// +// Runtime LIFECYCLE regression coverage for lr-16b88d (PEACHES blocking +// review on PR #405, comment 5403402620). The source-text assertions in +// frontend-state-correlation-lr-fb49.test.js passed even when suspend() +// captured a snapshot but did NOT settle the rename, so a rebuild's +// innerHTML="" teardown still fired a synthetic blur on the detached +// and committed the suspended partial edit as a real +// rename_session — the exact defect this task exists to fix. Source-text +// assertions cannot catch a runtime event-ordering defect, so this file +// drives the REAL exported renderSessionList() end-to-end: real session +// item -> real contextmenu -> real "Rename" click -> real (module-private, +// correctly unexported) startInlineRename closure -> a real detach-induced +// synthetic blur -> asserting on the real WS sends that would carry a +// partial rename_session. +// +// This required climbing sidebar-sessions.js's REAL import graph (it +// directly imports sidebar.js, app-projects.js, session-search.js, +// sidebar-mobile.js, agent-picker.js — a large slice of the frontend) far +// enough to let a genuine `node --test` process finish importing it and +// call real, unmodified production functions (initSidebar, renderSessionList, +// the context-menu click handlers). No jsdom, no new dependency: everything +// below is either (a) a minimal generic hand-built DOM stub implementing +// only the small surface these modules actually call — querySelector/ +// querySelectorAll via a real child-tree walk, appendChild/remove/ +// removeChild, classList, dataset, event listeners + dispatch, and, the one +// behavior this whole defect class hinges on, innerHTML="" synthesizing a +// real "blur" on any currently-focused descendant BEFORE it is torn down, +// exactly matching real browser/jsdom ordering — or (b) trivial no-op stubs +// for third-party browser-global libraries (marked, mermaid, DOMPurify, +// hljs, twemoji, lucide) that unrelated modules in the same import graph +// configure unconditionally at module top level. Class (b) stubs make NO +// claim about markdown/mermaid/icon rendering — they exist solely so the +// module graph finishes loading in Node. + +"use strict"; + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var path = require("path"); +var { pathToFileURL } = require("url"); + +// --------------------------------------------------------------------------- +// Minimal generic fake DOM +// --------------------------------------------------------------------------- + +function FakeElement(doc, tagName, opts) { + this._doc = doc; + this.tagName = String(tagName || "div").toUpperCase(); + this._children = []; + this._parent = null; + this._listeners = {}; + this._html = ""; + this.className = ""; + this.dataset = {}; + this.type = ""; + this.value = ""; + this.title = ""; + this.selectionStart = 0; + this.selectionEnd = 0; + this._focused = false; + // Permissive mode (used only for auto-vivified getElementById results — + // real static-HTML-shell elements this file's tests never assert on): + // an unmatched querySelector returns a fresh throwaway element instead of + // null, so unguarded "document.getElementById(id).querySelector(...). + // addEventListener(...)" chains in unrelated init code (resume-modal, + // search inputs, etc.) succeed without this file needing to model their + // real structure. The session-list element and everything rendered + // beneath it (session items, rename inputs, context menus) stay STRICT — + // this file's actual assertions depend on real null-vs-element semantics + // there (e.g. "the old input is gone after rebuild"). + this._permissive = !!(opts && opts.permissive); + var self = this; + this.classList = { + add: function () {}, + remove: function () {}, + toggle: function () {}, + contains: function () { return false; }, + }; + this.style = {}; +} + +FakeElement.prototype.setAttribute = function (name, value) { this[name] = value; }; +FakeElement.prototype.getAttribute = function (name) { return this[name]; }; + +FakeElement.prototype.addEventListener = function (type, fn) { + if (!this._listeners[type]) this._listeners[type] = []; + this._listeners[type].push(fn); +}; +FakeElement.prototype.removeEventListener = function (type, fn) { + if (!this._listeners[type]) return; + this._listeners[type] = this._listeners[type].filter(function (f) { return f !== fn; }); +}; +FakeElement.prototype.dispatch = function (type, evt) { + var e = evt || { preventDefault: function () {}, stopPropagation: function () {} }; + (this._listeners[type] || []).slice().forEach(function (fn) { fn(e); }); +}; + +FakeElement.prototype.appendChild = function (child) { + this._children.push(child); + child._parent = this; + return child; +}; +FakeElement.prototype.remove = function () { + if (this._parent) { + this._parent._children = this._parent._children.filter((c) => c !== this); + this._parent = null; + } +}; +// For a permissive (auto-vivified) element with no real parent, lazily +// materialize a throwaway parent too, so an unguarded +// "el.parentElement.getBoundingClientRect()" chain in unrelated init code +// (sidebar.js's resize-handle sync) never hits null. Real, deliberately +// parent-less elements (the ones this file's tests actually assert +// contains()/detach behavior on) are never permissive, so this never +// masks a real "was this actually removed from its parent" check. +function parentOrLazyPermissive(el) { + if (el._parent) return el._parent; + if (el._permissive) { + el._parent = new FakeElement(el._doc, "div", { permissive: true }); + el._parent._children.push(el); + } + return el._parent; +} +Object.defineProperty(FakeElement.prototype, "parentNode", { + get: function () { return parentOrLazyPermissive(this); }, +}); +Object.defineProperty(FakeElement.prototype, "parentElement", { + get: function () { return parentOrLazyPermissive(this); }, +}); +FakeElement.prototype.removeChild = function (child) { + this._children = this._children.filter((c) => c !== child); + child._parent = null; + return child; +}; +FakeElement.prototype.contains = function (node) { + var stack = this._children.slice(); + while (stack.length) { + var n = stack.pop(); + if (n === node) return true; + stack = stack.concat(n._children || []); + } + return false; +}; + +function walk(el, pred, out, stopAtFirst) { + for (var i = 0; i < el._children.length; i++) { + var c = el._children[i]; + if (pred(c)) { + out.push(c); + if (stopAtFirst) return true; + } + if (walk(c, pred, out, stopAtFirst) && stopAtFirst) return true; + } + return false; +} + +// Extremely small selector matcher: supports ".class", "[data-x=\"v\"]", +// and ".class[data-x=\"v\"]" combos — exactly what sidebar-sessions.js uses. +function matchesSelector(el, sel) { + var parts = sel.match(/\.[\w-]+|\[[^\]]+\]/g) || [sel]; + return parts.every(function (p) { + if (p[0] === ".") { + var cls = p.slice(1); + return (" " + (el.className || "") + " ").indexOf(" " + cls + " ") !== -1; + } + if (p[0] === "[") { + var m = /\[([\w-]+)(?:="([^"]*)")?\]/.exec(p); + if (!m) return false; + var attr = m[1]; + var val = m[2]; + var key = attr.indexOf("data-") === 0 ? attr.slice(5).replace(/-([a-z])/g, function (_, c) { return c.toUpperCase(); }) : attr; + var actual = attr.indexOf("data-") === 0 ? el.dataset[key] : el[attr]; + if (val === undefined) return actual !== undefined && actual !== null; + return String(actual) === val; + } + return false; + }); +} + +FakeElement.prototype.querySelector = function (sel) { + var out = []; + walk(this, function (el) { return matchesSelector(el, sel); }, out, true); + if (out[0]) return out[0]; + if (this._permissive) return new FakeElement(this._doc, "div", { permissive: true }); + return null; +}; +FakeElement.prototype.querySelectorAll = function (sel) { + var out = []; + walk(this, function (el) { return matchesSelector(el, sel); }, out, false); + return out; +}; + +FakeElement.prototype.getBoundingClientRect = function () { + return { top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0 }; +}; + +FakeElement.prototype.focus = function () { + this._focused = true; + this._doc.activeElement = this; +}; +FakeElement.prototype.select = function () { + this.selectionStart = 0; + this.selectionEnd = (this.value || "").length; +}; +FakeElement.prototype.setSelectionRange = function (s, e) { + this.selectionStart = s; + this.selectionEnd = e; +}; + +Object.defineProperty(FakeElement.prototype, "innerHTML", { + get: function () { return this._html; }, + set: function (v) { + // DETACH SEMANTICS (the mechanism this whole defect class hinges on): + // real browsers/jsdom synthesize a "blur" event on the currently + // focused element the instant it (or an ancestor) is removed from the + // document via innerHTML="", and that element's blur listener is still + // attached at the moment the synthetic event fires. Reproduce exactly + // that ordering here, recursively over the whole subtree being cleared. + var doc = this._doc; + var stack = this._children.slice(); + while (stack.length) { + var node = stack.pop(); + if (doc.activeElement === node) { + node.dispatch("blur", {}); + doc.activeElement = null; + } + stack = stack.concat(node._children || []); + } + this._children = []; + this._html = v; + }, +}); +Object.defineProperty(FakeElement.prototype, "textContent", { + get: function () { return this._html; }, + set: function (v) { this.innerHTML = String(v); }, +}); + +function FakeDocument() { + this._byId = {}; + this.activeElement = null; + this.body = new FakeElement(this, "body"); +} +FakeDocument.prototype.createElement = function (tag) { + return new FakeElement(this, tag); +}; +// Returns a fresh generic (permissive) element for ANY id not explicitly +// registered via registerById — several of sidebar-sessions.js's own DOM +// lookups at initSidebarSessions() time are unguarded (no "if (el)" check +// before .addEventListener), matching real app boot where these elements +// always exist in the real page's static HTML shell. A generic throwaway +// element here satisfies that without asserting anything about those +// unrelated UI surfaces (search input, resume modal, etc.) — none of which +// this file's tests exercise or assert on. +FakeDocument.prototype.getElementById = function (id) { + if (!this._byId[id]) this._byId[id] = new FakeElement(this, "div", { permissive: true }); + return this._byId[id]; +}; +FakeDocument.prototype.registerById = function (id, el) { + this._byId[id] = el; +}; +FakeDocument.prototype.addEventListener = function () {}; +FakeDocument.prototype.querySelector = function () { return null; }; + +// --------------------------------------------------------------------------- +// Module load — real ESM import against the stubbed globals. +// --------------------------------------------------------------------------- + +var SIDEBAR_SESSIONS_URL = pathToFileURL( + path.join(__dirname, "..", "lib", "public", "modules", "sidebar-sessions.js") +).href; + +var sidebarSessions; +var fakeDoc; +var sentMessages; + +// dom-refs.js's getSessionListEl() lazily caches document.getElementById +// ("session-list") on FIRST CALL and never re-reads `document` again for +// the lifetime of the process (see dom-refs.js's own `ref()` helper) — +// dom-refs.js is a real cached ESM module singleton shared across every +// test in this file, same as sidebar-sessions.js/sidebar.js/store.js/ +// ws-ref.js. So the actual
element object must be +// created ONCE (here, at file scope) and REUSED across every test's fresh +// fakeDoc/setupFakeGlobals() call — swapping `global.document` between +// tests would otherwise leave dom-refs.js's cache pointing at test 1's +// stale element while later tests render into a different one, which is a +// test-harness bug, not a module bug (this was diagnosed via a real +// failing run: renderSessionList() appeared to render nothing on test 2+). +var sharedSessionListEl = null; + +function makeFakeWs() { + return { + readyState: 1, + send: function (body) { sentMessages.push(JSON.parse(body)); }, + }; +} + +function setupFakeGlobals() { + fakeDoc = new FakeDocument(); + if (!sharedSessionListEl) { + sharedSessionListEl = new FakeElement(fakeDoc, "div"); + } else { + // Reset for reuse in this test: same object identity (what dom-refs.js's + // cache holds), fresh contents. + sharedSessionListEl._doc = fakeDoc; + sharedSessionListEl._children = []; + sharedSessionListEl._html = ""; + sharedSessionListEl._listeners = {}; + } + var sessionListEl = sharedSessionListEl; + fakeDoc.registerById("session-list", sessionListEl); + global.document = fakeDoc; + // window: the rename lifecycle itself never touches window, but several + // modules pulled in transitively by sidebar-sessions.js's own import + // graph (e.g. tool-palette.js) register window-level listeners at MODULE + // TOP LEVEL (import time), not inside a function — so a bare {innerWidth, + // innerHeight} stub is not enough; window needs real (no-op) listener + // methods too, purely to let those unrelated modules finish importing. + global.window = { + innerWidth: 1280, + innerHeight: 800, + addEventListener: function () {}, + removeEventListener: function () {}, + }; + global.lucide = { createIcons: function () {} }; + global.requestAnimationFrame = function () { return 0; }; // never fires — menu positioning is not under test + global.cancelAnimationFrame = function () {}; + var localStorageBacking = {}; + global.localStorage = { + getItem: function (k) { return Object.prototype.hasOwnProperty.call(localStorageBacking, k) ? localStorageBacking[k] : null; }, + setItem: function (k, v) { localStorageBacking[k] = String(v); }, + removeItem: function (k) { delete localStorageBacking[k]; }, + }; + // Node's own global.navigator is a getter-only accessor in modern Node — + // must redefine, not assign. + Object.defineProperty(global, "navigator", { + configurable: true, + value: { userAgent: "node-test-stub", clipboard: { writeText: function () { return Promise.resolve(); } } }, + }); + // markdown.js (pulled in transitively via the real import graph — see + // this file's header) configures the real browser-global marked / + // mermaid libraries UNCONDITIONALLY at module top level (marked.use(...), + // mermaid.initialize(...)) — these are real third-party libraries loaded + // as