diff --git a/api/routes.py b/api/routes.py index 5603081a..3a6561fc 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1908,10 +1908,20 @@ def _build_session_list_cache_payload( 1 for s in scoped if _is_cli_session_for_settings(s) ) - if sidebar_source == "webui": - scoped = [s for s in scoped if not _is_cli_session_for_settings(s)] - elif sidebar_source == "cli": - scoped = [s for s in scoped if _is_cli_session_for_settings(s)] + def _filter_sidebar_source(rows: list[dict]) -> list[dict]: + if sidebar_source == "webui": + return [s for s in rows if not _is_cli_session_for_settings(s)] + if sidebar_source == "cli": + return [s for s in rows if _is_cli_session_for_settings(s)] + return list(rows) + + scoped = _filter_sidebar_source(scoped) + sidebar_reference_sessions: list[dict] = [] + if not include_archived: + sidebar_reference_sessions = _hidden_archived_sidebar_reference_sessions( + _filter_sidebar_source(visible_scoped), + _filter_sidebar_source(archived_scoped), + ) if not include_archived: diag_stage("filter_archived_sessions") diag_stage("visible_lineage_metadata") @@ -1921,6 +1931,10 @@ def _build_session_list_cache_payload( dict(s) if isinstance(s, dict) else {} for s in scoped ], + "sidebar_reference_sessions": [ + dict(s) if isinstance(s, dict) else {} + for s in sidebar_reference_sessions + ], "cli_count": len(deduped_cli), "archived_count": archived_count, "archived_webui_count": archived_webui_count, @@ -1957,8 +1971,15 @@ def _session_list_payload_to_response(payload: dict) -> dict: for s in runtime_rows: item = _sidebar_session_response_item(s, redact_enabled=_redact_enabled) if isinstance(s, dict) else {} safe_merged.append(item) + safe_reference = [] + for s in payload.get("sidebar_reference_sessions", []) or []: + item = _sidebar_session_response_item(s, redact_enabled=_redact_enabled) if isinstance(s, dict) else {} + if item: + item["_sidebar_reference_only"] = True + safe_reference.append(item) response = { "sessions": safe_merged, + "sidebar_reference_sessions": safe_reference, "cli_count": int(payload.get("cli_count", 0)), "archived_count": int(payload.get("archived_count", 0)), "archived_webui_count": int(payload.get("archived_webui_count", 0)), @@ -1977,6 +1998,53 @@ def _session_list_payload_to_response(payload: dict) -> dict: return response +def _hidden_archived_sidebar_reference_sessions( + visible_rows: list[dict], + archived_rows: list[dict], +) -> list[dict]: + """Return hidden archived ancestors needed for client-side sidebar nesting. + + The default sidebar payload intentionally omits archived sessions. The + browser still needs a tiny reference row for an archived parent/ancestor so + `_attachChildSessionsToSidebarRows()` can suppress its visible child rows + instead of rendering them as orphan top-level conversations (#4293). + """ + archived_by_id = { + str(row.get("session_id")): row + for row in archived_rows + if isinstance(row, dict) and row.get("archived") and row.get("session_id") + } + if not archived_by_id: + return [] + + references: list[dict] = [] + added: set[str] = set() + visible_ids = { + str(row.get("session_id")) + for row in visible_rows + if isinstance(row, dict) and row.get("session_id") + } + + for row in visible_rows: + if not isinstance(row, dict): + continue + parent_id = str(row.get("parent_session_id") or "").strip() + seen: set[str] = set() + while parent_id and parent_id not in seen: + seen.add(parent_id) + if parent_id in visible_ids: + break + parent = archived_by_id.get(parent_id) + if not parent: + break + if parent_id not in added: + references.append(parent) + added.add(parent_id) + parent_id = str(parent.get("parent_session_id") or "").strip() + + return references + + def _get_cached_session_list_payload( *, key: tuple, diff --git a/static/sessions.js b/static/sessions.js index ee092023..04109f8f 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3024,6 +3024,7 @@ async function _ensureAllMessagesLoaded() { } let _allSessions = []; // cached for search filter +let _sidebarReferenceSessions = []; // hidden archived ancestor rows used only for nesting/suppression let _allSessionsScope = null; // {profile, allProfiles} the cache was loaded under (#4167) let _sessionAttentionSoundPrimed = false; const _sessionAttentionSoundState = new Map(); @@ -4169,6 +4170,9 @@ function _applySessionListPayload(sessData, projData){ const serverSessions=_optimisticallyRemovedSessionIds.size ? (sessData.sessions||[]).filter(s=>s&&!_optimisticallyRemovedSessionIds.has(s.session_id)) : (sessData.sessions||[]); + _sidebarReferenceSessions = Array.isArray(sessData.sidebar_reference_sessions) + ? sessData.sidebar_reference_sessions + : []; _reconcileActiveSessionIdleStateFromList(serverSessions); _allSessions = _mergeOptimisticFirstTurnSessions(serverSessions); // Tag the cache with the scope it was loaded under (active profile + @@ -4305,6 +4309,7 @@ async function _runRenderSessionListRefresh(opts, _gen){ renderSessionListFromCache(); } else { _allSessions = []; + _sidebarReferenceSessions = []; _allSessionsScope = _curScope; _clearSessionSourceTabCounts(); renderSessionListFromCache(); @@ -5989,7 +5994,10 @@ function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){ } function _renderSidebarRowsFromRawSessions(sessionsRaw, referenceSessionsRaw){ - const referenceRows=Array.isArray(referenceSessionsRaw)?referenceSessionsRaw:sessionsRaw; + let referenceRows=Array.isArray(referenceSessionsRaw)?referenceSessionsRaw:sessionsRaw; + if(typeof _sidebarReferenceSessions!=='undefined'&&Array.isArray(_sidebarReferenceSessions)&&_sidebarReferenceSessions.length){ + referenceRows=[...referenceRows,..._sidebarReferenceSessions]; + } return _attachChildSessionsToSidebarRows(_collapseSessionLineageForSidebar(sessionsRaw), sessionsRaw, referenceRows); } diff --git a/tests/test_issue4766_sidebar_source_pushdown.py b/tests/test_issue4766_sidebar_source_pushdown.py index a72087c6..6918a213 100644 --- a/tests/test_issue4766_sidebar_source_pushdown.py +++ b/tests/test_issue4766_sidebar_source_pushdown.py @@ -156,6 +156,49 @@ def test_sidebar_source_webui_excludes_cli_rows(monkeypatch): assert set(enriched[0]) == expected +def test_sidebar_source_webui_includes_hidden_archived_parent_reference(monkeypatch): + rows = [ + { + "session_id": "webui-parent", + "title": "Archived parent", + "profile": "default", + "archived": True, + "message_count": 10, + "updated_at": 1000, + "last_message_at": 1000, + "source": "webui", + "raw_source": "webui", + "session_source": "webui", + "source_tag": "webui", + }, + { + "session_id": "webui-child", + "title": "Subagent Session", + "profile": "default", + "archived": False, + "message_count": 4, + "updated_at": 1100, + "last_message_at": 1100, + "source": "subagent", + "raw_source": "subagent", + "session_source": "other", + "source_tag": "subagent", + "parent_session_id": "webui-parent", + "relationship_type": "child_session", + }, + ] + _install_common_monkeypatches(monkeypatch, rows) + + handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui&exclude_hidden=1") + + body = handler.json_body() + assert handler.status == 200 + assert [row["session_id"] for row in body["sessions"]] == ["webui-child"] + assert [row["session_id"] for row in body["sidebar_reference_sessions"]] == ["webui-parent"] + assert body["sidebar_reference_sessions"][0]["archived"] is True + assert body["sidebar_reference_sessions"][0]["_sidebar_reference_only"] is True + + def test_sidebar_source_cli_excludes_webui_rows(monkeypatch): rows = _session_rows(webui_count=30, cli_count=20) _install_common_monkeypatches(monkeypatch, rows) diff --git a/tests/test_session_lineage_collapse.py b/tests/test_session_lineage_collapse.py index 9d441245..2e693d6c 100644 --- a/tests/test_session_lineage_collapse.py +++ b/tests/test_session_lineage_collapse.py @@ -421,6 +421,41 @@ console.log(JSON.stringify(rows)); assert "_child_sessions" not in rows[0] +def test_sidebar_reference_rows_suppress_source_filtered_archived_parent_child(): + """Source-filtered payloads keep archived parents as non-rendered references.""" + js = SESSIONS_JS_PATH.read_text(encoding="utf-8") + source = f""" +const src = {js!r}; +function extractFunc(name) {{ + const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +var _showArchived = false; +var _sidebarReferenceSessions = [{{session_id:'parent', title:'Archived parent', archived:true, updated_at:10, last_message_at:10}}]; +eval(extractFunc('_sessionTimestampMs')); +eval(extractFunc('_isChildSession')); +eval(extractFunc('_isForkWithResolvableParent')); +eval(extractFunc('_sessionLineageKey')); +eval(extractFunc('_sidebarLineageKeyForRow')); +eval(extractFunc('_collapseSessionLineageForSidebar')); +eval(extractFunc('_attachChildSessionsToSidebarRows')); +eval(extractFunc('_renderSidebarRowsFromRawSessions')); +const child = {{session_id:'child', title:'Subagent', parent_session_id:'parent', relationship_type:'child_session', updated_at:20, last_message_at:20}}; +const rows = _renderSidebarRowsFromRawSessions([child], [child]); +console.log(JSON.stringify(rows)); +""" + assert json.loads(_run_node(source)) == [] + + def test_archived_hidden_parent_suppresses_child_and_fork_orphans(): """Archived parents should not leave child/delegate clutter behind (#4293).""" js = SESSIONS_JS_PATH.read_text(encoding="utf-8")