diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 590ba9c29..1f9b53395 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -1519,7 +1519,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const activeLockInfo = lockSessionId ? activeTabMap.get(lockSessionId) : null; const activeRemoteTab = activeLockInfo && activeLockInfo.tabId !== sessionTabId; - const activeInAnotherTab = Boolean(activeRemoteTab && !activeLockInfo.stale); const allowTakeover = isLockedByOther && (!activeRemoteTab || activeLockInfo.stale); if (!isOpen) return null; @@ -1585,11 +1584,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
{error &&
{error}
} {isReconnecting &&
Reconnecting…
} - {activeInAnotherTab && ( -
- Session is active in another tab. -
- )} {view.type === "initial" && (
diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx index 7493f870e..a7ded719e 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -6,6 +6,8 @@ import { TaskDetailModal } from "../TaskDetailModal"; import { useSessionLock } from "../../hooks/useSessionLock"; import { getSessionTabId } from "../../utils/getSessionTabId"; import type { MergeResult } from "@fusion/core"; +const mockUseAiSessionSync = vi.fn(); + import { mockStartPlanning, mockStartPlanningStreaming, @@ -99,6 +101,10 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: (...args: any[]) => mockUseMobileKeyboard(...args), })); +vi.mock("../../hooks/useAiSessionSync", () => ({ + useAiSessionSync: (...args: any[]) => mockUseAiSessionSync(...args), +})); + describe("PlanningModeModal", () => { const mockOnClose = vi.fn(); const mockOnTaskCreated = vi.fn(); @@ -147,6 +153,14 @@ describe("PlanningModeModal", () => { mockCancelPlanning.mockResolvedValue(undefined); mockUpdatePlanningSessionDraft.mockResolvedValue({ ok: true }); mockStopPlanningGeneration.mockResolvedValue({ success: true }); + mockUseAiSessionSync.mockReturnValue({ + activeTabMap: new Map(), + broadcastUpdate: vi.fn(), + broadcastCompleted: vi.fn(), + broadcastLock: vi.fn(), + broadcastUnlock: vi.fn(), + broadcastHeartbeat: vi.fn(), + }); // Default: simulate receiving a question after a brief delay mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => { @@ -228,6 +242,50 @@ describe("PlanningModeModal", () => { }); }); + it("does not render duplicate inline lock text while takeover overlay handles lock state", async () => { + window.sessionStorage.setItem("fusion-tab-id", "tab-self"); + mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" }); + mockUseAiSessionSync.mockReturnValueOnce({ + activeTabMap: new Map([ + [ + "session-123", + { + tabId: "tab-other", + stale: false, + }, + ], + ]), + broadcastUpdate: vi.fn(), + broadcastCompleted: vi.fn(), + broadcastLock: vi.fn(), + broadcastUnlock: vi.fn(), + broadcastHeartbeat: vi.fn(), + }); + + render( + , + ); + + 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(); + }); + + expect(screen.getByText("This session is active in another tab")).toBeDefined(); + expect(screen.queryByText("Session is active in another tab.")).toBeNull(); + expect(screen.getByRole("button", { name: "Take Control" })).toBeDefined(); + }); + it("allows normal question interaction when lock is acquired", async () => { window.sessionStorage.setItem("fusion-tab-id", "tab-self"); diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index a6f17ec7b..5a953ce72 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -2582,6 +2582,13 @@ export async function aiMergeTask( ): Promise { throwIfAborted(options.signal, taskId); + // 1. Validate task state + const task = await store.getTask(taskId); + const mergeBlocker = getTaskMergeBlocker(task); + if (mergeBlocker) { + throw new Error(`Cannot merge ${taskId}: ${mergeBlocker}`); + } + // Pre-merge guard against the common single-checkout setup where rootDir // is the developer's working tree. The merge flow below issues several // `git reset --hard/--merge` calls and forced checkouts that would @@ -2591,13 +2598,6 @@ export async function aiMergeTask( const autostashRef = await stashUnrelatedRootDirChanges(rootDir, taskId); try { - // 1. Validate task state - const task = await store.getTask(taskId); - const mergeBlocker = getTaskMergeBlocker(task); - if (mergeBlocker) { - throw new Error(`Cannot merge ${taskId}: ${mergeBlocker}`); - } - const branch = task.branch || `fusion/${taskId.toLowerCase()}`; const sourceIssueRef = buildSourceIssueRef(task.sourceIssue); const worktreePath = task.worktree;