refactor(app): reshape session store reads around narrow hook surface

Eliminate sidebar re-render cascades by fixing both sides of the
subscription contract:

- Session-store writes preserve identity: setWorkspaces, mergeWorkspaces,
  removeWorkspace, and patchWorkspaceScripts return the previous Map/entry
  reference when content is unchanged.
- HostRuntimeController.updateSnapshot only notifies when a patched field
  actually changed; idle probe ticks no longer replace the snapshot.
- New canonical hook surface in stores/session-store-hooks.ts
  (useWorkspace, useWorkspaceFields, useWorkspaceStructure,
  useWorkspaceKeys, useResolveWorkspaceIdByCwd,
  useWorkspaceStatusesForBadges, useWorkspaceExecutionAuthority,
  useRecommendedProjectPaths, useHasWorkspaces). These are the only
  place to subscribe to workspaces.
- 15 greedy subscribers migrated to the hook surface. The sidebar is now
  driven by useWorkspaceStructure for identity/ordering; each row
  hydrates its own descriptor via useWorkspaceFields, so a workspace
  update re-renders only its row. Archive-redirect callbacks switched to
  event-time useSessionStore.getState().
- Deleted dead code: use-session-directory.ts, workspace-fetch-debug.ts,
  and the unused SidebarProjectEntry aggregates (activeCount,
  totalWorkspaces, aggregated statusBucket).

