[eric] history: stop losing chats (merge live sessions, drop agent-spawned children, sort by last activity, never render a blank row)

This commit is contained in:
ciregenz
2026-07-30 12:59:45 -07:00
parent 7fb3b85a64
commit 600c04dd02
6 changed files with 63 additions and 9 deletions
@@ -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
+21
View File
@@ -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 = {}
@@ -260,6 +260,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
dashboardId={dashboardId}
canvasActions={canvas.actions}
onHighlightCard={onHighlightCard}
historyAvailable={!anyFullscreen}
/>
</Box>
</Box>
@@ -29,6 +29,8 @@ interface DashboardHeaderProps {
expandedSessionIds: string[];
outputs: Record<string, Output>;
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<DashboardHeaderProps> = ({
expandedSessionIds,
outputs,
dashboardId,
historyAvailable = true,
canvasActions,
onHighlightCard,
}) => {
@@ -165,6 +168,7 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
/>
)}
{/* Chat history lives up here on the island, not in the dock, and it spans every dashboard. */}
{historyAvailable && (
<Tooltip title="Chat history" placement="bottom">
<Box
onClick={(e: React.MouseEvent) => {
@@ -184,6 +188,7 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
<HistoryIcon sx={{ fontSize: 16, color: c.text.tertiary }} />
</Box>
</Tooltip>
)}
{dashboardId && (
<Box sx={{ ml: 0.25, display: 'flex' }}>
<ShareButton
@@ -15,6 +15,7 @@ import AddIcon from '@mui/icons-material/Add';
import { AnimatePresence, motion } from 'framer-motion';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppSelector } from '@/shared/hooks';
import { displaySessionName } from '@/shared/state/sessionDisplay';
import type { WorkflowRun } from '@/shared/state/workflowsSlice';
import ScheduleCalendar from './ScheduleCalendar';
import { HistoryList } from './WorkflowCardSubviews';
@@ -178,7 +179,7 @@ export default function SchedulePopover({
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: '7px', bgcolor: c.bg.elevated, color: c.text.muted, flexShrink: 0 }}>
<ChatBubbleOutlineIcon sx={{ fontSize: 13 }} />
</Box>
<Typography sx={{ flex: 1, fontSize: '0.8125rem', color: c.text.primary, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{entry.name}</Typography>
<Typography sx={{ flex: 1, fontSize: '0.8125rem', color: c.text.primary, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{displaySessionName(entry.name)}</Typography>
{/* 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`;
+5 -3
View File
@@ -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,
};