feat(dashboard,core): show completed planning sessions and add archive

Planning sidebar's `/api/ai-sessions` listing filtered out `complete`
rows, so a session that finished while the modal was closed disappeared
on refresh even though the result was still in SQLite. Add
`?includeCompleted` / `?includeArchived` flags and a new `archived`
column (migration 57) so users can hide terminal sessions on demand
without deleting them; only `complete`/`error` rows are archivable so
live agents can't be orphaned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-01 10:36:39 -07:00
parent 3b43630fa1
commit 56210e0712
7 changed files with 325 additions and 29 deletions

View File

@@ -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.

View File

@@ -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)",
);
}
});
}
}
/**

View File

@@ -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<AiSessionSummary[]> {
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<AiSessionSummary[]> {
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<AiSessionSumm
return data.sessions ?? [];
}
export async function archiveAiSession(id: string): Promise<void> {
return api<void>(`/ai-sessions/${encodeURIComponent(id)}/archive`, {
method: "POST",
});
}
export async function unarchiveAiSession(id: string): Promise<void> {
return api<void>(`/ai-sessions/${encodeURIComponent(id)}/unarchive`, {
method: "POST",
});
}
export async function fetchAiSession(id: string): Promise<AiSessionDetail | null> {
const res = await fetch(buildApiUrl(`/ai-sessions/${encodeURIComponent(id)}`), {
headers: withTokenHeader(),

View File

@@ -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;
}
}

View File

@@ -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>(Boolean(resumeSessionId));
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(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({
<MessageSquarePlus size={16} />
<span>New session</span>
</button>
<button
type="button"
className={`planning-sidebar-toggle-archived ${showArchived ? "active" : ""}`}
onClick={onToggleShowArchived}
aria-pressed={showArchived}
title={showArchived ? "Hide archived sessions" : "Show archived sessions"}
>
<Archive size={14} />
<span>{showArchived ? "Hide archived" : "Show archived"}</span>
</button>
</div>
<div className="planning-sidebar-list">
@@ -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 (
<div
key={session.id}
className={`planning-sidebar-item ${isSelected ? "selected" : ""} ${isPendingDelete ? "pending-delete" : ""}`}
className={`planning-sidebar-item ${isSelected ? "selected" : ""} ${isPendingDelete ? "pending-delete" : ""} ${isArchived ? "archived" : ""}`}
>
<button
type="button"
@@ -2197,18 +2261,34 @@ function PlanningSessionList({
</button>
</div>
) : (
<button
type="button"
className="planning-sidebar-item-delete"
onClick={(e) => {
e.stopPropagation();
onRequestDelete(session.id);
}}
aria-label="Delete session"
title="Delete session"
>
<Trash2 size={14} />
</button>
<div className="planning-sidebar-item-actions">
{isTerminal && (
<button
type="button"
className="planning-sidebar-item-archive"
onClick={(e) => {
e.stopPropagation();
onArchive(session.id);
}}
aria-label={isArchived ? "Unarchive session" : "Archive session"}
title={isArchived ? "Unarchive session" : "Archive session"}
>
{isArchived ? <ArchiveRestore size={14} /> : <Archive size={14} />}
</button>
)}
<button
type="button"
className="planning-sidebar-item-delete"
onClick={(e) => {
e.stopPropagation();
onRequestDelete(session.id);
}}
aria-label="Delete session"
title="Delete session"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
);

View File

@@ -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<AiSessionStoreEvents> {
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,
};
}

View File

@@ -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");