fix: focus changes panel for inactive task updates (#1542)

This commit is contained in:
Carlos Florêncio
2026-06-29 19:24:51 +01:00
committed by GitHub
parent 5ed975ddbf
commit 7632f626c8
5 changed files with 965 additions and 24 deletions
@@ -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<string>();
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<string>();
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<string>();
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<string>();
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<string>();
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<string>();
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);
});
});
@@ -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<ReturnType<DockviewApi["getPanel"]>>;
type ChangesMarkerState = Pick<AppState, "gitStatus" | "sessionCommits">;
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<FileInfo, string>();
const gitStatusFingerprintCache = new WeakMap<GitStatusEntry, GitStatusFingerprintCacheEntry>();
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<string, ChangesMarker> {
const envKeys = new Set([
...Object.keys(state.gitStatus.byEnvironmentRepo),
...Object.keys(state.sessionCommits.byEnvironmentId),
]);
const markers: Record<string, ChangesMarker> = {};
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<string, string>): Record<string, ChangesMarker> {
return Object.fromEntries(
Object.entries(signalsByEnv).map(([envKey, signal]) => [envKey, signalToMarker(signal)]),
);
}
export function selectChangesSignalByEnvironment(
state: ChangesMarkerState,
): Record<string, string> {
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<string, string>;
previousMarkers: Record<string, ChangesMarker>;
pendingEnvKeys: Set<string>;
}): 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<string, ChangesMarker>;
activeEnvKey: string | null;
previousActiveEnvKey: string | null;
previousMarkers: Record<string, ChangesMarker>;
pendingEnvKeys: Set<string>;
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<string, string>;
activeEnvKey: string | null;
previousActiveEnvKey: string | null;
environmentIdBySessionId: Record<string, string>;
previousMarkers: Record<string, ChangesMarker>;
pendingEnvKeys: Set<string>;
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<Record<string, ChangesMarker>>({});
const pendingEnvKeysRef = useRef<Set<string>>(new Set());
const previousActiveEnvKeyRef = useRef<string | null>(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]);
}
+2 -22
View File
@@ -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<ReturnType<DockviewApi["getPanel"]>>;
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.
@@ -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<string | null>(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();
@@ -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<number | null> {
return testPage.evaluate((sid) => {
type StoreWindow = Window & {
__KANDEV_E2E_STORE__?: {
getState: () => {
environmentIdBySessionId: Record<string, string>;
gitStatus: {
byEnvironmentRepo: Record<string, Record<string, { files?: Record<string, unknown> }>>;
};
sessionCommits: { byEnvironmentId: Record<string, unknown[]> };
};
};
};
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<string, FileInfo>;
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 });
});
});