Files
kandev/apps/web/components/task/dockview-minimize-sync.test.ts
T
João Salavisa 7771155c8f feat(task): add minimize button to dockview group header (#1039)
* feat(task): add per-group minimize state to dockview store

Adds `minimizedGroupIds: Set<string>` plus `toggleGroupMinimized` and
`clearMinimizedGroups` actions. The set is cleared on maximize,
exit-maximize, preset apply, custom layout apply, env switch,
default-layout rebuild, and release-to-default so minimize state never
leaks across task environments and never overlaps with maximize.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(task): add minimize button to dockview group header

Adds an IconMinus toggle button alongside Maximize/Split/Close in each
non-chat dockview group header. Clicking it adds the group ID to the
store's `minimizedGroupIds`; a new `useMinimizedGroupSync` hook walks
`api.groups`, stamps a `data-kandev-minimized` attribute on each group
element, and calls `group.api.setSize` to shrink the group to the
splitview minimum (the freed space redistributes to siblings). A CSS
rule hides the content container so only the tab strip remains visible.

The hook also auto-restores a minimized group when a new panel is added
to it (e.g. via the `+` menu) and evicts removed groups from the set.
Clicking Minimize while maximized first exits maximize so the user
doesn't end up with a header-only view.

In wide mode the button is inline; in narrow mode it folds into the
existing overflow dropdown as its first item. Chat group never sees the
button (same exclusion as Close).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(task): tighten minimize-icon test, drop unused TID

The previous "renders Restore tooltip and icon swap" case had two weak
assertions: `getAttribute(...) !== null || btn !== null` is
tautologically true, and `btn.innerHTML.toContain("svg")` matches both
icon variants. Replaced with two focused tests that assert the Tabler
class differs between the minimized and non-minimized state. Also
dropped the unused `minimizeMenuItem` entry from the TID map.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task): minimize-from-maximize used stale group ID

`handleMinimize` previously called `toggleGroupMinimized(group.id)`
after `exitMaximizedLayout()`. During maximize, `group.id` is the
ephemeral overlay group's ID — once the original layout is restored
that ID is gone from `api.groups`, and the minimize was silently lost.
Read `maximizedGroupId` (the pre-maximize group ID stored at maximize
time) from the store and target that instead.

Reported on PR #1039 by claude-review, CodeRabbit, and Greptile.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* perf(task): skip redundant data-attribute writes in minimize sync

The sync hook re-runs on every `onDidAddGroup` / `onDidAddPanel`, which
previously rewrote `data-kandev-minimized` on every group regardless of
whether the value actually changed. Guard with a getAttribute-before-set
check.

Reported on PR #1039 by claude-review.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(task): cover useMinimizedGroupSync hook

Adds 11 unit tests for the hook: mount-time attribute stamping, null-api
no-op, minimize/restore round-trip with prior-size replay, auto-restore
on panel add into a minimized group, no auto-restore for unrelated
groups, group-removal eviction from the store and prior-size map, fresh
size on re-added group with the same ID, sync of newly added groups,
the perf guard on redundant attribute writes, and clean unmount.

Reported on PR #1039 by claude-review as a blocker (CLAUDE.md mandates
tests for new hooks).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task): defer minimize toggle to next RAF when exiting maximize

`exitMaximizedLayout` schedules `api.layout(w, h)` in a requestAnimation
Frame after the synchronous fromJSON rebuild. Calling `toggleGroup
Minimized` immediately afterwards races with that layout pass, and the
resulting `setSize({ width: 0, height: 0 })` could be overridden — the
content was still hidden by CSS but the group kept its full splitview
footprint. Defer the toggle one frame so the maximize-exit settles
first.

Reported on PR #1039 by claude-review, Greptile, and cubic.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task): guard deferred minimize against env-switch + redundant setSize

Two small polish fixes from PR #1039 review:

- The RAF callback in `handleMinimize` now verifies the target group
  still exists in `api.groups` before toggling. Within the frame, an
  env-switch (or another maximize) could clear `minimizedGroupIds`;
  blindly re-adding a stale ID would leave it in the set, and if the
  next env happens to reuse the same well-known constant ID (e.g.
  "right-top"), the wrong group would render as minimized.
  (cubic P2 finding.)
