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:
@@ -28,6 +28,7 @@ import { AgentListModal } from "./components/AgentListModal";
|
||||
import { AgentsView } from "./components/AgentsView";
|
||||
import { ScriptsModal } from "./components/ScriptsModal";
|
||||
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
|
||||
import { useBackgroundSessions } from "./hooks/useBackgroundSessions";
|
||||
import { useTasks } from "./hooks/useTasks";
|
||||
import { useProjects } from "./hooks/useProjects";
|
||||
import { useCurrentProject } from "./hooks/useCurrentProject";
|
||||
@@ -52,6 +53,9 @@ function AppInner() {
|
||||
// Theme management
|
||||
const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme();
|
||||
|
||||
// Background AI sessions
|
||||
const { sessions: bgSessions, generating: bgGenerating, needsInput: bgNeedsInput, dismissSession: bgDismiss } = useBackgroundSessions(currentProject?.id);
|
||||
|
||||
// View state
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -90,6 +94,9 @@ function AppInner() {
|
||||
const [missionsOpen, setMissionsOpen] = useState(false);
|
||||
const [agentsOpen, setAgentsOpen] = useState(false);
|
||||
const [scriptsOpen, setScriptsOpen] = useState(false);
|
||||
const [planningResumeSessionId, setPlanningResumeSessionId] = useState<string | undefined>(undefined);
|
||||
const [subtaskResumeSessionId, setSubtaskResumeSessionId] = useState<string | undefined>(undefined);
|
||||
const [missionResumeSessionId, setMissionResumeSessionId] = useState<string | undefined>(undefined);
|
||||
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
|
||||
const [setupWizardOpen, setSetupWizardOpen] = useState(false);
|
||||
@@ -366,6 +373,7 @@ function AppInner() {
|
||||
const handlePlanningClose = useCallback(() => {
|
||||
setIsPlanningOpen(false);
|
||||
setPlanningInitialPlan(null);
|
||||
setPlanningResumeSessionId(undefined);
|
||||
}, []);
|
||||
const handlePlanningTaskCreated = useCallback((task: Task) => {
|
||||
addToast(`Created ${task.id} from planning mode`, "success");
|
||||
@@ -388,6 +396,7 @@ function AppInner() {
|
||||
const handleSubtaskClose = useCallback(() => {
|
||||
setIsSubtaskOpen(false);
|
||||
setSubtaskInitialDescription(null);
|
||||
setSubtaskResumeSessionId(undefined);
|
||||
}, []);
|
||||
|
||||
const handleSubtaskTasksCreated = useCallback((createdTasks: Task[]) => {
|
||||
@@ -626,7 +635,27 @@ function AppInner() {
|
||||
{renderMainContent()}
|
||||
</div>
|
||||
{viewMode === "project" && currentProject && (
|
||||
<ExecutorStatusBar tasks={tasks} projectId={currentProject.id} taskStuckTimeoutMs={taskStuckTimeoutMs} />
|
||||
<ExecutorStatusBar
|
||||
tasks={tasks}
|
||||
projectId={currentProject.id}
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
backgroundSessions={bgSessions}
|
||||
backgroundGenerating={bgGenerating}
|
||||
backgroundNeedsInput={bgNeedsInput}
|
||||
onOpenBackgroundSession={(session) => {
|
||||
if (session.type === "planning") {
|
||||
setPlanningResumeSessionId(session.id);
|
||||
setIsPlanningOpen(true);
|
||||
} else if (session.type === "subtask") {
|
||||
setSubtaskResumeSessionId(session.id);
|
||||
setIsSubtaskOpen(true);
|
||||
} else if (session.type === "mission_interview") {
|
||||
setMissionResumeSessionId(session.id);
|
||||
setMissionsOpen(true);
|
||||
}
|
||||
}}
|
||||
onDismissBackgroundSession={bgDismiss}
|
||||
/>
|
||||
)}
|
||||
{detailTask && (
|
||||
<TaskDetailModal
|
||||
@@ -673,6 +702,7 @@ function AppInner() {
|
||||
tasks={tasks}
|
||||
initialPlan={planningInitialPlan ?? undefined}
|
||||
projectId={currentProject?.id}
|
||||
resumeSessionId={planningResumeSessionId}
|
||||
/>
|
||||
<SubtaskBreakdownModal
|
||||
isOpen={isSubtaskOpen}
|
||||
@@ -680,6 +710,7 @@ function AppInner() {
|
||||
initialDescription={subtaskInitialDescription ?? ""}
|
||||
onTasksCreated={handleSubtaskTasksCreated}
|
||||
projectId={currentProject?.id}
|
||||
resumeSessionId={subtaskResumeSessionId}
|
||||
/>
|
||||
<TerminalModal
|
||||
isOpen={terminalOpen}
|
||||
@@ -759,9 +790,10 @@ function AppInner() {
|
||||
/>
|
||||
<MissionManager
|
||||
isOpen={missionsOpen}
|
||||
onClose={() => setMissionsOpen(false)}
|
||||
onClose={() => { setMissionsOpen(false); setMissionResumeSessionId(undefined); }}
|
||||
addToast={addToast}
|
||||
projectId={currentProject?.id}
|
||||
resumeSessionId={missionResumeSessionId}
|
||||
availableTasks={tasks.map((t) => ({ id: t.id, title: t.title }))}
|
||||
onSelectTask={(taskId) => {
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
|
||||
@@ -2593,3 +2593,42 @@ export function connectMissionInterviewStream(
|
||||
isConnected: () => !isClosed && eventSource.readyState === EventSource.OPEN,
|
||||
};
|
||||
}
|
||||
|
||||
// ── AI Sessions (Background Tasks) ─────────────────────────────────────────
|
||||
|
||||
export interface AiSessionSummary {
|
||||
id: string;
|
||||
type: "planning" | "subtask" | "mission_interview";
|
||||
status: "generating" | "awaiting_input" | "complete" | "error";
|
||||
title: string;
|
||||
projectId: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AiSessionDetail extends AiSessionSummary {
|
||||
inputPayload: string;
|
||||
conversationHistory: string;
|
||||
currentQuestion: string | null;
|
||||
result: string | null;
|
||||
thinkingOutput: string;
|
||||
error: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export async function fetchAiSessions(projectId?: string): Promise<AiSessionSummary[]> {
|
||||
const params = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const res = await fetch(buildApiUrl(`/ai-sessions${params}`));
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.sessions ?? [];
|
||||
}
|
||||
|
||||
export async function fetchAiSession(id: string): Promise<AiSessionDetail | null> {
|
||||
const res = await fetch(buildApiUrl(`/ai-sessions/${encodeURIComponent(id)}`));
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteAiSession(id: string): Promise<void> {
|
||||
await fetch(buildApiUrl(`/ai-sessions/${encodeURIComponent(id)}`), { method: "DELETE" });
|
||||
}
|
||||
|
||||
162
packages/dashboard/app/components/BackgroundTasksIndicator.tsx
Normal file
162
packages/dashboard/app/components/BackgroundTasksIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
84
packages/dashboard/app/hooks/useBackgroundSessions.ts
Normal file
84
packages/dashboard/app/hooks/useBackgroundSessions.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { fetchAiSessions, deleteAiSession, type AiSessionSummary } from "../api";
|
||||
|
||||
interface UseBackgroundSessionsResult {
|
||||
sessions: AiSessionSummary[];
|
||||
generating: number;
|
||||
needsInput: number;
|
||||
dismissSession: (id: string) => void;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function useBackgroundSessions(projectId?: string): UseBackgroundSessionsResult {
|
||||
const [sessions, setSessions] = useState<AiSessionSummary[]>([]);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
fetchAiSessions(projectId).then(setSessions).catch(() => {});
|
||||
}, [projectId]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Listen for SSE events
|
||||
useEffect(() => {
|
||||
const params = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const es = new EventSource(`/api/events${params}`);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
const handleUpdated = (e: MessageEvent) => {
|
||||
try {
|
||||
const updated = JSON.parse(e.data) as AiSessionSummary;
|
||||
setSessions((prev) => {
|
||||
const idx = prev.findIndex((s) => s.id === updated.id);
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
next[idx] = updated;
|
||||
return next;
|
||||
}
|
||||
// New session — only add if active
|
||||
if (updated.status === "generating" || updated.status === "awaiting_input") {
|
||||
return [updated, ...prev];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const handleDeleted = (e: MessageEvent) => {
|
||||
try {
|
||||
const id = JSON.parse(e.data);
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
es.addEventListener("ai_session:updated", handleUpdated);
|
||||
es.addEventListener("ai_session:deleted", handleDeleted);
|
||||
|
||||
return () => {
|
||||
es.removeEventListener("ai_session:updated", handleUpdated);
|
||||
es.removeEventListener("ai_session:deleted", handleDeleted);
|
||||
es.close();
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
const dismissSession = useCallback((id: string) => {
|
||||
deleteAiSession(id).catch(() => {});
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||
}, []);
|
||||
|
||||
// Filter to only active sessions
|
||||
const active = sessions.filter(
|
||||
(s) => s.status === "generating" || s.status === "awaiting_input"
|
||||
);
|
||||
|
||||
return {
|
||||
sessions: active,
|
||||
generating: active.filter((s) => s.status === "generating").length,
|
||||
needsInput: active.filter((s) => s.status === "awaiting_input").length,
|
||||
dismissSession,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user