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;
|
projectId: string | null;
|
||||||
lockedByTab: string | null;
|
lockedByTab: string | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
archived?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConversationHistoryEntry {
|
export interface ConversationHistoryEntry {
|
||||||
@@ -7070,9 +7071,16 @@ export function parseConversationHistory(raw: string): ConversationHistoryEntry[
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAiSessions(projectId?: string): Promise<AiSessionSummary[]> {
|
export async function fetchAiSessions(
|
||||||
const params = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
projectId?: string,
|
||||||
const res = await fetch(buildApiUrl(`/ai-sessions${params}`), {
|
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(),
|
headers: withTokenHeader(),
|
||||||
});
|
});
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
@@ -7080,6 +7088,18 @@ export async function fetchAiSessions(projectId?: string): Promise<AiSessionSumm
|
|||||||
return data.sessions ?? [];
|
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> {
|
export async function fetchAiSession(id: string): Promise<AiSessionDetail | null> {
|
||||||
const res = await fetch(buildApiUrl(`/ai-sessions/${encodeURIComponent(id)}`), {
|
const res = await fetch(buildApiUrl(`/ai-sessions/${encodeURIComponent(id)}`), {
|
||||||
headers: withTokenHeader(),
|
headers: withTokenHeader(),
|
||||||
|
|||||||
@@ -96,6 +96,36 @@
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
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 {
|
.planning-sidebar-new {
|
||||||
@@ -216,7 +246,13 @@
|
|||||||
.planning-sidebar-status-complete { color: var(--success, #3fb950); }
|
.planning-sidebar-status-complete { color: var(--success, #3fb950); }
|
||||||
.planning-sidebar-status-error { color: var(--danger, #f85149); }
|
.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;
|
flex-shrink: 0;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
display: none;
|
display: none;
|
||||||
@@ -230,7 +266,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.planning-sidebar-item:hover .planning-sidebar-item-delete,
|
.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;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +277,15 @@
|
|||||||
background: color-mix(in srgb, var(--danger, #f85149) 15%, transparent);
|
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 {
|
.planning-sidebar-confirm {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -304,8 +351,9 @@
|
|||||||
.planning-mobile-back {
|
.planning-mobile-back {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
/* Always keep delete button visible on mobile (no hover) */
|
/* Always keep action buttons visible on mobile (no hover) */
|
||||||
.planning-sidebar-item-delete {
|
.planning-sidebar-item-delete,
|
||||||
|
.planning-sidebar-item-archive {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
fetchAiSession,
|
fetchAiSession,
|
||||||
fetchAiSessions,
|
fetchAiSessions,
|
||||||
deleteAiSession,
|
deleteAiSession,
|
||||||
|
archiveAiSession,
|
||||||
|
unarchiveAiSession,
|
||||||
parseConversationHistory,
|
parseConversationHistory,
|
||||||
startPlanningBreakdown,
|
startPlanningBreakdown,
|
||||||
createTasksFromPlanning,
|
createTasksFromPlanning,
|
||||||
@@ -31,7 +33,7 @@ import {
|
|||||||
getPlanningDescription,
|
getPlanningDescription,
|
||||||
clearPlanningDescription,
|
clearPlanningDescription,
|
||||||
} from "../hooks/modalPersistence";
|
} 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 { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
import { ConversationHistory } from "./ConversationHistory";
|
import { ConversationHistory } from "./ConversationHistory";
|
||||||
import { useSessionLock } from "../hooks/useSessionLock";
|
import { useSessionLock } from "../hooks/useSessionLock";
|
||||||
@@ -145,6 +147,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
// `mobileShowDetail` toggles between list (false) and detail (true).
|
// `mobileShowDetail` toggles between list (false) and detail (true).
|
||||||
const [mobileShowDetail, setMobileShowDetail] = useState<boolean>(Boolean(resumeSessionId));
|
const [mobileShowDetail, setMobileShowDetail] = useState<boolean>(Boolean(resumeSessionId));
|
||||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
// Track whether the mousedown that initiated a click came from inside the
|
// Track whether the mousedown that initiated a click came from inside the
|
||||||
// modal. Resizing via the bottom-right grip can release the mouse outside
|
// modal. Resizing via the bottom-right grip can release the mouse outside
|
||||||
// the modal element; without this guard, that release fires a click whose
|
// 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 () => {
|
const refreshSessionsList = useCallback(async () => {
|
||||||
setSessionsLoading(true);
|
setSessionsLoading(true);
|
||||||
try {
|
try {
|
||||||
const all = await fetchAiSessions(projectId);
|
const all = await fetchAiSessions(projectId, {
|
||||||
|
includeCompleted: true,
|
||||||
|
includeArchived: showArchived,
|
||||||
|
});
|
||||||
const planning = all
|
const planning = all
|
||||||
.filter((s) => s.type === "planning")
|
.filter((s) => s.type === "planning")
|
||||||
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
||||||
@@ -573,7 +579,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
} finally {
|
} finally {
|
||||||
setSessionsLoading(false);
|
setSessionsLoading(false);
|
||||||
}
|
}
|
||||||
}, [projectId]);
|
}, [projectId, showArchived]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
@@ -696,6 +702,43 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
[broadcastCompleted, planningSessions, projectId, resetDetailState, selectedSessionId, sessionTabId],
|
[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
|
// Reset hasAutoStarted when modal closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
@@ -1119,6 +1162,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
loading={sessionsLoading}
|
loading={sessionsLoading}
|
||||||
selectedSessionId={selectedSessionId}
|
selectedSessionId={selectedSessionId}
|
||||||
pendingDeleteId={pendingDeleteId}
|
pendingDeleteId={pendingDeleteId}
|
||||||
|
showArchived={showArchived}
|
||||||
|
onToggleShowArchived={() => setShowArchived((v) => !v)}
|
||||||
|
onArchive={(id) => void handleArchiveSession(id)}
|
||||||
onSelectSession={handleSelectSession}
|
onSelectSession={handleSelectSession}
|
||||||
onNewSession={handleNewSession}
|
onNewSession={handleNewSession}
|
||||||
onRequestDelete={setPendingDeleteId}
|
onRequestDelete={setPendingDeleteId}
|
||||||
@@ -2115,6 +2161,9 @@ interface PlanningSessionListProps {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
selectedSessionId: string | null;
|
selectedSessionId: string | null;
|
||||||
pendingDeleteId: string | null;
|
pendingDeleteId: string | null;
|
||||||
|
showArchived: boolean;
|
||||||
|
onToggleShowArchived: () => void;
|
||||||
|
onArchive: (id: string) => void;
|
||||||
onSelectSession: (id: string) => void;
|
onSelectSession: (id: string) => void;
|
||||||
onNewSession: () => void;
|
onNewSession: () => void;
|
||||||
onRequestDelete: (id: string) => void;
|
onRequestDelete: (id: string) => void;
|
||||||
@@ -2127,6 +2176,9 @@ function PlanningSessionList({
|
|||||||
loading,
|
loading,
|
||||||
selectedSessionId,
|
selectedSessionId,
|
||||||
pendingDeleteId,
|
pendingDeleteId,
|
||||||
|
showArchived,
|
||||||
|
onToggleShowArchived,
|
||||||
|
onArchive,
|
||||||
onSelectSession,
|
onSelectSession,
|
||||||
onNewSession,
|
onNewSession,
|
||||||
onRequestDelete,
|
onRequestDelete,
|
||||||
@@ -2144,6 +2196,16 @@ function PlanningSessionList({
|
|||||||
<MessageSquarePlus size={16} />
|
<MessageSquarePlus size={16} />
|
||||||
<span>New session</span>
|
<span>New session</span>
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div className="planning-sidebar-list">
|
<div className="planning-sidebar-list">
|
||||||
@@ -2156,10 +2218,12 @@ function PlanningSessionList({
|
|||||||
{sessions.map((session) => {
|
{sessions.map((session) => {
|
||||||
const isSelected = session.id === selectedSessionId;
|
const isSelected = session.id === selectedSessionId;
|
||||||
const isPendingDelete = pendingDeleteId === session.id;
|
const isPendingDelete = pendingDeleteId === session.id;
|
||||||
|
const isArchived = session.archived === true;
|
||||||
|
const isTerminal = session.status === "complete" || session.status === "error";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={session.id}
|
key={session.id}
|
||||||
className={`planning-sidebar-item ${isSelected ? "selected" : ""} ${isPendingDelete ? "pending-delete" : ""}`}
|
className={`planning-sidebar-item ${isSelected ? "selected" : ""} ${isPendingDelete ? "pending-delete" : ""} ${isArchived ? "archived" : ""}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2197,18 +2261,34 @@ function PlanningSessionList({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<div className="planning-sidebar-item-actions">
|
||||||
type="button"
|
{isTerminal && (
|
||||||
className="planning-sidebar-item-delete"
|
<button
|
||||||
onClick={(e) => {
|
type="button"
|
||||||
e.stopPropagation();
|
className="planning-sidebar-item-archive"
|
||||||
onRequestDelete(session.id);
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
aria-label="Delete session"
|
onArchive(session.id);
|
||||||
title="Delete session"
|
}}
|
||||||
>
|
aria-label={isArchived ? "Unarchive session" : "Archive session"}
|
||||||
<Trash2 size={14} />
|
title={isArchived ? "Unarchive session" : "Archive session"}
|
||||||
</button>
|
>
|
||||||
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export interface AiSessionRow {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lockedByTab: string | null;
|
lockedByTab: string | null;
|
||||||
lockedAt: string | null;
|
lockedAt: string | null;
|
||||||
|
/** 1 if archived (hidden from planning sidebar), 0 otherwise. */
|
||||||
|
archived?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Summary returned by listActive (omits large fields) */
|
/** Summary returned by listActive (omits large fields) */
|
||||||
@@ -46,6 +48,7 @@ export interface AiSessionSummary {
|
|||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
lockedByTab: string | null;
|
lockedByTab: string | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
archived?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AiSessionStoreEvents {
|
export interface AiSessionStoreEvents {
|
||||||
@@ -215,21 +218,99 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
|||||||
if (projectId) {
|
if (projectId) {
|
||||||
return this.db
|
return this.db
|
||||||
.prepare(
|
.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 projectId = ?
|
WHERE status IN ('generating', 'awaiting_input', 'error')
|
||||||
|
AND COALESCE(archived, 0) = 0
|
||||||
|
AND projectId = ?
|
||||||
ORDER BY updatedAt DESC`,
|
ORDER BY updatedAt DESC`,
|
||||||
)
|
)
|
||||||
.all(projectId) as unknown as AiSessionSummary[];
|
.all(projectId) as unknown as AiSessionSummary[];
|
||||||
}
|
}
|
||||||
return this.db
|
return this.db
|
||||||
.prepare(
|
.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')
|
WHERE status IN ('generating', 'awaiting_input', 'error')
|
||||||
|
AND COALESCE(archived, 0) = 0
|
||||||
ORDER BY updatedAt DESC`,
|
ORDER BY updatedAt DESC`,
|
||||||
)
|
)
|
||||||
.all() as unknown as AiSessionSummary[];
|
.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.
|
* List recoverable sessions for in-memory rehydration.
|
||||||
* Returns full rows for sessions still in progress.
|
* Returns full rows for sessions still in progress.
|
||||||
@@ -565,5 +646,6 @@ function toSummary(session: AiSessionRow, updatedAt: string): AiSessionSummary {
|
|||||||
projectId: session.projectId,
|
projectId: session.projectId,
|
||||||
lockedByTab: session.lockedByTab ?? null,
|
lockedByTab: session.lockedByTab ?? null,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
|
archived: Number(session.archived ?? 0) === 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3371,8 +3371,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/ai-sessions
|
* GET /api/ai-sessions
|
||||||
* List active background AI sessions (generating or awaiting_input).
|
* List background AI sessions. By default returns only active/retryable
|
||||||
* Query: { projectId?: string }
|
* 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) => {
|
router.get("/ai-sessions", (req, res) => {
|
||||||
if (!aiSessionStore) {
|
if (!aiSessionStore) {
|
||||||
@@ -3380,7 +3385,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;
|
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 });
|
res.json({ sessions });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3427,6 +3438,42 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
res.json(session);
|
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) => {
|
router.post("/ai-sessions/:id/lock", (req, res) => {
|
||||||
if (!aiSessionStore) {
|
if (!aiSessionStore) {
|
||||||
throw notFound("AI sessions not available");
|
throw notFound("AI sessions not available");
|
||||||
|
|||||||
Reference in New Issue
Block a user