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;