Files
José Almeida 3e676e3bb2 feat(web): double-click tab to toggle maximize (#823)
* feat(web): double-click tab to toggle maximize

Adds a dblclick gesture on dockview tabs that toggles maximize/restore on
the containing group, mirroring the existing maximize button. Preview tabs
keep their promote-to-pin shortcut: first dblclick pins, then dblclick
maximizes. Sidebar group is excluded.

* fix(web): add cursor-pointer to tab wrappers for consistency

* fix(web): add cursor-pointer to plan-tab wrapper

* fix(web): make tab dblclick maximize resilient to layout rebuild

Refactor the dblclick-to-maximize handler so it reads the panel's group ID
and store state at call time instead of from a render-time closure, and
guards against firing while the dockview store is mid-layout-restore.

This addresses oscillation when double-clicking a session tab on a task
that has multiple sessions: the maximize call now stops event propagation,
respects isRestoringLayout, and always uses the live api.group.id, so it
remains correct even after dockview rebuilds groups via fromJSON.
2026-05-05 13:55:46 +01:00

155 lines
4.8 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render } from "@testing-library/react";
import type { TaskPlan } from "@/lib/types/http";
const mockMarkTaskPlanSeen = vi.fn();
let mockActiveTaskId: string | null = "task-1";
let mockPlan: TaskPlan | null = null;
let mockLastSeen: string | undefined = undefined;
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
tasks: { activeTaskId: mockActiveTaskId },
taskPlans: {
byTaskId: mockActiveTaskId && mockPlan ? { [mockActiveTaskId]: mockPlan } : {},
lastSeenUpdatedAtByTaskId:
mockActiveTaskId && mockLastSeen !== undefined
? { [mockActiveTaskId]: mockLastSeen }
: {},
},
markTaskPlanSeen: mockMarkTaskPlanSeen,
}),
}));
vi.mock("dockview-react", () => ({
DockviewDefaultTab: () => null,
}));
const mockDockviewState = {
preMaximizeLayout: null,
sidebarGroupId: "sidebar-group",
isRestoringLayout: false,
maximizeGroup: vi.fn(),
exitMaximizedLayout: vi.fn(),
};
vi.mock("@/lib/state/dockview-store", () => ({
useDockviewStore: Object.assign(
(selector: (state: typeof mockDockviewState) => unknown) => selector(mockDockviewState),
{ getState: () => mockDockviewState },
),
}));
import { PlanTab } from "./plan-tab";
const TS = "2026-04-20T00:00:00Z";
const TS_LATER = "2026-04-20T01:00:00Z";
function agentPlan(updated_at = TS): TaskPlan {
return {
id: "plan-1",
task_id: "task-1",
title: "Plan",
content: "# Plan",
created_by: "agent",
created_at: TS,
updated_at,
};
}
type TabApi = {
isActive: boolean;
group: { id: string };
onDidActiveChange: (listener: (e: { isActive: boolean }) => void) => { dispose: () => void };
};
function makeApi(isActive: boolean): TabApi {
return {
isActive,
group: { id: "group-a" },
onDidActiveChange: () => ({ dispose: () => {} }),
};
}
describe("PlanTab", () => {
beforeEach(() => {
vi.clearAllMocks();
mockActiveTaskId = "task-1";
mockPlan = agentPlan();
mockLastSeen = undefined;
});
it("renders the indicator when an agent-authored plan is unseen", () => {
const { container } = render(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<PlanTab {...({ api: makeApi(false) } as any)} />,
);
expect(container.querySelector('[data-testid="plan-tab-indicator"]')).toBeTruthy();
});
it("does not render the indicator when the plan is user-authored", () => {
mockPlan = { ...agentPlan(), created_by: "user" };
const { container } = render(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<PlanTab {...({ api: makeApi(false) } as any)} />,
);
expect(container.querySelector('[data-testid="plan-tab-indicator"]')).toBeNull();
});
it("does not render the indicator when lastSeen matches plan.updated_at", () => {
mockLastSeen = TS;
const { container } = render(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<PlanTab {...({ api: makeApi(false) } as any)} />,
);
expect(container.querySelector('[data-testid="plan-tab-indicator"]')).toBeNull();
});
it("marks seen on initial render when api.isActive is true", () => {
render(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<PlanTab {...({ api: makeApi(true) } as any)} />,
);
expect(mockMarkTaskPlanSeen).toHaveBeenCalledWith("task-1");
});
it("marks seen synchronously when an update lands while the tab is already active", () => {
// Initial render with the tab active and plan already seen
mockLastSeen = TS;
const { rerender } = render(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<PlanTab {...({ api: makeApi(true) } as any)} />,
);
mockMarkTaskPlanSeen.mockClear();
// Plan updates while the tab is still active — re-render with new updated_at.
mockPlan = agentPlan(TS_LATER);
rerender(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<PlanTab {...({ api: makeApi(true) } as any)} />,
);
// useLayoutEffect must have fired markTaskPlanSeen — keeps the dot from flashing.
expect(mockMarkTaskPlanSeen).toHaveBeenCalledWith("task-1");
});
it("does not mark seen on update when the tab is not active", () => {
mockLastSeen = TS;
const { rerender } = render(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<PlanTab {...({ api: makeApi(false) } as any)} />,
);
mockMarkTaskPlanSeen.mockClear();
mockPlan = agentPlan(TS_LATER);
rerender(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<PlanTab {...({ api: makeApi(false) } as any)} />,
);
expect(mockMarkTaskPlanSeen).not.toHaveBeenCalled();
});
});