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
@@ -8,6 +8,7 @@ import { MenuHeader } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { useHasWorkspaces } from "@/stores/session-store-hooks";
import {
useIsCompactFormFactor,
HEADER_INNER_HEIGHT,
@@ -22,7 +23,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
const openAgentList = usePanelStore((s) => s.openAgentList);
const openProjectPicker = useOpenProjectPicker(serverId);
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0);
const hasProjects = useHasWorkspaces(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
const [isPairDeviceOpen, setIsPairDeviceOpen] = useState(false);
@@ -11,9 +11,9 @@ import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { type Agent, useSessionStore } from "@/stores/session-store";
import type { Agent } from "@/stores/session-store";
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
import { encodeImages } from "@/utils/encode-images";
import { getWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
import type { AgentSnapshotPayload } from "@server/shared/messages";
@@ -50,9 +50,8 @@ export function WorkspaceDraftAgentTab({
}: WorkspaceDraftAgentTabProps) {
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const workspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
const workspaceAuthority = getWorkspaceExecutionAuthority({ workspaces, workspaceId });
const workspaceExecutionAuthority = workspaceAuthority.ok ? workspaceAuthority.authority : null;
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId);
const workspaceExecutionAuthority = workspaceAuthority?.ok ? workspaceAuthority.authority : null;
const workspaceDirectory = workspaceExecutionAuthority?.workspaceDirectory ?? null;
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const draftStoreKey = useMemo(
@@ -66,6 +66,7 @@ import {
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
import {
checkoutStatusQueryKey,
@@ -79,7 +80,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import { buildProviderCommand } from "@/utils/provider-command-templates";
import { generateDraftId } from "@/stores/draft-keys";
import {
resolveWorkspaceExecutionAuthority,
getWorkspaceExecutionAuthority,
resolveWorkspaceRouteId,
} from "@/utils/workspace-execution";
import {
@@ -617,9 +618,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
resolveWorkspaceRouteId({
routeWorkspaceId: workspaceId,
}) ?? "";
const sessionWorkspaces = useSessionStore(
(state) => state.sessions[normalizedServerId]?.workspaces,
);
const workspaceDescriptor = useWorkspace(normalizedServerId, normalizedWorkspaceId);
const workspaceTerminalScopeKey =
normalizedServerId && normalizedWorkspaceId
@@ -632,17 +631,18 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const queryClient = useQueryClient();
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const workspaceDescriptor = sessionWorkspaces?.get(normalizedWorkspaceId) ?? null;
const workspaceAuthority = useMemo(
() =>
resolveWorkspaceExecutionAuthority({
workspaces: sessionWorkspaces,
workspaceId: normalizedWorkspaceId,
getWorkspaceExecutionAuthority({
workspace: workspaceDescriptor,
}),
[normalizedWorkspaceId, sessionWorkspaces],
[workspaceDescriptor],
);
const workspaceExecutionAuthority = workspaceAuthority.ok ? workspaceAuthority.authority : null;
const workspaceDirectory = workspaceExecutionAuthority?.workspaceDirectory ?? null;
const isMissingWorkspaceExecutionAuthority = Boolean(
workspaceDescriptor && !workspaceExecutionAuthority,
);
const workspaceDirectory = workspaceAuthority?.workspaceDirectory ?? null;
const isMissingWorkspaceExecutionAuthority = Boolean(workspaceDescriptor && !workspaceAuthority);
// Warm the server-side provider snapshot for this workspace cwd so the model
// picker is ready when opened. Consumers share the same query cache key.
@@ -1,9 +1,14 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
vi.hoisted(() => {
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
});
import {
resolveWorkspaceHeader,
shouldRenderMissingWorkspaceDescriptor,
} from "./workspace-header-source";
import { buildSidebarProjectsFromWorkspaces } from "@/hooks/use-sidebar-workspaces-list";
import { createSidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { WorkspaceDescriptor } from "@/stores/session-store";
describe("workspace source of truth consumption", () => {
@@ -23,17 +28,15 @@ describe("workspace source of truth consumption", () => {
};
const header = resolveWorkspaceHeader({ workspace });
const sidebarProjects = buildSidebarProjectsFromWorkspaces({
const sidebarWorkspace = createSidebarWorkspaceEntry({
serverId: "srv",
workspaces: [workspace],
projectOrder: [],
workspaceOrderByScope: {},
workspace,
});
expect(header.title).toBe("feat/workspace-sot");
expect(header.subtitle).toBe("getpaseo/paseo");
expect(sidebarProjects[0]?.workspaces[0]?.name).toBe(header.title);
expect(sidebarProjects[0]?.workspaces[0]?.statusBucket).toBe("running");
expect(sidebarWorkspace.name).toBe(header.title);
expect(sidebarWorkspace.statusBucket).toBe("running");
});
it("renders explicit missing state only after workspace hydration", () => {