diff --git a/apps/web/components/task/dockview-session-tab-activation.ts b/apps/web/components/task/dockview-session-tab-activation.ts index f62562e014..2ff8adebfe 100644 --- a/apps/web/components/task/dockview-session-tab-activation.ts +++ b/apps/web/components/task/dockview-session-tab-activation.ts @@ -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, diff --git a/apps/web/components/task/dockview-session-tabs.test-utils.ts b/apps/web/components/task/dockview-session-tabs.test-utils.ts index 1883127633..987927e80d 100644 --- a/apps/web/components/task/dockview-session-tabs.test-utils.ts +++ b/apps/web/components/task/dockview-session-tabs.test-utils.ts @@ -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 void>>; + component: string; + moveTo: ReturnType void>>; + setActive: ReturnType 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, + }; +} diff --git a/apps/web/components/task/dockview-session-tabs.test.ts b/apps/web/components/task/dockview-session-tabs.test.ts index 02781ec3cb..5afd5cbb54 100644 --- a/apps/web/components/task/dockview-session-tabs.test.ts +++ b/apps/web/components/task/dockview-session-tabs.test.ts @@ -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 void>>; - component: string; - moveTo: ReturnType void>>; - setActive: ReturnType 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" }], diff --git a/apps/web/components/task/dockview-session-tabs.ts b/apps/web/components/task/dockview-session-tabs.ts index 67aea8c927..86145cd882 100644 --- a/apps/web/components/task/dockview-session-tabs.ts +++ b/apps/web/components/task/dockview-session-tabs.ts @@ -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; diff --git a/apps/web/e2e/tests/layout/saved-layout-session-isolation.spec.ts b/apps/web/e2e/tests/layout/saved-layout-session-isolation.spec.ts index 459b990537..acc077a53e 100644 --- a/apps/web/e2e/tests/layout/saved-layout-session-isolation.spec.ts +++ b/apps/web/e2e/tests/layout/saved-layout-session-isolation.spec.ts @@ -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 }; + }; + 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((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }), + ); + + await page.evaluate((currentTaskId) => { + type StoreState = { + taskSessionsByTask: { itemsByTaskId: Record }; + }; + 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); + }); }); diff --git a/docs/plans/dockview-session-focus-preservation/plan.md b/docs/plans/dockview-session-focus-preservation/plan.md new file mode 100644 index 0000000000..dcb05fed4a --- /dev/null +++ b/docs/plans/dockview-session-focus-preservation/plan.md @@ -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.` 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. diff --git a/docs/plans/dockview-session-focus-preservation/task-01-preserve-session-focus.md b/docs/plans/dockview-session-focus-preservation/task-01-preserve-session-focus.md new file mode 100644 index 0000000000..d0295ba26a --- /dev/null +++ b/docs/plans/dockview-session-focus-preservation/task-01-preserve-session-focus.md @@ -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. diff --git a/docs/specs/ui/task-layout-profiles.md b/docs/specs/ui/task-layout-profiles.md index 0034d28e4a..28e7dafe1c 100644 --- a/docs/specs/ui/task-layout-profiles.md +++ b/docs/specs/ui/task-layout-profiles.md @@ -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.