diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index 29e32f7014..ea3f480484 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -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 void>>; saveTimerRef: React.MutableRefObject | null>; setApi: (api: DockviewReadyEvent["api"] | null) => void; + layoutRootRef: React.MutableRefObject; }; 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], diff --git a/apps/web/components/task/dockview-layout-restore.ts b/apps/web/components/task/dockview-layout-restore.ts index 43ca85c8e9..b0ef342b8b 100644 --- a/apps/web/components/task/dockview-layout-restore.ts +++ b/apps/web/components/task/dockview-layout-restore.ts @@ -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; function applySavedMaximize( api: DockviewReadyEvent["api"], savedMax: NonNullable, - 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; } diff --git a/apps/web/components/task/dockview-layout-setup.ts b/apps/web/components/task/dockview-layout-setup.ts index cba89295c3..4b4f83ea52 100644 --- a/apps/web/components/task/dockview-layout-setup.ts +++ b/apps/web/components/task/dockview-layout-setup.ts @@ -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 diff --git a/apps/web/e2e/helpers/dockview-resize.ts b/apps/web/e2e/helpers/dockview-resize.ts index 0f4fd329e2..9ab8c4731c 100644 --- a/apps/web/e2e/helpers/dockview-resize.ts +++ b/apps/web/e2e/helpers/dockview-resize.ts @@ -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 diff --git a/apps/web/e2e/tests/layout/pane-resize-right.spec.ts b/apps/web/e2e/tests/layout/pane-resize-right.spec.ts index e1f2abab45..f1b6da6ba2 100644 --- a/apps/web/e2e/tests/layout/pane-resize-right.spec.ts +++ b/apps/web/e2e/tests/layout/pane-resize-right.spec.ts @@ -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); diff --git a/apps/web/e2e/tests/layout/saved-layout-session-isolation.spec.ts b/apps/web/e2e/tests/layout/saved-layout-session-isolation.spec.ts index 3cc6900fba..459b990537 100644 --- a/apps/web/e2e/tests/layout/saved-layout-session-isolation.spec.ts +++ b/apps/web/e2e/tests/layout/saved-layout-session-isolation.spec.ts @@ -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 ({ diff --git a/apps/web/lib/local-storage.test.ts b/apps/web/lib/local-storage.test.ts index e6a47f6e3f..5988497b4c 100644 --- a/apps/web/lib/local-storage.test.ts +++ b/apps/web/lib/local-storage.test.ts @@ -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(); diff --git a/apps/web/lib/local-storage.ts b/apps/web/lib/local-storage.ts index c11b835e2d..e5f19ef876 100644 --- a/apps/web/lib/local-storage.ts +++ b/apps/web/lib/local-storage.ts @@ -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( + `${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. diff --git a/apps/web/lib/state/dockview-env-switch-action.test.ts b/apps/web/lib/state/dockview-env-switch-action.test.ts index f1de3c88b2..b54ff90624 100644 --- a/apps/web/lib/state/dockview-env-switch-action.test.ts +++ b/apps/web/lib/state/dockview-env-switch-action.test.ts @@ -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(); + }); }); diff --git a/apps/web/lib/state/dockview-env-switch-pinned.test.ts b/apps/web/lib/state/dockview-env-switch-pinned.test.ts index a9703c4d90..29bb6f627e 100644 --- a/apps/web/lib/state/dockview-env-switch-pinned.test.ts +++ b/apps/web/lib/state/dockview-env-switch-pinned.test.ts @@ -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, ); + 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>, ); - 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); }); }); diff --git a/apps/web/lib/state/dockview-env-switch.test.ts b/apps/web/lib/state/dockview-env-switch.test.ts index d1a7376bc5..9bf7435702 100644 --- a/apps/web/lib/state/dockview-env-switch.test.ts +++ b/apps/web/lib/state/dockview-env-switch.test.ts @@ -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(); }); diff --git a/apps/web/lib/state/dockview-env-switch.ts b/apps/web/lib/state/dockview-env-switch.ts index 72eec7a7a1..6c73404664 100644 --- a/apps/web/lib/state/dockview-env-switch.ts +++ b/apps/web/lib/state/dockview-env-switch.ts @@ -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 }); diff --git a/apps/web/lib/state/dockview-layout-builders-fixups.test.ts b/apps/web/lib/state/dockview-layout-builders-fixups.test.ts index b2fb1c2802..a6107545c3 100644 --- a/apps/web/lib/state/dockview-layout-builders-fixups.test.ts +++ b/apps/web/lib/state/dockview-layout-builders-fixups.test.ts @@ -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 }); }); diff --git a/apps/web/lib/state/dockview-layout-builders.ts b/apps/web/lib/state/dockview-layout-builders.ts index 167f1472f3..6aa21fe23b 100644 --- a/apps/web/lib/state/dockview-layout-builders.ts +++ b/apps/web/lib/state/dockview-layout-builders.ts @@ -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 { diff --git a/apps/web/lib/state/dockview-measure.test.ts b/apps/web/lib/state/dockview-measure.test.ts index 86954ec3b3..057c3c6efb 100644 --- a/apps/web/lib/state/dockview-measure.test.ts +++ b/apps/web/lib/state/dockview-measure.test.ts @@ -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); diff --git a/apps/web/lib/state/dockview-measure.ts b/apps/web/lib/state/dockview-measure.ts index de1c23cc87..c86d85451b 100644 --- a/apps/web/lib/state/dockview-measure.ts +++ b/apps/web/lib/state/dockview-measure.ts @@ -1,5 +1,31 @@ import type { DockviewApi } from "dockview-react"; +const dockviewRoots = new WeakMap(); + +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(".dv-dockview"); + return typeof document !== "undefined" + ? document.querySelector(".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 diff --git a/apps/web/lib/state/dockview-pinned-enforce.test.ts b/apps/web/lib/state/dockview-pinned-enforce.test.ts new file mode 100644 index 0000000000..a842bd9b48 --- /dev/null +++ b/apps/web/lib/state/dockview-pinned-enforce.test.ts @@ -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); + }); +}); diff --git a/apps/web/lib/state/dockview-pinned-enforce.ts b/apps/web/lib/state/dockview-pinned-enforce.ts index 8784196b78..135ba0885f 100644 --- a/apps/web/lib/state/dockview-pinned-enforce.ts +++ b/apps/web/lib/state/dockview-pinned-enforce.ts @@ -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; diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts index c5e9854c93..62f54b97e1 100644 --- a/apps/web/lib/state/dockview-store.ts +++ b/apps/web/lib/state/dockview-store.ts @@ -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; diff --git a/apps/web/lib/state/layout-manager/constants.ts b/apps/web/lib/state/layout-manager/constants.ts index c99c235b63..7241bda674 100644 --- a/apps/web/lib/state/layout-manager/constants.ts +++ b/apps/web/lib/state/layout-manager/constants.ts @@ -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"; diff --git a/apps/web/lib/state/layout-manager/right-width.test.ts b/apps/web/lib/state/layout-manager/right-width.test.ts new file mode 100644 index 0000000000..6a3cf71926 --- /dev/null +++ b/apps/web/lib/state/layout-manager/right-width.test.ts @@ -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); + }); +}); diff --git a/apps/web/lib/state/layout-manager/right-width.ts b/apps/web/lib/state/layout-manager/right-width.ts new file mode 100644 index 0000000000..4a13397b14 --- /dev/null +++ b/apps/web/lib/state/layout-manager/right-width.ts @@ -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); +} diff --git a/apps/web/lib/state/layout-manager/sizing.test.ts b/apps/web/lib/state/layout-manager/sizing.test.ts index 8ceee12ae9..326d8f289e 100644 --- a/apps/web/lib/state/layout-manager/sizing.test.ts +++ b/apps/web/lib/state/layout-manager/sizing.test.ts @@ -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); }); }); diff --git a/docs/plans/dockview-post-restore-session-reconciliation/plan.md b/docs/plans/dockview-post-restore-session-reconciliation/plan.md new file mode 100644 index 0000000000..e13576167d --- /dev/null +++ b/docs/plans/dockview-post-restore-session-reconciliation/plan.md @@ -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. diff --git a/docs/plans/dockview-post-restore-session-reconciliation/task-01-post-restore-session-reconciliation.md b/docs/plans/dockview-post-restore-session-reconciliation/task-01-post-restore-session-reconciliation.md new file mode 100644 index 0000000000..0127013d09 --- /dev/null +++ b/docs/plans/dockview-post-restore-session-reconciliation/task-01-post-restore-session-reconciliation.md @@ -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. diff --git a/docs/plans/dockview-responsive-right-width/plan.md b/docs/plans/dockview-responsive-right-width/plan.md new file mode 100644 index 0000000000..19afedf6b6 --- /dev/null +++ b/docs/plans/dockview-responsive-right-width/plan.md @@ -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. diff --git a/docs/plans/dockview-responsive-right-width/task-01-responsive-right-width.md b/docs/plans/dockview-responsive-right-width/task-01-responsive-right-width.md new file mode 100644 index 0000000000..b44bffcbee --- /dev/null +++ b/docs/plans/dockview-responsive-right-width/task-01-responsive-right-width.md @@ -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. diff --git a/docs/specs/ui/task-layout-profiles.md b/docs/specs/ui/task-layout-profiles.md index e63839f374..0034d28e4a 100644 --- a/docs/specs/ui/task-layout-profiles.md +++ b/docs/specs/ui/task-layout-profiles.md @@ -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.