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:
5
.changeset/planner-show-and-archive-sessions.md
Normal file
5
.changeset/planner-show-and-archive-sessions.md
Normal 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.
|
||||
@@ -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)",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user