No user-visible behavior change. Targeted tests green (79 tests across
host-runtime, session-store, session-store-hooks, sidebar list, sidebar
row model, sidebar shortcuts, workspace source of truth).
This commit is contained in:
Mohamed Boudra
2026-04-19 20:19:45 +07:00
parent 8991d34f78
commit bd1fb311ea
27 changed files with 1797 additions and 623 deletions
+3 -17
View File
@@ -1,13 +1,9 @@
import { useEffect, useRef, useState } from "react";
import { useShallow } from "zustand/shallow";
import { getIsElectronRuntimeMac } from "@/constants/layout";
import { useAggregatedAgents } from "./use-aggregated-agents";
import { getDesktopHost } from "@/desktop/host";
import { useSessionStore } from "@/stores/session-store";
import {
deriveMacDockBadgeCountFromWorkspaceStatuses,
type DesktopBadgeWorkspaceStatus,
} from "@/utils/desktop-badge-state";
import { useWorkspaceStatusesForBadges } from "@/stores/session-store-hooks";
import { deriveMacDockBadgeCountFromWorkspaceStatuses } from "@/utils/desktop-badge-state";
import { isNative } from "@/constants/platform";
type FaviconStatus = "none" | "running" | "attention";
@@ -99,17 +95,7 @@ async function updateMacDockBadge(count?: number) {
export function useFaviconStatus() {
const { agents } = useAggregatedAgents();
const workspaceStatuses = useSessionStore(
useShallow((state) => {
const statuses: DesktopBadgeWorkspaceStatus[] = [];
for (const session of Object.values(state.sessions)) {
for (const workspace of session.workspaces.values()) {
statuses.push(workspace.status);
}
}
return statuses;
}),
);
const workspaceStatuses = useWorkspaceStatusesForBadges();
const [colorScheme, setColorScheme] = useState<ColorScheme>(getSystemColorScheme);
const lastDockBadgeCountRef = useRef<number | undefined>(undefined);
@@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AgentProvider, ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import type { DaemonClient } from "@server/client/daemon-client";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionForServer } from "./use-session-directory";
import { useSessionStore } from "@/stores/session-store";
import { queryClient as singletonQueryClient } from "@/query/query-client";
function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null {
@@ -39,9 +39,8 @@ export function useProvidersSnapshot(
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const normalizedCwd = cwd?.trim() || undefined;
const normalizedCwdKey = normalizeProvidersSnapshotCwdKey(normalizedCwd);
const supportsSnapshot = useSessionForServer(
serverId,
(session) => session?.serverInfo?.features?.providersSnapshot === true,
const supportsSnapshot = useSessionStore(
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providersSnapshot === true,
);
const queryKey = useMemo(
@@ -1,40 +0,0 @@
import { useCallback, useMemo } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useSessionStore, type SessionState } from "@/stores/session-store";
export function useSessionDirectory(): Map<string, SessionState> {
const sessions = useSessionStore((state) => state.sessions);
return useMemo(() => {
return new Map<string, SessionState>(Object.entries(sessions));
}, [sessions]);
}
type SessionSelector<T> = (session: SessionState | null) => T;
type EqualityFn<T> = ((left: T, right: T) => boolean) | undefined;
export function useSessionForServer(serverId: string | null): SessionState | null;
export function useSessionForServer<T>(
serverId: string | null,
selector: SessionSelector<T>,
equalityFn?: EqualityFn<T>,
): T;
export function useSessionForServer<T>(
serverId: string | null,
selector?: SessionSelector<T>,
equalityFn?: EqualityFn<T>,
): SessionState | null | T {
const storeSelector = useCallback(
(state: ReturnType<typeof useSessionStore.getState>): SessionState | null | T => {
const session = serverId ? (state.sessions[serverId] ?? null) : null;
return selector ? selector(session) : session;
},
[selector, serverId],
);
return useStoreWithEqualityFn(
useSessionStore,
storeSelector,
equalityFn as EqualityFn<SessionState | null | T>,
);
}
@@ -1,11 +1,15 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceScriptPayload } from "@server/shared/messages";
import { describe, expect, it, vi } from "vitest";
vi.hoisted(() => {
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
});
import {
appendMissingOrderKeys,
applyStoredOrdering,
buildSidebarProjectsFromWorkspaces,
buildSidebarProjectsFromStructure,
} from "./use-sidebar-workspaces-list";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/stores/session-store-hooks";
interface OrderedItem {
key: string;
@@ -15,57 +19,22 @@ function item(key: string): OrderedItem {
return { key };
}
function workspace(
input: Pick<WorkspaceDescriptor, "id" | "projectId" | "name" | "status"> &
Partial<
Pick<
WorkspaceDescriptor,
| "projectDisplayName"
| "projectRootPath"
| "workspaceDirectory"
| "projectKind"
| "workspaceKind"
| "scripts"
>
>,
): WorkspaceDescriptor {
function project(input: {
projectKey: string;
projectName?: string;
projectKind?: WorkspaceStructureProject["projectKind"];
iconWorkingDir?: string;
workspaceKeys: string[];
}): WorkspaceStructureProject {
return {
id: input.id,
projectId: input.projectId,
projectDisplayName: input.projectDisplayName ?? input.projectId,
projectRootPath: input.projectRootPath ?? input.id,
workspaceDirectory: input.workspaceDirectory ?? input.projectRootPath ?? input.id,
projectKey: input.projectKey,
projectName: input.projectName ?? input.projectKey,
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "checkout",
name: input.name,
status: input.status,
diffStat: null,
scripts: input.scripts ?? [],
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
workspaceKeys: input.workspaceKeys,
};
}
const runningScript: WorkspaceScriptPayload = {
scriptName: "web",
type: "service",
hostname: "web.paseo.localhost",
port: 3000,
proxyUrl: "http://web.paseo.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
};
const stoppedScript: WorkspaceScriptPayload = {
scriptName: "api",
type: "service",
hostname: "api.paseo.localhost",
port: 3001,
proxyUrl: "http://api.paseo.localhost:6767",
lifecycle: "stopped",
health: null,
exitCode: null,
};
describe("applyStoredOrdering", () => {
it("keeps unknown items on the baseline while applying stored order", () => {
const result = applyStoredOrdering({
@@ -121,187 +90,52 @@ describe("appendMissingOrderKeys", () => {
});
});
describe("buildSidebarProjectsFromWorkspaces", () => {
it("uses workspace descriptor name and status directly", () => {
const workspaces: WorkspaceDescriptor[] = [
workspace({
id: "/repo/main",
projectId: "project-1",
name: "feat/hard-cut",
status: "failed",
}),
];
const projects = buildSidebarProjectsFromWorkspaces({
describe("buildSidebarProjectsFromStructure", () => {
it("creates structural workspace rows from ordered workspace keys", () => {
const projects = buildSidebarProjectsFromStructure({
serverId: "srv",
workspaces,
projectOrder: [],
workspaceOrderByScope: {},
projects: [
project({
projectKey: "project-1",
projectName: "Project 1",
iconWorkingDir: "/repo/main",
workspaceKeys: ["ws-main"],
}),
],
});
expect(projects).toHaveLength(1);
expect(projects[0]?.statusBucket).toBe("failed");
expect(projects[0]?.workspaces[0]?.name).toBe("feat/hard-cut");
expect(projects[0]?.workspaces[0]?.statusBucket).toBe("failed");
expect(projects[0]?.projectName).toBe("Project 1");
expect(projects[0]?.workspaces[0]).toMatchObject({
workspaceKey: "srv:ws-main",
serverId: "srv",
workspaceId: "ws-main",
projectRootPath: "/repo/main",
projectKind: "git",
});
});
it("threads scripts into workspace rows and derives hasRunningScripts", () => {
const projects = buildSidebarProjectsFromWorkspaces({
it("preserves the structure hook project order", () => {
const projects = buildSidebarProjectsFromStructure({
serverId: "srv",
workspaces: [
workspace({
id: "/repo/main",
projectId: "project-1",
name: "main",
status: "running",
scripts: [runningScript, stoppedScript],
}),
projects: [
project({ projectKey: "project-b", workspaceKeys: ["ws-b"] }),
project({ projectKey: "project-a", workspaceKeys: ["ws-a"] }),
],
projectOrder: [],
workspaceOrderByScope: {},
});
expect(projects[0]?.workspaces[0]?.scripts).toEqual([runningScript, stoppedScript]);
expect(projects[0]?.workspaces[0]?.hasRunningScripts).toBe(true);
expect(projects.map((project) => project.projectKey)).toEqual(["project-b", "project-a"]);
});
it("preserves stored project order even when input order differs", () => {
const initialWorkspaces: WorkspaceDescriptor[] = [
workspace({
id: "/repo/b",
projectId: "project-b",
name: "feat/b",
status: "running",
}),
workspace({
id: "/repo/a",
projectId: "project-a",
name: "feat/a",
status: "running",
}),
];
const seededOrder = appendMissingOrderKeys({
currentOrder: [],
visibleKeys: buildSidebarProjectsFromWorkspaces({
serverId: "srv",
workspaces: initialWorkspaces,
projectOrder: [],
workspaceOrderByScope: {},
}).map((project) => project.projectKey),
});
const updatedProjects = buildSidebarProjectsFromWorkspaces({
it("preserves the structure hook workspace order", () => {
const projects = buildSidebarProjectsFromStructure({
serverId: "srv",
workspaces: [
workspace({
id: "/repo/a",
projectId: "project-a",
name: "feat/a",
status: "running",
}),
workspace({
id: "/repo/b",
projectId: "project-b",
name: "feat/b",
status: "running",
}),
],
projectOrder: seededOrder,
workspaceOrderByScope: {},
});
expect(updatedProjects.map((project) => project.projectKey)).toEqual([
"project-a",
"project-b",
]);
});
it("appends new projects after the stored project order", () => {
const projects = buildSidebarProjectsFromWorkspaces({
serverId: "srv",
workspaces: [
workspace({
id: "/repo/c",
projectId: "project-c",
name: "feat/c",
status: "running",
}),
workspace({
id: "/repo/b",
projectId: "project-b",
name: "feat/b",
status: "running",
}),
workspace({
id: "/repo/a",
projectId: "project-a",
name: "feat/a",
status: "running",
}),
],
projectOrder: ["project-b", "project-a", "project-c"],
workspaceOrderByScope: {},
});
expect(projects.map((project) => project.projectKey)).toEqual([
"project-b",
"project-a",
"project-c",
]);
});
it("preserves stored workspace order when workspace activity changes", () => {
const initialProjects = buildSidebarProjectsFromWorkspaces({
serverId: "srv",
workspaces: [
workspace({
id: "/repo/main",
projectId: "project-1",
name: "main",
status: "running",
}),
workspace({
id: "/repo/feature",
projectId: "project-1",
name: "feature",
status: "running",
}),
],
projectOrder: ["project-1"],
workspaceOrderByScope: {},
});
const seededWorkspaceOrder = appendMissingOrderKeys({
currentOrder: [],
visibleKeys: initialProjects[0]?.workspaces.map((workspace) => workspace.workspaceKey) ?? [],
});
const projects = buildSidebarProjectsFromWorkspaces({
serverId: "srv",
workspaces: [
workspace({
id: "/repo/main",
projectId: "project-1",
name: "main",
status: "running",
}),
workspace({
id: "/repo/feature",
projectId: "project-1",
name: "feature",
status: "running",
}),
],
projectOrder: ["project-1"],
workspaceOrderByScope: {
"srv::project-1": seededWorkspaceOrder,
},
projects: [project({ projectKey: "project-1", workspaceKeys: ["feature", "main"] })],
});
expect(projects[0]?.workspaces.map((workspace) => workspace.workspaceId)).toEqual([
"/repo/feature",
"/repo/main",
"feature",
"main",
]);
});
});
@@ -6,13 +6,15 @@ import {
useSessionStore,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import {
useWorkspaceStructure,
type WorkspaceStructureProject,
} from "@/stores/session-store-hooks";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
const EMPTY_ORDER: string[] = [];
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
const EMPTY_WORKSPACE_ORDER_SCOPE: Record<string, string[]> = {};
export type SidebarStateBucket = WorkspaceDescriptor["status"];
@@ -36,9 +38,6 @@ export interface SidebarProjectEntry {
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
statusBucket: SidebarStateBucket;
activeCount: number;
totalWorkspaces: number;
workspaces: SidebarWorkspaceEntry[];
}
@@ -50,119 +49,68 @@ export interface SidebarWorkspacesListResult {
refreshAll: () => void;
}
const SIDEBAR_BUCKET_PRIORITY: Record<SidebarStateBucket, number> = {
done: 0,
attention: 1,
running: 2,
failed: 3,
needs_input: 4,
};
function aggregateBucket(
current: SidebarStateBucket,
candidate: SidebarStateBucket,
): SidebarStateBucket {
return SIDEBAR_BUCKET_PRIORITY[candidate] > SIDEBAR_BUCKET_PRIORITY[current]
? candidate
: current;
}
function compareWorkspaceBaseline(
left: SidebarWorkspaceEntry,
right: SidebarWorkspaceEntry,
): number {
const nameDelta = left.name.localeCompare(right.name, undefined, {
numeric: true,
sensitivity: "base",
});
if (nameDelta !== 0) {
return nameDelta;
}
return left.workspaceId.localeCompare(right.workspaceId, undefined, {
sensitivity: "base",
});
}
function compareProjectBaseline(left: SidebarProjectEntry, right: SidebarProjectEntry): number {
return left.projectName.localeCompare(right.projectName, undefined, {
numeric: true,
sensitivity: "base",
});
}
export function buildSidebarProjectsFromWorkspaces(input: {
function createStructuralWorkspaceEntry(input: {
serverId: string;
workspaces: Iterable<WorkspaceDescriptor>;
projectOrder: string[];
workspaceOrderByScope: Record<string, string[]>;
project: WorkspaceStructureProject;
workspaceId: string;
}): SidebarWorkspaceEntry {
return {
workspaceKey: `${input.serverId}:${input.workspaceId}`,
serverId: input.serverId,
workspaceId: input.workspaceId,
projectRootPath: input.project.iconWorkingDir,
workspaceDirectory: undefined,
projectKind: input.project.projectKind,
workspaceKind: "checkout",
name: input.workspaceId,
statusBucket: "done",
diffStat: null,
scripts: [],
hasRunningScripts: false,
};
}
export function createSidebarWorkspaceEntry(input: {
serverId: string;
workspace: WorkspaceDescriptor;
}): SidebarWorkspaceEntry {
return {
workspaceKey: `${input.serverId}:${input.workspace.id}`,
serverId: input.serverId,
workspaceId: input.workspace.id,
projectRootPath: input.workspace.projectRootPath,
workspaceDirectory: input.workspace.workspaceDirectory,
projectKind: input.workspace.projectKind,
workspaceKind: input.workspace.workspaceKind,
name: input.workspace.name,
statusBucket: input.workspace.status,
diffStat: input.workspace.diffStat,
scripts: input.workspace.scripts,
hasRunningScripts: input.workspace.scripts.some((script) => script.lifecycle === "running"),
};
}
export function buildSidebarProjectsFromStructure(input: {
serverId: string;
projects: WorkspaceStructureProject[];
}): SidebarProjectEntry[] {
const byProject = new Map<string, SidebarProjectEntry>();
for (const workspace of input.workspaces) {
const project =
byProject.get(workspace.projectId) ??
({
projectKey: workspace.projectId,
projectName:
workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId),
projectKind: workspace.projectKind,
iconWorkingDir: workspace.projectRootPath,
statusBucket: "done",
activeCount: 0,
totalWorkspaces: 0,
workspaces: [],
} satisfies SidebarProjectEntry);
const row: SidebarWorkspaceEntry = {
workspaceKey: `${input.serverId}:${workspace.id}`,
serverId: input.serverId,
workspaceId: workspace.id,
projectRootPath: workspace.projectRootPath,
workspaceDirectory: workspace.workspaceDirectory,
projectKind: workspace.projectKind,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
statusBucket: workspace.status,
diffStat: workspace.diffStat,
scripts: workspace.scripts,
hasRunningScripts: workspace.scripts.some((script) => script.lifecycle === "running"),
};
project.workspaces.push(row);
project.totalWorkspaces += 1;
if (workspace.status !== "done") {
project.activeCount += 1;
}
project.statusBucket = aggregateBucket(project.statusBucket, workspace.status);
byProject.set(workspace.projectId, project);
if (input.projects.length === 0) {
return EMPTY_PROJECTS;
}
const baselineProjects = Array.from(byProject.values()).map((project) => {
const baselineWorkspaces = [...project.workspaces];
baselineWorkspaces.sort(compareWorkspaceBaseline);
const workspaceOrderScopeKey = getWorkspaceOrderScopeKey(input.serverId, project.projectKey);
const orderedWorkspaces = applyStoredOrdering({
items: baselineWorkspaces,
storedOrder: input.workspaceOrderByScope[workspaceOrderScopeKey] ?? EMPTY_ORDER,
getKey: (workspace) => workspace.workspaceKey,
});
return {
...project,
workspaces: orderedWorkspaces,
};
});
baselineProjects.sort(compareProjectBaseline);
return applyStoredOrdering({
items: baselineProjects,
storedOrder: input.projectOrder,
getKey: (project) => project.projectKey,
});
return input.projects.map((project) => ({
projectKey: project.projectKey,
projectName: project.projectName,
projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir,
workspaces: project.workspaceKeys.map((workspaceId) =>
createStructuralWorkspaceEntry({
serverId: input.serverId,
project,
workspaceId,
}),
),
}));
}
export function applyStoredOrdering<T>(input: {
@@ -229,10 +177,6 @@ export function appendMissingOrderKeys(input: {
return [...input.currentOrder, ...missingKeys];
}
function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string {
return `${serverId.trim()}::${projectKey.trim()}`;
}
function toWorkspaceDescriptor(payload: WorkspaceDescriptorPayload): WorkspaceDescriptor {
return normalizeWorkspaceDescriptor(payload);
}
@@ -250,17 +194,12 @@ export function useSidebarWorkspacesList(options?: {
const persistedProjectOrder = useSidebarOrderStore((state) =>
serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER,
);
const persistedWorkspaceOrderByScope = useSidebarOrderStore((state) =>
serverId ? state.workspaceOrderByServerAndProject : EMPTY_WORKSPACE_ORDER_SCOPE,
);
const isActive = Boolean(serverId);
const sessionWorkspaces = useSessionStore((state) =>
isActive && serverId ? (state.sessions[serverId]?.workspaces ?? null) : null,
);
const hasHydratedWorkspaces = useSessionStore((state) =>
isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
);
const workspaceStructure = useWorkspaceStructure(isActive ? serverId : null);
const connectionStatus = useSyncExternalStore(
(onStoreChange) =>
@@ -282,22 +221,20 @@ export function useSidebarWorkspacesList(options?: {
);
const projects = useMemo(() => {
if (!sessionWorkspaces || sessionWorkspaces.size === 0 || !serverId) {
if (!serverId || workspaceStructure.projects.length === 0) {
return EMPTY_PROJECTS;
}
return buildSidebarProjectsFromWorkspaces({
return buildSidebarProjectsFromStructure({
serverId,
workspaces: sessionWorkspaces.values(),
projectOrder: persistedProjectOrder,
workspaceOrderByScope: persistedWorkspaceOrderByScope,
projects: workspaceStructure.projects,
});
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, serverId, sessionWorkspaces]);
}, [serverId, workspaceStructure]);
useEffect(() => {
if (!serverId) {
return;
}
}, [connectionStatus, hasHydratedWorkspaces, projects, serverId, sessionWorkspaces]);
}, [connectionStatus, hasHydratedWorkspaces, projects, serverId]);
useEffect(() => {
if (!serverId || projects.length === 0) {
@@ -313,9 +250,9 @@ export function useSidebarWorkspacesList(options?: {
}
for (const project of projects) {
const workspaceOrderScopeKey = getWorkspaceOrderScopeKey(serverId, project.projectKey);
const persistedWorkspaceOrder =
persistedWorkspaceOrderByScope[workspaceOrderScopeKey] ?? EMPTY_ORDER;
const persistedWorkspaceOrder = useSidebarOrderStore
.getState()
.getWorkspaceOrder(serverId, project.projectKey);
const nextWorkspaceOrder = appendMissingOrderKeys({
currentOrder: persistedWorkspaceOrder,
visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey),
@@ -326,7 +263,7 @@ export function useSidebarWorkspacesList(options?: {
.setWorkspaceOrder(serverId, project.projectKey, nextWorkspaceOrder);
}
}
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, projects, serverId]);
}, [persistedProjectOrder, projects, serverId]);
const refreshAll = useCallback(() => {
if (!isActive || !serverId || connectionStatus !== "online") {