From e2c0d74d51a11d74e35881517ac776e59d2c850f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Fri, 24 Jul 2026 16:44:24 +0100 Subject: [PATCH] fix(web): preserve chat focus when restoring task layout (#1923) --- .../task/dockview-session-tabs.test.ts | 95 +++++++++++++++++++ .../components/task/dockview-session-tabs.ts | 21 ++-- 2 files changed, 109 insertions(+), 7 deletions(-) diff --git a/apps/web/components/task/dockview-session-tabs.test.ts b/apps/web/components/task/dockview-session-tabs.test.ts index a9abf2660d..6f928b629e 100644 --- a/apps/web/components/task/dockview-session-tabs.test.ts +++ b/apps/web/components/task/dockview-session-tabs.test.ts @@ -30,6 +30,22 @@ 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"; @@ -165,6 +181,72 @@ 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("closes a stale tracked panel that's still live in dockview", () => { // createdSet has session-A; A's panel is live; A is no longer in the task's @@ -554,6 +636,19 @@ describe("resolveSessionTabSyncTarget", () => { }); describe("runAutoSessionTabEffect", () => { + it("keeps chat active when replacing its placeholder beside a Plan tab", () => { + const sessionId = "session-current"; + const { api, activePanelId } = makeReorderingAutoSessionApi(); + const appStore = makeAutoSessionAppStore(AUTO_TASK_ID, [sessionId]); + const refs = makeAutoSessionRefs(); + + withDockviewState({ api, preMaximizeLayout: null }, () => { + runAutoSessionTabEffect(sessionId, appStore as never, refs as never); + }); + + expect(activePanelId()).toBe(`session:${sessionId}`); + }); + 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 1e8f4bed27..4f399c432b 100644 --- a/apps/web/components/task/dockview-session-tabs.ts +++ b/apps/web/components/task/dockview-session-tabs.ts @@ -533,31 +533,35 @@ type AutoSessionTabRefs = { function activateSessionPanel( api: DockviewApi, effectiveSessionId: string, - sessionPanelExistedBefore: boolean, + activation: { + sessionPanelExistedBefore: boolean; + activePanelIdBeforeEnsure: string | null; + }, refs: AutoSessionTabRefs, tid: string | null, ): ReturnType { const activePanel = api.getPanel(`session:${effectiveSessionId}`); if (!activePanel) return activePanel; - const currentActivePanelId = api.activePanel?.id ?? null; + const activePanelIdAfterReorder = api.activePanel?.id ?? null; const shouldActivate = shouldActivateSessionPanel({ - sessionPanelExistedBefore, + sessionPanelExistedBefore: activation.sessionPanelExistedBefore, prevTaskId: refs.prevTaskIdRef.current, prevSessionId: refs.prevSessionIdRef.current, currentTaskId: tid, currentSessionId: effectiveSessionId, - currentActivePanelId, + currentActivePanelId: activation.activePanelIdBeforeEnsure, }); if (isDebug()) { debug("useAutoSessionTab: activation decision", { effectiveSessionId, shouldActivate, - sessionPanelExistedBefore, + sessionPanelExistedBefore: activation.sessionPanelExistedBefore, prevTaskId: refs.prevTaskIdRef.current, prevSessionId: refs.prevSessionIdRef.current, currentTaskId: tid, - currentActivePanelId, + activePanelIdBeforeEnsure: activation.activePanelIdBeforeEnsure, + activePanelIdAfterReorder, activeGroupId: activePanel.group.id, }); } @@ -763,6 +767,9 @@ export function runAutoSessionTabEffect( const initialPosition = resolveInitialPosition(api); 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 + // with skipSetActive, so reading api.activePanel afterward loses this intent. const activePanelIdBeforeEnsure = api.activePanel?.id ?? null; const preserveActivePanel = shouldPreserveActivePanel( sessionPanelExistedBefore, @@ -788,7 +795,7 @@ export function runAutoSessionTabEffect( const activePanel = activateSessionPanel( api, effectiveSessionId, - sessionPanelExistedBefore, + { sessionPanelExistedBefore, activePanelIdBeforeEnsure }, refs, tid, );