fix(workspace): live-sync active Workspace Todos tab

This commit is contained in:
Stacey2911 authored and nesquena-hermes committed 2026-06-28 07:28:30 +00:00
1 parent 37f4af5229
commit b39c147b31
7 files changed
+196 -5

No files matched your search

+2
View File
@@ -18,6 +18,8 @@
### Fixed ### Fixed
- **Workspace → Todos now updates live while the tab is open.** The existing todo refresh scheduler now also repaints the right-panel Workspace → Todos tab when that optional tab is enabled and currently visible, while preserving the left sidebar Todos refresh path and avoiding work when Files or Artifacts is active.
- **Settled assistant turns with interleaved text and tool calls now keep their original order, and no post-tool text is dropped.** When an assistant turn mixed prose with tool calls (text → tool → more text), the settled/reloaded transcript could lose the chronological ordering or silently drop post-tool text/thinking from a non-final assistant message. The Stable Assistant Turn Anchor now promotes a settled turn's mixed `content[]` into ordered scene rows (prose, tool, and thinking rows in sequence) instead of only recovering the raw fallback, and the backend hydration path mirrors the same scene model so a cold reload reconstructs an identical transcript. Only the turn-final assistant message's post-last-tool text is treated as the final answer; earlier assistant messages keep their post-tool content as activity rows. Tool-row de-duplication is conservative (it only merges rows it can positively confirm are the same call), biasing toward an extra visible card over ever silently losing one. Thanks @franksong2702. (#4958) - **Settled assistant turns with interleaved text and tool calls now keep their original order, and no post-tool text is dropped.** When an assistant turn mixed prose with tool calls (text → tool → more text), the settled/reloaded transcript could lose the chronological ordering or silently drop post-tool text/thinking from a non-final assistant message. The Stable Assistant Turn Anchor now promotes a settled turn's mixed `content[]` into ordered scene rows (prose, tool, and thinking rows in sequence) instead of only recovering the raw fallback, and the backend hydration path mirrors the same scene model so a cold reload reconstructs an identical transcript. Only the turn-final assistant message's post-last-tool text is treated as the final answer; earlier assistant messages keep their post-tool content as activity rows. Tool-row de-duplication is conservative (it only merges rows it can positively confirm are the same call), biasing toward an extra visible card over ever silently losing one. Thanks @franksong2702. (#4958)
- **Manual `/compress` now sticks across reloads and restarts instead of springing back to the old context size.** A manual compression shrinks the model-facing context but the old transcript could return through two paths: (1) append-only `state.db` reconciliation re-appending pre-compression rows, and (2) startup `.bak` recovery treating the intentional shrink as data loss. `/compress` now persists a truncation watermark/boundary (so reconciliation stops replaying pre-compression rows), refreshes the post-compression token metric, and drops the now-stale backup. Crucially, the `.bak` safeguard is only suppressed for the *pre-compression* backup whose restore would undo the shrink (detected by content — its model-facing context is still the larger uncompressed one) — a backup written *after* the compression that captures genuine post-compression data loss is **still recovered**, so manual compression can never permanently disable crash-recovery for that session. Thanks @hyl-ailab. (#4986, fixes #4836) - **Manual `/compress` now sticks across reloads and restarts instead of springing back to the old context size.** A manual compression shrinks the model-facing context but the old transcript could return through two paths: (1) append-only `state.db` reconciliation re-appending pre-compression rows, and (2) startup `.bak` recovery treating the intentional shrink as data loss. `/compress` now persists a truncation watermark/boundary (so reconciliation stops replaying pre-compression rows), refreshes the post-compression token metric, and drops the now-stale backup. Crucially, the `.bak` safeguard is only suppressed for the *pre-compression* backup whose restore would undo the shrink (detected by content — its model-facing context is still the larger uncompressed one) — a backup written *after* the compression that captures genuine post-compression data loss is **still recovered**, so manual compression can never permanently disable crash-recovery for that session. Thanks @hyl-ailab. (#4986, fixes #4836)
+2 -1
View File
@@ -45,7 +45,7 @@ from api.helpers import redact_session_data, _redact_text
from api.compression_anchor import is_context_compression_marker, visible_messages_for_anchor from api.compression_anchor import is_context_compression_marker, visible_messages_for_anchor
from api.metering import meter from api.metering import meter
from api.run_journal import RunJournalWriter from api.run_journal import RunJournalWriter
from api.todo_state import emit_todo_state from api.todo_state import attach_todo_state, emit_todo_state
from api.turn_journal import append_turn_journal_event_for_stream from api.turn_journal import append_turn_journal_event_for_stream
from api.usage import prompt_cache_hit_percent from api.usage import prompt_cache_hit_percent
from api.models import ( from api.models import (
@@ -69,6 +69,7 @@ def _session_payload_with_full_messages(session, *, tool_calls=None):
'messages': messages, 'messages': messages,
'message_count': len(messages), 'message_count': len(messages),
} }
attach_todo_state(raw, messages)
if tool_calls is not None: if tool_calls is not None:
raw['tool_calls'] = tool_calls raw['tool_calls'] = tool_calls
return raw return raw
+9 -4
View File
@@ -7059,8 +7059,9 @@ function clearInflightState(sid){
// and as the hash that compares "rendered vs current" snapshots. // and as the hash that compares "rendered vs current" snapshots.
// //
// 2. scheduleTodosRefresh() — coalesces multiple `todo_state` events that // 2. scheduleTodosRefresh() — coalesces multiple `todo_state` events that
// land in the same animation frame into a single loadTodos() call. // land in the same animation frame into a single refresh pass. It keeps
// Skips work entirely when the panel is not active. // the left sidebar Todos behavior unchanged, and also lets the workspace
// Todos tab repaint when that tab is enabled and currently visible.
// //
// 3. _hydrateTodosFromSession(session) — applies cold-load todo_state // 3. _hydrateTodosFromSession(session) — applies cold-load todo_state
// from the session GET payload, or clears the panel when neither a // from the session GET payload, or clears the panel when neither a
@@ -7103,12 +7104,14 @@ function scheduleTodosRefresh(){
if(_todosRenderRafId) return; if(_todosRenderRafId) return;
if(typeof requestAnimationFrame!=='function'){ if(typeof requestAnimationFrame!=='function'){
if(typeof loadTodos==='function') loadTodos(); if(typeof loadTodos==='function') loadTodos();
if(typeof _refreshWorkspacePanelTodos==='function') _refreshWorkspacePanelTodos();
return; return;
} }
_todosRenderRafId=requestAnimationFrame(()=>{ _todosRenderRafId=requestAnimationFrame(()=>{
_todosRenderRafId=0; _todosRenderRafId=0;
if(!_todosPanelIsActive()) return; const sidebarActive=_todosPanelIsActive();
if(typeof loadTodos==='function') loadTodos(); if(sidebarActive&&typeof loadTodos==='function') loadTodos();
if(typeof _refreshWorkspacePanelTodos==='function') _refreshWorkspacePanelTodos();
}); });
} }
@@ -7116,6 +7119,7 @@ function _resetTodosRenderCache(){
// Clear after every cross-session navigation so the next render is // Clear after every cross-session navigation so the next render is
// never short-circuited against a hash from a different session. // never short-circuited against a hash from a different session.
_todosLastRenderedHash=null; _todosLastRenderedHash=null;
if(typeof _resetWorkspaceTodosRenderCache==='function') _resetWorkspaceTodosRenderCache();
} }
function _hydrateTodosFromSession(session){ function _hydrateTodosFromSession(session){
@@ -7196,6 +7200,7 @@ function _hydrateTodosFromSession(session){
S.todoStateMeta=null; S.todoStateMeta=null;
} }
_resetTodosRenderCache(); _resetTodosRenderCache();
if(typeof scheduleTodosRefresh==='function') scheduleTodosRefresh();
} }
function snapshotLiveTurnHtmlForSession(sid){ function snapshotLiveTurnHtmlForSession(sid){
+35
View File
@@ -289,6 +289,7 @@ async function authorizeWorkspaceEscapeNavigation(item){
let _workspacePanelActiveTab = 'files'; let _workspacePanelActiveTab = 'files';
let _renderSessionArtifactsTimer = null; let _renderSessionArtifactsTimer = null;
let _workspaceTodosLastRenderedHash = null;
function _setWorkspacePanelTabDataset(){ function _setWorkspacePanelTabDataset(){
const panel = document.querySelector('.rightpanel'); const panel = document.querySelector('.rightpanel');
@@ -303,6 +304,35 @@ function scheduleRenderSessionArtifacts(){
}, 100); }, 100);
} }
function _workspaceTodosHash(items){
if(!Array.isArray(items)) return '';
let h=items.length+'|';
for(let i=0;i<items.length;i++){
const t=items[i]||{};
h+=String(t.id==null?'':t.id)+'\x1f'+String(t.content==null?(t.text==null?'':t.text):t.content)+'\x1f'+String(t.status==null?'':t.status)+'\x1e';
}
return h;
}
function _workspaceTodosTabIsActive(){
if(typeof window==='undefined'||window._workspaceTodosTab!==true) return false;
if(typeof document==='undefined') return false;
const rightPanel=document.querySelector('.rightpanel');
if(!rightPanel||!rightPanel.dataset||rightPanel.dataset.activeTab!=='todos') return false;
const tab=document.getElementById('workspaceTodosTab');
const panel=document.getElementById('workspaceTodosPanel');
return !!(tab&&panel&&!tab.hidden&&!panel.hidden);
}
function _resetWorkspaceTodosRenderCache(){
_workspaceTodosLastRenderedHash=null;
}
function _refreshWorkspacePanelTodos(){
if(!_workspaceTodosTabIsActive()) return;
_loadWorkspacePanelTodos();
}
if(typeof document !== 'undefined'){ if(typeof document !== 'undefined'){
if(document.readyState === 'loading') document.addEventListener('DOMContentLoaded', _setWorkspacePanelTabDataset, {once:true}); if(document.readyState === 'loading') document.addEventListener('DOMContentLoaded', _setWorkspacePanelTabDataset, {once:true});
else _setWorkspacePanelTabDataset(); else _setWorkspacePanelTabDataset();
@@ -348,9 +378,14 @@ function _loadWorkspacePanelTodos(){
} }
}catch(e){ todos = []; } }catch(e){ todos = []; }
if(!todos.length){ if(!todos.length){
if(_workspaceTodosLastRenderedHash === '__empty__') return;
panel.innerHTML = '<div style="padding:24px 12px;text-align:center;color:var(--muted);font-size:12px">No active tasks</div>'; panel.innerHTML = '<div style="padding:24px 12px;text-align:center;color:var(--muted);font-size:12px">No active tasks</div>';
_workspaceTodosLastRenderedHash = '__empty__';
return; return;
} }
const hash = _workspaceTodosHash(todos);
if(hash === _workspaceTodosLastRenderedHash) return;
_workspaceTodosLastRenderedHash = hash;
const statusIcon = (s) => { const statusIcon = (s) => {
if(s === 'completed') return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>'; if(s === 'completed') return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
if(s === 'in_progress') return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--blue)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>'; if(s === 'in_progress') return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--blue)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
@@ -1,5 +1,6 @@
"""Regression coverage for settled SSE payload message counts.""" """Regression coverage for settled SSE payload message counts."""
import json
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
@@ -35,6 +36,40 @@ def test_full_message_payload_overrides_stale_compact_message_count():
assert payload["message_count"] != session.compact()["message_count"] assert payload["message_count"] != session.compact()["message_count"]
def test_full_message_payload_includes_todo_state_snapshot():
todo_result = {
"todos": [
{"id": "todo-1", "content": "keep workspace todos visible", "status": "in_progress"},
],
"summary": {
"total": 1,
"pending": 0,
"in_progress": 1,
"completed": 0,
"cancelled": 0,
},
}
session = _FakeSession(
session_id="todo-session",
messages=[
{"role": "user", "content": "plan the fix", "timestamp": 100},
{
"role": "tool",
"content": json.dumps(todo_result),
"timestamp": 101,
},
{"role": "assistant", "content": "done", "timestamp": 102},
],
)
payload = _session_payload_with_full_messages(session, tool_calls=[])
assert payload["todo_state"]["todos"] == todo_result["todos"]
assert payload["todo_state"]["summary"] == todo_result["summary"]
assert payload["todo_state"]["version"] == 1
assert payload["todo_state"]["ts"] == 101
def test_done_payload_uses_full_message_count_helper(): def test_done_payload_uses_full_message_count_helper():
done_idx = STREAMING_SOURCE.index("put('done', _done_payload)") done_idx = STREAMING_SOURCE.index("put('done', _done_payload)")
block_start = STREAMING_SOURCE.rfind("raw_session =", 0, done_idx) block_start = STREAMING_SOURCE.rfind("raw_session =", 0, done_idx)
+97
View File
@@ -51,6 +51,103 @@ def test_hydrate_todos_from_session_reconciles_cold_and_inflight_snapshots():
assert "S.todos=inflight.todos" in block assert "S.todos=inflight.todos" in block
assert "S.todoStateMeta=null" in block assert "S.todoStateMeta=null" in block
assert "_resetTodosRenderCache();" in block assert "_resetTodosRenderCache();" in block
assert "scheduleTodosRefresh()" in block
@pytest.mark.skipif(shutil.which("node") is None, reason="node is required for frontend scheduler behavior test")
def test_schedule_todos_refresh_fans_out_without_breaking_sidebar_path(tmp_path):
script = r'''
const fs = require('fs');
const vm = require('vm');
const src = fs.readFileSync(process.argv[2], 'utf8');
const start = src.indexOf('let _todosLastRenderedHash=null;');
const end = src.indexOf('function _resetTodosRenderCache()', start);
if (start < 0 || end < 0) throw new Error('todo scheduler block not found');
const block = src.slice(start, end) + '\nthis.scheduleTodosRefresh = scheduleTodosRefresh;';
function run(panelActive) {
const panel = {classList:{contains:(name)=> name === 'active' && panelActive}};
const calls = {load:0, workspace:0, raf:0};
const context = {
document:{getElementById:(id)=> id === 'panelTodos' ? panel : null},
requestAnimationFrame:(cb)=>{ calls.raf++; cb(); return calls.raf; },
loadTodos:()=>{ calls.load++; },
_refreshWorkspacePanelTodos:()=>{ calls.workspace++; },
};
vm.createContext(context);
vm.runInContext(block, context);
context.scheduleTodosRefresh();
return calls;
}
function assert(cond, msg){ if(!cond) throw new Error(msg); }
let active = run(true);
assert(active.raf === 1, 'scheduler must use RAF coalescing');
assert(active.load === 1, 'active sidebar Todos must still call loadTodos');
assert(active.workspace === 1, 'scheduler must fan out to workspace refresh helper');
let inactive = run(false);
assert(inactive.load === 0, 'inactive sidebar Todos must not call loadTodos from RAF path');
assert(inactive.workspace === 1, 'workspace helper remains responsible for workspace visibility gating');
'''
script_path = tmp_path / "todo_scheduler_test.js"
script_path.write_text(script, encoding="utf-8")
result = subprocess.run(
["node", str(script_path), str(REPO_ROOT / "static" / "ui.js")],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr or result.stdout
@pytest.mark.skipif(shutil.which("node") is None, reason="node is required for workspace todo gate behavior test")
def test_workspace_todos_refresh_gate_requires_enabled_visible_todos_tab(tmp_path):
script = r'''
const fs = require('fs');
const vm = require('vm');
const src = fs.readFileSync(process.argv[2], 'utf8');
const start = src.indexOf('function _workspaceTodosTabIsActive()');
const end = src.indexOf("if(typeof document !== 'undefined')", start);
if (start < 0 || end < 0) throw new Error('workspace todos gate block not found');
const block = src.slice(start, end) + '\nthis._refreshWorkspacePanelTodos = _refreshWorkspacePanelTodos;';
function run({enabled=true, activeTab='todos', tabHidden=false, panelHidden=false}) {
const elements = {
workspaceTodosTab:{hidden:tabHidden},
workspaceTodosPanel:{hidden:panelHidden},
};
const calls = {load:0};
const context = {
window:{_workspaceTodosTab:enabled},
document:{
querySelector:(sel)=> sel === '.rightpanel' ? {dataset:{activeTab}} : null,
getElementById:(id)=> elements[id] || null,
},
_loadWorkspacePanelTodos:()=>{ calls.load++; },
};
vm.createContext(context);
vm.runInContext(block, context);
context._refreshWorkspacePanelTodos();
return calls.load;
}
function assert(cond, msg){ if(!cond) throw new Error(msg); }
assert(run({}) === 1, 'visible enabled workspace Todos tab must refresh');
assert(run({activeTab:'files'}) === 0, 'Files tab must not refresh workspace Todos');
assert(run({activeTab:'artifacts'}) === 0, 'Artifacts tab must not refresh workspace Todos');
assert(run({enabled:false}) === 0, 'default-off workspace Todos setting must not refresh');
assert(run({tabHidden:true}) === 0, 'hidden workspace Todos tab must not refresh');
assert(run({panelHidden:true}) === 0, 'hidden workspace Todos panel must not refresh');
'''
script_path = tmp_path / "workspace_todos_gate_test.js"
script_path.write_text(script, encoding="utf-8")
result = subprocess.run(
["node", str(script_path), str(REPO_ROOT / "static" / "workspace.js")],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr or result.stdout
@pytest.mark.skipif(shutil.which("node") is None, reason="node is required for frontend handler behavior test") @pytest.mark.skipif(shutil.which("node") is None, reason="node is required for frontend handler behavior test")
+16
View File
@@ -52,3 +52,19 @@ def test_workspace_todos_tab_prefers_live_sse_snapshot_before_cold_load_sidecar(
"Workspace Todos must prefer the live S.todos snapshot so opening the tab " "Workspace Todos must prefer the live S.todos snapshot so opening the tab "
"after todo_state SSE updates does not render the stale cold-load sidecar" "after todo_state SSE updates does not render the stale cold-load sidecar"
) )
def test_workspace_todos_render_uses_separate_hash_cache():
src = (REPO_ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
start = src.find("function _loadWorkspacePanelTodos()")
end = src.find("function _escHtml", start)
assert "let _workspaceTodosLastRenderedHash = null;" in src
assert "function _resetWorkspaceTodosRenderCache()" in src
assert start != -1
assert end != -1
helper = src[start:end]
assert "_workspaceTodosLastRenderedHash === '__empty__'" in helper
assert "_workspaceTodosHash(todos)" in helper
assert "if(hash === _workspaceTodosLastRenderedHash) return;" in helper