Files
fusion/packages/dashboard/app/components/PlanningModeModal.tsx
gsxdsm 7cff0eb11c fix(dashboard): stop the planning textarea from reverting to the persisted description on every keystroke
The "restore description from localStorage" branch ran inside an effect
whose deps included handleStartPlanning, and that callback regenerated on
every change to initialPlan. So every keystroke would re-fire the effect,
hit the same `getPlanningDescription` value, and overwrite what the user
just typed.

Guard the restore with a one-shot ref so it only runs the first time the
modal opens, and reset the ref when the modal closes so a future re-open
still picks up an updated persisted value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:01:36 -07:00

1807 lines
65 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,
parseConversationHistory,
startPlanningBreakdown,
createTasksFromPlanning,
fetchModels,
cancelPlanning,
updateGlobalSettings,
type PlanningSession,
type SubtaskItem,
type ModelInfo,
type ConversationHistoryEntry,
} from "../api";
import {
savePlanningDescription,
getPlanningDescription,
clearPlanningDescription,
} from "../hooks/modalPersistence";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, Minimize2, RefreshCw, Lock } 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 textareaRef = useRef<HTMLTextAreaElement>(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);
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);
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]);
// 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;
setLockSessionId(resumeSessionId);
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: resumeSessionId, currentQuestion: question, summary: null } });
if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput);
// Connect to stream for real-time updates (e.g., thinking output, next question)
// The server will emit a catch-up question event if the client missed it
connectToPlanningStream(resumeSessionId);
} else if (session.status === "complete" && session.result) {
clearPlanningDescription(projectId);
const summary = JSON.parse(session.result);
setView({ type: "summary", session: { sessionId: resumeSessionId, currentQuestion: null, summary }, summary });
setEditedSummary(summary);
} else if (session.status === "generating") {
setView({ type: "loading" });
if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput);
connectToPlanningStream(resumeSessionId);
} else if (session.status === "error") {
setError(null);
setView({
type: "error",
session: { sessionId: resumeSessionId, currentQuestion: null, summary: null },
errorMessage: session.error || "Session failed",
});
}
} catch {
setError("Failed to resume session");
}
})();
return () => { cancelled = true; };
}, [connectToPlanningStream, isOpen, resumeSessionId, view.type, projectId]);
// 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]);
const handleSendToBackground = useCallback(() => {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
onClose();
}, [onClose]);
const handleCancel = useCallback(() => {
// Determine the active session ID to abandon
let activeSessionId: string | null = null;
if (view.type === "question" || view.type === "summary" || view.type === "error") {
activeSessionId = view.session.sessionId;
} else if (view.type === "breakdown") {
activeSessionId = view.sessionId;
} else if (view.type === "loading") {
// During loading, the session ID is stored in the ref
activeSessionId = currentSessionIdRef.current;
}
// Save to localStorage BEFORE any cleanup (preserve for re-entry)
if (initialPlan) {
savePlanningDescription(initialPlan, projectId);
}
// Always close the stream connection
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
// Explicitly abandon the session on the server to prevent zombie sessions
if (activeSessionId) {
void cancelPlanning(activeSessionId, projectId, sessionTabId).catch(() => {
// Best-effort: cancellation failures should not block UI reset
});
}
setInitialPlan("");
setView({ type: "initial" });
setError(null);
setResponseHistory([]);
setConversationHistory([]);
setEditedSummary(null);
setStreamingOutput("");
setIsReconnecting(false);
setIsRetrying(false);
setPlanningModelProvider(undefined);
setPlanningModelId(undefined);
currentSessionIdRef.current = null;
setLockSessionId(null);
onClose();
}, [initialPlan, onClose, projectId, sessionTabId, view]);
// Handle escape key to close
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handleCancel();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, handleCancel]);
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 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 task = await createTaskFromPlanning(view.session.sessionId, editedSummary ?? undefined, projectId);
onTaskCreated(task);
handleCancel();
} catch (err) {
setError(getErrorMessage(err) || "Failed to create task");
setView({ type: "summary", session: view.session, summary: view.summary });
}
}, [editedSummary, view, projectId, onTaskCreated, handleCancel]);
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 result = await createTasksFromPlanning(view.sessionId, view.subtasks, projectId);
onTasksCreated(result.tasks);
// Reset and close
setInitialPlan("");
setView({ type: "initial" });
setError(null);
setResponseHistory([]);
setConversationHistory([]);
setEditedSummary(null);
setStreamingOutput("");
setPlanningModelProvider(undefined);
setPlanningModelId(undefined);
currentSessionIdRef.current = null;
setLockSessionId(null);
onClose();
} catch (err) {
setError(getErrorMessage(err) || "Failed to create tasks");
setView({ type: "breakdown", sessionId: view.sessionId, subtasks: view.subtasks, dirty: view.dirty });
}
}, [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 showSendToBackgroundButton =
view.type === "loading" || view.type === "question" || view.type === "summary" || view.type === "error";
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" onClick={(e) => e.target === e.currentTarget && handleCancel()} role="dialog" aria-modal="true">
<div className="modal modal-lg planning-modal">
<div className="modal-header">
<div className="detail-title-row">
<Lightbulb size={20} className="icon-triage" />
<h3>Planning Mode</h3>
</div>
<div className="modal-header-actions">
{showSendToBackgroundButton && (
<button
className="modal-send-to-background"
onClick={handleSendToBackground}
title="Send to background"
aria-label="Send to background"
>
<Minimize2 size={16} />
</button>
)}
<button className="modal-close" onClick={handleCancel} aria-label="Close">
<X size={20} />
</button>
</div>
</div>
<div className="planning-modal-body">
{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>
<div className="planning-thinking-container">
<button
className="planning-thinking-toggle"
onClick={() => setShowThinking(!showThinking)}
type="button"
>
{showThinking ? "Hide thinking" : "Show thinking"}
</button>
{showThinking && streamingOutput && (
<div className="planning-thinking-output">
<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={handleCancel} 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 });
}
}}
/>
)}
{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>
);
}