diff --git a/apps/backend/internal/github/poller.go b/apps/backend/internal/github/poller.go index c28f16a8dc..d63a7b2e77 100644 --- a/apps/backend/internal/github/poller.go +++ b/apps/backend/internal/github/poller.go @@ -26,9 +26,12 @@ type TaskBranchInfo struct { Branch string } -// TaskBranchProvider lists tasks that should have PR watches. +// TaskBranchProvider lists tasks that should have PR watches and resolves branches. type TaskBranchProvider interface { ListTasksNeedingPRWatch(ctx context.Context) ([]TaskBranchInfo, error) + // ResolveBranchForSession returns the current branch for a task+session pair. + // Used to detect branch renames and update stale PR watches. + ResolveBranchForSession(ctx context.Context, taskID, sessionID string) string } // Poller runs background loops for PR monitoring and review queue checking. @@ -231,11 +234,17 @@ func (p *Poller) SetTaskBranchProvider(provider TaskBranchProvider) { p.taskBranchProvider = provider } -// reconcileWatches ensures PR watches exist for all tasks that need them. +// reconcileWatches ensures PR watches exist for all tasks that need them, +// and refreshes stale branches on existing watches that haven't found a PR yet. func (p *Poller) reconcileWatches(ctx context.Context) { if p.taskBranchProvider == nil { return } + + // 1. Refresh branches on existing pr_number=0 watches (branch may have changed). + p.refreshStaleBranches(ctx) + + // 2. Create new watches for sessions that don't have one. tasks, err := p.taskBranchProvider.ListTasksNeedingPRWatch(ctx) if err != nil { p.logger.Error("failed to list tasks needing PR watch", zap.Error(err)) @@ -251,6 +260,35 @@ func (p *Poller) reconcileWatches(ctx context.Context) { } } +// refreshStaleBranches re-resolves branches for watches that haven't found a PR yet. +// If the user renamed/changed the branch, the watch is updated so the next poll +// searches on the correct branch. +func (p *Poller) refreshStaleBranches(ctx context.Context) { + watches, err := p.service.ListActivePRWatches(ctx) + if err != nil { + return + } + for _, watch := range watches { + if watch.PRNumber != 0 { + continue // already found a PR, branch is correct + } + currentBranch := p.taskBranchProvider.ResolveBranchForSession( + ctx, watch.TaskID, watch.SessionID, + ) + if currentBranch == "" || currentBranch == watch.Branch { + continue + } + p.logger.Info("PR watch branch changed, updating", + zap.String("session_id", watch.SessionID), + zap.String("old_branch", watch.Branch), + zap.String("new_branch", currentBranch)) + if updateErr := p.service.UpdatePRWatchBranchIfSearching(ctx, watch.ID, currentBranch); updateErr != nil { + p.logger.Error("failed to update PR watch branch", + zap.String("watch_id", watch.ID), zap.Error(updateErr)) + } + } +} + // reviewQueueLoop polls review watches for new PRs. func (p *Poller) reviewQueueLoop(ctx context.Context) { defer p.wg.Done() diff --git a/apps/backend/internal/github/poller_test.go b/apps/backend/internal/github/poller_test.go index 55871f0736..88de4ae36e 100644 --- a/apps/backend/internal/github/poller_test.go +++ b/apps/backend/internal/github/poller_test.go @@ -199,14 +199,22 @@ func TestCheckSinglePRWatch_OpenPR_SyncsOnChange(t *testing.T) { // mockTaskBranchProvider implements TaskBranchProvider for testing. type mockTaskBranchProvider struct { - tasks []TaskBranchInfo - err error + tasks []TaskBranchInfo + err error + branches map[string]string // sessionID -> branch } func (m *mockTaskBranchProvider) ListTasksNeedingPRWatch(_ context.Context) ([]TaskBranchInfo, error) { return m.tasks, m.err } +func (m *mockTaskBranchProvider) ResolveBranchForSession(_ context.Context, _, sessionID string) string { + if m.branches != nil { + return m.branches[sessionID] + } + return "" +} + func TestReconcileWatches_CreatesWatchesForTasks(t *testing.T) { poller, _, _, store := setupPollerTest(t) ctx := context.Background() @@ -359,3 +367,137 @@ func TestCheckSinglePRWatch_OpenPR_NoChange_NoSync(t *testing.T) { t.Error("expected PR watch to still exist") } } + +func TestRefreshStaleBranches_UpdatesBranchWhenChanged(t *testing.T) { + poller, _, _, store := setupPollerTest(t) + ctx := context.Background() + + // Create a watch with pr_number=0 on old branch. + watch := &PRWatch{ + SessionID: "s1", + TaskID: "t1", + Owner: "myorg", + Repo: "myrepo", + PRNumber: 0, + Branch: "old-branch", + } + if err := store.CreatePRWatch(ctx, watch); err != nil { + t.Fatalf("create PR watch: %v", err) + } + + // Provider resolves a different branch for this session. + prov := &mockTaskBranchProvider{ + branches: map[string]string{ + "s1": "new-branch", + }, + } + poller.SetTaskBranchProvider(prov) + + poller.refreshStaleBranches(ctx) + + // Verify the watch branch was updated. + updated, err := store.GetPRWatchBySession(ctx, "s1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if updated == nil { + t.Fatal("expected PR watch to exist") + } + if updated.Branch != "new-branch" { + t.Errorf("expected branch %q, got %q", "new-branch", updated.Branch) + } +} + +func TestRefreshStaleBranches_SkipsWhenBranchUnchanged(t *testing.T) { + poller, _, _, store := setupPollerTest(t) + ctx := context.Background() + + watch := &PRWatch{ + SessionID: "s1", + TaskID: "t1", + Owner: "myorg", + Repo: "myrepo", + PRNumber: 0, + Branch: "same-branch", + } + if err := store.CreatePRWatch(ctx, watch); err != nil { + t.Fatalf("create PR watch: %v", err) + } + + prov := &mockTaskBranchProvider{ + branches: map[string]string{ + "s1": "same-branch", + }, + } + poller.SetTaskBranchProvider(prov) + + poller.refreshStaleBranches(ctx) + + updated, _ := store.GetPRWatchBySession(ctx, "s1") + if updated.Branch != "same-branch" { + t.Errorf("expected branch unchanged, got %q", updated.Branch) + } +} + +func TestRefreshStaleBranches_SkipsWatchesWithPR(t *testing.T) { + poller, _, _, store := setupPollerTest(t) + ctx := context.Background() + + // Watch that already found a PR (pr_number > 0). + watch := &PRWatch{ + SessionID: "s1", + TaskID: "t1", + Owner: "myorg", + Repo: "myrepo", + PRNumber: 42, + Branch: "old-branch", + } + if err := store.CreatePRWatch(ctx, watch); err != nil { + t.Fatalf("create PR watch: %v", err) + } + + prov := &mockTaskBranchProvider{ + branches: map[string]string{ + "s1": "new-branch", + }, + } + poller.SetTaskBranchProvider(prov) + + poller.refreshStaleBranches(ctx) + + // Branch should NOT be updated because PR was already found. + updated, _ := store.GetPRWatchBySession(ctx, "s1") + if updated.Branch != "old-branch" { + t.Errorf("expected branch unchanged for PR watch, got %q", updated.Branch) + } +} + +func TestRefreshStaleBranches_SkipsWhenResolverReturnsEmpty(t *testing.T) { + poller, _, _, store := setupPollerTest(t) + ctx := context.Background() + + watch := &PRWatch{ + SessionID: "s1", + TaskID: "t1", + Owner: "myorg", + Repo: "myrepo", + PRNumber: 0, + Branch: "old-branch", + } + if err := store.CreatePRWatch(ctx, watch); err != nil { + t.Fatalf("create PR watch: %v", err) + } + + // Provider returns empty string (can't resolve). + prov := &mockTaskBranchProvider{ + branches: map[string]string{}, + } + poller.SetTaskBranchProvider(prov) + + poller.refreshStaleBranches(ctx) + + updated, _ := store.GetPRWatchBySession(ctx, "s1") + if updated.Branch != "old-branch" { + t.Errorf("expected branch unchanged when resolver returns empty, got %q", updated.Branch) + } +} diff --git a/apps/backend/internal/github/service.go b/apps/backend/internal/github/service.go index 05ddd6bf55..64247673cc 100644 --- a/apps/backend/internal/github/service.go +++ b/apps/backend/internal/github/service.go @@ -213,6 +213,11 @@ func (s *Service) UpdatePRWatchBranch(ctx context.Context, id, branch string) er return s.store.UpdatePRWatchBranch(ctx, id, branch) } +// UpdatePRWatchBranchIfSearching atomically updates branch only when pr_number = 0. +func (s *Service) UpdatePRWatchBranchIfSearching(ctx context.Context, id, branch string) error { + return s.store.UpdatePRWatchBranchIfSearching(ctx, id, branch) +} + // UpdatePRWatchPRNumber updates a PR watch's PR number after discovery. func (s *Service) UpdatePRWatchPRNumber(ctx context.Context, id string, prNumber int) error { return s.store.UpdatePRWatchPRNumber(ctx, id, prNumber) diff --git a/apps/backend/internal/github/store.go b/apps/backend/internal/github/store.go index 27f6a769a9..2db6173a33 100644 --- a/apps/backend/internal/github/store.go +++ b/apps/backend/internal/github/store.go @@ -187,6 +187,15 @@ func (s *Store) UpdatePRWatchBranch(ctx context.Context, id, branch string) erro return err } +// UpdatePRWatchBranchIfSearching atomically updates branch only when pr_number = 0, +// preventing races with concurrent PR association. +func (s *Store) UpdatePRWatchBranchIfSearching(ctx context.Context, id, branch string) error { + _, err := s.db.ExecContext(ctx, + `UPDATE github_pr_watches SET branch = ?, updated_at = ? WHERE id = ? AND pr_number = 0`, + branch, time.Now().UTC(), id) + return err +} + // --- TaskPR operations --- // CreateTaskPR associates a PR with a task. diff --git a/apps/backend/internal/orchestrator/event_handlers_git.go b/apps/backend/internal/orchestrator/event_handlers_git.go index 48ac42c567..b9589daf43 100644 --- a/apps/backend/internal/orchestrator/event_handlers_git.go +++ b/apps/backend/internal/orchestrator/event_handlers_git.go @@ -62,6 +62,9 @@ func (s *Service) handleGitStatusUpdate(ctx context.Context, data watcher.GitEve _ = s.eventBus.Publish(ctx, events.BuildGitWSEventSubject(data.SessionID), event) } + // Update PR watch branch if the user changed branches (e.g. renamed) + s.syncPRWatchBranch(ctx, data.SessionID, data.Status.Branch) + // Push detection: when ahead goes from >0 to 0, a push happened s.trackPushAndAssociatePR(ctx, data) } @@ -79,6 +82,10 @@ func (s *Service) trackPushAndAssociatePR(ctx context.Context, data watcher.GitE } // Push detected: ahead went from >0 to 0 if data.Status.Ahead == 0 && data.Status.RemoteBranch != "" { + s.logger.Info("git push detected, starting PR association", + zap.String("session_id", data.SessionID), + zap.String("task_id", data.TaskID), + zap.String("branch", data.Status.Branch)) go s.detectPushAndAssociatePR( context.Background(), data.SessionID, @@ -88,6 +95,36 @@ func (s *Service) trackPushAndAssociatePR(ctx context.Context, data watcher.GitE } } +// syncPRWatchBranch updates the PR watch branch if the live git branch +// differs from what's stored (e.g. user renamed the branch). +// Only updates watches that haven't found a PR yet (pr_number=0). +func (s *Service) syncPRWatchBranch(ctx context.Context, sessionID, liveBranch string) { + if s.githubService == nil || liveBranch == "" { + return + } + watch, err := s.githubService.GetPRWatchBySession(ctx, sessionID) + if err != nil { + s.logger.Warn("failed to get PR watch for branch sync", + zap.String("session_id", sessionID), + zap.Error(err)) + return + } + if watch == nil || watch.PRNumber != 0 { + return + } + if watch.Branch == liveBranch { + return + } + s.logger.Info("PR watch branch changed, updating from git status", + zap.String("session_id", sessionID), + zap.String("old_branch", watch.Branch), + zap.String("new_branch", liveBranch)) + if updateErr := s.githubService.UpdatePRWatchBranchIfSearching(ctx, watch.ID, liveBranch); updateErr != nil { + s.logger.Error("failed to update PR watch branch", + zap.String("session_id", sessionID), zap.Error(updateErr)) + } +} + // handleContextWindowUpdated handles context window updates and persists them to session metadata func (s *Service) handleContextWindowUpdated(ctx context.Context, data watcher.ContextWindowData) { s.logger.Debug("handling context window update", diff --git a/apps/backend/internal/orchestrator/event_handlers_github.go b/apps/backend/internal/orchestrator/event_handlers_github.go index d6e60b17d4..fa6a68b3ba 100644 --- a/apps/backend/internal/orchestrator/event_handlers_github.go +++ b/apps/backend/internal/orchestrator/event_handlers_github.go @@ -25,6 +25,7 @@ type GitHubService interface { EnsurePRWatch(ctx context.Context, sessionID, taskID, owner, repo, branch string) (*github.PRWatch, error) GetPRWatchBySession(ctx context.Context, sessionID string) (*github.PRWatch, error) UpdatePRWatchBranch(ctx context.Context, id, branch string) error + UpdatePRWatchBranchIfSearching(ctx context.Context, id, branch string) error UpdatePRWatchPRNumber(ctx context.Context, id string, prNumber int) error AssociatePRWithTask(ctx context.Context, taskID string, pr *github.PR) (*github.TaskPR, error) GetTaskPR(ctx context.Context, taskID string) (*github.TaskPR, error) @@ -323,9 +324,18 @@ func (s *Service) detectPushAndAssociatePR( zap.Duration("delay", delay)) continue } + s.logger.Info("PR found after push, associating with task", + zap.String("session_id", sessionID), + zap.String("task_id", taskID), + zap.Int("pr_number", foundPR.Number), + zap.String("branch", branch)) s.associatePRFromPush(ctx, sessionID, taskID, owner, repoName, branch, foundPR) return } + s.logger.Warn("exhausted all retries, no PR found after push", + zap.String("session_id", sessionID), + zap.String("task_id", taskID), + zap.String("branch", branch)) } // searchPRForExistingWatch handles the case where a PR watch exists with pr_number=0 @@ -336,8 +346,9 @@ func (s *Service) searchPRForExistingWatch( sessionID, taskID, branch string, ) { // Update branch if the agent switched branches since the watch was created. + // Use the atomic variant to guard against a concurrent PR association. if watch.Branch != branch { - if err := s.githubService.UpdatePRWatchBranch(ctx, watch.ID, branch); err != nil { + if err := s.githubService.UpdatePRWatchBranchIfSearching(ctx, watch.ID, branch); err != nil { s.logger.Warn("failed to update PR watch branch", zap.String("watch_id", watch.ID), zap.String("old_branch", watch.Branch), @@ -571,6 +582,12 @@ func (s *Service) ListTasksNeedingPRWatch(ctx context.Context) ([]github.TaskBra return s.buildTaskBranchList(ctx, store) } +// ResolveBranchForSession returns the current branch for a task+session. +// This is used by the poller to detect branch renames on existing PR watches. +func (s *Service) ResolveBranchForSession(ctx context.Context, taskID, sessionID string) string { + return s.resolvePRWatchBranch(ctx, taskID, sessionID, "") +} + // buildTaskBranchList queries sessions with branches, filters out those with // existing PR watches, and resolves GitHub owner/repo for the remainder. func (s *Service) buildTaskBranchList(ctx context.Context, store repoStore) ([]github.TaskBranchInfo, error) { diff --git a/apps/backend/internal/orchestrator/event_handlers_github_test.go b/apps/backend/internal/orchestrator/event_handlers_github_test.go index 064db3ee9a..1357bcd41f 100644 --- a/apps/backend/internal/orchestrator/event_handlers_github_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_github_test.go @@ -54,6 +54,11 @@ func (m *mockGitHubService) UpdatePRWatchBranch(_ context.Context, _, branch str m.updatedBranch = branch return nil } +func (m *mockGitHubService) UpdatePRWatchBranchIfSearching(_ context.Context, _, branch string) error { + m.updateBranchCalls++ + m.updatedBranch = branch + return nil +} func (m *mockGitHubService) UpdatePRWatchPRNumber(_ context.Context, _ string, prNumber int) error { m.updatePRNumberCalls++ m.updatedPRNumber = prNumber diff --git a/apps/web/components/task/changes-tab.tsx b/apps/web/components/task/changes-tab.tsx index 3770ab092e..649576c652 100644 --- a/apps/web/components/task/changes-tab.tsx +++ b/apps/web/components/task/changes-tab.tsx @@ -12,32 +12,19 @@ import { useAppStore } from "@/components/state-provider"; import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status"; import { useSessionCommits } from "@/hooks/domains/session/use-session-commits"; import { useDockviewStore } from "@/lib/state/dockview-store"; -import { focusOrAddPanel } from "@/lib/state/dockview-layout-builders"; import { cn } from "@kandev/ui/lib/utils"; -/** Auto-activate the changes panel in the right sidebar, or quietly add to center if missing. */ +/** Auto-activate the changes panel only when it lives in the right sidebar. */ function autoActivateChangesPanel(): void { - const { api, rightTopGroupId, centerGroupId } = useDockviewStore.getState(); + const { api, rightTopGroupId } = useDockviewStore.getState(); if (!api) return; const panel = api.getPanel("changes"); + // Only auto-focus when the panel is in the right sidebar. + // When it's in the center group (e.g. plan mode layout), never steal focus + // from the active chat/session panel. if (panel && panel.group.id === rightTopGroupId) { panel.api.setActive(); - return; - } - - if (!panel) { - focusOrAddPanel( - api, - { - id: "changes", - component: "changes", - tabComponent: "changesTab", - title: "Changes", - position: { referenceGroup: centerGroupId }, - }, - true, - ); } } @@ -55,9 +42,16 @@ export function ChangesTab(props: IDockviewPanelHeaderProps) { const fileCount = gitStatus?.files ? Object.keys(gitStatus.files).length : 0; const totalCount = fileCount + commits.length; + // gitStatus is undefined until the first WS git-status event arrives, + // which marks the end of the initial data load for this session. + const gitStatusLoaded = gitStatus !== undefined; + const prevTotalRef = useRef(totalCount); const seenCountRef = useRef(api.isActive ? totalCount : 0); const flashTimerRef = useRef | null>(null); + // Armed once we know the initial git data has settled. Until then, any + // 0→N transition is treated as an initial load, not a real new change. + const initializedRef = useRef(false); const [isFlashing, setIsFlashing] = useState(false); const [badgeCount, setBadgeCount] = useState(0); @@ -85,7 +79,13 @@ export function ChangesTab(props: IDockviewPanelHeaderProps) { const increased = totalCount > prev && totalCount > 0; const decreased = totalCount < prev; - if (increased && prev === 0) { + // Auto-activate when changes appear for the first time (0 → N), but only + // after initial git data has settled. gitStatusLoaded is false until the + // first WS git-status event arrives, guaranteeing data has loaded before we + // arm auto-activate (handles both page-refresh and clean-session cases). + if (!initializedRef.current) { + if (gitStatusLoaded) initializedRef.current = true; + } else if (increased && prev === 0) { autoActivateChangesPanel(); } @@ -100,7 +100,7 @@ export function ChangesTab(props: IDockviewPanelHeaderProps) { const unseen = Math.max(0, totalCount - seenCountRef.current); requestAnimationFrame(() => setBadgeCount(unseen)); } - }, [totalCount, api]); + }, [totalCount, api, gitStatusLoaded]); // Cleanup flash timer on unmount useEffect(() => { diff --git a/apps/web/e2e/tests/layout/changes-panel-focus.spec.ts b/apps/web/e2e/tests/layout/changes-panel-focus.spec.ts new file mode 100644 index 0000000000..c4a4245d37 --- /dev/null +++ b/apps/web/e2e/tests/layout/changes-panel-focus.spec.ts @@ -0,0 +1,174 @@ +import { test, expect } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; +import fs from "node:fs"; +import path from "node:path"; +import { execSync } from "node:child_process"; + +/** Minimal git helper for E2E tests - runs git commands in the test repository. */ +class GitHelper { + constructor( + private repoDir: string, + private env: NodeJS.ProcessEnv, + ) {} + + exec(cmd: string): string { + return execSync(cmd, { cwd: this.repoDir, env: this.env, encoding: "utf8" }); + } + + createFile(name: string, content: string) { + fs.writeFileSync(path.join(this.repoDir, name), content); + } + + stageAll() { + this.exec("git add -A"); + } + + commit(message: string) { + this.exec(`git commit -m "${message}"`); + } +} + +test.describe("Changes panel focus behavior", () => { + /** + * Verifies the changes panel does NOT steal focus from the chat tab + * on page refresh when the task has existing git changes/commits. + * + * Root cause of the bug: the changes-tab component auto-activated the + * changes panel whenever totalCount went from 0 → N. On page refresh, + * hooks start with totalCount=0 (no data loaded), then async git data + * arrives making totalCount > 0 — triggering the auto-activate. + */ + test("changes panel does not auto-focus on page refresh", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + test.setTimeout(90_000); + + const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo"); + const gitEnv = { + ...process.env, + HOME: backend.tmpDir, + GIT_AUTHOR_NAME: "E2E Test", + GIT_AUTHOR_EMAIL: "e2e@test.local", + GIT_COMMITTER_NAME: "E2E Test", + GIT_COMMITTER_EMAIL: "e2e@test.local", + }; + const git = new GitHelper(repoDir, gitEnv); + + // Create a task and wait for the agent to be ready + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Focus test task", + seedData.agentProfileId, + { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + + // Create a file and commit so there are existing changes + git.createFile("test-file.txt", "hello world"); + git.stageAll(); + git.commit("test commit"); + + // Wait for the changes panel to show the commit + await session.clickTab("Changes"); + await expect(session.changes).toBeVisible({ timeout: 10_000 }); + await expect(session.changes.getByText("test commit")).toBeVisible({ timeout: 10_000 }); + + // Switch back to chat tab — this is the tab that should be active after refresh + await session.clickSessionChatTab(); + await expect(session.chat).toBeVisible({ timeout: 5_000 }); + + // Refresh the page + await testPage.reload(); + await session.waitForLoad(); + + // Wait for the git data to load (changes tab should show count) + await expect(testPage.locator(".dv-default-tab:has-text('Changes')")).toBeVisible({ + timeout: 15_000, + }); + + // The chat/session panel should be the active tab, NOT changes + const changesTab = testPage.locator(".dv-default-tab:has-text('Changes')"); + await expect(changesTab).not.toHaveClass(/dv-active-tab/, { timeout: 5_000 }); + + // Chat should be visible (active in center group) + await expect(session.chat).toBeVisible({ timeout: 5_000 }); + }); + + /** + * Verifies the changes panel does NOT auto-focus when it is in the center + * group (e.g. plan mode layout). Even when new changes appear, the chat + * panel should stay focused. + */ + test("changes panel does not auto-focus when in center group", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + test.setTimeout(90_000); + + const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo"); + const gitEnv = { + ...process.env, + HOME: backend.tmpDir, + GIT_AUTHOR_NAME: "E2E Test", + GIT_AUTHOR_EMAIL: "e2e@test.local", + GIT_COMMITTER_NAME: "E2E Test", + GIT_COMMITTER_EMAIL: "e2e@test.local", + }; + const git = new GitHelper(repoDir, gitEnv); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Center group focus test", + seedData.agentProfileId, + { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + + // Toggle plan mode — this moves changes into the center group + await session.togglePlanMode(); + await expect(session.planPanel).toBeVisible({ timeout: 10_000 }); + await expect(session.chat).toBeVisible(); + + // Verify the chat/session tab is active, not changes + const changesTab = testPage.locator(".dv-default-tab:has-text('Changes')"); + await expect(changesTab).not.toHaveClass(/dv-active-tab/, { timeout: 5_000 }); + + // Create new changes — this would previously trigger auto-activate + git.createFile("new-file.txt", "new content"); + git.stageAll(); + git.commit("new commit"); + + // Wait for the changes badge to update + await expect(testPage.locator(".dv-default-tab:has-text('Changes')")).toBeVisible({ + timeout: 15_000, + }); + + // Wait a bit for any async auto-activate to fire + await testPage.waitForTimeout(2_000); + + // Changes tab should NOT have stolen focus + await expect(changesTab).not.toHaveClass(/dv-active-tab/, { timeout: 5_000 }); + + // Chat should still be visible (active) + await expect(session.chat).toBeVisible(); + }); +}); diff --git a/apps/web/lib/state/layout-manager/applier.ts b/apps/web/lib/state/layout-manager/applier.ts index db54d9a003..b644b82045 100644 --- a/apps/web/lib/state/layout-manager/applier.ts +++ b/apps/web/lib/state/layout-manager/applier.ts @@ -36,7 +36,11 @@ export function resolveGroupIds(api: DockviewApi): LayoutGroupIds { return { sidebarGroupId: findGroupId(api, SIDEBAR_GROUP, "sidebar"), centerGroupId: findGroupId(api, CENTER_GROUP, "chat"), - rightTopGroupId: findGroupId(api, RIGHT_TOP_GROUP, "changes"), + // Always use the well-known constant — do NOT fall back to the "changes" + // panel's current group. In plan mode the "changes" panel moves into the + // center group; a panel-based fallback would return the center group ID and + // defeat the auto-focus guard in changes-tab.tsx. + rightTopGroupId: RIGHT_TOP_GROUP, rightBottomGroupId: findGroupId(api, RIGHT_BOTTOM_GROUP, TERMINAL_DEFAULT_ID), }; }