Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@

## [Unreleased]

## [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).
- 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
Expand Down
6 changes: 5 additions & 1 deletion static/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1974,7 +1977,8 @@ 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('.');
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);
}
Expand Down
2 changes: 1 addition & 1 deletion static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
56 changes: 49 additions & 7 deletions static/workspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -223,6 +229,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();
Expand Down Expand Up @@ -284,7 +321,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{
Expand Down Expand Up @@ -314,12 +352,14 @@ 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{
clearPreview({keepPanelOpen:true});
}
}else if(preservePreview){
await refreshOpenPreviewIfMutated();
}
// Fetch git info for workspace root (non-blocking)
if(!path||path==='.') _refreshGitBadge();
Expand Down Expand Up @@ -488,9 +528,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)){
Expand All @@ -507,14 +549,14 @@ async function openFile(path){
if(IMAGE_EXTS.has(ext)){
// Image: load via raw endpoint, show as <img>
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')
Expand All @@ -524,7 +566,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
Expand Down Expand Up @@ -556,7 +598,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
Expand Down
2 changes: 1 addition & 1 deletion tests/test_issue2823_large_markdown_preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
96 changes: 96 additions & 0 deletions tests/test_issue3250_upward_scroll_intent_window.py
Original file line number Diff line number Diff line change
@@ -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)."
)
87 changes: 87 additions & 0 deletions tests/test_issue3262_artifact_path_canonicalization.py
Original file line number Diff line number Diff line change
@@ -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}"
)
2 changes: 1 addition & 1 deletion tests/test_issue856_background_completion_unread.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading