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
- **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)
- **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.metering import meter
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.usage import prompt_cache_hit_percent
from api.models import (
@@ -69,6 +69,7 @@ def _session_payload_with_full_messages(session, *, tool_calls=None):
'messages': messages,
'message_count': len(messages),
}
attach_todo_state(raw, messages)
if tool_calls is not None:
raw['tool_calls'] = tool_calls
return raw
+9 -4
View File
@@ -7059,8 +7059,9 @@ function clearInflightState(sid){
// and as the hash that compares "rendered vs current" snapshots.
//
// 2. scheduleTodosRefresh() — coalesces multiple `todo_state` events that
// land in the same animation frame into a single loadTodos() call.
// Skips work entirely when the panel is not active.
// land in the same animation frame into a single refresh pass. It keeps
// 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
// from the session GET payload, or clears the panel when neither a
@@ -7103,12 +7104,14 @@ function scheduleTodosRefresh(){
if(_todosRenderRafId) return;
if(typeof requestAnimationFrame!=='function'){
if(typeof loadTodos==='function') loadTodos();
if(typeof _refreshWorkspacePanelTodos==='function') _refreshWorkspacePanelTodos();
return;
}
_todosRenderRafId=requestAnimationFrame(()=>{
_todosRenderRafId=0;
if(!_todosPanelIsActive()) return;
if(typeof loadTodos==='function') loadTodos();
const sidebarActive=_todosPanelIsActive();
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
// never short-circuited against a hash from a different session.
_todosLastRenderedHash=null;
if(typeof _resetWorkspaceTodosRenderCache==='function') _resetWorkspaceTodosRenderCache();
}
function _hydrateTodosFromSession(session){
@@ -7196,6 +7200,7 @@ function _hydrateTodosFromSession(session){
S.todoStateMeta=null;
}
_resetTodosRenderCache();
if(typeof scheduleTodosRefresh==='function') scheduleTodosRefresh();
}
function snapshotLiveTurnHtmlForSession(sid){
+35
View File
@@ -289,6 +289,7 @@ async function authorizeWorkspaceEscapeNavigation(item){
let _workspacePanelActiveTab = 'files';
let _renderSessionArtifactsTimer = null;
let _workspaceTodosLastRenderedHash = null;
function _setWorkspacePanelTabDataset(){
const panel = document.querySelector('.rightpanel');
@@ -303,6 +304,35 @@ function scheduleRenderSessionArtifacts(){
}, 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(document.readyState === 'loading') document.addEventListener('DOMContentLoaded', _setWorkspacePanelTabDataset, {once:true});
else _setWorkspacePanelTabDataset();
@@ -348,9 +378,14 @@ function _loadWorkspacePanelTodos(){
}
}catch(e){ todos = []; }
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>';
_workspaceTodosLastRenderedHash = '__empty__';
return;
}
const hash = _workspaceTodosHash(todos);
if(hash === _workspaceTodosLastRenderedHash) return;
_workspaceTodosLastRenderedHash = hash;
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 === '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."""
import json
from pathlib import Path
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"]
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():
done_idx = STREAMING_SOURCE.index("put('done', _done_payload)")
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.todoStateMeta=null" 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")
+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 "
"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