From e24ca105d7525972d669ad6ee3cb345a95002afe Mon Sep 17 00:00:00 2001 From: emanon312 <2287452941@qq.com> Date: Sun, 31 May 2026 14:53:35 +0800 Subject: [PATCH 1/5] fix: extend upward scroll intent timeout to prevent streaming scroll snap-back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Increase MESSAGE_UPWARD_INTENT_MS from 450ms to 2000ms to fix a race condition where the user scrolls up during streaming, pauses to read for >450ms, and then gets snapped back to the bottom. The root cause: after the 450ms upward-intent window expires, DOM layout changes from the streaming markdown parser (smd), tool card insertions, or code re-highlighting can trigger scroll events that the handler no longer recognizes as user-initiated. When the resulting position lands inside the 250px near-bottom zone for two consecutive samples, the hysteresis counter re-pins (_scrollPinned=true) and the next streaming token's scrollIfPinned() call forces scrollTop to the bottom. With a 2-second window, the user's upward intent persists through typical streaming DOM churn. Downward scrolling and the scroll-to-bottom button are unaffected — movedUp requires top < _lastScrollTop-2 which is false for downward movement regardless of the intent timeout. Refs: #1360 (macOS momentum protection), #1731 (direction-aware unpin) --- static/ui.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/ui.js b/static/ui.js index a002f18bc8b..cc92642c164 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2231,7 +2231,7 @@ let _lastMessageUpwardIntentMs=0; let _messageUserUnpinned=false; let _bottomSettleToken=0; const NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS=350; -const MESSAGE_UPWARD_INTENT_MS=450; +const MESSAGE_UPWARD_INTENT_MS=2000; function _cancelBottomSettle(){ _bottomSettleToken++; } function _recordNonMessageScrollIntent(e){ const el=document.getElementById('messages'); From 9365f2d21974e3768100f128e750753d3b06ccd5 Mon Sep 17 00:00:00 2001 From: Pamnard Date: Sun, 31 May 2026 14:56:17 +0300 Subject: [PATCH 2/5] Fix workspace preview closing on chat stream done Background file-tree refresh after a response must not call clearPreview(); preserve the open preview while still reloading the directory listing. --- CHANGELOG.md | 7 +++ static/messages.js | 2 +- static/workspace.js | 5 +- ...t_issue856_background_completion_unread.py | 2 +- ...kspace_preview_preserved_on_stream_done.py | 60 +++++++++++++++++++ 5 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 tests/test_workspace_preview_preserved_on_stream_done.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c4e50e70a14..bde28b3dd09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ ## [Unreleased] +## [v0.51.187] — 2026-05-31 — Release FG (stage-batchG — workspace-preview persistence + repaired-sidecar order + scroll-intent window) + +### Fixed +- Workspace file preview no longer closes when a chat response finishes and the UI refreshes the workspace file tree. Background `loadDir('.')` on stream `done` now preserves an open preview instead of always calling `clearPreview()`, and reloads the open file when a write/edit tool touched that path during the turn (skipping reload while the preview has unsaved local edits) (#3262, @pamnard). +- Repaired messaging sidecars now keep their authoritative order when merged with CLI/state rows for display: the sidecar ordering is preserved verbatim and only non-overlapping CLI rows outside the sidecar timestamp window are added, instead of timestamp-sorting the whole union (which replayed older compression-lineage rows into the middle/tail and made repeated user turns reappear after reload) (#3268, @ai-ag2026). +- During streaming, scrolling up to read earlier content no longer snaps back to the bottom after a brief pause: the upward-scroll intent window was widened from 450ms to 2000ms so DOM-layout changes from the markdown parser / tool-card insertions are still recognized as co-occurring with user intent and don't re-pin the view. Downward scroll, the scroll-to-bottom button, and trackpad-momentum protection are unaffected (#3250, @emanon312). + ## [v0.51.186] — 2026-05-31 — Release FF (stage-batchF — update-checker ff-reachability fall-through + utf-8 git-output test coverage) ### Fixed diff --git a/static/messages.js b/static/messages.js index a5ff0572f56..00b37c3bf18 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1974,7 +1974,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(isSessionViewed) _markSessionViewed(completedSid, completedSession.message_count ?? S.messages.length); syncTopbar();renderMessages({preserveScroll:true}); if(shouldFollowOnDone&&typeof scrollToBottom==='function') scrollToBottom(); - loadDir('.'); + loadDir('.', { preservePreview: true }); // TTS auto-read: speak the last assistant response if enabled (#499) if(typeof autoReadLastAssistant==='function') setTimeout(()=>autoReadLastAssistant(), 300); } diff --git a/static/workspace.js b/static/workspace.js index 5f86dafa891..63451c8a110 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -284,7 +284,8 @@ async function openArtifactPath(path){ openFile(rel); } -async function loadDir(path){ +async function loadDir(path, opts={}){ + const preservePreview=!!(opts&&opts.preservePreview); if(!S.session)return; const sessionId=S.session.session_id; try{ @@ -314,7 +315,7 @@ async function loadDir(path){ } if(expanded.size>0)renderFileTree(); } - if(typeof clearPreview==='function'){ + if(!preservePreview&&typeof clearPreview==='function'){ if(typeof _previewDirty!=='undefined'&&_previewDirty){ showConfirmDialog({title:t('unsaved_confirm'),message:'',confirmLabel:'Discard',danger:true,focusCancel:true}).then(ok=>{if(ok)clearPreview({keepPanelOpen:true});}); }else{ diff --git a/tests/test_issue856_background_completion_unread.py b/tests/test_issue856_background_completion_unread.py index 2f885a8833d..91815737d39 100644 --- a/tests/test_issue856_background_completion_unread.py +++ b/tests/test_issue856_background_completion_unread.py @@ -314,7 +314,7 @@ def test_hidden_active_done_still_updates_current_pane_but_not_read_state(): active_guard_idx = done_block.find("if(isActiveSession){", viewed_const_idx) session_update_idx = done_block.find("S.session=d.session", active_guard_idx) render_idx = done_block.find("renderMessages(", active_guard_idx) - load_dir_idx = done_block.find("loadDir('.')", active_guard_idx) + load_dir_idx = done_block.find("preservePreview", active_guard_idx) mark_viewed_idx = done_block.find("if(isSessionViewed) _markSessionViewed(completedSid", active_guard_idx) assert active_const_idx != -1, "done handler must compute active/current pane separately" diff --git a/tests/test_workspace_preview_preserved_on_stream_done.py b/tests/test_workspace_preview_preserved_on_stream_done.py new file mode 100644 index 00000000000..ad03e716c2d --- /dev/null +++ b/tests/test_workspace_preview_preserved_on_stream_done.py @@ -0,0 +1,60 @@ +"""Regression: workspace file preview must survive background file-tree refresh on chat done.""" + +from pathlib import Path + + +REPO = Path(__file__).resolve().parent.parent +MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8") +WORKSPACE_JS = (REPO / "static" / "workspace.js").read_text(encoding="utf-8") + + +def _function_block(src: str, name: str) -> str: + marker = f"function {name}(" + start = src.find(marker) + assert start != -1, f"{name}() not found" + params_end = src.find("){", start) + assert params_end != -1, f"{name}() body not found" + brace = params_end + 1 + depth = 0 + for idx in range(brace, len(src)): + ch = src[idx] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return src[start : idx + 1] + raise AssertionError(f"{name}() body did not close") + + +def _done_block() -> str: + start = MESSAGES_JS.find("source.addEventListener('done'") + assert start != -1, "done handler not found in messages.js" + end = MESSAGES_JS.find("source.addEventListener('stream_end'", start) + assert end != -1, "stream_end handler not found after done handler" + return MESSAGES_JS[start:end] + + +def test_stream_done_refreshes_workspace_without_clearing_preview(): + """Chat completion should refresh the tree but not exit an open file preview.""" + done_block = _done_block() + assert "preservePreview:true" in done_block.replace(" ", ""), ( + "The done handler must refresh the workspace file tree without calling the " + "directory-navigation clearPreview path in loadDir()." + ) + + +def test_load_dir_supports_preserve_preview_option(): + block = _function_block(WORKSPACE_JS, "loadDir") + assert "preservePreview" in block, "loadDir() must accept a preservePreview option" + assert "if(!preservePreview&&typeofclearPreview" in block.replace(" ", ""), ( + "loadDir() should skip clearPreview() when preservePreview is requested" + ) + + +def test_load_dir_still_clears_preview_for_directory_navigation(): + """#1785: explicit directory navigation must still switch preview back to browse mode.""" + block = _function_block(WORKSPACE_JS, "loadDir") + assert "clearPreview({keepPanelOpen:true})" in block.replace(" ", ""), ( + "Directory navigation must still clear previews when preservePreview is not set" + ) From ee414144d34d2f23fd1b40f74611d064869d83e0 Mon Sep 17 00:00:00 2001 From: Pamnard Date: Sun, 31 May 2026 14:58:45 +0300 Subject: [PATCH 3/5] Reload open workspace preview when agent mutates that file Track write/edit tool paths per turn, refresh the open preview on tool_complete and after preservePreview loadDir on stream done, without closing preview for unrelated responses or wiping unsaved local edits. --- CHANGELOG.md | 3 +- static/messages.js | 4 ++ static/workspace.js | 45 ++++++++++++++++--- ...kspace_preview_preserved_on_stream_done.py | 25 +++++++++++ 4 files changed, 70 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bde28b3dd09..dd1a23905a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,10 @@ ## [Unreleased] -## [v0.51.187] — 2026-05-31 — Release FG (stage-batchG — workspace-preview persistence + repaired-sidecar order + scroll-intent window) +## [v0.51.187] — 2026-05-31 — Release FG (stage-batchG — workspace-preview persistence + scroll-intent window) ### Fixed - Workspace file preview no longer closes when a chat response finishes and the UI refreshes the workspace file tree. Background `loadDir('.')` on stream `done` now preserves an open preview instead of always calling `clearPreview()`, and reloads the open file when a write/edit tool touched that path during the turn (skipping reload while the preview has unsaved local edits) (#3262, @pamnard). -- Repaired messaging sidecars now keep their authoritative order when merged with CLI/state rows for display: the sidecar ordering is preserved verbatim and only non-overlapping CLI rows outside the sidecar timestamp window are added, instead of timestamp-sorting the whole union (which replayed older compression-lineage rows into the middle/tail and made repeated user turns reappear after reload) (#3268, @ai-ag2026). - During streaming, scrolling up to read earlier content no longer snaps back to the bottom after a brief pause: the upward-scroll intent window was widened from 450ms to 2000ms so DOM-layout changes from the markdown parser / tool-card insertions are still recognized as co-occurring with user intent and don't re-pin the view. Downward scroll, the scroll-to-bottom button, and trackpad-momentum protection are unaffected (#3250, @emanon312). ## [v0.51.186] — 2026-05-31 — Release FF (stage-batchF — update-checker ff-reachability fall-through + utf-8 git-output test coverage) diff --git a/static/messages.js b/static/messages.js index 00b37c3bf18..2a1fe8d03d7 100644 --- a/static/messages.js +++ b/static/messages.js @@ -684,6 +684,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } closeOtherLiveStreams(activeSid); closeLiveStream(activeSid); + if(!reconnecting&&typeof resetTurnWorkspaceMutations==='function') resetTurnWorkspaceMutations(); // On reconnect, restore accumulated text from INFLIGHT so we don't lose // progress made before the session switch. Without this the closure starts @@ -1710,8 +1711,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(d.duration!==undefined) tc.duration=d.duration; S.toolCalls=inflight.toolCalls; persistInflightState(); + if(typeof noteWorkspaceMutationsFromToolCall==='function') noteWorkspaceMutationsFromToolCall(tc); if(S.session&&S.session.session_id===activeSid&&typeof scheduleRenderSessionArtifacts==='function') scheduleRenderSessionArtifacts(); if(!S.session||S.session.session_id!==activeSid) return; + if(typeof refreshOpenPreviewIfMutated==='function') refreshOpenPreviewIfMutated(); appendLiveToolCard(tc); snapshotLiveTurn(); scrollIfPinned(); @@ -1974,6 +1977,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(isSessionViewed) _markSessionViewed(completedSid, completedSession.message_count ?? S.messages.length); syncTopbar();renderMessages({preserveScroll:true}); if(shouldFollowOnDone&&typeof scrollToBottom==='function') scrollToBottom(); + if(typeof noteWorkspaceMutationsFromToolCalls==='function') noteWorkspaceMutationsFromToolCalls(S.toolCalls); loadDir('.', { preservePreview: true }); // TTS auto-read: speak the last assistant response if enabled (#499) if(typeof autoReadLastAssistant==='function') setTimeout(()=>autoReadLastAssistant(), 300); diff --git a/static/workspace.js b/static/workspace.js index 63451c8a110..e3a3da4ce0e 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -223,6 +223,37 @@ function _artifactCandidatesFromToolCall(tc){ return out; } +const _turnMutatedPreviewPaths = new Set(); + +function resetTurnWorkspaceMutations(){ + _turnMutatedPreviewPaths.clear(); +} + +function noteWorkspaceMutationsFromToolCall(tc){ + for(const a of _artifactCandidatesFromToolCall(tc)){ + const path=_normalizeArtifactPath(a.path); + if(path) _turnMutatedPreviewPaths.add(path); + } +} + +function noteWorkspaceMutationsFromToolCalls(toolCalls){ + if(!Array.isArray(toolCalls)) return; + for(const tc of toolCalls) noteWorkspaceMutationsFromToolCall(tc); +} + +function _isOpenPreviewPathMutated(){ + if(!_previewCurrentPath) return false; + const current=_normalizeArtifactPath(_previewCurrentPath); + return !!(current&&_turnMutatedPreviewPaths.has(current)); +} + +async function refreshOpenPreviewIfMutated(){ + if(typeof _previewDirty!=='undefined'&&_previewDirty) return; + if(!_isOpenPreviewPathMutated()) return; + if(!_previewCurrentPath||!S.session) return; + await openFile(_previewCurrentPath, { bustCache: true }); +} + function collectSessionArtifacts(){ const items = []; const seen = new Set(); @@ -321,6 +352,8 @@ async function loadDir(path, opts={}){ }else{ clearPreview({keepPanelOpen:true}); } + }else if(preservePreview){ + await refreshOpenPreviewIfMutated(); } // Fetch git info for workspace root (non-blocking) if(!path||path==='.') _refreshGitBadge(); @@ -489,9 +522,11 @@ function cancelEditMode(){ updateEditBtn(); } -async function openFile(path){ +async function openFile(path, opts={}){ if(!S.session)return; const ext=fileExt(path); + const bustCache=!!(opts&&opts.bustCache); + const cacheBust=bustCache?`&_=${Date.now()}`:''; // Binary/download-only formats: trigger browser download, don't preview if(DOWNLOAD_EXTS.has(ext)){ @@ -508,14 +543,14 @@ async function openFile(path){ if(IMAGE_EXTS.has(ext)){ // Image: load via raw endpoint, show as showPreview('image'); - const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`; + const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}${cacheBust}`; $('previewImg').alt=path; $('previewImg').src=url; $('previewImg').onerror=()=>setStatus(t('image_load_failed')); } else if(AUDIO_EXTS.has(ext)||VIDEO_EXTS.has(ext)){ const mode=VIDEO_EXTS.has(ext)?'video':'audio'; showPreview(mode); - const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1`; + const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1${cacheBust}`; const wrap=$('previewMediaWrap'); if(wrap){ wrap.innerHTML=(typeof _mediaPlayerHtml==='function') @@ -525,7 +560,7 @@ async function openFile(path){ } } else if(PDF_EXTS.has(ext)){ showPreview('pdf'); - const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1`; + const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1${cacheBust}`; const frame=$('previewPdfFrame'); if(frame){ frame.src=''; // clear first to avoid stale content @@ -557,7 +592,7 @@ async function openFile(path){ // or reading other origin data. If a stricter mode is needed, remove // allow-scripts (or add sandbox="") to disable all JS execution. showPreview('html'); - const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1`; + const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1${cacheBust}`; const iframe=$('previewHtmlIframe'); if(iframe){ iframe.src=''; // clear first to avoid stale content diff --git a/tests/test_workspace_preview_preserved_on_stream_done.py b/tests/test_workspace_preview_preserved_on_stream_done.py index ad03e716c2d..eeddfb81148 100644 --- a/tests/test_workspace_preview_preserved_on_stream_done.py +++ b/tests/test_workspace_preview_preserved_on_stream_done.py @@ -50,6 +50,9 @@ def test_load_dir_supports_preserve_preview_option(): assert "if(!preservePreview&&typeofclearPreview" in block.replace(" ", ""), ( "loadDir() should skip clearPreview() when preservePreview is requested" ) + assert "awaitrefreshOpenPreviewIfMutated()" in block.replace(" ", ""), ( + "Background refresh must reload the open preview when a mutation tool touched it" + ) def test_load_dir_still_clears_preview_for_directory_navigation(): @@ -58,3 +61,25 @@ def test_load_dir_still_clears_preview_for_directory_navigation(): assert "clearPreview({keepPanelOpen:true})" in block.replace(" ", ""), ( "Directory navigation must still clear previews when preservePreview is not set" ) + + +def test_turn_mutation_tracking_reloads_open_preview(): + block = _function_block(WORKSPACE_JS, "refreshOpenPreviewIfMutated") + assert "openFile(_previewCurrentPath" in block.replace(" ", ""), ( + "Mutated open previews must reload through openFile()" + ) + assert "_previewDirty" in block, "Reload must be skipped while the preview has unsaved edits" + + +def test_tool_complete_tracks_workspace_mutations_for_preview_reload(): + tool_complete_idx = MESSAGES_JS.find("source.addEventListener('tool_complete'") + assert tool_complete_idx != -1 + end = MESSAGES_JS.find("source.addEventListener('approval'", tool_complete_idx) + block = MESSAGES_JS[tool_complete_idx:end] + assert "noteWorkspaceMutationsFromToolCall" in block + assert "refreshOpenPreviewIfMutated" in block + + +def test_stream_start_resets_turn_mutation_tracking(): + block = _function_block(MESSAGES_JS, "attachLiveStream") + assert "resetTurnWorkspaceMutations" in block From 16bb1df44b33dbb497aa365be3cc092705344bb7 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 31 May 2026 16:54:55 +0000 Subject: [PATCH 4/5] test: pin #3250 scroll-intent window + fix #2823 openFile anchor for #3262 - Pin MESSAGE_UPWARD_INTENT_MS>=2000ms + intent-helper-uses-constant + downward-repin-independence (#3250, co-authored emanon312). - Update the #2823 large-markdown-preview test's openFile() signature anchor for #3262's openFile(path, opts={}) extension. Co-authored-by: emanon312 --- .../test_issue2823_large_markdown_preview.py | 2 +- ...t_issue3250_upward_scroll_intent_window.py | 96 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue3250_upward_scroll_intent_window.py diff --git a/tests/test_issue2823_large_markdown_preview.py b/tests/test_issue2823_large_markdown_preview.py index e2821ed3b5d..4e2d957b7bf 100644 --- a/tests/test_issue2823_large_markdown_preview.py +++ b/tests/test_issue2823_large_markdown_preview.py @@ -7,7 +7,7 @@ def _open_file_block() -> str: - marker = "async function openFile(path){" + marker = "async function openFile(path, opts={}){" start = WORKSPACE_JS.find(marker) assert start != -1, "openFile() not found in workspace.js" end = WORKSPACE_JS.find("\nfunction downloadFile", start) diff --git a/tests/test_issue3250_upward_scroll_intent_window.py b/tests/test_issue3250_upward_scroll_intent_window.py new file mode 100644 index 00000000000..ba1544dcfc8 --- /dev/null +++ b/tests/test_issue3250_upward_scroll_intent_window.py @@ -0,0 +1,96 @@ +"""Regression test for #3250: upward-scroll intent window during streaming. + +The pre-fix `MESSAGE_UPWARD_INTENT_MS` window was only 450ms. When a user +scrolled up to read earlier content during a streaming response and then +*paused* to read (>450ms since their last wheel/touch event), the intent +expired. Subsequent DOM-layout changes from the streaming markdown parser +(smd), tool-card insertions, or code re-highlighting then produced scroll +events that `_recentMessageUpwardIntent()` no longer attributed to the user +(`movedUp = false`). If the resulting position sat inside the 250px +near-bottom zone for two consecutive samples, `_scrollPinned` flipped back to +true and the next streaming token snapped the user to the bottom. + +The fix widens the window to 2000ms so a brief reading pause no longer drops +the user's intent. Direction detection is unchanged — downward motion still +re-pins regardless of the timeout because `movedUp` additionally requires +`top < _lastScrollTop - 2` — so this is a pure intent-duration tuning, not a +relaxation of the re-pin semantics. +""" + +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") + + +def _intent_window_ms() -> int: + """Extract the numeric value of the MESSAGE_UPWARD_INTENT_MS constant.""" + marker = "const MESSAGE_UPWARD_INTENT_MS=" + idx = UI_JS.find(marker) + assert idx != -1, "MESSAGE_UPWARD_INTENT_MS constant not found in ui.js" + start = idx + len(marker) + end = UI_JS.find(";", start) + raw = UI_JS[start:end].strip() + return int(raw) + + +def test_upward_intent_window_is_widened_for_reading_pauses(): + """The intent window must be >=2000ms so a brief reading pause during a + streaming response does not drop upward-scroll intent and re-pin the view + (#3250). The pre-fix 450ms value was too short for a real read-pause. + """ + window = _intent_window_ms() + assert window >= 2000, ( + f"MESSAGE_UPWARD_INTENT_MS is {window}ms; #3250 requires >=2000ms so a " + "reading pause during streaming does not expire upward-scroll intent " + "and snap the user back to the bottom." + ) + + +def test_intent_helper_compares_against_the_window_constant(): + """_recentMessageUpwardIntent() must gate on the window constant, so the + widened value actually takes effect (guards against the helper being + rewritten with a hardcoded duration). + """ + marker = "function _recentMessageUpwardIntent()" + idx = UI_JS.find(marker) + assert idx != -1, "_recentMessageUpwardIntent() not found in ui.js" + body = UI_JS[idx:UI_JS.find("}", idx) + 1] + assert "MESSAGE_UPWARD_INTENT_MS" in body, ( + "_recentMessageUpwardIntent() must compare against MESSAGE_UPWARD_INTENT_MS " + "rather than a hardcoded duration (#3250)." + ) + assert "_lastMessageUpwardIntentMs" in body, ( + "_recentMessageUpwardIntent() must measure elapsed time since the last " + "recorded upward intent timestamp (#3250)." + ) + + +def test_downward_repin_is_independent_of_the_intent_window(): + """Widening the intent window must not weaken downward re-pin: the movedUp + flag still requires an actual upward scrollTop delta (`top < _lastScrollTop + - 2`), so downward motion re-pins regardless of how long the intent window + is. This is what keeps the #3250 tuning safe. + """ + anchor = "el.addEventListener('scroll'" + start = UI_JS.index(anchor) + raf_start = UI_JS.index("requestAnimationFrame", start) + brace = UI_JS.index("{", raf_start) + depth = 0 + block = "" + for i in range(brace, len(UI_JS)): + ch = UI_JS[i] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + block = UI_JS[brace:i + 1] + break + assert block, "scroll listener rAF callback not found" + moved_idx = block.index("const movedUp=") + moved_expr = block[moved_idx:block.find(";", moved_idx)] + assert "_lastScrollTop-2" in moved_expr or "_lastScrollTop -" in moved_expr, ( + "movedUp must still require an explicit upward scrollTop delta so " + "downward motion re-pins independently of the intent window (#3250)." + ) From d46d3a141163661e889f57a57e6580e782e9b840 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 31 May 2026 17:03:07 +0000 Subject: [PATCH 5/5] fix: canonicalize ./ and ~/ prefixes in _normalizeArtifactPath (#3262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-release Codex regression gate caught that _normalizeArtifactPath() did not strip ./ or ~/ prefixes, so a tool arg recorded as ./foo.md did not match a file-tree-opened foo.md in _turnMutatedPreviewPaths — the open preview was left stale after an agent edit via a ./-prefixed path. Strip ~/ and leading ./ before ignore/membership checks. Node-driven regression test pins foo.md == ./foo.md == ~/foo.md and confirms the existing ignore-dir / URL / empty rejections still hold. Co-authored-by: Pamnard --- static/workspace.js | 6 ++ ...ssue3262_artifact_path_canonicalization.py | 87 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 tests/test_issue3262_artifact_path_canonicalization.py diff --git a/static/workspace.js b/static/workspace.js index e3a3da4ce0e..4a2eefb49b3 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -170,6 +170,12 @@ function _normalizeArtifactPath(path){ if(!path) return ''; path = String(path).trim().replace(/[\`"'<>),.;:]+$/g,'').replace(/^[\`"'(<]+/g,''); if(!path || path.length > 240 || path.includes('://')) return ''; + // Canonicalize workspace-relative prefixes so a file-tree open ("foo.md") and a + // tool arg recorded as "./foo.md" or "~/foo.md" compare equal for mutation + // tracking; otherwise an agent edit via a ./-prefixed path leaves the open + // preview stale (#3262 / pre-release regression-gate finding). + path = path.replace(/^~\//,'').replace(/^(?:\.\/)+/,''); + if(!path) return ''; if(ARTIFACT_IGNORE_RE.test(path)) return ''; if(!/[./]/.test(path)) return ''; return path; diff --git a/tests/test_issue3262_artifact_path_canonicalization.py b/tests/test_issue3262_artifact_path_canonicalization.py new file mode 100644 index 00000000000..76c813053fa --- /dev/null +++ b/tests/test_issue3262_artifact_path_canonicalization.py @@ -0,0 +1,87 @@ +"""Regression: _normalizeArtifactPath() canonicalizes ./ and ~/ prefixes (#3262). + +The workspace preview reload-on-mutation tracking (#3262) compares the open +preview path against the set of paths the agent's tools touched during the +turn. File-tree opens record a bare workspace-relative path ("foo.md"), but a +tool argument can arrive as "./foo.md" or "~/foo.md". Before the fix, +_normalizeArtifactPath() did not strip those prefixes, so "./foo.md" != "foo.md" +in _turnMutatedPreviewPaths and an agent edit via a ./-prefixed path left the +open preview stale (pre-release Codex regression-gate finding). + +This drives the ACTUAL _normalizeArtifactPath() from static/workspace.js via +node so it can't drift from a Python mirror. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent +WORKSPACE_JS = (REPO / "static" / "workspace.js").read_text(encoding="utf-8") +NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") + + +def _extract(decl_regex: str) -> str: + m = re.search(decl_regex, WORKSPACE_JS) + assert m, f"definition not found: {decl_regex}" + return m.group(0) + + +def _normalize_via_node(paths): + ignore_re = _extract(r"const ARTIFACT_IGNORE_RE = /.*?/;") + # Extract the full function body by brace-matching. + start = WORKSPACE_JS.index("function _normalizeArtifactPath(") + brace = WORKSPACE_JS.index("{", start) + depth = 0 + end = None + for i in range(brace, len(WORKSPACE_JS)): + c = WORKSPACE_JS[i] + if c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + end = i + 1 + break + fn = WORKSPACE_JS[start:end] + driver = ( + ignore_re + "\n" + fn + "\n" + + "const out = JSON.parse(process.argv[1]).map(_normalizeArtifactPath);\n" + + "process.stdout.write(JSON.stringify(out));\n" + ) + r = subprocess.run( + [NODE, "-e", driver, json.dumps(paths)], + capture_output=True, text=True, timeout=15, + ) + assert r.returncode == 0, f"node failed: {r.stderr}" + return json.loads(r.stdout) + + +def test_dot_slash_and_tilde_prefixes_canonicalize_to_bare_path(): + out = _normalize_via_node(["foo.md", "./foo.md", "~/foo.md", "././foo.md"]) + assert out == ["foo.md", "foo.md", "foo.md", "foo.md"], ( + f"./ and ~/ prefixes must canonicalize to the bare workspace-relative " + f"path so mutation tracking matches a file-tree open (#3262); got {out}" + ) + + +def test_nested_relative_path_prefix_canonicalizes(): + out = _normalize_via_node(["sub/dir/x.py", "./sub/dir/x.py", "~/sub/dir/x.py"]) + assert out == ["sub/dir/x.py", "sub/dir/x.py", "sub/dir/x.py"], ( + f"prefix canonicalization must apply to nested paths too (#3262); got {out}" + ) + + +def test_canonicalization_preserves_ignore_and_url_rejection(): + # Canonicalization must not weaken the existing rejections. + out = _normalize_via_node(["./node_modules/x.js", "https://e.com/a", "./"]) + assert out == ["", "", ""], ( + f"ignore-dir, URL, and empty-after-strip rejections must still hold " + f"after prefix canonicalization (#3262); got {out}" + )