fix: focus changes panel for inactive task updates (#1542)
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user