- `applyGroupMinimized` now early-returns when re-syncing an already-
  minimized group (`priorSizes.has(groupId)`), instead of re-emitting
  setSize(0,0) and its size-change events. New test covers this.
  (claude-review suggestion.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 13:43:29 +01:00

290 lines
9.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import type { DockviewApi } from "dockview-react";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { useMinimizedGroupSync } from "./dockview-minimize-sync";
const MINIMIZED_ATTR = "data-kandev-minimized";
type FakeListener<T> = (event: T) => void;
type FakeGroup = {
id: string;
element: HTMLElement;
api: {
width: number;
height: number;
setSize: ReturnType<typeof vi.fn>;
};
};
type FakePanel = { group: FakeGroup };
type FakeApi = {
groups: FakeGroup[];
emit: {
addGroup: (g: FakeGroup) => void;
removeGroup: (g: FakeGroup) => void;
addPanel: (panel: FakePanel) => void;
};
};
function makeFakeApi(initialGroups: FakeGroup[] = []): { api: DockviewApi; fake: FakeApi } {
const addGroupListeners: Array<FakeListener<FakeGroup>> = [];
const removeGroupListeners: Array<FakeListener<FakeGroup>> = [];
const addPanelListeners: Array<FakeListener<FakePanel>> = [];
const api = {
groups: [...initialGroups],
onDidAddGroup: (cb: FakeListener<FakeGroup>) => {
addGroupListeners.push(cb);
return { dispose: () => addGroupListeners.splice(addGroupListeners.indexOf(cb), 1) };
},
onDidRemoveGroup: (cb: FakeListener<FakeGroup>) => {
removeGroupListeners.push(cb);
return { dispose: () => removeGroupListeners.splice(removeGroupListeners.indexOf(cb), 1) };
},
onDidAddPanel: (cb: FakeListener<FakePanel>) => {
addPanelListeners.push(cb);
return { dispose: () => addPanelListeners.splice(addPanelListeners.indexOf(cb), 1) };
},
} as unknown as DockviewApi & { groups: FakeGroup[] };
const fake: FakeApi = {
groups: (api as unknown as { groups: FakeGroup[] }).groups,
emit: {
addGroup: (g) => {
fake.groups.push(g);
addGroupListeners.slice().forEach((cb) => cb(g));
},
removeGroup: (g) => {
const idx = fake.groups.indexOf(g);
if (idx >= 0) fake.groups.splice(idx, 1);
removeGroupListeners.slice().forEach((cb) => cb(g));
},
addPanel: (p) => addPanelListeners.slice().forEach((cb) => cb(p)),
},
};
return { api: api as DockviewApi, fake };
}
function makeGroup(id: string, opts: { width?: number; height?: number } = {}): FakeGroup {
return {
id,
element: document.createElement("div"),
api: {
width: opts.width ?? 800,
height: opts.height ?? 600,
setSize: vi.fn(),
},
};
}
describe("useMinimizedGroupSync", () => {
beforeEach(() => {
useDockviewStore.setState({ minimizedGroupIds: new Set<string>() });
});
afterEach(() => {
useDockviewStore.setState({ minimizedGroupIds: new Set<string>() });
});
it("stamps data-kandev-minimized=false on every group at mount", () => {
const groupA = makeGroup("A");
const groupB = makeGroup("B");
const { api } = makeFakeApi([groupA, groupB]);
renderHook(() => useMinimizedGroupSync(api));
expect(groupA.element.getAttribute(MINIMIZED_ATTR)).toBe("false");
expect(groupB.element.getAttribute(MINIMIZED_ATTR)).toBe("false");
});
it("does nothing when api is null", () => {
const { result } = renderHook(() => useMinimizedGroupSync(null));
expect(result.current).toBeUndefined();
});
it("on minimize: sets data-attribute=true, records prior size, calls setSize(0,0)", () => {
const groupA = makeGroup("A", { width: 800, height: 600 });
const { api } = makeFakeApi([groupA]);
renderHook(() => useMinimizedGroupSync(api));
act(() => {
useDockviewStore.getState().toggleGroupMinimized("A");
});
expect(groupA.element.getAttribute(MINIMIZED_ATTR)).toBe("true");
expect(groupA.api.setSize).toHaveBeenCalledWith({ width: 0, height: 0 });
});
it("skips a redundant setSize when sync re-fires while already minimized", () => {
const groupA = makeGroup("A");
const { api, fake } = makeFakeApi([groupA]);
renderHook(() => useMinimizedGroupSync(api));
act(() => {
useDockviewStore.getState().toggleGroupMinimized("A");
});
expect(groupA.api.setSize).toHaveBeenCalledTimes(1);
// A subsequent sync (triggered here by an addGroup event) should not re-fire
// setSize on the already-minimized group.
act(() => {
fake.emit.addGroup(makeGroup("B"));
});
expect(groupA.api.setSize).toHaveBeenCalledTimes(1);
});
it("on restore: replays the recorded prior size and resets data-attribute", () => {
const groupA = makeGroup("A", { width: 800, height: 600 });
const { api } = makeFakeApi([groupA]);
renderHook(() => useMinimizedGroupSync(api));
act(() => {
useDockviewStore.getState().toggleGroupMinimized("A");
});
expect(groupA.api.setSize).toHaveBeenLastCalledWith({ width: 0, height: 0 });
act(() => {
useDockviewStore.getState().toggleGroupMinimized("A");
});
expect(groupA.element.getAttribute(MINIMIZED_ATTR)).toBe("false");
expect(groupA.api.setSize).toHaveBeenLastCalledWith({ width: 800, height: 600 });
});
it("auto-restores a minimized group when a new panel is added to it", () => {
const groupA = makeGroup("A");
const { api, fake } = makeFakeApi([groupA]);
renderHook(() => useMinimizedGroupSync(api));
act(() => {
useDockviewStore.getState().toggleGroupMinimized("A");
});
expect(useDockviewStore.getState().minimizedGroupIds.has("A")).toBe(true);
act(() => {
fake.emit.addPanel({ group: groupA });
});
expect(useDockviewStore.getState().minimizedGroupIds.has("A")).toBe(false);
});
it("ignores panel-add events for groups that are not minimized", () => {
const groupA = makeGroup("A");
const groupB = makeGroup("B");
const { api, fake } = makeFakeApi([groupA, groupB]);
renderHook(() => useMinimizedGroupSync(api));
act(() => {
useDockviewStore.getState().toggleGroupMinimized("A");
});
act(() => {
fake.emit.addPanel({ group: groupB });
});
expect(useDockviewStore.getState().minimizedGroupIds.has("A")).toBe(true);
});
});
describe("useMinimizedGroupSync — group lifecycle", () => {
beforeEach(() => {
useDockviewStore.setState({ minimizedGroupIds: new Set<string>() });
});
afterEach(() => {
useDockviewStore.setState({ minimizedGroupIds: new Set<string>() });
});
it("evicts a removed group from minimizedGroupIds", () => {
const groupA = makeGroup("A");
const { api, fake } = makeFakeApi([groupA]);
renderHook(() => useMinimizedGroupSync(api));
act(() => {
useDockviewStore.getState().toggleGroupMinimized("A");
});
expect(useDockviewStore.getState().minimizedGroupIds.has("A")).toBe(true);
act(() => {
fake.emit.removeGroup(groupA);
});
expect(useDockviewStore.getState().minimizedGroupIds.has("A")).toBe(false);
});
it("clears recorded prior size when the group is removed (no stale restore on a re-added ID)", () => {
const groupA = makeGroup("A", { width: 800, height: 600 });
const { api, fake } = makeFakeApi([groupA]);
renderHook(() => useMinimizedGroupSync(api));
act(() => {
useDockviewStore.getState().toggleGroupMinimized("A");
});
act(() => {
fake.emit.removeGroup(groupA);
});
// A new group with the same ID later — its size should NOT be reset to the
// old prior. We re-add and then minimize+restore; restore should use the
// new group's current width/height, not the deleted one.
const groupA2 = makeGroup("A", { width: 400, height: 300 });
act(() => {
fake.emit.addGroup(groupA2);
});
act(() => {
useDockviewStore.getState().toggleGroupMinimized("A");
});
act(() => {
useDockviewStore.getState().toggleGroupMinimized("A");
});
expect(groupA2.api.setSize).toHaveBeenLastCalledWith({ width: 400, height: 300 });
});
it("syncs newly added groups", () => {
const groupA = makeGroup("A");
const { api, fake } = makeFakeApi([groupA]);
renderHook(() => useMinimizedGroupSync(api));
const groupB = makeGroup("B");
act(() => {
fake.emit.addGroup(groupB);
});
expect(groupB.element.getAttribute(MINIMIZED_ATTR)).toBe("false");
});
it("does not rewrite data-attribute when unchanged (perf guard)", () => {
const groupA = makeGroup("A");
const { api, fake } = makeFakeApi([groupA]);
renderHook(() => useMinimizedGroupSync(api));
const setAttrSpy = vi.spyOn(groupA.element, "setAttribute");
// Trigger a sync that wouldn't change the existing attribute.
act(() => {
fake.emit.addGroup(makeGroup("B"));
});
expect(setAttrSpy).not.toHaveBeenCalledWith(MINIMIZED_ATTR, "false");
});
it("disposes all subscribers on unmount", () => {
const groupA = makeGroup("A");
const { api, fake } = makeFakeApi([groupA]);
const { unmount } = renderHook(() => useMinimizedGroupSync(api));
unmount();
// After unmount, store changes must not touch the group element or call setSize.
const setAttrSpy = vi.spyOn(groupA.element, "setAttribute");
act(() => {
useDockviewStore.getState().toggleGroupMinimized("A");
});
expect(setAttrSpy).not.toHaveBeenCalled();
expect(groupA.api.setSize).not.toHaveBeenCalled();
// And dockview events should no longer mutate the store.
act(() => {
fake.emit.removeGroup(groupA);
});
// The store still has "A" because the onDidRemoveGroup listener was disposed.
expect(useDockviewStore.getState().minimizedGroupIds.has("A")).toBe(true);
});
});