fix(web): reconcile responsive task layouts (#2011)

This commit is contained in:
Carlos Florêncio
2026-07-28 23:29:32 +01:00
committed by GitHub
parent 7b796b1eaa
commit abf10d7814
28 changed files with 1021 additions and 111 deletions
@@ -59,6 +59,7 @@ import { PanelPortalHost, usePortalSlot } from "@/lib/layout/panel-portal-host";
import { ENV_SCOPED_DOCKVIEW_COMPONENTS } from "@/lib/state/dockview-env-scoped-components";
import { resolveEffectiveDefaultLayout } from "@/lib/layout/layout-profiles";
import type { LayoutState } from "@/lib/state/layout-manager";
import { registerDockviewRoot, unregisterDockviewRoot } from "@/lib/state/dockview-measure";
// ---------------------------------------------------------------------------
// PORTAL SLOT — generic dockview component that adopts a persistent portal
@@ -275,6 +276,7 @@ type ReadyDockviewRefs = {
readyDisposersRef: React.MutableRefObject<Array<() => void>>;
saveTimerRef: React.MutableRefObject<ReturnType<typeof setTimeout> | null>;
setApi: (api: DockviewReadyEvent["api"] | null) => void;
layoutRootRef: React.MutableRefObject<HTMLDivElement | null>;
};
type ReadyDockviewSetup = {
@@ -289,6 +291,11 @@ function setupReadyDockview({ api, appStore, layout, refs }: ReadyDockviewSetup)
// settings. Seed the store before exposing the API or building a cold layout.
useDockviewStore.getState().setUserDefaultLayout(layout.userDefaultLayout);
refs.setApi(api);
const layoutRoot = refs.layoutRootRef.current;
if (layoutRoot) {
registerDockviewRoot(api, layoutRoot);
refs.readyDisposersRef.current.push(() => unregisterDockviewRoot(api));
}
const currentEnvId = refs.envIdRef.current;
const restored =
@@ -390,7 +397,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
api: event.api,
appStore,
layout: { buildDefaultLayout, compact, initialLayout, userDefaultLayout },
refs: { envIdRef, readyDisposersRef, saveTimerRef, setApi },
refs: { envIdRef, readyDisposersRef, saveTimerRef, setApi, layoutRootRef },
});
},
[setApi, buildDefaultLayout, initialLayout, compact, userDefaultLayout, appStore],
@@ -2,14 +2,18 @@ import type { DockviewReadyEvent, SerializedDockview } from "dockview-react";
import type { StoreApi } from "zustand";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { applyLayoutFixups } from "@/lib/state/dockview-layout-builders";
import { savedRightColumnWidth } from "@/lib/state/dockview-env-switch";
import { isLayoutShapeHealthy } from "@/lib/state/dockview-layout-health";
import { measureDockviewContainer } from "@/lib/state/dockview-measure";
import { isEnvScopedDockviewComponent } from "@/lib/state/dockview-env-scoped-components";
import type { LayoutState } from "@/lib/state/layout-manager";
import { setPinnedTarget } from "@/lib/state/layout-manager";
import type { AppState } from "@/lib/state/store";
import { getEnvLayout, getEnvMaximizeState, removeEnvMaximizeState } from "@/lib/local-storage";
import {
getEnvLayout,
getEnvMaximizeState,
getManualRightWidth,
removeEnvMaximizeState,
} from "@/lib/local-storage";
import { createDebugLogger, isDebug } from "@/lib/debug/log";
const debug = createDebugLogger("dockview:restore");
@@ -157,17 +161,17 @@ type SavedMax = ReturnType<typeof getEnvMaximizeState>;
function applySavedMaximize(
api: DockviewReadyEvent["api"],
savedMax: NonNullable<SavedMax>,
savedRightWidth?: number,
manualRightWidth?: number | null,
): void {
api.fromJSON(savedMax.maximizedDockviewJson as SerializedDockview);
const { width, height } = measureDockviewContainer(api);
api.layout(width, height);
const ids = applyLayoutFixups(api);
const ids = applyLayoutFixups(api, undefined, manualRightWidth);
// The maximize JSON is 2-column — captureRightTarget skips it (sv.length < 3).
// Seed the right target directly so enforcePinnedTargets can snap the column
// back to the saved width when the user exits maximize mode.
if (savedRightWidth !== undefined && savedRightWidth > 0) {
setPinnedTarget("right", savedRightWidth);
if (manualRightWidth !== undefined && manualRightWidth !== null && manualRightWidth > 0) {
setPinnedTarget("right", manualRightWidth);
}
useDockviewStore.setState({
...ids,
@@ -176,21 +180,17 @@ function applySavedMaximize(
});
}
function applyFixupsWithMaximize(
api: DockviewReadyEvent["api"],
envId: string | null,
savedRightWidth?: number,
): void {
function applyFixupsWithMaximize(api: DockviewReadyEvent["api"], envId: string | null): void {
const manualRightWidth = getManualRightWidth(envId);
const savedMax = envId ? getEnvMaximizeState(envId) : null;
if (savedMax) {
applySavedMaximize(api, savedMax, savedRightWidth);
applySavedMaximize(api, savedMax, manualRightWidth);
} else {
const { width, height } = measureDockviewContainer(api);
api.layout(width, height);
// Anchor the right column to its per-env saved width (see
// `captureRightTarget`) so a page reload restores the task's remembered
// width instead of resetting it to the default.
const ids = applyLayoutFixups(api, savedRightWidth);
// Anchor the right column to its per-env manual width when one exists;
// serialized geometry otherwise remains responsive to the current viewport.
const ids = applyLayoutFixups(api, undefined, manualRightWidth);
useDockviewStore.setState(ids);
}
}
@@ -250,7 +250,7 @@ function tryRestoreEnvLayout(
});
}
api.fromJSON(sanitized as SerializedDockview);
applyFixupsWithMaximize(api, envId, savedRightColumnWidth(sanitized as SerializedDockview));
applyFixupsWithMaximize(api, envId);
return true;
}
@@ -5,13 +5,16 @@ import { useDockviewStore } from "@/lib/state/dockview-store";
import { getRootSplitview } from "@/lib/state/dockview-layout-builders";
import {
computeRightMaxPx,
getPinnedTarget,
LAYOUT_PINNED_MIN_PX,
RIGHT_TOP_GROUP,
RIGHT_BOTTOM_GROUP,
setPinnedTarget,
getPinnedTarget,
} from "@/lib/state/layout-manager";
import { setEnvLayout } from "@/lib/local-storage";
import { getManualRightWidth, setEnvLayout, setManualRightWidth } from "@/lib/local-storage";
import { resolveResponsiveRightWidth } from "@/lib/state/layout-manager/right-width";
import { setSashDragging as setPinnedEnforcementSashDragging } from "@/lib/state/dockview-pinned-enforce";
import { getDockviewElement, measureDockviewGridWidth } from "@/lib/state/dockview-measure";
import { panelPortalManager } from "@/lib/layout/panel-portal-manager";
import { stopVscode } from "@/lib/api/domains/vscode-api";
import { parkUserShell, stopUserShell } from "@/lib/api/domains/user-shell-api";
@@ -23,7 +26,6 @@ import {
} from "@/lib/state/dockview-widths-debug";
const debugWidths = createDebugLogger("dockview:widths");
// v3: bumped alongside DOCKVIEW_ENV_LAYOUT_PREFIX so the no-env fallback
// also discards layouts captured with the now-removed dockview sidebar column.
const LAYOUT_STORAGE_KEY = "dockview-layout-v3";
@@ -53,6 +55,31 @@ let enforcing = false;
* enforcement during the drag so the in-progress resize doesn't get
* reverted to the previous target on every intermediate layout change. */
let sashDragging = false;
let rightSashDragging = false;
function hasPinnedRightColumn(api: DockviewReadyEvent["api"]): boolean {
return api.groups.some((group) => [RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP].includes(group.id));
}
function refreshAutomaticRightTarget(api: DockviewReadyEvent["api"]): void {
const store = useDockviewStore.getState();
if (!hasPinnedRightColumn(api) || getManualRightWidth(store.currentLayoutEnvId) !== null) return;
const sv = getRootSplitview(api);
const sidebarWidth = store.sidebarVisible && sv?.length >= 3 ? sv.getViewSize(0) : 0;
setPinnedTarget(
"right",
resolveResponsiveRightWidth(measureDockviewGridWidth(api), sidebarWidth, null),
);
}
function isPinnedRightSash(api: DockviewReadyEvent["api"], target: EventTarget | null): boolean {
if (!hasPinnedRightColumn(api)) return false;
const sash = (target as HTMLElement | null)?.closest(".dv-sash");
if (!sash) return false;
const sv = getRootSplitview(api);
const rootSashes = sv?.sashes as Array<{ container?: HTMLElement }> | undefined;
return rootSashes?.[rootSashes.length - 1]?.container === sash;
}
function restoreColumnToTarget(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -76,7 +103,7 @@ function restoreColumnToTarget(
* `window.innerWidth`. The app sidebar sits outside Dockview, so the browser
* viewport can materially overstate the space available to chat + files. */
function applyRightConstraints(api: DockviewReadyEvent["api"]): number {
const measuredWidth = api.width > 0 ? api.width : undefined;
const measuredWidth = measureDockviewGridWidth(api);
const sv = getRootSplitview(api);
const sidebarWidth = sv?.length >= 3 ? sv.getViewSize(0) : 0;
const maximumWidth = computeRightMaxPx(measuredWidth, sidebarWidth);
@@ -91,18 +118,27 @@ function applyRightConstraints(api: DockviewReadyEvent["api"]): number {
return maximumWidth;
}
function enforcePinnedTargets(api: DockviewReadyEvent["api"]): void {
// eslint-disable-next-line complexity
function enforcePinnedTargets(api: DockviewReadyEvent["api"], allowDuringRestore = false): void {
if (enforcing || sashDragging) return;
const store = useDockviewStore.getState();
if (store.isRestoringLayout) return;
if (store.isRestoringLayout && !allowDuringRestore) return;
if (api.hasMaximizedGroup() || store.preMaximizeLayout !== null) return;
const sv = getRootSplitview(api);
if (!sv || sv.length < 2) return;
const rightVisible = hasPinnedRightColumn(api);
enforcing = true;
try {
if (store.rightPanelsVisible) {
if (rightVisible) {
const maximumWidth = applyRightConstraints(api);
restoreColumnToTarget(sv, sv.length - 1, getPinnedTarget("right"), maximumWidth);
const sidebarWidth = store.sidebarVisible && sv.length >= 3 ? sv.getViewSize(0) : 0;
const measuredWidth = measureDockviewGridWidth(api);
const manualRightWidth = getManualRightWidth(store.currentLayoutEnvId);
const target =
manualRightWidth ??
getPinnedTarget("right") ??
resolveResponsiveRightWidth(measuredWidth, sidebarWidth, null);
restoreColumnToTarget(sv, sv.length - 1, target, maximumWidth);
}
} finally {
enforcing = false;
@@ -115,7 +151,9 @@ function setLooseConstraints(api: DockviewReadyEvent["api"]): void {
if (store.isRestoringLayout) return;
if (api.hasMaximizedGroup() || store.preMaximizeLayout !== null) return;
if (store.rightPanelsVisible) applyRightConstraints(api);
if (hasPinnedRightColumn(api)) {
applyRightConstraints(api);
}
}
/**
@@ -144,6 +182,8 @@ export function setupSashDragCapToggle(api: DockviewReadyEvent["api"]): () => vo
const t = e.target as HTMLElement | null;
if (t?.closest(".dv-sash")) {
sashDragging = true;
rightSashDragging = isPinnedRightSash(api, e.target);
setPinnedEnforcementSashDragging(true);
if (isDebug()) {
debugWidths(`sash-drag-start ${formatWidthsSnapshot(snapshotColumnWidths(api))}`);
}
@@ -151,15 +191,19 @@ export function setupSashDragCapToggle(api: DockviewReadyEvent["api"]): () => vo
};
const onMouseUp = (e: MouseEvent): void => {
if (e.button !== 0 || !sashDragging) return;
const persistRight = rightSashDragging;
sashDragging = false;
rightSashDragging = false;
setPinnedEnforcementSashDragging(false);
// Capture the post-drag width as the new target.
requestAnimationFrame(() => {
const sv = getRootSplitview(api);
if (!sv) return;
const store = useDockviewStore.getState();
if (store.rightPanelsVisible) {
if (persistRight) {
const newRight = sv.getViewSize(sv.length - 1);
setPinnedTarget("right", newRight);
setManualRightWidth(store.currentLayoutEnvId, newRight);
if (isDebug()) {
debugWidths(
`sash-drag-end captured=right:${Math.round(newRight)} ` +
@@ -182,6 +226,8 @@ export function setupSashDragCapToggle(api: DockviewReadyEvent["api"]): () => vo
// away while holding a sash) doesn't leave enforcement permanently paused
// for the next mount (claude).
sashDragging = false;
rightSashDragging = false;
setPinnedEnforcementSashDragging(false);
};
}
@@ -194,7 +240,8 @@ function trackPinnedWidths(api: DockviewReadyEvent["api"]): void {
try {
// Right column is the last grid index when present. Skip when there is
// no right column (compact preset, rightPanelsVisible=false).
if (store.rightPanelsVisible) {
if (hasPinnedRightColumn(api)) {
if (getManualRightWidth(store.currentLayoutEnvId) === null) return;
const rightIdx = sv.length - 1;
const rightW = sv.getViewSize(rightIdx);
if (rightW > 50) {
@@ -220,17 +267,36 @@ function trackPinnedWidths(api: DockviewReadyEvent["api"]): void {
* forcing `api.layout` on every resize is a cheap belt-and-suspenders fix.
*/
export function setupContainerResizeSync(api: DockviewReadyEvent["api"]): () => void {
if (typeof window === "undefined" || typeof ResizeObserver === "undefined") {
if (typeof window === "undefined") {
return () => {};
}
const dv = document.querySelector(".dv-dockview") as HTMLElement | null;
const dv = getDockviewElement(api);
const parent = dv?.parentElement;
if (!parent) return () => {};
const ro = new ResizeObserver(() => {
syncDockviewContainerSize(api, parent);
});
ro.observe(parent);
return () => ro.disconnect();
let previousViewportWidth = window.innerWidth;
const ro =
parent && typeof ResizeObserver !== "undefined"
? new ResizeObserver(() => {
const viewportWidth = window.innerWidth;
if (viewportWidth !== previousViewportWidth) {
previousViewportWidth = viewportWidth;
refreshAutomaticRightTarget(api);
}
syncDockviewContainerSize(api, parent);
})
: null;
if (ro && parent) ro.observe(parent);
const onWindowResize = (): void => {
requestAnimationFrame(() => {
refreshAutomaticRightTarget(api);
const currentParent = getDockviewElement(api)?.parentElement;
if (currentParent) syncDockviewContainerSize(api, currentParent);
});
};
window.addEventListener("resize", onWindowResize);
return () => {
ro?.disconnect();
window.removeEventListener("resize", onWindowResize);
};
}
/**
@@ -246,7 +312,12 @@ export function syncDockviewContainerSize(
const width = container.clientWidth;
const height = container.clientHeight;
if (width <= 0 || height <= 0) return;
if (width === api.width && height === api.height) return;
if (width === api.width && height === api.height) {
// Environment switches can replace pinned targets without changing the
// container dimensions, so enforce even when Dockview's layout is a no-op.
enforcePinnedTargets(api, true);
return;
}
api.layout(width, height);
// Dockview's direct `api.layout` path does not consistently emit
// `onDidLayoutChange`, so enforce immediately after the proportional
+8
View File
@@ -139,6 +139,7 @@ async function loosenPinnedConstraints(
);
}
// eslint-disable-next-line complexity
export async function resizeColumnViaSplitview(
page: Page,
column: "sidebar" | "right",
@@ -167,6 +168,7 @@ export async function resizeColumnViaSplitview(
: computeRightMaxPx(availableWidth, sidebarWidth);
await loosenPinnedConstraints(page, column, runtimeCap);
const result = await page.evaluate(
// eslint-disable-next-line complexity
({ col, target }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const w = window as any;
@@ -178,7 +180,13 @@ export async function resizeColumnViaSplitview(
throw new Error("cannot resize sidebar when hidden (index 0 is center column)");
}
const idx = col === "sidebar" ? 0 : sv.length - 1;
const sash =
col === "right"
? (sv.sashes?.[sv.sashes.length - 1]?.container as HTMLElement | undefined)
: null;
sash?.dispatchEvent(new MouseEvent("mousedown", { button: 0, bubbles: true }));
sv.resizeView(idx, target);
sash?.dispatchEvent(new MouseEvent("mouseup", { button: 0, bubbles: true }));
const actual = sv.getViewSize(idx) as number;
// Mirror the production sash-drag mouseup behavior: update the pinned
// target so `enforcePinnedTargets` doesn't restore the old size on the
@@ -12,6 +12,67 @@ import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
test.describe("Right pane resize — container-proportional cap", () => {
test("opens with a narrower default width on a laptop viewport", async ({
testPage,
apiClient,
seedData,
}) => {
await openWideTask(testPage, apiClient, seedData, "Laptop default right width", {
width: 1280,
height: 900,
});
const initialWidth = await getDockviewGroupWidth(testPage, "files");
// 1280px minus the 320px app sidebar leaves a 960px task workbench.
// The default right pane should use 30% of that space, rather than the
// prior one-third proportion, leaving more room for the central task view.
expectApproxWidth(initialWidth, 288, 12);
});
test("recomputes automatic width across monitor changes and restores the large-screen ratio", async ({
testPage,
apiClient,
seedData,
}) => {
await openWideTask(testPage, apiClient, seedData, "Responsive right width", {
width: 2200,
height: 900,
});
const largeWidth = await getDockviewGroupWidth(testPage, "files");
expectApproxWidth(largeWidth, 450, 12);
await testPage.setViewportSize({ width: 1280, height: 900 });
await testPage.waitForTimeout(500);
const laptopWidth = await getDockviewGroupWidth(testPage, "files");
expectApproxWidth(laptopWidth, 288, 12);
await testPage.setViewportSize({ width: 2200, height: 900 });
await testPage.waitForTimeout(500);
expectApproxWidth(await getDockviewGroupWidth(testPage, "files"), largeWidth, 12);
});
test("preserves a manual width while switching between monitor sizes", async ({
testPage,
apiClient,
seedData,
}) => {
await openWideTask(testPage, apiClient, seedData, "Manual right width across monitors", {
width: 2200,
height: 900,
});
const manualWidth = await resizeColumnViaSplitview(testPage, "right", 360);
expectApproxWidth(manualWidth, 360, 12);
await testPage.setViewportSize({ width: 1280, height: 900 });
await testPage.waitForTimeout(500);
expectApproxWidth(await getDockviewGroupWidth(testPage, "files"), manualWidth, 12);
await testPage.setViewportSize({ width: 2200, height: 900 });
await testPage.waitForTimeout(500);
expectApproxWidth(await getDockviewGroupWidth(testPage, "files"), manualWidth, 12);
});
test("resizes past the old 450px hard cap", async ({ testPage, apiClient, seedData }) => {
await openWideTask(testPage, apiClient, seedData, "Right resize past old cap");
const actual = await resizeColumnViaSplitview(testPage, "right", 700);
@@ -160,6 +160,8 @@ test.describe("saved Dockview layouts", () => {
})
.toContain(`session:${child.session_id}`);
expect(await dockviewPanelIds(testPage)).not.toContain(`session:${parent.sessionId}`);
await expect(testPage.getByTestId(`session-tab-${child.session_id}`)).toBeVisible();
await expect(testPage.getByTestId("watermark-add-panel-btn")).toHaveCount(0);
await expect
.poll(() => dockviewDefaultTree(testPage, child.session_id!))
@@ -180,6 +182,8 @@ test.describe("saved Dockview layouts", () => {
rootColumnCount: 2,
rightColumnPreserved: true,
});
await expect(testPage.getByTestId(`session-tab-${child.session_id}`)).toBeVisible();
await expect(testPage.getByTestId("watermark-add-panel-btn")).toHaveCount(0);
});
test("saved chat-only layouts keep the current task session", async ({
+36
View File
@@ -3,11 +3,14 @@ import {
cleanupTaskStorage,
clearGlobalSidebarWidth,
getGlobalSidebarWidth,
getManualRightWidth,
getOpenFileTabs,
markPRClosedBannerDismissed,
markPRMergedBannerDismissed,
restoreAttachmentPreview,
setGlobalSidebarWidth,
setManualRightWidth,
clearManualRightWidth,
setOpenFileTabs,
wasPRClosedBannerDismissed,
wasPRMergedBannerDismissed,
@@ -127,6 +130,39 @@ describe("global sidebar width storage", () => {
});
});
describe("manual right width storage", () => {
beforeEach(() => {
window.sessionStorage.clear();
});
it("keeps a rounded width scoped to its environment", () => {
setManualRightWidth("env-a", 421.6);
expect(getManualRightWidth("env-a")).toBe(422);
expect(getManualRightWidth("env-b")).toBeNull();
});
it("ignores invalid widths and can clear a preference", () => {
setManualRightWidth("env-a", 0);
setManualRightWidth("env-a", Number.NaN);
expect(getManualRightWidth("env-a")).toBeNull();
setManualRightWidth("env-a", 320);
clearManualRightWidth("env-a");
expect(getManualRightWidth("env-a")).toBeNull();
});
it("cleans only the deleted task environments while preserving other widths", () => {
setManualRightWidth("env-a", 320);
setManualRightWidth("env-b", 420);
cleanupTaskStorage("task-a", [], ["env-a"]);
expect(getManualRightWidth("env-a")).toBeNull();
expect(getManualRightWidth("env-b")).toBe(420);
});
});
describe("task-scoped artifact notification storage", () => {
beforeEach(() => {
window.localStorage.clear();
+27
View File
@@ -247,6 +247,11 @@ export function setFilesPanelScrollPosition(sessionId: string, position: number)
// discard layouts captured with the now-removed dockview sidebar column.
const DOCKVIEW_ENV_LAYOUT_PREFIX = "kandev.dockview.env-layout-v3.";
// A serialized Dockview layout is geometry, not evidence of an intentional
// pixel preference. Keep that preference separately so automatic restores can
// recompute responsive defaults while genuine sash drags remain per-env.
const DOCKVIEW_MANUAL_RIGHT_WIDTH_PREFIX = "kandev.dockview.manual-right-width-v1.";
/**
* Get the saved dockview layout for a task environment.
* Returns null if not found.
@@ -272,6 +277,27 @@ export function setEnvLayout(envId: string, layout: object): void {
}
}
export function getManualRightWidth(envId: string | null): number | null {
if (!envId) return null;
const value = getSessionStorage<number | null>(
`${DOCKVIEW_MANUAL_RIGHT_WIDTH_PREFIX}${envId}`,
null,
);
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.round(value)
: null;
}
export function setManualRightWidth(envId: string | null, width: number): void {
if (!envId || !Number.isFinite(width) || width <= 0) return;
setSessionStorage(`${DOCKVIEW_MANUAL_RIGHT_WIDTH_PREFIX}${envId}`, Math.round(width));
}
export function clearManualRightWidth(envId: string | null): void {
if (!envId) return;
removeSessionStorage(`${DOCKVIEW_MANUAL_RIGHT_WIDTH_PREFIX}${envId}`);
}
// --- Dockview global left-sidebar width (localStorage) ---
// The LEFT sidebar width is a single GLOBAL preference shared across every
// task env (unlike the per-env layout above, which keys widths by envId).
@@ -655,6 +681,7 @@ export function cleanupTaskStorage(
for (const envId of envIds) {
removeEnvMaximizeState(envId);
removeSessionStorage(`${DOCKVIEW_ENV_LAYOUT_PREFIX}${envId}`);
clearManualRightWidth(envId);
}
// Session-keyed storage — drafts, files panel state, scroll, etc.
@@ -9,6 +9,7 @@ vi.mock("@/lib/local-storage", () => ({
setEnvMaximizeState: vi.fn(),
removeEnvMaximizeState: vi.fn(),
getGlobalSidebarWidth: vi.fn(() => null),
getManualRightWidth: vi.fn(() => null),
setGlobalSidebarWidth: vi.fn(),
clearGlobalSidebarWidth: vi.fn(),
}));
@@ -278,4 +279,32 @@ describe("switchEnvLayout — maximize+sidebar-switch regression", () => {
it("normalizes stale session panels when restoring a saved maximize layout", () => {
testNormalizesStaleSessionPanelsOnSavedMaximize();
});
it("does not add Agent when the saved maximized group intentionally excludes it", () => {
vi.mocked(getEnvMaximizeState).mockReturnValueOnce({
preMaximizeLayout: { columns: [] },
maximizedDockviewJson: {
grid: {
root: {
type: "leaf",
size: 600,
data: { id: "group-right-bottom", views: ["terminal-default"] },
},
height: 600,
width: 800,
orientation: "HORIZONTAL",
},
panels: {
"terminal-default": { contentComponent: "terminal" },
},
activeGroup: "group-right-bottom",
},
});
const api = makeMockApi();
useDockviewStore.setState({ api, currentLayoutEnvId: "env-b" });
useDockviewStore.getState().switchEnvLayout("env-b", "env-a", "session-a");
expect(api.addPanel).not.toHaveBeenCalled();
});
});
@@ -7,6 +7,7 @@ import { performEnvSwitch, type EnvSwitchParams } from "./dockview-env-switch";
vi.mock("@/lib/local-storage", () => ({
getEnvLayout: vi.fn(() => null),
getManualRightWidth: vi.fn(() => null),
}));
vi.mock("./dockview-layout-builders", () => ({
@@ -43,7 +44,7 @@ vi.mock("./layout-manager", () => {
};
});
import { getEnvLayout } from "@/lib/local-storage";
import { getEnvLayout, getManualRightWidth } from "@/lib/local-storage";
import { getRootSplitview, savedLayoutMatchesLive } from "./layout-manager";
import { applyLayoutFixups } from "./dockview-layout-builders";
@@ -74,6 +75,7 @@ function makeParams(): EnvSwitchParams {
describe("performEnvSwitch — pinned column resize after fast-path", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getManualRightWidth).mockReturnValue(null);
});
it("resizes sidebar and right to default widths when no saved layout exists", () => {
@@ -94,7 +96,7 @@ describe("performEnvSwitch — pinned column resize after fast-path", () => {
performEnvSwitch(makeParams());
expect(resizeView).toHaveBeenCalledWith(0, 300); // sidebar default (global)
expect(resizeView).toHaveBeenCalledWith(2, 350); // right default
expect(resizeView).toHaveBeenCalledWith(2, 180); // responsive right cap
// center column is at index 1 and is not pinned — must not be resized.
expect(resizeView).not.toHaveBeenCalledWith(1, expect.anything());
});
@@ -143,7 +145,7 @@ describe("performEnvSwitch — pinned column resize after fast-path", () => {
expect(resizeView).not.toHaveBeenCalledWith(0, 420);
});
it("uses the saved size for RIGHT but the global width for the sidebar", () => {
it("uses the manual RIGHT preference but ignores serialized geometry", () => {
// Cover the 3-column saved-layout path so the right column's
// saved-size branch is exercised. The previous test exits the loop
// before reaching the right column because sv.length = 2.
@@ -174,6 +176,7 @@ describe("performEnvSwitch — pinned column resize after fast-path", () => {
vi.mocked(getEnvLayout).mockReturnValue(
savedLayout as unknown as ReturnType<typeof getEnvLayout>,
);
vi.mocked(getManualRightWidth).mockReturnValue(420);
vi.mocked(savedLayoutMatchesLive).mockReturnValue(true);
const resizeView = vi.fn();
@@ -186,18 +189,15 @@ describe("performEnvSwitch — pinned column resize after fast-path", () => {
}) as unknown as NonNullable<ReturnType<typeof getRootSplitview>>,
);
performEnvSwitch(makeParams());
performEnvSwitch({ ...makeParams(), safeWidth: 1600 });
// Sidebar ignores its saved size (420) → global default 300; the right
// column still restores its saved size (420). Center (index 1) is not
// column uses the separate manual preference (420). Center (index 1) is not
// pinned and is skipped.
expect(resizeView).toHaveBeenCalledWith(0, 300);
expect(resizeView).toHaveBeenCalledWith(2, 420);
expect(resizeView).not.toHaveBeenCalledWith(1, expect.anything());
// The saved right width is also forwarded to applyLayoutFixups so the
// fixups pass anchors the right target to the per-env saved width (420)
// rather than re-capturing dockview's transient post-fromJSON live size.
expect(applyLayoutFixups).toHaveBeenCalledWith(expect.anything(), 420);
expect(applyLayoutFixups).toHaveBeenCalledWith(expect.anything(), 420, 420);
});
});
+92 -5
View File
@@ -6,8 +6,15 @@ import {
} from "./dockview-env-switch";
import type { SerializedDockview } from "dockview-react";
const { CANONICAL_CENTER_GROUP_ID, RIGHT_TOP_GROUP_ID, RIGHT_BOTTOM_GROUP_ID } = vi.hoisted(() => ({
CANONICAL_CENTER_GROUP_ID: "group-center",
RIGHT_TOP_GROUP_ID: "group-right-top",
RIGHT_BOTTOM_GROUP_ID: "group-right-bottom",
}));
vi.mock("@/lib/local-storage", () => ({
getEnvLayout: vi.fn(() => null),
getManualRightWidth: vi.fn(() => null),
}));
vi.mock("./dockview-layout-builders", () => ({
@@ -25,9 +32,16 @@ vi.mock("./layout-manager", () => ({
layoutStructuresMatch: vi.fn(() => false),
getRootSplitview: vi.fn(() => null),
getPinnedWidth: vi.fn(() => 350),
isCenterCandidateGroupId: vi.fn(
(groupId: string) =>
groupId !== "group-sidebar" &&
groupId !== RIGHT_TOP_GROUP_ID &&
groupId !== RIGHT_BOTTOM_GROUP_ID,
),
setPinnedTarget: vi.fn(),
RIGHT_TOP_GROUP: "group-right-top",
RIGHT_BOTTOM_GROUP: "group-right-bottom",
CENTER_GROUP: CANONICAL_CENTER_GROUP_ID,
RIGHT_TOP_GROUP: RIGHT_TOP_GROUP_ID,
RIGHT_BOTTOM_GROUP: RIGHT_BOTTOM_GROUP_ID,
}));
import { getEnvLayout } from "@/lib/local-storage";
@@ -511,6 +525,79 @@ describe("performEnvSwitch slow-path session tab restoration", () => {
});
});
describe("performEnvSwitch missing Agent restoration", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("restores and activates Agent in an empty center group", () => {
const savedLayout = makeHealthyLayoutWith({});
vi.mocked(getEnvLayout).mockReturnValueOnce(savedLayout).mockReturnValueOnce(savedLayout);
vi.mocked(savedLayoutMatchesLive).mockReturnValueOnce(false);
const setActive = vi.fn();
const centerGroup = { id: CANONICAL_CENTER_GROUP_ID, panels: [] };
const addPanel = vi.fn(() => ({
id: NEW_SESSION_PANEL_ID,
api: { component: "chat", setActive },
group: centerGroup,
}));
const api = {
...makeMockApi(),
panels: [],
groups: [centerGroup],
getPanel: vi.fn(() => null),
addPanel,
} as unknown as EnvSwitchParams["api"];
performEnvSwitch(makeParams({ api }));
expect(addPanel).toHaveBeenCalledWith(
expect.objectContaining({
id: NEW_SESSION_PANEL_ID,
position: expect.objectContaining({ referenceGroup: centerGroup.id }),
inactive: false,
}),
);
expect(setActive).toHaveBeenCalledOnce();
});
it("restores Agent beside a saved active Plan tab without stealing focus", () => {
const savedLayout = makeHealthyLayoutWith({ plan: { contentComponent: "plan" } });
vi.mocked(getEnvLayout).mockReturnValueOnce(savedLayout).mockReturnValueOnce(savedLayout);
vi.mocked(savedLayoutMatchesLive).mockReturnValueOnce(false);
const setActive = vi.fn();
const planPanel = { id: "plan", api: { component: "plan", isActive: true } };
const centerGroup = { id: CANONICAL_CENTER_GROUP_ID, panels: [] };
const planGroup = { id: "center-secondary", panels: [planPanel] };
const addPanel = vi.fn(() => ({
id: NEW_SESSION_PANEL_ID,
api: { component: "chat", setActive },
group: centerGroup,
}));
const api = {
...makeMockApi(),
activePanel: planPanel,
panels: [planPanel],
groups: [centerGroup, planGroup],
getPanel: vi.fn((id: string) => (id === "plan" ? planPanel : null)),
addPanel,
} as unknown as EnvSwitchParams["api"];
performEnvSwitch(makeParams({ api }));
expect(addPanel).toHaveBeenCalledWith(
expect.objectContaining({
id: NEW_SESSION_PANEL_ID,
position: expect.objectContaining({ referenceGroup: centerGroup.id }),
inactive: true,
}),
);
expect(setActive).not.toHaveBeenCalled();
});
});
describe("performEnvSwitch fast-path active view restoration", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -583,7 +670,7 @@ describe("savedRightColumnWidth", () => {
it("returns the saved right size for a 3-column layout (sidebar+center+right)", () => {
const saved = makeSaved([
{ id: "group-sidebar", size: 300 },
{ id: "group-center", size: 1000 },
{ id: CANONICAL_CENTER_GROUP_ID, size: 1000 },
{ id: "group-right-top", size: 300 },
]);
expect(savedRightColumnWidth(saved)).toBe(300);
@@ -594,7 +681,7 @@ describe("savedRightColumnWidth", () => {
// caused the right column to fall back to ~450 default on env switch
// instead of restoring the user's narrow width.
const saved = makeSaved([
{ id: "group-center", size: 1380 },
{ id: CANONICAL_CENTER_GROUP_ID, size: 1380 },
{ id: "group-right-top", size: 220 },
]);
expect(savedRightColumnWidth(saved)).toBe(220);
@@ -605,7 +692,7 @@ describe("savedRightColumnWidth", () => {
// mistake the center for the right column.
const saved = makeSaved([
{ id: "group-sidebar", size: 300 },
{ id: "group-center", size: 1300 },
{ id: CANONICAL_CENTER_GROUP_ID, size: 1300 },
]);
expect(savedRightColumnWidth(saved)).toBeUndefined();
});
+65 -14
View File
@@ -8,7 +8,7 @@
* "slow path" (full layout rebuild via fromJSON).
*/
import type { DockviewApi, SerializedDockview } from "dockview-react";
import { getEnvLayout } from "@/lib/local-storage";
import { getEnvLayout, getManualRightWidth } from "@/lib/local-storage";
import { applyLayoutFixups } from "./dockview-layout-builders";
import { isLayoutShapeHealthy } from "./dockview-layout-health";
import {
@@ -17,10 +17,13 @@ import {
layoutStructuresMatch,
getPinnedWidth,
getRootSplitview,
isCenterCandidateGroupId,
setPinnedTarget,
CENTER_GROUP,
RIGHT_TOP_GROUP,
RIGHT_BOTTOM_GROUP,
} from "./layout-manager";
import { resolveResponsiveRightWidth } from "./layout-manager/right-width";
import type { LayoutState, LayoutGroupIds } from "./layout-manager";
import { ENV_SCOPED_DOCKVIEW_COMPONENTS } from "./dockview-env-scoped-components";
import { createDebugLogger, isDebug } from "@/lib/debug/log";
@@ -221,6 +224,31 @@ export function replaceStaleSessionPanels(
addCurrentSessionSiblings(api, keepSessionId, currentSessionIds);
}
const RESTORED_SESSION_ANCHOR_IDS = ["plan", "pr-detail", "mr-detail"];
function findRestoredSessionGroup(api: DockviewApi): DockviewApi["groups"][number] | undefined {
const canonicalCenter = api.groups.find((group) => group.id === CENTER_GROUP);
if (canonicalCenter) return canonicalCenter;
for (const panelId of RESTORED_SESSION_ANCHOR_IDS) {
const group = api.getPanel(panelId)?.group;
if (group && isCenterCandidateGroupId(group.id)) return group;
}
return api.groups.find((group) => isCenterCandidateGroupId(group.id));
}
function restoreMissingSessionPanel(api: DockviewApi, sessionId: string): void {
if (api.getPanel(`session:${sessionId}`)) return;
const targetGroup = findRestoredSessionGroup(api);
const shouldActivate = !api.activePanel;
const panel = addIncomingSessionPanel(api, sessionId, targetGroup?.id, targetGroup ? 0 : -1, {
inactive: !shouldActivate,
});
if (shouldActivate) panel?.api.setActive();
}
function addCurrentSessionSiblings(
api: DockviewApi,
keepSessionId: string | null,
@@ -341,21 +369,29 @@ function tryFastEnvSwitch(params: EnvSwitchParams): LayoutGroupIds | null {
// Column widths from the outgoing env stay live across the switch because
// we skipped fromJSON. Apply the target env's widths explicitly:
// - saved layout exists → use its serialized sizes
// - saved layout exists → use responsive defaults (or a manual right width)
// - no saved layout (brand-new env) → compute fresh defaults via
// getPinnedWidth (ratio-based, clamped to legacy initial cap)
applyPinnedColumnSizes(api, saved as SerializedDockview | null, params.safeWidth);
applyPinnedColumnSizes(
api,
saved as SerializedDockview | null,
params.safeWidth,
getManualRightWidth(newEnvId),
);
api.layout(params.safeWidth, params.safeHeight);
return applyLayoutFixups(api, savedRightColumnWidth(saved as SerializedDockview | null));
return applyLayoutFixups(
api,
savedRightColumnWidth(saved as SerializedDockview | null),
getManualRightWidth(newEnvId),
);
}
/**
* The per-env saved width of the right column (the last grid-root child) for a
* The serialized width of the right column (the last grid-root child) for a
* default-preset layout, or undefined when the saved layout has no distinct
* right column. Forwarded to `applyLayoutFixups` so the fixups pass anchors the
* pinned right target to this stable saved width instead of dockview's
* transient post-`fromJSON` live size (the dockview-wrong-width drift).
* right column. Retained for diagnostics/compatibility only; callers use the
* separate manual-width preference for an intentional override.
*
* The right column is identified by the presence of RIGHT_TOP_GROUP /
* RIGHT_BOTTOM_GROUP inside the last grid-root child — NOT by column count.
@@ -404,14 +440,20 @@ function extractSavedColumnSizes(saved: SerializedDockview): number[] | null {
return root.data.map((child: any) => (typeof child?.size === "number" ? child.size : NaN));
}
/** Compute the target width for a pinned column from saved sizes or fall
* back to the preset's ratio-based default. */
/** Compute the target width for a pinned column. Right-column geometry from a
* serialized layout is intentionally ignored unless a manual preference exists. */
// eslint-disable-next-line max-params
function targetPinnedWidth(
col: LayoutState["columns"][number],
index: number,
savedSizes: number[] | null,
totalWidth: number,
manualRightWidth: number | null,
sidebarWidth: number,
): number | undefined {
if (col.id === "right") {
return resolveResponsiveRightWidth(totalWidth, sidebarWidth, manualRightWidth);
}
if (savedSizes && Number.isFinite(savedSizes[index])) return savedSizes[index];
return getPinnedWidth(col, totalWidth, undefined);
}
@@ -422,16 +464,20 @@ function targetPinnedWidth(
* widths bleed into the new env — a brand-new task would open at whatever
* width the user last dragged the previous task's sidebar/right to.
*/
// eslint-disable-next-line complexity
function applyPinnedColumnSizes(
api: DockviewApi,
saved: SerializedDockview | null,
totalWidth: number,
manualRightWidth: number | null,
): void {
const sv = getRootSplitview(api);
if (!sv || sv.length < 2) return;
const savedSizes = saved ? extractSavedColumnSizes(saved) : null;
const liveLayout = fromDockviewApi(api);
const sidebarColumn = liveLayout.columns.find((column) => column.id === "sidebar");
const sidebarTarget = sidebarColumn ? getPinnedWidth(sidebarColumn, totalWidth, undefined) : 0;
if (isDebug()) {
const savedStr = savedSizes
? savedSizes.map((n) => (Number.isFinite(n) ? String(Math.round(n)) : "-")).join(",")
@@ -449,7 +495,7 @@ function applyPinnedColumnSizes(
const target =
col.id === "sidebar"
? getPinnedWidth(col, totalWidth, undefined)
: targetPinnedWidth(col, i, savedSizes, totalWidth);
: targetPinnedWidth(col, i, savedSizes, totalWidth, manualRightWidth, sidebarTarget);
if (typeof target !== "number" || target <= 0) continue;
try {
sv.resizeView(i, target);
@@ -475,7 +521,7 @@ function addIncomingSessionPanel(
outgoingGroupId: string | undefined,
outgoingIndex: number,
options: { inactive?: boolean } = {},
): void {
): DockviewApi["panels"][number] {
let position: import("dockview-react").AddPanelOptions["position"];
if (outgoingGroupId && api.groups.some((g) => g.id === outgoingGroupId)) {
position =
@@ -485,7 +531,7 @@ function addIncomingSessionPanel(
} else if (api.getPanel("sidebar")) {
position = { direction: "right" as const, referencePanel: "sidebar" };
}
api.addPanel({
return api.addPanel({
id: `session:${sessionId}`,
component: "chat",
tabComponent: "sessionTab",
@@ -563,6 +609,7 @@ export function performEnvSwitch(params: EnvSwitchParams): LayoutGroupIds {
// part of this env's saved state and must NOT be touched.
// useAutoSessionTab will still no-op if the panel was just added here.
replaceStaleSessionPanels(api, activeSessionId, currentSessionIds);
if (activeSessionId) restoreMissingSessionPanel(api, activeSessionId);
api.layout(safeWidth, safeHeight);
if (isDebug()) {
debug("performEnvSwitch: completed via slow path (fromJSON)", {
@@ -570,7 +617,11 @@ export function performEnvSwitch(params: EnvSwitchParams): LayoutGroupIds {
livePanelIdsAfter: api.panels.map((p) => p.id),
});
}
return applyLayoutFixups(api, savedRightColumnWidth(saved as SerializedDockview));
return applyLayoutFixups(
api,
savedRightColumnWidth(saved as SerializedDockview),
getManualRightWidth(newEnvId),
);
} catch (err) {
console.warn("performEnvSwitch: fromJSON threw", err);
debug("performEnvSwitch: fromJSON threw, falling through to default", { newEnvId, err });
@@ -134,23 +134,21 @@ describe("applyLayoutFixups — pinned target capture", () => {
applyLayoutFixups(api);
expect(setPinnedTarget).toHaveBeenCalledWith("sidebar", 350);
expect(setPinnedTarget).toHaveBeenCalledWith("right", 350); // default, NOT live 400
expect(setPinnedTarget).toHaveBeenCalledWith("right", 441); // responsive default, NOT live 400
expect(setPinnedTarget).not.toHaveBeenCalledWith("right", 400);
});
it("anchors the pinned right target to the saved per-env width, not the live size", () => {
// A restore passes the env's saved right width; it wins over both the live
// size (400) and the default (350) so a deliberately-resized task restores
// its own remembered width. Also asserts that the column is physically
// resized to the stable width (not left at the transient live size).
it("treats legacy saved geometry as responsive default width", () => {
// A legacy restore has no manual-width marker. Its serialized width (420)
// must not become sticky when the workbench moves to another monitor.
mockSplitview([350, 720, 400]);
const api = makeApi([SIDEBAR_GROUP, CENTER_GROUP, RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP]);
applyLayoutFixups(api, 420);
expect(setPinnedTarget).toHaveBeenCalledWith("right", 420);
expect(setPinnedTarget).not.toHaveBeenCalledWith("right", 400);
expect(mockResizeView).toHaveBeenCalledWith(2, 420);
expect(setPinnedTarget).toHaveBeenCalledWith("right", 441);
expect(setPinnedTarget).not.toHaveBeenCalledWith("right", 420);
expect(mockResizeView).toHaveBeenCalledWith(2, 441);
});
it("derives the caps from api.width, not the window.innerWidth fallback", () => {
@@ -183,7 +181,7 @@ describe("applyLayoutFixups — pinned target capture", () => {
mockSplitview([350, 200, 800]);
const api = makeApi([SIDEBAR_GROUP, CENTER_GROUP, RIGHT_TOP_GROUP]);
applyLayoutFixups(api, 1200); // saved 1200 exceeds RIGHT_CAP (1029)
applyLayoutFixups(api, 1200, 1200); // manual 1200 exceeds RIGHT_CAP (1029)
expect(setPinnedTarget).toHaveBeenCalledWith("right", RIGHT_CAP);
});
@@ -197,7 +195,7 @@ describe("applyLayoutFixups — pinned target capture", () => {
mockSplitview([720, 400]); // idx0 = center, idx1 = right
const api = makeApi([CENTER_GROUP, RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP]);
applyLayoutFixups(api, 420);
applyLayoutFixups(api, 420, 420);
expect(setPinnedTarget).toHaveBeenCalledWith("right", 420);
expect(mockResizeView).toHaveBeenCalledWith(1, 420);
@@ -209,7 +207,7 @@ describe("applyLayoutFixups — pinned target capture", () => {
applyLayoutFixups(api);
expect(setPinnedTarget).toHaveBeenCalledWith("right", 350); // default
expect(setPinnedTarget).toHaveBeenCalledWith("right", 441); // responsive default
expect(setPinnedTarget).not.toHaveBeenCalledWith("right", 400); // not live
});
});
+20 -13
View File
@@ -15,6 +15,7 @@ import {
} from "./layout-manager";
import type { LayoutGroupIds } from "./layout-manager";
import { getGlobalSidebarWidth } from "@/lib/local-storage";
import { resolveResponsiveRightWidth } from "./layout-manager/right-width";
import { createDebugLogger, isDebug } from "@/lib/debug/log";
// Re-export for consumers that import from this module
@@ -61,7 +62,12 @@ function captureCallerChain(): string {
* Apply loose runtime caps so the user can drag freely; the just-restored
* widths become the new pinned targets, and `enforcePinnedTargets` restores
* the column to that target on every subsequent rebalance. */
export function applyLayoutFixups(api: DockviewApi, savedRightWidth?: number): LayoutGroupIds {
export function applyLayoutFixups(
api: DockviewApi,
/** Retained for callers restoring legacy layouts; manual width is authoritative. */
_savedRightWidth?: number,
manualRightWidth?: number | null,
): LayoutGroupIds {
const sv = getRootSplitviewImpl(api);
captureSidebarTarget(api, sv);
@@ -70,7 +76,7 @@ export function applyLayoutFixups(api: DockviewApi, savedRightWidth?: number): L
const oldFiles = api.getPanel("all-files");
if (oldFiles) oldFiles.api.setTitle("Files");
captureRightTarget(api, sv, savedRightWidth);
captureRightTarget(api, sv, manualRightWidth);
logFixupsCapture(api, sv);
@@ -127,19 +133,15 @@ function captureSidebarTarget(api: DockviewApi, sv: any): void {
}
/** Resolve the pinned right column's target width (clamped to the cap): the
* per-env SAVED width when the restore supplies one, else the column default
* for the current measured layout. Never the live splitview size — see
* per-env MANUAL width when the user has dragged the sash, else the responsive
* column default for the current measured layout. Never the live splitview size — see
* `captureRightTarget`. */
function resolveRightTarget(
cap: number,
totalWidth: number | undefined,
savedRightWidth: number | undefined,
manualRightWidth: number | null | undefined,
): number {
if (savedRightWidth !== undefined && savedRightWidth > 0) return Math.min(savedRightWidth, cap);
const width =
totalWidth ??
(typeof window !== "undefined" && window.innerWidth > 0 ? window.innerWidth : cap);
return Math.min(getPinnedWidth({ id: "right", pinned: true, groups: [] }, width, undefined), cap);
return Math.min(resolveResponsiveRightWidth(totalWidth, 0, manualRightWidth ?? null, cap), cap);
}
/** Constrain the default layout's right column groups and record the side
@@ -147,7 +149,7 @@ function resolveRightTarget(
*
* For the DEFAULT preset (the right column is pinned — identified by the
* well-known RIGHT_TOP_GROUP), the target is anchored to a STABLE width: the
* per-env saved width passed by the restore, or the column default when none.
* per-env manual width passed by the restore, or the responsive default when none.
* It is deliberately NOT read from the live splitview: dockview's
* post-`fromJSON` proportional rebalance reports a transient/rescaled size,
* and persisting that as the target ratcheted the right column wider on every
@@ -171,7 +173,12 @@ function resolveRightTarget(
* stripped) is excluded because neither right group exists in `api.groups`
* and `sv.length < 3` — its last splitview child is the CENTER column. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function captureRightTarget(api: DockviewApi, sv: any, savedRightWidth?: number): void {
function captureRightTarget(
api: DockviewApi,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sv: any,
manualRightWidth?: number | null,
): void {
// Constrain the default preset's right column groups (stable well-known IDs).
// Other presets' side columns aren't pinned and carry no max-width cap.
// Use the measured grid width, not the window.innerWidth fallback (see
@@ -191,7 +198,7 @@ function captureRightTarget(api: DockviewApi, sv: any, savedRightWidth?: number)
// both 3-column (sidebar+center+right) and 2-column (center+right, sidebar
// hidden) layouts since the right column is always at sv.length-1.
if (api.groups.some((g) => g.id === RIGHT_TOP_GROUP || g.id === RIGHT_BOTTOM_GROUP)) {
const target = resolveRightTarget(rightCap, measuredWidth, savedRightWidth);
const target = resolveRightTarget(rightCap, measuredWidth, manualRightWidth);
const cur = sv.getViewSize(idx);
if (typeof cur === "number" && cur > 0 && Math.abs(cur - target) > 1) {
try {
+32 -3
View File
@@ -1,22 +1,51 @@
import { afterEach, describe, expect, it } from "vitest";
import type { DockviewApi } from "dockview-react";
import { measureDockviewContainer } from "./dockview-measure";
import {
measureDockviewContainer,
measureDockviewGridWidth,
registerDockviewRoot,
unregisterDockviewRoot,
} from "./dockview-measure";
function fakeApi(width: number, height: number): DockviewApi {
return { width, height } as unknown as DockviewApi;
}
describe("measureDockviewContainer", () => {
const DV_CLASS = "dv-dockview";
afterEach(() => {
document.body.innerHTML = "";
});
it("measures the registered Dockview root instead of another mounted instance", () => {
const firstParent = document.createElement("div");
Object.defineProperty(firstParent, "clientWidth", { value: 300, configurable: true });
const first = document.createElement("div");
first.className = DV_CLASS;
firstParent.appendChild(first);
document.body.appendChild(firstParent);
const targetParent = document.createElement("div");
Object.defineProperty(targetParent, "clientWidth", { value: 900, configurable: true });
const target = document.createElement("div");
target.className = DV_CLASS;
targetParent.appendChild(target);
document.body.appendChild(targetParent);
const api = fakeApi(0, 0);
registerDockviewRoot(api, targetParent);
expect(measureDockviewGridWidth(api)).toBe(900);
unregisterDockviewRoot(api);
});
it("uses the live container size when it is laid out", () => {
const parent = document.createElement("div");
Object.defineProperty(parent, "clientWidth", { value: 1500, configurable: true });
Object.defineProperty(parent, "clientHeight", { value: 700, configurable: true });
const dv = document.createElement("div");
dv.className = "dv-dockview";
dv.className = DV_CLASS;
parent.appendChild(dv);
document.body.appendChild(parent);
@@ -37,7 +66,7 @@ describe("measureDockviewContainer", () => {
Object.defineProperty(parent, "clientWidth", { value: 80, configurable: true });
Object.defineProperty(parent, "clientHeight", { value: 700, configurable: true });
const dv = document.createElement("div");
dv.className = "dv-dockview";
dv.className = DV_CLASS;
parent.appendChild(dv);
document.body.appendChild(parent);
+29 -4
View File
@@ -1,5 +1,31 @@
import type { DockviewApi } from "dockview-react";
const dockviewRoots = new WeakMap<DockviewApi, HTMLElement>();
export function registerDockviewRoot(api: DockviewApi, root: HTMLElement): void {
dockviewRoots.set(api, root);
}
export function unregisterDockviewRoot(api: DockviewApi): void {
dockviewRoots.delete(api);
}
export function getDockviewElement(api: DockviewApi): HTMLElement | null {
const root = dockviewRoots.get(api);
if (root) return root.querySelector<HTMLElement>(".dv-dockview");
return typeof document !== "undefined"
? document.querySelector<HTMLElement>(".dv-dockview")
: null;
}
export function measureDockviewGridWidth(api: DockviewApi): number | undefined {
const dockview = getDockviewElement(api);
const domWidth = dockview?.clientWidth || dockview?.parentElement?.clientWidth;
if (domWidth && domWidth > 0) return domWidth;
if (api.width > 0) return api.width;
return undefined;
}
/**
* Measure the live size of the dockview container element.
*
@@ -11,7 +37,7 @@ import type { DockviewApi } from "dockview-react";
* source of truth and lets us recover from a drifted internal width.
*/
export function measureDockviewContainer(api: DockviewApi): { width: number; height: number } {
const live = liveContainerSize();
const live = liveContainerSize(api);
if (live) return live;
// No laid-out container (e.g. a fresh client-side navigation mounts dockview
// before the grid has painted). `api.width/height` are 0 at that point, and
@@ -33,9 +59,8 @@ export function measureDockviewContainer(api: DockviewApi): { width: number; hei
// a transient and must be treated like a not-yet-laid-out (0-width) container.
const MIN_PLAUSIBLE_WIDTH_RATIO = 0.5;
function liveContainerSize(): { width: number; height: number } | null {
if (typeof document === "undefined") return null;
const dv = document.querySelector(".dv-dockview") as HTMLElement | null;
function liveContainerSize(api: DockviewApi): { width: number; height: number } | null {
const dv = getDockviewElement(api);
const parent = dv?.parentElement;
if (!parent || parent.clientWidth <= 0 || parent.clientHeight <= 0) return null;
// Root flex layout changes are horizontal, so only the width can be a
@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DockviewApi } from "dockview-react";
vi.mock("@/lib/local-storage", () => ({
getManualRightWidth: vi.fn(() => null),
}));
vi.mock("./dockview-measure", () => ({
measureDockviewGridWidth: vi.fn(() => 800),
}));
import { getManualRightWidth } from "@/lib/local-storage";
import { enforcePinnedTargets } from "./dockview-pinned-enforce";
import { clearAllPinnedTargets, setPinnedTarget } from "./layout-manager";
function makeApi() {
const splitview = {
length: 2,
getViewSize: vi.fn(() => 400),
resizeView: vi.fn(),
};
const api = {
component: { gridview: { root: { splitview } } },
hasMaximizedGroup: vi.fn(() => false),
} as unknown as DockviewApi;
return { api, splitview };
}
describe("enforcePinnedTargets", () => {
beforeEach(() => {
clearAllPinnedTargets();
vi.mocked(getManualRightWidth).mockReturnValue(null);
});
it("clamps an oversized manual right width to the current viewport cap", () => {
vi.mocked(getManualRightWidth).mockReturnValue(600);
const { api, splitview } = makeApi();
enforcePinnedTargets(api, {
sidebarVisible: false,
rightPanelsVisible: true,
maximized: false,
envId: "env-a",
});
expect(splitview.resizeView).toHaveBeenCalledWith(1, 320);
});
it("clamps an oversized pinned right width to the current viewport cap", () => {
setPinnedTarget("right", 600);
const { api, splitview } = makeApi();
enforcePinnedTargets(api, {
sidebarVisible: false,
rightPanelsVisible: true,
maximized: false,
envId: "env-a",
});
expect(splitview.resizeView).toHaveBeenCalledWith(1, 320);
});
});
+14 -1
View File
@@ -20,7 +20,10 @@
*/
import type { DockviewApi } from "dockview-react";
import { getRootSplitview, getPinnedTarget, computeSidebarMaxPx } from "./layout-manager";
import { resolveResponsiveRightWidth } from "./layout-manager/right-width";
import { getManualRightWidth } from "@/lib/local-storage";
import { createDebugLogger, isDebug } from "@/lib/debug/log";
import { measureDockviewGridWidth } from "./dockview-measure";
const debugWidths = createDebugLogger("dockview:widths");
@@ -66,8 +69,17 @@ export type EnforcePinnedTargetsCtx = {
/** Non-null when we are in (or restoring) a maximized-group state; skip
* enforcement so we don't fight the 2-column maximize overlay. */
maximized: boolean;
envId?: string | null;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function resolveRightTarget(api: DockviewApi, ctx: EnforcePinnedTargetsCtx, sv: any): number {
const sidebarWidth = ctx.sidebarVisible && sv.length >= 3 ? sv.getViewSize(0) : 0;
const persistedTarget =
getManualRightWidth(ctx.envId ?? null) ?? getPinnedTarget("right") ?? null;
return resolveResponsiveRightWidth(measureDockviewGridWidth(api), sidebarWidth, persistedTarget);
}
/**
* Snap the pinned columns back to their recorded targets.
*
@@ -110,7 +122,8 @@ export function enforcePinnedTargets(api: DockviewApi, ctx: EnforcePinnedTargets
restoreColumnToTarget(sv, 0, clamped);
}
if (ctx.rightPanelsVisible) {
restoreColumnToTarget(sv, sv.length - 1, getPinnedTarget("right"));
const target = resolveRightTarget(api, ctx, sv);
restoreColumnToTarget(sv, sv.length - 1, target);
}
} finally {
enforcing = false;
+11 -3
View File
@@ -8,6 +8,7 @@ import {
removeEnvMaximizeState,
clearGlobalSidebarWidth,
setGlobalSidebarWidth,
getManualRightWidth,
} from "@/lib/local-storage";
import { setPinnedTarget, clearPinnedTarget } from "./layout-manager";
import { applyLayoutFixups, focusOrAddPanel } from "./dockview-layout-builders";
@@ -280,7 +281,8 @@ function syncPinnedWidthsFromApi(api: DockviewApi, set: StoreSet): void {
try {
const state = fromDockviewApi(api);
if (state.columns.length !== sv.length) return;
const { rightPanelsVisible } = useDockviewStore.getState();
const { rightPanelsVisible, currentLayoutEnvId } = useDockviewStore.getState();
if (getManualRightWidth(currentLayoutEnvId) === null) return;
const updates = collectPinnedWidthUpdates(state.columns, (i) => sv.getViewSize(i), {
rightPanelsVisible,
});
@@ -416,6 +418,7 @@ function enforceFromStore(api: DockviewApi, get: StoreGet): void {
sidebarVisible: s.sidebarVisible,
rightPanelsVisible: s.rightPanelsVisible,
maximized: s.preMaximizeLayout !== null,
envId: s.currentLayoutEnvId,
});
}
@@ -685,7 +688,7 @@ function restoreMaximizeFromStorage(
// wrong width on subsequent restores.
const { width, height } = measureDockviewContainer(api);
api.layout(width, height);
const ids = applyLayoutFixups(api);
const ids = applyLayoutFixups(api, undefined, getManualRightWidth(envId));
const preMax = saved.preMaximizeLayout as unknown as LayoutState;
// The maximized layout is `[sidebar?, maximized]` — the non-sidebar group
// is the one being maximized, which `resolveGroupIds` returns as
@@ -833,7 +836,12 @@ function buildEnvSwitchAction(set: StoreSet, get: StoreGet) {
const effectiveOld = oldEnvId ?? currentLayoutEnvId;
saveOutgoingEnv(api, effectiveOld, preMaximizeLayout, get().pinnedWidths);
set({ preMaximizeLayout: null, maximizedGroupId: null });
set({ isRestoringLayout: true, currentLayoutEnvId: newEnvId });
const manualRightWidth = getManualRightWidth(newEnvId);
set({
isRestoringLayout: true,
currentLayoutEnvId: newEnvId,
pinnedWidths: manualRightWidth === null ? new Map() : new Map([["right", manualRightWidth]]),
});
try {
if (restoreMaximizeFromStorage(api, newEnvId, set, activeSessionId, currentSessionIds))
return;
@@ -5,10 +5,10 @@ import type { LayoutPanel } from "./types";
//
// Ratio applied to dockview width to compute initial defaults when no user
// override exists. The left app sidebar now sits outside dockview, so the
// right pane needs a larger dockview-relative ratio to preserve the old
// laptop proportions after the app sidebar consumes horizontal space.
// right pane uses a slightly narrower dockview-relative ratio to leave more
// room for the main task surface on laptop-sized screens.
export const LAYOUT_SIDEBAR_RATIO = 2.5 / 10;
export const LAYOUT_RIGHT_RATIO = 1 / 3;
export const LAYOUT_RIGHT_RATIO = 3 / 10;
// Well-known group/panel IDs
export const SIDEBAR_GROUP = "group-sidebar";
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { resolveResponsiveRightWidth } from "./right-width";
describe("resolveResponsiveRightWidth", () => {
it("prefers a manual width when it fits within the cap", () => {
expect(resolveResponsiveRightWidth(1600, 0, 320, 500)).toBe(320);
});
it("clamps a manual width to an explicit cap", () => {
expect(resolveResponsiveRightWidth(1600, 0, 900, 500)).toBe(500);
});
it("falls back to the viewport when the dockview width is unavailable", () => {
expect(resolveResponsiveRightWidth(undefined, 0, null, 500)).toBe(
resolveResponsiveRightWidth(window.innerWidth, 0, null, 500),
);
});
it("uses a supplied cap without recomputing the runtime cap", () => {
expect(resolveResponsiveRightWidth(1600, 0, null, 280)).toBe(280);
});
});
@@ -0,0 +1,18 @@
import { computeRightMaxPx } from "./caps";
import { getPinnedWidth } from "./sizing";
const RIGHT_COLUMN = { id: "right", pinned: true, groups: [] };
export function resolveResponsiveRightWidth(
totalWidth: number | undefined,
sidebarWidth: number,
manualWidth: number | null,
capOverride?: number,
): number {
const cap = capOverride ?? computeRightMaxPx(totalWidth, sidebarWidth);
const width =
totalWidth ??
(typeof window !== "undefined" && window.innerWidth > 0 ? window.innerWidth : cap);
const target = manualWidth ?? getPinnedWidth(RIGHT_COLUMN, width, undefined);
return Math.min(target, cap);
}
@@ -47,10 +47,10 @@ describe("getPinnedWidth — global sidebar width pref", () => {
expect(getPinnedWidth(right(), TOTAL, undefined)).toBe(450);
});
it("keeps the right pane near its old laptop width after the app sidebar is outside dockview", () => {
it("uses a narrower right pane on laptop-sized workbenches", () => {
// MacBook Air-style viewport: 1280px total. The unified AppSidebar's
// 320px default leaves 960px for dockview; 1/3 restores the old 320px
// right pane that used to be computed as 25% of the full dockview.
expect(getPinnedWidth(right(), 960, undefined)).toBe(320);
// 320px default leaves 960px for dockview; 30% gives the right pane a
// roomier 288px default while retaining space for its tools.
expect(getPinnedWidth(right(), 960, undefined)).toBe(288);
});
});
@@ -0,0 +1,90 @@
---
spec: docs/specs/ui/task-layout-profiles.md
created: 2026-07-28
status: implemented
---
# Implementation Plan: Reconcile the Agent Panel After Dockview Restore
## Root cause
The environment restore path replaces stale saved session tabs, but it does not
repair a restored layout that contains no session tab at all. The later React
session-tab effect usually fills that gap, while the remove-panel safety net is
suppressed during `isRestoringLayout`. If no dependency changes after the
restore, the desktop workbench can retain an empty center group.
The active-tab trace is a related but distinct rule: saved non-session tabs such
as Plan are intentionally restored and must remain active. The repair therefore
must add Agent without overriding a valid saved active tab.
## Wave 1
- [x] [task-01-post-restore-session-reconciliation](task-01-post-restore-session-reconciliation.md)
## Design
Add a post-restore reconciliation helper beside the existing stale-session
replacement logic. When an active session has no live `session:*` panel, place
it in this order:
1. the canonical center group;
2. the group containing a contextual Plan/PR/MR tab;
3. the first remaining center-candidate group;
4. the existing sidebar-relative fallback.
Activate the restored Agent only when its target group was empty or no panel is
active. Otherwise add it inactive so a valid saved Plan, Files, or Changes tab
retains focus. Continue restoring sibling sessions as inactive tabs beside the
active session.
Invoke the reconciliation synchronously before `isRestoringLayout` clears so
the workbench cannot expose the empty group. Preserve the atomic stale-session
handoff and existing active-view restoration order. Do not run the missing
Agent repair for a saved maximize overlay because maximizing Terminal, Files,
or another non-Agent group intentionally excludes Agent.
## Tests
RED unit coverage in `apps/web/lib/state/dockview-env-switch.test.ts`:
- a slow-path restore with an empty center group and no saved session panel
must add and activate the incoming Agent in that group;
- a slow-path restore with Plan active and no saved session panel must add
Agent to Plan's group without activating it.
Desktop Playwright coverage extends
`apps/web/e2e/tests/layout/saved-layout-session-isolation.spec.ts` to assert
that task switching never exposes the Dockview empty-group watermark and that
the selected task retains a visible Agent tab.
No mobile test is required: phone and tablet task layouts do not mount
Dockview, and the repair changes state normalization only within the desktop
workbench.
## Verification
```bash
cd apps && pnpm --filter @kandev/web exec vitest run lib/state/dockview-env-switch.test.ts
cd apps/web && pnpm e2e:run tests/layout/saved-layout-session-isolation.spec.ts
make fmt
make typecheck test lint
```
## Risks and exclusions
- Do not activate Agent over a genuinely saved active Plan or other valid tab.
- Do not rebuild or reshape healthy user-customized groups.
- Do not inject Agent into a deliberately maximized non-Agent group.
- Do not change mobile/tablet task composition.
- Network failures fetching PR, commit, or diff data remain out of scope.
## Result
- RED reproduced both missing Agent outcomes after a normal slow-path restore.
- GREEN restores Agent into the center group, activates it only for an empty
target, and preserves saved Plan focus.
- A second RED regression prevented the repair from affecting maximized
non-Agent groups.
- Focused unit tests, the production-build Playwright spec, formatting,
typecheck, full tests, and lint passed on 2026-07-28.
@@ -0,0 +1,58 @@
---
id: "01-post-restore-session-reconciliation"
title: "Repair missing Agent panels at restore completion"
status: done
wave: 1
depends_on: []
parallelism: sequential
plan: "plan.md"
spec: "../../specs/ui/task-layout-profiles.md"
---
# Task 01: Repair Missing Agent Panels at Restore Completion
## Acceptance
- A completed desktop environment restore with an active session always has a
live Agent panel.
- An empty center group receives and activates Agent before restoration ends.
- A valid saved active tab remains active while Agent is restored beside it.
- Existing stale-session replacement remains atomic and sibling session tabs
remain inactive.
- The desktop task-switch regression never displays the empty-group watermark.
## TDD sequence
1. RED: reproduce missing Agent restoration with an empty center-group fake.
2. RED: prove restoring beside active Plan must not activate Agent.
3. GREEN: add the minimum post-restore reconciliation and call it from stale
session replacement.
4. REFACTOR: centralize target-group and activation decisions without changing
existing handoff behavior.
5. E2E: assert the visible desktop task-switch outcome.
## Files
- `apps/web/lib/state/dockview-env-switch.ts`
- `apps/web/lib/state/dockview-env-switch.test.ts`
- `apps/web/e2e/tests/layout/saved-layout-session-isolation.spec.ts`
- `docs/specs/ui/task-layout-profiles.md`
## Verification
```bash
cd apps && pnpm --filter @kandev/web exec vitest run lib/state/dockview-env-switch.test.ts
cd apps/web && pnpm e2e:run tests/layout/saved-layout-session-isolation.spec.ts
make fmt
make typecheck test lint
```
## Result
- A normal slow-path environment restore now repairs a missing active Agent
panel synchronously before restoration completes.
- Empty center groups activate the repaired Agent; a saved active Plan remains
active.
- Saved maximize overlays intentionally excluding Agent remain unchanged.
- Unit regressions, the focused production Playwright spec, formatting,
typecheck, the full test suite, and lint passed on 2026-07-28.
@@ -0,0 +1,111 @@
---
spec: docs/specs/ui/task-layout-profiles.md
created: 2026-07-28
status: completed
---
# Implementation Plan: Responsive Dockview Right-Column Width
## Overview
Separate the right column's automatically calculated default from an explicit
desktop sash resize. Existing environment layouts contain only serialized
Dockview geometry, so their right width must be treated as responsive by
default. A new environment-scoped manual-width marker will preserve a raw
user-requested right width across reloads and display changes.
## Confirmed Root Cause
Every persisted right-column size is currently forwarded from the serialized
environment layout to `resolveRightTarget`. That value wins over the ratio
default, so a 450px automatic large-display width is restored unchanged on a
laptop. The live-width synchronizers also place automatic widths in
`pinnedWidths`, making later layout operations treat them as overrides.
## Frontend
### Manual-width persistence
- Add a versioned, session-scoped companion key in
`apps/web/lib/local-storage.ts` for a raw manual right-column width per
environment. Legacy layouts have no marker and therefore remain responsive.
- Record the marker only after the existing genuine right-sash mouseup path in
`apps/web/components/task/dockview-layout-setup.ts`. Do not record it for
`ResizeObserver`, `api.layout`, restore, or programmatic layout changes.
### Restore and resize resolution
- Update `apps/web/lib/state/dockview-layout-builders.ts`,
`apps/web/lib/state/dockview-env-switch.ts`, and
`apps/web/lib/state/dockview-store.ts` so the right target is the manual
marker when present; otherwise it is `getPinnedWidth` for the current
measured Dockview width.
- On container resize, recalculate the automatic target before enforcing it.
Keep a manual raw width stored even when it is temporarily clamped by the
laptop cap, so it returns on a larger display.
- Keep task-specific panel structure, user layout profiles, the global left
sidebar preference, and the existing runtime caps unchanged.
## Tests
- **What:** a serialized legacy/automatic 450px right column resolves to the
current ratio rather than 450px. **Files:**
`apps/web/lib/state/dockview-layout-builders-fixups.test.ts` and
`apps/web/lib/state/dockview-env-switch-pinned.test.ts`.
- **What:** a genuine right-sash resize creates a manual marker and that marker
wins across restore and fast environment switches. **Files:**
`apps/web/components/task/dockview-layout-setup.test.ts` (or a focused new
sibling test) plus `apps/web/lib/local-storage.test.ts`.
- **What:** automatic layout events do not become manual overrides. **Files:**
`apps/web/lib/state/dockview-store.test.ts`.
## E2E Tests
- **Scenario:** a fresh desktop task moves large → laptop → large and the
right Files/Changes/Terminal column follows the default ratio at each width.
**File:** `apps/web/e2e/tests/layout/pane-resize-right.spec.ts`.
- **Scenario:** a manually resized right column moves large → laptop → large;
it is clamped only on the laptop and returns to the requested width on the
large display. **File:** `apps/web/e2e/tests/layout/pane-resize-right.spec.ts`.
## Mobile and Tablet
`TaskLayout` selects `SessionMobileLayout` and `SessionTabletLayout` before
the desktop Dockview workbench. No phone or tablet geometry or interaction
changes. The nearest mobile exemplar is
`apps/web/components/task/mobile/session-mobile-layout.tsx`; it keeps its
existing single-panel navigation and scroll ownership. The desktop browser
tests are the rendered verification for this desktop-only path.
## Implementation Waves
Wave 1 (sequential):
- [x] [task-01-responsive-right-width](task-01-responsive-right-width.md)
## Verification
```bash
cd apps && pnpm --filter @kandev/web test -- --run \
lib/state/dockview-layout-builders-fixups.test.ts \
lib/state/dockview-env-switch-pinned.test.ts \
lib/state/dockview-store.test.ts
cd apps/web && pnpm e2e:run tests/layout/pane-resize-right.spec.ts
cd apps/web && pnpm run typecheck
```
## Risks
- Automatic layout changes must never create a manual marker, or a monitor
switch will become sticky again.
- The raw manual width must survive a temporary cap without persisting the
capped value as the user preference.
- Legacy persisted layouts lack intent metadata; treating them as automatic is
required to repair the reported issue, but it deliberately changes their
prior width-restoration behavior.
## Documentation Impact
No public documentation changes are required. The affected internal product
spec is updated in `docs/specs/ui/task-layout-profiles.md`. This is a local
layout-persistence repair, so no ADR is warranted.
@@ -0,0 +1,80 @@
---
id: "01-responsive-right-width"
title: "Separate responsive and manual right-column widths"
status: completed
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/task-layout-profiles.md"
---
# Task 01: Separate Responsive and Manual Right-Column Widths
## Acceptance
- A right column without a genuine sash-resize marker recomputes from the
current Dockview width on reload, environment switch, and container resize.
- A genuine right-sash resize stores its raw requested width per environment;
it survives reload and returns after a temporary narrow-screen clamp.
- Existing serialized environment layouts with no marker are treated as
responsive defaults; mobile, tablet, layout profiles, and the global left
sidebar are unchanged.
## TDD Sequence
1. RED: prove a saved 450px automatic right column restores at the laptop
ratio, not at 450px, and that a container expand returns it to the wider
ratio.
2. RED: prove a manual marker wins over the responsive target and its raw
width returns after a laptop clamp.
3. GREEN: add the environment-scoped marker, write it only on a genuine sash
mouseup, and route restore/enforcement/fast-switch targets through it.
4. REFACTOR: centralize the right-target selection so all Dockview entry
points use the same automatic-versus-manual rule.
## Verification
```bash
cd apps && pnpm --filter @kandev/web test -- --run \
lib/state/dockview-layout-builders-fixups.test.ts \
lib/state/dockview-env-switch-pinned.test.ts \
lib/state/dockview-store.test.ts
cd apps/web && pnpm e2e:run tests/layout/pane-resize-right.spec.ts
cd apps/web && pnpm run typecheck
```
## Files likely touched
- `apps/web/lib/local-storage.ts`
- `apps/web/lib/local-storage.test.ts`
- `apps/web/components/task/dockview-layout-setup.ts`
- `apps/web/components/task/dockview-layout-setup.test.ts`
- `apps/web/lib/state/dockview-layout-builders.ts`
- `apps/web/lib/state/dockview-layout-builders-fixups.test.ts`
- `apps/web/lib/state/dockview-env-switch.ts`
- `apps/web/lib/state/dockview-env-switch-pinned.test.ts`
- `apps/web/lib/state/dockview-store.ts`
- `apps/web/lib/state/dockview-store.test.ts`
- `apps/web/e2e/tests/layout/pane-resize-right.spec.ts`
## Dependencies
None.
## Parallelism
Sequential. Persistence metadata, target selection, and browser coverage share
the same behavior and must land together.
## Inputs
- `docs/specs/ui/task-layout-profiles.md`, especially Persistence guarantees
and the display-switch scenarios.
- `docs/plans/dockview-responsive-right-width/plan.md`.
- Existing user-resize persistence coverage in
`apps/web/e2e/tests/layout/pane-resize-right.spec.ts`.
## Output contract
Report the RED failures, the automatic/manual target rules, exact files
changed, targeted unit/E2E/typecheck results, and task/plan status updates.
+10 -2
View File
@@ -1,5 +1,5 @@
---
status: shipped
status: building
created: 2026-07-19
owner: kandev
---
@@ -26,6 +26,8 @@ Users can arrange and save the desktop task workbench only while a task is open,
- The existing workbench layout menu continues to apply built-in and custom profiles and save the current workbench as a custom profile. Profile mutations from either surface remain consistent after the user-settings response is received.
- Layout-profile editing is usable with pointer, keyboard, and touch input. On narrow settings viewports, profile management and all editor commands remain reachable without horizontal page scrolling.
- Layout profiles configure the desktop Dockview workbench only. Mobile and tablet task-detail layouts retain their existing behavior.
- The default right-side workbench column is responsive: its Files, Changes, and Terminal panels resize together from the current desktop workbench width whenever the display changes.
- A right-column resize performed through the desktop sash is an explicit per-task-environment preference. It persists across reloads and display changes, while still respecting the current screen's safety cap.
## Data model
@@ -45,7 +47,7 @@ The built-in layouts are code-defined templates. A customization is stored in `s
The editor persists the existing declarative `LayoutState`: ordered columns contain ordered groups, groups contain ordered panels and an active panel, and captured tree/size data preserves split placement and proportions. New editor-created profiles use only the reusable panel registry. A legacy profile with an unreadable layout remains listed for rename, duplication, deletion, or default removal, but cannot enter the visual editor or become a new default until replaced with a valid reusable layout.
Task-specific restored layouts remain device-local environment state and take precedence over the user default. They are not copied into or overwritten by layout-profile edits.
Task-specific restored layouts remain device-local environment state and take precedence over the user default. They are not copied into or overwritten by layout-profile edits. The serialized Dockview layout preserves panel structure and transient geometry. A companion environment-scoped preference stores a raw right-column width only after a genuine user sash drag; legacy layouts and layouts without that preference are responsive defaults rather than manual overrides.
## API surface
@@ -70,7 +72,9 @@ The frontend treats the returned settings payload as authoritative after each su
- Custom profiles and the selected custom default survive browser and Kandev restarts through backend user settings and are portable across the user's devices.
- An unsaved editor draft does not survive navigation or restart.
- Per-task layout state continues to use its existing environment-scoped persistence and is not made portable by this feature.
- A saved default right-column geometry adapts to the current workbench width on reload, monitor switch, and return to a wider monitor. A manual right-column width keeps its raw requested width across those events and is only clamped while the current screen cannot accommodate it.
- A task handoff within the same environment does not persist an intermediate panel-removal state or change the root split orientation.
- Completing a normal, non-maximized desktop task-layout restore re-establishes a live Agent panel for the active session before the workbench is revealed. If the restored center group is empty, Agent is inserted there and activated; if another valid saved center tab such as Plan is active, that tab remains active.
## Scenarios
@@ -83,6 +87,9 @@ The frontend treats the returned settings payload as authoritative after each su
- **GIVEN** an existing task with a task-specific layout, **WHEN** the user changes the default profile and returns to that task, **THEN** the task-specific layout is unchanged.
- **GIVEN** an existing task with a task-specific layout, **WHEN** the user chooses Reset Layout, **THEN** the latest effective default profile replaces that task's layout.
- **GIVEN** two tasks whose active sessions share one task environment and a desktop workbench with Agent in the center and Files or Changes above Terminal on the right, **WHEN** the user switches between those tasks, **THEN** the incoming Agent replaces the outgoing Agent in the same group, the right column remains vertically split, the root remains horizontally split, and the same geometry survives reload.
- **GIVEN** a normal desktop task layout restore completes while the active session's Agent panel is absent, **WHEN** the center group is empty, **THEN** the Agent panel is restored into that group and activated before the workbench is shown; when Plan or another valid saved center tab is active, restoring Agent does not steal focus from it, and a deliberately maximized non-Agent group remains unchanged.
- **GIVEN** a task environment whose right column has never been manually resized, **WHEN** its desktop workbench moves from a large monitor to a laptop-sized workbench and back, **THEN** the Files, Changes, and Terminal column follows the default ratio at each width and returns to the large-workbench ratio.
- **GIVEN** a task environment whose right column was manually resized through its desktop sash, **WHEN** the workbench moves between large and laptop-sized displays, **THEN** that requested width is restored whenever it fits and is temporarily clamped only to preserve the current screen's minimum center width.
- **GIVEN** a custom default profile, **WHEN** the user deletes it and confirms, **THEN** the built-in Default becomes effective.
- **GIVEN** a profile draft with Agent removed, duplicate reusable panels, or an empty group, **WHEN** the user attempts to save, **THEN** saving is blocked and the invalid locations are identified.
- **GIVEN** a backend save failure, **WHEN** the user saves a profile edit, **THEN** the draft remains available and the previous persisted layout stays selected.
@@ -91,6 +98,7 @@ The frontend treats the returned settings payload as authoritative after each su
## Out of scope
- Customizing mobile or tablet task-detail layouts.
- Changing the global app sidebar width or other layout-profile split proportions.
- Forcing a changed default onto existing task-specific layouts without Reset Layout.
- Configuring task-specific panels such as individual files, diffs, commits, pull requests, extra sessions, or extra terminals.
- Mutating the code-defined built-in definitions; direct edits are persisted as hidden user overrides.