fix(web): reconcile responsive task layouts (#2011)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
+58
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user