fix(web): preserve dockview focus during session restore (#2060)
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user