diff --git a/CHANGELOG.md b/CHANGELOG.md index 102a077f..af7ed133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ### Fixed +- **A background child stream no longer bumps its parent session to the top of the sidebar.** When a child (branched/background) stream was active, its activity re-sorted the parent session's sidebar time-bucket, yanking the parent around while you were working elsewhere. The sidebar sort no longer keys the parent's bucket off child-stream activity, so parents stay put. Thanks @rodboev. (#5729, #5699) + +- **The embedded terminal now recovers from a dropped connection instead of silently freezing.** A transport-level failure on the terminal's output stream (session expired, gateway restarted, network drop) fired an EventSource `error` with no handler, so the terminal froze with no feedback. It now shows a "connection lost, reconnecting…" / "disconnected" line, lets the browser auto-reconnect a recoverable connection, and tears down a permanently-closed one so a restart can reconnect — notifying once per outage so a flapping connection can't flood the pane. Thanks @ai-ag2026. (#5786) + - **The OpenCode Go model picker now lists the current flat-rate catalog.** The static `opencode-go` model snapshot was stale; it's been re-synced from the public opencode.ai/go docs + models endpoint (adds MiniMax M3, Kimi K2.7 Code, GLM-5.2, Qwen 3.7 Max/Plus, and more), keeping preview/free-only Zen models out of the Go picker. Thanks @ai-ag2026. (#5761) - **The background-task completion drain thread can no longer spin at 100% CPU when the process registry is (re)initializing.** If the registry didn't yet expose its `completion_queue`, the drain loop's broad `except Exception: continue` swallowed the `AttributeError` and immediately retried with no delay — a hot loop. The loop now reads the queue defensively (`getattr(..., None)`) and backs off on the stop event when it's missing, catches `queue.Empty` separately as the normal idle path, and logs (no longer silently swallows) any unexpected queue error before backing off. No delivery latency is added — real completions are still drained on the 1 Hz blocking `get`. Thanks @ai-ag2026. (#5782, #2476, #4633) diff --git a/static/sessions.js b/static/sessions.js index 71092b2e..7a7cc79a 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -6214,7 +6214,6 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere const childActivitySec=Number(childActivityRaw); if(Number.isFinite(childActivitySec)&&childActivitySec>Number(parentRow._child_session_latest_at||0)){ parentRow._child_session_latest_at=childActivitySec; - parentRow._sidebar_activity_at=Math.max(Number(parentRow.last_message_at||parentRow.updated_at||parentRow.created_at||0), childActivitySec); } const childAttention=childRow&&childRow.attention&&typeof childRow.attention==='object'?childRow.attention:null; if(!childAttention||!childAttention.kind||!Number.isFinite(Number(childAttention.count))||Number(childAttention.count)<=0) return; diff --git a/static/terminal.js b/static/terminal.js index 69002012..82def255 100644 --- a/static/terminal.js +++ b/static/terminal.js @@ -431,6 +431,34 @@ function _connectTerminalOutput(){ try{if(source&&source.readyState!==2)source.close();}catch(_){} TERMINAL_UI.source=null; }); + // A successful (re)connect clears the notify latch so the NEXT outage notifies + // again — "once per outage", not "once per source lifetime". Without this, a + // source that errors, auto-reconnects, then drops a second time would stay + // silent (guard already true, not CLOSED) — the exact freeze this handler + // prevents. + source.addEventListener('open',()=>{ + if(TERMINAL_UI.source!==source)return; + source._terminalErrNotified=false; + }); + // Transport-level failures (session expired, gateway killed, network drop) + // fire 'error' rather than a terminal_* event; without this the terminal froze + // with no feedback and no telemetry. Let the browser auto-reconnect a merely + // CONNECTING source (no manual loop/backoff), but surface a permanently CLOSED + // one and tear it down so a restart can reconnect. Notify once per outage so a + // flapping connection can't flood the pane or telemetry. + source.addEventListener('error',()=>{ + if(TERMINAL_UI.source!==source)return; + const closed=source.readyState===EventSource.CLOSED; + if(closed||!source._terminalErrNotified){ + source._terminalErrNotified=true; + if(typeof recordClientSSEError==='function')recordClientSSEError('terminal',{ready_state:source?source.readyState:null,reason:'terminal EventSource.onerror'}); + if(TERMINAL_UI.term)TERMINAL_UI.term.writeln('\r\n[terminal '+(closed?'disconnected':'connection lost, reconnecting…')+']\r\n'); + } + if(closed){ + try{if(source&&source.readyState!==2)source.close();}catch(_){} + TERMINAL_UI.source=null; + } + }); } async function _startComposerTerminal(restart=false){ diff --git a/tests/test_session_lineage_collapse.py b/tests/test_session_lineage_collapse.py index 56b355eb..5a7ce5a6 100644 --- a/tests/test_session_lineage_collapse.py +++ b/tests/test_session_lineage_collapse.py @@ -1082,7 +1082,7 @@ console.log(JSON.stringify(rows)); assert "_child_sessions" not in rows[0] -def test_nested_fork_bubbles_latest_child_timestamp_for_sorting(): +def test_nested_fork_tracks_latest_child_without_changing_parent_bucket_timestamp(): js = SESSIONS_JS_PATH.read_text(encoding="utf-8") source = f""" const src = {js!r}; @@ -1112,15 +1112,15 @@ console.log(JSON.stringify({{row: rows[0], timestampMs: _sessionTimestampMs(rows result = json.loads(_run_node(source)) row = result["row"] assert row["session_id"] == "parent" - # Keep the parent row's persisted timestamp intact, but use newest attached - # child/subagent activity for sidebar sorting/date grouping. + # Keep the parent row's persisted timestamp intact while still recording + # the newest attached child/subagent metadata. assert row["last_message_at"] == 10 assert row["_child_session_latest_at"] == 20 - assert row["_sidebar_activity_at"] == 20 - assert result["timestampMs"] == 20_000 + assert "_sidebar_activity_at" not in row + assert result["timestampMs"] == 10_000 -def test_hidden_archived_lineage_child_bubbles_running_state_and_activity_to_visible_parent(): +def test_hidden_archived_lineage_child_bubbles_running_state_and_latest_child_timestamp_without_changing_parent_bucket(): js = SESSIONS_JS_PATH.read_text(encoding="utf-8") source = f""" const src = {js!r}; @@ -1179,11 +1179,11 @@ console.log(JSON.stringify({{row: rows[0], count: rows.length, timestampMs: _ses assert "_child_sessions" not in row assert row["_child_session_streaming"] is True assert row["_child_session_latest_at"] == 30 - assert row["_sidebar_activity_at"] == 30 - assert result["timestampMs"] == 30_000 + assert "_sidebar_activity_at" not in row + assert result["timestampMs"] == 10_000 -def test_archived_lineage_child_without_root_id_falls_back_to_parent_for_bubbling(): +def test_archived_lineage_child_without_root_id_preserves_parent_bucket_timestamp(): js = SESSIONS_JS_PATH.read_text(encoding="utf-8") source = f""" const src = {js!r}; @@ -1241,8 +1241,8 @@ console.log(JSON.stringify({{row: rows[0], count: rows.length, timestampMs: _ses assert "_child_sessions" not in row assert row["_child_session_streaming"] is True assert row["_child_session_latest_at"] == 30 - assert row["_sidebar_activity_at"] == 30 - assert result["timestampMs"] == 30_000 + assert "_sidebar_activity_at" not in row + assert result["timestampMs"] == 10_000 def test_non_archived_reference_only_lineage_row_does_not_bubble_to_visible_parent(): @@ -1308,7 +1308,7 @@ console.log(JSON.stringify({{row: rows[0], count: rows.length, timestampMs: _ses assert result["timestampMs"] == 10_000 -def test_stale_active_stream_id_on_hidden_archived_child_does_not_bubble_streaming_state(): +def test_stale_active_stream_id_on_hidden_archived_child_keeps_parent_bucket_and_latest_child_timestamp(): js = SESSIONS_JS_PATH.read_text(encoding="utf-8") source = f""" const src = {js!r}; @@ -1367,8 +1367,8 @@ console.log(JSON.stringify({{row: rows[0], count: rows.length, timestampMs: _ses assert "_child_sessions" not in row assert "_child_session_streaming" not in row assert row["_child_session_latest_at"] == 30 - assert row["_sidebar_activity_at"] == 30 - assert result["timestampMs"] == 30_000 + assert "_sidebar_activity_at" not in row + assert result["timestampMs"] == 10_000 def test_collapsed_lineage_segments_do_not_render_as_child_sessions():