From ba8d99f33f0e959e018c8a24e03f3adf328fbcda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Thu, 23 Jul 2026 21:24:37 +0100 Subject: [PATCH] fix(task): preserve saved panel focus on task return (#1907) --- .../task/changes-panel-focus.test.ts | 30 +++++++++ .../components/task/changes-panel-focus.ts | 21 ++++++- .../tests/layout/changes-panel-focus.spec.ts | 63 +++++++++++++++++++ 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/apps/web/components/task/changes-panel-focus.test.ts b/apps/web/components/task/changes-panel-focus.test.ts index f147e39015..e43b5c2877 100644 --- a/apps/web/components/task/changes-panel-focus.test.ts +++ b/apps/web/components/task/changes-panel-focus.test.ts @@ -223,6 +223,36 @@ describe("applyChangesPanelAutoFocusState fallback session keys", () => { }); }); +describe("applyChangesPanelAutoFocusState saved layouts", () => { + it("keeps a returning environment's saved active panel over pending changes", () => { + const previousMarkers = { + envB: { count: 1, fingerprint: "b1" }, + }; + const pendingEnvKeys = new Set(["envB"]); + let activateCalls = 0; + + applyChangesPanelAutoFocusState({ + signalsByEnv: { + envB: signal(1, "b1"), + }, + activeEnvKey: "envB", + previousActiveEnvKey: "envA", + environmentIdBySessionId: {}, + previousMarkers, + pendingEnvKeys, + hasSavedLayout: true, + isRestoringLayout: false, + activate: () => { + activateCalls += 1; + return "activated"; + }, + }); + + expect(activateCalls).toBe(0); + expect(pendingEnvKeys.size).toBe(0); + }); +}); + describe("applyChangesPanelAutoFocusState", () => { it("migrates keys before queuing, defers during restore, and clears after activation", () => { const previousMarkers = {}; diff --git a/apps/web/components/task/changes-panel-focus.ts b/apps/web/components/task/changes-panel-focus.ts index 6e1672350c..2972b0f1c7 100644 --- a/apps/web/components/task/changes-panel-focus.ts +++ b/apps/web/components/task/changes-panel-focus.ts @@ -7,6 +7,7 @@ import { useAppStore } from "@/components/state-provider"; import { useDockviewStore } from "@/lib/state/dockview-store"; import type { AppState } from "@/lib/state/store"; import type { FileInfo, GitStatusEntry } from "@/lib/state/slices/session-runtime/types"; +import { getEnvLayout } from "@/lib/local-storage"; import { djb2Hash } from "@/lib/utils/hash"; type DockviewPanel = NonNullable>; @@ -250,6 +251,7 @@ export function applyChangesPanelAutoFocusState(args: { environmentIdBySessionId: Record; previousMarkers: Record; pendingEnvKeys: Set; + hasSavedLayout?: boolean; isRestoringLayout: boolean; getIsRestoringLayout?: () => boolean; activate: () => ActivateChangesPanelResult; @@ -261,6 +263,7 @@ export function applyChangesPanelAutoFocusState(args: { environmentIdBySessionId, previousMarkers, pendingEnvKeys, + hasSavedLayout = false, isRestoringLayout, getIsRestoringLayout, activate, @@ -286,7 +289,12 @@ export function applyChangesPanelAutoFocusState(args: { const isCurrentlyRestoringLayout = () => isRestoringLayout || getIsRestoringLayout?.() === true; - if (activeEnvKey && !isCurrentlyRestoringLayout() && pendingEnvKeys.has(activeEnvKey)) { + // A saved env layout records the user's active panel. Pending Changes + // attention may choose the first panel for an unseen env, but must not + // override that explicit focus when the user returns to a task. + if (activeEnvKey && hasSavedLayout) { + pendingEnvKeys.delete(activeEnvKey); + } else if (activeEnvKey && !isCurrentlyRestoringLayout() && pendingEnvKeys.has(activeEnvKey)) { const result = activate(); if ( shouldClearPendingChangesFocus(result) && @@ -307,6 +315,7 @@ export function useChangesPanelAutoFocus(activeEnvKey: string | null) { const previousMarkersRef = useRef>({}); const pendingEnvKeysRef = useRef>(new Set()); const previousActiveEnvKeyRef = useRef(null); + const hasSavedLayout = activeEnvKey !== null && getEnvLayout(activeEnvKey) !== null; useEffect(() => { previousActiveEnvKeyRef.current = applyChangesPanelAutoFocusState({ @@ -316,9 +325,17 @@ export function useChangesPanelAutoFocus(activeEnvKey: string | null) { environmentIdBySessionId, previousMarkers: previousMarkersRef.current, pendingEnvKeys: pendingEnvKeysRef.current, + hasSavedLayout, isRestoringLayout, getIsRestoringLayout: () => useDockviewStore.getState().isRestoringLayout, activate: () => activateChangesPanel(api), }); - }, [signalsByEnv, activeEnvKey, api, isRestoringLayout, environmentIdBySessionId]); + }, [ + signalsByEnv, + activeEnvKey, + api, + isRestoringLayout, + environmentIdBySessionId, + hasSavedLayout, + ]); } diff --git a/apps/web/e2e/tests/layout/changes-panel-focus.spec.ts b/apps/web/e2e/tests/layout/changes-panel-focus.spec.ts index 87a7731d51..8819130b9b 100644 --- a/apps/web/e2e/tests/layout/changes-panel-focus.spec.ts +++ b/apps/web/e2e/tests/layout/changes-panel-focus.spec.ts @@ -384,4 +384,67 @@ test.describe("Changes panel focus behavior", () => { await expect(changesTab(testPage)).toHaveClass(/dv-active-tab/, { timeout: 5_000 }); }); + + test("new git updates do not replace a returning task's saved agent tab", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + + const taskA = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Saved Agent Focus A", + seedData.agentProfileId, + { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + const taskB = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Saved Agent Focus B", + seedData.agentProfileId, + { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + if (!taskA.session_id || !taskB.session_id) { + throw new Error("Tasks did not start sessions"); + } + + await testPage.goto(`/t/${taskA.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); + await session.waitForDockviewReady(); + await session.clickSessionChatTab(); + await expect(session.activeChat()).toBeVisible(); + + await session.taskInSidebar("Saved Agent Focus B").click(); + await expect(testPage).toHaveURL((url) => url.pathname.includes(taskB.id), { + timeout: 15_000, + }); + await session.waitForLoad(); + + await setGitStatusForSession(testPage, taskA.session_id, []); + await expect + .poll(() => changesCountForSession(testPage, taskA.session_id!), { timeout: 15_000 }) + .toBe(0); + await setGitStatusForSession(testPage, taskA.session_id, ["background-change.txt"]); + await expect + .poll(() => changesCountForSession(testPage, taskA.session_id!), { timeout: 15_000 }) + .toBe(1); + + await session.taskInSidebar("Saved Agent Focus A").click(); + + await expect(testPage).toHaveURL((url) => url.pathname.includes(taskA.id), { + timeout: 15_000, + }); + await expect(session.activeChat()).toBeVisible({ timeout: 5_000 }); + await expect(changesTab(testPage)).not.toHaveClass(/dv-active-tab/); + }); });