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:
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user