mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[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:
@@ -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
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user