feat(FN-1153): add cross-tab locks for AI planning sessions

- Bump SQLite schema to v19 with ai_sessions lock columns and lock index, and align migration coverage in core DB tests
- Extend AiSessionStore with acquire/release/force lock APIs, stale lock cleanup, and lock metadata in ai_session update summaries
- Enforce lock checks on planning, subtask, and mission interview mutation routes with 409 conflict responses while keeping stream reads unaffected
- Add frontend tab identity + useSessionLock hook and wire Planning, Subtask, and Mission modals to pass tabId, show lock overlay, and support Take Control
- Expand dashboard route/e2e and modal tests to validate lock enforcement, lock handoff, and lock-aware session reentry behavior
This commit is contained in:
gsxdsm
2026-04-08 16:14:32 -07:00
parent 55e8b6fd94
commit 02148d79b6
23 changed files with 1540 additions and 58 deletions

View File

@@ -10,6 +10,9 @@ const mockCreateMissionFromInterview = vi.fn();
const mockConnectMissionInterviewStream = vi.fn();
const mockFetchAiSession = vi.fn();
const mockParseConversationHistory = vi.fn();
const mockAcquireSessionLock = vi.fn();
const mockReleaseSessionLock = vi.fn();
const mockForceAcquireSessionLock = vi.fn();
vi.mock("../api", () => ({
startMissionInterview: (...args: any[]) => mockStartMissionInterview(...args),
@@ -20,6 +23,9 @@ vi.mock("../api", () => ({
connectMissionInterviewStream: (...args: any[]) => mockConnectMissionInterviewStream(...args),
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
parseConversationHistory: (...args: any[]) => mockParseConversationHistory(...args),
acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args),
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
}));
vi.mock("../hooks/modalPersistence", () => ({
@@ -65,6 +71,9 @@ describe("MissionInterviewModal", () => {
isConnected: vi.fn().mockReturnValue(true),
};
});
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockReleaseSessionLock.mockResolvedValue(undefined);
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
});
function renderModal() {
@@ -77,6 +86,32 @@ describe("MissionInterviewModal", () => {
);
}
it("shows lock overlay and allows take-control", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" });
renderModal();
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
target: { value: "Build a mission planning workflow" },
});
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByTestId("session-lock-overlay")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Take Control"));
await waitFor(() => {
expect(mockForceAcquireSessionLock).toHaveBeenCalledWith("mission-session-1", "tab-self");
});
await waitFor(() => {
expect(screen.queryByTestId("session-lock-overlay")).not.toBeInTheDocument();
});
});
it("shows reconnecting indicator without clearing current question", async () => {
renderModal();
@@ -189,7 +224,7 @@ describe("MissionInterviewModal", () => {
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(mockRetryMissionInterviewSession).toHaveBeenCalledWith("mission-session-1", undefined);
expect(mockRetryMissionInterviewSession).toHaveBeenCalledWith("mission-session-1", undefined, expect.any(String));
});
await waitFor(() => {
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import type { PlanningQuestion } from "@fusion/core";
import {
startMissionInterview,
@@ -38,8 +38,11 @@ import {
Trash2,
Minimize2,
RefreshCw,
Lock,
} from "lucide-react";
import { ConversationHistory } from "./ConversationHistory";
import { useSessionLock } from "../hooks/useSessionLock";
import { getSessionTabId } from "../utils/getSessionTabId";
interface MissionInterviewModalProps {
isOpen: boolean;
@@ -92,6 +95,13 @@ export function MissionInterviewModal({
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 connectToMissionInterviewStream = useCallback(
(sessionId: string) => {
@@ -157,6 +167,7 @@ export function MissionInterviewModal({
try {
const { sessionId } = await startMissionInterview(goal.trim(), projectId);
currentSessionIdRef.current = sessionId;
setLockSessionId(sessionId);
clearMissionGoal(projectId);
connectToMissionInterviewStream(sessionId);
@@ -166,6 +177,7 @@ export function MissionInterviewModal({
setError(err.message || "Failed to start interview session");
setView({ type: "initial" });
currentSessionIdRef.current = null;
setLockSessionId(null);
}
},
[connectToMissionInterviewStream, missionGoal, projectId]
@@ -201,6 +213,7 @@ export function MissionInterviewModal({
hasAutoStartedRef.current = false;
setIsReconnecting(false);
setIsRetrying(false);
setLockSessionId(null);
}
}, [isOpen]);
@@ -215,6 +228,7 @@ export function MissionInterviewModal({
const parsedHistory = parseConversationHistory(session.conversationHistory);
setConversationHistory(parsedHistory);
setLockSessionId(session.id);
setResponseHistory(
parsedHistory
.map((entry) => entry.response)
@@ -318,7 +332,7 @@ export function MissionInterviewModal({
if (view.type === "question" || view.type === "summary" || view.type === "error") {
try {
await cancelMissionInterview(view.sessionId, projectId);
await cancelMissionInterview(view.sessionId, projectId, sessionTabId);
} catch {
// Ignore errors on cancel
}
@@ -336,8 +350,9 @@ export function MissionInterviewModal({
setHasProgress(false);
setIsCreating(false);
currentSessionIdRef.current = null;
setLockSessionId(null);
onClose();
}, [missionGoal, hasProgress, view, onClose, projectId]);
}, [missionGoal, hasProgress, view, onClose, projectId, sessionTabId]);
// Escape key handler
useEffect(() => {
@@ -377,14 +392,14 @@ export function MissionInterviewModal({
setStreamingOutput("");
try {
await respondToMissionInterview(sessionId, responses, projectId);
await respondToMissionInterview(sessionId, responses, projectId, sessionTabId);
setHasProgress(true);
} catch (err: any) {
setError(err.message || "Failed to submit response");
setView({ type: "question", sessionId, question: view.question });
}
},
[view, projectId]
[view, projectId, sessionTabId]
);
const handleRetryFromError = useCallback(async () => {
@@ -401,7 +416,8 @@ export function MissionInterviewModal({
try {
currentSessionIdRef.current = retrySessionId;
await retryMissionInterviewSession(retrySessionId, projectId);
setLockSessionId(retrySessionId);
await retryMissionInterviewSession(retrySessionId, projectId, sessionTabId);
} catch (err: any) {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
@@ -414,7 +430,7 @@ export function MissionInterviewModal({
} finally {
setIsRetrying(false);
}
}, [connectToMissionInterviewStream, projectId, view]);
}, [connectToMissionInterviewStream, projectId, sessionTabId, view]);
const handleApprovePlan = useCallback(async () => {
if (view.type !== "summary") return;
@@ -441,6 +457,7 @@ export function MissionInterviewModal({
setHasProgress(false);
setIsCreating(false);
currentSessionIdRef.current = null;
setLockSessionId(null);
onClose();
} catch (err: any) {
setError(err.message || "Failed to create mission");
@@ -628,12 +645,32 @@ export function MissionInterviewModal({
setEditedSummary(null);
setResponseHistory([]);
setConversationHistory([]);
setLockSessionId(null);
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
}}
isCreating={isCreating}
/>
)}
{isLockedByOther && (
<div className="session-lock-overlay" data-testid="session-lock-overlay">
<div className="session-lock-banner">
<Lock size={16} />
<span>This session is active in another tab</span>
<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>

View File

@@ -1,7 +1,9 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { act, render, renderHook, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { PlanningModeModal } from "./PlanningModeModal";
import { TaskDetailModal } from "./TaskDetailModal";
import { useSessionLock } from "../hooks/useSessionLock";
import { getSessionTabId } from "../utils/getSessionTabId";
import type { Task, TaskDetail, PlanningQuestion, PlanningSummary, MergeResult } from "@fusion/core";
// Mock the API functions
@@ -17,6 +19,9 @@ const mockCreateTasksFromPlanning = vi.fn();
const mockFetchAiSession = vi.fn();
const mockParseConversationHistory = vi.fn();
const mockFetchModels = vi.fn();
const mockAcquireSessionLock = vi.fn();
const mockReleaseSessionLock = vi.fn();
const mockForceAcquireSessionLock = vi.fn();
const mockUploadAttachment = vi.fn();
const mockDeleteAttachment = vi.fn();
const mockUpdateTask = vi.fn();
@@ -40,6 +45,9 @@ vi.mock("../api", () => ({
createTasksFromPlanning: (...args: any[]) => mockCreateTasksFromPlanning(...args),
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
parseConversationHistory: (...args: any[]) => mockParseConversationHistory(...args),
acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args),
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
uploadAttachment: (...args: any[]) => mockUploadAttachment(...args),
deleteAttachment: (...args: any[]) => mockDeleteAttachment(...args),
updateTask: (...args: any[]) => mockUpdateTask(...args),
@@ -126,12 +134,55 @@ const mockTaskDetail = {
paused: false,
} as TaskDetail;
class MockEventSource {
static instances: MockEventSource[] = [];
url: string;
closed = false;
private listeners = new Map<string, Set<(event: MessageEvent) => void>>();
constructor(url: string) {
this.url = url;
MockEventSource.instances.push(this);
}
addEventListener(event: string, listener: (event: MessageEvent) => void): void {
const set = this.listeners.get(event) ?? new Set();
set.add(listener);
this.listeners.set(event, set);
}
removeEventListener(event: string, listener: (event: MessageEvent) => void): void {
const set = this.listeners.get(event);
if (!set) return;
set.delete(listener);
}
close(): void {
this.closed = true;
}
emit(event: string, data: unknown): void {
const listeners = this.listeners.get(event);
if (!listeners) return;
const message = { data: JSON.stringify(data) } as MessageEvent;
listeners.forEach((listener) => listener(message));
}
static reset(): void {
MockEventSource.instances = [];
}
}
describe("PlanningModeModal", () => {
const mockOnClose = vi.fn();
const mockOnTaskCreated = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
MockEventSource.reset();
vi.stubGlobal("EventSource", MockEventSource as any);
window.sessionStorage.clear();
vi.spyOn(window, "confirm").mockReturnValue(true);
// Default mock for streaming
@@ -153,6 +204,9 @@ describe("PlanningModeModal", () => {
favoriteProviders: [],
favoriteModels: [],
});
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockReleaseSessionLock.mockResolvedValue(undefined);
mockForceAcquireSessionLock.mockResolvedValue(undefined);
// Default: simulate receiving a question after a brief delay
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
@@ -401,6 +455,80 @@ describe("PlanningModeModal", () => {
});
});
it("shows locked overlay and allows take-control", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" });
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByTestId("session-lock-overlay")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByText("Take Control"));
});
await waitFor(() => {
expect(mockForceAcquireSessionLock).toHaveBeenCalledWith("session-123", "tab-self");
});
await waitFor(() => {
expect(screen.queryByTestId("session-lock-overlay")).toBeNull();
});
});
it("allows normal question interaction when lock is acquired", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.queryByTestId("session-lock-overlay")).toBeNull();
});
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
fireEvent.click(screen.getByText("Small"));
fireEvent.click(screen.getByText("Continue"));
await waitFor(() => {
expect(mockRespondToPlanning).toHaveBeenCalledWith(
"session-123",
{ "q-scope": "small" },
undefined,
"tab-self",
);
});
});
it("shows error message when planning fails", async () => {
// Override the default mock to simulate an error
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
@@ -472,7 +600,7 @@ describe("PlanningModeModal", () => {
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-123", undefined);
expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-123", undefined, expect.any(String));
});
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
@@ -1599,3 +1727,118 @@ describe("PlanningModeModal", () => {
});
});
});
describe("getSessionTabId", () => {
it("creates and persists a per-tab id in sessionStorage", () => {
window.sessionStorage.clear();
const first = getSessionTabId();
const second = getSessionTabId();
expect(first).toBeTruthy();
expect(second).toBe(first);
expect(window.sessionStorage.getItem("fusion-tab-id")).toBe(first);
});
});
describe("useSessionLock", () => {
beforeEach(() => {
MockEventSource.reset();
vi.stubGlobal("EventSource", MockEventSource as any);
window.sessionStorage.clear();
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockReleaseSessionLock.mockResolvedValue(undefined);
mockForceAcquireSessionLock.mockResolvedValue(undefined);
});
it("acquires on mount and releases on unmount", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
const { unmount } = renderHook(() => useSessionLock("session-1"));
await waitFor(() => {
expect(mockAcquireSessionLock).toHaveBeenCalledWith("session-1", "tab-self");
});
unmount();
await waitFor(() => {
expect(mockReleaseSessionLock).toHaveBeenCalledWith("session-1", "tab-self");
});
});
it("exposes locked state and allows taking control", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" });
const { result } = renderHook(() => useSessionLock("session-2"));
await waitFor(() => {
expect(result.current.isLockedByOther).toBe(true);
expect(result.current.currentHolder).toBe("tab-other");
});
await act(async () => {
await result.current.takeControl();
});
expect(mockForceAcquireSessionLock).toHaveBeenCalledWith("session-2", "tab-self");
expect(result.current.isLockedByOther).toBe(false);
expect(result.current.currentHolder).toBeNull();
});
it("updates lock state from ai_session:updated SSE events and uses sendBeacon on beforeunload", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
const sendBeaconSpy = vi.fn(() => true);
vi.stubGlobal("navigator", {
...window.navigator,
sendBeacon: sendBeaconSpy,
} as Navigator);
const { result } = renderHook(() => useSessionLock("session-3"));
await waitFor(() => {
expect(mockAcquireSessionLock).toHaveBeenCalledWith("session-3", "tab-self");
});
const source = MockEventSource.instances[0];
expect(source).toBeDefined();
act(() => {
source?.emit("ai_session:updated", {
id: "session-3",
type: "planning",
status: "awaiting_input",
title: "Session",
projectId: null,
lockedByTab: "tab-other",
updatedAt: new Date().toISOString(),
});
});
expect(result.current.isLockedByOther).toBe(true);
expect(result.current.currentHolder).toBe("tab-other");
act(() => {
source?.emit("ai_session:updated", {
id: "session-3",
type: "planning",
status: "awaiting_input",
title: "Session",
projectId: null,
lockedByTab: "tab-self",
updatedAt: new Date().toISOString(),
});
});
expect(result.current.isLockedByOther).toBe(false);
act(() => {
window.dispatchEvent(new Event("beforeunload"));
});
expect(sendBeaconSpy).toHaveBeenCalledWith(
"/api/ai-sessions/session-3/lock/beacon?tabId=tab-self",
);
});
});

View File

@@ -21,9 +21,11 @@ import {
getPlanningDescription,
clearPlanningDescription,
} from "../hooks/modalPersistence";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, Minimize2, RefreshCw } from "lucide-react";
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 { getSessionTabId } from "../utils/getSessionTabId";
interface PlanningModeModalProps {
isOpen: boolean;
@@ -96,6 +98,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
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 [planningModelProvider, setPlanningModelProvider] = useState<string | undefined>(undefined);
const [planningModelId, setPlanningModelId] = useState<string | undefined>(undefined);
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>([]);
@@ -225,6 +234,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const { sessionId } = await startPlanningStreaming(plan.trim(), projectId, modelOverride);
currentSessionIdRef.current = sessionId;
setLockSessionId(sessionId);
connectToPlanningStream(sessionId);
setResponseHistory([]);
@@ -233,6 +243,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setError(err.message || "Failed to start planning session");
setView({ type: "initial" });
currentSessionIdRef.current = null;
setLockSessionId(null);
}
}, [connectToPlanningStream, initialPlan, planningModelId, planningModelProvider, projectId]);
@@ -280,6 +291,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
if (cancelled || !session) return;
currentSessionIdRef.current = resumeSessionId;
setLockSessionId(resumeSessionId);
const parsedHistory = parseConversationHistory(session.conversationHistory);
setConversationHistory(parsedHistory);
setResponseHistory(
@@ -325,6 +337,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
hasAutoStartedRef.current = false;
setIsReconnecting(false);
setIsRetrying(false);
setLockSessionId(null);
}
}, [isOpen]);
@@ -378,6 +391,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setPlanningModelProvider(undefined);
setPlanningModelId(undefined);
currentSessionIdRef.current = null;
setLockSessionId(null);
onClose();
}, [initialPlan, onClose, projectId]);
@@ -428,14 +442,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
try {
// Submit response - AI will broadcast events via the already-connected stream
await respondToPlanning(sessionId, responses, projectId);
await respondToPlanning(sessionId, responses, projectId, sessionTabId);
// Events (question/summary) will arrive via the existing SSE stream
} catch (err: any) {
setError(err.message || "Failed to submit response");
setView({ type: "question", session });
}
},
[projectId, view]
[projectId, sessionTabId, view]
);
const handleRetryFromError = useCallback(async () => {
@@ -453,7 +467,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
try {
currentSessionIdRef.current = retryTarget.sessionId;
await retryPlanningSession(retryTarget.sessionId, projectId);
setLockSessionId(retryTarget.sessionId);
await retryPlanningSession(retryTarget.sessionId, projectId, sessionTabId);
} catch (err: any) {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
@@ -466,7 +481,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
} finally {
setIsRetrying(false);
}
}, [connectToPlanningStream, projectId, view]);
}, [connectToPlanningStream, projectId, sessionTabId, view]);
const handleCreateTask = useCallback(async () => {
if (view.type !== "summary") return;
@@ -492,6 +507,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
try {
const result = await startPlanningBreakdown(view.session.sessionId, projectId);
setLockSessionId(result.sessionId);
setView({
type: "breakdown",
sessionId: result.sessionId,
@@ -524,6 +540,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setPlanningModelProvider(undefined);
setPlanningModelId(undefined);
currentSessionIdRef.current = null;
setLockSessionId(null);
onClose();
} catch (err: any) {
setError(err.message || "Failed to create tasks");
@@ -826,6 +843,25 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}}
/>
)}
{isLockedByOther && (
<div className="session-lock-overlay" data-testid="session-lock-overlay">
<div className="session-lock-banner">
<Lock size={16} />
<span>This session is active in another tab</span>
<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>

View File

@@ -7,6 +7,9 @@ const mockRetrySubtaskSession = vi.fn();
const mockConnectSubtaskStream = vi.fn();
const mockCreateTasksFromBreakdown = vi.fn();
const mockCancelSubtaskBreakdown = vi.fn();
const mockAcquireSessionLock = vi.fn();
const mockReleaseSessionLock = vi.fn();
const mockForceAcquireSessionLock = vi.fn();
vi.mock("../api", () => ({
startSubtaskBreakdown: (...args: any[]) => mockStartSubtaskBreakdown(...args),
@@ -14,6 +17,9 @@ vi.mock("../api", () => ({
connectSubtaskStream: (...args: any[]) => mockConnectSubtaskStream(...args),
createTasksFromBreakdown: (...args: any[]) => mockCreateTasksFromBreakdown(...args),
cancelSubtaskBreakdown: (...args: any[]) => mockCancelSubtaskBreakdown(...args),
acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args),
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
}));
vi.mock("../hooks/modalPersistence", () => ({
@@ -49,6 +55,9 @@ describe("SubtaskBreakdownModal", () => {
});
mockCreateTasksFromBreakdown.mockResolvedValue({ tasks: [{ id: "FN-101" }, { id: "FN-102" }] });
mockCancelSubtaskBreakdown.mockResolvedValue(undefined);
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockReleaseSessionLock.mockResolvedValue(undefined);
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
vi.stubGlobal("confirm", vi.fn(() => true));
});
@@ -73,6 +82,27 @@ describe("SubtaskBreakdownModal", () => {
expect(await screen.findByText("AI is generating subtasks...")).toBeInTheDocument();
});
it("shows lock overlay and allows take-control", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" });
renderModal();
await waitFor(() => {
expect(screen.getByTestId("session-lock-overlay")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Take Control"));
await waitFor(() => {
expect(mockForceAcquireSessionLock).toHaveBeenCalledWith("session-123", "tab-self");
});
await waitFor(() => {
expect(screen.queryByTestId("session-lock-overlay")).not.toBeInTheDocument();
});
});
it("hides send to background button in initial state", () => {
render(
<SubtaskBreakdownModal
@@ -495,7 +525,7 @@ describe("SubtaskBreakdownModal", () => {
fireEvent.click(retryButton);
await waitFor(() => {
expect(mockRetrySubtaskSession).toHaveBeenCalledWith("session-123", undefined);
expect(mockRetrySubtaskSession).toHaveBeenCalledWith("session-123", undefined, expect.any(String));
});
expect(mockConnectSubtaskStream).toHaveBeenCalledTimes(2);
});

View File

@@ -16,8 +16,10 @@ import {
getSubtaskDescription,
clearSubtaskDescription,
} from "../hooks/modalPersistence";
import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown, Minimize2, RefreshCw } from "lucide-react";
import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown, Minimize2, RefreshCw, Lock } from "lucide-react";
import { ConversationHistory } from "./ConversationHistory";
import { useSessionLock } from "../hooks/useSessionLock";
import { getSessionTabId } from "../utils/getSessionTabId";
interface SubtaskBreakdownModalProps {
isOpen: boolean;
@@ -91,6 +93,12 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
const sessionId = view.type === "generating" || view.type === "editing" || view.type === "creating" || view.type === "error"
? view.sessionId
: null;
const sessionTabId = useMemo(() => getSessionTabId(), []);
const {
isLockedByOther,
takeControl,
isLoading: isLockLoading,
} = useSessionLock(isOpen ? sessionId : null);
const isInvalid = useMemo(() => {
if (subtasks.length === 0) return true;
@@ -131,14 +139,14 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
}
if (sessionId) {
try {
await cancelSubtaskBreakdown(sessionId, projectId);
await cancelSubtaskBreakdown(sessionId, projectId, sessionTabId);
} catch {
// ignore cancel errors
}
}
resetState();
onClose();
}, [dirty, onClose, resetState, sessionId, view.type, projectId]);
}, [dirty, onClose, resetState, sessionId, sessionTabId, view.type, projectId]);
const connectToSubtaskStream = useCallback(
(activeSessionId: string) => {
@@ -389,7 +397,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
connectToSubtaskStream(retrySessionId);
try {
await retrySubtaskSession(retrySessionId, projectId);
await retrySubtaskSession(retrySessionId, projectId, sessionTabId);
} catch (err: any) {
streamRef.current?.close();
streamRef.current = null;
@@ -402,7 +410,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
} finally {
setIsRetrying(false);
}
}, [connectToSubtaskStream, projectId, view]);
}, [connectToSubtaskStream, projectId, sessionTabId, view]);
if (!isOpen) return null;
@@ -678,6 +686,25 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
</div>
</div>
)}
{isLockedByOther && (
<div className="session-lock-overlay" data-testid="session-lock-overlay">
<div className="session-lock-banner">
<Lock size={16} />
<span>This session is active in another tab</span>
<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>

View File

@@ -14,6 +14,9 @@ vi.mock("../../api", () => ({
connectMissionInterviewStream: vi.fn(),
fetchAiSession: vi.fn(),
parseConversationHistory: vi.fn(),
acquireSessionLock: vi.fn(),
releaseSessionLock: vi.fn(),
forceAcquireSessionLock: vi.fn(),
}));
vi.mock("../../hooks/modalPersistence", () => ({
@@ -29,6 +32,9 @@ const mockCreateMissionFromInterview = vi.mocked(api.createMissionFromInterview)
const mockConnectMissionInterviewStream = vi.mocked(api.connectMissionInterviewStream);
const mockFetchAiSession = vi.mocked(api.fetchAiSession);
const mockParseConversationHistory = vi.mocked(api.parseConversationHistory);
const mockAcquireSessionLock = vi.mocked(api.acquireSessionLock);
const mockReleaseSessionLock = vi.mocked(api.releaseSessionLock);
const mockForceAcquireSessionLock = vi.mocked(api.forceAcquireSessionLock);
const mockGetMissionGoal = vi.mocked(modalPersistence.getMissionGoal);
const sampleQuestionSingle: PlanningQuestion = {
@@ -96,6 +102,9 @@ describe("MissionInterviewModal", () => {
}
});
mockGetMissionGoal.mockReturnValue("");
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockReleaseSessionLock.mockResolvedValue(undefined);
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
streamHandlers = handlers;
@@ -236,6 +245,7 @@ describe("MissionInterviewModal", () => {
"mission-session-1",
{ scope: "mvp" },
undefined,
expect.any(String),
);
});
});
@@ -367,7 +377,7 @@ describe("MissionInterviewModal", () => {
await user.click(await screen.findByLabelText("Close"));
await waitFor(() => {
expect(mockCancelMissionInterview).toHaveBeenCalledWith("mission-session-1", undefined);
expect(mockCancelMissionInterview).toHaveBeenCalledWith("mission-session-1", undefined, expect.any(String));
});
});

View File

@@ -51,6 +51,9 @@ const {
mockConnectMissionInterviewStream,
mockCancelMissionInterview,
mockCreateMissionFromInterview,
mockAcquireSessionLock,
mockReleaseSessionLock,
mockForceAcquireSessionLock,
} = vi.hoisted(() => ({
mockStartPlanningStreaming: vi.fn(),
mockConnectPlanningStream: vi.fn(),
@@ -65,6 +68,9 @@ const {
mockConnectMissionInterviewStream: vi.fn(),
mockCancelMissionInterview: vi.fn(),
mockCreateMissionFromInterview: vi.fn(),
mockAcquireSessionLock: vi.fn(),
mockReleaseSessionLock: vi.fn(),
mockForceAcquireSessionLock: vi.fn(),
}));
vi.mock("../../api", () => ({
@@ -81,6 +87,9 @@ vi.mock("../../api", () => ({
connectMissionInterviewStream: (...args: any[]) => mockConnectMissionInterviewStream(...args),
cancelMissionInterview: (...args: any[]) => mockCancelMissionInterview(...args),
createMissionFromInterview: (...args: any[]) => mockCreateMissionFromInterview(...args),
acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args),
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
@@ -131,6 +140,9 @@ describe("ModalReentry", () => {
slices: [],
features: [],
});
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockReleaseSessionLock.mockResolvedValue(undefined);
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
vi.stubGlobal("confirm", vi.fn(() => true));
});