Files
kandev/apps/web/components/task/dockview-minimize-sync.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

110 lines
3.7 KiB
TypeScript

import { useEffect, useRef } from "react";
import type { DockviewApi } from "dockview-react";
import { useDockviewStore } from "@/lib/state/dockview-store";
const MINIMIZED_ATTR = "data-kandev-minimized";
/** Minimum splitview size we request when minimizing — the splitview clamps to the
* group's actual minimum (tab-strip height / minimum group width) so this can be 0. */
const MIN_SIZE = 0;
type PriorSize = { width: number; height: number };
/** Apply a single group's minimized state: data-attribute + splitview size shrink/restore. */
function applyGroupMinimized(
api: DockviewApi,
groupId: string,
minimized: boolean,
priorSizes: Map<string, PriorSize>,
): void {
const group = api.groups.find((g) => g.id === groupId);
if (!group) return;
const next = minimized ? "true" : "false";
if (group.element.getAttribute(MINIMIZED_ATTR) !== next) {
group.element.setAttribute(MINIMIZED_ATTR, next);
}
if (minimized) {
// Skip the setSize when the group is already minimized (priorSizes already
// has the entry). Re-firing setSize on every sync would re-emit dockview's
// size-change events for no benefit.
if (priorSizes.has(groupId)) return;
priorSizes.set(groupId, { width: group.api.width, height: group.api.height });
try {
group.api.setSize({ width: MIN_SIZE, height: MIN_SIZE });
} catch {
/* setSize may throw if the group is being torn down */
}
} else {
const prior = priorSizes.get(groupId);
if (prior) {
try {
group.api.setSize({ width: prior.width, height: prior.height });
} catch {
/* same */
}
priorSizes.delete(groupId);
}
}
}
/**
* Keep the DOM and splitview sizes in sync with the store's `minimizedGroupIds`.
*
* - On set change: walk every group, set the `data-kandev-minimized` attribute,
* and call `group.api.setSize` to either shrink (clamps to splitview minimum)
* or restore the prior recorded size.
* - On `onDidAddPanel` into a minimized group: auto-restore (user is trying to
* add something they want to see).
* - On `onDidRemoveGroup`: evict the group from `minimizedGroupIds` and prior-sizes.
* - On `onDidAddGroup`: new groups default to not-minimized (no-op — they don't
* appear in the set).
*/
export function useMinimizedGroupSync(api: DockviewApi | null): void {
const priorSizesRef = useRef<Map<string, PriorSize>>(new Map());
useEffect(() => {
if (!api) return;
const priorSizes = priorSizesRef.current;
const sync = () => {
const ids = useDockviewStore.getState().minimizedGroupIds;
for (const group of api.groups) {
applyGroupMinimized(api, group.id, ids.has(group.id), priorSizes);
}
};
sync();
const unsubscribe = useDockviewStore.subscribe((state, prev) => {
if (state.minimizedGroupIds === prev.minimizedGroupIds) return;
sync();
});
const d1 = api.onDidAddGroup(() => sync());
const d2 = api.onDidRemoveGroup((group) => {
const ids = useDockviewStore.getState().minimizedGroupIds;
priorSizes.delete(group.id);
if (ids.has(group.id)) {
const next = new Set(ids);
next.delete(group.id);
useDockviewStore.setState({ minimizedGroupIds: next });
}
});
const d3 = api.onDidAddPanel((panel) => {
const groupId = panel.group?.id;
if (!groupId) return;
const ids = useDockviewStore.getState().minimizedGroupIds;
if (!ids.has(groupId)) return;
const next = new Set(ids);
next.delete(groupId);
useDockviewStore.setState({ minimizedGroupIds: next });
});
return () => {
unsubscribe();
d1.dispose();
d2.dispose();
d3.dispose();
};
}, [api]);
}