refactor: re-key dockview panel state by environmentId instead of sessionId (#491)

* refactor: re-key dockview panel state by environmentId instead of sessionId

- Remove terminal from SESSION_SCOPED_COMPONENTS (uses useEnvironmentSessionId internally)
- Convert ChangesContent and file-editor-panel to useEnvironmentSessionId()
- Re-key sessionCommits store from bySessionId to byEnvironmentId
- Re-key useCumulativeDiff cache by environmentId
- Update WS handler to resolve envKey before invalidating cumulative diff cache
- Update comments across portal manager, session switch, and layout files

* fix: revert useEnvironmentSessionId in file-editor-panel

file-editor is session-scoped and ephemeral — portal is destroyed on
session switch, so useEnvironmentSessionId() has no practical effect.

* fix: address CodeRabbit review comments

- Pass appStore to setupPortalCleanup so terminal tab close still calls
  stopUserShell via activeSessionId fallback (entry.sessionId is now
  undefined for global panels)
- Add request version guard to useCumulativeDiff to discard stale
  responses after environment switches

* fix: scope activeSessionId fallback to terminal only in portal cleanup

vscode is session-scoped and always has entry.sessionId — the
activeSessionId fallback should only apply to terminal (now global).

* fix: migrate stranded env-keyed data on registerSessionEnvironment

When registerSessionEnvironment(sessionId, environmentId) is called,
any data previously stored under the fallback sessionId key is now
migrated to the proper environmentId key. This prevents data from
"vanishing" when selectors start reading by environmentId after the
mapping is registered.

Applies to: sessionCommits, gitStatus, shell outputs/statuses,
userShells.

* fix: extract migrateEnvKeyedData helper to fix lint max-lines

* fix: address CodeRabbit review comments

- Always delete orphaned sessionId key in migrateEnvKeyedData even when
  environmentId key already exists (prevents stale accumulation)
- Reset loading state when switching to an envKey with warm cache to
  avoid brief spinner flash

* fix: always reset loading on envKey switch in useCumulativeDiff

Prevents stuck loading state on rapid A→B→A env switches where the
stale in-flight response is discarded by version guard but loading
is never reset.

* fix: move sync effect before fetch effect in useCumulativeDiff

React 18 batches effects in declaration order. With the sync effect
after the fetch effect, setLoading(false) from the sync overrode
setLoading(true) from the fetch on cold-cache env switches. Moving
the sync effect first ensures the fetch effect's setLoading(true)
wins the batch.

* test: add migrateEnvKeyedData coverage for registerSessionEnvironment

Tests 4 cases: normal migration, don't-clobber existing data,
no-op when sessionId equals environmentId, and partial sub-key
migration.

* fix: close stale-diff gaps in useCumulativeDiff sync effect

- Bump requestVersionRef on envKey change so in-flight fetches for
  the previous envKey are discarded
- Clear loadingState[envKey] so the fetch effect isn't blocked on
  re-entry (A→B→A scenario)
This commit is contained in:
José Almeida
2026-04-14 11:44:38 +01:00
committed by GitHub
parent be83ab80b1
commit c08d68c0fe
10 changed files with 237 additions and 63 deletions
@@ -22,6 +22,7 @@ import { useLspFileOpener } from "@/hooks/use-lsp-file-opener";
import { useEditorKeybinds } from "@/hooks/use-editor-keybinds";
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
import { useSessionCommits } from "@/hooks/domains/session/use-session-commits";
import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id";
// Panel components (rendered via portals, not directly by dockview)
import { TaskSessionSidebar } from "./task-session-sidebar";
@@ -77,7 +78,6 @@ import { PanelPortalHost, usePortalSlot } from "@/lib/layout/panel-portal-host";
* state that can't be swapped by simply reading a new `activeSessionId` from
* the store:
*
* - terminal — holds a live xterm WebSocket to a shell in the session's container
* - file-editor — editing a file in the session's worktree
* - browser — iframe preview of the session's dev server URL
* - vscode — VS Code Server iframe running in the session's container
@@ -90,12 +90,12 @@ import { PanelPortalHost, usePortalSlot } from "@/lib/layout/panel-portal-host";
*
* - sidebar — workspace/task navigation, not session-specific
* - chat — subscribes to `activeSessionId`, re-renders for new session
* - changes — reads session git status via `useSessionGitStatus(activeSessionId)`
* - files — reads the active session's file tree reactively
* - terminal — uses `useEnvironmentSessionId()`, reconnects only on env change
* - changes — uses `useEnvironmentSessionId()` for stable git state
* - files — uses `useEnvironmentSessionId()` for stable file tree
* - plan — reads `activeTaskId` from the store
*/
const SESSION_SCOPED_COMPONENTS = new Set([
"terminal",
"file-editor",
"browser",
"vscode",
@@ -263,10 +263,11 @@ function ChangesContent({ panelId }: { panelId: string }) {
const addCommitDetailPanel = useDockviewStore((s) => s.addCommitDetailPanel);
const { openFile } = useFileEditors();
// Dynamic title with file count
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
// Dynamic title with file count — use environment-stable sessionId so the
// tab title doesn't re-fetch on same-environment session tab switches.
const activeSessionId = useEnvironmentSessionId();
const gitStatus = useSessionGitStatus(activeSessionId);
const { commits } = useSessionCommits(activeSessionId ?? null);
const { commits } = useSessionCommits(activeSessionId);
const fileCount = gitStatus?.files ? Object.keys(gitStatus.files).length : 0;
const totalCount = fileCount + commits.length;
@@ -468,7 +469,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
setupSessionTabSync(api, appStore);
setupChatPanelSafetyNet(api, appStore);
setupLayoutPersistence(api, saveTimerRef, sessionIdRef);
setupPortalCleanup(api);
setupPortalCleanup(api, appStore);
},
[setApi, buildDefaultLayout, initialLayout, appStore],
);
@@ -1,4 +1,6 @@
import type { DockviewReadyEvent } from "dockview-react";
import type { StoreApi } from "zustand";
import type { AppState } from "@/lib/state/store";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { getRootSplitview } from "@/lib/state/dockview-layout-builders";
import { setSessionLayout } from "@/lib/local-storage";
@@ -73,7 +75,10 @@ export function setupLayoutPersistence(
});
}
export function setupPortalCleanup(api: DockviewReadyEvent["api"]): void {
export function setupPortalCleanup(
api: DockviewReadyEvent["api"],
appStore: StoreApi<AppState>,
): void {
api.onDidRemovePanel((panel) => {
if (useDockviewStore.getState().isRestoringLayout) return;
const isMax = useDockviewStore.getState().preMaximizeLayout !== null;
@@ -97,10 +102,13 @@ export function setupPortalCleanup(api: DockviewReadyEvent["api"]): void {
});
}
const entry = panelPortalManager.get(panel.id);
// vscode is session-scoped so entry.sessionId is always set by PortalSlot.
if (entry?.component === "vscode" && entry.sessionId) stopVscode(entry.sessionId);
if (entry?.component === "terminal" && entry.sessionId) {
// terminal is global (no entry.sessionId) — fall back to the active session.
if (entry?.component === "terminal") {
const terminalSessionId = entry.sessionId ?? appStore.getState().tasks.activeSessionId;
const terminalId = entry.params.terminalId as string | undefined;
if (terminalId) stopUserShell(entry.sessionId, terminalId);
if (terminalId && terminalSessionId) stopUserShell(terminalSessionId, terminalId);
}
panelPortalManager.release(panel.id);
});
@@ -1,66 +1,105 @@
import { useEffect, useCallback, useState } from "react";
import { useEffect, useCallback, useRef, useState } from "react";
import { useAppStore } from "@/components/state-provider";
import { getWebSocketClient } from "@/lib/ws/connection";
import type { CumulativeDiff } from "@/lib/state/slices/session-runtime/types";
const cumulativeDiffCache: Record<string, CumulativeDiff | null> = {};
const loadingState: Record<string, boolean> = {};
const listeners = new Set<(sessionId: string) => void>();
const listeners = new Set<(envKey: string) => void>();
export function invalidateCumulativeDiffCache(sessionId: string) {
delete cumulativeDiffCache[sessionId];
listeners.forEach((fn) => fn(sessionId));
/**
* Invalidate the cumulative diff cache for the given environment key.
* Callers should resolve sessionId → envKey before calling this.
*/
export function invalidateCumulativeDiffCache(envKey: string) {
delete cumulativeDiffCache[envKey];
listeners.forEach((fn) => fn(envKey));
}
export function useCumulativeDiff(sessionId: string | null) {
// Resolve to environment key so sessions sharing the same environment share the cache.
const envKey = useAppStore((state) => {
if (!sessionId) return null;
return state.environmentIdBySessionId[sessionId] ?? sessionId;
});
// Guard against stale responses after an environment switch.
const requestVersionRef = useRef(0);
const [diff, setDiff] = useState<CumulativeDiff | null>(
sessionId ? (cumulativeDiffCache[sessionId] ?? null) : null,
envKey ? (cumulativeDiffCache[envKey] ?? null) : null,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [invalidationCount, setInvalidationCount] = useState(0);
const fetchCumulativeDiff = useCallback(async () => {
if (!sessionId) return;
if (loadingState[sessionId]) return;
if (!sessionId || !envKey) return;
if (loadingState[envKey]) return;
const client = getWebSocketClient();
if (!client) return;
const version = ++requestVersionRef.current;
setLoading(true);
loadingState[sessionId] = true;
loadingState[envKey] = true;
setError(null);
try {
// Backend routes by session_id, but we cache by envKey
const response = await client.request<{ cumulative_diff?: CumulativeDiff }>(
"session.cumulative_diff",
{ session_id: sessionId },
);
// Discard if the environment changed while the request was in flight
if (version !== requestVersionRef.current) return;
if (response?.cumulative_diff) {
cumulativeDiffCache[sessionId] = response.cumulative_diff;
cumulativeDiffCache[envKey] = response.cumulative_diff;
setDiff(response.cumulative_diff);
}
} catch (err) {
if (version !== requestVersionRef.current) return;
console.error("Failed to fetch cumulative diff:", err);
setError(err instanceof Error ? err.message : "Failed to fetch cumulative diff");
} finally {
setLoading(false);
loadingState[sessionId] = false;
if (version === requestVersionRef.current) {
setLoading(false);
}
loadingState[envKey] = false;
}
}, [sessionId]);
}, [sessionId, envKey]);
// Sync cached state when envKey changes. Must run BEFORE the fetch effect
// so that fetchCumulativeDiff's setLoading(true) wins the React 18 batch.
useEffect(() => {
// Bump version so any in-flight fetch for the previous envKey is discarded.
// Clear the per-key loading flag so the fetch effect isn't blocked on re-entry
// (e.g. A→B→A where A's original fetch is still in-flight).
requestVersionRef.current++;
if (envKey) {
loadingState[envKey] = false;
setDiff(cumulativeDiffCache[envKey] ?? null);
} else {
setDiff(null);
}
setLoading(false);
}, [envKey]);
// Fetch on mount and when cache is invalidated
useEffect(() => {
if (!sessionId) return;
if (!envKey) return;
fetchCumulativeDiff();
}, [sessionId, invalidationCount, fetchCumulativeDiff]);
}, [envKey, invalidationCount, fetchCumulativeDiff]);
// Subscribe to cache invalidation from WS handlers
useEffect(() => {
if (!sessionId) return;
const handler = (invalidatedSessionId: string) => {
if (invalidatedSessionId === sessionId) {
if (!envKey) return;
const handler = (invalidatedEnvKey: string) => {
if (invalidatedEnvKey === envKey) {
setInvalidationCount((c) => c + 1);
}
};
@@ -68,17 +107,7 @@ export function useCumulativeDiff(sessionId: string | null) {
return () => {
listeners.delete(handler);
};
}, [sessionId]);
// Sync cached state when sessionId changes
useEffect(() => {
if (sessionId) {
setDiff(cumulativeDiffCache[sessionId] ?? null);
} else {
setDiff(null);
setLoading(false);
}
}, [sessionId]);
}, [envKey]);
return {
diff,
@@ -5,15 +5,20 @@ import type { SessionCommit } from "@/lib/state/slices/session-runtime/types";
/**
* Hook to fetch and manage commits for a session.
* Session commits track all commits made during a task session.
* Commits are keyed by environmentId so sessions sharing the same environment
* share the same commit list and don't duplicate fetches.
*/
export function useSessionCommits(sessionId: string | null) {
const commits = useAppStore((state) =>
sessionId ? state.sessionCommits.bySessionId[sessionId] : undefined,
);
const loading = useAppStore((state) =>
sessionId ? state.sessionCommits.loading[sessionId] : false,
);
const commits = useAppStore((state) => {
if (!sessionId) return undefined;
const envKey = state.environmentIdBySessionId[sessionId] ?? sessionId;
return state.sessionCommits.byEnvironmentId[envKey];
});
const loading = useAppStore((state) => {
if (!sessionId) return false;
const envKey = state.environmentIdBySessionId[sessionId] ?? sessionId;
return state.sessionCommits.loading[envKey] ?? false;
});
const setSessionCommits = useAppStore((state) => state.setSessionCommits);
const setSessionCommitsLoading = useAppStore((state) => state.setSessionCommitsLoading);
const connectionStatus = useAppStore((state) => state.connection.status);
+4 -3
View File
@@ -20,15 +20,16 @@
* - **Global** (`sessionId` is `undefined`) — the portal persists across session
* switches. Used for panels that read `activeSessionId` reactively from the
* store and automatically show the correct data for whichever session is active.
* Examples: sidebar, chat, changes, files, plan.
* Examples: sidebar, chat, terminal, changes, files, plan.
* Note: terminal, changes, and files use `useEnvironmentSessionId()` to stay
* stable across same-environment session switches.
*
* - **Session-scoped** (`sessionId` is set) — the portal is bound to the session
* that created it and is destroyed via `releaseBySession()` when the user
* switches away. Used for panels whose content is intrinsically tied to a
* specific session's runtime state (container processes, worktree files, etc.)
* and cannot simply re-read a store selector to switch context.
* Examples: terminal, file-editor, browser, vscode, commit-detail, diff-viewer,
* pr-detail.
* Examples: file-editor, browser, vscode, commit-detail, diff-viewer, pr-detail.
*
* See `SESSION_SCOPED_COMPONENTS` in `dockview-desktop-layout.tsx` for the
* authoritative list and per-component rationale.
@@ -80,7 +80,7 @@ function tryFastSessionSwitch(params: SessionSwitchParams): LayoutGroupIds | nul
// Fast path: keep the grid structure, clean up ephemeral panels and any
// session chat tabs that belong to a different session (cross-task switch).
// Session-scoped portals (terminal, browser, etc.) will be re-acquired
// Session-scoped portals (browser, vscode, etc.) will be re-acquired
// via usePortalSlot's sessionId dependency change.
removeEphemeralPanels(api, newSessionId);
api.layout(params.safeWidth, params.safeHeight);
@@ -0,0 +1,91 @@
import { describe, it, expect } from "vitest";
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import { createSessionRuntimeSlice } from "./session-runtime-slice";
import type { SessionRuntimeSlice } from "./types";
function makeStore() {
return create<SessionRuntimeSlice>()(immer(createSessionRuntimeSlice));
}
describe("registerSessionEnvironment — migrateEnvKeyedData", () => {
it("migrates data from sessionId key to environmentId key", () => {
const store = makeStore();
// Simulate data arriving under the fallback sessionId key
store.getState().setSessionCommits("sess-1", [{ commit_sha: "abc" } as never]);
store.getState().setGitStatus("sess-1", { branch: "main" } as never);
store.getState().appendShellOutput("sess-1", "hello");
// Register the environment mapping
store.getState().registerSessionEnvironment("sess-1", "env-1");
const state = store.getState();
// Data should now live under the environmentId key
expect(state.sessionCommits.byEnvironmentId["env-1"]).toEqual([{ commit_sha: "abc" }]);
expect(state.gitStatus.byEnvironmentId["env-1"]).toEqual({ branch: "main" });
expect(state.shell.outputs["env-1"]).toBe("hello");
// sessionId key should be cleaned up
expect(state.sessionCommits.byEnvironmentId["sess-1"]).toBeUndefined();
expect(state.gitStatus.byEnvironmentId["sess-1"]).toBeUndefined();
expect(state.shell.outputs["sess-1"]).toBeUndefined();
});
it("does not clobber pre-existing environmentId data", () => {
const store = makeStore();
// Data already stored under the environmentId key
store.getState().registerSessionEnvironment("other-sess", "env-1");
store.getState().setSessionCommits("other-sess", [{ commit_sha: "existing" } as never]);
// Stale data under sessionId from before mapping was known
store.getState().setGitStatus("sess-2", { branch: "stale" } as never);
// A different session wrote commits under its own fallback key
store.setState((draft) => {
draft.sessionCommits.byEnvironmentId["sess-2"] = [{ commit_sha: "orphan" } as never];
});
// Register: sess-2 maps to env-1 (which already has commit data)
store.getState().registerSessionEnvironment("sess-2", "env-1");
const state = store.getState();
// Commits: pre-existing env-1 data preserved, orphaned sess-2 key deleted
expect(state.sessionCommits.byEnvironmentId["env-1"]).toEqual([{ commit_sha: "existing" }]);
expect(state.sessionCommits.byEnvironmentId["sess-2"]).toBeUndefined();
// Git status: no env-1 data existed, so sess-2 data migrated
expect(state.gitStatus.byEnvironmentId["env-1"]).toEqual({ branch: "stale" });
expect(state.gitStatus.byEnvironmentId["sess-2"]).toBeUndefined();
});
it("no-ops when sessionId equals environmentId", () => {
const store = makeStore();
store.getState().setSessionCommits("local-1", [{ commit_sha: "abc" } as never]);
store.getState().registerSessionEnvironment("local-1", "local-1");
const state = store.getState();
// Data stays under the same key
expect(state.sessionCommits.byEnvironmentId["local-1"]).toEqual([{ commit_sha: "abc" }]);
});
it("migrates only sub-keys that have data", () => {
const store = makeStore();
// Only put data in shell outputs and userShells, leave others empty
store.getState().appendShellOutput("sess-3", "output");
store.getState().setUserShells("sess-3", [{ terminalId: "t1" } as never]);
store.getState().registerSessionEnvironment("sess-3", "env-3");
const state = store.getState();
expect(state.shell.outputs["env-3"]).toBe("output");
expect(state.shell.outputs["sess-3"]).toBeUndefined();
expect(state.userShells.byEnvironmentId["env-3"]).toEqual([{ terminalId: "t1" }]);
expect(state.userShells.byEnvironmentId["sess-3"]).toBeUndefined();
// Other stores should have no data for either key
expect(state.sessionCommits.byEnvironmentId["env-3"]).toBeUndefined();
expect(state.sessionCommits.byEnvironmentId["sess-3"]).toBeUndefined();
});
});
@@ -78,7 +78,7 @@ export const defaultSessionRuntimeState: SessionRuntimeSliceState = {
},
gitStatus: { byEnvironmentId: {} },
environmentIdBySessionId: {},
sessionCommits: { bySessionId: {}, loading: {} },
sessionCommits: { byEnvironmentId: {}, loading: {} },
contextWindow: { bySessionId: {} },
agents: { agents: [] },
availableCommands: { bySessionId: {} },
@@ -185,6 +185,35 @@ function buildUserShellActions(set: ImmerSet) {
};
}
/**
* Migrate any env-keyed data stored under the fallback `sessionId` key to the
* proper `environmentId` key so selectors don't see stale data after the
* session→environment mapping is registered.
*/
function migrateEnvKeyedData(
draft: SessionRuntimeSliceState,
sessionId: string,
environmentId: string,
) {
if (sessionId === environmentId) return;
const migrate = <T>(store: Record<string, T>) => {
if (sessionId in store) {
if (!(environmentId in store)) {
store[environmentId] = store[sessionId];
}
delete store[sessionId];
}
};
migrate(draft.sessionCommits.byEnvironmentId);
migrate(draft.sessionCommits.loading);
migrate(draft.gitStatus.byEnvironmentId);
migrate(draft.shell.outputs);
migrate(draft.shell.statuses);
migrate(draft.userShells.byEnvironmentId);
migrate(draft.userShells.loading);
migrate(draft.userShells.loaded);
}
export const createSessionRuntimeSlice: StateCreator<
SessionRuntimeSlice,
[["zustand/immer", never]],
@@ -208,6 +237,7 @@ export const createSessionRuntimeSlice: StateCreator<
registerSessionEnvironment: (sessionId, environmentId) =>
set((draft) => {
draft.environmentIdBySessionId[sessionId] = environmentId;
migrateEnvKeyedData(draft, sessionId, environmentId);
}),
setContextWindow: (sessionId, contextWindow) =>
set((draft) => {
@@ -215,28 +245,32 @@ export const createSessionRuntimeSlice: StateCreator<
}),
setSessionCommits: (sessionId, commits) =>
set((draft) => {
draft.sessionCommits.bySessionId[sessionId] = commits;
const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId;
draft.sessionCommits.byEnvironmentId[envKey] = commits;
}),
setSessionCommitsLoading: (sessionId, loading) =>
set((draft) => {
draft.sessionCommits.loading[sessionId] = loading;
const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId;
draft.sessionCommits.loading[envKey] = loading;
}),
addSessionCommit: (sessionId, commit) =>
set((draft) => {
const existing = draft.sessionCommits.bySessionId[sessionId] || [];
const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId;
const existing = draft.sessionCommits.byEnvironmentId[envKey] || [];
// For amend: only replace HEAD (first entry) if it has the same parent
if (existing.length > 0 && existing[0].parent_sha === commit.parent_sha) {
// Replace HEAD commit (this is an amend)
existing[0] = commit;
draft.sessionCommits.bySessionId[sessionId] = existing;
draft.sessionCommits.byEnvironmentId[envKey] = existing;
} else {
// Normal commit: prepend to list
draft.sessionCommits.bySessionId[sessionId] = [commit, ...existing];
draft.sessionCommits.byEnvironmentId[envKey] = [commit, ...existing];
}
}),
clearSessionCommits: (sessionId) =>
set((draft) => {
delete draft.sessionCommits.bySessionId[sessionId];
const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId;
delete draft.sessionCommits.byEnvironmentId[envKey];
}),
setAvailableCommands: (sessionId, commands) =>
set((draft) => {
@@ -99,7 +99,7 @@ export type CumulativeDiff = {
};
export type SessionCommitsState = {
bySessionId: Record<string, SessionCommit[]>;
byEnvironmentId: Record<string, SessionCommit[]>;
loading: Record<string, boolean>;
};
+9 -4
View File
@@ -18,6 +18,11 @@ type GitEventHandlers = {
branch_switched: (store: StoreApi<AppState>, event: GitBranchSwitchedEvent) => void;
};
/** Resolve sessionId → environmentId for cache keying. */
function resolveEnvKey(store: StoreApi<AppState>, sessionId: string): string {
return store.getState().environmentIdBySessionId[sessionId] ?? sessionId;
}
const gitEventHandlers: GitEventHandlers = {
status_update: (store, event) => {
store.getState().setGitStatus(event.session_id, {
@@ -36,7 +41,7 @@ const gitEventHandlers: GitEventHandlers = {
branch_deletions: event.status.branch_deletions,
});
// Invalidate cumulative diff cache when files change
invalidateCumulativeDiffCache(event.session_id);
invalidateCumulativeDiffCache(resolveEnvKey(store, event.session_id));
},
commit_created: (store, event) => {
@@ -55,21 +60,21 @@ const gitEventHandlers: GitEventHandlers = {
created_at: event.commit.created_at ?? event.timestamp,
});
// Invalidate cumulative diff cache when new commit is created
invalidateCumulativeDiffCache(event.session_id);
invalidateCumulativeDiffCache(resolveEnvKey(store, event.session_id));
},
commits_reset: (store, event) => {
// Clear commits to trigger refetch in useSessionCommits hook
store.getState().clearSessionCommits(event.session_id);
// Invalidate cumulative diff cache when commits are reset
invalidateCumulativeDiffCache(event.session_id);
invalidateCumulativeDiffCache(resolveEnvKey(store, event.session_id));
},
branch_switched: (store, event) => {
// Clear commits to trigger refetch with new base commit
store.getState().clearSessionCommits(event.session_id);
// Invalidate cumulative diff cache when branch switches
invalidateCumulativeDiffCache(event.session_id);
invalidateCumulativeDiffCache(resolveEnvKey(store, event.session_id));
},
};