feat(FN-3419): remove inline lock indicator from PlanningModeModal
Removed the inline lock indicator from PlanningModeModal and added a new test file covering the planning flow behavior. Fusion-Task-Id: FN-3419
This commit is contained in:
@@ -1519,7 +1519,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
|
|
||||||
const activeLockInfo = lockSessionId ? activeTabMap.get(lockSessionId) : null;
|
const activeLockInfo = lockSessionId ? activeTabMap.get(lockSessionId) : null;
|
||||||
const activeRemoteTab = activeLockInfo && activeLockInfo.tabId !== sessionTabId;
|
const activeRemoteTab = activeLockInfo && activeLockInfo.tabId !== sessionTabId;
|
||||||
const activeInAnotherTab = Boolean(activeRemoteTab && !activeLockInfo.stale);
|
|
||||||
const allowTakeover = isLockedByOther && (!activeRemoteTab || activeLockInfo.stale);
|
const allowTakeover = isLockedByOther && (!activeRemoteTab || activeLockInfo.stale);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
@@ -1585,11 +1584,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
<div className="planning-detail">
|
<div className="planning-detail">
|
||||||
{error && <div className="form-error planning-error">{error}</div>}
|
{error && <div className="form-error planning-error">{error}</div>}
|
||||||
{isReconnecting && <div className="form-hint text-muted">Reconnecting…</div>}
|
{isReconnecting && <div className="form-hint text-muted">Reconnecting…</div>}
|
||||||
{activeInAnotherTab && (
|
|
||||||
<div className="form-hint text-muted" data-testid="session-active-another-tab-banner">
|
|
||||||
Session is active in another tab.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{view.type === "initial" && (
|
{view.type === "initial" && (
|
||||||
<div className="planning-initial">
|
<div className="planning-initial">
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { TaskDetailModal } from "../TaskDetailModal";
|
|||||||
import { useSessionLock } from "../../hooks/useSessionLock";
|
import { useSessionLock } from "../../hooks/useSessionLock";
|
||||||
import { getSessionTabId } from "../../utils/getSessionTabId";
|
import { getSessionTabId } from "../../utils/getSessionTabId";
|
||||||
import type { MergeResult } from "@fusion/core";
|
import type { MergeResult } from "@fusion/core";
|
||||||
|
const mockUseAiSessionSync = vi.fn();
|
||||||
|
|
||||||
import {
|
import {
|
||||||
mockStartPlanning,
|
mockStartPlanning,
|
||||||
mockStartPlanningStreaming,
|
mockStartPlanningStreaming,
|
||||||
@@ -99,6 +101,10 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({
|
|||||||
useMobileKeyboard: (...args: any[]) => mockUseMobileKeyboard(...args),
|
useMobileKeyboard: (...args: any[]) => mockUseMobileKeyboard(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useAiSessionSync", () => ({
|
||||||
|
useAiSessionSync: (...args: any[]) => mockUseAiSessionSync(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("PlanningModeModal", () => {
|
describe("PlanningModeModal", () => {
|
||||||
const mockOnClose = vi.fn();
|
const mockOnClose = vi.fn();
|
||||||
const mockOnTaskCreated = vi.fn();
|
const mockOnTaskCreated = vi.fn();
|
||||||
@@ -147,6 +153,14 @@ describe("PlanningModeModal", () => {
|
|||||||
mockCancelPlanning.mockResolvedValue(undefined);
|
mockCancelPlanning.mockResolvedValue(undefined);
|
||||||
mockUpdatePlanningSessionDraft.mockResolvedValue({ ok: true });
|
mockUpdatePlanningSessionDraft.mockResolvedValue({ ok: true });
|
||||||
mockStopPlanningGeneration.mockResolvedValue({ success: 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
|
// Default: simulate receiving a question after a brief delay
|
||||||
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
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(
|
||||||
|
<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();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it("allows normal question interaction when lock is acquired", async () => {
|
||||||
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
|
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
|
||||||
|
|
||||||
|
|||||||
@@ -2582,6 +2582,13 @@ export async function aiMergeTask(
|
|||||||
): Promise<MergeResult> {
|
): Promise<MergeResult> {
|
||||||
throwIfAborted(options.signal, taskId);
|
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
|
// Pre-merge guard against the common single-checkout setup where rootDir
|
||||||
// is the developer's working tree. The merge flow below issues several
|
// is the developer's working tree. The merge flow below issues several
|
||||||
// `git reset --hard/--merge` calls and forced checkouts that would
|
// `git reset --hard/--merge` calls and forced checkouts that would
|
||||||
@@ -2591,13 +2598,6 @@ export async function aiMergeTask(
|
|||||||
const autostashRef = await stashUnrelatedRootDirChanges(rootDir, taskId);
|
const autostashRef = await stashUnrelatedRootDirChanges(rootDir, taskId);
|
||||||
try {
|
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 branch = task.branch || `fusion/${taskId.toLowerCase()}`;
|
||||||
const sourceIssueRef = buildSourceIssueRef(task.sourceIssue);
|
const sourceIssueRef = buildSourceIssueRef(task.sourceIssue);
|
||||||
const worktreePath = task.worktree;
|
const worktreePath = task.worktree;
|
||||||
|
|||||||
Reference in New Issue
Block a user