From 600c04dd02fd3414543e18a1bb9a0fe5344f6942 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 12:59:45 -0700 Subject: [PATCH] [eric] history: stop losing chats (merge live sessions, drop agent-spawned children, sort by last activity, never render a blank row) --- .../manager/session/SessionLifecycle.py | 31 +++++++++++++++++-- backend/tests/test_ws_efficiency.py | 21 +++++++++++++ .../Dashboard/canvas/DashboardCanvas.tsx | 1 + .../Dashboard/canvas/DashboardHeader.tsx | 5 +++ .../app/pages/Workflows/SchedulePopover.tsx | 6 ++-- frontend/src/shared/state/agentsSlice.ts | 8 +++-- 6 files changed, 63 insertions(+), 9 deletions(-) diff --git a/backend/apps/agents/manager/session/SessionLifecycle.py b/backend/apps/agents/manager/session/SessionLifecycle.py index 60eec353..dc85279e 100644 --- a/backend/apps/agents/manager/session/SessionLifecycle.py +++ b/backend/apps/agents/manager/session/SessionLifecycle.py @@ -27,6 +27,9 @@ from backend.apps.agents.manager.run.client_pool import dispose_client_soon logger = logging.getLogger(__name__) +# Agent-spawned children, never a chat the user started, so they stay out of chat history. +P_NON_CHAT_MODES = {"browser-agent", "sub-agent", "invoked-agent", "app-agent"} + from backend.apps.agents.manager.AgentManagerProtocol import AgentManagerProtocol @@ -155,13 +158,35 @@ class SessionLifecycle(AgentManagerProtocol): dashboard_id: Optional[str] = None, closed_only: bool = False, ) -> Dict: - """Return paginated, optionally filtered summaries of closed sessions.""" - all_data = load_all_session_data() - all_data.sort(key=lambda pair: pair[1].get("closed_at") or "", reverse=True) + """Return paginated, optionally filtered summaries of sessions, live ones included.""" + # A malformed file (a list, a bare string) would blow up data.get and 500 the whole endpoint. + all_data = [pair for pair in load_all_session_data() if isinstance(pair[1], dict)] + # Restore deletes the file of every still-open session at boot, so the live ones exist ONLY in memory; without merging them your current chats simply are not in history. + on_disk = {data.get("id", sid) for sid, data in all_data} + for sid, session in self.sessions.items(): + if sid in on_disk: + continue + all_data.append((sid, { + "id": sid, + "name": session.name, + "status": session.status, + "model": session.model, + "mode": session.mode, + "created_at": session.created_at.isoformat() if session.created_at else None, + "closed_at": None, + "cost_usd": session.cost_usd, + "dashboard_id": session.dashboard_id, + "search_text": build_search_text(session), + })) + # Sort on last-activity, not closed_at: keying on closed_at alone sorted every live chat ("" ) below every finished one, i.e. off page 1. + all_data.sort(key=lambda pair: str(pair[1].get("closed_at") or pair[1].get("created_at") or ""), reverse=True) q_lower = q.strip().lower() history = [] for sid, data in all_data: + # Children are machinery, not chats: a busy user's real history was buried under hundreds of "Browser Agent" rows. + if data.get("mode") in P_NON_CHAT_MODES: + continue # The boot fetch wants CLOSED sessions only: open ones landing in the client's history map made its resurrection gate swallow their terminal frames. Search keeps the full pool (open sessions on other dashboards are reachable nowhere else). if closed_only and not data.get("closed_at"): continue diff --git a/backend/tests/test_ws_efficiency.py b/backend/tests/test_ws_efficiency.py index 8f0b239c..31b09a16 100644 --- a/backend/tests/test_ws_efficiency.py +++ b/backend/tests/test_ws_efficiency.py @@ -133,6 +133,8 @@ def test_history_closed_only_filters_open_sessions(monkeypatch): ] import backend.apps.agents.manager.session.SessionLifecycle as lifecycle_mod monkeypatch.setattr(lifecycle_mod, "load_all_session_data", lambda: list(rows)) + # get_history also merges LIVE sessions, so pin them empty or a leftover from another test leaks in. + monkeypatch.setattr(agent_manager, "sessions", {}) closed = agent_manager.get_history(closed_only=True) assert [s["id"] for s in closed["sessions"]] == ["closed1"] # Search keeps the full pool: open sessions on other dashboards are reachable nowhere else. @@ -140,6 +142,25 @@ def test_history_closed_only_filters_open_sessions(monkeypatch): assert {s["id"] for s in everything["sessions"]} == {"open1", "closed1"} +def test_history_includes_live_sessions_missing_from_disk(monkeypatch): + """Boot deletes the file of every still-open session, so history must merge memory or your current chats vanish.""" + from backend.apps.agents.core.models import AgentSession + import backend.apps.agents.manager.session.SessionLifecycle as lifecycle_mod + + rows = [("closed1", {"id": "closed1", "name": "closed chat", "closed_at": "2026-07-01T00:00:00", "dashboard_id": None})] + monkeypatch.setattr(lifecycle_mod, "load_all_session_data", lambda: list(rows)) + live = AgentSession(id="live1", name="live chat", model="haiku") + monkeypatch.setattr(agent_manager, "sessions", {"live1": live}) + + everything = agent_manager.get_history() + assert {s["id"] for s in everything["sessions"]} == {"closed1", "live1"} + # A live session is not closed, so the boot fetch still must not see it. + closed = agent_manager.get_history(closed_only=True) + assert [s["id"] for s in closed["sessions"]] == ["closed1"] + # Findable by content, not just by name. + assert [s["id"] for s in agent_manager.get_history(q="live chat")["sessions"]] == ["live1"] + + def test_history_route_threads_closed_only(monkeypatch): seen = {} diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index f06892be..27cb646c 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -260,6 +260,7 @@ const DashboardCanvas: React.FC = ({ dashboardId={dashboardId} canvasActions={canvas.actions} onHighlightCard={onHighlightCard} + historyAvailable={!anyFullscreen} /> diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardHeader.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardHeader.tsx index 0bf3be12..75cf5e49 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardHeader.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardHeader.tsx @@ -29,6 +29,8 @@ interface DashboardHeaderProps { expandedSessionIds: string[]; outputs: Record; dashboardId: string | undefined; + // The history popover lives in the bottom toolbar, which unmounts in fullscreen; without this the button would be a dead click. + historyAvailable?: boolean; canvasActions: CanvasActions; onHighlightCard?: (cardId: string) => void; } @@ -53,6 +55,7 @@ const DashboardHeader: React.FC = ({ expandedSessionIds, outputs, dashboardId, + historyAvailable = true, canvasActions, onHighlightCard, }) => { @@ -165,6 +168,7 @@ const DashboardHeader: React.FC = ({ /> )} {/* Chat history lives up here on the island, not in the dock, and it spans every dashboard. */} + {historyAvailable && ( { @@ -184,6 +188,7 @@ const DashboardHeader: React.FC = ({ + )} {dashboardId && ( - {entry.name} + {displaySessionName(entry.name)} {/* Only annotate chats that became saved workflows. A small workflow glyph reads as a tag, where the old single-letter chip read as a random initial. */} @@ -291,8 +292,7 @@ function dayBucket(iso: string | null): string { function relTime(iso: string | null): string { if (!iso) return ''; - const sec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); - if (sec < 60) return 'just now'; + const sec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); if (sec < 60) return 'just now'; const m = Math.floor(sec / 60); if (m < 60) return `${m}m ago`; const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`; return `${Math.floor(h / 24)}d ago`; diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 59b4dab6..ea1f240f 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -559,11 +559,13 @@ export const searchHistory = createAsyncThunk( const params = new URLSearchParams({ q, limit: String(limit), offset: String(offset) }); if (dashboardId) params.set('dashboard_id', dashboardId); const res = await fetch(`${AGENTS_API}/history?${params}`); + if (!res.ok) throw new Error(`history ${res.status}`); const data = await res.json(); + // A 500 body has no sessions array; without this the reducer stored undefined and the popover's .map took the whole dashboard down. return { - sessions: data.sessions as HistorySession[], - total: data.total as number, - hasMore: data.has_more as boolean, + sessions: Array.isArray(data.sessions) ? (data.sessions as HistorySession[]) : [], + total: typeof data.total === 'number' ? data.total : 0, + hasMore: !!data.has_more, query: q, offset, };