fix(ui): restrict Changes auto-focus to Default layout (#3205)

This commit is contained in:
Carlos Florêncio
2026-08-31 21:42:12 +00:00
committed by GitHub
parent b8364975ef
commit b292f9241e
18 changed files with 786 additions and 53 deletions
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
activateChangesPanel,
applyChangesPanelAutoFocusState,
markInactiveChangesIncreases,
migrateEnvironmentKeys,
@@ -7,6 +8,8 @@ import {
shouldClearPendingChangesFocus,
} from "./changes-panel-focus";
import type { FileInfo, GitStatusEntry } from "@/lib/state/slices/session-runtime/types";
import { RIGHT_TOP_GROUP } from "@/lib/state/layout-manager";
import { useDockviewStore } from "@/lib/state/dockview-store";
const hashDiffMock = vi.hoisted(() => vi.fn((diff: string) => `hash:${diff}`));
@@ -20,6 +23,52 @@ const UPDATED_FINGERPRINT = "repo:path:updated";
beforeEach(() => {
hashDiffMock.mockClear();
useDockviewStore.setState({
activeLayoutProfile: { kind: "built-in", id: "default" },
});
});
function activationApi(groupId: string, panelIds: string[]) {
const setActive = vi.fn();
const changesPanel = {
id: "changes",
group: { id: groupId, panels: panelIds.map((id) => ({ id })) },
api: { setActive },
};
const api = {
getPanel: vi.fn().mockReturnValue(changesPanel),
} as unknown as Parameters<typeof activateChangesPanel>[0];
return { api, setActive };
}
// @covers AC-UI-TASK-LAYOUT-PROFILES-001.11
describe("activateChangesPanel", () => {
it("activates Changes in the Default Files and Changes group", () => {
const { api, setActive } = activationApi(RIGHT_TOP_GROUP, ["files", "changes"]);
expect(activateChangesPanel(api)).toBe("activated");
expect(setActive).toHaveBeenCalledOnce();
});
it("does not activate Changes in a copied Default custom profile", () => {
useDockviewStore.setState({
activeLayoutProfile: { kind: "custom", id: "layout-copied-default" },
});
const { api, setActive } = activationApi(RIGHT_TOP_GROUP, ["files", "changes"]);
expect(activateChangesPanel(api)).toBe("blocked-layout");
expect(setActive).not.toHaveBeenCalled();
});
it.each([
["a non-Default group", "group-center", ["files", "changes"]],
["a Default group with an extra VS Code tab", RIGHT_TOP_GROUP, ["files", "changes", "vscode"]],
])("does not activate Changes in %s", (_name, groupId, panelIds) => {
const { api, setActive } = activationApi(groupId, panelIds);
expect(activateChangesPanel(api)).toBe("blocked-layout");
expect(setActive).not.toHaveBeenCalled();
});
});
function gitStatus(files: string[], timestamp = TEST_TIMESTAMP, diff = ""): GitStatusEntry {
@@ -581,6 +630,7 @@ describe("shouldClearPendingChangesFocus", () => {
expect(shouldClearPendingChangesFocus("activated")).toBe(true);
expect(shouldClearPendingChangesFocus("no-panel")).toBe(true);
expect(shouldClearPendingChangesFocus("blocked-agent-group")).toBe(false);
expect(shouldClearPendingChangesFocus("blocked-layout")).toBe(false);
expect(shouldClearPendingChangesFocus("no-api")).toBe(false);
});
});
@@ -5,6 +5,7 @@ import type { DockviewApi } from "dockview-react";
import { useShallow } from "zustand/react/shallow";
import { useAppStore } from "@/components/state-provider";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { RIGHT_TOP_GROUP } from "@/lib/state/layout-manager";
import type { AppState } from "@/lib/state/store";
import type {
FileChangeFacet,
@@ -30,6 +31,7 @@ type GitStatusFingerprintCacheEntry = {
export type ActivateChangesPanelResult =
| "activated"
| "blocked-agent-group"
| "blocked-layout"
| "no-api"
| "no-panel";
@@ -37,7 +39,17 @@ function groupContainsAgentSessionPanel(panel: DockviewPanel): boolean {
return panel.group.panels.some((p) => p.id === "chat" || p.id.startsWith("session:"));
}
/** Activate the Changes panel unless it shares a group with agent sessions. */
function isDefaultChangesGroup(panel: DockviewPanel): boolean {
const panelIds = panel.group.panels.map((groupPanel) => groupPanel.id);
return panel.group.id === RIGHT_TOP_GROUP && panelIds.length === 2 && panelIds.includes("files");
}
function isDefaultLayoutProfile(): boolean {
const { activeLayoutProfile } = useDockviewStore.getState();
return activeLayoutProfile.kind === "built-in" && activeLayoutProfile.id === "default";
}
/** Activate the Changes panel only in the Default Files and Changes group. */
export function activateChangesPanel(
api: DockviewApi | null | undefined,
): ActivateChangesPanelResult {
@@ -46,6 +58,8 @@ export function activateChangesPanel(
const panel = api.getPanel("changes");
if (!panel) return "no-panel";
if (groupContainsAgentSessionPanel(panel)) return "blocked-agent-group";
if (!isDefaultLayoutProfile()) return "blocked-layout";
if (!isDefaultChangesGroup(panel)) return "blocked-layout";
panel.api.setActive();
return "activated";
@@ -9,7 +9,11 @@ import {
type DockviewReadyEvent,
} from "dockview-react";
import { themeKandev } from "@/lib/layout/dockview-theme";
import { useDockviewStore, performLayoutSwitch } from "@/lib/state/dockview-store";
import {
resolveRestoredLayoutProfile,
useDockviewStore,
performLayoutSwitch,
} from "@/lib/state/dockview-store";
import { restoreEnvLayout } from "./dockview-layout-restore";
import {
setupContainerResizeSync,
@@ -56,7 +60,11 @@ import type { Terminal } from "@/hooks/domains/session/use-terminals";
// Portal system
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 {
getLayoutProfileIdentity,
resolveEffectiveDefaultLayout,
type LayoutProfileIdentity,
} from "@/lib/layout/layout-profiles";
import type { LayoutState } from "@/lib/state/layout-manager";
import { registerDockviewRoot, unregisterDockviewRoot } from "@/lib/state/dockview-measure";
@@ -158,15 +166,26 @@ function PermanentTab(props: IDockviewPanelHeaderProps) {
}
/** Sync the user's default saved layout from settings into the dockview store. */
function useSyncUserDefaultLayout(): LayoutState | null {
type SyncedUserDefaultLayout = {
layout: LayoutState | null;
profile: LayoutProfileIdentity;
};
function useSyncUserDefaultLayout(): SyncedUserDefaultLayout {
const savedLayouts = useAppStore((s) => s.userSettings.savedLayouts);
const setUserDefaultLayout = useDockviewStore((s) => s.setUserDefaultLayout);
const userDefaultLayout = useMemo(() => {
const userDefaultLayout = useMemo<SyncedUserDefaultLayout>(() => {
const effectiveDefault = resolveEffectiveDefaultLayout(savedLayouts);
return effectiveDefault.source === "custom" ? effectiveDefault.layout : null;
return {
layout: effectiveDefault.source === "custom" ? effectiveDefault.layout : null,
profile:
effectiveDefault.source === "custom"
? getLayoutProfileIdentity(effectiveDefault.profile)
: { kind: "built-in", id: effectiveDefault.profile.id },
};
}, [savedLayouts]);
useEffect(() => {
setUserDefaultLayout(userDefaultLayout);
setUserDefaultLayout(userDefaultLayout.layout, userDefaultLayout.profile);
}, [setUserDefaultLayout, userDefaultLayout]);
return userDefaultLayout;
}
@@ -285,6 +304,7 @@ type ReadyDockviewLayoutSetup = {
compact: boolean;
initialLayout?: string | null;
userDefaultLayout: LayoutState | null;
userDefaultLayoutProfile: LayoutProfileIdentity;
};
type ReadyDockviewRefs = {
@@ -313,7 +333,9 @@ type ReadyDockviewSetup = {
function setupReadyDockview({ api, appStore, layout, refs }: ReadyDockviewSetup): void {
// Dockview can become ready before the parent passive effect synchronizes
// settings. Seed the store before exposing the API or building a cold layout.
useDockviewStore.getState().setUserDefaultLayout(layout.userDefaultLayout);
useDockviewStore
.getState()
.setUserDefaultLayout(layout.userDefaultLayout, layout.userDefaultLayoutProfile);
refs.setApi(api);
const layoutRoot = refs.layoutRootRef.current;
if (layoutRoot) {
@@ -330,6 +352,10 @@ function setupReadyDockview({ api, appStore, layout, refs }: ReadyDockviewSetup)
api,
layout.initialLayout ?? (layout.compact ? "compact" : undefined),
);
} else {
useDockviewStore.setState({
activeLayoutProfile: resolveRestoredLayoutProfile(api, currentEnvId),
});
}
useDockviewStore.setState({ currentLayoutEnvId: currentEnvId });
@@ -402,7 +428,8 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
const envIdRef = useRef<string | null>(effectiveEnvId);
const hasDevScript = Boolean(repository?.dev_script?.trim());
const userDefaultLayout = useSyncUserDefaultLayout();
const { layout: userDefaultLayout, profile: userDefaultLayoutProfile } =
useSyncUserDefaultLayout();
useLspFileOpener();
useEditorKeybinds();
usePlanPanelAutoOpen();
@@ -425,11 +452,25 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
setupReadyDockview({
api: event.api,
appStore,
layout: { buildDefaultLayout, compact, initialLayout, userDefaultLayout },
layout: {
buildDefaultLayout,
compact,
initialLayout,
userDefaultLayout,
userDefaultLayoutProfile,
},
refs: { envIdRef, readyDisposersRef, saveTimerRef, setApi, layoutRootRef },
});
},
[setApi, buildDefaultLayout, initialLayout, compact, userDefaultLayout, appStore],
[
setApi,
buildDefaultLayout,
initialLayout,
compact,
userDefaultLayout,
userDefaultLayoutProfile,
appStore,
],
);
// Release session-scoped portals + trigger layout switch on session change.
@@ -11,7 +11,12 @@ import {
RIGHT_BOTTOM_GROUP,
setPinnedTarget,
} from "@/lib/state/layout-manager";
import { getManualRightWidth, setEnvLayout, setManualRightWidth } from "@/lib/local-storage";
import {
getManualRightWidth,
setEnvLayout,
setEnvLayoutProfile,
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";
@@ -388,6 +393,7 @@ export function setupLayoutPersistence(
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(json));
if (envId) {
setEnvLayout(envId, json);
setEnvLayoutProfile(envId, live.activeLayoutProfile);
}
if (isDebug()) {
debugWidths(
@@ -38,6 +38,12 @@ function changesTab(testPage: Page) {
});
}
function vscodeTab(testPage: Page) {
return testPage.locator(".dv-tab:visible", {
has: testPage.locator(".dv-default-tab:visible").filter({ hasText: /^VS Code/ }),
});
}
async function changesCountForSession(testPage: Page, sessionId: string): Promise<number | null> {
return testPage.evaluate((sid) => {
type StoreWindow = Window & {
@@ -132,26 +138,6 @@ async function setGitStatusForSession(testPage: Page, sessionId: string, changed
);
}
async function moveChangesToTerminalGroupAndFocusTerminal(testPage: Page) {
await testPage.evaluate(() => {
type Group = { id: string };
type PanelApi = {
moveTo: (opts: { group: Group }) => void;
setActive: () => void;
};
type Panel = { id: string; api: PanelApi; group?: Group };
type Api = { getPanel: (id: string) => Panel | undefined };
const dockview = (window as unknown as { __dockviewApi__?: Api }).__dockviewApi__;
const changes = dockview?.getPanel("changes");
const terminal = dockview?.getPanel("terminal-default");
if (!changes || !terminal?.group) {
throw new Error("Dockview changes or terminal panel was not available");
}
changes.api.moveTo({ group: terminal.group });
terminal.api.setActive();
});
}
async function moveChangesToChatGroupAndFocusChat(testPage: Page) {
await testPage.evaluate(() => {
type Group = { id: string };
@@ -360,7 +346,7 @@ test.describe("Changes panel focus behavior", () => {
await expect(session.chat).toBeVisible();
});
test("new git updates focus the changes tab in its current non-agent group", async ({
test("new git updates do not steal focus from the VS Code group", async ({
testPage,
apiClient,
seedData,
@@ -374,7 +360,7 @@ test.describe("Changes panel focus behavior", () => {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Moved changes panel focus test",
"VS Code group focus test",
seedData.agentProfileId,
{
workflow_id: seedData.workflowId,
@@ -393,16 +379,29 @@ test.describe("Changes panel focus behavior", () => {
testPage.locator(".dv-default-tab").filter({ hasText: /^Changes \(1\)$/ }),
).toBeVisible({ timeout: 15_000 });
await moveChangesToTerminalGroupAndFocusTerminal(testPage);
await expect(changesTab(testPage)).not.toHaveClass(/dv-active-tab/, { timeout: 5_000 });
const presetTrigger = testPage.getByTestId("layout-preset-trigger");
await presetTrigger.click();
await testPage.locator('[data-testid="layout-preset-item"][data-preset-id="vscode"]').click();
const activeVscodeTab = vscodeTab(testPage);
await expect(activeVscodeTab).toBeVisible({ timeout: 15_000 });
await activeVscodeTab.click();
await expect(activeVscodeTab).toHaveClass(/dv-active-tab/, { timeout: 5_000 });
git.createFile("second-change.txt", "two");
await expect(
testPage.locator(".dv-default-tab").filter({ hasText: /^Changes \(2\)$/ }),
).toBeVisible({ timeout: 15_000 });
await expect(changesTab(testPage)).toHaveClass(/dv-active-tab/, { timeout: 5_000 });
await expect(session.changesFileRow("second-change.txt")).toBeVisible({ timeout: 10_000 });
await dwell(
testPage,
2_000,
"negative-assertion",
"the VS Code group must remain active; a forbidden Changes auto-focus publishes no completion event, so the bounded negative window gives it room to fire",
);
await expect(activeVscodeTab).toHaveClass(/dv-active-tab/, { timeout: 5_000 });
await expect(changesTab(testPage)).not.toHaveClass(/dv-active-tab/, { timeout: 5_000 });
});
test("new git updates focus changes after switching to an inactive task", async ({
@@ -10,6 +10,7 @@ import {
getBuiltInLayoutOverride,
getBuiltInLayoutOverrideId,
getBuiltInLayoutProfile,
getLayoutProfileIdentity,
getLayoutProfileCompatibility,
isBuiltInLayoutOverride,
renameLayoutProfile,
@@ -293,6 +294,16 @@ describe("layout profile defaults", () => {
});
describe("layout profile IDs", () => {
it("maps reserved overrides to their built-in identity", () => {
expect(getLayoutProfileIdentity({ id: getBuiltInLayoutOverrideId(DEFAULT_LAYOUT_ID) })).toEqual(
{ kind: "built-in", id: DEFAULT_LAYOUT_ID },
);
expect(getLayoutProfileIdentity({ id: "layout-copied-default" })).toEqual({
kind: "custom",
id: "layout-copied-default",
});
});
it("uses UUID-backed IDs", () => {
const first = createLayoutProfileId();
const second = createLayoutProfileId();
+13
View File
@@ -14,6 +14,11 @@ import { generateUUID } from "@/lib/utils";
export type BuiltInLayoutProfileId = Exclude<BuiltInPreset, "compact">;
/** Identity of the profile that produced the currently rendered layout. */
export type LayoutProfileIdentity =
| { kind: "built-in"; id: BuiltInPreset }
| { kind: "custom"; id: string };
export type BuiltInLayoutProfileDescriptor = {
id: BuiltInLayoutProfileId;
/** Canonical English; persisted into a saved override. Not for display. */
@@ -155,6 +160,14 @@ export function getBuiltInLayoutOverrideSourceId(
);
}
/** Resolve the identity represented by a saved layout profile ID. */
export function getLayoutProfileIdentity(profile: Pick<SavedLayout, "id">): LayoutProfileIdentity {
const builtInSource = getBuiltInLayoutOverrideSourceId(profile);
return builtInSource
? { kind: "built-in", id: builtInSource }
: { kind: "custom", id: profile.id };
}
export function isBuiltInLayoutOverride(profile: Pick<SavedLayout, "id">): boolean {
return getBuiltInLayoutOverrideSourceId(profile) !== null;
}
+41
View File
@@ -3,6 +3,7 @@ import {
cleanupTaskStorage,
clearGlobalSidebarWidth,
getGlobalSidebarWidth,
getEnvLayoutProfile,
getManualRightWidth,
getOpenFileTabs,
getStoredAutoScrollEnabled,
@@ -11,7 +12,9 @@ import {
markPRMergedBannerDismissed,
markPRPanelOffered,
restoreAttachmentPreview,
removeEnvLayoutProfile,
setGlobalSidebarWidth,
setEnvLayoutProfile,
setManualRightWidth,
clearManualRightWidth,
setOpenFileTabs,
@@ -194,6 +197,44 @@ describe("manual right width storage", () => {
});
});
describe("dockview layout profile storage", () => {
beforeEach(() => {
window.sessionStorage.clear();
});
it("round-trips profile identity per environment", () => {
setEnvLayoutProfile("env-a", { kind: "custom", id: "layout-copied-default" });
expect(getEnvLayoutProfile("env-a")).toEqual({
kind: "custom",
id: "layout-copied-default",
});
expect(getEnvLayoutProfile("env-b")).toBeNull();
});
it("ignores malformed profile identity and supports explicit removal", () => {
window.sessionStorage.setItem(
"kandev.dockview.env-layout-profile-v1.env-a",
JSON.stringify({ kind: "custom" }),
);
expect(getEnvLayoutProfile("env-a")).toBeNull();
setEnvLayoutProfile("env-a", { kind: "built-in", id: "default" });
removeEnvLayoutProfile("env-a");
expect(getEnvLayoutProfile("env-a")).toBeNull();
});
it("cleans profile identity with the deleted environment", () => {
setEnvLayoutProfile("env-a", { kind: "built-in", id: "default" });
setEnvLayoutProfile("env-b", { kind: "custom", id: "other" });
cleanupTaskStorage("task-a", [], ["env-a"]);
expect(getEnvLayoutProfile("env-a")).toBeNull();
expect(getEnvLayoutProfile("env-b")).toEqual({ kind: "custom", id: "other" });
});
});
describe("task-scoped artifact notification storage", () => {
beforeEach(() => {
window.localStorage.clear();
+36
View File
@@ -1,5 +1,6 @@
import { setWalkthroughLastSeen } from "@/lib/walkthrough-notification-storage";
import { attachmentContentUrl } from "@/lib/api/domains/attachment-api";
import type { LayoutProfileIdentity } from "@/lib/layout/layout-profiles";
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
@@ -289,6 +290,11 @@ export function setFilesPanelScrollPosition(sessionId: string, position: number)
// `apps/web/e2e/helpers/dockview-persistence.ts`; bump both together.
const DOCKVIEW_ENV_LAYOUT_PREFIX = "kandev.dockview.env-layout-v3.";
// The active profile is persisted separately from Dockview's serialized layout.
// A copied Default profile can retain Default's group IDs, so the layout shape
// alone cannot identify whether Changes auto-focus is allowed.
const DOCKVIEW_ENV_LAYOUT_PROFILE_PREFIX = "kandev.dockview.env-layout-profile-v1.";
// 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.
@@ -319,6 +325,35 @@ export function setEnvLayout(envId: string, layout: object): void {
}
}
function isLayoutProfileIdentity(value: unknown): value is LayoutProfileIdentity {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
const profile = value as { kind?: unknown; id?: unknown };
return (
(profile.kind === "built-in" || profile.kind === "custom") &&
typeof profile.id === "string" &&
profile.id.length > 0
);
}
/** Read the profile identity saved with a task environment's layout. */
export function getEnvLayoutProfile(envId: string): LayoutProfileIdentity | null {
const profile = getSessionStorage<LayoutProfileIdentity | null>(
`${DOCKVIEW_ENV_LAYOUT_PROFILE_PREFIX}${envId}`,
null,
);
return isLayoutProfileIdentity(profile) ? profile : null;
}
/** Save the profile identity that produced a task environment's layout. */
export function setEnvLayoutProfile(envId: string, profile: LayoutProfileIdentity): void {
setSessionStorage(`${DOCKVIEW_ENV_LAYOUT_PROFILE_PREFIX}${envId}`, profile);
}
/** Remove the profile identity saved for a task environment. */
export function removeEnvLayoutProfile(envId: string): void {
removeSessionStorage(`${DOCKVIEW_ENV_LAYOUT_PROFILE_PREFIX}${envId}`);
}
/** Read the manually-dragged right-panel width for a task env, or `null` if
* never set or invalid (sessionStorage). */
export function getManualRightWidth(envId: string | null): number | null {
@@ -786,6 +821,7 @@ export function cleanupTaskStorage(
for (const envId of envIds) {
removeEnvMaximizeState(envId);
removeSessionStorage(`${DOCKVIEW_ENV_LAYOUT_PREFIX}${envId}`);
removeEnvLayoutProfile(envId);
clearManualRightWidth(envId);
}
@@ -0,0 +1,181 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { DockviewApi } from "dockview-react";
import { getBuiltInLayoutOverrideId, getLayoutProfileIdentity } from "@/lib/layout/layout-profiles";
import type { LayoutState } from "./layout-manager";
vi.mock("@/lib/local-storage", () => ({
setEnvLayout: vi.fn(),
getEnvLayout: vi.fn(() => null),
getEnvLayoutProfile: vi.fn(() => null),
setEnvLayoutProfile: vi.fn(),
getEnvMaximizeState: vi.fn(() => null),
setEnvMaximizeState: vi.fn(),
removeEnvMaximizeState: vi.fn(),
getGlobalSidebarWidth: vi.fn(() => null),
setGlobalSidebarWidth: vi.fn(),
clearGlobalSidebarWidth: vi.fn(),
getManualRightWidth: vi.fn(() => null),
}));
vi.mock("@/lib/layout/panel-portal-manager", () => ({
panelPortalManager: { releaseByEnv: vi.fn(), reconcile: vi.fn() },
}));
vi.mock("./dockview-scroll-preserve", () => ({
preserveChatScrollDuringLayout: vi.fn(),
}));
vi.mock("./dockview-measure", () => ({
measureDockviewContainer: vi.fn(() => ({ width: 800, height: 600 })),
}));
vi.mock("./dockview-pinned-enforce", () => ({
enforcePinnedTargets: vi.fn(),
}));
vi.mock("./dockview-layout-builders", () => ({
applyLayoutFixups: vi.fn(() => ({
sidebarGroupId: "g-sidebar",
centerGroupId: "g-center",
rightTopGroupId: "g-right-top",
rightBottomGroupId: "g-right-bottom",
})),
focusOrAddPanel: vi.fn(),
}));
vi.mock("./layout-manager", async (importOriginal) => {
const actual = await importOriginal<typeof import("./layout-manager")>();
return {
...actual,
SIDEBAR_GROUP: "sidebar",
CENTER_GROUP: "center",
RIGHT_TOP_GROUP: "right-top",
RIGHT_BOTTOM_GROUP: "right-bottom",
TERMINAL_DEFAULT_ID: "terminal",
LAYOUT_SIDEBAR_RATIO: 0.2,
LAYOUT_RIGHT_RATIO: 0.25,
LAYOUT_PINNED_MIN_PX: 200,
computeSidebarMaxPx: vi.fn(() => 350),
computeRightMaxPx: vi.fn(() => 500),
getPresetLayout: vi.fn(() => ({ columns: [] })),
applyLayout: vi.fn(() => ({
sidebarGroupId: "g-sidebar",
centerGroupId: "g-center",
rightTopGroupId: "g-right-top",
rightBottomGroupId: "g-right-bottom",
})),
getPinnedWidth: vi.fn(() => 350),
getRootSplitview: vi.fn(() => null),
fromDockviewApi: vi.fn(() => ({ columns: [] })),
filterEphemeral: vi.fn((s: unknown) => s),
defaultLayout: vi.fn(() => ({ columns: [] })),
mergeCurrentPanelsIntoPreset: vi.fn((_api: unknown, preset: unknown) => preset),
toSerializedDockview: vi.fn((s: unknown) => s),
injectIntentPanels: vi.fn(),
applyActivePanelOverrides: vi.fn(),
resolveNamedIntent: vi.fn(),
setPinnedTarget: vi.fn(),
clearPinnedTarget: vi.fn(),
getPinnedTarget: vi.fn(() => undefined),
layoutStructuresMatch: vi.fn(() => false),
savedLayoutMatchesLive: vi.fn(() => false),
};
});
import { useDockviewStore } from "./dockview-store";
import { applyLayout } from "./layout-manager";
function makeStoreApi(): DockviewApi {
return {
width: 800,
height: 600,
panels: [],
groups: [],
layout: vi.fn(),
fromJSON: vi.fn(),
toJSON: vi.fn(() => ({ columns: [{ id: "center" }] })),
getPanel: vi.fn(() => null),
addPanel: vi.fn(),
activeGroup: null,
hasMaximizedGroup: vi.fn(() => false),
exitMaximizedGroup: vi.fn(),
onDidActivePanelChange: vi.fn(() => ({ dispose: vi.fn() })),
} as unknown as DockviewApi;
}
function flushRaf(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
function resetStore(): void {
vi.clearAllMocks();
useDockviewStore.setState({
api: null,
currentLayoutEnvId: null,
preMaximizeLayout: null,
maximizedGroupId: null,
isRestoringLayout: false,
pinnedWidths: new Map(),
userDefaultLayout: null,
userDefaultLayoutProfile: { kind: "built-in", id: "default" },
});
}
describe("effective default layout profile identity", () => {
beforeEach(resetStore);
// @covers AC-UI-TASK-LAYOUT-PROFILES-001.11
it("preserves built-in Default through a fresh build and Reset Layout", async () => {
const api = makeStoreApi();
const userDefaultLayout = {
columns: [
{
id: "center",
groups: [{ panels: [{ id: "chat", component: "chat", title: "Agent" }] }],
},
],
} as LayoutState;
const reservedDefaultProfile = getLayoutProfileIdentity({
id: getBuiltInLayoutOverrideId("default"),
});
useDockviewStore.setState({ api, currentLayoutEnvId: "env-default" });
useDockviewStore.getState().setUserDefaultLayout(userDefaultLayout, reservedDefaultProfile);
useDockviewStore.getState().buildDefaultLayout(api);
expect(useDockviewStore.getState().activeLayoutProfile).toEqual({
kind: "built-in",
id: "default",
});
expect(applyLayout).toHaveBeenCalledWith(api, userDefaultLayout, expect.any(Map), 800, 600);
await flushRaf();
useDockviewStore.getState().resetLayout();
expect(useDockviewStore.getState().activeLayoutProfile).toEqual({
kind: "built-in",
id: "default",
});
expect(applyLayout).toHaveBeenLastCalledWith(api, userDefaultLayout, expect.any(Map), 800, 600);
await flushRaf();
});
it("keeps an arbitrary saved default custom", () => {
const api = makeStoreApi();
const userDefaultLayout = { columns: [{ id: "center", groups: [] }] } as LayoutState;
useDockviewStore.setState({ api, currentLayoutEnvId: "env-custom" });
useDockviewStore
.getState()
.setUserDefaultLayout(
userDefaultLayout,
getLayoutProfileIdentity({ id: "layout-copied-default" }),
);
useDockviewStore.getState().buildDefaultLayout(api);
expect(useDockviewStore.getState().activeLayoutProfile).toEqual({
kind: "custom",
id: "layout-copied-default",
});
});
});
@@ -4,7 +4,9 @@ import { useDockviewStore } from "./dockview-store";
vi.mock("@/lib/local-storage", () => ({
getEnvLayout: vi.fn(() => null),
getEnvLayoutProfile: vi.fn(() => null),
setEnvLayout: vi.fn(),
setEnvLayoutProfile: vi.fn(),
getEnvMaximizeState: vi.fn(() => null),
setEnvMaximizeState: vi.fn(),
removeEnvMaximizeState: vi.fn(),
@@ -21,7 +23,12 @@ vi.mock("@/lib/layout/panel-portal-manager", () => ({
},
}));
import { setEnvLayout, getEnvMaximizeState } from "@/lib/local-storage";
import {
getEnvLayoutProfile,
getEnvMaximizeState,
setEnvLayout,
setEnvLayoutProfile,
} from "@/lib/local-storage";
import { panelPortalManager } from "@/lib/layout/panel-portal-manager";
function makeMockApi(): DockviewApi {
@@ -157,10 +164,24 @@ describe("switchEnvLayout — root fix for terminal/layout swapping", () => {
useDockviewStore.getState().switchEnvLayout("env-old", "env-new", "session-X");
expect(setEnvLayout).toHaveBeenCalledWith("env-old", expect.anything());
expect(setEnvLayoutProfile).toHaveBeenCalledWith("env-old", expect.anything());
expect(panelPortalManager.releaseByEnv).toHaveBeenCalledWith("env-old");
expect(useDockviewStore.getState().currentLayoutEnvId).toBe("env-new");
});
it("restores the saved env layout profile when switching to a new env", () => {
const api = makeMockApi();
vi.mocked(getEnvLayoutProfile).mockReturnValue({ kind: "built-in", id: "vscode" });
useDockviewStore.setState({ api, currentLayoutEnvId: null });
useDockviewStore.getState().switchEnvLayout(null, "env-saved", "session-X");
expect(useDockviewStore.getState().activeLayoutProfile).toEqual({
kind: "built-in",
id: "vscode",
});
});
it("first adoption applies the env's layout without overwriting it or releasing portals", () => {
const api = makeMockApi();
useDockviewStore.setState({ api, currentLayoutEnvId: null });
@@ -4,6 +4,8 @@ import type { DockviewApi } from "dockview-react";
vi.mock("@/lib/local-storage", () => ({
setEnvLayout: vi.fn(),
getEnvLayout: vi.fn(() => null),
getEnvLayoutProfile: vi.fn(() => null),
setEnvLayoutProfile: vi.fn(),
getEnvMaximizeState: vi.fn(() => null),
setEnvMaximizeState: vi.fn(),
removeEnvMaximizeState: vi.fn(),
@@ -185,6 +187,7 @@ function resetStoreForIntegration() {
isRestoringLayout: false,
pinnedWidths: new Map(),
userDefaultLayout: null,
userDefaultLayoutProfile: { kind: "built-in", id: "default" },
});
}
@@ -296,6 +296,10 @@ describe("applyCustomLayout", () => {
};
expect(serialized.grid.root.data[1].size).toBe(350);
expect(useDockviewStore.getState().pinnedWidths.get("right")).toBe(350);
expect(useDockviewStore.getState().activeLayoutProfile).toEqual({
kind: "built-in",
id: "default",
});
});
});
+131 -10
View File
@@ -3,6 +3,9 @@ import { create } from "zustand";
import type { DockviewApi, AddPanelOptions, SerializedDockview } from "dockview-react";
import {
setEnvLayout,
getEnvLayout,
getEnvLayoutProfile,
setEnvLayoutProfile,
getEnvMaximizeState,
setEnvMaximizeState,
removeEnvMaximizeState,
@@ -10,6 +13,7 @@ import {
setGlobalSidebarWidth,
getManualRightWidth,
} from "@/lib/local-storage";
import { getLayoutProfileIdentity, type LayoutProfileIdentity } from "@/lib/layout/layout-profiles";
import { setPinnedTarget, clearPinnedTarget } from "./layout-manager";
import { applyLayoutFixups, focusOrAddPanel } from "./dockview-layout-builders";
import {
@@ -66,6 +70,57 @@ const debugWidths = createDebugLogger("dockview:widths");
const RIGHT_PANEL_IDS = new Set(["changes", "files", TERMINAL_DEFAULT_ID]);
const DEFAULT_LAYOUT_PROFILE: LayoutProfileIdentity = { kind: "built-in", id: "default" };
function profileForCustomLayout(layout: Pick<SavedLayoutConfig, "id">): LayoutProfileIdentity {
return getLayoutProfileIdentity(layout);
}
function inferLayoutProfileFromApi(api: DockviewApi): LayoutProfileIdentity {
const changesPanel = api.getPanel("changes");
const panelIds = changesPanel?.group.panels.map((panel) => panel.id) ?? [];
if (
changesPanel?.group.id === RIGHT_TOP_GROUP &&
panelIds.length === 2 &&
panelIds.includes("files")
) {
return DEFAULT_LAYOUT_PROFILE;
}
return { kind: "custom", id: "restored-layout" };
}
/** Resolve the profile identity for an env restore, with a legacy shape fallback. */
export function resolveRestoredLayoutProfile(
api: DockviewApi,
envId: string | null,
): LayoutProfileIdentity {
return (envId && getEnvLayoutProfile(envId)) || inferLayoutProfileFromApi(api);
}
function resolveEnvSwitchProfile(args: {
hasFirstAdoptionRouteLayout: boolean;
savedEnvLayout: object | null;
savedLayoutProfile: LayoutProfileIdentity | null;
currentLayoutProfile: LayoutProfileIdentity;
api: DockviewApi;
}): LayoutProfileIdentity {
if (args.hasFirstAdoptionRouteLayout) return args.currentLayoutProfile;
if (args.savedLayoutProfile) return args.savedLayoutProfile;
if (args.savedEnvLayout) return inferLayoutProfileFromApi(args.api);
return args.currentLayoutProfile;
}
function resolveDefaultLayoutProfile(
basePreset: BuiltInPreset | undefined,
userDefaultLayout: LayoutState | null,
userDefaultLayoutProfile: LayoutProfileIdentity,
defaultPreset: BuiltInPreset,
): LayoutProfileIdentity {
if (basePreset) return { kind: "built-in", id: basePreset };
if (userDefaultLayout) return userDefaultLayoutProfile;
return { kind: "built-in", id: defaultPreset };
}
// Re-export types and constants used by other modules
export type { BuiltInPreset } from "./layout-manager";
export {
@@ -220,6 +275,8 @@ type DockviewStore = {
sidebarGroupId: string;
sidebarVisible: boolean;
rightPanelsVisible: boolean;
/** Identity of the active built-in or saved custom layout profile. */
activeLayoutProfile: LayoutProfileIdentity;
toggleSidebar: () => void;
toggleRightPanels: () => void;
setSidebarVisible: (visible: boolean) => void;
@@ -248,7 +305,8 @@ type DockviewStore = {
pinnedWidths: Map<string, number>;
setPinnedWidth: (columnId: string, width: number) => void;
userDefaultLayout: LayoutState | null;
setUserDefaultLayout: (layout: LayoutState | null) => void;
userDefaultLayoutProfile: LayoutProfileIdentity;
setUserDefaultLayout: (layout: LayoutState | null, profile: LayoutProfileIdentity) => void;
activeFilePath: string | null;
activeFileRepo: string | null;
activePanelComponent: string | null;
@@ -638,7 +696,10 @@ function buildPresetActions(set: StoreSet, get: StoreGet) {
preserveChatScrollDuringLayout();
// Capture before layout change; api.width can become stale in rAF.
const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api);
set({ isRestoringLayout: true });
set({
isRestoringLayout: true,
activeLayoutProfile: { kind: "built-in", id: preset },
});
const presetState = getPresetLayout(preset);
const state = mergeCurrentPanelsIntoPreset(api, presetState);
// resetWidths (explicit pick from the layout selector) → preset defaults;
@@ -687,7 +748,10 @@ function buildPresetActions(set: StoreSet, get: StoreGet) {
captureLiveWidths(api, set);
preserveChatScrollDuringLayout();
const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api);
set({ isRestoringLayout: true });
set({
isRestoringLayout: true,
activeLayoutProfile: profileForCustomLayout(layout),
});
const { appliedState, oldFormatRestoreFailed } = restoreCustomLayout({
api,
layout,
@@ -821,6 +885,33 @@ function restoreMaximizeFromStorage(
return true;
}
function restoreIncomingMaximize(args: {
api: DockviewApi;
envId: string;
set: StoreSet;
activeSessionId: string | null;
currentSessionIds: string[];
hasFirstAdoptionRouteLayout: boolean;
savedLayoutProfile: LayoutProfileIdentity | null;
}): boolean {
if (args.hasFirstAdoptionRouteLayout) return false;
if (
!restoreMaximizeFromStorage(
args.api,
args.envId,
args.set,
args.activeSessionId,
args.currentSessionIds,
)
) {
return false;
}
args.set({
activeLayoutProfile: args.savedLayoutProfile ?? inferLayoutProfileFromApi(args.api),
});
return true;
}
// Persist settled layout to env storage; auto-save in setupLayoutPersistence is gated by isRestoringLayout, so preset/custom actions must call this after the flag clears.
/**
* Persist the settled layout to the env's storage slot. No-op when maximized
@@ -838,6 +929,7 @@ export function persistEnvLayoutNow(
if (preMaximizeLayout !== null) return;
try {
setEnvLayout(envId, api.toJSON());
setEnvLayoutProfile(envId, useDockviewStore.getState().activeLayoutProfile);
} catch {
/* ignore serialization/storage failures */
}
@@ -886,6 +978,7 @@ function saveOutgoingEnv(
const { width, height } = measureDockviewContainer(api);
const preMaxSerialized = toSerializedDockview(preMaximizeLayout, width, height, pinnedWidths);
setEnvLayout(oldEnvId, preMaxSerialized as unknown as object);
setEnvLayoutProfile(oldEnvId, useDockviewStore.getState().activeLayoutProfile);
} catch (err) {
console.warn("saveOutgoingEnv: serialize failed", err);
/* fall back: skip writing rather than overwrite with maximized JSON */
@@ -895,6 +988,7 @@ function saveOutgoingEnv(
try {
const json = api.toJSON();
setEnvLayout(oldEnvId, json);
setEnvLayoutProfile(oldEnvId, useDockviewStore.getState().activeLayoutProfile);
if (isDebug()) {
debugWidths(
`save-outgoing env=${oldEnvId} ${formatWidthsSnapshot(snapshotColumnWidths(api))} ` +
@@ -963,6 +1057,8 @@ function buildEnvSwitchAction(set: StoreSet, get: StoreGet) {
saveOutgoingEnv(api, effectiveOld, preMaximizeLayout, get().pinnedWidths);
set({ preMaximizeLayout: null, maximizedGroupId: null });
const manualRightWidth = getManualRightWidth(newEnvId);
const savedEnvLayout = getEnvLayout(newEnvId);
const savedLayoutProfile = getEnvLayoutProfile(newEnvId);
set({
isRestoringLayout: true,
currentLayoutEnvId: newEnvId,
@@ -972,8 +1068,15 @@ function buildEnvSwitchAction(set: StoreSet, get: StoreGet) {
const hasFirstAdoptionRouteLayout =
oldEnvId === null && currentLayoutEnvId === null && Boolean(initialLayout);
if (
!hasFirstAdoptionRouteLayout &&
restoreMaximizeFromStorage(api, newEnvId, set, activeSessionId, currentSessionIds)
restoreIncomingMaximize({
api,
envId: newEnvId,
set,
activeSessionId,
currentSessionIds,
hasFirstAdoptionRouteLayout,
savedLayoutProfile,
})
)
return;
const measured = measureDockviewContainer(api);
@@ -995,7 +1098,16 @@ function buildEnvSwitchAction(set: StoreSet, get: StoreGet) {
},
initialLayout,
});
set(ids);
set({
...ids,
activeLayoutProfile: resolveEnvSwitchProfile({
hasFirstAdoptionRouteLayout,
savedEnvLayout,
savedLayoutProfile,
currentLayoutProfile: get().activeLayoutProfile,
api,
}),
});
enforceFromStore(api, get);
set({ isRestoringLayout: false });
if (isDebug()) {
@@ -1112,7 +1224,7 @@ function performBuildDefault(
get: StoreGet,
intentName?: string,
): void {
const { userDefaultLayout } = get();
const { userDefaultLayout, userDefaultLayoutProfile } = get();
const intent = intentName ? resolveNamedIntent(intentName) : null;
// Capture dimensions before layout change — api.width can become stale
// after fromJSON inside applyLayout
@@ -1124,8 +1236,14 @@ function performBuildDefault(
`pre=${formatWidthsSnapshot(snapshotColumnWidths(api))}`,
);
}
const basePreset = intent?.preset as BuiltInPreset | undefined;
const activeLayoutProfile = resolveDefaultLayoutProfile(
basePreset,
userDefaultLayout,
userDefaultLayoutProfile,
get().defaultPreset,
);
let state = basePreset
? getPresetLayout(basePreset)
: (userDefaultLayout ?? getPresetLayout(get().defaultPreset));
@@ -1143,7 +1261,7 @@ function performBuildDefault(
basePreset,
safeWidth,
);
set({ isRestoringLayout: true, pinnedWidths });
set({ isRestoringLayout: true, pinnedWidths, activeLayoutProfile });
const ids = applyLayout(api, state, pinnedWidths, safeWidth, safeHeight);
const hasSidebar = state.columns.some((c) => c.id === "sidebar");
@@ -1313,6 +1431,7 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
sidebarGroupId: SIDEBAR_GROUP,
sidebarVisible: false,
rightPanelsVisible: true,
activeLayoutProfile: DEFAULT_LAYOUT_PROFILE,
pinnedWidths: new Map(),
setPinnedWidth: (columnId, width) => {
set((prev) => {
@@ -1322,7 +1441,9 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
});
},
userDefaultLayout: null,
setUserDefaultLayout: (layout) => set({ userDefaultLayout: layout }),
userDefaultLayoutProfile: DEFAULT_LAYOUT_PROFILE,
setUserDefaultLayout: (layout, profile) =>
set({ userDefaultLayout: layout, userDefaultLayoutProfile: profile }),
...buildVisibilityActions(set, get),
...buildPresetActions(set, get),
defaultPreset: "default",
@@ -0,0 +1,85 @@
---
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: Restrict Changes Auto-focus
## Overview
Restrict automatic Changes activation to the Default layout's Files and Changes group. The implementation and its regression tests form one sequential work order.
## Scope
### In scope
- Apply one shared eligibility rule to active-task and returning-task updates.
- Track the active built-in or custom profile identity across preset application, restores, task switches, and reloads.
- Require the stable Default top-right group.
- Require exactly the Files and Changes tabs in that group.
- Preserve the current tab in VS Code, Plan, Preview, compact, and custom group compositions.
### Out of scope
- Change Git-status detection, fingerprinting, or inactive-task pending state.
- Change backend layout-profile persistence or panel placement.
- Change mobile or tablet task layouts.
## Technical approach
### Changes activation guard
Update `activateChangesPanel` in `apps/web/components/task/changes-panel-focus.ts`. Require the active profile to be the built-in Default, use `RIGHT_TOP_GROUP` as the layout identity, and permit activation only when the live group contains exactly `files` and `changes`. Persist the active profile identity with each environment-scoped Dockview layout so copied custom profiles remain ineligible after restores and task switches. Carry the effective default's identity alongside its layout when seeding the store, so a reserved customized Default remains built-in Default during fresh builds and Reset Layout while arbitrary custom defaults remain custom.
Keep both activation callers on this shared helper. `ChangesTab` uses it for active-task count increases. `useChangesPanelAutoFocus` uses it after inactive-task updates.
Keep an ineligible pending update available for a later eligible layout. Do not change the pending-state cleanup rules.
### Regression coverage
Add direct unit coverage for the activation guard in `apps/web/components/task/changes-panel-focus.test.ts`.
Replace the E2E expectation that activates Changes in any non-Agent group. The new scenario selects the VS Code preset, leaves VS Code active, and creates another Git update. The test proves that the `vscode | files | changes` group does not lose focus.
Add store and profile-identity coverage for a reserved customized Default used as the effective default during fresh build and Reset Layout, plus the existing env-profile read/write paths.
## Tests
- `AC-UI-TASK-LAYOUT-PROFILES-001.11`: Unit cases cover the eligible Default group, a non-Default group, and a Default group with an extra VS Code tab.
- `AC-UI-TASK-LAYOUT-PROFILES-001.11`: Unit and store-integration cases cover a copied Default custom profile, built-in override identity, and per-environment profile persistence.
- `AC-UI-TASK-LAYOUT-PROFILES-001.11`: Existing state tests preserve reload baselining and inactive-task attention.
- `AC-UI-TASK-LAYOUT-PROFILES-001.11`: A reserved customized Default keeps built-in identity through a fresh build and Reset Layout; arbitrary saved profile IDs remain custom.
## E2E tests
- `AC-UI-TASK-LAYOUT-PROFILES-001.11`: Update `apps/web/e2e/tests/layout/changes-panel-focus.spec.ts` with the reported VS Code group scenario.
- Existing Default-layout scenarios continue to prove eligible activation for active and returning tasks.
- No mobile E2E case is required. Mobile and tablet task layouts do not mount `DockviewDesktopLayout` or this focus hook.
## Work orders
- [x] [Task 01: Restrict Changes auto-focus](task-01-restrict-changes-auto-focus.md) *(done)*
## Verification results
- `pnpm exec vitest run components/task/changes-panel-focus.test.ts`: passed, 20 tests before the fixup and 20 tests after the fixup.
- `pnpm exec vitest run lib/local-storage.test.ts lib/state/dockview-store.test.ts lib/state/dockview-preset-persistence.test.ts components/task/changes-panel-focus.test.ts`: passed, 92 tests.
- `pnpm run typecheck`: passed.
- `pnpm test`: passed, 1,677 test files and 14,381 tests (4 skipped).
- `pnpm run lint`: passed.
- `pnpm e2e:run tests/layout/changes-panel-focus.spec.ts -- --grep "VS Code group"`: passed.
- The complete `changes-panel-focus.spec.ts` suite passed, 6 tests, with the desktop PR capture enabled.
- `python3 ../../scripts/lint-spec-files.test.py`: passed, 20 tests.
- `python3 scripts/lint-spec-files.py --all`: passed when run from the repository root.
- `git diff --check`: passed.
## Risks
- A loose panel-membership check can allow another editor tab to lose focus.
- A guard that uses the current fallback group can misidentify a non-Default layout as the Default layout.
- Removing pending state for an ineligible layout can lose later attention after a switch to the Default layout.
@@ -0,0 +1,101 @@
---
id: "01-restrict-changes-auto-focus"
title: "Restrict Changes auto-focus"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-UI-TASK-LAYOUT-PROFILES-001
acceptance_criteria:
- AC-UI-TASK-LAYOUT-PROFILES-001.11
system_design:
- ../../specs/ui/system-design/task-layout-profiles.md
---
# Task 01: Restrict Changes Auto-focus
## Summary
Restrict automatic Changes activation to the Default top-right group with exactly Files and Changes. Preserve the active tab in all other layouts and group compositions.
## In scope
- Add direct unit coverage for the shared activation guard.
- Track the active built-in or custom profile identity through layout application and environment-scoped restores.
- Preserve the effective default's profile identity through fresh builds and Reset Layout, including reserved built-in overrides.
- Require `RIGHT_TOP_GROUP` and exact `files` and `changes` membership.
- Replace the arbitrary non-Agent-group E2E expectation with the VS Code complaint scenario.
- Preserve Default-layout activation and inactive-task pending behavior.
## Out of scope
- Change the marker and fingerprint logic.
- Change backend layout-profile persistence.
- Change mobile or tablet behavior.
## Acceptance
- A meaningful update activates Changes in the Default Files and Changes group.
- A `vscode | files | changes` group keeps VS Code active after the update.
- A Files and Changes group outside `RIGHT_TOP_GROUP` keeps its active tab.
- A copied Default custom profile keeps its active tab even when its group IDs and tabs match the built-in Default.
- A reserved customized Default remains the built-in Default identity when used for a fresh build or Reset Layout; arbitrary custom defaults remain custom.
## Verification
```bash
pnpm exec vitest run components/task/changes-panel-focus.test.ts
pnpm run typecheck
pnpm test
pnpm run lint
pnpm e2e:run tests/layout/changes-panel-focus.spec.ts -- --grep "VS Code group"
python3 ../../scripts/lint-spec-files.test.py
python3 scripts/lint-spec-files.py --all
git diff --check -- apps/web/components/task/changes-panel-focus.ts apps/web/components/task/changes-panel-focus.test.ts apps/web/e2e/tests/layout/changes-panel-focus.spec.ts docs/specs docs/plans
```
Run the first five commands from `apps/web`. Run the spec test linter from `apps/web` and the all-files spec linter plus the diff check from the repository root. Run `pnpm install --frozen-lockfile` from `apps/` first when dependencies are absent.
## Files likely touched
- `apps/web/components/task/changes-panel-focus.ts`
- `apps/web/components/task/changes-panel-focus.test.ts`
- `apps/web/e2e/tests/layout/changes-panel-focus.spec.ts`
- `apps/web/lib/layout/layout-profiles.ts`
- `apps/web/lib/layout/layout-profiles.test.ts`
- `apps/web/lib/state/dockview-store.ts`
- `apps/web/lib/state/dockview-env-switch-action.test.ts`
- `apps/web/lib/state/dockview-preset-persistence.test.ts`
- `apps/web/components/task/dockview-desktop-layout.tsx`
## Dependencies
None.
## Risks
- The E2E stimulus has no completion event for a forbidden activation. Use the existing bounded negative-assertion dwell.
## Parallelism
`sequential`
## Inputs
- `AC-UI-TASK-LAYOUT-PROFILES-001.11`
- The Changes-attention control flow in the task-layout system design.
- Commit `f100fc97a5`, which expanded activation from the Default group to every non-Agent group.
## Results
- `pnpm exec vitest run components/task/changes-panel-focus.test.ts`: passed, 20 tests before the fixup and 20 tests after the fixup.
- `pnpm exec vitest run lib/local-storage.test.ts lib/state/dockview-store.test.ts lib/state/dockview-preset-persistence.test.ts components/task/changes-panel-focus.test.ts`: passed, 92 tests.
- `pnpm run typecheck`: passed.
- `pnpm test`: passed, 1,677 test files and 14,381 tests (4 skipped).
- `pnpm run lint`: passed.
- `pnpm e2e:run tests/layout/changes-panel-focus.spec.ts -- --grep "VS Code group"`: passed, 1 test.
- The complete `changes-panel-focus.spec.ts` suite passed, 6 tests, and produced a fresh desktop PR capture.
- `python3 ../../scripts/lint-spec-files.test.py`: passed, 20 tests.
- `python3 scripts/lint-spec-files.py --all`: passed from the repository root.
- `git diff --check`: passed.
@@ -29,6 +29,7 @@ Users can arrange and save the desktop task workbench only while a task is open,
- **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.
- **AC-UI-TASK-LAYOUT-PROFILES-001.11:** When a meaningful Git or commit update arrives, Kandev activates Changes only in the desktop Default layout's top-right group. That group must contain exactly the Files and Changes tabs. All other layouts and group contents keep the selected tab active.
## System design
@@ -45,9 +45,11 @@ Users can arrange and save the desktop task workbench only while a task is open,
- Removing Terminal from the effective default prevents the default terminal panel and its backing user shell from being created when a fresh task environment is first opened.
- Applying a layout preserves every configured reusable panel regardless of whether the task has repositories, except PR Details, whose runtime visibility is conditional on a linked review. Other panels without applicable content remain available and show their normal empty state.
- A changed default applies to task environments that have no saved task-specific layout and to an explicit Reset Layout action. It does not overwrite an existing task-specific layout merely because the setting changed.
- Returning to a desktop task restores its task-specific layout, then activates Changes when a meaningful Git or commit update was detected while that task was inactive. This pending-change attention overrides the saved active panel, but an ordinary reload with already-known changes preserves the saved active panel.
- A meaningful Git or commit update can activate Changes for the active desktop task or after the user returns to it.
- `activateChangesPanel` permits this activation only when the active profile is the built-in Default profile and the Changes group uses `RIGHT_TOP_GROUP`. The group must contain exactly the `files` and `changes` panels. Other profiles, layouts, and group contents preserve the selected tab.
- An ordinary reload with already-known changes preserves the saved active panel.
- Switching between tasks whose sessions share one task environment atomically replaces task-owned Agent tabs in their existing group. The handoff preserves the live split tree and proportions instead of briefly emptying the group or rebuilding the environment layout.
- Desktop Agent-tab reconciliation preserves both the selected tab in each existing group and the globally focused panel unless pending inactive-task changes deliberately activate Changes. Replacing a selected Chat placeholder with the active session keeps Agent selected in that group without transiently selecting a neighboring Plan tab, while focus in another group such as Files or Changes remains unchanged. A valid non-Agent tab that was already selected in the Agent group remains selected.
- Desktop Agent-tab reconciliation preserves the selected tab in each group and the globally focused panel. Eligible pending Changes attention can override the selected tab. Replacing a selected Chat placeholder with the active session keeps Agent selected in that group without transiently selecting a neighboring Plan tab. Focus in another group, such as Files or Changes, remains unchanged. A valid non-Agent tab that was already selected in the Agent group remains selected.
- Desktop task-environment restoration finalizes each group's selected tab only after the incoming task's Agent panels have been reconciled. A saved or default Agent selection is semantic: `chat` or a stale `session:<id>` resolves to the incoming active session's live Agent panel in that group. Stable non-Agent selections such as PR Details, Plan, Files, Changes, or a surviving task-specific panel remain exact, and the restored globally focused group is applied last.
- 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.
@@ -80,7 +82,7 @@ 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.
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. Companion environment-scoped preferences store the active layout-profile identity and a raw right-column width only after a genuine user sash drag; legacy layouts without profile metadata use a shape-based compatibility fallback, and layouts without the width preference are responsive defaults rather than manual overrides.
## API surface
@@ -107,6 +109,7 @@ 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.
- The environment-scoped profile identity is saved with the task layout so copied custom profiles cannot be mistaken for the built-in Default after a task switch or reload.
- Existing saved profiles and task-specific layouts are not rewritten to add or remove PR Details. The code-defined templates without PR Details affect fresh environments and explicit Reset Layout actions; custom profiles retain their configured placement while runtime review state controls visibility.
- 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.
@@ -134,7 +137,9 @@ The frontend treats the returned settings payload as authoritative after each su
- **GIVEN** a caller without an explicit split opens a specific pull request or merge request and no canonical PR Details panel exists, **THEN** the keyed review tab opens in the center fallback group.
- **GIVEN** a default profile without Terminal and a task environment with no saved layout, **WHEN** the user first opens that task, **THEN** the workbench has no Terminal tab and no default user shell is created.
- **GIVEN** an existing task with a task-specific layout and no pending inactive-task changes, **WHEN** the user changes the default profile and returns to that task, **THEN** the task-specific layout and saved active panel are unchanged.
- **GIVEN** an existing desktop task with a saved active panel and a Changes panel outside the Agent group, **WHEN** a meaningful Git or commit update is detected while the task is inactive and the user returns to it, **THEN** the task-specific geometry is restored and Changes becomes the active panel after restoration completes.
- **GIVEN** an existing desktop task that uses the Default top-right Files and Changes group, **WHEN** a meaningful Git or commit update is detected while the task is inactive and the user returns, **THEN** the task-specific geometry is restored and Changes becomes active.
- **GIVEN** a custom profile copied from the built-in Default with the same top-right group IDs and tabs, **WHEN** a meaningful Git or commit update arrives, **THEN** the copied profile's selected tab remains active and Changes does not take focus.
- **GIVEN** a desktop task whose Changes group also contains VS Code, **WHEN** a meaningful Git or commit update arrives, **THEN** VS Code remains active and Changes does not take focus.
- **GIVEN** an existing desktop task with already-known changes and a saved active panel, **WHEN** the user reloads that task without a new Git or commit update, **THEN** the saved active panel remains active.
- **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.