fix(workspace): live-sync active Workspace Todos tab
This commit is contained in:
1 parent
37f4af5229
commit
b39c147b31
7 files changed
+196
-5
No files matched your search
@@ -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
@@ -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
@@ -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){
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user