## Problem Reported: planning gets stuck in a cycle of retrying and regenerating after a response was already supplied. After the user answers a planning question, `submitResponse` pushed the answer to history but left `session.currentQuestion` pointing at the just-answered question for the whole next generation. The planning SSE route's catch-up path re-emits `currentQuestion` to every fresh connection — and each FN-7946 auto-retry (#2073) opens a fresh connection. So after any generation error: 1. Auto-retry connects a fresh stream → the server re-emits the **already-answered** question. 2. The client treats any question event as progress: it **resets the 3-attempt auto-retry budget** and re-shows the answered question. 3. The retry regenerates; if it errors again the cycle repeats with a fresh budget — an unbounded retry/regenerate loop. Re-answering the stale question also 409-collided with the in-flight generation, feeding the same loop. ## Fix Invariant: `currentQuestion` is only set while the session is genuinely awaiting user input. - `submitResponse` clears it the moment an answer is accepted (normal turns and the deepening checkpoint), while preserving the legacy 200 respond contract on generation failure (the modal ignores the body and lets the SSE error drive recovery). - `retrySession` scrubs stale questions persisted by pre-fix builds before regenerating. - `buildSessionFromRow` only restores a question when the persisted row is `awaiting_input`. - `didSubmitSameAnswer` now compares against the last history entry so the duplicate-submit 409 message survives. - Agent onboarding gets the same fix (its SSE route also re-emits `currentQuestion` on connect); retry now asks the next question instead of re-asking the answered one. Surface enumeration: mission and milestone interviews keep questions the same way but their SSE routes never re-emit on connect, and the auto-retry budget machinery is Planning-Mode-only — planning + onboarding were the two affected surfaces. ## Symptom Verification - **Original symptom:** after answering a question, Planning Mode loops between "Retrying…" and regenerating, re-showing the already-answered question, with the auto-retry budget never exhausting. - **Exact reproduction:** answer a question, have the next generation fail (stuck watchdog/provider error), let the client auto-retry open a fresh SSE connection. - **Assertion it is gone:** new regression suite `planning-answered-question-reemit.test.ts` asserts `currentQuestion` is cleared mid-generation, on generation failure, on retry, and on restore from non-`awaiting_input` rows — so the SSE catch-up path has nothing stale to re-emit. All 5 tests fail against pre-fix code and pass with the fix; an onboarding regression test covers the sibling surface. ## Verification - New regression tests: 5/5 fail on pre-fix code, pass with the fix (plus 1 onboarding test). - Existing suites: 137 planning server tests pass (3 failures in `routes-planning.test.ts` fail identically without this change — pre-existing on the branch); all 69 `PlanningModeModal.planning-flow` client tests pass; `tsc --noEmit` clean; `pnpm check:changesets` passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Made Planning Mode (and related planning controls) lock-free and multi-tab—no more take-over/active-in-another-tab lock overlays. * **Bug Fixes** * Fixed Planning Mode retry/generation flows where already-answered questions could reappear. * Ensured answered questions clear immediately and aren’t re-emitted during session recovery/SSE catch-up. * Improved session restoration and preserved legacy recovery behavior when generation fails after an answer. * **Tests** * Added regression coverage for the answered-question invariant and updated existing tests to reflect lock-free behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --- ## Follow-up: Planning Mode is now multi-tab via DB state (lock-free) Second commit removes all cross-tab coordination from planning — the persisted session row is the single source of truth and multiple tabs can read and interact with the same session: - **Server:** `/planning/*` routes no longer run `checkSessionLock` or parse `tabId`; a stale `tabId` from an older client is ignored instead of 409'd. Subtask/mission interview routes keep their existing lock behavior. - **Client:** `PlanningModeModal` drops `useSessionLock`, the `useAiSessionSync` BroadcastChannel broadcasts, `sessionTabId`/`lockSessionId` state, and the "Take Control" overlay. Tabs stay current via the per-session SSE stream plus the global `ai_session:updated` events `useBackgroundSessions` already consumes; concurrent writes resolve via the server's generation-in-progress guard (409). - **API client:** planning functions lose their `tabId` params. - **Fix uncovered by the refactor:** the 8s stuck-poll now resolves the session id inside each tick — the removed lock state was what previously re-armed the poll after Start Planning resolved the session id. - Also fixes a pre-existing PG-cutover break in `planning-generation-cancellation.test.ts` (`getSession` is async). Verification: 144 client planning tests and 137 server planning tests pass (the 3 remaining `routes-planning.test.ts` failures are pre-existing on the branch and fail identically without these changes); `tsc --noEmit` and eslint clean on changed files; `pnpm check:changesets` passes. Lock-conflict route tests were rewritten to assert lock-free semantics, plus a new modal test proving a session stays fully interactive with no lock acquisition even when another tab is active. --- ## Follow-up 2: the per-tab session lock is gone entirely Third commit extends the multi-tab model from planning to **every** AI interview surface (planning, subtask breakdown, mission interview, milestone/slice interview) and deletes the lock machinery root and branch. **Server** - Deleted the `/ai-sessions/:id/lock`, `/lock/force`, and `/lock/beacon` routes. - Dropped `checkSessionLock` from every planning/subtask/mission/milestone route (both copies — `routes.ts` and `mission-routes.ts`). A `tabId` from an older client is ignored, never 409'd; all `tabId` body parsing is gone. - Dropped `acquireLock` / `releaseLock` / `forceAcquireLock` / `getLockHolder` / `releaseStaleLocks` from `AiSessionStore`, plus the `@fusion/core` async helpers (`acquireAiSessionLock` et al) and core's re-exports. - Removed `lockedByTab`/`lockedAt` from `AiSessionRow`/`AiSessionSummary`, the upsert SQL, and all four session producers. **Client** - Deleted `useSessionLock` and the now-orphaned `getSessionTabId` util. - Removed the Take Control overlay, the "active in another tab" banners, and `BackgroundTasksIndicator`'s active-elsewhere gate (the confirm prompt and lock badge — sessions now just open). - Reduced `useAiSessionSync` to what its own comments already called it — a low-latency *status* supplement to SSE: no `activeTabMap`, `broadcastLock/Unlock/Heartbeat`, `owningTabId`, `tab:*` messages, or stale-heartbeat sweep. - Dropped `tabId` from every session API client function; removed the lock CSS. **Deliberately kept: the two DB columns.** `ai_sessions.locked_by_tab` / `locked_at` remain as dead, always-NULL columns with a deprecation note. Dropping them is an irreversible migration, and released binaries still name those columns explicitly in their upsert — an older install pointed at the same database would fail every session write. They can be dropped once no such binary can reach it. No code reads or writes them. **Verification**: 397 client tests and 137 server planning tests pass (the same 3 `routes-planning.test.ts` failures are pre-existing — verified identical on a clean stash); `tsc --noEmit` clean for `@fusion/core` and `@fusion/dashboard`; eslint clean on all changed files; the 30 PG `schema-applier` tests pass (they exercise the retained columns); `pnpm check:changesets` passes. The lock-conflict route tests and both modal lock tests were rewritten to assert the inverse: routes and modals stay fully interactive while another tab "holds" a lock, and the lock API is never called. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
910 lines
39 KiB
TypeScript
910 lines
39 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { Task } from "@fusion/core";
|
|
import { getErrorMessage } from "@fusion/core";
|
|
import {
|
|
startSubtaskBreakdown,
|
|
retrySubtaskSession,
|
|
connectSubtaskStream,
|
|
createTasksFromBreakdown,
|
|
fetchAiSession,
|
|
fetchTaskWorkflow,
|
|
parseConversationHistory,
|
|
type SubtaskItem,
|
|
type ConversationHistoryEntry,
|
|
} from "../api";
|
|
import {
|
|
saveSubtaskDescription,
|
|
getSubtaskDescription,
|
|
clearSubtaskDescription,
|
|
} from "../hooks/modalPersistence";
|
|
import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown, Minimize2, RefreshCw } from "lucide-react";
|
|
import { ConversationHistory } from "./ConversationHistory";
|
|
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
|
import { useConfirm } from "../hooks/useConfirm";
|
|
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
|
import { useViewportMode } from "../hooks/useViewportMode";
|
|
|
|
const WARNING_ICON = "⚠️";
|
|
|
|
interface SubtaskBreakdownModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
initialDescription: string;
|
|
onTasksCreated: (tasks: Task[]) => void;
|
|
parentTaskId?: string;
|
|
projectId?: string;
|
|
/** Active workflow lane selected when the breakdown modal was opened. */
|
|
workflowId?: string | null;
|
|
resumeSessionId?: string;
|
|
onOpenGroupModal?: (groupId: string) => void;
|
|
}
|
|
|
|
type ViewState =
|
|
| { type: "initial" }
|
|
| { type: "generating"; sessionId: string }
|
|
| { type: "editing"; sessionId: string }
|
|
| { type: "error"; sessionId: string; errorMessage: string }
|
|
| { type: "creating"; sessionId: string };
|
|
|
|
function createEmptySubtask(index: number): SubtaskItem {
|
|
return {
|
|
id: `subtask-${index}`,
|
|
title: "",
|
|
description: "",
|
|
suggestedSize: "M",
|
|
dependsOn: [],
|
|
};
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onTasksCreated, parentTaskId, projectId, workflowId, resumeSessionId, onOpenGroupModal }: SubtaskBreakdownModalProps) {
|
|
const { t } = useTranslation("app");
|
|
const viewportMode = useViewportMode();
|
|
useMobileScrollLock(isOpen);
|
|
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
|
|
enabled: viewportMode === "mobile",
|
|
});
|
|
const keyboardStyle: CSSProperties = keyboardOpen
|
|
? ({
|
|
"--keyboard-overlap": `${keyboardOverlap}px`,
|
|
"--vv-offset-top": `${viewportOffsetTop}px`,
|
|
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
|
|
} as CSSProperties)
|
|
: {};
|
|
const [view, setView] = useState<ViewState>({ type: "initial" });
|
|
const [subtasks, setSubtasks] = useState<SubtaskItem[]>([]);
|
|
const [conversationHistory, setConversationHistory] = useState<ConversationHistoryEntry[]>([]);
|
|
const [thinkingOutput, setThinkingOutput] = useState("");
|
|
const [showThinking, setShowThinking] = useState(true);
|
|
const [isReconnecting, setIsReconnecting] = useState(false);
|
|
const [isRetrying, setIsRetrying] = useState(false);
|
|
const [isStartingBreakdown, setIsStartingBreakdown] = useState(false);
|
|
// Local description: synced from prop, can fall back to localStorage
|
|
const [localDescription, setLocalDescription] = useState(initialDescription);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [dirty, setDirty] = useState(false);
|
|
const [branchMode, setBranchMode] = useState<"project-default" | "auto-new" | "existing" | "custom-new">("project-default");
|
|
const [branchName, setBranchName] = useState("");
|
|
const [baseBranch, setBaseBranch] = useState("");
|
|
const [branchAssignmentMode, setBranchAssignmentMode] = useState<"shared" | "per-task-derived">("shared");
|
|
|
|
// Drag-and-drop state
|
|
const [draggingId, setDraggingId] = useState<string | null>(null);
|
|
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
|
const [dragOverPosition, setDragOverPosition] = useState<'before' | 'after' | null>(null);
|
|
|
|
const streamRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
|
|
const titleRefs = useRef<Array<HTMLInputElement | null>>([]);
|
|
const autoStartedRef = useRef(false);
|
|
|
|
const sessionId = view.type === "generating" || view.type === "editing" || view.type === "creating" || view.type === "error"
|
|
? view.sessionId
|
|
: null;
|
|
/*
|
|
FNXC:PlanningMultiTab 2026-07-14-00:00:
|
|
No tab lock: the persisted session row is the shared source of truth, so every tab may read
|
|
and interact with this breakdown. The lock overlay, Take Control affordance, ownership
|
|
broadcasts, heartbeat, and the "active in another tab" banner were removed; only
|
|
session-status sync remains.
|
|
*/
|
|
const { broadcastUpdate, broadcastCompleted } = useAiSessionSync();
|
|
|
|
const isInvalid = useMemo(() => {
|
|
if (subtasks.length === 0) return true;
|
|
if (subtasks.some((subtask) => !subtask.title.trim())) return true;
|
|
if ((branchMode === "existing" || branchMode === "custom-new") && !branchName.trim()) return true;
|
|
return hasDependencyCycle(subtasks);
|
|
}, [branchMode, branchName, subtasks]);
|
|
|
|
const showSendToBackgroundButton = view.type === "generating" || view.type === "editing" || view.type === "error";
|
|
const { confirm } = useConfirm();
|
|
|
|
const resetState = useCallback(() => {
|
|
// Save to localStorage before cleanup (preserve for re-entry)
|
|
if (localDescription) {
|
|
saveSubtaskDescription(localDescription, projectId);
|
|
}
|
|
streamRef.current?.close();
|
|
streamRef.current = null;
|
|
setView({ type: "initial" });
|
|
setSubtasks([]);
|
|
setConversationHistory([]);
|
|
setThinkingOutput("");
|
|
setShowThinking(true);
|
|
setIsReconnecting(false);
|
|
setIsRetrying(false);
|
|
setIsStartingBreakdown(false);
|
|
setError(null);
|
|
setDirty(false);
|
|
setBranchMode("project-default");
|
|
setBranchName("");
|
|
setBaseBranch("");
|
|
setBranchAssignmentMode("shared");
|
|
autoStartedRef.current = false;
|
|
}, [localDescription, projectId]);
|
|
|
|
const keepSessionReachableInBackground = useCallback(() => {
|
|
if (!sessionId) return;
|
|
|
|
/**
|
|
* FNXC:SubtaskBreakdown 2026-06-16-21:10:
|
|
* Closing the modal is an exit affordance, not an explicit discard. Preserve running and review-ready subtask sessions in the background-session list so users can resume from BackgroundTasksIndicator instead of losing AI work.
|
|
*/
|
|
if (view.type !== "generating" && view.type !== "editing") return;
|
|
|
|
const resumableStatus = view.type === "generating" ? "generating" : "awaiting_input";
|
|
broadcastUpdate({
|
|
sessionId,
|
|
status: resumableStatus,
|
|
needsInput: resumableStatus === "awaiting_input",
|
|
type: "subtask",
|
|
title: localDescription.trim() || undefined,
|
|
projectId: projectId ?? null,
|
|
});
|
|
}, [broadcastUpdate, localDescription, projectId, sessionId, view.type]);
|
|
|
|
const handleSendToBackground = useCallback(() => {
|
|
keepSessionReachableInBackground();
|
|
streamRef.current?.close();
|
|
streamRef.current = null;
|
|
onClose();
|
|
}, [keepSessionReachableInBackground, onClose]);
|
|
|
|
const handleClose = useCallback(async () => {
|
|
const hasUnsavedChanges = dirty || view.type === "editing" || view.type === "creating";
|
|
if (hasUnsavedChanges) {
|
|
const shouldClose = await confirm({
|
|
title: t("subtasks.keepSessionTitle", "Keep subtask session available?"),
|
|
message: t("subtasks.keepSessionMessage", "Close this modal and keep the subtask session available from Background Tasks? Edited fields that have not been saved to the session may be reset when you resume."),
|
|
});
|
|
if (!shouldClose) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
keepSessionReachableInBackground();
|
|
streamRef.current?.close();
|
|
streamRef.current = null;
|
|
resetState();
|
|
onClose();
|
|
}, [dirty, keepSessionReachableInBackground, onClose, resetState, view.type, confirm, t]);
|
|
|
|
const connectToSubtaskStream = useCallback(
|
|
(activeSessionId: string) => {
|
|
streamRef.current?.close();
|
|
streamRef.current = connectSubtaskStream(activeSessionId, projectId, {
|
|
onThinking: (data) => {
|
|
setThinkingOutput((prev) => prev + data);
|
|
broadcastUpdate({
|
|
sessionId: activeSessionId,
|
|
status: "generating",
|
|
needsInput: false,
|
|
type: "subtask",
|
|
title: localDescription.trim() || undefined,
|
|
projectId: projectId ?? null,
|
|
});
|
|
},
|
|
onSubtasks: (items) => {
|
|
setIsReconnecting(false);
|
|
setIsRetrying(false);
|
|
clearSubtaskDescription(projectId);
|
|
setSubtasks(items);
|
|
setView({ type: "editing", sessionId: activeSessionId });
|
|
setDirty(false);
|
|
|
|
broadcastUpdate({
|
|
sessionId: activeSessionId,
|
|
status: "awaiting_input",
|
|
needsInput: true,
|
|
type: "subtask",
|
|
title: localDescription.trim() || undefined,
|
|
projectId: projectId ?? null,
|
|
});
|
|
},
|
|
onError: (message) => {
|
|
const errorMessage = message || t("subtasks.errorSessionFailed", "Session failed while contacting the AI.");
|
|
setIsReconnecting(false);
|
|
setIsRetrying(false);
|
|
setError(null);
|
|
setView({ type: "error", sessionId: activeSessionId, errorMessage });
|
|
|
|
broadcastUpdate({
|
|
sessionId: activeSessionId,
|
|
status: "error",
|
|
needsInput: false,
|
|
type: "subtask",
|
|
title: localDescription.trim() || undefined,
|
|
projectId: projectId ?? null,
|
|
});
|
|
broadcastCompleted({ sessionId: activeSessionId, status: "error" });
|
|
},
|
|
onComplete: () => {
|
|
broadcastCompleted({ sessionId: activeSessionId, status: "complete" });
|
|
},
|
|
onConnectionStateChange: (state) => {
|
|
setIsReconnecting(state === "reconnecting");
|
|
},
|
|
});
|
|
},
|
|
[
|
|
broadcastCompleted,
|
|
broadcastUpdate,
|
|
localDescription,
|
|
projectId,
|
|
],
|
|
);
|
|
|
|
const beginBreakdown = useCallback(async () => {
|
|
if (!localDescription.trim()) return;
|
|
setError(null);
|
|
setConversationHistory([]);
|
|
setThinkingOutput("");
|
|
setIsReconnecting(false);
|
|
setIsStartingBreakdown(true);
|
|
|
|
try {
|
|
const { sessionId } = await startSubtaskBreakdown(localDescription.trim(), projectId);
|
|
setView({ type: "generating", sessionId });
|
|
broadcastUpdate({
|
|
sessionId,
|
|
status: "generating",
|
|
needsInput: false,
|
|
type: "subtask",
|
|
title: localDescription.trim() || undefined,
|
|
projectId: projectId ?? null,
|
|
});
|
|
connectToSubtaskStream(sessionId);
|
|
} catch (err) {
|
|
setError(getErrorMessage(err) || t("subtasks.errorStartBreakdown", "Failed to start subtask breakdown"));
|
|
setView({ type: "initial" });
|
|
} finally {
|
|
setIsStartingBreakdown(false);
|
|
}
|
|
}, [broadcastUpdate, connectToSubtaskStream, localDescription, projectId, t]);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) {
|
|
resetState();
|
|
return;
|
|
}
|
|
|
|
if (isOpen && initialDescription && !autoStartedRef.current) {
|
|
setLocalDescription(initialDescription);
|
|
autoStartedRef.current = true;
|
|
void beginBreakdown();
|
|
} else if (isOpen && !initialDescription && !autoStartedRef.current) {
|
|
// Check localStorage for persisted description when no prop provided
|
|
const persisted = getSubtaskDescription(projectId);
|
|
if (persisted) {
|
|
setLocalDescription(persisted);
|
|
}
|
|
}
|
|
}, [isOpen, initialDescription, beginBreakdown, resetState]);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen || !resumeSessionId || view.type !== "initial") return;
|
|
|
|
void (async () => {
|
|
try {
|
|
const session = await fetchAiSession(resumeSessionId);
|
|
if (!session) return;
|
|
|
|
const parsedHistory = parseConversationHistory(session.conversationHistory);
|
|
setConversationHistory(parsedHistory);
|
|
|
|
if (session.status === "generating" || session.status === "awaiting_input") {
|
|
setThinkingOutput(session.thinkingOutput ?? "");
|
|
setView({ type: "generating", sessionId: resumeSessionId });
|
|
connectToSubtaskStream(resumeSessionId);
|
|
} else if (session.status === "complete" && session.result) {
|
|
clearSubtaskDescription(projectId);
|
|
const items = JSON.parse(session.result) as SubtaskItem[];
|
|
setSubtasks(items);
|
|
setView({ type: "editing", sessionId: resumeSessionId });
|
|
} else if (session.status === "error") {
|
|
setError(null);
|
|
setView({
|
|
type: "error",
|
|
sessionId: resumeSessionId,
|
|
errorMessage: session.error ?? t("subtasks.errorSessionEncountered", "Session encountered an error"),
|
|
});
|
|
}
|
|
} catch (err) {
|
|
setError(getErrorMessage(err) || t("subtasks.errorResumeSession", "Failed to resume session"));
|
|
}
|
|
})();
|
|
}, [connectToSubtaskStream, isOpen, resumeSessionId, view.type, projectId]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
streamRef.current?.close();
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
void handleClose();
|
|
}
|
|
};
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, [isOpen, handleClose]);
|
|
|
|
const updateSubtask = useCallback((id: string, patch: Partial<SubtaskItem>) => {
|
|
setSubtasks((current) => current.map((item) => item.id === id ? { ...item, ...patch } : item));
|
|
setDirty(true);
|
|
}, []);
|
|
|
|
const addSubtask = useCallback(() => {
|
|
setSubtasks((current) => [...current, createEmptySubtask(current.length + 1)]);
|
|
setDirty(true);
|
|
}, []);
|
|
|
|
const removeSubtask = useCallback((id: string) => {
|
|
setSubtasks((current) => current
|
|
.filter((item) => item.id !== id)
|
|
.map((item) => ({ ...item, dependsOn: item.dependsOn.filter((dep) => dep !== id) })));
|
|
setDirty(true);
|
|
}, []);
|
|
|
|
// 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) {
|
|
setDraggingId(null);
|
|
setDragOverId(null);
|
|
setDragOverPosition(null);
|
|
return;
|
|
}
|
|
|
|
setSubtasks((current) => {
|
|
const fromIndex = current.findIndex((s) => s.id === draggedId);
|
|
const toIndex = current.findIndex((s) => s.id === targetId);
|
|
|
|
if (fromIndex === -1 || toIndex === -1) return current;
|
|
|
|
const newSubtasks = [...current];
|
|
const [moved] = newSubtasks.splice(fromIndex, 1);
|
|
|
|
let insertIndex = toIndex;
|
|
if (dragOverPosition === 'after' && fromIndex < toIndex) insertIndex--;
|
|
if (dragOverPosition === 'after') insertIndex++;
|
|
|
|
newSubtasks.splice(insertIndex, 0, moved);
|
|
return newSubtasks;
|
|
});
|
|
|
|
setDirty(true);
|
|
setDraggingId(null);
|
|
setDragOverId(null);
|
|
setDragOverPosition(null);
|
|
}, [dragOverPosition]);
|
|
|
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
const x = e.clientX;
|
|
const y = e.clientY;
|
|
|
|
// Only clear if leaving the element entirely, not just moving between children
|
|
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
|
setDragOverId(null);
|
|
setDragOverPosition(null);
|
|
}
|
|
}, []);
|
|
|
|
// Keyboard reordering handlers
|
|
const moveSubtask = useCallback((fromIndex: number, toIndex: number) => {
|
|
if (toIndex < 0 || toIndex >= subtasks.length) return;
|
|
|
|
setSubtasks((current) => {
|
|
const newSubtasks = [...current];
|
|
const [moved] = newSubtasks.splice(fromIndex, 1);
|
|
newSubtasks.splice(toIndex, 0, moved);
|
|
return newSubtasks;
|
|
});
|
|
setDirty(true);
|
|
}, [subtasks.length]);
|
|
|
|
const moveFocusToNext = useCallback((index: number) => {
|
|
titleRefs.current[index + 1]?.focus();
|
|
}, []);
|
|
|
|
const handleCreateTasks = useCallback(async () => {
|
|
if (!sessionId || isInvalid) return;
|
|
setError(null);
|
|
setView({ type: "creating", sessionId });
|
|
try {
|
|
let effectiveWorkflowId = workflowId;
|
|
if (parentTaskId) {
|
|
try {
|
|
effectiveWorkflowId = (await fetchTaskWorkflow(parentTaskId, projectId)).workflowId;
|
|
} catch {
|
|
effectiveWorkflowId = workflowId;
|
|
}
|
|
}
|
|
const result = await createTasksFromBreakdown(sessionId, subtasks, parentTaskId, projectId, {
|
|
branchSelection: {
|
|
mode: branchMode,
|
|
...(branchMode === "existing" || branchMode === "custom-new" ? { branchName } : {}),
|
|
...(baseBranch.trim() ? { baseBranch: baseBranch.trim() } : {}),
|
|
},
|
|
branchAssignment: { mode: branchAssignmentMode },
|
|
/*
|
|
FNXC:WorkflowSelection 2026-06-20-16:48:
|
|
Subtask Breakdown children inherit the parent task's workflow when available, otherwise they keep the workflow lane that opened this modal.
|
|
*/
|
|
...(effectiveWorkflowId !== undefined ? { workflowId: effectiveWorkflowId } : {}),
|
|
});
|
|
onTasksCreated(result.tasks);
|
|
resetState();
|
|
onClose();
|
|
} catch (err) {
|
|
setError(getErrorMessage(err) || t("subtasks.errorCreateTasks", "Failed to create tasks"));
|
|
setView({ type: "editing", sessionId });
|
|
}
|
|
}, [baseBranch, branchAssignmentMode, branchMode, branchName, isInvalid, onClose, onTasksCreated, parentTaskId, projectId, resetState, sessionId, subtasks, workflowId]);
|
|
|
|
const handleRetry = useCallback(async () => {
|
|
if (view.type !== "error") {
|
|
return;
|
|
}
|
|
|
|
const retrySessionId = view.sessionId;
|
|
setError(null);
|
|
setIsRetrying(true);
|
|
setThinkingOutput("");
|
|
setView({ type: "generating", sessionId: retrySessionId });
|
|
connectToSubtaskStream(retrySessionId);
|
|
|
|
try {
|
|
await retrySubtaskSession(retrySessionId, projectId);
|
|
} catch (err) {
|
|
let retryError: unknown = err;
|
|
const retryErrorMessage = getErrorMessage(err) || "";
|
|
|
|
if (retryErrorMessage.includes("not in an error state")) {
|
|
try {
|
|
const session = await fetchAiSession(retrySessionId);
|
|
if (!session) {
|
|
throw new Error(t("subtasks.errorRefreshSession", "Failed to refresh subtask session."));
|
|
}
|
|
|
|
setConversationHistory(parseConversationHistory(session.conversationHistory));
|
|
|
|
if (session.status === "generating" || session.status === "awaiting_input") {
|
|
setThinkingOutput(session.thinkingOutput ?? "");
|
|
setView({ type: "generating", sessionId: session.id });
|
|
if (!streamRef.current?.isConnected()) {
|
|
connectToSubtaskStream(session.id);
|
|
}
|
|
} else if (session.status === "complete") {
|
|
if (!session.result) {
|
|
throw new Error(t("subtasks.errorSessionNoResult", "Subtask session is complete but has no result."));
|
|
}
|
|
clearSubtaskDescription(projectId);
|
|
const items = JSON.parse(session.result) as SubtaskItem[];
|
|
setSubtasks(items);
|
|
setView({ type: "editing", sessionId: session.id });
|
|
setDirty(false);
|
|
} else if (session.status === "error") {
|
|
setView({
|
|
type: "error",
|
|
sessionId: session.id,
|
|
errorMessage: session.error ?? t("subtasks.errorRetryFailed", "Retry failed. Please try again."),
|
|
});
|
|
}
|
|
|
|
setIsReconnecting(false);
|
|
return;
|
|
} catch (sessionRefreshError) {
|
|
retryError = sessionRefreshError;
|
|
}
|
|
}
|
|
|
|
streamRef.current?.close();
|
|
streamRef.current = null;
|
|
setView({
|
|
type: "error",
|
|
sessionId: retrySessionId,
|
|
errorMessage: getErrorMessage(retryError) || t("subtasks.errorRetryFailed", "Retry failed. Please try again."),
|
|
});
|
|
setIsReconnecting(false);
|
|
} finally {
|
|
setIsRetrying(false);
|
|
}
|
|
}, [connectToSubtaskStream, projectId, view]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div className="modal-overlay open" onClick={(event) => event.target === event.currentTarget && void handleClose()} role="dialog" aria-modal="true">
|
|
<div className="modal modal-lg planning-modal" style={keyboardStyle}>
|
|
<div className="modal-header">
|
|
<div className="detail-title-row">
|
|
<ListTree size={20} className="icon-triage" />
|
|
<h3>{t("subtasks.modalTitle", "Subtask Breakdown")}</h3>
|
|
</div>
|
|
<div className="modal-header-actions">
|
|
{showSendToBackgroundButton && (
|
|
<button
|
|
className="modal-send-to-background"
|
|
onClick={handleSendToBackground}
|
|
title={t("subtasks.sendToBackgroundTitle", "Send to background")}
|
|
aria-label={t("subtasks.sendToBackgroundAriaLabel", "Send to background")}
|
|
>
|
|
<Minimize2 size={16} />
|
|
</button>
|
|
)}
|
|
<button className="modal-close" onClick={() => void handleClose()} aria-label={t("common.close", "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">{t("subtasks.reconnecting", "Reconnecting…")}</div>}
|
|
|
|
{view.type === "initial" && (
|
|
<div className="planning-loading" data-testid="subtask-progress-state">
|
|
<div className="planning-view-scroll">
|
|
{/*
|
|
* FNXC:SubtaskBreakdown 2026-06-16-00:00:
|
|
* Subtask generation must show live progress as soon as a run is requested, before the session id or streamed thinking exists, so users do not see a dead static preparation state.
|
|
*/}
|
|
<Loader2 size={40} className="spin icon-todo" aria-hidden="true" />
|
|
<p>{isStartingBreakdown ? t("subtasks.startingBreakdown", "Starting subtask breakdown...") : t("subtasks.preparingBreakdown", "Preparing to break this task into subtasks.")}</p>
|
|
<p className="text-muted">{t("subtasks.progressHint", "Fusion will keep working while the AI prepares subtasks.")}</p>
|
|
<pre className="planning-thinking-output">{localDescription}</pre>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{view.type === "generating" && (
|
|
<div className="planning-loading" data-testid="subtask-progress-state">
|
|
{conversationHistory.length > 0 && (
|
|
<>
|
|
<ConversationHistory entries={conversationHistory} defaultShowThinking={true} />
|
|
<div className="conversation-separator" />
|
|
</>
|
|
)}
|
|
<Loader2 size={40} className="spin icon-todo" aria-hidden="true" />
|
|
<p>{isReconnecting ? t("subtasks.reconnecting", "Reconnecting…") : t("subtasks.generatingSubtasks", "AI is generating subtasks...")}</p>
|
|
<p className="text-muted">{t("subtasks.progressHint", "Fusion will keep working while the AI prepares subtasks.")}</p>
|
|
<div className="planning-thinking-container">
|
|
<button className="planning-thinking-toggle" onClick={() => setShowThinking(!showThinking)} type="button">
|
|
{showThinking ? t("subtasks.hideThinking", "Hide thinking") : t("subtasks.showThinking", "Show thinking")}
|
|
</button>
|
|
{showThinking && (
|
|
<div className="planning-thinking-output">
|
|
{thinkingOutput ? <pre>{thinkingOutput}</pre> : <p className="text-muted">{t("subtasks.waitingForThinking", "Waiting for AI progress updates...")}</p>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{view.type === "error" && (
|
|
<div className="planning-summary">
|
|
<div className="planning-view-scroll planning-summary-scroll">
|
|
{conversationHistory.length > 0 && (
|
|
<>
|
|
<ConversationHistory entries={conversationHistory} defaultShowThinking={true} />
|
|
<div className="conversation-separator" />
|
|
</>
|
|
)}
|
|
<div
|
|
className="ai-error-panel"
|
|
role="alert"
|
|
>
|
|
<div className="ai-error-icon">{WARNING_ICON}</div>
|
|
<div className="ai-error-message">{view.errorMessage}</div>
|
|
<div className="ai-error-actions">
|
|
<button className="btn btn-primary" onClick={() => void handleRetry()} disabled={isRetrying}>
|
|
{isRetrying ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
|
|
<span className="icon-ml-6">{isRetrying ? t("subtasks.retrying", "Retrying...") : t("subtasks.retry", "Retry")}</span>
|
|
</button>
|
|
<button className="btn" onClick={() => void handleClose()} disabled={isRetrying}>{t("common.cancel", "Cancel")}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{(view.type === "editing" || view.type === "creating") && (
|
|
<div className="planning-summary">
|
|
<div className="planning-view-scroll planning-summary-scroll">
|
|
{conversationHistory.length > 0 && (
|
|
<>
|
|
<ConversationHistory entries={conversationHistory} />
|
|
<div className="conversation-separator" />
|
|
</>
|
|
)}
|
|
|
|
<div className="planning-summary-header">
|
|
<CheckCircle size={24} className="icon-success" />
|
|
<h4>{t("subtasks.reviewSubtasksHeading", "Review your subtasks")}</h4>
|
|
<p className="text-muted">{t("subtasks.reviewSubtasksHint", "Edit titles, descriptions, sizes, and dependencies before creating all tasks at once.")}</p>
|
|
</div>
|
|
|
|
<div className="planning-summary-form">
|
|
<div className="task-detail-section">
|
|
<div className="form-group">
|
|
<label>{t("subtasks.branchStrategyLabel", "Branch strategy")}</label>
|
|
<select value={branchMode} onChange={(event) => setBranchMode(event.target.value as typeof branchMode)} disabled={view.type === "creating"}>
|
|
<option value="project-default">{t("subtasks.branchModeProjectDefault", "Use project/default branch")}</option>
|
|
<option value="auto-new">{t("subtasks.branchModeAutoNew", "Create auto-named branch per task")}</option>
|
|
<option value="existing">{t("subtasks.branchModeExisting", "Use existing branch")}</option>
|
|
<option value="custom-new">{t("subtasks.branchModeCustomNew", "Create custom new branch")}</option>
|
|
</select>
|
|
</div>
|
|
{(branchMode === "existing" || branchMode === "custom-new") && (
|
|
<div className="form-group">
|
|
<label>{t("subtasks.branchNameLabel", "Branch name")}</label>
|
|
<input value={branchName} onChange={(event) => setBranchName(event.target.value)} disabled={view.type === "creating"} />
|
|
</div>
|
|
)}
|
|
<div className="form-group">
|
|
<label>{t("subtasks.baseBranchLabel", "Merge target / base branch (optional)")}</label>
|
|
<input value={baseBranch} onChange={(event) => setBaseBranch(event.target.value)} disabled={view.type === "creating"} placeholder={t("subtasks.baseBranchPlaceholder", "main")} />
|
|
</div>
|
|
<div className="form-group">
|
|
<label>{t("subtasks.planningBranchModeLabel", "Planning branch mode")}</label>
|
|
<select value={branchAssignmentMode} onChange={(event) => setBranchAssignmentMode(event.target.value as typeof branchAssignmentMode)} disabled={view.type === "creating"}>
|
|
<option value="shared">{t("subtasks.branchAssignmentShared", "Shared merge target — subtasks run on their own branches")}</option>
|
|
<option value="per-task-derived">{t("subtasks.branchAssignmentPerTask", "Per-task branches derived from planning branch")}</option>
|
|
</select>
|
|
{branchAssignmentMode === "shared" && branchName.trim() && (
|
|
<p className="text-muted">
|
|
{t("subtasks.groupedOnSharedBranch", "Grouped on shared branch")} <strong>{branchName.trim()}</strong>
|
|
{onOpenGroupModal && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm"
|
|
onClick={() => onOpenGroupModal(`BG-${branchName.trim()}`)}
|
|
>
|
|
{t("subtasks.openGroupModal", "Open group modal")}
|
|
</button>
|
|
)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{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={view.type !== "creating"}
|
|
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={t("subtasks.dragToReorder", "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={view.type === "creating" || index === 0}
|
|
title={t("subtasks.moveUp", "Move up")}
|
|
aria-label={t("subtasks.moveSubtaskUpAriaLabel", "Move subtask up")}
|
|
>
|
|
<ArrowUp />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-icon btn-sm"
|
|
onClick={() => moveSubtask(index, index + 1)}
|
|
disabled={view.type === "creating" || index === subtasks.length - 1}
|
|
title={t("subtasks.moveDown", "Move down")}
|
|
aria-label={t("subtasks.moveSubtaskDownAriaLabel", "Move subtask down")}
|
|
>
|
|
<ArrowDown />
|
|
</button>
|
|
<button type="button" className="btn btn-sm" onClick={() => removeSubtask(subtask.id)} disabled={view.type === "creating"}>
|
|
<Trash2 size={14} /> {t("subtasks.remove", "Remove")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label>{t("subtasks.titleLabel", "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();
|
|
moveFocusToNext(index);
|
|
}
|
|
}}
|
|
disabled={view.type === "creating"}
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label>{t("subtasks.descriptionLabel", "Description")}</label>
|
|
<textarea
|
|
rows={8}
|
|
value={subtask.description}
|
|
onChange={(event) => updateSubtask(subtask.id, { description: event.target.value })}
|
|
disabled={view.type === "creating"}
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label>{t("subtasks.sizeLabel", "Size")}</label>
|
|
<select
|
|
className="planning-size-select"
|
|
value={subtask.suggestedSize}
|
|
onChange={(event) => updateSubtask(subtask.id, { suggestedSize: event.target.value as "S" | "M" | "L" })}
|
|
disabled={view.type === "creating"}
|
|
>
|
|
<option value="S">S</option>
|
|
<option value="M">M</option>
|
|
<option value="L">L</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label>{t("subtasks.dependenciesLabel", "Dependencies")}</label>
|
|
<div className="planning-deps-list">
|
|
{/* Only show subtasks that come BEFORE this one in the list (prevents cycles) */}
|
|
{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={view.type === "creating"}
|
|
/>
|
|
<span className="planning-dep-id">{candidate.id}</span>
|
|
<span className="planning-dep-title">{candidate.title || t("subtasks.untitled", "Untitled")}</span>
|
|
</label>
|
|
);
|
|
})}
|
|
{index === 0 && (
|
|
<div className="text-muted">{t("subtasks.noDepsFirstSubtask", "First subtask cannot have dependencies.")}</div>
|
|
)}
|
|
{index > 0 && subtasks.slice(0, index).filter((item) => item.id !== subtask.id).length === 0 && (
|
|
<div className="text-muted">{t("subtasks.noPreviousSubtasks", "No previous subtasks available.")}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
<button type="button" className="btn" onClick={addSubtask} disabled={view.type === "creating"}>
|
|
<Plus size={16} className="icon-mr-6" /> {t("subtasks.addSubtask", "Add subtask")}
|
|
</button>
|
|
|
|
{hasDependencyCycle(subtasks) && (
|
|
<div className="form-error planning-error">{t("subtasks.dependencyCycleError", "Dependencies contain a cycle. Remove circular references before creating tasks.")}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="planning-actions planning-summary-actions">
|
|
<button className="btn" onClick={() => void handleClose()} disabled={view.type === "creating"}>
|
|
{t("common.cancel", "Cancel")}
|
|
</button>
|
|
<button className="btn btn-primary" onClick={() => void handleCreateTasks()} disabled={view.type === "creating" || isInvalid}>
|
|
{view.type === "creating" ? (
|
|
<>
|
|
<Loader2 size={16} className="spin icon-mr-6" />
|
|
{t("subtasks.creating", "Creating...")}
|
|
</>
|
|
) : (
|
|
<>{t("subtasks.createTasks", "Create Tasks")}</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export type { SubtaskBreakdownModalProps };
|