From 7632f626c8c2d75a56515ecf69d8f93e60ee1328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 29 Jun 2026 19:24:51 +0100 Subject: [PATCH] fix: focus changes panel for inactive task updates (#1542) --- .../task/changes-panel-focus.test.ts | 491 ++++++++++++++++++ .../components/task/changes-panel-focus.ts | 324 ++++++++++++ apps/web/components/task/changes-tab.tsx | 24 +- .../task/dockview-desktop-layout.tsx | 3 + .../tests/layout/changes-panel-focus.spec.ts | 147 +++++- 5 files changed, 965 insertions(+), 24 deletions(-) create mode 100644 apps/web/components/task/changes-panel-focus.test.ts create mode 100644 apps/web/components/task/changes-panel-focus.ts diff --git a/apps/web/components/task/changes-panel-focus.test.ts b/apps/web/components/task/changes-panel-focus.test.ts new file mode 100644 index 0000000000..f147e39015 --- /dev/null +++ b/apps/web/components/task/changes-panel-focus.test.ts @@ -0,0 +1,491 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + applyChangesPanelAutoFocusState, + markInactiveChangesIncreases, + migrateEnvironmentKeys, + selectChangesMarkerByEnvironment, + shouldClearPendingChangesFocus, +} from "./changes-panel-focus"; +import type { FileInfo, GitStatusEntry } from "@/lib/state/slices/session-runtime/types"; + +const hashDiffMock = vi.hoisted(() => vi.fn((diff: string) => `hash:${diff}`)); + +vi.mock("@/lib/utils/hash", () => ({ + djb2Hash: hashDiffMock, +})); + +const TEST_TIMESTAMP = "2026-06-29T00:00:00Z"; +const INITIAL_FINGERPRINT = "repo:path:initial"; +const UPDATED_FINGERPRINT = "repo:path:updated"; + +beforeEach(() => { + hashDiffMock.mockClear(); +}); + +function gitStatus(files: string[], timestamp = TEST_TIMESTAMP, diff = ""): GitStatusEntry { + return { + branch: "main", + remote_branch: null, + modified: [], + added: [], + deleted: [], + untracked: files, + renamed: [], + ahead: 0, + behind: 0, + files: Object.fromEntries( + files.map((path) => [path, { path, status: "untracked", staged: false, diff }]), + ), + timestamp, + }; +} + +describe("selectChangesMarkerByEnvironment cache", () => { + it("reuses cached fingerprints for unchanged git status objects", () => { + const file: FileInfo = { + path: "one.ts", + status: "modified", + staged: false, + diff: "large diff body", + }; + const status: GitStatusEntry = { + branch: "main", + remote_branch: null, + modified: ["one.ts"], + added: [], + deleted: [], + untracked: [], + renamed: [], + ahead: 0, + behind: 0, + files: { "one.ts": file }, + timestamp: TEST_TIMESTAMP, + }; + const state = { + gitStatus: { + byEnvironmentId: {}, + byEnvironmentRepo: { + envA: { + repo1: status, + }, + }, + }, + sessionCommits: { + loading: {}, + refetchTrigger: {}, + byEnvironmentId: {}, + }, + }; + + const baseMarker = selectChangesMarkerByEnvironment(state).envA; + const nextMarker = selectChangesMarkerByEnvironment(state).envA; + + expect(nextMarker).toEqual(baseMarker); + expect(hashDiffMock).toHaveBeenCalledTimes(1); + }); +}); + +describe("selectChangesMarkerByEnvironment", () => { + it("ignores timestamp-only git refreshes", () => { + const baseState = { + gitStatus: { + byEnvironmentId: {}, + byEnvironmentRepo: { + envA: { + repo1: gitStatus(["one.ts"], "2026-06-29T00:00:00Z"), + }, + }, + }, + sessionCommits: { + loading: {}, + refetchTrigger: {}, + byEnvironmentId: {}, + }, + }; + const nextState = { + ...baseState, + gitStatus: { + byEnvironmentId: {}, + byEnvironmentRepo: { + envA: { + repo1: gitStatus(["one.ts"], "2026-06-29T00:01:00Z"), + }, + }, + }, + }; + + const baseMarker = selectChangesMarkerByEnvironment(baseState).envA; + const nextMarker = selectChangesMarkerByEnvironment(nextState).envA; + + expect(nextMarker.count).toBe(baseMarker.count); + expect(nextMarker.fingerprint).toBe(baseMarker.fingerprint); + }); + + it("changes fingerprint for diff-only git updates with the same count", () => { + const baseState = { + gitStatus: { + byEnvironmentId: {}, + byEnvironmentRepo: { + envA: { + repo1: gitStatus(["one.ts"], TEST_TIMESTAMP, "old diff"), + }, + }, + }, + sessionCommits: { + loading: {}, + refetchTrigger: {}, + byEnvironmentId: {}, + }, + }; + const nextState = { + ...baseState, + gitStatus: { + byEnvironmentId: {}, + byEnvironmentRepo: { + envA: { + repo1: gitStatus(["one.ts"], TEST_TIMESTAMP, "new diff"), + }, + }, + }, + }; + + const baseMarker = selectChangesMarkerByEnvironment(baseState).envA; + const nextMarker = selectChangesMarkerByEnvironment(nextState).envA; + + expect(nextMarker.count).toBe(baseMarker.count); + expect(nextMarker.fingerprint).not.toBe(baseMarker.fingerprint); + }); +}); + +function signal(count: number, fingerprint: string): string { + return `${count}\u0000${fingerprint}`; +} + +describe("applyChangesPanelAutoFocusState fallback session keys", () => { + it("activates pending fallback session keys before the env id mapping arrives", () => { + const previousMarkers = { + "session-B": { count: 1, fingerprint: INITIAL_FINGERPRINT }, + }; + const pendingEnvKeys = new Set(["session-B"]); + let activateCalls = 0; + + const previousActiveEnvKey = applyChangesPanelAutoFocusState({ + signalsByEnv: { + "session-B": signal(1, INITIAL_FINGERPRINT), + }, + activeEnvKey: "session-B", + previousActiveEnvKey: "envA", + environmentIdBySessionId: {}, + previousMarkers, + pendingEnvKeys, + isRestoringLayout: false, + activate: () => { + activateCalls += 1; + return "activated"; + }, + }); + + expect(previousActiveEnvKey).toBe("session-B"); + expect(activateCalls).toBe(1); + expect(pendingEnvKeys.size).toBe(0); + }); + + it("does not queue updates for the active fallback session key", () => { + const previousMarkers = { + "session-A": { count: 1, fingerprint: INITIAL_FINGERPRINT }, + }; + const pendingEnvKeys = new Set(); + let activateCalls = 0; + + const previousActiveEnvKey = applyChangesPanelAutoFocusState({ + signalsByEnv: { + "session-A": signal(2, UPDATED_FINGERPRINT), + }, + activeEnvKey: "session-A", + previousActiveEnvKey: "session-A", + environmentIdBySessionId: {}, + previousMarkers, + pendingEnvKeys, + isRestoringLayout: false, + activate: () => { + activateCalls += 1; + return "activated"; + }, + }); + + expect(previousActiveEnvKey).toBe("session-A"); + expect(activateCalls).toBe(0); + expect(pendingEnvKeys.size).toBe(0); + expect(previousMarkers["session-A"]).toEqual({ + count: 2, + fingerprint: UPDATED_FINGERPRINT, + }); + }); +}); + +describe("applyChangesPanelAutoFocusState", () => { + it("migrates keys before queuing, defers during restore, and clears after activation", () => { + const previousMarkers = {}; + const pendingEnvKeys = new Set(); + let previousActiveEnvKey: string | null = "envA"; + let activateCalls = 0; + + previousActiveEnvKey = applyChangesPanelAutoFocusState({ + signalsByEnv: { + "session-B": signal(1, INITIAL_FINGERPRINT), + }, + activeEnvKey: "envA", + previousActiveEnvKey, + environmentIdBySessionId: {}, + previousMarkers, + pendingEnvKeys, + isRestoringLayout: false, + activate: () => { + activateCalls += 1; + return "activated"; + }, + }); + + expect(pendingEnvKeys.size).toBe(0); + + previousActiveEnvKey = applyChangesPanelAutoFocusState({ + signalsByEnv: { + envB: signal(12, UPDATED_FINGERPRINT), + }, + activeEnvKey: "envA", + previousActiveEnvKey, + environmentIdBySessionId: { "session-B": "envB" }, + previousMarkers, + pendingEnvKeys, + isRestoringLayout: false, + activate: () => { + activateCalls += 1; + return "activated"; + }, + }); + + expect([...pendingEnvKeys]).toEqual(["envB"]); + expect(previousMarkers).toEqual({ + envB: { count: 12, fingerprint: UPDATED_FINGERPRINT }, + }); + + previousActiveEnvKey = applyChangesPanelAutoFocusState({ + signalsByEnv: { + envB: signal(12, UPDATED_FINGERPRINT), + }, + activeEnvKey: "envB", + previousActiveEnvKey, + environmentIdBySessionId: { "session-B": "envB" }, + previousMarkers, + pendingEnvKeys, + isRestoringLayout: true, + activate: () => { + activateCalls += 1; + return "activated"; + }, + }); + + expect(activateCalls).toBe(0); + expect([...pendingEnvKeys]).toEqual(["envB"]); + + previousActiveEnvKey = applyChangesPanelAutoFocusState({ + signalsByEnv: { + envB: signal(12, UPDATED_FINGERPRINT), + }, + activeEnvKey: "envB", + previousActiveEnvKey, + environmentIdBySessionId: { "session-B": "envB" }, + previousMarkers, + pendingEnvKeys, + isRestoringLayout: false, + activate: () => { + activateCalls += 1; + return "activated"; + }, + }); + + expect(previousActiveEnvKey).toBe("envB"); + expect(activateCalls).toBe(1); + expect(pendingEnvKeys.size).toBe(0); + }); +}); + +describe("markInactiveChangesIncreases", () => { + it("baselines first observations and queues only inactive environment increases", () => { + const previousMarkers = {}; + const pendingEnvKeys = new Set(); + + markInactiveChangesIncreases({ + markersByEnv: { + envA: { count: 1, fingerprint: "a1" }, + envB: { count: 0, fingerprint: "b0" }, + }, + activeEnvKey: "envA", + previousActiveEnvKey: "envA", + previousMarkers, + pendingEnvKeys, + }); + + expect(pendingEnvKeys.size).toBe(0); + + markInactiveChangesIncreases({ + markersByEnv: { + envA: { count: 2, fingerprint: "a2" }, + envB: { count: 1, fingerprint: "b1" }, + }, + activeEnvKey: "envA", + previousActiveEnvKey: "envA", + previousMarkers, + pendingEnvKeys, + }); + + expect([...pendingEnvKeys]).toEqual(["envB"]); + expect(previousMarkers).toEqual({ + envA: { count: 2, fingerprint: "a2" }, + envB: { count: 1, fingerprint: "b1" }, + }); + }); + + it("queues a same-batch update for the previously inactive environment", () => { + const previousMarkers = { + envB: { count: 0, fingerprint: "b0" }, + }; + const pendingEnvKeys = new Set(); + + markInactiveChangesIncreases({ + markersByEnv: { + envB: { count: 1, fingerprint: "b1" }, + }, + activeEnvKey: "envB", + previousActiveEnvKey: "envA", + previousMarkers, + pendingEnvKeys, + }); + + expect([...pendingEnvKeys]).toEqual(["envB"]); + }); + + it("queues count-neutral meaningful updates for inactive environments", () => { + const previousMarkers = { + envB: { count: 1, fingerprint: "b1" }, + }; + const pendingEnvKeys = new Set(); + + markInactiveChangesIncreases({ + markersByEnv: { + envB: { count: 1, fingerprint: "b2" }, + }, + activeEnvKey: "envA", + previousActiveEnvKey: "envA", + previousMarkers, + pendingEnvKeys, + }); + + expect([...pendingEnvKeys]).toEqual(["envB"]); + }); +}); + +describe("markInactiveChangesIncreases pending cleanup", () => { + it("queues first observed inactive changes after bootstrap", () => { + const previousMarkers = { + envA: { count: 0, fingerprint: "a0" }, + }; + const pendingEnvKeys = new Set(); + + markInactiveChangesIncreases({ + markersByEnv: { + envA: { count: 0, fingerprint: "a0" }, + envB: { count: 1, fingerprint: "b1" }, + }, + activeEnvKey: "envA", + previousActiveEnvKey: "envA", + previousMarkers, + pendingEnvKeys, + queueFirstObservedInactiveChanges: true, + }); + + expect([...pendingEnvKeys]).toEqual(["envB"]); + }); + + it("clears pending focus when changes disappear or the environment disappears", () => { + const previousMarkers = { + envB: { count: 1, fingerprint: "b1" }, + envC: { count: 1, fingerprint: "c1" }, + }; + const pendingEnvKeys = new Set(["envB", "envC"]); + + markInactiveChangesIncreases({ + markersByEnv: { + envB: { count: 0, fingerprint: "b0" }, + }, + activeEnvKey: "envA", + previousActiveEnvKey: "envA", + previousMarkers, + pendingEnvKeys, + queueFirstObservedInactiveChanges: true, + }); + + expect(pendingEnvKeys.size).toBe(0); + expect(previousMarkers).toEqual({ + envB: { count: 0, fingerprint: "b0" }, + }); + }); +}); + +describe("applyChangesPanelAutoFocusState restore races", () => { + it("keeps pending focus when a stale restore flag produces no panel", () => { + const previousMarkers = { + envB: { count: 1, fingerprint: "b1" }, + }; + const pendingEnvKeys = new Set(["envB"]); + let restoring = false; + + applyChangesPanelAutoFocusState({ + signalsByEnv: { + envB: signal(1, "b1"), + }, + activeEnvKey: "envB", + previousActiveEnvKey: "envA", + environmentIdBySessionId: {}, + previousMarkers, + pendingEnvKeys, + isRestoringLayout: false, + getIsRestoringLayout: () => restoring, + activate: () => { + restoring = true; + return "no-panel"; + }, + }); + + expect([...pendingEnvKeys]).toEqual(["envB"]); + }); +}); + +describe("migrateEnvironmentKeys", () => { + it("migrates pending and previous fallback session keys to environment keys", () => { + const previousMarkers = { + "session-B": { count: 1, fingerprint: "b1" }, + }; + const pendingEnvKeys = new Set(["session-B"]); + + migrateEnvironmentKeys({ + environmentIdBySessionId: { "session-B": "envB" }, + previousMarkers, + pendingEnvKeys, + }); + + expect([...pendingEnvKeys]).toEqual(["envB"]); + expect(previousMarkers).toEqual({ + envB: { count: 1, fingerprint: "b1" }, + }); + }); +}); + +describe("shouldClearPendingChangesFocus", () => { + it("keeps retryable activation results pending", () => { + expect(shouldClearPendingChangesFocus("activated")).toBe(true); + expect(shouldClearPendingChangesFocus("no-panel")).toBe(true); + expect(shouldClearPendingChangesFocus("blocked-agent-group")).toBe(false); + expect(shouldClearPendingChangesFocus("no-api")).toBe(false); + }); +}); diff --git a/apps/web/components/task/changes-panel-focus.ts b/apps/web/components/task/changes-panel-focus.ts new file mode 100644 index 0000000000..6e1672350c --- /dev/null +++ b/apps/web/components/task/changes-panel-focus.ts @@ -0,0 +1,324 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import type { DockviewApi } from "dockview-react"; +import { useShallow } from "zustand/react/shallow"; +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 { djb2Hash } from "@/lib/utils/hash"; + +type DockviewPanel = NonNullable>; +type ChangesMarkerState = Pick; + +export type ChangesMarker = { + count: number; + fingerprint: string; +}; + +type GitStatusFingerprintCacheEntry = { + repoName: string; + fileCount: number; + fingerprint: string; +}; + +export type ActivateChangesPanelResult = + | "activated" + | "blocked-agent-group" + | "no-api" + | "no-panel"; + +function groupContainsAgentSessionPanel(panel: DockviewPanel): boolean { + return panel.group.panels.some((p) => p.id === "chat" || p.id.startsWith("session:")); +} + +/** Activate the Changes panel unless it shares a group with agent sessions. */ +export function activateChangesPanel( + api: DockviewApi | null | undefined, +): ActivateChangesPanelResult { + if (!api) return "no-api"; + + const panel = api.getPanel("changes"); + if (!panel) return "no-panel"; + if (groupContainsAgentSessionPanel(panel)) return "blocked-agent-group"; + + panel.api.setActive(); + return "activated"; +} + +export function autoActivateChangesPanel(): ActivateChangesPanelResult { + return activateChangesPanel(useDockviewStore.getState().api); +} + +const fileFingerprintCache = new WeakMap(); +const gitStatusFingerprintCache = new WeakMap(); + +function fileFingerprint(file: FileInfo): string { + const cached = fileFingerprintCache.get(file); + if (cached !== undefined) return cached; + + const fingerprint = [ + file.path, + file.status, + file.staged ? "1" : "0", + file.additions ?? 0, + file.deletions ?? 0, + file.old_path ?? "", + djb2Hash(file.diff ?? ""), + file.diff_skip_reason ?? "", + file.repository_name ?? "", + ].join(":"); + fileFingerprintCache.set(file, fingerprint); + return fingerprint; +} + +function gitStatusFingerprint( + repoName: string, + status: GitStatusEntry, +): GitStatusFingerprintCacheEntry { + const cached = gitStatusFingerprintCache.get(status); + if (cached?.repoName === repoName) return cached; + + const fileKeys = Object.keys(status.files ?? {}).sort(); + const files = fileKeys.map((path) => fileFingerprint(status.files[path])).join(","); + const entry = { + repoName, + fileCount: fileKeys.length, + fingerprint: [ + repoName, + status.branch ?? "", + status.remote_branch ?? "", + status.ahead, + status.behind, + status.repository_name ?? "", + files, + ].join("|"), + }; + gitStatusFingerprintCache.set(status, entry); + return entry; +} + +export function selectChangesMarkerByEnvironment( + state: ChangesMarkerState, +): Record { + const envKeys = new Set([ + ...Object.keys(state.gitStatus.byEnvironmentRepo), + ...Object.keys(state.sessionCommits.byEnvironmentId), + ]); + const markers: Record = {}; + + for (const envKey of envKeys) { + const commits = state.sessionCommits.byEnvironmentId[envKey] ?? []; + let count = commits.length; + const repoStatuses = state.gitStatus.byEnvironmentRepo[envKey] ?? {}; + const repoFingerprint = Object.entries(repoStatuses) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([repoName, status]) => { + const entry = gitStatusFingerprint(repoName, status); + count += entry.fileCount; + return entry.fingerprint; + }) + .join(";"); + const commitFingerprint = commits.map((commit) => commit.commit_sha).join(","); + markers[envKey] = { + count, + fingerprint: `${repoFingerprint}#${commitFingerprint}`, + }; + } + + return markers; +} + +function markerToSignal(marker: ChangesMarker): string { + // Zustand shallow comparison is stable for primitive values; count always + // lives left of the first NUL and the full fingerprint lives to the right. + return `${marker.count}\u0000${marker.fingerprint}`; +} + +function signalToMarker(signal: string): ChangesMarker { + const separatorIndex = signal.indexOf("\u0000"); + return { + count: Number(signal.slice(0, separatorIndex)), + fingerprint: signal.slice(separatorIndex + 1), + }; +} + +function signalsToMarkers(signalsByEnv: Record): Record { + return Object.fromEntries( + Object.entries(signalsByEnv).map(([envKey, signal]) => [envKey, signalToMarker(signal)]), + ); +} + +export function selectChangesSignalByEnvironment( + state: ChangesMarkerState, +): Record { + const markers = selectChangesMarkerByEnvironment(state); + return Object.fromEntries( + Object.entries(markers).map(([envKey, marker]) => [envKey, markerToSignal(marker)]), + ); +} + +function shouldQueueInactiveFocus(args: { + envKey: string; + activeEnvKey: string | null; + previousActiveEnvKey: string | null; + previous: ChangesMarker; + next: ChangesMarker; +}): boolean { + const { envKey, activeEnvKey, previousActiveEnvKey, previous, next } = args; + const changed = + next.count > previous.count || (next.count > 0 && next.fingerprint !== previous.fingerprint); + if (!changed) return false; + return ( + envKey !== activeEnvKey || (previousActiveEnvKey !== null && envKey !== previousActiveEnvKey) + ); +} + +export function migrateEnvironmentKeys(args: { + environmentIdBySessionId: Record; + previousMarkers: Record; + pendingEnvKeys: Set; +}): void { + const { environmentIdBySessionId, previousMarkers, pendingEnvKeys } = args; + for (const [sessionId, envKey] of Object.entries(environmentIdBySessionId)) { + if (sessionId === envKey) continue; + if (pendingEnvKeys.delete(sessionId)) pendingEnvKeys.add(envKey); + if (previousMarkers[sessionId] && !previousMarkers[envKey]) { + previousMarkers[envKey] = previousMarkers[sessionId]; + } + delete previousMarkers[sessionId]; + } +} + +export function markInactiveChangesIncreases(args: { + markersByEnv: Record; + activeEnvKey: string | null; + previousActiveEnvKey: string | null; + previousMarkers: Record; + pendingEnvKeys: Set; + queueFirstObservedInactiveChanges?: boolean; +}): void { + const { + markersByEnv, + activeEnvKey, + previousActiveEnvKey, + previousMarkers, + pendingEnvKeys, + queueFirstObservedInactiveChanges = false, + } = args; + for (const envKey of Object.keys(previousMarkers)) { + if (markersByEnv[envKey] !== undefined) continue; + delete previousMarkers[envKey]; + pendingEnvKeys.delete(envKey); + } + for (const [envKey, marker] of Object.entries(markersByEnv)) { + const previous = previousMarkers[envKey]; + previousMarkers[envKey] = marker; + if (marker.count === 0) { + pendingEnvKeys.delete(envKey); + continue; + } + if (previous === undefined) { + if (queueFirstObservedInactiveChanges && envKey !== activeEnvKey) { + pendingEnvKeys.add(envKey); + } + continue; + } + if ( + shouldQueueInactiveFocus({ + envKey, + activeEnvKey, + previousActiveEnvKey, + previous, + next: marker, + }) + ) { + pendingEnvKeys.add(envKey); + } + } +} + +export function shouldClearPendingChangesFocus(result: ActivateChangesPanelResult): boolean { + return result === "activated" || result === "no-panel"; +} + +export function applyChangesPanelAutoFocusState(args: { + signalsByEnv: Record; + activeEnvKey: string | null; + previousActiveEnvKey: string | null; + environmentIdBySessionId: Record; + previousMarkers: Record; + pendingEnvKeys: Set; + isRestoringLayout: boolean; + getIsRestoringLayout?: () => boolean; + activate: () => ActivateChangesPanelResult; +}): string | null { + const { + signalsByEnv, + activeEnvKey, + previousActiveEnvKey, + environmentIdBySessionId, + previousMarkers, + pendingEnvKeys, + isRestoringLayout, + getIsRestoringLayout, + activate, + } = args; + + migrateEnvironmentKeys({ + environmentIdBySessionId, + previousMarkers, + pendingEnvKeys, + }); + + const markersByEnv = signalsToMarkers(signalsByEnv); + const hasPreviousMarkers = Object.keys(previousMarkers).length > 0; + + markInactiveChangesIncreases({ + markersByEnv, + activeEnvKey, + previousActiveEnvKey, + previousMarkers, + pendingEnvKeys, + queueFirstObservedInactiveChanges: hasPreviousMarkers, + }); + + const isCurrentlyRestoringLayout = () => isRestoringLayout || getIsRestoringLayout?.() === true; + + if (activeEnvKey && !isCurrentlyRestoringLayout() && pendingEnvKeys.has(activeEnvKey)) { + const result = activate(); + if ( + shouldClearPendingChangesFocus(result) && + !(result === "no-panel" && isCurrentlyRestoringLayout()) + ) { + pendingEnvKeys.delete(activeEnvKey); + } + } + + return activeEnvKey; +} + +export function useChangesPanelAutoFocus(activeEnvKey: string | null) { + const api = useDockviewStore((s) => s.api); + const isRestoringLayout = useDockviewStore((s) => s.isRestoringLayout); + const signalsByEnv = useAppStore(useShallow(selectChangesSignalByEnvironment)); + const environmentIdBySessionId = useAppStore((state) => state.environmentIdBySessionId); + const previousMarkersRef = useRef>({}); + const pendingEnvKeysRef = useRef>(new Set()); + const previousActiveEnvKeyRef = useRef(null); + + useEffect(() => { + previousActiveEnvKeyRef.current = applyChangesPanelAutoFocusState({ + signalsByEnv, + activeEnvKey, + previousActiveEnvKey: previousActiveEnvKeyRef.current, + environmentIdBySessionId, + previousMarkers: previousMarkersRef.current, + pendingEnvKeys: pendingEnvKeysRef.current, + isRestoringLayout, + getIsRestoringLayout: () => useDockviewStore.getState().isRestoringLayout, + activate: () => activateChangesPanel(api), + }); + }, [signalsByEnv, activeEnvKey, api, isRestoringLayout, environmentIdBySessionId]); +} diff --git a/apps/web/components/task/changes-tab.tsx b/apps/web/components/task/changes-tab.tsx index 9671ae1d83..dd962d75d7 100644 --- a/apps/web/components/task/changes-tab.tsx +++ b/apps/web/components/task/changes-tab.tsx @@ -1,11 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { - DockviewDefaultTab, - type DockviewApi, - type IDockviewPanelHeaderProps, -} from "dockview-react"; +import { DockviewDefaultTab, type IDockviewPanelHeaderProps } from "dockview-react"; import { ContextMenu, ContextMenuContent, @@ -15,25 +11,9 @@ import { import { useAppStore } from "@/components/state-provider"; import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status"; import { useSessionChangesCount } from "@/hooks/domains/session/use-session-changes-count"; -import { useDockviewStore } from "@/lib/state/dockview-store"; import { cn } from "@kandev/ui/lib/utils"; import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize"; - -type DockviewPanel = NonNullable>; - -function groupContainsAgentSessionPanel(panel: DockviewPanel): boolean { - return panel.group.panels.some((p) => p.id === "chat" || p.id.startsWith("session:")); -} - -/** Auto-activate the changes panel unless it shares a group with agent sessions. */ -function autoActivateChangesPanel(): void { - const { api } = useDockviewStore.getState(); - if (!api) return; - - const panel = api.getPanel("changes"); - if (!panel || groupContainsAgentSessionPanel(panel)) return; - panel.api.setActive(); -} +import { autoActivateChangesPanel } from "./changes-panel-focus"; /** * Custom tab component for the Changes panel. diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index bedd63324c..d9ea6b5bbb 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -42,6 +42,7 @@ import { PassthroughToolbar } from "./passthrough-toolbar"; import { PanelRoot, PanelBody } from "./panel-primitives"; import { ContextMenuTab } from "./tab-context-menu"; import { ChangesTab } from "./changes-tab"; +import { useChangesPanelAutoFocus } from "./changes-panel-focus"; import { PlanTab } from "./plan-tab"; import { PreviewFileTab, PreviewDiffTab, PreviewCommitTab, PinnedDefaultTab } from "./preview-tab"; import { SessionTab } from "./session-tab"; @@ -553,6 +554,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ const effectiveEnvId = useAppStore((state) => effectiveSessionId ? (state.environmentIdBySessionId[effectiveSessionId] ?? null) : null, ); + const changesFocusKey = effectiveEnvId ?? effectiveSessionId; const envIdRef = useRef(effectiveEnvId); const hasDevScript = Boolean(repository?.dev_script?.trim()); @@ -603,6 +605,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ // Auto-create a session tab when a session becomes active useAutoSessionTab(effectiveSessionId); + useChangesPanelAutoFocus(changesFocusKey); // Auto-show PR detail panel when the task has an associated PR useAutoPRPanel(); 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 e04563a66e..87a7731d51 100644 --- a/apps/web/e2e/tests/layout/changes-panel-focus.spec.ts +++ b/apps/web/e2e/tests/layout/changes-panel-focus.spec.ts @@ -31,11 +31,99 @@ class GitHelper { } function changesTab(testPage: Page) { - return testPage.locator(".dv-tab", { - has: testPage.locator(".dv-default-tab").filter({ hasText: /^Changes/ }), + return testPage.locator(".dv-tab:visible", { + has: testPage.locator(".dv-default-tab:visible").filter({ hasText: /^Changes/ }), }); } +async function changesCountForSession(testPage: Page, sessionId: string): Promise { + return testPage.evaluate((sid) => { + type StoreWindow = Window & { + __KANDEV_E2E_STORE__?: { + getState: () => { + environmentIdBySessionId: Record; + gitStatus: { + byEnvironmentRepo: Record }>>; + }; + sessionCommits: { byEnvironmentId: Record }; + }; + }; + }; + const store = (window as StoreWindow).__KANDEV_E2E_STORE__; + if (!store) throw new Error("E2E store bridge missing"); + const state = store.getState(); + const envKey = state.environmentIdBySessionId[sid] ?? sid; + const hasRepoStatuses = envKey in state.gitStatus.byEnvironmentRepo; + const hasCommits = envKey in state.sessionCommits.byEnvironmentId; + if (!hasRepoStatuses && !hasCommits) return null; + const repoStatuses = state.gitStatus.byEnvironmentRepo[envKey] ?? {}; + // Keep this count in sync with selectChangesMarkerByEnvironment. + let count = state.sessionCommits.byEnvironmentId[envKey]?.length ?? 0; + for (const status of Object.values(repoStatuses)) { + count += Object.keys(status.files ?? {}).length; + } + return count; + }, sessionId); +} + +async function setGitStatusForSession(testPage: Page, sessionId: string, changedFiles: string[]) { + await testPage.evaluate( + ({ sid, files }) => { + type FileInfo = { + path: string; + status: "modified" | "added" | "deleted" | "untracked" | "renamed"; + staged: boolean; + additions?: number; + deletions?: number; + }; + type StoreWindow = Window & { + __KANDEV_E2E_STORE__?: { + getState: () => { + setGitStatus: ( + sessionId: string, + status: { + branch: string; + remote_branch: string | null; + modified: string[]; + added: string[]; + deleted: string[]; + untracked: string[]; + renamed: string[]; + ahead: number; + behind: number; + files: Record; + timestamp: string; + }, + ) => boolean; + }; + }; + }; + const store = (window as StoreWindow).__KANDEV_E2E_STORE__; + if (!store) throw new Error("E2E store bridge missing"); + const fileMap = Object.fromEntries( + files.map((path): [string, FileInfo] => [ + path, + { path, status: "untracked", staged: false, additions: 1, deletions: 0 }, + ]), + ); + store.getState().setGitStatus(sid, { + branch: "main", + remote_branch: null, + modified: [], + added: [], + deleted: [], + untracked: files, + renamed: [], + ahead: 0, + behind: 0, + files: fileMap, + timestamp: new Date().toISOString(), + }); + }, + { sid: sessionId, files: changedFiles }, + ); +} + async function moveChangesToTerminalGroupAndFocusTerminal(testPage: Page) { await testPage.evaluate(() => { type Group = { id: string }; @@ -241,4 +329,59 @@ test.describe("Changes panel focus behavior", () => { await expect(changesTab(testPage)).toHaveClass(/dv-active-tab/, { timeout: 5_000 }); await expect(session.changesFileRow("second-change.txt")).toBeVisible({ timeout: 10_000 }); }); + + test("new git updates focus changes after switching to an inactive task", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + + const taskA = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Inactive Changes Focus A", + seedData.agentProfileId, + { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + const taskB = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Inactive Changes Focus B", + seedData.agentProfileId, + { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + if (!taskB.session_id) throw new Error("Task B did not start a session"); + + await testPage.goto(`/t/${taskA.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); + await session.waitForDockviewReady(); + + await expect(session.taskInSidebar("Inactive Changes Focus B")).toBeVisible({ + timeout: 15_000, + }); + await setGitStatusForSession(testPage, taskB.session_id, []); + await expect + .poll(() => changesCountForSession(testPage, taskB.session_id!), { timeout: 15_000 }) + .toBe(0); + + await setGitStatusForSession(testPage, taskB.session_id, ["background-change.txt"]); + await expect + .poll(() => changesCountForSession(testPage, taskB.session_id!), { timeout: 15_000 }) + .toBe(1); + await expect(session.activeChat()).toBeVisible(); + await expect(changesTab(testPage)).not.toHaveClass(/dv-active-tab/); + + await session.taskInSidebar("Inactive Changes Focus B").click(); + + await expect(changesTab(testPage)).toHaveClass(/dv-active-tab/, { timeout: 5_000 }); + }); });