Files
fusion/packages/dashboard/app/components/PlanningModeModal.tsx
Fusion ff58623c32 fix(FN-2861): harden planning session recovery and retry UX
- Add planning and subtask retry routes with session-lock checks and proper API error mapping
- Improve planning session execution with timeout/abort handling, stop-generation support, and resilient stream catch-up behavior
- Update Planning Mode modal to handle reconnects, retry-from-error flow, stop action, and cross-tab session state synchronization
- Expand dashboard tests for planning routes, planning session behavior, and PlanningModeModal retry/error coverage
2026-04-28 14:25:55 -07:00

2266 lines
82 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "./PlanningModeModal.css";
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import type { Task, PlanningQuestion, PlanningSummary } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import {
startPlanningStreaming,
respondToPlanning,
retryPlanningSession,
createTaskFromPlanning,
connectPlanningStream,
fetchAiSession,
fetchAiSessions,
deleteAiSession,
parseConversationHistory,
startPlanningBreakdown,
createTasksFromPlanning,
fetchModels,
cancelPlanning,
stopPlanningGeneration,
updateGlobalSettings,
type PlanningSession,
type SubtaskItem,
type ModelInfo,
type ConversationHistoryEntry,
type AiSessionSummary,
} from "../api";
import { subscribeSse } from "../sse-bus";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import {
savePlanningDescription,
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 { CustomModelDropdown } from "./CustomModelDropdown";
import { ConversationHistory } from "./ConversationHistory";
import { useSessionLock } from "../hooks/useSessionLock";
import { useAiSessionSync } from "../hooks/useAiSessionSync";
import { getSessionTabId } from "../utils/getSessionTabId";
interface PlanningModeModalProps {
isOpen: boolean;
onClose: () => void;
onTaskCreated: (task: Task) => void;
onTasksCreated: (tasks: Task[]) => void;
tasks: Task[];
initialPlan?: string;
projectId?: string;
/** When set, reconnect to a persisted background session instead of starting fresh */
resumeSessionId?: string;
}
interface QuestionResponse {
[key: string]: unknown;
}
type ViewState =
| { type: "initial" }
| { type: "question"; session: PlanningSession }
| { type: "summary"; session: PlanningSession; summary: PlanningSummary }
| { type: "error"; session: PlanningSession; errorMessage: string }
| { type: "breakdown"; sessionId: string; subtasks: SubtaskItem[]; dirty: boolean }
| { type: "loading" }
| { type: "creating" };
const EXAMPLE_PLANS = [
"Build a user authentication system with login and signup",
"Add dark mode support to the dashboard",
"Create an API endpoint for exporting tasks as CSV",
"Refactor the task card component for better performance",
];
function getModelSelectionValue(provider?: string, modelId?: string): string {
return provider && modelId ? `${provider}/${modelId}` : "";
}
function parseModelSelection(value: string): { provider?: string; modelId?: string } {
if (!value) {
return { provider: undefined, modelId: undefined };
}
const slashIndex = value.indexOf("/");
if (slashIndex === -1) {
return { provider: undefined, modelId: undefined };
}
return {
provider: value.slice(0, slashIndex),
modelId: value.slice(slashIndex + 1),
};
}
export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreated, tasks, initialPlan: initialPlanProp, projectId, resumeSessionId }: PlanningModeModalProps) {
const [initialPlan, setInitialPlan] = useState("");
const [view, setView] = useState<ViewState>({ type: "initial" });
const [error, setError] = useState<string | null>(null);
const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]);
const [conversationHistory, setConversationHistory] = useState<ConversationHistoryEntry[]>([]);
const [editedSummary, setEditedSummary] = useState<PlanningSummary | null>(null);
// Use ref instead of state for hasAutoStarted to handle React StrictMode double-render.
// In StrictMode, components render twice but state persists across renders,
// which would skip auto-start on the second (committed) render. Refs are
// re-initialized on each render, ensuring the auto-start effect runs correctly.
const hasAutoStartedRef = useRef(false);
const hasLoadedPersistedRef = useRef(false);
const [streamingOutput, setStreamingOutput] = useState<string>("");
const [showThinking, setShowThinking] = useState(true);
const [isReconnecting, setIsReconnecting] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
const [generationStartTime, setGenerationStartTime] = useState<number | null>(null);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
const currentSessionIdRef = useRef<string | null>(null);
const [lockSessionId, setLockSessionId] = useState<string | null>(resumeSessionId ?? null);
const sessionTabId = useMemo(() => getSessionTabId(), []);
const {
isLockedByOther,
takeControl,
isLoading: isLockLoading,
} = useSessionLock(isOpen ? lockSessionId : null);
const {
activeTabMap,
broadcastUpdate,
broadcastCompleted,
broadcastLock,
broadcastUnlock,
broadcastHeartbeat,
} = useAiSessionSync();
const [planningModelProvider, setPlanningModelProvider] = useState<string | undefined>(undefined);
const [planningModelId, setPlanningModelId] = useState<string | undefined>(undefined);
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
const trackedLockSessionRef = useRef<string | null>(null);
// Sidebar list state
const [planningSessions, setPlanningSessions] = useState<AiSessionSummary[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(resumeSessionId ?? null);
// Mobile: when the modal is narrow, only one pane is visible at a time.
// `mobileShowDetail` toggles between list (false) and detail (true).
const [mobileShowDetail, setMobileShowDetail] = useState<boolean>(Boolean(resumeSessionId));
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
// 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
// target is the overlay and would dismiss the modal mid-resize.
const overlayMouseDownOnSelfRef = useRef(false);
const thinkingOutputRef = useRef<HTMLDivElement>(null);
useModalResizePersist(modalRef, isOpen, "fusion:planning-modal-size");
// Keep the streaming AI thinking pane pinned to the bottom as new tokens
// arrive. If the user has scrolled up to read earlier output, we leave the
// scroll position alone — only auto-follow when they're already near the
// tail. The 32px slack accounts for line-height jitter.
useEffect(() => {
const node = thinkingOutputRef.current;
if (!node) return;
const distanceFromBottom = node.scrollHeight - node.scrollTop - node.clientHeight;
if (distanceFromBottom < 32) {
node.scrollTop = node.scrollHeight;
}
}, [streamingOutput]);
useEffect(() => {
if (view.type !== "loading") {
setGenerationStartTime(null);
setElapsedSeconds(0);
return;
}
const startedAt = Date.now();
setGenerationStartTime(startedAt);
setElapsedSeconds(0);
const timer = setInterval(() => {
setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
}, 1000);
return () => clearInterval(timer);
}, [view.type]);
const resetDetailState = useCallback(() => {
setInitialPlan("");
setView({ type: "initial" });
setError(null);
setResponseHistory([]);
setConversationHistory([]);
setEditedSummary(null);
setStreamingOutput("");
setIsReconnecting(false);
setIsRetrying(false);
setPlanningModelProvider(undefined);
setPlanningModelId(undefined);
currentSessionIdRef.current = null;
setLockSessionId(null);
}, []);
const planningSelectionValue = getModelSelectionValue(planningModelProvider, planningModelId);
const getModelBadgeLabel = useCallback(
(provider?: string, modelId?: string) => {
if (!provider || !modelId) return "Using default";
const matched = loadedModels.find((model) => model.provider === provider && model.id === modelId);
return matched ? `${matched.provider}/${matched.id}` : `${provider}/${modelId}`;
},
[loadedModels],
);
const loadModels = useCallback(async () => {
setModelsLoading(true);
setModelsError(null);
try {
const response = await fetchModels();
setLoadedModels(response.models);
setFavoriteProviders(response.favoriteProviders);
setFavoriteModels(response.favoriteModels);
} catch (err) {
setModelsError(getErrorMessage(err) || "Failed to load models");
} finally {
setModelsLoading(false);
}
}, []);
const handleToggleFavoriteProvider = useCallback((provider: string) => {
setFavoriteProviders((prev) => {
const currentFavorites = prev;
const isFavorite = currentFavorites.includes(provider);
const newFavorites = isFavorite
? currentFavorites.filter((item) => item !== provider)
: [provider, ...currentFavorites];
updateGlobalSettings({ favoriteProviders: newFavorites, favoriteModels }).catch(() => {
setFavoriteProviders(currentFavorites);
});
return newFavorites;
});
}, [favoriteModels]);
const handleToggleFavoriteModel = useCallback((modelId: string) => {
setFavoriteModels((prev) => {
const currentFavorites = prev;
const isFavorite = currentFavorites.includes(modelId);
const newFavorites = isFavorite
? currentFavorites.filter((item) => item !== modelId)
: [modelId, ...currentFavorites];
updateGlobalSettings({ favoriteProviders, favoriteModels: newFavorites }).catch(() => {
setFavoriteModels(currentFavorites);
});
return newFavorites;
});
}, [favoriteProviders]);
const connectToPlanningStream = useCallback(
(sessionId: string) => {
streamConnectionRef.current?.close();
const connection = connectPlanningStream(sessionId, projectId, {
onThinking: (data) => {
setStreamingOutput((prev) => prev + data);
broadcastUpdate({
sessionId,
status: "generating",
needsInput: false,
owningTabId: sessionTabId,
type: "planning",
title: initialPlan.trim() || "Planning session",
projectId: projectId ?? null,
});
},
onQuestion: (question) => {
setIsReconnecting(false);
setIsRetrying(false);
clearPlanningDescription(projectId);
setView({
type: "question",
session: { sessionId, currentQuestion: question, summary: null },
});
setStreamingOutput("");
broadcastUpdate({
sessionId,
status: "awaiting_input",
needsInput: true,
owningTabId: sessionTabId,
type: "planning",
title: initialPlan.trim() || "Planning session",
projectId: projectId ?? null,
});
},
onSummary: (summary) => {
setIsReconnecting(false);
setIsRetrying(false);
clearPlanningDescription(projectId);
setView({
type: "summary",
session: { sessionId, currentQuestion: null, summary },
summary,
});
setEditedSummary(summary);
setStreamingOutput("");
broadcastUpdate({
sessionId,
status: "complete",
needsInput: false,
owningTabId: sessionTabId,
type: "planning",
title: initialPlan.trim() || "Planning session",
projectId: projectId ?? null,
});
},
onError: (message) => {
const errorMessage = message || "Session failed while contacting the AI.";
// A single transient stream error (e.g. tab was backgrounded long
// enough for the SSE to time out) should not bounce the user to a
// permanent error view. Refetch the session state — if the server
// still has it in a recoverable state, silently reconnect; only
// surface the error if the server actually persisted one.
setIsReconnecting(true);
(async () => {
try {
const session = await fetchAiSession(sessionId);
if (
session &&
(session.status === "generating" || session.status === "awaiting_input")
) {
connectToPlanningStream(sessionId);
return;
}
} catch {
// fall through to error view below
}
setIsReconnecting(false);
setIsRetrying(false);
setError(null);
setView((prev) => {
if (prev.type === "question" || prev.type === "summary" || prev.type === "error") {
return { type: "error", session: prev.session, errorMessage };
}
return {
type: "error",
session: { sessionId, currentQuestion: null, summary: null },
errorMessage,
};
});
setStreamingOutput("");
currentSessionIdRef.current = sessionId;
broadcastUpdate({
sessionId,
status: "error",
needsInput: false,
owningTabId: sessionTabId,
type: "planning",
title: initialPlan.trim() || "Planning session",
projectId: projectId ?? null,
});
broadcastCompleted({ sessionId, status: "error" });
})();
},
onComplete: () => {
setIsReconnecting(false);
setIsRetrying(false);
currentSessionIdRef.current = null;
broadcastCompleted({ sessionId, status: "complete" });
},
onConnectionStateChange: (state) => {
setIsReconnecting(state === "reconnecting");
},
});
streamConnectionRef.current = connection;
},
[broadcastCompleted, broadcastUpdate, initialPlan, projectId, sessionTabId],
);
const handleStartPlanning = useCallback(async (planOverride?: string) => {
const plan = planOverride ?? initialPlan;
if (!plan.trim()) return;
setError(null);
setStreamingOutput("");
setConversationHistory([]);
setResponseHistory([]);
setIsReconnecting(false);
setView({ type: "loading" });
try {
// Use streaming mode for real-time AI thinking display
const modelOverride =
planningModelProvider && planningModelId
? { planningModelProvider, planningModelId }
: undefined;
const { sessionId } = await startPlanningStreaming(plan.trim(), projectId, modelOverride);
currentSessionIdRef.current = sessionId;
setLockSessionId(sessionId);
setSelectedSessionId(sessionId);
connectToPlanningStream(sessionId);
setResponseHistory([]);
} catch (err) {
setIsReconnecting(false);
setError(getErrorMessage(err) || "Failed to start planning session");
setView({ type: "initial" });
currentSessionIdRef.current = null;
setLockSessionId(null);
}
}, [connectToPlanningStream, initialPlan, planningModelId, planningModelProvider, projectId]);
// Focus textarea when opening
useEffect(() => {
if (isOpen && view.type === "initial") {
textareaRef.current?.focus();
}
}, [isOpen, view.type]);
useEffect(() => {
if (!isOpen) {
return;
}
void loadModels();
}, [isOpen, loadModels]);
// Auto-start planning when initialPlan prop is provided
useEffect(() => {
if (isOpen && initialPlanProp && !hasAutoStartedRef.current && view.type === "initial") {
setInitialPlan(initialPlanProp);
// Use a small timeout to allow state update to propagate before starting
const timer = setTimeout(() => {
// Only mark as auto-started when we actually start planning
hasAutoStartedRef.current = true;
handleStartPlanning(initialPlanProp);
}, 0);
return () => clearTimeout(timer);
} else if (
isOpen &&
!initialPlanProp &&
!hasAutoStartedRef.current &&
!hasLoadedPersistedRef.current &&
view.type === "initial"
) {
// Restore the persisted description from localStorage on first open only.
// Without the ref this effect re-fires on every keystroke (handleStart-
// Planning depends on initialPlan), and each fire would clobber what
// the user just typed back to the persisted value.
hasLoadedPersistedRef.current = true;
const persisted = getPlanningDescription(projectId);
if (persisted) {
setInitialPlan(persisted);
}
}
}, [isOpen, initialPlanProp, view.type, handleStartPlanning, projectId]);
// Load a specific persisted session into the right pane.
const loadSession = useCallback(
async (sessionId: string) => {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
setError(null);
setStreamingOutput("");
setResponseHistory([]);
setConversationHistory([]);
setEditedSummary(null);
setIsRetrying(false);
setView({ type: "loading" });
try {
const session = await fetchAiSession(sessionId);
if (!session) {
// The session was deleted (commonly: this tab just turned it into
// tasks via Create Task / Create Tasks). Quietly fall back to the
// new-session view rather than surfacing a scary error banner.
setSelectedSessionId(null);
setMobileShowDetail(false);
setView({ type: "initial" });
return;
}
currentSessionIdRef.current = sessionId;
setLockSessionId(sessionId);
const parsedHistory = parseConversationHistory(session.conversationHistory);
setConversationHistory(parsedHistory);
setResponseHistory(
parsedHistory
.map((entry) => entry.response)
.filter((response): response is QuestionResponse =>
Boolean(response && typeof response === "object" && !Array.isArray(response)),
),
);
if (session.status === "awaiting_input" && session.currentQuestion) {
clearPlanningDescription(projectId);
const question = JSON.parse(session.currentQuestion);
setView({ type: "question", session: { sessionId, currentQuestion: question, summary: null } });
if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput);
connectToPlanningStream(sessionId);
} else if (session.status === "complete" && session.result) {
clearPlanningDescription(projectId);
const summary = JSON.parse(session.result);
setView({ type: "summary", session: { sessionId, currentQuestion: null, summary }, summary });
setEditedSummary(summary);
} else if (session.status === "generating") {
setView({ type: "loading" });
if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput);
connectToPlanningStream(sessionId);
} else if (session.status === "error") {
setView({
type: "error",
session: { sessionId, currentQuestion: null, summary: null },
errorMessage: session.error || "Session failed",
});
}
} catch {
setError("Failed to load session");
setView({ type: "initial" });
}
},
[connectToPlanningStream, projectId],
);
// Resume the externally-requested session when the modal first opens.
// (Selecting from the sidebar uses handleSelectSession instead.)
useEffect(() => {
if (!isOpen || !resumeSessionId) return;
if (currentSessionIdRef.current === resumeSessionId) return;
setSelectedSessionId(resumeSessionId);
setMobileShowDetail(true);
void loadSession(resumeSessionId);
}, [isOpen, resumeSessionId, loadSession]);
// Re-sync the selected session whenever the modal is reopened. Without this,
// a session that progressed (or completed) on the server while the modal was
// closed — or whose terminal SSE event we missed because the stream had been
// torn down on close — keeps showing its stale view (e.g. stuck on "loading"
// even though `awaiting_input` is already persisted). Hard reload used to be
// the only fix; this effect makes close+reopen equivalent.
useEffect(() => {
if (!isOpen) return;
if (!selectedSessionId) return;
if (resumeSessionId && resumeSessionId === selectedSessionId) return; // resume effect handles this case
if (streamConnectionRef.current?.isConnected()) return;
void loadSession(selectedSessionId);
// We intentionally do not depend on selectedSessionId or loadSession here:
// handleSelectSession already drives loadSession when the user picks a
// different row, and this effect only needs to fire on the open
// transition. Listing them here would cause it to re-run mid-session.
}, [isOpen]);
// Load + maintain the planning sessions list (sidebar).
const refreshSessionsList = useCallback(async () => {
setSessionsLoading(true);
try {
const all = await fetchAiSessions(projectId);
const planning = all
.filter((s) => s.type === "planning")
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
setPlanningSessions(planning);
} catch {
// Best-effort: list errors should not block the modal
} finally {
setSessionsLoading(false);
}
}, [projectId]);
useEffect(() => {
if (!isOpen) return;
void refreshSessionsList();
}, [isOpen, refreshSessionsList]);
// SSE subscription keeps the list live (mirrors useBackgroundSessions, but
// unfiltered by status so completed/errored sessions stay visible).
useEffect(() => {
if (!isOpen) return;
const params = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const handleUpdated = (e: MessageEvent) => {
try {
const updated = JSON.parse(e.data) as AiSessionSummary;
if (updated.type !== "planning") return;
setPlanningSessions((prev) => {
const idx = prev.findIndex((s) => s.id === updated.id);
const next = idx >= 0 ? [...prev.slice(0, idx), updated, ...prev.slice(idx + 1)] : [updated, ...prev];
return next.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
});
} catch {
// ignore malformed payload
}
};
const handleDeleted = (e: MessageEvent) => {
try {
const id = JSON.parse(e.data) as string;
setPlanningSessions((prev) => prev.filter((s) => s.id !== id));
} catch {
// ignore malformed payload
}
};
return subscribeSse(`/api/events${params}`, {
events: {
"ai_session:updated": handleUpdated,
"ai_session:deleted": handleDeleted,
},
// Re-fetch on reconnect so terminal events that fired while the
// channel was down don't leave stale rows in the sidebar.
onReconnect: () => {
void refreshSessionsList();
},
});
}, [isOpen, projectId, refreshSessionsList]);
// Sidebar handlers
const handleSelectSession = useCallback(
(sessionId: string) => {
if (selectedSessionId === sessionId) {
setMobileShowDetail(true);
return;
}
setSelectedSessionId(sessionId);
setMobileShowDetail(true);
void loadSession(sessionId);
},
[loadSession, selectedSessionId],
);
const handleNewSession = useCallback(() => {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
resetDetailState();
setSelectedSessionId(null);
setMobileShowDetail(true);
}, [resetDetailState]);
const handleBackToList = useCallback(() => {
setMobileShowDetail(false);
}, []);
const handleDeleteSession = useCallback(
async (sessionId: string) => {
const isActiveServerSession = (status: AiSessionSummary["status"]) =>
status === "generating" || status === "awaiting_input";
const target = planningSessions.find((s) => s.id === sessionId);
// Cancel an in-flight server session before deleting so the engine stops
// generating; for terminal sessions skip the cancel call.
if (target && isActiveServerSession(target.status)) {
try {
await cancelPlanning(sessionId, projectId, sessionTabId);
} catch {
// best-effort
}
}
try {
await deleteAiSession(sessionId);
} catch {
// best-effort: SSE will reconcile if the delete actually succeeded
}
// Broadcast completion so sibling consumers (BackgroundTasksIndicator's
// useBackgroundSessions hook, other tabs) prune this session from their
// active lists. The server-side SSE delete event covers the in-flight
// path, but the cross-tab broadcast is what keeps the footer pill in
// lockstep when this modal initiates the delete.
broadcastCompleted({
sessionId,
status: "complete",
timestamp: Date.now(),
});
setPlanningSessions((prev) => prev.filter((s) => s.id !== sessionId));
if (selectedSessionId === sessionId) {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
resetDetailState();
setSelectedSessionId(null);
setMobileShowDetail(false);
}
setPendingDeleteId(null);
},
[broadcastCompleted, planningSessions, projectId, resetDetailState, selectedSessionId, sessionTabId],
);
// Reset hasAutoStarted when modal closes
useEffect(() => {
if (!isOpen) {
hasAutoStartedRef.current = false;
hasLoadedPersistedRef.current = false;
setIsReconnecting(false);
setIsRetrying(false);
setLockSessionId(null);
}
}, [isOpen]);
// Broadcast lock ownership transitions for cross-tab awareness.
useEffect(() => {
if (!isOpen) {
if (trackedLockSessionRef.current) {
broadcastUnlock(trackedLockSessionRef.current, sessionTabId);
trackedLockSessionRef.current = null;
}
return;
}
if (lockSessionId && trackedLockSessionRef.current !== lockSessionId) {
if (trackedLockSessionRef.current) {
broadcastUnlock(trackedLockSessionRef.current, sessionTabId);
}
broadcastLock(lockSessionId, sessionTabId);
trackedLockSessionRef.current = lockSessionId;
return;
}
if (!lockSessionId && trackedLockSessionRef.current) {
broadcastUnlock(trackedLockSessionRef.current, sessionTabId);
trackedLockSessionRef.current = null;
}
}, [broadcastLock, broadcastUnlock, isOpen, lockSessionId, sessionTabId]);
// Emit heartbeat while this tab actively owns the current session lock.
useEffect(() => {
if (!isOpen || !lockSessionId || trackedLockSessionRef.current !== lockSessionId) {
return;
}
broadcastHeartbeat(sessionTabId);
const timer = setInterval(() => {
broadcastHeartbeat(sessionTabId);
}, 30_000);
return () => {
clearInterval(timer);
};
}, [broadcastHeartbeat, isOpen, lockSessionId, sessionTabId]);
// Cleanup stream connection on unmount
useEffect(() => {
return () => {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
if (trackedLockSessionRef.current) {
broadcastUnlock(trackedLockSessionRef.current, sessionTabId);
trackedLockSessionRef.current = null;
}
};
}, [broadcastUnlock, sessionTabId]);
// Handle browser unload while modal is open
useEffect(() => {
if (!isOpen) return;
const handleBeforeUnload = () => {
// Session is preserved server-side; just disconnect the local stream.
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [isOpen]);
// Close the modal without abandoning the active server session. Sessions
// remain in the list and can be resumed later. Only an explicit Delete
// (from the sidebar) cancels and removes a session.
const handleClose = useCallback(() => {
// Save the in-progress draft so the next open restores it.
if (initialPlan && view.type === "initial") {
savePlanningDescription(initialPlan, projectId);
}
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
setIsReconnecting(false);
setIsRetrying(false);
onClose();
}, [initialPlan, onClose, projectId, view.type]);
// Handle escape key to close
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handleClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, handleClose]);
const handleSubmitResponse = useCallback(
async (responses: QuestionResponse) => {
if (view.type !== "question") return;
const { session } = view;
const sessionId = session.sessionId;
const activeQuestion = session.currentQuestion;
if (!activeQuestion) {
setError("No active question in session");
return;
}
setError(null);
// Keep the existing SSE connection alive - do NOT close it!
// The connection established in handleStartPlanning will continue
// to receive events (thinking, question, summary) throughout the session.
// This prevents the race condition where events are missed because
// the frontend disconnects and reconnects after the API call.
setResponseHistory((prev) => [...prev, responses]);
setConversationHistory((prev) => [
...prev,
{
question: activeQuestion,
response: responses,
},
]);
setView({ type: "loading" });
setStreamingOutput(""); // Clear old thinking output when entering loading state
try {
// Submit response - AI will broadcast events via the already-connected stream
await respondToPlanning(sessionId, responses, projectId, sessionTabId);
// Events (question/summary) will arrive via the existing SSE stream
} catch (err) {
setError(getErrorMessage(err) || "Failed to submit response");
setView({ type: "question", session });
}
},
[projectId, sessionTabId, view]
);
const handleStopGeneration = useCallback(async () => {
const sessionId = currentSessionIdRef.current;
if (!sessionId) {
return;
}
try {
await stopPlanningGeneration(sessionId, projectId, sessionTabId);
} catch {
// best-effort; server-side timeout/stop event may have already fired
}
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
setIsReconnecting(false);
setIsRetrying(false);
setView({
type: "error",
session: { sessionId, currentQuestion: null, summary: null },
errorMessage: "Generation stopped by user. You can retry or start a new session.",
});
setStreamingOutput("");
}, [projectId, sessionTabId]);
const handleRetryFromError = useCallback(async () => {
if (view.type !== "error") {
return;
}
const retryTarget = view.session;
setError(null);
setIsRetrying(true);
setStreamingOutput("");
setView({ type: "loading" });
connectToPlanningStream(retryTarget.sessionId);
try {
currentSessionIdRef.current = retryTarget.sessionId;
setLockSessionId(retryTarget.sessionId);
await retryPlanningSession(retryTarget.sessionId, projectId, sessionTabId);
} catch (err) {
let retryError: unknown = err;
const retryErrorMessage = getErrorMessage(err) || "";
if (retryErrorMessage.includes("not in an error state")) {
try {
const session = await fetchAiSession(retryTarget.sessionId);
if (!session) {
throw new Error("Failed to refresh planning session.");
}
currentSessionIdRef.current = session.id;
setLockSessionId(session.id);
if (session.status === "generating") {
setStreamingOutput(session.thinkingOutput ?? "");
setView({ type: "loading" });
} else if (session.status === "awaiting_input") {
if (!session.currentQuestion) {
throw new Error("Planning session is awaiting input but has no current question.");
}
const question = JSON.parse(session.currentQuestion) as PlanningQuestion;
clearPlanningDescription(projectId);
setView({
type: "question",
session: { sessionId: session.id, currentQuestion: question, summary: null },
});
if (!streamConnectionRef.current?.isConnected()) {
connectToPlanningStream(session.id);
}
} else if (session.status === "complete") {
if (!session.result) {
throw new Error("Planning session is complete but has no result.");
}
const summary = JSON.parse(session.result) as PlanningSummary;
clearPlanningDescription(projectId);
setView({
type: "summary",
session: { sessionId: session.id, currentQuestion: null, summary },
summary,
});
setEditedSummary(summary);
} else if (session.status === "error") {
setView({
type: "error",
session: { sessionId: session.id, currentQuestion: null, summary: null },
errorMessage: session.error || "Retry failed. Please try again.",
});
}
setIsReconnecting(false);
return;
} catch (sessionRefreshError) {
retryError = sessionRefreshError;
}
}
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
setView({
type: "error",
session: retryTarget,
errorMessage: getErrorMessage(retryError) || "Retry failed. Please try again.",
});
setIsReconnecting(false);
} finally {
setIsRetrying(false);
}
}, [connectToPlanningStream, projectId, sessionTabId, view]);
const handleCreateTask = useCallback(async () => {
if (view.type !== "summary") return;
setError(null);
setView({ type: "loading" });
try {
const completedSessionId = view.session.sessionId;
const task = await createTaskFromPlanning(completedSessionId, editedSummary ?? undefined, projectId);
onTaskCreated(task);
// The server cleans up the planning session after task creation. Drop
// the local selection so a future reopen doesn't try to fetch a deleted
// id (which would otherwise show "Session not found"). Also broadcast
// completion so the footer's useBackgroundSessions prunes its count.
setSelectedSessionId(null);
setPlanningSessions((prev) => prev.filter((s) => s.id !== completedSessionId));
broadcastCompleted({
sessionId: completedSessionId,
status: "complete",
timestamp: Date.now(),
});
handleClose();
} catch (err) {
setError(getErrorMessage(err) || "Failed to create task");
setView({ type: "summary", session: view.session, summary: view.summary });
}
}, [broadcastCompleted, editedSummary, view, projectId, onTaskCreated, handleClose]);
const handleStartBreakdown = useCallback(async () => {
if (view.type !== "summary") return;
setError(null);
setView({ type: "loading" });
try {
const result = await startPlanningBreakdown(view.session.sessionId, editedSummary ?? undefined, projectId);
setLockSessionId(result.sessionId);
setView({
type: "breakdown",
sessionId: result.sessionId,
subtasks: result.subtasks,
dirty: false,
});
} catch (err) {
setError(getErrorMessage(err) || "Failed to start breakdown");
setView({ type: "summary", session: view.session, summary: view.summary });
}
}, [editedSummary, view, projectId]);
const handleCreateTasksFromBreakdown = useCallback(async () => {
if (view.type !== "breakdown") return;
setError(null);
setView({ type: "creating" });
try {
const completedSessionId = view.sessionId;
const result = await createTasksFromPlanning(completedSessionId, view.subtasks, projectId);
onTasksCreated(result.tasks);
// Server cleans up the planning session after task creation; mirror that
// locally so reopen doesn't try to load a 404 and the footer count drops.
setPlanningSessions((prev) => prev.filter((s) => s.id !== completedSessionId));
broadcastCompleted({
sessionId: completedSessionId,
status: "complete",
timestamp: Date.now(),
});
// Reset and close
setInitialPlan("");
setView({ type: "initial" });
setError(null);
setResponseHistory([]);
setConversationHistory([]);
setEditedSummary(null);
setStreamingOutput("");
setPlanningModelProvider(undefined);
setPlanningModelId(undefined);
currentSessionIdRef.current = null;
setLockSessionId(null);
setSelectedSessionId(null);
onClose();
} catch (err) {
setError(getErrorMessage(err) || "Failed to create tasks");
setView({ type: "breakdown", sessionId: view.sessionId, subtasks: view.subtasks, dirty: view.dirty });
}
}, [broadcastCompleted, view, onTasksCreated, onClose, projectId]);
const handleBack = useCallback(() => {
if (view.type === "question" && responseHistory.length > 0) {
// Remove last response and go back
const previousResponses = responseHistory.slice(0, -1);
setResponseHistory(previousResponses);
// Note: We don't actually have a way to go back in the backend,
// so we just reset to the question from the initial session
setView({ type: "question", session: view.session });
}
}, [view, responseHistory]);
const getProgress = () => {
if (view.type === "question") {
return Math.min(responseHistory.length + 1, 3);
}
return 3;
};
const activeLockInfo = lockSessionId ? activeTabMap.get(lockSessionId) : null;
const activeRemoteTab = activeLockInfo && activeLockInfo.tabId !== sessionTabId;
const activeInAnotherTab = Boolean(activeRemoteTab && !activeLockInfo.stale);
const allowTakeover = isLockedByOther && (!activeRemoteTab || activeLockInfo.stale);
if (!isOpen) return null;
return (
<div
className="modal-overlay open"
onMouseDown={(e) => {
overlayMouseDownOnSelfRef.current = e.target === e.currentTarget;
}}
onClick={(e) => {
if (e.target === e.currentTarget && overlayMouseDownOnSelfRef.current) {
handleClose();
}
overlayMouseDownOnSelfRef.current = false;
}}
role="dialog"
aria-modal="true"
>
<div className="modal modal-lg planning-modal" ref={modalRef}>
<div className="modal-header">
<div className="detail-title-row">
{mobileShowDetail && (
<button
className="modal-back planning-mobile-back"
onClick={handleBackToList}
aria-label="Back to sessions"
title="Back to sessions"
>
<ChevronLeft size={18} />
</button>
)}
<Lightbulb size={20} className="icon-triage" />
<h3>Planning Mode</h3>
</div>
<div className="modal-header-actions">
<button className="modal-close" onClick={handleClose} aria-label="Close">
<X size={20} />
</button>
</div>
</div>
<div
className={`planning-modal-body planning-modal-body--split ${
mobileShowDetail ? "planning-modal-body--show-detail" : "planning-modal-body--show-list"
}`}
>
<PlanningSessionList
sessions={planningSessions}
loading={sessionsLoading}
selectedSessionId={selectedSessionId}
pendingDeleteId={pendingDeleteId}
onSelectSession={handleSelectSession}
onNewSession={handleNewSession}
onRequestDelete={setPendingDeleteId}
onConfirmDelete={(id) => void handleDeleteSession(id)}
onCancelDelete={() => setPendingDeleteId(null)}
/>
<div className="planning-detail">
{error && <div className="form-error planning-error">{error}</div>}
{isReconnecting && <div className="form-hint text-muted">Reconnecting</div>}
{activeInAnotherTab && (
<div className="form-hint text-muted" data-testid="session-active-another-tab-banner">
Session is active in another tab.
</div>
)}
{view.type === "initial" && (
<div className="planning-initial">
<div className="planning-view-scroll">
<div className="planning-intro">
<Sparkles size={32} className="icon-triage-lg" />
<h4>Transform your idea into a detailed task</h4>
<p className="text-muted">
Describe what you want to build in plain language. The AI will ask clarifying
questions and help you structure a well-defined task.
</p>
</div>
<div className="form-group">
<label htmlFor="initial-plan">What do you want to build?</label>
<textarea
ref={textareaRef}
id="initial-plan"
rows={4}
className="planning-textarea"
placeholder="e.g., Build a user authentication system with login, signup, and password reset..."
value={initialPlan}
onChange={(e) => setInitialPlan(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && initialPlan.trim()) {
e.preventDefault();
handleStartPlanning();
}
}}
/>
</div>
<div className="planning-examples">
<span className="planning-examples-label">Try an example:</span>
<div className="planning-example-chips">
{EXAMPLE_PLANS.map((plan, i) => (
<button
key={i}
className="planning-example-chip"
onClick={() => setInitialPlan(plan)}
>
{plan.length > 40 ? plan.slice(0, 40) + "..." : plan}
</button>
))}
</div>
</div>
<div className="planning-model-select-group">
<label htmlFor="planning-modal-model" className="form-label">
Planning Model
{modelsLoading && (
<span className="text-muted text-muted-sm">
Loading models
</span>
)}
</label>
<CustomModelDropdown
id="planning-modal-model"
label="Planning Model"
value={planningSelectionValue}
onChange={(value) => {
const { provider, modelId } = parseModelSelection(value);
setPlanningModelProvider(provider);
setPlanningModelId(modelId);
}}
models={loadedModels}
disabled={modelsLoading}
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavoriteProvider}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleFavoriteModel}
/>
{modelsError && (
<div className="form-hint form-hint-error">
{modelsError}{" "}
<button
type="button"
className="text-link-btn"
onClick={() => {
void loadModels();
}}
>
Retry
</button>
</div>
)}
<div className="model-selector-current model-selector-current--spaced">
<span
className={`model-badge ${
planningModelProvider && planningModelId
? "model-badge-custom"
: "model-badge-default"
}`}
>
{getModelBadgeLabel(planningModelProvider, planningModelId)}
</span>
</div>
</div>
</div>
<div className="planning-view-footer">
<button
className="btn btn-primary planning-start-btn"
onClick={() => handleStartPlanning()}
disabled={!initialPlan.trim()}
>
<Lightbulb size={16} className="icon-mr-8" />
Start Planning
</button>
</div>
</div>
)}
{view.type === "loading" && (
<div className="planning-loading">
<Loader2 size={40} className="spin icon-todo" />
<p>{streamingOutput ? "AI is thinking..." : "Generating next question..."}</p>
{generationStartTime && (
<div className="planning-elapsed">Thinking ({elapsedSeconds}s)</div>
)}
<div className="planning-thinking-container">
<button
className="planning-thinking-toggle"
onClick={() => setShowThinking(!showThinking)}
type="button"
>
{showThinking ? "Hide thinking" : "Show thinking"}
</button>
<div className="planning-loading-actions">
<button className="btn planning-stop-btn" type="button" onClick={() => void handleStopGeneration()}>
<StopCircle size={14} />
<span className="icon-ml-6">Stop</span>
</button>
</div>
{showThinking && streamingOutput && (
<div className="planning-thinking-output" ref={thinkingOutputRef}>
<pre>{streamingOutput}</pre>
</div>
)}
</div>
</div>
)}
{view.type === "error" && (
<div className="planning-summary">
<div className="planning-view-scroll planning-summary-scroll">
{conversationHistory.length > 0 && (
<>
<ConversationHistory entries={conversationHistory} />
<div className="conversation-separator" />
</>
)}
<div
className="ai-error-panel"
role="alert"
>
<div className="ai-error-icon"></div>
<div className="ai-error-message">{view.errorMessage}</div>
<div className="ai-error-actions">
<button className="btn btn-primary" onClick={() => void handleRetryFromError()} disabled={isRetrying}>
{isRetrying ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
<span className="icon-ml-6">{isRetrying ? "Retrying..." : "Retry"}</span>
</button>
<button className="btn" onClick={handleClose} disabled={isRetrying}>Dismiss</button>
</div>
</div>
</div>
</div>
)}
{view.type === "creating" && (
<div className="planning-loading">
<Loader2 size={40} className="spin icon-todo" />
<p>Creating tasks...</p>
</div>
)}
{view.type === "question" && view.session.currentQuestion && (
<div className="planning-question">
<QuestionForm
question={view.session.currentQuestion}
progress={getProgress()}
historyEntries={conversationHistory}
onSubmit={handleSubmitResponse}
onBack={responseHistory.length > 0 ? handleBack : undefined}
/>
</div>
)}
{view.type === "summary" && editedSummary && (
<SummaryView
summary={editedSummary}
historyEntries={conversationHistory}
onSummaryChange={setEditedSummary}
tasks={tasks}
onCreateTask={handleCreateTask}
onBreakIntoTasks={handleStartBreakdown}
onRefine={() => {
// Reset to question mode for more refinement
setView({ type: "question", session: view.session });
}}
isLoading={false}
/>
)}
{view.type === "breakdown" && (
<BreakdownView
subtasks={view.subtasks}
dirty={view.dirty}
isLoading={false}
onUpdateSubtasks={(newSubtasks) =>
setView({ ...view, subtasks: newSubtasks, dirty: true })
}
onCreateTasks={handleCreateTasksFromBreakdown}
onBack={() => {
// Return to summary view — re-fetch the session
const sessionId = view.sessionId;
const session: PlanningSession = {
sessionId,
currentQuestion: null,
summary: editedSummary ?? null,
};
if (editedSummary) {
setView({ type: "summary", session, summary: editedSummary });
}
}}
/>
)}
</div>
{isLockedByOther && (
<div className="session-lock-overlay" data-testid="session-lock-overlay">
<div className="session-lock-banner">
<Lock size={16} />
<span>
{allowTakeover
? "This session is active in another tab"
: "This session is active in another tab (live heartbeat)"}
</span>
{allowTakeover && (
<button
type="button"
onClick={() => {
void takeControl();
}}
disabled={isLockLoading}
className="btn btn-primary session-lock-take-control"
>
{isLockLoading ? "Taking control..." : "Take Control"}
</button>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
}
interface QuestionFormProps {
question: PlanningQuestion;
progress: number;
historyEntries: ConversationHistoryEntry[];
onSubmit: (responses: QuestionResponse) => void;
onBack?: () => void;
}
function QuestionForm({ question, progress, historyEntries, onSubmit, onBack }: QuestionFormProps) {
const [response, setResponse] = useState<QuestionResponse>({});
const [textValue, setTextValue] = useState("");
const handleSubmit = useCallback(() => {
if (question.type === "text") {
onSubmit({ [question.id]: textValue });
} else if (question.type === "confirm") {
onSubmit({ [question.id]: response[question.id] === true });
} else {
onSubmit(response);
}
}, [question, response, textValue, onSubmit]);
// Reset state when question changes
useEffect(() => {
setResponse({});
setTextValue("");
}, [question.id]);
const isValid = () => {
switch (question.type) {
case "text":
return textValue.trim().length > 0;
case "single_select":
return response[question.id] !== undefined;
case "multi_select":
return Array.isArray(response[question.id] as unknown) && (response[question.id] as unknown[]).length > 0;
case "confirm":
return response[question.id] !== undefined;
default:
return true;
}
};
return (
<div className="planning-question-form">
<div className="planning-view-scroll planning-question-scroll">
{historyEntries.length > 0 && (
<>
<ConversationHistory entries={historyEntries} />
<div className="conversation-separator" />
</>
)}
<div className="planning-question-panel">
<div className="planning-progress">
<div className="planning-progress-bar">
{[1, 2, 3].map((step) => (
<div
key={step}
className={`planning-progress-step ${step <= progress ? "active" : ""}`}
/>
))}
</div>
<span className="planning-progress-text">Question {progress} of ~3</span>
</div>
<div className="planning-question-content">
<h4 className="planning-question-text">{question.question}</h4>
{question.description && (
<p className="planning-question-desc">{question.description}</p>
)}
<div className="planning-options">
{question.type === "text" && (
<textarea
className="planning-textarea"
rows={4}
placeholder="Type your answer here..."
value={textValue}
onChange={(e) => setTextValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && textValue.trim()) {
e.preventDefault();
handleSubmit();
}
}}
/>
)}
{question.type === "single_select" && question.options && (
<div className="planning-radio-group" role="radiogroup">
{question.options.map((option) => (
<label key={option.id} className="planning-option planning-option--radio">
<input
type="radio"
name={question.id}
value={option.id}
checked={response[question.id] === option.id}
onChange={() => setResponse({ [question.id]: option.id })}
/>
<div className="planning-option-content">
<span className="planning-option-label">{option.label}</span>
{option.description && (
<span className="planning-option-desc">{option.description}</span>
)}
</div>
</label>
))}
</div>
)}
{question.type === "multi_select" && question.options && (
<div className="planning-checkbox-group">
{question.options.map((option) => {
const selected = (response[question.id] as string[]) || [];
return (
<label key={option.id} className="planning-option planning-option--checkbox">
<input
type="checkbox"
value={option.id}
checked={selected.includes(option.id)}
onChange={(e) => {
const newSelected = e.target.checked
? [...selected, option.id]
: selected.filter((id) => id !== option.id);
setResponse({ [question.id]: newSelected });
}}
/>
<div className="planning-option-content">
<span className="planning-option-label">{option.label}</span>
{option.description && (
<span className="planning-option-desc">{option.description}</span>
)}
</div>
</label>
);
})}
</div>
)}
{question.type === "confirm" && (
<div className="planning-confirm-group">
<button
className={`planning-confirm-btn ${response[question.id] === true ? "selected" : ""}`}
onClick={() => setResponse({ [question.id]: true })}
>
<CheckCircle size={18} />
Yes
</button>
<button
className={`planning-confirm-btn ${response[question.id] === false ? "selected" : ""}`}
onClick={() => setResponse({ [question.id]: false })}
>
<X size={18} />
No
</button>
</div>
)}
</div>
</div>
</div>
</div>
<div className="planning-actions">
{onBack && (
<button className="btn" onClick={onBack}>
<ArrowLeft size={16} className="icon-mr-4" />
Back
</button>
)}
<button
className="btn btn-primary planning-actions-primary"
onClick={handleSubmit}
disabled={!isValid()}
>
Continue
<ArrowRight size={16} className="icon-ml-4" />
</button>
</div>
</div>
);
}
interface SummaryViewProps {
summary: PlanningSummary;
historyEntries: ConversationHistoryEntry[];
onSummaryChange: (summary: PlanningSummary) => void;
tasks: Task[];
onCreateTask: () => void;
onBreakIntoTasks: () => void;
onRefine: () => void;
isLoading: boolean;
}
function SummaryView({
summary,
historyEntries,
onSummaryChange,
tasks,
onCreateTask,
onBreakIntoTasks,
onRefine,
isLoading,
}: SummaryViewProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [selectedDependencies, setSelectedDependencies] = useState<string[]>(
summary.suggestedDependencies
);
const handleDependencyToggle = (taskId: string) => {
const newDeps = selectedDependencies.includes(taskId)
? selectedDependencies.filter((id) => id !== taskId)
: [...selectedDependencies, taskId];
setSelectedDependencies(newDeps);
onSummaryChange({ ...summary, suggestedDependencies: newDeps });
};
return (
<div className="planning-summary">
<div className="planning-view-scroll planning-summary-scroll">
{historyEntries.length > 0 && (
<>
<ConversationHistory entries={historyEntries} />
<div className="conversation-separator" />
</>
)}
<div className="planning-summary-header">
<CheckCircle size={24} className="icon-success" />
<h4>Planning Complete!</h4>
<p className="text-muted">Review and refine your task before creating it.</p>
</div>
<div className="planning-summary-form">
<div className="form-group">
<label>
Description
<button
type="button"
className="planning-expand-btn"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? "Collapse" : "Expand"}
</button>
</label>
<textarea
className={`planning-textarea ${isExpanded ? "expanded" : ""}`}
rows={isExpanded ? 10 : 4}
value={summary.description}
onChange={(e) => onSummaryChange({ ...summary, description: e.target.value })}
/>
</div>
<div className="form-group">
<label>Suggested Size</label>
<select
className="planning-size-select"
value={summary.suggestedSize}
onChange={(event) =>
onSummaryChange({
...summary,
suggestedSize: event.target.value as "S" | "M" | "L",
})
}
disabled={isLoading}
>
<option value="S">S (Small)</option>
<option value="M">M (Medium)</option>
<option value="L">L (Large)</option>
</select>
</div>
{tasks.length > 0 && (
<div className="form-group">
<label>Suggested Dependencies</label>
<div className="planning-deps-list">
{tasks.map((task) => (
<label
key={task.id}
className={`planning-dep-chip ${selectedDependencies.includes(task.id) ? "selected" : ""}`}
>
<input
type="checkbox"
checked={selectedDependencies.includes(task.id)}
onChange={() => handleDependencyToggle(task.id)}
/>
<span className="planning-dep-id">{task.id}</span>
<span className="planning-dep-title">
{task.title || task.description.slice(0, 30)}
</span>
</label>
))}
</div>
</div>
)}
<div className="form-group">
<label>Key Deliverables</label>
<ul className="planning-deliverables">
{summary.keyDeliverables.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
</div>
</div>
</div>
<div className="planning-actions planning-summary-actions">
<button className="btn" onClick={onRefine} disabled={isLoading}>
<ArrowLeft size={16} className="icon-mr-4" />
Refine Further
</button>
<div className="planning-summary-actions-right">
<button
className="btn"
onClick={onBreakIntoTasks}
disabled={isLoading}
title="Break the plan into multiple tasks with dependencies"
>
{isLoading ? (
<>
<Loader2 size={16} className="spin icon-mr-8" />
Breaking down...
</>
) : (
<>
<ListTree size={16} className="icon-mr-8" />
Break into Tasks
</>
)}
</button>
<button
className="btn btn-primary"
onClick={onCreateTask}
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 size={16} className="spin icon-mr-8" />
Creating...
</>
) : (
<>
<CheckCircle size={16} className="icon-mr-8" />
Create Task
</>
)}
</button>
</div>
</div>
</div>
);
}
// ── BreakdownView (subtask editing in planning modal) ──────────────────────
function hasDependencyCycle(subtasks: SubtaskItem[]): boolean {
const graph = new Map(subtasks.map((item) => [item.id, item.dependsOn]));
const visiting = new Set<string>();
const visited = new Set<string>();
const visit = (id: string): boolean => {
if (visiting.has(id)) return true;
if (visited.has(id)) return false;
visiting.add(id);
for (const dep of graph.get(id) ?? []) {
if (graph.has(dep) && visit(dep)) return true;
}
visiting.delete(id);
visited.add(id);
return false;
};
return subtasks.some((item) => visit(item.id));
}
function createEmptySubtask(index: number): SubtaskItem {
return {
id: `subtask-${index}`,
title: "",
description: "",
suggestedSize: "M",
dependsOn: [],
};
}
interface BreakdownViewProps {
subtasks: SubtaskItem[];
dirty: boolean;
isLoading: boolean;
onUpdateSubtasks: (subtasks: SubtaskItem[]) => void;
onCreateTasks: () => void;
onBack: () => void;
}
function BreakdownView({
subtasks,
dirty: _dirty,
isLoading,
onUpdateSubtasks,
onCreateTasks,
onBack,
}: BreakdownViewProps) {
const [draggingId, setDraggingId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
const [dragOverPosition, setDragOverPosition] = useState<"before" | "after" | null>(null);
const titleRefs = useRef<Array<HTMLInputElement | null>>([]);
const isInvalid = useMemo(() => {
if (subtasks.length === 0) return true;
if (subtasks.some((s) => !s.title.trim())) return true;
return hasDependencyCycle(subtasks);
}, [subtasks]);
const updateSubtask = useCallback(
(id: string, patch: Partial<SubtaskItem>) => {
onUpdateSubtasks(subtasks.map((item) => (item.id === id ? { ...item, ...patch } : item)));
},
[subtasks, onUpdateSubtasks],
);
const addSubtask = useCallback(() => {
onUpdateSubtasks([...subtasks, createEmptySubtask(subtasks.length + 1)]);
}, [subtasks, onUpdateSubtasks]);
const removeSubtask = useCallback(
(id: string) => {
onUpdateSubtasks(
subtasks
.filter((item) => item.id !== id)
.map((item) => ({ ...item, dependsOn: item.dependsOn.filter((dep) => dep !== id) })),
);
},
[subtasks, onUpdateSubtasks],
);
const moveSubtask = useCallback(
(fromIndex: number, toIndex: number) => {
if (toIndex < 0 || toIndex >= subtasks.length) return;
const newSubtasks = [...subtasks];
const [moved] = newSubtasks.splice(fromIndex, 1);
newSubtasks.splice(toIndex, 0, moved);
onUpdateSubtasks(newSubtasks);
},
[subtasks, onUpdateSubtasks],
);
// Drag-and-drop handlers
const handleDragStart = useCallback((subtaskId: string) => (e: React.DragEvent) => {
setDraggingId(subtaskId);
e.dataTransfer.setData("text/plain", subtaskId);
e.dataTransfer.effectAllowed = "move";
}, []);
const handleDragEnd = useCallback(() => {
setDraggingId(null);
setDragOverId(null);
setDragOverPosition(null);
}, []);
const handleDragOver = useCallback((targetId: string) => (e: React.DragEvent) => {
e.preventDefault();
if (targetId === draggingId) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const position: "before" | "after" = e.clientY < midY ? "before" : "after";
setDragOverId(targetId);
setDragOverPosition(position);
}, [draggingId]);
const handleDrop = useCallback((targetId: string) => (e: React.DragEvent) => {
e.preventDefault();
const draggedId = e.dataTransfer.getData("text/plain");
if (!draggedId || draggedId === targetId) {
handleDragEnd();
return;
}
const fromIndex = subtasks.findIndex((s) => s.id === draggedId);
const toIndex = subtasks.findIndex((s) => s.id === targetId);
if (fromIndex === -1 || toIndex === -1) {
handleDragEnd();
return;
}
const newSubtasks = [...subtasks];
const [moved] = newSubtasks.splice(fromIndex, 1);
let insertIndex = toIndex;
if (dragOverPosition === "after" && fromIndex < toIndex) insertIndex--;
if (dragOverPosition === "after") insertIndex++;
newSubtasks.splice(insertIndex, 0, moved);
onUpdateSubtasks(newSubtasks);
handleDragEnd();
}, [subtasks, dragOverPosition, onUpdateSubtasks, handleDragEnd]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
setDragOverId(null);
setDragOverPosition(null);
}
}, []);
return (
<div className="planning-summary">
<div className="planning-view-scroll planning-summary-scroll">
<div className="planning-summary-header">
<ListTree size={24} className="icon-triage" />
<h4>Break into Tasks</h4>
<p className="text-muted">
Review and edit the subtasks generated from your plan. Adjust titles,
descriptions, sizes, and dependencies before creating.
</p>
</div>
<div className="planning-summary-form">
{subtasks.map((subtask, index) => {
const isDragging = draggingId === subtask.id;
const isDragOver = dragOverId === subtask.id;
const dragClasses = [
"task-detail-section",
"subtask-item",
isDragging ? "subtask-item-dragging" : "",
isDragOver ? "subtask-item-drop-target" : "",
isDragOver && dragOverPosition === "before" ? "subtask-item-drop-before" : "",
isDragOver && dragOverPosition === "after" ? "subtask-item-drop-after" : "",
]
.filter(Boolean)
.join(" ");
return (
<div
key={subtask.id}
className={dragClasses}
data-testid={`subtask-item-${index}`}
draggable={!isLoading}
onDragStart={handleDragStart(subtask.id)}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver(subtask.id)}
onDrop={handleDrop(subtask.id)}
onDragLeave={handleDragLeave}
>
<div
className="detail-title-row subtask-item-header subtask-item-header--between"
>
<div className="subtask-drag-handle" title="Drag to reorder">
<GripVertical size={16} />
<strong>{subtask.id}</strong>
</div>
<div className="subtask-item-actions">
<button
type="button"
className="btn btn-icon btn-sm"
onClick={() => moveSubtask(index, index - 1)}
disabled={isLoading || index === 0}
title="Move up"
aria-label="Move subtask up"
>
<ArrowUp size={14} />
</button>
<button
type="button"
className="btn btn-icon btn-sm"
onClick={() => moveSubtask(index, index + 1)}
disabled={isLoading || index === subtasks.length - 1}
title="Move down"
aria-label="Move subtask down"
>
<ArrowDown size={14} />
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => removeSubtask(subtask.id)}
disabled={isLoading}
>
<Trash2 size={14} /> Remove
</button>
</div>
</div>
<div className="form-group">
<label>Title</label>
<input
ref={(element) => {
titleRefs.current[index] = element;
}}
value={subtask.title}
onChange={(event) => updateSubtask(subtask.id, { title: event.target.value })}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
if (index < subtasks.length - 1) {
titleRefs.current[index + 1]?.focus();
}
}
}}
disabled={isLoading}
/>
</div>
<div className="form-group">
<label>Description</label>
<textarea
rows={3}
value={subtask.description}
onChange={(event) =>
updateSubtask(subtask.id, { description: event.target.value })
}
disabled={isLoading}
/>
</div>
<div className="form-group">
<label>Size</label>
<select
className="planning-size-select"
value={subtask.suggestedSize}
onChange={(event) =>
updateSubtask(subtask.id, {
suggestedSize: event.target.value as "S" | "M" | "L",
})
}
disabled={isLoading}
>
<option value="S">S</option>
<option value="M">M</option>
<option value="L">L</option>
</select>
</div>
<div className="form-group">
<label>Dependencies</label>
<div className="planning-deps-list">
{subtasks
.slice(0, index)
.filter((item) => item.id !== subtask.id)
.map((candidate) => {
const selected = subtask.dependsOn.includes(candidate.id);
return (
<label
key={candidate.id}
className={`planning-dep-chip ${selected ? "selected" : ""}`}
>
<input
type="checkbox"
checked={selected}
onChange={() => {
const nextDeps = selected
? subtask.dependsOn.filter((dep) => dep !== candidate.id)
: [...subtask.dependsOn, candidate.id];
updateSubtask(subtask.id, { dependsOn: nextDeps });
}}
disabled={isLoading}
/>
<span className="planning-dep-id">{candidate.id}</span>
<span className="planning-dep-title">
{candidate.title || "Untitled"}
</span>
</label>
);
})}
{index === 0 && (
<div className="text-muted">First subtask cannot have dependencies.</div>
)}
{index > 0 &&
subtasks
.slice(0, index)
.filter((item) => item.id !== subtask.id).length === 0 && (
<div className="text-muted">No previous subtasks available.</div>
)}
</div>
</div>
</div>
);
})}
<button type="button" className="btn" onClick={addSubtask} disabled={isLoading}>
<Plus size={16} className="icon-mr-6" /> Add subtask
</button>
{hasDependencyCycle(subtasks) && (
<div className="form-error planning-error">
Dependencies contain a cycle. Remove circular references before creating tasks.
</div>
)}
</div>
</div>
<div className="planning-actions planning-summary-actions">
<button className="btn" onClick={onBack} disabled={isLoading}>
<ArrowLeft size={16} className="icon-mr-4" />
Back to Summary
</button>
<button
className="btn btn-primary"
onClick={onCreateTasks}
disabled={isLoading || isInvalid}
>
{isLoading ? (
<>
<Loader2 size={16} className="spin icon-mr-6" />
Creating...
</>
) : (
<>Create Tasks</>
)}
</button>
</div>
</div>
);
}
// ── PlanningSessionList (sidebar) ──────────────────────────────────────────
interface PlanningSessionListProps {
sessions: AiSessionSummary[];
loading: boolean;
selectedSessionId: string | null;
pendingDeleteId: string | null;
onSelectSession: (id: string) => void;
onNewSession: () => void;
onRequestDelete: (id: string) => void;
onConfirmDelete: (id: string) => void;
onCancelDelete: () => void;
}
function PlanningSessionList({
sessions,
loading,
selectedSessionId,
pendingDeleteId,
onSelectSession,
onNewSession,
onRequestDelete,
onConfirmDelete,
onCancelDelete,
}: PlanningSessionListProps) {
return (
<aside className="planning-sidebar" aria-label="Planning sessions">
<div className="planning-sidebar-header">
<button
className={`planning-sidebar-new ${selectedSessionId === null ? "active" : ""}`}
onClick={onNewSession}
type="button"
>
<MessageSquarePlus size={16} />
<span>New session</span>
</button>
</div>
<div className="planning-sidebar-list">
{sessions.length === 0 && !loading && (
<div className="planning-sidebar-empty text-muted">
No saved sessions yet. Start one on the right to see it here.
</div>
)}
{sessions.map((session) => {
const isSelected = session.id === selectedSessionId;
const isPendingDelete = pendingDeleteId === session.id;
return (
<div
key={session.id}
className={`planning-sidebar-item ${isSelected ? "selected" : ""} ${isPendingDelete ? "pending-delete" : ""}`}
>
<button
type="button"
className="planning-sidebar-item-button"
onClick={() => onSelectSession(session.id)}
>
<PlanningSessionStatusIcon status={session.status} />
<span className="planning-sidebar-item-body">
<span className="planning-sidebar-item-title">
{session.title || "Untitled session"}
</span>
<span className="planning-sidebar-item-meta">
<PlanningSessionStatusLabel status={session.status} />
<span aria-hidden> · </span>
<span>{formatRelativeTime(session.updatedAt)}</span>
</span>
</span>
</button>
{isPendingDelete ? (
<div className="planning-sidebar-confirm">
<button
type="button"
className="btn btn-sm btn-danger"
onClick={() => onConfirmDelete(session.id)}
>
Delete
</button>
<button
type="button"
className="btn btn-sm"
onClick={onCancelDelete}
>
Cancel
</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>
);
})}
</div>
</aside>
);
}
function PlanningSessionStatusIcon({ status }: { status: AiSessionSummary["status"] }) {
switch (status) {
case "generating":
return <Loader2 size={14} className="spin planning-sidebar-status-icon planning-sidebar-status-generating" />;
case "awaiting_input":
return <HelpCircle size={14} className="planning-sidebar-status-icon planning-sidebar-status-awaiting" />;
case "complete":
return <CheckCircle size={14} className="planning-sidebar-status-icon planning-sidebar-status-complete" />;
case "error":
return <AlertCircle size={14} className="planning-sidebar-status-icon planning-sidebar-status-error" />;
default:
return <Clock size={14} className="planning-sidebar-status-icon" />;
}
}
function PlanningSessionStatusLabel({ status }: { status: AiSessionSummary["status"] }) {
switch (status) {
case "generating":
return <span>Generating</span>;
case "awaiting_input":
return <span>Needs input</span>;
case "complete":
return <span>Complete</span>;
case "error":
return <span>Error</span>;
default:
return <span>{status}</span>;
}
}
function formatRelativeTime(iso: string): string {
const ms = Date.now() - Date.parse(iso);
if (!Number.isFinite(ms) || ms < 0) return "";
const sec = Math.floor(ms / 1000);
if (sec < 60) return "just now";
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const days = Math.floor(hr / 24);
if (days < 7) return `${days}d ago`;
const weeks = Math.floor(days / 7);
if (weeks < 4) return `${weeks}w ago`;
return new Date(iso).toLocaleDateString();
}