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:
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user