fix: preserve custom default layout proportions (#3201)
This commit is contained in:
@@ -4,6 +4,11 @@ import type { ApiClient } from "../../helpers/api-client";
|
||||
import { LayoutSettingsPage } from "../../pages/layout-settings-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { dwell } from "../../helpers/causal-waits";
|
||||
import {
|
||||
expectApproxWidth,
|
||||
getDockviewContainerWidth,
|
||||
getDockviewGroupWidth,
|
||||
} from "../../helpers/dockview-resize";
|
||||
|
||||
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
|
||||
const PR_NUMBER = 702;
|
||||
@@ -21,6 +26,7 @@ function noTerminalLayout() {
|
||||
columns: [
|
||||
{
|
||||
id: "center",
|
||||
width: 1050,
|
||||
groups: [
|
||||
{
|
||||
id: "group-center",
|
||||
@@ -197,6 +203,9 @@ async function expectNoTerminalDefault(page: Page): Promise<void> {
|
||||
});
|
||||
const snapshot = await dockviewSnapshot(page);
|
||||
expect(snapshot.panelIds).not.toContain("terminal-default");
|
||||
const containerWidth = await getDockviewContainerWidth(page);
|
||||
const rightWidth = await getDockviewGroupWidth(page, "files");
|
||||
expectApproxWidth(rightWidth, Math.round(containerWidth * 0.25), 12);
|
||||
}
|
||||
|
||||
async function ordinaryShells(apiClient: ApiClient, taskId: string) {
|
||||
|
||||
@@ -75,6 +75,7 @@ function makeParams(): EnvSwitchParams {
|
||||
describe("performEnvSwitch — pinned column resize after fast-path", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getEnvLayout).mockReturnValue(null);
|
||||
vi.mocked(getManualRightWidth).mockReturnValue(null);
|
||||
});
|
||||
|
||||
@@ -201,3 +202,30 @@ describe("performEnvSwitch — pinned column resize after fast-path", () => {
|
||||
expect(applyLayoutFixups).toHaveBeenCalledWith(expect.anything(), 420, 420);
|
||||
});
|
||||
});
|
||||
|
||||
describe("performEnvSwitch — custom default fast-path widths", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getEnvLayout).mockReturnValue(null);
|
||||
vi.mocked(getManualRightWidth).mockReturnValue(null);
|
||||
});
|
||||
|
||||
it("uses custom default proportions for an unsaved environment", () => {
|
||||
const resizeView = vi.fn();
|
||||
vi.mocked(getRootSplitview).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
length: 3,
|
||||
getViewSize: () => 800,
|
||||
resizeView,
|
||||
}) as unknown as NonNullable<ReturnType<typeof getRootSplitview>>,
|
||||
);
|
||||
|
||||
const params = makeParams();
|
||||
params.getDefaultPinnedWidths = vi.fn(() => new Map([["right", 240]]));
|
||||
|
||||
performEnvSwitch(params);
|
||||
|
||||
expect(resizeView).toHaveBeenCalledWith(2, 240);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,6 +94,8 @@ export type EnvSwitchParams = {
|
||||
/** Build the effective default, optionally honoring a route layout intent. */
|
||||
buildDefault: (api: DockviewApi, intentName?: string) => void;
|
||||
getDefaultLayout: () => LayoutState;
|
||||
/** Resolve pinned widths from the effective custom default for a workbench. */
|
||||
getDefaultPinnedWidths?: (totalWidth: number) => Map<string, number>;
|
||||
/** Explicit layout from the task route, such as `?layout=plan`. */
|
||||
initialLayout?: string | null;
|
||||
};
|
||||
@@ -335,13 +337,14 @@ 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 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)
|
||||
// - no saved layout with a custom default → use its scaled pinned widths
|
||||
// - otherwise → compute fresh defaults via getPinnedWidth
|
||||
applyPinnedColumnSizes(
|
||||
api,
|
||||
saved as SerializedDockview | null,
|
||||
params.safeWidth,
|
||||
getManualRightWidth(newEnvId),
|
||||
!saved ? (params.getDefaultPinnedWidths?.(params.safeWidth) ?? new Map()) : new Map(),
|
||||
);
|
||||
|
||||
api.layout(params.safeWidth, params.safeHeight);
|
||||
@@ -405,8 +408,7 @@ 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. Right-column geometry from a
|
||||
* serialized layout is intentionally ignored unless a manual preference exists. */
|
||||
/** Compute the target width for a pinned column from the target environment. */
|
||||
// eslint-disable-next-line max-params
|
||||
function targetPinnedWidth(
|
||||
col: LayoutState["columns"][number],
|
||||
@@ -415,7 +417,12 @@ function targetPinnedWidth(
|
||||
totalWidth: number,
|
||||
manualRightWidth: number | null,
|
||||
sidebarWidth: number,
|
||||
defaultPinnedWidths: ReadonlyMap<string, number>,
|
||||
): number | undefined {
|
||||
if (!savedSizes && manualRightWidth === null) {
|
||||
const defaultWidth = defaultPinnedWidths.get(col.id);
|
||||
if (typeof defaultWidth === "number" && defaultWidth > 0) return defaultWidth;
|
||||
}
|
||||
if (col.id === "right") {
|
||||
return resolveResponsiveRightWidth(totalWidth, sidebarWidth, manualRightWidth);
|
||||
}
|
||||
@@ -435,6 +442,7 @@ function applyPinnedColumnSizes(
|
||||
saved: SerializedDockview | null,
|
||||
totalWidth: number,
|
||||
manualRightWidth: number | null,
|
||||
defaultPinnedWidths: ReadonlyMap<string, number> = new Map(),
|
||||
): void {
|
||||
const sv = getRootSplitview(api);
|
||||
if (!sv || sv.length < 2) return;
|
||||
@@ -460,7 +468,15 @@ function applyPinnedColumnSizes(
|
||||
const target =
|
||||
col.id === "sidebar"
|
||||
? getPinnedWidth(col, totalWidth, undefined)
|
||||
: targetPinnedWidth(col, i, savedSizes, totalWidth, manualRightWidth, sidebarTarget);
|
||||
: targetPinnedWidth(
|
||||
col,
|
||||
i,
|
||||
savedSizes,
|
||||
totalWidth,
|
||||
manualRightWidth,
|
||||
sidebarTarget,
|
||||
defaultPinnedWidths,
|
||||
);
|
||||
if (typeof target !== "number" || target <= 0) continue;
|
||||
try {
|
||||
sv.resizeView(i, target);
|
||||
|
||||
@@ -79,7 +79,13 @@ vi.mock("./layout-manager", async (importOriginal) => {
|
||||
|
||||
import { removeEnvMaximizeState, setEnvLayout } from "@/lib/local-storage";
|
||||
import { persistEnvLayoutNow, useDockviewStore } from "./dockview-store";
|
||||
import { applyLayout, defaultLayout, fromDockviewApi } from "./layout-manager";
|
||||
import {
|
||||
applyLayout,
|
||||
defaultLayout,
|
||||
fromDockviewApi,
|
||||
getPresetLayout,
|
||||
resolveNamedIntent,
|
||||
} from "./layout-manager";
|
||||
|
||||
function makeApi(snapshot: object = { columns: [] }): DockviewApi {
|
||||
return {
|
||||
@@ -177,6 +183,8 @@ function resetStoreForIntegration() {
|
||||
preMaximizeLayout: null,
|
||||
maximizedGroupId: null,
|
||||
isRestoringLayout: false,
|
||||
pinnedWidths: new Map(),
|
||||
userDefaultLayout: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -313,6 +321,37 @@ describe("applyBuiltInPreset — persistence at call site", () => {
|
||||
describe("resetLayout — effective default persistence", () => {
|
||||
beforeEach(resetStoreForIntegration);
|
||||
|
||||
// @covers AC-UI-TASK-LAYOUT-PROFILES-001.9 AC-UI-TASK-LAYOUT-PROFILES-001.10
|
||||
it("uses scaled proportions from the effective custom default", () => {
|
||||
const api = makeStoreApi();
|
||||
const userDefaultLayout = {
|
||||
columns: [
|
||||
{
|
||||
id: "center",
|
||||
width: 700,
|
||||
groups: [{ panels: [{ id: "chat", component: "chat", title: "Agent" }] }],
|
||||
},
|
||||
{
|
||||
id: "right",
|
||||
pinned: true,
|
||||
width: 300,
|
||||
groups: [{ panels: [{ id: "files", component: "files", title: "Files" }] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
useDockviewStore.setState({
|
||||
api,
|
||||
currentLayoutEnvId: "env-reset",
|
||||
userDefaultLayout,
|
||||
});
|
||||
|
||||
useDockviewStore.getState().resetLayout();
|
||||
|
||||
const appliedWidths = vi.mocked(applyLayout).mock.calls.at(-1)?.[2];
|
||||
expect(appliedWidths).toEqual(new Map([["right", 240]]));
|
||||
expect(useDockviewStore.getState().pinnedWidths).toBe(appliedWidths);
|
||||
});
|
||||
|
||||
it("applies the current user default and persists it for the active environment", async () => {
|
||||
const api = makeStoreApi();
|
||||
const userDefaultLayout = {
|
||||
@@ -357,6 +396,69 @@ describe("resetLayout — effective default persistence", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDefaultLayout — effective default widths", () => {
|
||||
beforeEach(resetStoreForIntegration);
|
||||
|
||||
it("uses scaled proportions when building a custom default without an intent", () => {
|
||||
const api = makeStoreApi();
|
||||
useDockviewStore.setState({
|
||||
api,
|
||||
userDefaultLayout: {
|
||||
columns: [
|
||||
{ id: "center", width: 700, groups: [] },
|
||||
{ id: "right", pinned: true, width: 300, groups: [] },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
useDockviewStore.getState().buildDefaultLayout(api);
|
||||
|
||||
expect(applyLayout).toHaveBeenLastCalledWith(
|
||||
api,
|
||||
expect.anything(),
|
||||
new Map([["right", 240]]),
|
||||
800,
|
||||
600,
|
||||
);
|
||||
expect(useDockviewStore.getState().pinnedWidths).toEqual(new Map([["right", 240]]));
|
||||
});
|
||||
|
||||
it("keeps named preset widths responsive", () => {
|
||||
const api = makeStoreApi();
|
||||
const planLayout = {
|
||||
columns: [
|
||||
{
|
||||
id: "center",
|
||||
width: 600,
|
||||
groups: [{ panels: [{ id: "chat", component: "chat", title: "Agent" }] }],
|
||||
},
|
||||
{
|
||||
id: "right",
|
||||
pinned: true,
|
||||
width: 200,
|
||||
groups: [{ panels: [{ id: "plan", component: "plan", title: "Plan" }] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(resolveNamedIntent).mockReturnValue({ preset: "plan" });
|
||||
vi.mocked(getPresetLayout).mockReturnValue(planLayout);
|
||||
useDockviewStore.setState({
|
||||
api,
|
||||
userDefaultLayout: {
|
||||
columns: [
|
||||
{ id: "center", width: 700, groups: [] },
|
||||
{ id: "right", pinned: true, width: 300, groups: [] },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
useDockviewStore.getState().buildDefaultLayout(api, "plan");
|
||||
|
||||
expect(applyLayout).toHaveBeenLastCalledWith(api, planLayout, new Map(), 800, 600);
|
||||
expect(useDockviewStore.getState().pinnedWidths).toEqual(new Map());
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleRightPanels — center fallback", () => {
|
||||
beforeEach(resetStoreForIntegration);
|
||||
|
||||
|
||||
@@ -987,6 +987,12 @@ function buildEnvSwitchAction(set: StoreSet, get: StoreGet) {
|
||||
safeHeight: measured.height,
|
||||
buildDefault: (a, intentName) => get().buildDefaultLayout(a, intentName),
|
||||
getDefaultLayout: () => get().userDefaultLayout ?? getPresetLayout(get().defaultPreset),
|
||||
getDefaultPinnedWidths: (width) => {
|
||||
const defaultLayout = get().userDefaultLayout;
|
||||
return defaultLayout
|
||||
? resolveCustomLayoutPinnedWidths(defaultLayout.columns, width)
|
||||
: new Map();
|
||||
},
|
||||
initialLayout,
|
||||
});
|
||||
set(ids);
|
||||
@@ -1082,6 +1088,18 @@ function buildMaximizeActions(set: StoreSet, get: StoreGet) {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBuildDefaultPinnedWidths(
|
||||
state: LayoutState,
|
||||
userDefaultLayout: LayoutState | null,
|
||||
basePreset: BuiltInPreset | undefined,
|
||||
availableWidth: number,
|
||||
): Map<string, number> {
|
||||
if (!userDefaultLayout || basePreset) return new Map();
|
||||
// Intent panel injection preserves column widths, so the effective state
|
||||
// still carries the saved custom-default geometry used for scaling.
|
||||
return resolveCustomLayoutPinnedWidths(state.columns, availableWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and apply the default layout — the user's saved default or the active
|
||||
* preset, optionally injected with an intent's panels/active-panel overrides —
|
||||
@@ -1096,7 +1114,6 @@ function performBuildDefault(
|
||||
): void {
|
||||
const { userDefaultLayout } = get();
|
||||
const intent = intentName ? resolveNamedIntent(intentName) : null;
|
||||
const freshPinned = new Map<string, number>();
|
||||
// Capture dimensions before layout change — api.width can become stale
|
||||
// after fromJSON inside applyLayout
|
||||
const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api);
|
||||
@@ -1107,7 +1124,6 @@ function performBuildDefault(
|
||||
`pre=${formatWidthsSnapshot(snapshotColumnWidths(api))}`,
|
||||
);
|
||||
}
|
||||
set({ isRestoringLayout: true, pinnedWidths: freshPinned });
|
||||
|
||||
const basePreset = intent?.preset as BuiltInPreset | undefined;
|
||||
let state = basePreset
|
||||
@@ -1121,7 +1137,15 @@ function performBuildDefault(
|
||||
state = applyActivePanelOverrides(state, intent.activePanels);
|
||||
}
|
||||
|
||||
const ids = applyLayout(api, state, freshPinned, safeWidth, safeHeight);
|
||||
const pinnedWidths = resolveBuildDefaultPinnedWidths(
|
||||
state,
|
||||
userDefaultLayout,
|
||||
basePreset,
|
||||
safeWidth,
|
||||
);
|
||||
set({ isRestoringLayout: true, pinnedWidths });
|
||||
|
||||
const ids = applyLayout(api, state, pinnedWidths, safeWidth, safeHeight);
|
||||
const hasSidebar = state.columns.some((c) => c.id === "sidebar");
|
||||
const hasRight = state.columns.length > (hasSidebar ? 2 : 1);
|
||||
set({ ...ids, sidebarVisible: hasSidebar, rightPanelsVisible: hasRight });
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
created: 2026-08-31
|
||||
status: done
|
||||
requirements:
|
||||
- REQ-UI-TASK-LAYOUT-PROFILES-001
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/task-layout-profiles.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Custom Default Layout Proportions
|
||||
|
||||
## Overview
|
||||
|
||||
Restore the saved proportions when a custom profile supplies the effective desktop default. One work order adds regression evidence before it changes the shared default-layout path.
|
||||
|
||||
## Confirmed root cause
|
||||
|
||||
The saved profile contains its column widths and nested split sizes. Explicit profile selection resolves those values for the current workbench.
|
||||
|
||||
Fresh task setup and Reset Layout use `performBuildDefault`. This function passes an empty pinned-width map to `applyLayout`. The layout manager then substitutes responsive built-in widths for pinned columns. A fast environment switch can take the same shortcut without calling `performBuildDefault`, so it must receive the same resolved custom-default widths.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Preserve saved custom-default proportions for fresh desktop task environments.
|
||||
- Preserve the same proportions after Reset Layout.
|
||||
- Preserve the proportions when a fast switch enters an unsaved environment.
|
||||
- Scale complete saved geometry to the current workbench before safety caps apply.
|
||||
- Keep the runtime pinned-width state consistent with the applied geometry.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Changes to the saved-layout JSON format or backend persistence.
|
||||
- Changes to explicit saved-profile selection, which already restores saved proportions.
|
||||
- Changes to per-environment manual sash widths.
|
||||
- Changes to mobile or tablet task layouts.
|
||||
|
||||
## Technical approach
|
||||
|
||||
Update `performBuildDefault` in `apps/web/lib/state/dockview-store.ts`. Detect when the base state comes from `userDefaultLayout` instead of an intent or built-in preset. Pass the same resolver result into the fast environment-switch path for an unsaved environment.
|
||||
|
||||
For that path, call `resolveCustomLayoutPinnedWidths` with the final state and measured workbench width. Pass the result to `applyLayout` and store it in `pinnedWidths`.
|
||||
|
||||
Keep an empty pinned-width map for code-defined presets and named intents. This rule preserves their current responsive width behavior.
|
||||
|
||||
## Tests
|
||||
|
||||
- **AC-UI-TASK-LAYOUT-PROFILES-001.9:** Add a focused test to `apps/web/lib/state/dockview-preset-persistence.test.ts`. The test proves that Reset Layout sends scaled custom-default widths to `applyLayout`.
|
||||
- **AC-UI-TASK-LAYOUT-PROFILES-001.10:** Use a saved 700/300 profile on an 800px workbench. The expected right width is 240px before the safety caps.
|
||||
- Add focused fast-switch coverage in `apps/web/lib/state/dockview-env-switch-pinned.test.ts` for an unsaved environment with a custom default.
|
||||
- Keep existing built-in and explicit custom-layout tests green.
|
||||
|
||||
## E2E tests
|
||||
|
||||
- **AC-UI-TASK-LAYOUT-PROFILES-001.9 and AC-UI-TASK-LAYOUT-PROFILES-001.10:** Update `apps/web/e2e/tests/settings/layout-profiles.spec.ts`.
|
||||
- Extend the fresh-default and Reset Layout scenario with captured center and right widths.
|
||||
- Assert the saved right-column proportion after fresh setup and after Reset Layout.
|
||||
|
||||
## Mobile and tablet
|
||||
|
||||
`TaskLayout` selects separate mobile and tablet compositions before the desktop Dockview workbench. This repair changes only desktop Dockview state normalization.
|
||||
|
||||
The nearest mobile exemplar is `apps/web/components/task/mobile/session-mobile-layout.tsx`. It keeps its existing single-panel navigation and scroll ownership. No mobile Playwright case is required for this state-only desktop repair.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Restore custom-default proportions](task-01-restore-custom-default-proportions.md)
|
||||
|
||||
## Verification results
|
||||
|
||||
- `cd apps && pnpm --filter @kandev/web test -- --run lib/state/dockview-preset-persistence.test.ts lib/state/dockview-store.test.ts` passed (44 tests).
|
||||
- `cd apps/web && pnpm e2e:run tests/settings/layout-profiles.spec.ts -- --grep "fresh tasks use the no-terminal default while existing tasks wait for Reset Layout"` passed (1 test).
|
||||
- `cd apps/web && pnpm run typecheck` passed.
|
||||
- `node --test scripts/validate-public-docs.test.mjs` passed (61 tests).
|
||||
- `node scripts/validate-public-docs.mjs` passed (41 pages).
|
||||
|
||||
## Risks
|
||||
|
||||
- A named intent must not inherit widths from an unrelated custom default.
|
||||
- The stored pinned-width map must match the applied geometry before post-layout enforcement runs.
|
||||
- Built-in presets must retain their responsive width defaults.
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
---
|
||||
id: "01-restore-custom-default-proportions"
|
||||
title: "Restore custom-default proportions"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-UI-TASK-LAYOUT-PROFILES-001
|
||||
acceptance_criteria:
|
||||
- AC-UI-TASK-LAYOUT-PROFILES-001.9
|
||||
- AC-UI-TASK-LAYOUT-PROFILES-001.10
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/task-layout-profiles.md
|
||||
---
|
||||
|
||||
# Task 01: Restore Custom-Default Proportions
|
||||
|
||||
## Summary
|
||||
|
||||
Make fresh desktop tasks and Reset Layout use the geometry from the effective custom default. Preserve the responsive behavior of built-in presets and named intents.
|
||||
|
||||
## In scope
|
||||
|
||||
- Add a failing store regression for custom-default proportions.
|
||||
- Resolve saved custom-default widths for the measured workbench.
|
||||
- Store and apply the same pinned-width map.
|
||||
- Pass the resolved widths through the fast environment-switch path.
|
||||
- Extend the existing desktop layout-profile E2E scenario.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Backend or saved-layout format changes.
|
||||
- Mobile and tablet task composition.
|
||||
- Per-environment manual sash-width behavior.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A fresh desktop task uses the proportions from the effective custom default.
|
||||
- Reset Layout applies the same saved proportions.
|
||||
- A fast switch into an unsaved environment applies the same saved proportions.
|
||||
- Built-in presets and named intents continue to use responsive default widths.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web test -- --run lib/state/dockview-preset-persistence.test.ts lib/state/dockview-store.test.ts
|
||||
cd apps/web && pnpm e2e:run tests/settings/layout-profiles.spec.ts -- --grep "fresh tasks use the no-terminal default while existing tasks wait for Reset Layout"
|
||||
cd apps/web && pnpm run typecheck
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/lib/state/dockview-store.ts`
|
||||
- `apps/web/lib/state/dockview-env-switch.ts`
|
||||
- `apps/web/lib/state/dockview-preset-persistence.test.ts`
|
||||
- `apps/web/lib/state/dockview-env-switch-pinned.test.ts`
|
||||
- `apps/web/e2e/tests/settings/layout-profiles.spec.ts`
|
||||
- `docs/plans/custom-default-layout-proportions/plan.md`
|
||||
- `docs/plans/custom-default-layout-proportions/task-01-restore-custom-default-proportions.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Risks
|
||||
|
||||
- Intent-based layouts can use the wrong saved widths if the source check is too broad.
|
||||
- Post-layout enforcement can overwrite the result if `pinnedWidths` stays empty.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- `docs/specs/ui/requirements/task-layout-profiles.md`
|
||||
- `docs/specs/ui/system-design/task-layout-profiles.md`
|
||||
- `docs/decisions/0041-backend-owned-portable-user-settings.md`
|
||||
- Existing custom-layout width resolution in `apps/web/lib/state/dockview-store.ts`.
|
||||
- Existing default-layout coverage in `apps/web/lib/state/dockview-preset-persistence.test.ts`.
|
||||
|
||||
## Results
|
||||
|
||||
- Updated `performBuildDefault` to resolve scaled pinned widths when the
|
||||
effective default comes from a saved custom profile.
|
||||
- Passed the same scaled widths into fast environment switches when the target
|
||||
environment has no task-specific saved layout.
|
||||
- Stored and applied the same width map. Named built-in intents keep their
|
||||
responsive preset widths.
|
||||
- Added store and fast-switch regressions for the scaled custom default and the
|
||||
named-intent safeguard.
|
||||
- Extended the existing layout-profile E2E test with a 75/25 split assertion
|
||||
for fresh tasks and Reset Layout.
|
||||
- Updated the public layout-profile guidance and the durable requirements and
|
||||
system design.
|
||||
- `cd apps && pnpm --filter @kandev/web test -- --run lib/state/dockview-preset-persistence.test.ts lib/state/dockview-store.test.ts` passed (44 tests).
|
||||
- `cd apps/web && pnpm e2e:run tests/settings/layout-profiles.spec.ts -- --grep "fresh tasks use the no-terminal default while existing tasks wait for Reset Layout"` passed (1 test).
|
||||
- `cd apps/web && pnpm run typecheck` passed.
|
||||
@@ -125,6 +125,9 @@ Open **Settings > Preferences > Keyboard Shortcuts** to customize these bindings
|
||||
|
||||
Open **Settings > Preferences > Layouts** to configure reusable desktop workbench profiles. Select a tab in a built-in layout to reveal its nearby edit controls, arrange or remove tabs and splits, then use the floating **Save changes** control. Kandev keeps the built-in row visible, marks it **Customized**, and stores your override without requiring a duplicate. Choose **Reset** beside a customized built-in to restore its original definition.
|
||||
|
||||
When you save a custom default, Kandev reapplies its split proportions to new
|
||||
and reset desktop tasks and scales them to the available workbench.
|
||||
|
||||

|
||||
|
||||
**PR Details** is a reusable Layouts panel whose visibility follows the active task's review association. Without a linked GitHub pull request or GitLab merge request, the tab stays hidden, even when the selected layout includes it. Once a review is linked, Kandev adds PR Details as an inactive tab: beside **Agent** for the built-in Default, or in the group and tab position you configured in the Layouts editor. Closing that tab prevents it from reappearing automatically in the same session. Changing the default applies to task environments without a saved task-specific layout and **Reset Layout**, not a layout already saved for a task. Removing Terminal from the Default layout also prevents Kandev from creating its initial user shell.
|
||||
|
||||
@@ -27,6 +27,8 @@ Users can arrange and save the desktop task workbench only while a task is open,
|
||||
- **AC-UI-TASK-LAYOUT-PROFILES-001.6:** PR Details is the canonical reusable `pr-detail` panel. Its position in the selected layout is a placement template, not an instruction to keep an empty runtime tab open. The tab is visible only while the active task has a linked GitHub pull request or GitLab merge request, and then renders that review through the existing provider-aware review surface.
|
||||
- **AC-UI-TASK-LAYOUT-PROFILES-001.7:** The code-defined Default and compact desktop layouts omit PR Details. Agent remains initially selected in the Default center group, and Files remains initially selected in the top-right Files and Changes group.
|
||||
- **AC-UI-TASK-LAYOUT-PROFILES-001.8:** When the active task gains a linked GitHub pull request or GitLab merge request, Kandev adds the canonical panel as an inactive tab in the group and tab index configured by the selected custom Default. If that layout does not configure PR Details, Kandev adds it beside the live Agent panel. The user's current tab remains selected.
|
||||
- **AC-UI-TASK-LAYOUT-PROFILES-001.9:** When a saved profile is the effective desktop default, Kandev shall preserve its split proportions in fresh task environments and Reset Layout.
|
||||
- **AC-UI-TASK-LAYOUT-PROFILES-001.10:** When the workbench size differs from the saved profile, Kandev shall scale its proportions and apply the desktop safety caps.
|
||||
|
||||
## System design
|
||||
|
||||
|
||||
@@ -73,6 +73,13 @@ 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. The canonical `pr-detail` ID is reusable; keyed panels such as `pr-detail|owner/repository/123` and `mr-detail|host/project/123` remain task-specific runtime tabs and are not accepted by the profile editor. 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.
|
||||
|
||||
Every application path for a reusable profile uses the same geometry resolver.
|
||||
These paths include workbench selection, fresh task setup, Reset Layout, and
|
||||
the fast environment-switch path for an unsaved task environment. The resolver
|
||||
scales complete saved column widths to the current workbench. It then applies
|
||||
the pinned-column safety caps. A code-defined built-in preset continues to use
|
||||
its responsive defaults.
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user