feat: add background AI sessions with persistent storage and SSE streaming

Introduces ai_sessions table (schema v9), AiSessionStore, and background
session support for mission interviews, planning, and subtask breakdown.
Adds BackgroundTasksIndicator component, SSE-based progress streaming,
and dashboard API routes for session management.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-04 13:42:02 -07:00
parent 4f2b840eb4
commit eef87a2aea
23 changed files with 1128 additions and 68 deletions

View File

@@ -0,0 +1,162 @@
import { useState, useRef, useEffect } from "react";
import { Lightbulb, Layers, Target, Loader2, HelpCircle, X } from "lucide-react";
import type { AiSessionSummary } from "../api";
interface BackgroundTasksIndicatorProps {
sessions: AiSessionSummary[];
generating: number;
needsInput: number;
onOpenSession: (session: AiSessionSummary) => void;
onDismissSession: (id: string) => void;
}
const TYPE_ICONS = {
planning: Lightbulb,
subtask: Layers,
mission_interview: Target,
} as const;
const TYPE_LABELS = {
planning: "Planning",
subtask: "Subtask Breakdown",
mission_interview: "Mission Interview",
} as const;
export function BackgroundTasksIndicator({
sessions,
generating,
needsInput,
onOpenSession,
onDismissSession,
}: BackgroundTasksIndicatorProps) {
const [popoverOpen, setPopoverOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Close popover on outside click
useEffect(() => {
if (!popoverOpen) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setPopoverOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [popoverOpen]);
if (sessions.length === 0) return null;
const total = sessions.length;
const hasAttention = needsInput > 0;
return (
<div ref={containerRef} className="background-tasks-indicator" style={{ position: "relative" }}>
<button
className="background-tasks-indicator__pill"
onClick={() => setPopoverOpen((prev) => !prev)}
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "2px 10px",
borderRadius: "12px",
border: "1px solid var(--border-color)",
background: hasAttention ? "var(--triage)" : "var(--surface-secondary)",
color: hasAttention ? "#fff" : "var(--text-primary)",
cursor: "pointer",
fontSize: "12px",
fontWeight: 500,
lineHeight: "20px",
whiteSpace: "nowrap",
}}
title={`${total} background AI task${total !== 1 ? "s" : ""}${needsInput > 0 ? ` (${needsInput} need${needsInput !== 1 ? "" : "s"} input)` : ""}`}
>
{generating > 0 && (
<Loader2 size={12} style={{ animation: "spin 1s linear infinite" }} />
)}
{needsInput > 0 && generating === 0 && <HelpCircle size={12} />}
<span>AI {total}</span>
</button>
{popoverOpen && (
<div
className="background-tasks-indicator__popover"
style={{
position: "absolute",
bottom: "calc(100% + 8px)",
left: 0,
minWidth: "280px",
maxWidth: "360px",
background: "var(--surface-primary)",
border: "1px solid var(--border-color)",
borderRadius: "8px",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
zIndex: 1000,
overflow: "hidden",
}}
>
<div style={{ padding: "8px 12px", borderBottom: "1px solid var(--border-color)", fontSize: "12px", fontWeight: 600, color: "var(--text-secondary)" }}>
Background Tasks
</div>
<div style={{ maxHeight: "240px", overflowY: "auto" }}>
{sessions.map((session) => {
const Icon = TYPE_ICONS[session.type];
const isGenerating = session.status === "generating";
const isAwaiting = session.status === "awaiting_input";
return (
<div
key={session.id}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "8px 12px",
borderBottom: "1px solid var(--border-color)",
cursor: "pointer",
}}
onClick={() => {
onOpenSession(session);
setPopoverOpen(false);
}}
>
<Icon size={14} style={{ flexShrink: 0, color: "var(--text-secondary)" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: "13px", fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{session.title}
</div>
<div style={{ fontSize: "11px", color: "var(--text-secondary)" }}>
{TYPE_LABELS[session.type]}
{isGenerating && " — generating..."}
{isAwaiting && " — needs input"}
</div>
</div>
{isGenerating && <Loader2 size={14} style={{ flexShrink: 0, animation: "spin 1s linear infinite", color: "var(--color-success)" }} />}
{isAwaiting && <HelpCircle size={14} style={{ flexShrink: 0, color: "var(--triage)" }} />}
<button
onClick={(e) => {
e.stopPropagation();
onDismissSession(session.id);
}}
style={{
flexShrink: 0,
background: "none",
border: "none",
cursor: "pointer",
padding: "2px",
color: "var(--text-secondary)",
borderRadius: "4px",
}}
title="Dismiss"
>
<X size={12} />
</button>
</div>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -2,7 +2,8 @@ import { useMemo } from "react";
import type { Task } from "@fusion/core";
import { Activity, AlertTriangle, Clock, Pause, Play, Zap } from "lucide-react";
import { useExecutorStats } from "../hooks/useExecutorStats";
import type { ExecutorState } from "../api";
import type { ExecutorState, AiSessionSummary } from "../api";
import { BackgroundTasksIndicator } from "./BackgroundTasksIndicator";
interface ExecutorStatusBarProps {
/** Task list (shared with the board to keep counts in sync) */
@@ -11,6 +12,12 @@ interface ExecutorStatusBarProps {
projectId?: string;
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
taskStuckTimeoutMs?: number;
/** Background AI sessions */
backgroundSessions?: AiSessionSummary[];
backgroundGenerating?: number;
backgroundNeedsInput?: number;
onOpenBackgroundSession?: (session: AiSessionSummary) => void;
onDismissBackgroundSession?: (id: string) => void;
}
/**
@@ -60,7 +67,7 @@ function getStateDisplay(state: ExecutorState): { label: string; color: string;
* - Executor state badge (idle/running/paused)
* - Last activity timestamp
*/
export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs }: ExecutorStatusBarProps) {
export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, backgroundSessions, backgroundGenerating, backgroundNeedsInput, onOpenBackgroundSession, onDismissBackgroundSession }: ExecutorStatusBarProps) {
const { stats, loading, error } = useExecutorStats(tasks, projectId, taskStuckTimeoutMs);
const stateDisplay = useMemo(() => getStateDisplay(stats.executorState), [stats.executorState]);
@@ -94,6 +101,20 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs }: Exec
role="status"
aria-label="Executor status"
>
{/* Background AI tasks indicator */}
{backgroundSessions && backgroundSessions.length > 0 && onOpenBackgroundSession && onDismissBackgroundSession && (
<>
<BackgroundTasksIndicator
sessions={backgroundSessions}
generating={backgroundGenerating ?? 0}
needsInput={backgroundNeedsInput ?? 0}
onOpenSession={onOpenBackgroundSession}
onDismissSession={onDismissBackgroundSession}
/>
<span className="executor-status-bar__divider" aria-hidden="true" />
</>
)}
{/* Running tasks */}
<div className="executor-status-bar__segment">
<span

View File

@@ -6,6 +6,7 @@ import {
cancelMissionInterview,
createMissionFromInterview,
connectMissionInterviewStream,
fetchAiSession,
type MissionPlanSummary,
type MissionPlanMilestone,
type MissionPlanSlice,
@@ -35,6 +36,7 @@ interface MissionInterviewModalProps {
onMissionCreated: (mission: MissionWithHierarchy) => void;
projectId?: string;
initialGoal?: string;
resumeSessionId?: string;
}
interface QuestionResponse {
@@ -60,6 +62,7 @@ export function MissionInterviewModal({
onMissionCreated,
projectId,
initialGoal: initialGoalProp,
resumeSessionId,
}: MissionInterviewModalProps) {
const [missionGoal, setMissionGoal] = useState("");
const [view, setView] = useState<ViewState>({ type: "initial" });
@@ -150,6 +153,79 @@ export function MissionInterviewModal({
}
}, [isOpen]);
// Reconnect to a persisted session when resumeSessionId is provided
useEffect(() => {
if (!isOpen || !resumeSessionId || view.type !== "initial") return;
let cancelled = false;
fetchAiSession(resumeSessionId).then((session) => {
if (cancelled || !session) return;
if (session.status === "awaiting_input" && session.currentQuestion) {
try {
const question = JSON.parse(session.currentQuestion) as import("@fusion/core").PlanningQuestion;
currentSessionIdRef.current = session.id;
setHasProgress(true);
setView({ type: "question", sessionId: session.id, question });
} catch {
setError("Failed to restore session question.");
}
} else if (session.status === "complete" && session.result) {
try {
const summary = JSON.parse(session.result) as MissionPlanSummary;
currentSessionIdRef.current = session.id;
setHasProgress(true);
setEditedSummary(summary);
setView({ type: "summary", sessionId: session.id, summary });
} catch {
setError("Failed to restore session result.");
}
} else if (session.status === "generating") {
currentSessionIdRef.current = session.id;
setHasProgress(true);
if (session.thinkingOutput) {
setStreamingOutput(session.thinkingOutput);
}
setView({ type: "loading" });
const connection = connectMissionInterviewStream(session.id, projectId, {
onThinking: (data) => {
setStreamingOutput((prev) => prev + data);
},
onQuestion: (question) => {
setView({ type: "question", sessionId: session.id, question });
setStreamingOutput("");
},
onSummary: (summary) => {
setView({ type: "summary", sessionId: session.id, summary });
setEditedSummary(summary);
setStreamingOutput("");
},
onError: (message) => {
setError(message);
setView({ type: "initial" });
setStreamingOutput("");
currentSessionIdRef.current = null;
},
onComplete: () => {
currentSessionIdRef.current = null;
},
});
streamConnectionRef.current = connection;
} else if (session.status === "error") {
setError(session.error ?? "The session encountered an error.");
}
}).catch(() => {
if (!cancelled) setError("Failed to resume session.");
});
return () => {
cancelled = true;
};
}, [isOpen, resumeSessionId, view.type, projectId]);
// Cleanup stream on unmount
useEffect(() => {
return () => {

View File

@@ -60,6 +60,7 @@ interface MissionManagerProps {
projectId?: string;
onSelectTask?: (taskId: string) => void;
availableTasks?: Array<{ id: string; title?: string }>;
resumeSessionId?: string;
}
// Status badge colors — use CSS custom-property-compatible tokens
@@ -146,7 +147,7 @@ const EMPTY_FEATURE_FORM: FeatureFormData = {
status: "defined",
};
export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectTask, availableTasks = [] }: MissionManagerProps) {
export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectTask, availableTasks = [], resumeSessionId }: MissionManagerProps) {
const [missions, setMissions] = useState<Mission[]>([]);
const [selectedMission, setSelectedMission] = useState<MissionWithHierarchy | null>(null);
const [loading, setLoading] = useState(true);
@@ -183,6 +184,13 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
// AI Interview modal
const [showInterviewModal, setShowInterviewModal] = useState(false);
// Auto-open interview modal when resuming a session
useEffect(() => {
if (isOpen && resumeSessionId) {
setShowInterviewModal(true);
}
}, [isOpen, resumeSessionId]);
// Delete confirmation
const [deleteConfirmId, setDeleteConfirmId] = useState<{ type: string; id: string } | null>(null);
@@ -1418,6 +1426,7 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
addToast("Mission created from AI interview", "success");
}}
projectId={projectId}
resumeSessionId={resumeSessionId}
/>
</div>
);

View File

@@ -7,6 +7,7 @@ import {
cancelPlanning,
createTaskFromPlanning,
connectPlanningStream,
fetchAiSession,
type PlanningSession,
} from "../api";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles } from "lucide-react";
@@ -18,6 +19,8 @@ interface PlanningModeModalProps {
tasks: Task[];
initialPlan?: string;
projectId?: string;
/** When set, reconnect to a persisted background session instead of starting fresh */
resumeSessionId?: string;
}
interface QuestionResponse {
@@ -37,7 +40,7 @@ const EXAMPLE_PLANS = [
"Refactor the task card component for better performance",
];
export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initialPlan: initialPlanProp, projectId }: PlanningModeModalProps) {
export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initialPlan: initialPlanProp, projectId, resumeSessionId }: PlanningModeModalProps) {
const [initialPlan, setInitialPlan] = useState("");
const [view, setView] = useState<ViewState>({ type: "initial" });
const [error, setError] = useState<string | null>(null);
@@ -132,6 +135,59 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
}
}, [isOpen, initialPlanProp, view.type, handleStartPlanning]);
// Resume a persisted background session
useEffect(() => {
if (!isOpen || !resumeSessionId || view.type !== "initial") return;
let cancelled = false;
(async () => {
try {
const session = await fetchAiSession(resumeSessionId);
if (cancelled || !session) return;
currentSessionIdRef.current = resumeSessionId;
if (session.status === "awaiting_input" && session.currentQuestion) {
const question = JSON.parse(session.currentQuestion);
setView({ type: "question", session: { sessionId: resumeSessionId, currentQuestion: question, summary: null } });
if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput);
setHasProgress(true);
} else if (session.status === "complete" && session.result) {
const summary = JSON.parse(session.result);
setView({ type: "summary", session: { sessionId: resumeSessionId, currentQuestion: null, summary }, summary });
setEditedSummary(summary);
setHasProgress(true);
} else if (session.status === "generating") {
setView({ type: "loading" });
if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput);
// Connect to live SSE stream to pick up new events
const connection = connectPlanningStream(resumeSessionId, projectId, {
onThinking: (data) => setStreamingOutput((prev) => prev + data),
onQuestion: (question) => {
setView({ type: "question", session: { sessionId: resumeSessionId, currentQuestion: question, summary: null } });
setStreamingOutput("");
setHasProgress(true);
},
onSummary: (summary) => {
setView({ type: "summary", session: { sessionId: resumeSessionId, currentQuestion: null, summary }, summary });
setEditedSummary(summary);
setStreamingOutput("");
setHasProgress(true);
},
onError: (message) => { setError(message); setView({ type: "initial" }); },
onComplete: () => { currentSessionIdRef.current = null; },
});
streamConnectionRef.current = connection;
} else if (session.status === "error") {
setError(session.error || "Session failed");
setView({ type: "initial" });
}
} catch {
setError("Failed to resume session");
}
})();
return () => { cancelled = true; };
}, [isOpen, resumeSessionId, view.type, projectId]);
// Reset hasAutoStarted when modal closes
useEffect(() => {
if (!isOpen) {

View File

@@ -5,6 +5,7 @@ import {
connectSubtaskStream,
createTasksFromBreakdown,
cancelSubtaskBreakdown,
fetchAiSession,
type SubtaskItem,
} from "../api";
import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown } from "lucide-react";
@@ -16,6 +17,7 @@ interface SubtaskBreakdownModalProps {
onTasksCreated: (tasks: Task[]) => void;
parentTaskId?: string;
projectId?: string;
resumeSessionId?: string;
}
type ViewState =
@@ -54,7 +56,7 @@ function hasDependencyCycle(subtasks: SubtaskItem[]): boolean {
return subtasks.some((item) => visit(item.id));
}
export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onTasksCreated, parentTaskId, projectId }: SubtaskBreakdownModalProps) {
export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onTasksCreated, parentTaskId, projectId, resumeSessionId }: SubtaskBreakdownModalProps) {
const [view, setView] = useState<ViewState>({ type: "initial" });
const [subtasks, setSubtasks] = useState<SubtaskItem[]>([]);
const [thinkingOutput, setThinkingOutput] = useState("");
@@ -147,6 +149,42 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
}
}, [isOpen, initialDescription, beginBreakdown, resetState]);
useEffect(() => {
if (!isOpen || !resumeSessionId || view.type !== "initial") return;
void (async () => {
try {
const session = await fetchAiSession(resumeSessionId);
if (!session) return;
if (session.status === "generating" || session.status === "awaiting_input") {
setThinkingOutput(session.thinkingOutput ?? "");
setView({ type: "generating", sessionId: resumeSessionId });
streamRef.current?.close();
streamRef.current = connectSubtaskStream(resumeSessionId, projectId, {
onThinking: (data) => setThinkingOutput((prev) => prev + data),
onSubtasks: (items) => {
setSubtasks(items);
setView({ type: "editing", sessionId: resumeSessionId });
setDirty(false);
},
onError: (message) => {
setError(message);
setView({ type: "initial" });
},
});
} else if (session.status === "complete" && session.result) {
const items = JSON.parse(session.result) as SubtaskItem[];
setSubtasks(items);
setView({ type: "editing", sessionId: resumeSessionId });
} else if (session.status === "error") {
setError(session.error ?? "Session encountered an error");
}
} catch (err: any) {
setError(err.message || "Failed to resume session");
}
})();
}, [isOpen, resumeSessionId, view.type, projectId]);
useEffect(() => {
return () => {
streamRef.current?.close();