diff --git a/.changeset/planner-show-and-archive-sessions.md b/.changeset/planner-show-and-archive-sessions.md new file mode 100644 index 0000000000..405a88580f --- /dev/null +++ b/.changeset/planner-show-and-archive-sessions.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Planning sidebar now lists every saved planning session, not just active ones, so a session that finishes while the modal is closed remains selectable on refresh — previously the `/api/ai-sessions` listing filtered out `complete` rows and they vanished from the UI even though the result was still in SQLite. Adds the ability to archive and unarchive completed (or errored) planning sessions: a per-row archive button hides terminal sessions from the sidebar, and a "Show archived" toggle reveals them for unarchive. Backed by a new `ai_sessions.archived` column (migration 57), `POST /api/ai-sessions/:id/archive` and `/unarchive` endpoints (only terminal sessions are archivable so live agents can't be orphaned), and `?includeCompleted` / `?includeArchived` query flags on `GET /api/ai-sessions`. Existing consumers (`useBackgroundSessions`, `MissionManager`) are unchanged — they continue to see only active/retryable sessions. diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index c39d2d34a4..bd0708814c 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -2126,6 +2126,20 @@ export class Database { }); } + // Allow users to archive completed/errored AI sessions out of the + // planning sidebar without deleting them. Cleanup still removes them + // after the configured TTL; archive is purely for hiding. + if (version < 57) { + this.applyMigration(57, () => { + if (this.hasTable("ai_sessions")) { + this.addColumnIfMissing("ai_sessions", "archived", "INTEGER DEFAULT 0"); + this.db.exec( + "CREATE INDEX IF NOT EXISTS idxAiSessionsArchived ON ai_sessions(archived)", + ); + } + }); + } + } /** diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index e30ccb21d3..600e25fc8d 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -7040,6 +7040,7 @@ export interface AiSessionSummary { projectId: string | null; lockedByTab: string | null; updatedAt: string; + archived?: boolean; } export interface ConversationHistoryEntry { @@ -7070,9 +7071,16 @@ export function parseConversationHistory(raw: string): ConversationHistoryEntry[ } } -export async function fetchAiSessions(projectId?: string): Promise { - const params = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; - const res = await fetch(buildApiUrl(`/ai-sessions${params}`), { +export async function fetchAiSessions( + projectId?: string, + options?: { includeCompleted?: boolean; includeArchived?: boolean }, +): Promise { + const search = new URLSearchParams(); + if (projectId) search.set("projectId", projectId); + if (options?.includeCompleted) search.set("includeCompleted", "1"); + if (options?.includeArchived) search.set("includeArchived", "1"); + const qs = search.toString(); + const res = await fetch(buildApiUrl(`/ai-sessions${qs ? `?${qs}` : ""}`), { headers: withTokenHeader(), }); if (!res.ok) return []; @@ -7080,6 +7088,18 @@ export async function fetchAiSessions(projectId?: string): Promise { + return api(`/ai-sessions/${encodeURIComponent(id)}/archive`, { + method: "POST", + }); +} + +export async function unarchiveAiSession(id: string): Promise { + return api(`/ai-sessions/${encodeURIComponent(id)}/unarchive`, { + method: "POST", + }); +} + export async function fetchAiSession(id: string): Promise { const res = await fetch(buildApiUrl(`/ai-sessions/${encodeURIComponent(id)}`), { headers: withTokenHeader(), diff --git a/packages/dashboard/app/components/PlanningModeModal.css b/packages/dashboard/app/components/PlanningModeModal.css index 6c74171387..60d0156f40 100644 --- a/packages/dashboard/app/components/PlanningModeModal.css +++ b/packages/dashboard/app/components/PlanningModeModal.css @@ -96,6 +96,36 @@ padding: 12px; border-bottom: 1px solid var(--border); flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.planning-sidebar-toggle-archived { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 6px 8px; + background: none; + border: 1px solid transparent; + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: background var(--transition-fast), color var(--transition-fast); +} + +.planning-sidebar-toggle-archived:hover { + background: var(--card-hover); + color: var(--text); +} + +.planning-sidebar-toggle-archived.active { + background: color-mix(in srgb, var(--todo) 12%, transparent); + border-color: var(--todo); + color: var(--todo); } .planning-sidebar-new { @@ -216,7 +246,13 @@ .planning-sidebar-status-complete { color: var(--success, #3fb950); } .planning-sidebar-status-error { color: var(--danger, #f85149); } -.planning-sidebar-item-delete { +.planning-sidebar-item-actions { + display: flex; + align-items: stretch; +} + +.planning-sidebar-item-delete, +.planning-sidebar-item-archive { flex-shrink: 0; width: 32px; display: none; @@ -230,7 +266,9 @@ } .planning-sidebar-item:hover .planning-sidebar-item-delete, -.planning-sidebar-item:focus-within .planning-sidebar-item-delete { +.planning-sidebar-item:focus-within .planning-sidebar-item-delete, +.planning-sidebar-item:hover .planning-sidebar-item-archive, +.planning-sidebar-item:focus-within .planning-sidebar-item-archive { display: flex; } @@ -239,6 +277,15 @@ background: color-mix(in srgb, var(--danger, #f85149) 15%, transparent); } +.planning-sidebar-item-archive:hover { + color: var(--todo); + background: color-mix(in srgb, var(--todo) 15%, transparent); +} + +.planning-sidebar-item.archived .planning-sidebar-item-button { + opacity: 0.6; +} + .planning-sidebar-confirm { display: flex; align-items: center; @@ -304,8 +351,9 @@ .planning-mobile-back { display: inline-flex; } - /* Always keep delete button visible on mobile (no hover) */ - .planning-sidebar-item-delete { + /* Always keep action buttons visible on mobile (no hover) */ + .planning-sidebar-item-delete, + .planning-sidebar-item-archive { display: flex; } } diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index c843559294..53fe2796d8 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -11,6 +11,8 @@ import { fetchAiSession, fetchAiSessions, deleteAiSession, + archiveAiSession, + unarchiveAiSession, parseConversationHistory, startPlanningBreakdown, createTasksFromPlanning, @@ -31,7 +33,7 @@ import { getPlanningDescription, clearPlanningDescription, } from "../hooks/modalPersistence"; -import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, RefreshCw, Lock, ChevronLeft, MessageSquarePlus, AlertCircle, Clock, HelpCircle, StopCircle } from "lucide-react"; +import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, RefreshCw, Lock, ChevronLeft, MessageSquarePlus, AlertCircle, Clock, HelpCircle, StopCircle, Archive, ArchiveRestore } from "lucide-react"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { ConversationHistory } from "./ConversationHistory"; import { useSessionLock } from "../hooks/useSessionLock"; @@ -145,6 +147,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat // `mobileShowDetail` toggles between list (false) and detail (true). const [mobileShowDetail, setMobileShowDetail] = useState(Boolean(resumeSessionId)); const [pendingDeleteId, setPendingDeleteId] = useState(null); + const [showArchived, setShowArchived] = useState(false); // Track whether the mousedown that initiated a click came from inside the // modal. Resizing via the bottom-right grip can release the mouse outside // the modal element; without this guard, that release fires a click whose @@ -563,7 +566,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const refreshSessionsList = useCallback(async () => { setSessionsLoading(true); try { - const all = await fetchAiSessions(projectId); + const all = await fetchAiSessions(projectId, { + includeCompleted: true, + includeArchived: showArchived, + }); const planning = all .filter((s) => s.type === "planning") .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)); @@ -573,7 +579,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat } finally { setSessionsLoading(false); } - }, [projectId]); + }, [projectId, showArchived]); useEffect(() => { if (!isOpen) return; @@ -696,6 +702,43 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat [broadcastCompleted, planningSessions, projectId, resetDetailState, selectedSessionId, sessionTabId], ); + const handleArchiveSession = useCallback( + async (sessionId: string) => { + const target = planningSessions.find((s) => s.id === sessionId); + const wasArchived = target?.archived === true; + try { + if (wasArchived) { + await unarchiveAiSession(sessionId); + } else { + await archiveAiSession(sessionId); + } + } catch { + // best-effort; SSE will reconcile on success and the row stays put on + // failure so the user can retry. + return; + } + // Optimistic local update — SSE will deliver the authoritative version. + // When hiding (archive while showArchived=false) drop the row; when + // unarchiving keep it visible with the new flag flipped. + setPlanningSessions((prev) => { + if (!wasArchived && !showArchived) { + return prev.filter((s) => s.id !== sessionId); + } + return prev.map((s) => (s.id === sessionId ? { ...s, archived: !wasArchived } : s)); + }); + if (!wasArchived && selectedSessionId === sessionId && !showArchived) { + // The currently-open archived session is no longer in the visible list; + // collapse the detail pane so the user lands on a sensible default. + streamConnectionRef.current?.close(); + streamConnectionRef.current = null; + resetDetailState(); + setSelectedSessionId(null); + setMobileShowDetail(false); + } + }, + [planningSessions, resetDetailState, selectedSessionId, showArchived], + ); + // Reset hasAutoStarted when modal closes useEffect(() => { if (!isOpen) { @@ -1119,6 +1162,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat loading={sessionsLoading} selectedSessionId={selectedSessionId} pendingDeleteId={pendingDeleteId} + showArchived={showArchived} + onToggleShowArchived={() => setShowArchived((v) => !v)} + onArchive={(id) => void handleArchiveSession(id)} onSelectSession={handleSelectSession} onNewSession={handleNewSession} onRequestDelete={setPendingDeleteId} @@ -2115,6 +2161,9 @@ interface PlanningSessionListProps { loading: boolean; selectedSessionId: string | null; pendingDeleteId: string | null; + showArchived: boolean; + onToggleShowArchived: () => void; + onArchive: (id: string) => void; onSelectSession: (id: string) => void; onNewSession: () => void; onRequestDelete: (id: string) => void; @@ -2127,6 +2176,9 @@ function PlanningSessionList({ loading, selectedSessionId, pendingDeleteId, + showArchived, + onToggleShowArchived, + onArchive, onSelectSession, onNewSession, onRequestDelete, @@ -2144,6 +2196,16 @@ function PlanningSessionList({ New session +
@@ -2156,10 +2218,12 @@ function PlanningSessionList({ {sessions.map((session) => { const isSelected = session.id === selectedSessionId; const isPendingDelete = pendingDeleteId === session.id; + const isArchived = session.archived === true; + const isTerminal = session.status === "complete" || session.status === "error"; return (
) : ( - +
+ {isTerminal && ( + + )} + +
)}
); diff --git a/packages/dashboard/src/ai-session-store.ts b/packages/dashboard/src/ai-session-store.ts index 6e21d1d840..2ff508a63c 100644 --- a/packages/dashboard/src/ai-session-store.ts +++ b/packages/dashboard/src/ai-session-store.ts @@ -35,6 +35,8 @@ export interface AiSessionRow { updatedAt: string; lockedByTab: string | null; lockedAt: string | null; + /** 1 if archived (hidden from planning sidebar), 0 otherwise. */ + archived?: number; } /** Summary returned by listActive (omits large fields) */ @@ -46,6 +48,7 @@ export interface AiSessionSummary { projectId: string | null; lockedByTab: string | null; updatedAt: string; + archived?: boolean; } export interface AiSessionStoreEvents { @@ -215,21 +218,99 @@ export class AiSessionStore extends EventEmitter { if (projectId) { return this.db .prepare( - `SELECT id, type, status, title, projectId, lockedByTab, updatedAt FROM ai_sessions - WHERE status IN ('generating', 'awaiting_input', 'error') AND projectId = ? + `SELECT id, type, status, title, projectId, lockedByTab, updatedAt, archived FROM ai_sessions + WHERE status IN ('generating', 'awaiting_input', 'error') + AND COALESCE(archived, 0) = 0 + AND projectId = ? ORDER BY updatedAt DESC`, ) .all(projectId) as unknown as AiSessionSummary[]; } return this.db .prepare( - `SELECT id, type, status, title, projectId, lockedByTab, updatedAt FROM ai_sessions + `SELECT id, type, status, title, projectId, lockedByTab, updatedAt, archived FROM ai_sessions WHERE status IN ('generating', 'awaiting_input', 'error') + AND COALESCE(archived, 0) = 0 ORDER BY updatedAt DESC`, ) .all() as unknown as AiSessionSummary[]; } + /** + * List sessions regardless of status (including `complete`). + * Used by the planning sidebar so previously completed sessions remain + * selectable on refresh — `listActive` filters them out, which would + * otherwise hide a session that finished while the modal was closed. + * By default archived sessions are excluded; pass `includeArchived` to + * surface them too. Completed sessions are pruned by `cleanupOld` after + * the configured TTL, so this list does not grow unbounded. + */ + listAll(projectId?: string, options?: { includeArchived?: boolean }): AiSessionSummary[] { + const archivedClause = options?.includeArchived ? "" : " WHERE COALESCE(archived, 0) = 0"; + if (projectId) { + const where = options?.includeArchived + ? "WHERE projectId = ?" + : "WHERE projectId = ? AND COALESCE(archived, 0) = 0"; + return this.db + .prepare( + `SELECT id, type, status, title, projectId, lockedByTab, updatedAt, archived FROM ai_sessions + ${where} + ORDER BY updatedAt DESC`, + ) + .all(projectId) as unknown as AiSessionSummary[]; + } + return this.db + .prepare( + `SELECT id, type, status, title, projectId, lockedByTab, updatedAt, archived FROM ai_sessions + ${archivedClause} + ORDER BY updatedAt DESC`, + ) + .all() as unknown as AiSessionSummary[]; + } + + /** + * Mark a session as archived (hidden from planning sidebar). Only + * terminal sessions (`complete` or `error`) are archivable — archiving + * an in-flight session would orphan the live agent. Returns true when + * the row was updated. Emits `ai_session:updated` so other tabs sync. + */ + archive(id: string): boolean { + const now = new Date().toISOString(); + const result = this.db + .prepare( + `UPDATE ai_sessions + SET archived = 1, updatedAt = ? + WHERE id = ? AND status IN ('complete', 'error')`, + ) + .run(now, id) as { changes?: number }; + + const changed = Number(result.changes ?? 0) > 0; + if (changed) { + const row = this.get(id); + if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt)); + } + return changed; + } + + /** Restore an archived session so it reappears in the sidebar. */ + unarchive(id: string): boolean { + const now = new Date().toISOString(); + const result = this.db + .prepare( + `UPDATE ai_sessions + SET archived = 0, updatedAt = ? + WHERE id = ?`, + ) + .run(now, id) as { changes?: number }; + + const changed = Number(result.changes ?? 0) > 0; + if (changed) { + const row = this.get(id); + if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt)); + } + return changed; + } + /** * List recoverable sessions for in-memory rehydration. * Returns full rows for sessions still in progress. @@ -565,5 +646,6 @@ function toSummary(session: AiSessionRow, updatedAt: string): AiSessionSummary { projectId: session.projectId, lockedByTab: session.lockedByTab ?? null, updatedAt, + archived: Number(session.archived ?? 0) === 1, }; } diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 3513d24703..ef65fe5122 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -3371,8 +3371,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout /** * GET /api/ai-sessions - * List active background AI sessions (generating or awaiting_input). - * Query: { projectId?: string } + * List background AI sessions. By default returns only active/retryable + * statuses (generating, awaiting_input, error). Pass `includeCompleted=1` + * to also include `complete` sessions — used by the planning sidebar so a + * session that finished while the modal was closed remains selectable. + * Pass `includeArchived=1` (only meaningful with `includeCompleted`) to + * also surface sessions the user has explicitly archived. + * Query: { projectId?, includeCompleted?, includeArchived? } */ router.get("/ai-sessions", (req, res) => { if (!aiSessionStore) { @@ -3380,7 +3385,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout return; } const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined; - const sessions = aiSessionStore.listActive(projectId); + const includeCompleted = + req.query.includeCompleted === "1" || req.query.includeCompleted === "true"; + const includeArchived = + req.query.includeArchived === "1" || req.query.includeArchived === "true"; + const sessions = includeCompleted + ? aiSessionStore.listAll(projectId, { includeArchived }) + : aiSessionStore.listActive(projectId); res.json({ sessions }); }); @@ -3427,6 +3438,42 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout res.json(session); }); + /** + * POST /api/ai-sessions/:id/archive + * Hide a completed/errored session from the planning sidebar without + * deleting it. Only terminal sessions are archivable; archiving an + * in-flight session is rejected so we don't orphan a live agent. + */ + router.post("/ai-sessions/:id/archive", (req, res) => { + if (!aiSessionStore) { + throw notFound("AI sessions not available"); + } + const session = aiSessionStore.get(req.params.id); + if (!session) { + throw notFound("Session not found"); + } + if (session.status !== "complete" && session.status !== "error") { + throw badRequest("Only completed or errored sessions can be archived"); + } + const ok = aiSessionStore.archive(req.params.id); + res.json({ archived: ok }); + }); + + /** + * POST /api/ai-sessions/:id/unarchive + * Restore a previously archived session. + */ + router.post("/ai-sessions/:id/unarchive", (req, res) => { + if (!aiSessionStore) { + throw notFound("AI sessions not available"); + } + if (!aiSessionStore.get(req.params.id)) { + throw notFound("Session not found"); + } + const ok = aiSessionStore.unarchive(req.params.id); + res.json({ archived: !ok }); + }); + router.post("/ai-sessions/:id/lock", (req, res) => { if (!aiSessionStore) { throw notFound("AI sessions not available");