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.
This commit is contained in:
Pamnard
2026-05-31 14:58:45 +03:00
committed by nesquena-hermes
parent 9365f2d219
commit ee414144d3
4 changed files with 70 additions and 7 deletions

View File

@@ -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)

View File

@@ -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);

View File

@@ -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 <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')
@@ -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

View File

@@ -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