fix(web): preserve dockview focus during session restore (#2060)

This commit is contained in:
Carlos Florêncio
2026-07-30 21:27:04 +01:00
committed by GitHub
parent 167ab6b778
commit f74b0cc618
8 changed files with 748 additions and 86 deletions
@@ -1,3 +1,5 @@
import type { DockviewApi } from "dockview-react";
export type SessionPanelActivationArgs = {
sessionPanelExistedBefore: boolean;
prevTaskId: string | null;
@@ -14,6 +16,29 @@ export function shouldPreserveActivePanel(
return !sessionPanelExistedBefore && !!activePanelId && activePanelId !== "chat";
}
export function isChatPlaceholderSelected(api: DockviewApi): boolean {
const chatPanel = api.getPanel("chat");
return chatPanel?.group.activePanel?.id === chatPanel?.id;
}
export function activateChatReplacement(
api: DockviewApi,
effectiveSessionId: string,
shouldActivateReplacement: boolean,
): void {
if (!shouldActivateReplacement) return;
api.getPanel(`session:${effectiveSessionId}`)?.api.setActive();
}
export function restorePreservedActivePanel(
api: DockviewApi,
panelId: string | null,
shouldRestore: boolean,
): void {
if (!shouldRestore || !panelId) return;
api.getPanel(panelId)?.api.setActive();
}
export function shouldActivateSessionPanel(args: SessionPanelActivationArgs): boolean {
const {
sessionPanelExistedBefore,
@@ -85,3 +85,127 @@ export function makeSharedEnvironmentHandoffApi(): {
groupIds: () => groups.map((group) => group.id),
};
}
type ReorderingMoveOptions = {
index: number;
skipSetActive: boolean;
};
type ReorderingPanel = {
id: string;
group: ReorderingGroup;
api: {
close: ReturnType<typeof vi.fn<() => void>>;
component: string;
moveTo: ReturnType<typeof vi.fn<(options: ReorderingMoveOptions) => void>>;
setActive: ReturnType<typeof vi.fn<() => void>>;
};
};
type ReorderingGroup = {
id: string;
panels: ReorderingPanel[];
activePanel: ReorderingPanel | null;
};
/**
* Models Dockview 4.13.1's separate group-local selection and global active
* panel, including the sibling activation caused by removing or moving the
* selected panel.
*/
export function makeReorderingAutoSessionApi(globalActivePanelId: "chat" | "files" = "chat"): {
api: DockviewApi;
activePanelId: () => string | null;
centerActivePanelId: () => string | null;
activationSequence: string[];
} {
const panels: ReorderingPanel[] = [];
const group: ReorderingGroup = { id: CENTER_GROUP, panels: [], activePanel: null };
const rightGroup: ReorderingGroup = { id: RIGHT_TOP_GROUP, panels: [], activePanel: null };
const activationSequence: string[] = [];
let activePanel: ReorderingPanel | null = null;
const activate = (panel: ReorderingPanel) => {
panel.group.activePanel = panel;
activePanel = panel;
activationSequence.push(panel.id);
};
const removePanel = (panel: ReorderingPanel) => {
const panelGroup = panel.group;
const groupIndex = panelGroup.panels.indexOf(panel);
if (groupIndex !== -1) panelGroup.panels.splice(groupIndex, 1);
const panelIndex = panels.indexOf(panel);
if (panelIndex !== -1) panels.splice(panelIndex, 1);
if (panelGroup.activePanel === panel) {
const successor = panelGroup.panels[0] ?? null;
panelGroup.activePanel = successor;
if (successor) activate(successor);
}
};
const createPanel = (
id: string,
component: string,
panelGroup: ReorderingGroup = group,
index?: number,
): ReorderingPanel => {
const panel: ReorderingPanel = {
id,
group: panelGroup,
api: {
close: vi.fn(() => removePanel(panel)),
component,
moveTo: vi.fn((options: ReorderingMoveOptions) => {
const sourceIndex = panelGroup.panels.indexOf(panel);
if (sourceIndex !== -1) panelGroup.panels.splice(sourceIndex, 1);
if (panelGroup.activePanel === panel) {
const successor = panelGroup.panels[0] ?? null;
panelGroup.activePanel = successor;
if (successor) activate(successor);
}
panelGroup.panels.splice(options.index, 0, panel);
if (!options.skipSetActive) activate(panel);
}),
setActive: vi.fn(() => activate(panel)),
},
};
panels.push(panel);
panelGroup.panels.splice(index ?? panelGroup.panels.length, 0, panel);
return panel;
};
const chatPanel = createPanel("chat", "chat");
createPanel("plan", "plan");
const filesPanel = createPanel("files", "files", rightGroup);
group.activePanel = chatPanel;
rightGroup.activePanel = filesPanel;
activePanel = globalActivePanelId === "files" ? filesPanel : chatPanel;
const api = {
get activePanel() {
return activePanel;
},
panels,
groups: [group, rightGroup],
getPanel: (id: string) => panels.find((panel) => panel.id === id) ?? null,
addPanel: (options: {
id: string;
component: string;
inactive?: boolean;
position?: { index?: number };
}): ReorderingPanel => {
const panel = createPanel(options.id, options.component, group, options.position?.index);
if (!options.inactive) activate(panel);
return panel;
},
removePanel,
} as unknown as DockviewApi;
return {
api,
activePanelId: () => activePanel?.id ?? null,
centerActivePanelId: () => group.activePanel?.id ?? null,
activationSequence,
};
}
@@ -12,7 +12,10 @@ import {
} from "./dockview-session-tabs";
import { CENTER_GROUP, RIGHT_TOP_GROUP } from "@/lib/state/layout-manager";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { makeSharedEnvironmentHandoffApi } from "./dockview-session-tabs.test-utils";
import {
makeReorderingAutoSessionApi,
makeSharedEnvironmentHandoffApi,
} from "./dockview-session-tabs.test-utils";
type FakePanel = {
id: string;
@@ -31,22 +34,6 @@ type MoveToCall = {
options: MoveToOptions;
};
type ReorderingPanel = {
id: string;
group: ReorderingGroup;
api: {
close: ReturnType<typeof vi.fn<() => void>>;
component: string;
moveTo: ReturnType<typeof vi.fn<(options: MoveToOptions) => void>>;
setActive: ReturnType<typeof vi.fn<() => void>>;
};
};
type ReorderingGroup = {
id: string;
panels: ReorderingPanel[];
};
const KEEP = "keep";
const KEEP_PANEL = `session:${KEEP}`;
const LEAKED_PANEL = "session:leaked";
@@ -182,72 +169,6 @@ function expectedMove(panelId: string, index: number): MoveToCall {
};
}
/**
* Models Dockview 4.13.1's same-group move behavior: moving the active panel
* removes it first, which activates a remaining sibling, then reinserts the
* moved panel inactive when skipSetActive is true.
*/
function makeReorderingAutoSessionApi(): {
api: DockviewApi;
activePanelId: () => string | null;
} {
const panels: ReorderingPanel[] = [];
const group: ReorderingGroup = { id: CENTER_GROUP, panels: [] };
let activePanel: ReorderingPanel | null = null;
const removePanel = (panel: ReorderingPanel) => {
const groupIndex = group.panels.indexOf(panel);
if (groupIndex !== -1) group.panels.splice(groupIndex, 1);
const panelIndex = panels.indexOf(panel);
if (panelIndex !== -1) panels.splice(panelIndex, 1);
if (activePanel === panel) activePanel = group.panels[0] ?? null;
};
const createPanel = (id: string, component: string): ReorderingPanel => {
const panel: ReorderingPanel = {
id,
group,
api: {
close: vi.fn(() => removePanel(panel)),
component,
moveTo: vi.fn((options: MoveToOptions) => {
const sourceIndex = group.panels.indexOf(panel);
if (sourceIndex !== -1) group.panels.splice(sourceIndex, 1);
if (activePanel === panel) activePanel = group.panels[0] ?? null;
group.panels.splice(options.index, 0, panel);
if (!options.skipSetActive) activePanel = panel;
}),
setActive: vi.fn(() => {
activePanel = panel;
}),
},
};
panels.push(panel);
group.panels.push(panel);
return panel;
};
activePanel = createPanel("chat", "chat");
createPanel("plan", "plan");
const api = {
get activePanel() {
return activePanel;
},
panels,
groups: [group],
getPanel: (id: string) => panels.find((panel) => panel.id === id) ?? null,
addPanel: (options: { id: string; component: string; inactive?: boolean }): ReorderingPanel => {
const panel = createPanel(options.id, options.component);
if (!options.inactive) activePanel = panel;
return panel;
},
removePanel,
} as unknown as DockviewApi;
return { api, activePanelId: () => activePanel?.id ?? null };
}
describe("reconcileRemovedSessionPanels", () => {
it("replaces the outgoing session before its final close can destroy the center group", () => {
const { api, events, groupIds } = makeSharedEnvironmentHandoffApi();
@@ -410,6 +331,15 @@ describe("findSessionAnchorGroupId", () => {
});
describe("resolveInitialPosition", () => {
it("replaces Chat inside its live custom-layout group", () => {
const api = makePositionApi({
groups: [RIGHT_TOP_GROUP],
panels: [{ id: "chat", groupId: RIGHT_TOP_GROUP }],
});
expect(resolveInitialPosition(api)).toEqual({ referenceGroup: RIGHT_TOP_GROUP });
});
it("creates a center column left of the right sidebar when only the right group remains", () => {
useDockviewStore.setState({ centerGroupId: RIGHT_TOP_GROUP });
const api = makePositionApi({ groups: [RIGHT_TOP_GROUP] });
@@ -666,6 +596,28 @@ describe("runAutoSessionTabEffect", () => {
expect(activePanelId()).toBe(`session:${sessionId}`);
});
it("replaces selected Chat without activating Plan while Files owns global focus", () => {
const sessionId = "session-current";
const { api, activePanelId, centerActivePanelId, activationSequence } =
makeReorderingAutoSessionApi("files");
const appStore = makeAutoSessionAppStore(AUTO_TASK_ID, [sessionId]);
const refs = makeAutoSessionRefs();
withDockviewState({ api, preMaximizeLayout: null }, () => {
runAutoSessionTabEffect(sessionId, appStore as never, refs as never);
});
expect({
centerActivePanelId: centerActivePanelId(),
globalActivePanelId: activePanelId(),
planActivated: activationSequence.includes("plan"),
}).toEqual({
centerActivePanelId: `session:${sessionId}`,
globalActivePanelId: "files",
planActivated: false,
});
});
it("updates previous refs when panel ensure is skipped for an unhydrated session", () => {
const api = {
panels: [{ id: "chat" }],
@@ -18,6 +18,9 @@ import type { TaskPR } from "@/lib/types/github";
import { getPrimaryTaskPR } from "@/hooks/domains/github/use-task-pr";
import { prTaskKey } from "@/components/github/pr-utils";
import {
activateChatReplacement,
isChatPlaceholderSelected,
restorePreservedActivePanel,
shouldActivateSessionPanel,
shouldPreserveActivePanel,
} from "./dockview-session-tab-activation";
@@ -363,14 +366,25 @@ export function findSessionAnchorGroupId(api: DockviewApi): string | null {
return null;
}
export function resolveInitialPosition(api: DockviewApi): AddPanelOptions["position"] {
export function resolveInitialPosition(
api: DockviewApi,
chatWasSelected = false,
): AddPanelOptions["position"] {
// Prefer the live "chat" placeholder's group. The session panel must be added
// INTO that group (so it's a tab beside chat) BEFORE chat is removed —
// otherwise removing chat empties and destroys the center group, the stored
// centerGroupId goes stale, and the session panel gets appended as a new row,
// collapsing the horizontal default layout into a vertical stack.
const chatGroupId = api.getPanel("chat")?.group?.id;
if (chatGroupId && isCenterCandidateGroupId(chatGroupId)) return { referenceGroup: chatGroupId };
if (chatGroupId) {
// Put the replacement before selected Chat so its group remains focused
// through removal. Otherwise retain the saved tab order for an inactive
// Chat placeholder.
return {
referenceGroup: chatGroupId,
...(chatWasSelected ? { index: 0 } : {}),
};
}
const { centerGroupId } = useDockviewStore.getState();
const centerGroupExists =
centerGroupId &&
@@ -750,7 +764,8 @@ export function runAutoSessionTabEffect(
return;
}
const initialPosition = resolveInitialPosition(api);
const chatWasSelected = isChatPlaceholderSelected(api);
const initialPosition = resolveInitialPosition(api, chatWasSelected);
const sessionPanelExistedBefore = !!api.getPanel(`session:${effectiveSessionId}`);
// Preserve the restored/user-selected panel before adding and reordering the
// session tab. Dockview's same-group move briefly activates a sibling even
@@ -771,6 +786,13 @@ export function runAutoSessionTabEffect(
refs.sessionTabCreatedRef.current,
);
// Chat is a placeholder for the real Agent panel. When it was selected and
// this effect created its replacement, transfer that group-local selection
// before removal so Dockview never chooses a neighboring Plan tab. A session
// anchored during task switching already has its restored selection, which
// must not be overwritten here.
activateChatReplacement(api, effectiveSessionId, chatWasSelected && !sessionPanelExistedBefore);
// Now that the session panel occupies the center group, drop the generic
// "chat" placeholder. Order matters: removing chat first would empty and
// destroy the center group, collapsing the horizontal layout.
@@ -785,6 +807,8 @@ export function runAutoSessionTabEffect(
tid,
);
restorePreservedActivePanel(api, activePanelIdBeforeEnsure, preserveActivePanel);
const siblingAnchor: AddPanelOptions["position"] = activePanel
? { referenceGroup: activePanel.group.id }
: initialPosition;
@@ -1,8 +1,16 @@
import { expect, type Page } from "@playwright/test";
import { test, type SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { SessionPage } from "../../pages/session-page";
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
const CREATE_PLAN_SCRIPT = [
'e2e:thinking("creating plan")',
"e2e:delay(100)",
'e2e:mcp:kandev:create_task_plan_kandev({"task_id":"{task_id}","content":"## Focus regression\\n\\nKeep Agent selected","title":"Focus plan"})',
"e2e:delay(100)",
'e2e:message("focus regression agent")',
].join("\n");
async function createFinishedTaskWithSession(
apiClient: ApiClient,
@@ -106,6 +114,161 @@ async function dockviewDefaultTree(page: Page, sessionId: string) {
}, sessionId);
}
function focusSplitLayout() {
return {
grid: {
root: {
type: "branch",
size: 700,
data: [
{
type: "leaf",
size: 900,
data: {
id: "group-center",
views: ["chat", "plan"],
activeView: "chat",
},
},
{
type: "branch",
size: 360,
data: [
{
type: "leaf",
size: 420,
data: {
id: "group-right-top",
views: ["files", "changes"],
activeView: "files",
},
},
{
type: "leaf",
size: 280,
data: {
id: "group-right-bottom",
views: ["terminal-default"],
activeView: "terminal-default",
},
},
],
},
],
},
height: 700,
width: 1260,
orientation: "HORIZONTAL",
},
panels: {
chat: { id: "chat", contentComponent: "chat", title: "Agent" },
plan: {
id: "plan",
contentComponent: "plan",
tabComponent: "planTab",
title: "Plan",
},
files: { id: "files", contentComponent: "files", title: "Files" },
changes: {
id: "changes",
contentComponent: "changes",
tabComponent: "changesTab",
title: "Changes",
},
"terminal-default": {
id: "terminal-default",
contentComponent: "terminal",
tabComponent: "terminalTab",
title: "Terminal",
},
},
activeGroup: "group-right-top",
};
}
async function triggerFocusSplitReconciliation(page: Page, taskId: string, sessionId: string) {
await page.evaluate(
({ currentTaskId, currentSessionId }) => {
type Panel = {
id: string;
group: { id: string };
api: { close: () => void; setActive: () => void };
};
type StoreState = {
taskSessionsByTask: { itemsByTaskId: Record<string, unknown[]> };
};
type TestWindow = Window & {
__KANDEV_E2E_STORE__?: {
getState: () => StoreState;
setState: (updater: (state: StoreState) => void) => void;
};
__dockviewApi__?: {
addPanel: (options: object) => Panel;
getPanel: (id: string) => Panel | null;
};
__focusTestSessions__?: unknown[];
};
const win = window as TestWindow;
const api = win.__dockviewApi__;
const store = win.__KANDEV_E2E_STORE__;
if (!api || !store) throw new Error("Dockview or E2E store bridge missing");
const sessionPanel = api.getPanel(`session:${currentSessionId}`);
const centerGroupId = sessionPanel?.group.id ?? api.getPanel("plan")?.group.id;
if (!centerGroupId) throw new Error("Center group missing");
const chatPanel =
api.getPanel("chat") ??
api.addPanel({
id: "chat",
component: "chat",
title: "Agent",
position: { referenceGroup: centerGroupId, index: 0 },
inactive: true,
});
const filesPanel = api.getPanel("files");
if (!filesPanel) throw new Error("Files panel missing");
chatPanel.api.setActive();
filesPanel.api.setActive();
sessionPanel?.api.close();
const sessions = store.getState().taskSessionsByTask.itemsByTaskId[currentTaskId];
if (!sessions?.length) throw new Error("Task session list missing");
win.__focusTestSessions__ = sessions;
store.setState((state) => {
state.taskSessionsByTask.itemsByTaskId[currentTaskId] = [];
});
},
{ currentTaskId: taskId, currentSessionId: sessionId },
);
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
await page.evaluate((currentTaskId) => {
type StoreState = {
taskSessionsByTask: { itemsByTaskId: Record<string, unknown[]> };
};
type TestWindow = Window & {
__KANDEV_E2E_STORE__?: {
setState: (updater: (state: StoreState) => void) => void;
};
__focusTestSessions__?: unknown[];
};
const win = window as TestWindow;
const store = win.__KANDEV_E2E_STORE__;
const sessions = win.__focusTestSessions__;
if (!store || !sessions) throw new Error("Focus test session snapshot missing");
store.setState((state) => {
state.taskSessionsByTask.itemsByTaskId[currentTaskId] = sessions;
});
}, taskId);
}
test.describe("saved Dockview layouts", () => {
test("keeps the desktop split tree when switching tasks in one environment", async ({
testPage,
@@ -253,4 +416,71 @@ test.describe("saved Dockview layouts", () => {
await expect(testPage.getByText("target task current")).toBeVisible();
await expect(testPage.getByText("source task only")).not.toBeVisible();
});
test("preserves Agent selection while Files owns global focus", async ({
testPage,
apiClient,
seedData,
}) => {
const { task, sessionId } = await createFinishedTaskWithSession(
apiClient,
seedData,
"Dockview focus preservation",
'/e2e:message("focus regression agent")',
);
const { sessions } = await apiClient.listTaskSessions(task.id);
const environmentId = sessions[0]?.task_environment_id;
if (!environmentId) throw new Error("Task environment id missing");
await testPage.addInitScript(
({ envId, layout }) => {
window.sessionStorage.setItem(
`kandev.dockview.env-layout-v3.${envId}`,
JSON.stringify(layout),
);
},
{ envId: environmentId, layout: focusSplitLayout() },
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await expect(testPage.getByTestId("dockview-task-layout")).toBeVisible({ timeout: 15_000 });
await expect(testPage.getByTestId(`session-tab-${sessionId}`)).toBeVisible({
timeout: 15_000,
});
await session.clickSessionChatTab();
await session.waitForLoad();
await session.sendMessage(CREATE_PLAN_SCRIPT);
await expect(session.idleInput()).toBeVisible({ timeout: 45_000 });
await expect.poll(async () => (await apiClient.getTaskPlan(task.id))?.created_by).toBe("agent");
const planTab = testPage.locator(".dv-tab", {
has: testPage.locator(".dv-default-tab:has-text('Plan')"),
});
await expect(planTab).toBeVisible({ timeout: 15_000 });
await expect(testPage.getByTestId("plan-tab-indicator")).toBeVisible({ timeout: 15_000 });
await triggerFocusSplitReconciliation(testPage, task.id, sessionId);
const sessionTab = testPage.locator(".dv-tab", {
has: testPage.getByTestId(`session-tab-${sessionId}`),
});
await expect(sessionTab).toHaveClass(/dv-active-tab/);
await expect(planTab).not.toHaveClass(/dv-active-tab/);
await expect(testPage.getByTestId("plan-tab-indicator")).toBeVisible();
await expect
.poll(() =>
testPage.evaluate(
() =>
(
window as unknown as {
__dockviewApi__?: { activePanel?: { id: string }; panels: Array<{ id: string }> };
}
).__dockviewApi__?.activePanel?.id ?? null,
),
)
.toBe("files");
expect(
(await dockviewPanelIds(testPage)).filter((id) => id === `session:${sessionId}`),
).toHaveLength(1);
});
});
@@ -0,0 +1,185 @@
---
spec: docs/specs/ui/task-layout-profiles.md
created: 2026-07-30
status: completed
---
# Implementation Plan: Preserve Dockview Session Focus During Reconciliation
## Overview
Repair the desktop session-tab reconciliation path that can select Plan while
replacing the generic Chat placeholder. The implementation will preserve the
Agent group's selected content independently from Dockview's globally focused
panel, then prove the exact Files-focused task-switch regression with unit and
production-build browser coverage.
## Confirmed Root Cause
`runAutoSessionTabEffect` records only `api.activePanel`, which is Dockview's
globally active panel. In the reported layout, Files owns global focus while
Chat remains the selected tab in the inactive center group beside Plan.
The effect adds the incoming session inactive, removes the selected Chat
placeholder, and then normalizes session-tab order. Dockview 4.13.1 responds to
removing the selected group tab by opening its most-recent sibling and making
that group globally active. Because the session was appended after Plan, Plan
becomes active. The later activation decision correctly returns
`shouldActivate=false` for the globally focused Files panel, but that branch
does not restore Files or transfer Chat's group-local selection to Agent. The
existing logs already capture the decisive transition
`files -> plan, shouldActivate=false`; no new production logging is required.
## Frontend
### Session placeholder replacement
Likely file:
- `apps/web/components/task/dockview-session-tabs.ts`
Treat the generic Chat placeholder as the session panel's replacement slot,
not merely as a group anchor:
- capture whether Chat is the selected panel in its group separately from
`api.activePanel`;
- insert the incoming session inactive at Chat's session-first replacement
position while Chat is still selected, so later tab-order normalization
does not have to move an active Agent panel past Plan;
- when Chat was selected, establish the incoming Agent as the selected panel
before removing Chat, so Dockview never chooses Plan as Chat's successor;
- after the Agent group's selection is stable, restore the previously global
non-session panel when it still exists and the existing activation policy
says to preserve it;
- retain the current behavior for an already-selected Plan, PR, MR, file
preview, explicit intra-task session switch, missing panel, and maximized
layout.
Do not add a delayed correction after Plan activates. Plan activation has an
observable side effect (`PlanTab` marks the plan seen), so the repair must
prevent the transient activation itself.
## Tests
### Unit RED
File:
- `apps/web/components/task/dockview-session-tabs.test.ts`
Extend the fake Dockview model to represent:
- a center group with Chat selected beside Plan;
- a right group with Files globally active;
- Dockview's real behavior where removing the selected group tab opens its
most-recent sibling and activates that group.
The regression must fail before production changes by showing Plan activation.
The passing assertions require:
- the incoming session is the center group's selected panel;
- Files remains Dockview's global active panel;
- Plan is never present in the recorded activation sequence; and
- the existing Chat-global, saved-Plan, and task/session-switch activation
cases continue to pass.
### Existing helper coverage
Keep `apps/web/components/task/dockview-session-tab-activation.test.ts` in the
targeted unit command because it owns the surrounding session-activation
policy, even if the pure helper does not require a production change.
## E2E Tests
### Desktop restored-layout regression
File:
- `apps/web/e2e/tests/layout/saved-layout-session-isolation.spec.ts`
Create an agent-authored plan through the mock-agent script, wait for the task
session and plan to be durable, and resolve the task environment ID. Before
navigating to the task, seed that environment's
`kandev.dockview.env-layout-v3.<env-id>` session-storage entry with:
- center `activeView: "chat"` and views `[chat, plan]`;
- right-top `activeView: "files"` and views `[files, changes]`; and
- `activeGroup: "group-right-top"`.
Open the task through the UI, create the agent-authored plan while the page is
subscribed so its unseen indicator is armed, and use the existing E2E store
bridge to replay the late session-list hydration that triggers Agent
reconciliation. Assert afterward that:
- the active session's Agent content is visible rather than Plan content;
- Files remains the globally active Dockview panel;
- the Plan tab is not active and its unseen indicator remains visible; and
- the current session panel is present exactly once.
Use API helpers only for task/plan/layout preconditions and verify the outcome
through the rendered task UI plus the exposed Dockview API for the global-focus
identity.
## Mobile And Tablet
No mobile or tablet test is required. `TaskLayout` renders
`SessionMobileLayout` and `SessionTabletLayout` instead of
`DockviewDesktopLayout`; neither viewport executes `useAutoSessionTab` or the
Chat-placeholder replacement. The closest mobile exemplar is
`apps/web/components/task/mobile/session-mobile-layout.tsx`, whose
single-surface navigation, touch targets, scroll ownership, and task
capabilities remain unchanged. This is state normalization inside the existing
desktop workbench, not a responsive composition or interaction change.
## Implementation Waves
Wave 1 (sequential):
- [x] [Task 01 — Preserve session focus](task-01-preserve-session-focus.md)
## Verification
Unit RED/GREEN and related activation policy:
```bash
cd apps && pnpm --filter @kandev/web test -- --run \
components/task/dockview-session-tabs.test.ts \
components/task/dockview-session-tab-activation.test.ts
```
Frontend typecheck:
```bash
cd apps/web && pnpm run typecheck
```
Managed production-build desktop E2E:
```bash
cd apps/web && pnpm e2e:run tests/layout/saved-layout-session-isolation.spec.ts \
-- --grep "preserves Agent selection while Files owns global focus"
```
## Risks
- Dockview distinguishes a group's selected panel from the globally active
panel. Collapsing those concepts recreates this bug in another group.
- Reacting after Plan activates is insufficient because the Plan tab can mark
an unseen plan as seen during the transient activation.
- Repositioning the fresh session panel must retain the existing session-first
tab-order convention and must not move an already-restored session panel.
- Restoring a captured global panel must be conditional on that panel still
existing; stale cross-task or deleted panels must never be resurrected.
## Out Of Scope
- Changing task-layout persistence, profile schemas, or Dockview versions.
- Altering explicit Plan, PR, MR, preview, Files, or Changes tab selections.
- Changing mobile or tablet task-detail composition.
- Adding more debug logging.
## Documentation Impact
No public documentation changes are required. This repair restores the
existing internal desktop layout-focus contract and changes no command,
configuration key, API, or user-facing terminology.
@@ -0,0 +1,119 @@
---
id: "01-preserve-session-focus"
title: "Preserve session focus during Chat reconciliation"
status: done
wave: 1
depends_on: []
parallelism: sequential
plan: "plan.md"
spec: "../../specs/ui/task-layout-profiles.md"
---
# Task 01: Preserve Session Focus During Chat Reconciliation
## Acceptance
- Replacing a selected Chat placeholder beside Plan selects the incoming
session's Agent panel without ever selecting Plan.
- A valid globally focused non-session panel such as Files or Changes remains
globally focused after the Agent group's selection is established.
- Existing saved non-Agent selections, explicit session switches, tab order,
stale-panel guards, maximized layouts, and mobile/tablet behavior remain
unchanged.
## TDD Sequence
1. RED: extend the session-tabs fake to reproduce a center Chat/Plan group with
Chat selected while Files is globally active; prove the current effect
activates Plan.
2. RED: add the desktop Playwright scenario by creating an agent plan, resolving
its environment ID, and seeding the v3 environment layout with center
`activeView: "chat"` and global `activeGroup: "group-right-top"`; prove the
rendered task opens Plan or loses its unseen state.
3. GREEN: place the inactive session in Chat's session-first replacement slot,
select it before removing Chat, then restore the still-live global
non-session panel.
4. REFACTOR: keep group-selection and global-focus preservation explicit and
reuse the existing activation policy without widening the change.
5. Run the exact targeted unit, typecheck, and production-build E2E commands.
## Verification
```bash
cd apps && pnpm --filter @kandev/web test -- --run \
components/task/dockview-session-tabs.test.ts \
components/task/dockview-session-tab-activation.test.ts
cd apps/web && pnpm run typecheck
cd apps/web && pnpm e2e:run tests/layout/saved-layout-session-isolation.spec.ts \
-- --grep "preserves Agent selection while Files owns global focus"
```
## Files Likely Touched
- `apps/web/components/task/dockview-session-tab-activation.ts`
- `apps/web/components/task/dockview-session-tabs.ts`
- `apps/web/components/task/dockview-session-tabs.test.ts`
- `apps/web/components/task/dockview-session-tabs.test-utils.ts`
- `apps/web/e2e/tests/layout/saved-layout-session-isolation.spec.ts`
## Dependencies
None.
## Parallelism
Sequential. The unit fake, production behavior, and browser scenario describe
one ordering-sensitive Dockview interaction and should be implemented in one
Red-Green-Refactor cycle.
## Inputs
- `docs/specs/ui/task-layout-profiles.md`, especially the desktop session-focus
behavior, persistence guarantee, and reconciliation scenario.
- `docs/plans/dockview-session-focus-preservation/plan.md`.
- Dockview 4.13.1 `DockviewGroupPanelModel.openPanel`, `removePanel`, and
`doSetActivePanel` behavior.
- Existing focus regressions in
`apps/web/components/task/dockview-session-tabs.test.ts`,
`apps/web/e2e/tests/layout/plan-panel-indicator.spec.ts`, and
`apps/web/e2e/tests/layout/preview-tab-session-switch.spec.ts`.
## Risks
- A final-state-only assertion can miss transient Plan activation and its
plan-seen side effect; record the activation sequence in the unit fake.
- The saved E2E precondition must prove Files owns global focus while Chat is
selected in the center group or it will exercise the already-covered Chat
global-focus branch.
- A captured global panel may disappear during cross-task cleanup; restore it
only when it remains live.
## Output Contract
Report the RED assertion and activation sequence, the minimum production
change, exact files changed, unit/typecheck/E2E results, task and plan status
updates, and any remaining Dockview focus ambiguity.
## Results
- RED unit: the center selection and global focus both became `plan`, and the
activation sequence recorded Plan.
- RED E2E: the production-build screenshot showed Plan selected, Agent
inactive, Files unfocused, and the unseen Plan indicator consumed.
- GREEN implementation: the session replaces Chat in Chat's live group (at
index 0 when Chat was selected), inherits its group-local selection before
removal, and then yields global focus back to the captured live panel.
- Unit: 44 tests passed across `dockview-session-tabs.test.ts` and
`dockview-session-tab-activation.test.ts`.
- Typecheck: `pnpm run typecheck` passed.
- E2E: the focused Chromium production-build scenario passed, including Agent
selection, Files global focus, preserved Plan indicator, and exactly one
session panel.
- Mobile/tablet: no rendered or responsive path changed; these viewports do not
mount `DockviewDesktopLayout` or execute the repaired reconciliation hook.
- CI remediation: sessions anchored before reconciliation no longer re-activate
over a saved preview/file selection, while custom layouts always replace Chat
in Chat's actual group rather than creating a default center split.
- Regression checks: targeted unit suite (37 tests), typecheck, and focused
Chromium scenarios for task-switch preview focus, refresh preview focus,
returning-Agent focus, and the Plan/Files indicator all passed.
+3
View File
@@ -23,6 +23,7 @@ Users can arrange and save the desktop task workbench only while a task is open,
- Applying a layout preserves every configured reusable panel regardless of whether the task has repositories. 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.
- 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. 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.
- 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.
@@ -75,6 +76,7 @@ The frontend treats the returned settings payload as authoritative after each su
- 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.
- Replacing the generic Chat placeholder with a session-owned Agent panel preserves the placeholder group's selected content and the workbench's globally focused panel; internal tab insertion, removal, or ordering does not acknowledge an unseen Plan.
## Scenarios
@@ -88,6 +90,7 @@ The frontend treats the returned settings payload as authoritative after each su
- **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 desktop task layout whose Agent group has Chat selected beside an unseen Plan while Files or Changes owns global focus, **WHEN** session reconciliation replaces Chat with the active session's Agent panel, **THEN** Agent remains selected in its group, the globally focused Files or Changes panel remains focused, Plan is never selected or marked seen, and the user is not switched to Plan.
- **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.