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

127
package-lock.json generated
View File

@@ -12765,6 +12765,93 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/dom/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@testing-library/dom/node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/@testing-library/dom/node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@testing-library/react": {
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@testing-library/dom": "^10.0.0",
"@types/react": "^18.0.0 || ^19.0.0",
"@types/react-dom": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@testing-library/react-hooks": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-7.0.2.tgz",
@@ -12880,6 +12967,14 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -14893,6 +14988,17 @@
"node": ">=10"
}
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/array-buffer-byte-length": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
@@ -18008,6 +18114,14 @@
"node": ">=0.10.0"
}
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -27199,6 +27313,17 @@
"react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -37233,6 +37358,7 @@
"expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12",
"expo-web-browser": "~15.0.8",
"fast-deep-equal": "^3.1.3",
"lucide-react-native": "^0.546.0",
"mnemonic-id": "^3.2.7",
"react": "19.1.0",
@@ -37261,6 +37387,7 @@
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@testing-library/react": "^16.3.2",
"@types/react": "~19.2.0",
"@types/ws": "^8.18.1",
"eas-cli": "^16.24.1",

View File

@@ -79,6 +79,7 @@
"expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12",
"expo-web-browser": "~15.0.8",
"fast-deep-equal": "^3.1.3",
"lucide-react-native": "^0.546.0",
"mnemonic-id": "^3.2.7",
"react": "19.1.0",
@@ -107,6 +108,7 @@
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@testing-library/react": "^16.3.2",
"@types/react": "~19.2.0",
"@types/ws": "^8.18.1",
"eas-cli": "^16.24.1",

View File

@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
import { useLocalSearchParams, useRouter } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useSessionStore } from "@/stores/session-store";
import { useResolveWorkspaceIdByCwd } from "@/stores/session-store-hooks";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
@@ -32,21 +33,10 @@ function HostAgentReadyRouteContent() {
}
return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null;
});
const sessionWorkspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined,
);
const hasHydratedWorkspaces = useSessionStore((state) =>
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
);
const resolvedWorkspaceId = useSessionStore((state) => {
if (!serverId || !agentId) {
return null;
}
return resolveWorkspaceIdByExecutionDirectory({
workspaces: state.sessions[serverId]?.workspaces?.values(),
workspaceDirectory: state.sessions[serverId]?.agents?.get(agentId)?.cwd,
});
});
const resolvedWorkspaceId = useResolveWorkspaceIdByCwd(serverId, agentCwd);
useEffect(() => {
if (redirectedRef.current) {
@@ -102,8 +92,9 @@ function HostAgentReadyRouteContent() {
return;
}
const cwd = result?.agent?.cwd?.trim();
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: sessionWorkspaces?.values(),
workspaces: workspaces?.values(),
workspaceDirectory: cwd,
});
if (!workspaceId && !hasHydratedWorkspaces) {
@@ -133,7 +124,7 @@ function HostAgentReadyRouteContent() {
return () => {
cancelled = true;
};
}, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId, sessionWorkspaces]);
}, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
return null;
}

View File

@@ -6,7 +6,7 @@ import { useQuery } from "@tanstack/react-query";
import { usePathname } from "expo-router";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { shortenPath } from "@/utils/shorten-path";
import { useSessionStore } from "@/stores/session-store";
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useOpenProject } from "@/hooks/use-open-project";
import { parseServerIdFromPathname } from "@/utils/host-routes";
@@ -29,9 +29,7 @@ export function ProjectPickerModal() {
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const workspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined,
);
const recommendedPaths = useRecommendedProjectPaths(serverId);
const inputRef = useRef<TextInput>(null);
const [query, setQuery] = useState("");
@@ -39,13 +37,6 @@ export function ProjectPickerModal() {
const [isSubmitting, setIsSubmitting] = useState(false);
const openProject = useOpenProject(serverId);
const recommendedPaths = useMemo(() => {
if (!workspaces) return [];
return Array.from(workspaces.values())
.map((workspace) => workspace.projectRootPath)
.filter((path) => path.length > 0);
}, [workspaces]);
const directorySuggestionsQuery = useQuery({
queryKey: ["project-picker-directory-suggestions", serverId, query],
queryFn: async () => {

View File

@@ -0,0 +1,337 @@
/**
* @vitest-environment jsdom
*/
import { act } from "@testing-library/react";
import type { DaemonClient } from "@server/client/daemon-client";
import type { WorkspaceScriptPayload } from "@server/shared/messages";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import React from "react";
import type { ReactElement } from "react";
vi.hoisted(() => {
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
});
import {
createSidebarWorkspaceEntry,
type SidebarProjectEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
import {
getHostRuntimeStore,
type HostRuntimeController,
type HostRuntimeSnapshot,
} from "@/runtime/host-runtime";
import type { HostProfile } from "@/types/host-connection";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
},
}));
const SERVER_ID = "sidebar-render-count";
type RenderCounts = {
frame: number;
headers: Record<string, number>;
rows: Record<string, number>;
};
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,
};
function workspace(input: {
id: string;
projectId: string;
projectDisplayName: string;
name: string;
status?: WorkspaceDescriptor["status"];
scripts?: WorkspaceDescriptor["scripts"];
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId,
projectDisplayName: input.projectDisplayName,
projectRootPath: `/repo/${input.projectId}`,
workspaceDirectory: `/repo/${input.projectId}/${input.id}`,
projectKind: "git",
workspaceKind: input.name === "main" ? "local_checkout" : "worktree",
name: input.name,
status: input.status ?? "done",
diffStat: null,
scripts: input.scripts ?? [],
};
}
function createWorkspaces(): WorkspaceDescriptor[] {
return [
workspace({
id: "a-main",
projectId: "project-a",
projectDisplayName: "Project A",
name: "main",
scripts: [runningScript],
}),
workspace({
id: "a-one",
projectId: "project-a",
projectDisplayName: "Project A",
name: "one",
}),
workspace({
id: "a-two",
projectId: "project-a",
projectDisplayName: "Project A",
name: "two",
}),
workspace({
id: "b-main",
projectId: "project-b",
projectDisplayName: "Project B",
name: "main",
}),
workspace({
id: "b-one",
projectId: "project-b",
projectDisplayName: "Project B",
name: "one",
}),
workspace({
id: "b-two",
projectId: "project-b",
projectDisplayName: "Project B",
name: "two",
}),
];
}
function makeHost(): HostProfile {
const now = "2026-04-19T00:00:00.000Z";
return {
serverId: SERVER_ID,
label: "Render Count Host",
lifecycle: {},
connections: [],
preferredConnectionId: null,
createdAt: now,
updatedAt: now,
};
}
function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void {
act(() => {
getHostRuntimeStore().syncHosts([makeHost()]);
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore
.getState()
.setWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
useSidebarOrderStore.setState({
projectOrderByServerId: {
[SERVER_ID]: ["project-a", "project-b"],
},
workspaceOrderByServerAndProject: {
[`${SERVER_ID}::project-a`]: [
`${SERVER_ID}:a-main`,
`${SERVER_ID}:a-one`,
`${SERVER_ID}:a-two`,
],
[`${SERVER_ID}::project-b`]: [
`${SERVER_ID}:b-main`,
`${SERVER_ID}:b-one`,
`${SERVER_ID}:b-two`,
],
},
});
});
}
function resetCounts(counts: RenderCounts): void {
counts.frame = 0;
counts.headers = {};
counts.rows = {};
}
function incrementRecord(record: Record<string, number>, key: string): void {
record[key] = (record[key] ?? 0) + 1;
}
function ProjectHeaderProbe({
project,
counts,
}: {
project: SidebarProjectEntry;
counts: RenderCounts;
}): null {
incrementRecord(counts.headers, project.projectKey);
return null;
}
function WorkspaceRowProbe({
serverId,
workspaceId,
counts,
}: {
serverId: string;
workspaceId: string;
counts: RenderCounts;
}): null {
const workspace = useWorkspaceFields(serverId, workspaceId, (entry) =>
createSidebarWorkspaceEntry({ serverId, workspace: entry }),
);
if (workspace) {
incrementRecord(counts.rows, workspace.workspaceId);
}
return null;
}
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
counts.frame += 1;
const { projects } = useSidebarWorkspacesList({ serverId: SERVER_ID });
return (
<>
{projects.map((project) => (
<div key={project.projectKey}>
<ProjectHeaderProbe project={project} counts={counts} />
{project.workspaces.map((workspace) => (
<WorkspaceRowProbe
key={workspace.workspaceKey}
serverId={workspace.serverId}
workspaceId={workspace.workspaceId}
counts={counts}
/>
))}
</div>
))}
</>
);
}
function getHostController(): HostRuntimeController {
const controllers = (
getHostRuntimeStore() as unknown as {
controllers: Map<string, HostRuntimeController>;
}
).controllers;
const controller = controllers.get(SERVER_ID);
if (!controller) {
throw new Error("Host runtime controller was not initialized");
}
return controller;
}
function updateControllerSnapshot(
patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>,
): void {
(
getHostController() as unknown as {
updateSnapshot: (
patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>,
) => void;
}
).updateSnapshot(patch);
}
async function renderProbe(counts: RenderCounts): Promise<{ root: Root; container: HTMLElement }> {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(<SidebarFrameProbe counts={counts} />);
});
resetCounts(counts);
return { root, container };
}
describe("sidebar workspace render isolation", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(async () => {
initializeSidebarState(createWorkspaces());
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
act(() => {
getHostRuntimeStore().syncHosts([]);
useSessionStore.getState().clearSession(SERVER_ID);
useSidebarOrderStore.setState({
projectOrderByServerId: {},
workspaceOrderByServerAndProject: {},
});
});
});
it("re-renders only the changed workspace row for a status update", async () => {
const counts: RenderCounts = { frame: 0, headers: {}, rows: {} };
({ root, container } = await renderProbe(counts));
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
{
...createWorkspaces()[1]!,
status: "running",
},
]);
});
expect(counts.frame).toBe(0);
expect(counts.headers).toEqual({});
expect(counts.rows).toEqual({ "a-one": 1 });
});
it("does not re-render the sidebar for a host-runtime probe tick with no content change", async () => {
const counts: RenderCounts = { frame: 0, headers: {}, rows: {} };
({ root, container } = await renderProbe(counts));
act(() => {
const probeByConnectionId = getHostController().getSnapshot().probeByConnectionId;
updateControllerSnapshot({
probeByConnectionId: new Map(probeByConnectionId),
});
});
expect(counts).toEqual({ frame: 0, headers: {}, rows: {} });
});
it("does not re-render for a deep-equal scripts patch", async () => {
const counts: RenderCounts = { frame: 0, headers: {}, rows: {} };
({ root, container } = await renderProbe(counts));
act(() => {
useSessionStore.getState().setWorkspaces(SERVER_ID, (current) =>
patchWorkspaceScripts(current, {
workspaceId: "a-main",
scripts: [{ ...runningScript }],
}),
);
});
expect(counts).toEqual({ frame: 0, headers: {}, rows: {} });
});
});

View File

@@ -56,6 +56,7 @@ import {
} from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import {
createSidebarWorkspaceEntry,
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
@@ -92,7 +93,12 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import {
normalizeWorkspaceDescriptor,
useSessionStore,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
import { openExternalUrl } from "@/utils/open-external-url";
import {
@@ -114,7 +120,6 @@ function toProjectIconDataUri(icon: { mimeType: string; data: string } | null):
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
const EMPTY_WORKSPACES = new Map();
const WORKSPACE_STATUS_DOT_WIDTH = 14;
const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
@@ -194,6 +199,19 @@ interface WorkspaceRowInnerProps {
archiveShortcutKeys?: ShortcutKey[][] | null;
}
function useSidebarWorkspaceEntry(
serverId: string | null,
workspaceId: string | null,
): SidebarWorkspaceEntry | null {
const projectWorkspaceEntry = useCallback(
(workspace: WorkspaceDescriptor): SidebarWorkspaceEntry =>
createSidebarWorkspaceEntry({ serverId: serverId ?? "", workspace }),
[serverId],
);
return useWorkspaceFields(serverId, workspaceId, projectWorkspaceEntry);
}
export function PrBadge({ hint }: { hint: PrHint }) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
@@ -1151,9 +1169,6 @@ function WorkspaceRowWithMenu({
const toast = useToast();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const sessionWorkspaces = useSessionStore(
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES,
);
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
workspaceDirectory: workspace.workspaceDirectory,
@@ -1181,10 +1196,11 @@ function WorkspaceRowWithMenu({
buildWorkspaceArchiveRedirectRoute({
serverId: workspace.serverId,
archivedWorkspaceId: workspace.workspaceId,
workspaces: sessionWorkspaces.values(),
workspaces:
useSessionStore.getState().sessions[workspace.serverId]?.workspaces.values() ?? [],
}),
);
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
const handleArchiveWorktree = useCallback(() => {
if (isArchiving) {
@@ -1378,9 +1394,6 @@ function NonGitProjectRowWithMenuContent({
const toast = useToast();
const contextMenu = useContextMenu();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
const sessionWorkspaces = useSessionStore(
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES,
);
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const redirectAfterArchive = useCallback(() => {
if (
@@ -1394,10 +1407,11 @@ function NonGitProjectRowWithMenuContent({
buildWorkspaceArchiveRedirectRoute({
serverId: workspace.serverId,
archivedWorkspaceId: workspace.workspaceId,
workspaces: sessionWorkspaces.values(),
workspaces:
useSessionStore.getState().sessions[workspace.serverId]?.workspaces.values() ?? [],
}) as any,
);
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
const handleArchiveWorkspace = useCallback(() => {
if (isArchivingWorkspace) {
@@ -1542,13 +1556,19 @@ function FlattenedProjectRow({
onRemoveProject?: () => void;
removeProjectStatus?: "idle" | "pending";
}) {
const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId);
if (!workspace) {
return null;
}
if (project.projectKind === "directory") {
return (
<NonGitProjectRowWithMenu
project={project}
displayName={displayName}
iconDataUri={iconDataUri}
workspace={rowModel.workspace}
workspace={workspace}
selected={rowModel.selected}
onPress={onPress}
shortcutNumber={shortcutNumber}
@@ -1565,7 +1585,7 @@ function FlattenedProjectRow({
project={project}
displayName={displayName}
iconDataUri={iconDataUri}
workspace={rowModel.workspace}
workspace={workspace}
selected={rowModel.selected}
chevron={rowModel.chevron}
onPress={onPress}
@@ -1609,9 +1629,15 @@ function WorkspaceRow({
canCopyBranchName: boolean;
isCreating?: boolean;
}) {
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
if (!hydratedWorkspace) {
return null;
}
return (
<WorkspaceRowWithMenu
workspace={workspace}
workspace={hydratedWorkspace}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}

View File

@@ -1,3 +1,4 @@
import equal from "fast-deep-equal";
import type { ScriptStatusUpdateMessage } from "@server/shared/messages";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
@@ -19,6 +20,10 @@ export function patchWorkspaceScripts(
return workspaces;
}
if (equal(existing.scripts, update.scripts)) {
return workspaces;
}
const next = new Map(workspaces);
next.set(workspaceKey, {
...existing,

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);

View File

@@ -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(

View File

@@ -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>,
);
}

View File

@@ -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",
]);
});
});

View File

@@ -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") {

View File

@@ -1,12 +1,10 @@
import { useMemo } from "react";
import { Text, View } from "react-native";
import { FileText } from "lucide-react-native";
import invariant from "tiny-invariant";
import { FilePane } from "@/components/file-pane";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelRegistration } from "@/panels/panel-registry";
import { useSessionStore } from "@/stores/session-store";
import { resolveWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
const fileName = target.path.split("/").filter(Boolean).pop() ?? target.path;
@@ -21,25 +19,19 @@ function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
function FilePanel() {
const { serverId, workspaceId, target } = usePaneContext();
const workspace = useSessionStore(
(state) => state.sessions[serverId]?.workspaces.get(workspaceId) ?? null,
);
const authority = useMemo(() => resolveWorkspaceExecutionAuthority({ workspace }), [workspace]);
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId);
const workspaceDirectory = workspaceAuthority?.ok
? workspaceAuthority.authority.workspaceDirectory
: null;
invariant(target.kind === "file", "FilePanel requires file target");
if (!authority) {
if (!workspaceDirectory) {
return (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 16 }}>
<Text>Workspace execution directory not found.</Text>
</View>
);
}
return (
<FilePane
serverId={serverId}
workspaceRoot={authority.workspaceDirectory}
filePath={target.path}
/>
);
return <FilePane serverId={serverId} workspaceRoot={workspaceDirectory} filePath={target.path} />;
}
export const filePanelRegistration: PanelRegistration<"file"> = {

View File

@@ -8,7 +8,7 @@ import { TerminalPane } from "@/components/terminal-pane";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { useSessionStore } from "@/stores/session-store";
import { getWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
type ListTerminalsPayload = ListTerminalsResponse["payload"];
@@ -25,11 +25,7 @@ function useTerminalPanelDescriptor(
context: { serverId: string; workspaceId: string },
): PanelDescriptor {
const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null);
const workspaces = useSessionStore((state) => state.sessions[context.serverId]?.workspaces);
const workspaceAuthority = getWorkspaceExecutionAuthority({
workspaces,
workspaceId: context.workspaceId,
});
const workspaceAuthority = useWorkspaceExecutionAuthority(context.serverId, context.workspaceId)!;
const workspaceDirectory = workspaceAuthority.ok
? workspaceAuthority.authority.workspaceDirectory
: null;
@@ -63,8 +59,7 @@ function useTerminalPanelDescriptor(
function TerminalPanel() {
const isFocused = useIsFocused();
const { serverId, workspaceId, target, isPaneFocused } = usePaneContext();
const workspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
const workspaceAuthority = getWorkspaceExecutionAuthority({ workspaces, workspaceId });
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!;
const workspaceDirectory = workspaceAuthority.ok
? workspaceAuthority.authority.workspaceDirectory
: null;

View File

@@ -12,6 +12,7 @@ import {
HostRuntimeController,
HostRuntimeStore,
type HostRuntimeControllerDeps,
type HostRuntimeSnapshot,
} from "./host-runtime";
class FakeDaemonClient {
@@ -264,6 +265,27 @@ function clearProbeBackoff(controller: HostRuntimeController): void {
).connectionLastProbedAt.clear();
}
type HostRuntimeSnapshotPatch = Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>;
function updateControllerSnapshot(
controller: HostRuntimeController,
patch: HostRuntimeSnapshotPatch,
): void {
(
controller as unknown as {
updateSnapshot: (patch: HostRuntimeSnapshotPatch) => void;
}
).updateSnapshot(patch);
}
function makeProbeMap(
entries: Array<
[string, HostRuntimeSnapshot["probeByConnectionId"] extends Map<string, infer T> ? T : never]
>,
): HostRuntimeSnapshot["probeByConnectionId"] {
return new Map(entries);
}
describe("HostRuntimeController", () => {
it("keeps known hosts in connecting when a created client reports idle during connect", async () => {
const host = makeHost({
@@ -923,6 +945,134 @@ describe("HostRuntimeController", () => {
expect(controller.getSnapshot().clientGeneration).toBe(generationBeforeProbes);
expect(createdClients).toHaveLength(0);
});
it("does not notify or replace the snapshot for equal probe maps", () => {
const controller = new HostRuntimeController({ host: makeHost() });
const firstProbeMap = makeProbeMap([
["direct:lan:6767", { status: "available", latencyMs: 12 }],
]);
const equalProbeMap = makeProbeMap([
["direct:lan:6767", { status: "available", latencyMs: 12 }],
]);
updateControllerSnapshot(controller, { probeByConnectionId: firstProbeMap });
const snapshotAfterFirstProbe = controller.getSnapshot();
let notifyCount = 0;
const unsubscribe = controller.subscribe(() => {
notifyCount += 1;
});
updateControllerSnapshot(controller, { probeByConnectionId: equalProbeMap });
expect(notifyCount).toBe(0);
expect(controller.getSnapshot()).toBe(snapshotAfterFirstProbe);
expect(controller.getSnapshot().probeByConnectionId).toBe(firstProbeMap);
unsubscribe();
});
it("does not notify or replace the snapshot when connection status is already equal", () => {
const controller = new HostRuntimeController({ host: makeHost() });
updateControllerSnapshot(controller, { connectionStatus: "online" });
const snapshotAfterOnline = controller.getSnapshot();
let notifyCount = 0;
const unsubscribe = controller.subscribe(() => {
notifyCount += 1;
});
updateControllerSnapshot(controller, { connectionStatus: "online" });
expect(notifyCount).toBe(0);
expect(controller.getSnapshot()).toBe(snapshotAfterOnline);
unsubscribe();
});
it("does not notify or replace the snapshot when every patched field is equal", () => {
const controller = new HostRuntimeController({ host: makeHost() });
const firstProbeMap = makeProbeMap([
["direct:lan:6767", { status: "available", latencyMs: 12 }],
]);
const equalProbeMap = makeProbeMap([
["direct:lan:6767", { status: "available", latencyMs: 12 }],
]);
updateControllerSnapshot(controller, {
connectionStatus: "online",
probeByConnectionId: firstProbeMap,
});
const snapshotAfterSetup = controller.getSnapshot();
let notifyCount = 0;
const unsubscribe = controller.subscribe(() => {
notifyCount += 1;
});
updateControllerSnapshot(controller, {
connectionStatus: "online",
probeByConnectionId: equalProbeMap,
});
expect(notifyCount).toBe(0);
expect(controller.getSnapshot()).toBe(snapshotAfterSetup);
expect(controller.getSnapshot().probeByConnectionId).toBe(firstProbeMap);
unsubscribe();
});
it("notifies once for a changed field while preserving equal field identity", () => {
const controller = new HostRuntimeController({ host: makeHost() });
const firstProbeMap = makeProbeMap([
["direct:lan:6767", { status: "available", latencyMs: 12 }],
]);
const equalProbeMap = makeProbeMap([
["direct:lan:6767", { status: "available", latencyMs: 12 }],
]);
updateControllerSnapshot(controller, {
connectionStatus: "online",
probeByConnectionId: firstProbeMap,
});
const snapshotBeforeChange = controller.getSnapshot();
let notifyCount = 0;
const unsubscribe = controller.subscribe(() => {
notifyCount += 1;
});
updateControllerSnapshot(controller, {
connectionStatus: "offline",
probeByConnectionId: equalProbeMap,
});
expect(notifyCount).toBe(1);
expect(controller.getSnapshot()).not.toBe(snapshotBeforeChange);
expect(controller.getSnapshot().connectionStatus).toBe("offline");
expect(controller.getSnapshot().probeByConnectionId).toBe(firstProbeMap);
unsubscribe();
});
it("notifies once and replaces the probe map when probe contents change", () => {
const controller = new HostRuntimeController({ host: makeHost() });
const firstProbeMap = makeProbeMap([
["direct:lan:6767", { status: "available", latencyMs: 12 }],
]);
const changedProbeMap = makeProbeMap([
["direct:lan:6767", { status: "available", latencyMs: 12 }],
["relay:relay.paseo.sh:443", { status: "unavailable", latencyMs: null }],
]);
updateControllerSnapshot(controller, { probeByConnectionId: firstProbeMap });
const snapshotBeforeChange = controller.getSnapshot();
let notifyCount = 0;
const unsubscribe = controller.subscribe(() => {
notifyCount += 1;
});
updateControllerSnapshot(controller, { probeByConnectionId: changedProbeMap });
expect(notifyCount).toBe(1);
expect(controller.getSnapshot()).not.toBe(snapshotBeforeChange);
expect(controller.getSnapshot().probeByConnectionId).toBe(changedProbeMap);
expect(controller.getSnapshot().probeByConnectionId).not.toBe(firstProbeMap);
unsubscribe();
});
});
describe("HostRuntimeStore", () => {

View File

@@ -1,5 +1,6 @@
import { useSyncExternalStore, useCallback, useMemo } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import equal from "fast-deep-equal/es6";
import {
DaemonClient,
type ConnectionState,
@@ -67,6 +68,16 @@ export type HostRuntimeSnapshot = {
clientGeneration: number;
};
type HostRuntimeSnapshotPatch = Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>;
function setSnapshotPatchField<Key extends keyof HostRuntimeSnapshotPatch>(
patch: HostRuntimeSnapshotPatch,
key: Key,
value: HostRuntimeSnapshot[Key],
): void {
patch[key] = value;
}
export function isHostRuntimeConnected(snapshot: HostRuntimeSnapshot | null): boolean {
return snapshot?.connectionStatus === "online";
}
@@ -831,15 +842,25 @@ export class HostRuntimeController {
});
}
private updateSnapshot(
patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>,
): void {
const next: HostRuntimeSnapshot = {
private updateSnapshot(patch: HostRuntimeSnapshotPatch): void {
const preservedPatch: HostRuntimeSnapshotPatch = { ...patch };
let hasChanged = this.host.serverId !== this.snapshot.serverId;
for (const key of Object.keys(patch) as Array<keyof HostRuntimeSnapshotPatch>) {
const incomingValue = patch[key];
if (equal(this.snapshot[key], incomingValue)) {
setSnapshotPatchField(preservedPatch, key, this.snapshot[key]);
continue;
}
hasChanged = true;
}
if (!hasChanged) {
return;
}
this.snapshot = {
...this.snapshot,
...patch,
...preservedPatch,
serverId: this.host.serverId,
};
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
@@ -1021,7 +1042,7 @@ export class HostRuntimeController {
state,
lastError: client.lastError,
});
const patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">> = {
const patch: HostRuntimeSnapshotPatch = {
...toSnapshotConnectionPatch(this.connectionMachineState),
};

View File

@@ -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);

View File

@@ -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(

View File

@@ -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.

View File

@@ -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", () => {

View File

@@ -0,0 +1,364 @@
/**
* @vitest-environment jsdom
*/
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { DaemonClient } from "@server/client/daemon-client";
import {
useHasWorkspaces,
useRecommendedProjectPaths,
useResolveWorkspaceIdByCwd,
useWorkspace,
useWorkspaceExecutionAuthority,
useWorkspaceFields,
useWorkspaceKeys,
useWorkspaceStatusesForBadges,
useWorkspaceStructure,
} from "./session-store-hooks";
import { useSidebarOrderStore } from "./sidebar-order-store";
import { useSessionStore, type WorkspaceDescriptor } from "./session-store";
const SERVER_ID = "test-server";
function createWorkspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project 1",
projectRootPath: input.projectRootPath ?? "/repo",
workspaceDirectory: input.workspaceDirectory ?? "/repo",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
}
function initializeWorkspaces(workspaces: WorkspaceDescriptor[]): void {
act(() => {
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore
.getState()
.setWorkspaces(SERVER_ID, new Map(workspaces.map((workspace) => [workspace.id, workspace])));
});
}
afterEach(() => {
act(() => {
useSessionStore.getState().clearSession(SERVER_ID);
useSidebarOrderStore.setState({
projectOrderByServerId: {},
workspaceOrderByServerAndProject: {},
});
});
});
describe("useWorkspace", () => {
it("resolves a descriptor when the route id matches workspace identity but not the map key", () => {
const workspace = createWorkspace({ id: "workspace-a" });
act(() => {
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(SERVER_ID, new Map([["map-key-a", workspace]]));
});
const { result } = renderHook(() => useWorkspace(SERVER_ID, workspace.id));
expect(result.current).toBe(workspace);
});
it("keeps the descriptor reference for unrelated workspace updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
initializeWorkspaces([workspaceA, workspaceB]);
const { result } = renderHook(() => useWorkspace(SERVER_ID, workspaceA.id));
const before = result.current;
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]);
});
expect(result.current).toBe(before);
act(() => {
useSessionStore
.getState()
.mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "attention" }]);
});
expect(result.current).not.toBe(before);
});
it("keeps the descriptor reference when the observed workspace is rewritten with content-equal data", () => {
const workspace = createWorkspace({ id: "workspace-a", scripts: [] });
initializeWorkspaces([workspace]);
const { result } = renderHook(() => useWorkspace(SERVER_ID, workspace.id));
const before = result.current;
act(() => {
useSessionStore
.getState()
.setWorkspaces(SERVER_ID, new Map([[workspace.id, { ...workspace, scripts: [] }]]));
});
expect(result.current).toBe(before);
});
});
describe("useWorkspaceExecutionAuthority", () => {
it("preserves the old missing-workspace message with the requested id", () => {
initializeWorkspaces([]);
const { result } = renderHook(() => useWorkspaceExecutionAuthority(SERVER_ID, "missing-id"));
expect(result.current).toMatchObject({
ok: false,
message: "Workspace not found: missing-id",
});
});
it("keeps deep-equal authority references under unrelated workspace updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
initializeWorkspaces([workspaceA, workspaceB]);
const { result } = renderHook(() => useWorkspaceExecutionAuthority(SERVER_ID, workspaceA.id));
const before = result.current;
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]);
});
expect(result.current).toBe(before);
});
});
describe("useWorkspaceFields", () => {
it("keeps deep-equal projection references until projected fields change", () => {
const workspace = createWorkspace({ id: "workspace-a", name: "A", status: "done" });
initializeWorkspaces([workspace]);
const { result } = renderHook(() =>
useWorkspaceFields(SERVER_ID, workspace.id, (current) => ({
identity: {
id: current.id,
name: current.name,
},
})),
);
const before = result.current;
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspace, status: "running" }]);
});
expect(result.current).toBe(before);
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
{
...workspace,
name: "A renamed",
status: "running",
},
]);
});
expect(result.current).not.toBe(before);
});
});
describe("useWorkspaceStructure", () => {
it("changes for membership updates but not status-only updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
initializeWorkspaces([workspaceA]);
const { result } = renderHook(() => useWorkspaceStructure(SERVER_ID));
const before = result.current;
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspaceB]);
});
const afterAdd = result.current;
expect(afterAdd).not.toBe(before);
expect(afterAdd.projects[0]?.workspaceKeys).toEqual(["workspace-a", "workspace-b"]);
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "running" }]);
});
expect(result.current).toBe(afterAdd);
});
it("changes when a structure-relevant project identity field changes", () => {
const workspace = createWorkspace({
id: "workspace-a",
projectDisplayName: "Project 1",
});
initializeWorkspaces([workspace]);
const { result } = renderHook(() => useWorkspaceStructure(SERVER_ID));
const before = result.current;
act(() => {
useSessionStore
.getState()
.mergeWorkspaces(SERVER_ID, [{ ...workspace, projectDisplayName: "Project Renamed" }]);
});
expect(result.current).not.toBe(before);
});
it("changes when persisted sidebar project order changes", () => {
const workspaceA = createWorkspace({
id: "workspace-a",
projectId: "project-a",
projectDisplayName: "Project A",
});
const workspaceB = createWorkspace({
id: "workspace-b",
projectId: "project-b",
projectDisplayName: "Project B",
});
initializeWorkspaces([workspaceA, workspaceB]);
const { result } = renderHook(() => useWorkspaceStructure(SERVER_ID));
const before = result.current;
act(() => {
useSidebarOrderStore.getState().setProjectOrder(SERVER_ID, ["project-b", "project-a"]);
});
expect(result.current).not.toBe(before);
});
});
describe("useWorkspaceKeys", () => {
it("changes for reorder updates but not content-only updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
initializeWorkspaces([workspaceA, workspaceB]);
const { result } = renderHook(() => useWorkspaceKeys(SERVER_ID));
const before = result.current;
expect(before).toEqual(["workspace-a", "workspace-b"]);
act(() => {
useSessionStore.getState().setWorkspaces(
SERVER_ID,
new Map([
[workspaceB.id, workspaceB],
[workspaceA.id, workspaceA],
]),
);
});
const afterReorder = result.current;
expect(afterReorder).not.toBe(before);
expect(afterReorder).toEqual(["workspace-b", "workspace-a"]);
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "running" }]);
});
expect(result.current).toBe(afterReorder);
});
});
describe("useRecommendedProjectPaths", () => {
it("updates when an existing workspace project root changes", () => {
const workspace = createWorkspace({ id: "workspace-a", projectRootPath: "/repo/a" });
initializeWorkspaces([workspace]);
const { result } = renderHook(() => useRecommendedProjectPaths(SERVER_ID));
act(() => {
useSessionStore
.getState()
.mergeWorkspaces(SERVER_ID, [{ ...workspace, projectRootPath: "/repo/b" }]);
});
expect(result.current).toEqual(["/repo/b"]);
});
it("keeps the path list reference under unrelated workspace updates", () => {
const workspace = createWorkspace({ id: "workspace-a", projectRootPath: "/repo/a" });
initializeWorkspaces([workspace]);
const { result } = renderHook(() => useRecommendedProjectPaths(SERVER_ID));
const before = result.current;
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspace, status: "running" }]);
});
expect(result.current).toBe(before);
});
});
describe("useHasWorkspaces", () => {
it("stays stable when workspace membership changes without flipping the boolean", () => {
const workspaceA = createWorkspace({ id: "workspace-a" });
const workspaceB = createWorkspace({ id: "workspace-b" });
initializeWorkspaces([workspaceA]);
const { result } = renderHook(() => useHasWorkspaces(SERVER_ID));
const before = result.current;
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspaceB]);
});
expect(result.current).toBe(before);
});
});
describe("useResolveWorkspaceIdByCwd", () => {
it("resolves by cwd and stays stable under unrelated updates", () => {
const workspaceA = createWorkspace({
id: "workspace-a",
workspaceDirectory: "/repo/a",
});
const workspaceB = createWorkspace({
id: "workspace-b",
workspaceDirectory: "/repo/b",
});
initializeWorkspaces([workspaceA, workspaceB]);
const { result } = renderHook(() => useResolveWorkspaceIdByCwd(SERVER_ID, "/repo/a"));
const before = result.current;
expect(before).toBe("workspace-a");
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]);
});
expect(result.current).toBe(before);
});
});
describe("useWorkspaceStatusesForBadges", () => {
it("tracks status changes without changing for no-ops or unrelated descriptor updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", status: "done" });
const workspaceB = createWorkspace({ id: "workspace-b", status: "attention" });
initializeWorkspaces([workspaceA, workspaceB]);
const { result } = renderHook(() => useWorkspaceStatusesForBadges());
const before = result.current;
expect(before).toEqual(["done", "attention"]);
act(() => {
useSessionStore
.getState()
.mergeWorkspaces(SERVER_ID, [{ ...workspaceA, scripts: [...workspaceA.scripts] }]);
});
expect(result.current).toBe(before);
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, name: "Renamed" }]);
});
expect(result.current).toBe(before);
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "failed" }]);
});
expect(result.current).not.toBe(before);
expect(result.current).toEqual(["failed", "attention"]);
});
});

View File

@@ -0,0 +1,384 @@
import equal from "fast-deep-equal";
import { useMemo } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
import {
getWorkspaceExecutionAuthority,
resolveWorkspaceIdByExecutionDirectory,
resolveWorkspaceMapKeyByIdentity,
type WorkspaceExecutionAuthorityResult,
} from "@/utils/workspace-execution";
import { useSessionStore, type WorkspaceDescriptor } from "./session-store";
// These are the ONLY supported ways to read workspaces from the session store.
// Do not write raw `useSessionStore` selectors that return the workspaces Map, a session object,
// or the sessions dict — it breaks re-render isolation.
export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
export interface WorkspaceStructureProject {
projectKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
workspaceKeys: string[];
}
export interface WorkspaceStructure {
projects: WorkspaceStructureProject[];
}
type SessionStoreSnapshot = ReturnType<typeof useSessionStore.getState>;
const EMPTY_WORKSPACE_KEYS: string[] = [];
const EMPTY_WORKSPACE_STRUCTURE: WorkspaceStructure = { projects: [] };
function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string {
return `${serverId.trim()}::${projectKey.trim()}`;
}
function compareWorkspaceStructureItems(
left: { workspaceId: string; workspaceName: string },
right: { workspaceId: string; workspaceName: string },
): number {
const nameDelta = left.workspaceName.localeCompare(right.workspaceName, undefined, {
numeric: true,
sensitivity: "base",
});
if (nameDelta !== 0) {
return nameDelta;
}
return left.workspaceId.localeCompare(right.workspaceId, undefined, {
sensitivity: "base",
});
}
function compareWorkspaceStructureProjects(
left: WorkspaceStructureProject,
right: WorkspaceStructureProject,
): number {
return left.projectName.localeCompare(right.projectName, undefined, {
numeric: true,
sensitivity: "base",
});
}
function applyStoredOrdering<T>(input: {
items: T[];
storedOrder: readonly string[];
getKey: (item: T) => string;
}): T[] {
if (input.items.length <= 1 || input.storedOrder.length === 0) {
return input.items;
}
const itemByKey = new Map<string, T>();
for (const item of input.items) {
itemByKey.set(input.getKey(item), item);
}
const prunedOrder: string[] = [];
const seen = new Set<string>();
for (const key of input.storedOrder) {
if (!itemByKey.has(key) || seen.has(key)) {
continue;
}
seen.add(key);
prunedOrder.push(key);
}
if (prunedOrder.length === 0) {
return input.items;
}
const orderedSet = new Set(prunedOrder);
const ordered: T[] = [];
let orderedIndex = 0;
for (const item of input.items) {
const key = input.getKey(item);
if (!orderedSet.has(key)) {
ordered.push(item);
continue;
}
const targetKey = prunedOrder[orderedIndex] ?? key;
orderedIndex += 1;
ordered.push(itemByKey.get(targetKey) ?? item);
}
return ordered;
}
function selectWorkspace(
state: SessionStoreSnapshot,
serverId: string | null,
workspaceId: string | null,
): WorkspaceDescriptor | null {
if (!serverId || !workspaceId) {
return null;
}
const workspaces = state.sessions[serverId]?.workspaces;
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
workspaces,
workspaceId,
});
return workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
}
export function useWorkspace(
serverId: string | null,
workspaceId: string | null,
): WorkspaceDescriptor | null {
return useStoreWithEqualityFn(
useSessionStore,
(state) => selectWorkspace(state, serverId, workspaceId),
Object.is,
);
}
export function useWorkspaceFields<T>(
serverId: string | null,
workspaceId: string | null,
project: (w: WorkspaceDescriptor) => T,
): T | null {
return useStoreWithEqualityFn(
useSessionStore,
(state) => {
const workspace = selectWorkspace(state, serverId, workspaceId);
return workspace ? project(workspace) : null;
},
equal,
);
}
export function useWorkspaceExecutionAuthority(
serverId: string | null,
workspaceId: string | null,
): WorkspaceExecutionAuthorityResult | null {
return useStoreWithEqualityFn(
useSessionStore,
(state) => {
if (serverId === null || workspaceId === null) {
return null;
}
return getWorkspaceExecutionAuthority({
workspaces: state.sessions[serverId]?.workspaces,
workspaceId,
});
},
equal,
);
}
function selectWorkspaceStructureProjects(
state: SessionStoreSnapshot,
serverId: string | null,
): WorkspaceStructureProject[] {
if (!serverId) {
return EMPTY_WORKSPACE_STRUCTURE.projects;
}
const workspaces = state.sessions[serverId]?.workspaces;
if (!workspaces || workspaces.size === 0) {
return EMPTY_WORKSPACE_STRUCTURE.projects;
}
const byProject = new Map<
string,
WorkspaceStructureProject & {
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
}
>();
for (const workspace of workspaces.values()) {
const project =
byProject.get(workspace.projectId) ??
({
projectKey: workspace.projectId,
projectName:
workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId),
projectKind: workspace.projectKind,
iconWorkingDir: workspace.projectRootPath,
workspaceKeys: [],
workspaces: [],
} satisfies WorkspaceStructureProject & {
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
});
project.workspaces.push({
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceKey: `${serverId}:${workspace.id}`,
});
byProject.set(workspace.projectId, project);
}
const projects = Array.from(byProject.values()).map(
({ workspaces: projectWorkspaces, ...project }) => {
const sortedWorkspaces = [...projectWorkspaces].sort(compareWorkspaceStructureItems);
return {
...project,
workspaceKeys: sortedWorkspaces.map((workspace) => workspace.workspaceId),
};
},
);
projects.sort(compareWorkspaceStructureProjects);
return projects;
}
function selectWorkspaceOrderByScopeForServer(
workspaceOrderByScope: Record<string, string[]>,
serverId: string | null,
): Record<string, string[]> {
if (!serverId) {
return {};
}
const prefix = `${serverId.trim()}::`;
const relevantOrderByScope: Record<string, string[]> = {};
for (const [scopeKey, order] of Object.entries(workspaceOrderByScope)) {
if (scopeKey.startsWith(prefix)) {
relevantOrderByScope[scopeKey] = order;
}
}
return relevantOrderByScope;
}
export function useWorkspaceStructure(serverId: string | null): WorkspaceStructure {
const projects = useStoreWithEqualityFn(
useSessionStore,
(state) => selectWorkspaceStructureProjects(state, serverId),
equal,
);
const projectOrder = useStoreWithEqualityFn(
useSidebarOrderStore,
(state) =>
serverId
? (state.projectOrderByServerId[serverId] ?? EMPTY_WORKSPACE_KEYS)
: EMPTY_WORKSPACE_KEYS,
equal,
);
const workspaceOrderByScope = useStoreWithEqualityFn(
useSidebarOrderStore,
(state) =>
selectWorkspaceOrderByScopeForServer(state.workspaceOrderByServerAndProject, serverId),
equal,
);
return useMemo(() => {
if (!serverId || projects.length === 0) {
return EMPTY_WORKSPACE_STRUCTURE;
}
const orderedProjects = applyStoredOrdering({
items: projects.map((project) => {
const workspaceOrder =
workspaceOrderByScope[getWorkspaceOrderScopeKey(serverId, project.projectKey)] ??
EMPTY_WORKSPACE_KEYS;
const workspaceItems = project.workspaceKeys.map((workspaceId) => ({
workspaceId,
workspaceKey: `${serverId}:${workspaceId}`,
}));
return {
...project,
workspaceKeys: applyStoredOrdering({
items: workspaceItems,
storedOrder: workspaceOrder,
getKey: (workspace) => workspace.workspaceKey,
}).map((workspace) => workspace.workspaceId),
};
}),
storedOrder: projectOrder,
getKey: (project) => project.projectKey,
});
return { projects: orderedProjects };
}, [projectOrder, projects, serverId, workspaceOrderByScope]);
}
export function useWorkspaceKeys(serverId: string | null): string[] {
return useStoreWithEqualityFn(
useSessionStore,
(state) => {
if (!serverId) {
return EMPTY_WORKSPACE_KEYS;
}
const workspaces = state.sessions[serverId]?.workspaces;
return workspaces ? Array.from(workspaces.keys()) : EMPTY_WORKSPACE_KEYS;
},
equal,
);
}
export function useRecommendedProjectPaths(serverId: string | null): string[] {
return useStoreWithEqualityFn(
useSessionStore,
(state) => {
if (!serverId) {
return EMPTY_WORKSPACE_KEYS;
}
const workspaces = state.sessions[serverId]?.workspaces;
if (!workspaces) {
return EMPTY_WORKSPACE_KEYS;
}
return Array.from(workspaces.values())
.map((workspace) => workspace.projectRootPath)
.filter((path) => path.length > 0);
},
equal,
);
}
export function useHasWorkspaces(serverId: string | null): boolean {
return useStoreWithEqualityFn(
useSessionStore,
(state) => {
if (!serverId) {
return false;
}
return (state.sessions[serverId]?.workspaces?.size ?? 0) > 0;
},
Object.is,
);
}
export function useResolveWorkspaceIdByCwd(
serverId: string | null,
cwd: string | null | undefined,
): string | null {
return useStoreWithEqualityFn(
useSessionStore,
(state) => {
if (!serverId || !cwd) {
return null;
}
const workspaces = state.sessions[serverId]?.workspaces;
return resolveWorkspaceIdByExecutionDirectory({
workspaces: workspaces?.values(),
workspaceDirectory: cwd,
});
},
Object.is,
);
}
export function useWorkspaceStatusesForBadges(): DesktopBadgeWorkspaceStatus[] {
return useStoreWithEqualityFn(
useSessionStore,
(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;
},
equal,
);
}

View File

@@ -9,6 +9,7 @@ import {
useSessionStore,
type WorkspaceDescriptor,
} from "./session-store";
import { patchWorkspaceScripts } from "../contexts/session-workspace-scripts";
function createWorkspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
@@ -32,6 +33,23 @@ afterEach(() => {
useSessionStore.getState().clearSession("test-server");
});
function initializeTestSession(): void {
useSessionStore.getState().initializeSession("test-server", null as unknown as DaemonClient);
}
function getTestSessionReferences() {
const state = useSessionStore.getState();
const session = state.sessions["test-server"];
if (!session) {
throw new Error("test session is not initialized");
}
return {
sessions: state.sessions,
session,
workspaces: session.workspaces,
};
}
describe("normalizeWorkspaceDescriptor", () => {
it("normalizes workspace scripts and invalid activity timestamps", () => {
const scripts = [
@@ -138,6 +156,126 @@ describe("mergeWorkspaces", () => {
},
]);
});
it("preserves identity when merging content-equal workspace descriptors", () => {
const store = useSessionStore.getState();
initializeTestSession();
const workspace = createWorkspace({ id: "/repo/main" });
store.mergeWorkspaces("test-server", [workspace]);
const first = getTestSessionReferences();
store.mergeWorkspaces("test-server", [{ ...workspace, scripts: [...workspace.scripts] }]);
const second = getTestSessionReferences();
expect(second.sessions).toBe(first.sessions);
expect(second.session).toBe(first.session);
expect(second.workspaces).toBe(first.workspaces);
expect(second.workspaces.get("/repo/main")).toBe(first.workspaces.get("/repo/main"));
});
it("preserves unaffected workspace entry identity when one workspace changes", () => {
const store = useSessionStore.getState();
initializeTestSession();
const workspaceA = createWorkspace({ id: "/repo/a", name: "main" });
const workspaceB = createWorkspace({ id: "/repo/b", name: "feature" });
store.mergeWorkspaces("test-server", [workspaceA, workspaceB]);
const before = getTestSessionReferences();
const beforeA = before.workspaces.get("/repo/a");
const beforeB = before.workspaces.get("/repo/b");
store.mergeWorkspaces("test-server", [{ ...workspaceA, status: "running" }]);
const after = getTestSessionReferences();
expect(after.sessions).not.toBe(before.sessions);
expect(after.session).not.toBe(before.session);
expect(after.workspaces).not.toBe(before.workspaces);
expect(after.workspaces.get("/repo/a")).not.toBe(beforeA);
expect(after.workspaces.get("/repo/b")).toBe(beforeB);
});
});
describe("setWorkspaces", () => {
it("preserves identity when replacing workspaces with content-equal entries", () => {
const store = useSessionStore.getState();
initializeTestSession();
const workspace = createWorkspace({ id: "/repo/main" });
store.setWorkspaces("test-server", new Map([[workspace.id, workspace]]));
const before = getTestSessionReferences();
store.setWorkspaces(
"test-server",
new Map([[workspace.id, { ...workspace, scripts: [...workspace.scripts] }]]),
);
const after = getTestSessionReferences();
expect(after.sessions).toBe(before.sessions);
expect(after.session).toBe(before.session);
expect(after.workspaces).toBe(before.workspaces);
expect(after.workspaces.get(workspace.id)).toBe(before.workspaces.get(workspace.id));
});
it("stores replacement descriptors as-is when diff stat is cleared", () => {
const store = useSessionStore.getState();
initializeTestSession();
const workspace = createWorkspace({
id: "/repo/main",
diffStat: { additions: 2, deletions: 1 },
});
store.setWorkspaces("test-server", new Map([[workspace.id, workspace]]));
const before = getTestSessionReferences();
store.setWorkspaces("test-server", new Map([[workspace.id, { ...workspace, diffStat: null }]]));
const after = getTestSessionReferences();
expect(after.sessions).not.toBe(before.sessions);
expect(after.session).not.toBe(before.session);
expect(after.workspaces).not.toBe(before.workspaces);
expect(after.workspaces.get(workspace.id)?.diffStat).toBeNull();
});
});
describe("removeWorkspace", () => {
it("preserves identity when removing a missing workspace", () => {
const store = useSessionStore.getState();
initializeTestSession();
const workspace = createWorkspace({ id: "/repo/main" });
store.setWorkspaces("test-server", new Map([[workspace.id, workspace]]));
const before = getTestSessionReferences();
store.removeWorkspace("test-server", "/repo/missing");
const after = getTestSessionReferences();
expect(after.sessions).toBe(before.sessions);
expect(after.session).toBe(before.session);
expect(after.workspaces).toBe(before.workspaces);
});
});
describe("patchWorkspaceScripts", () => {
it("preserves workspace entry identity when scripts are content-equal", () => {
const script = {
scriptName: "web",
type: "service" as const,
hostname: "web.paseo.localhost",
port: 3000,
proxyUrl: "http://web.paseo.localhost:6767",
lifecycle: "running" as const,
health: "healthy" as const,
exitCode: null,
};
const workspace = createWorkspace({ id: "/repo/main", scripts: [script] });
const current = new Map([[workspace.id, workspace]]);
const next = patchWorkspaceScripts(current, {
workspaceId: workspace.id,
scripts: [{ ...script }],
});
expect(next).toBe(current);
expect(next.get(workspace.id)).toBe(workspace);
});
});
describe("mergeWorkspaceSnapshotWithExisting", () => {

View File

@@ -1,3 +1,4 @@
import equal from "fast-deep-equal";
import { create } from "zustand";
import { subscribeWithSelector } from "zustand/middleware";
import type { DaemonClient } from "@server/client/daemon-client";
@@ -159,6 +160,41 @@ export function mergeWorkspaceSnapshotWithExisting(input: {
};
}
function preserveWorkspaceDescriptorIdentity(
incoming: WorkspaceDescriptor,
existing?: WorkspaceDescriptor | null,
): WorkspaceDescriptor {
if (existing && equal(existing, incoming)) {
return existing;
}
return incoming;
}
function preserveWorkspaceMapIdentity(
existing: Map<string, WorkspaceDescriptor>,
incoming: Map<string, WorkspaceDescriptor>,
): Map<string, WorkspaceDescriptor> {
if (existing === incoming) {
return existing;
}
const next = new Map<string, WorkspaceDescriptor>();
let changed = existing.size !== incoming.size;
const existingEntries = existing.entries();
for (const [key, workspace] of incoming) {
const existingWorkspace = existing.get(key);
const nextWorkspace = preserveWorkspaceDescriptorIdentity(workspace, existingWorkspace);
next.set(key, nextWorkspace);
const existingEntry = existingEntries.next().value;
if (!existingEntry || existingEntry[0] !== key || existingEntry[1] !== nextWorkspace) {
changed = true;
}
}
return changed ? next : existing;
}
export type ExplorerEntryKind = "file" | "directory";
export type ExplorerFileKind = "text" | "image" | "binary";
export type ExplorerEncoding = "utf-8" | "base64" | "none";
@@ -938,14 +974,18 @@ export const useSessionStore = create<SessionStore>()(
}
const nextWorkspaces =
typeof workspaces === "function" ? workspaces(session.workspaces) : workspaces;
if (session.workspaces === nextWorkspaces) {
const preservedWorkspaces = preserveWorkspaceMapIdentity(
session.workspaces,
nextWorkspaces,
);
if (session.workspaces === preservedWorkspaces) {
return prev;
}
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, workspaces: nextWorkspaces },
[serverId]: { ...session, workspaces: preservedWorkspaces },
},
};
});
@@ -962,13 +1002,15 @@ export const useSessionStore = create<SessionStore>()(
let changed = false;
for (const workspace of nextEntries) {
const existing = next.get(workspace.id);
if (existing === workspace) {
const mergedWorkspace = mergeWorkspaceSnapshotWithExisting({
incoming: workspace,
existing,
});
const nextWorkspace = preserveWorkspaceDescriptorIdentity(mergedWorkspace, existing);
if (existing === nextWorkspace) {
continue;
}
next.set(
workspace.id,
mergeWorkspaceSnapshotWithExisting({ incoming: workspace, existing }),
);
next.set(workspace.id, nextWorkspace);
changed = true;
}
if (!changed) {

View File

@@ -31,9 +31,6 @@ function project(overrides: Partial<SidebarProjectEntry> = {}): SidebarProjectEn
projectName: "paseo",
projectKind: "git",
iconWorkingDir: "/repo",
statusBucket: "done",
activeCount: 0,
totalWorkspaces: 1,
workspaces: [workspace()],
...overrides,
};

View File

@@ -33,9 +33,6 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
projectName: projectKey,
projectKind: "git",
iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "",
statusBucket: "done",
activeCount: 0,
totalWorkspaces: workspaces.length,
workspaces,
};
}

View File

@@ -1,104 +0,0 @@
type WorkspaceLike = {
id: string;
projectId: string;
projectDisplayName?: string;
name: string;
status: string;
workspaceKind: string;
};
type SidebarWorkspaceLike = {
workspaceId: string;
name: string;
statusBucket: string;
workspaceKind: string;
};
type SidebarProjectLike = {
projectKey: string;
projectName: string;
statusBucket: string;
activeCount: number;
totalWorkspaces: number;
workspaces: SidebarWorkspaceLike[];
};
function countByValue(values: Iterable<string>): Record<string, number> {
const counts = new Map<string, number>();
for (const value of values) {
counts.set(value, (counts.get(value) ?? 0) + 1);
}
return Object.fromEntries(counts);
}
export function summarizeWorkspaceCollection(
workspaces: Iterable<WorkspaceLike> | null | undefined,
): {
count: number;
projectIds: string[];
statusCounts: Record<string, number>;
workspaces: Array<{
id: string;
projectId: string;
projectDisplayName: string | null;
name: string;
status: string;
workspaceKind: string;
}>;
} {
const entries = Array.from(workspaces ?? [], (workspace) => ({
id: workspace.id,
projectId: workspace.projectId,
projectDisplayName: workspace.projectDisplayName?.trim() || null,
name: workspace.name,
status: workspace.status,
workspaceKind: workspace.workspaceKind,
}));
return {
count: entries.length,
projectIds: [...new Set(entries.map((workspace) => workspace.projectId))],
statusCounts: countByValue(entries.map((workspace) => workspace.status)),
workspaces: entries,
};
}
export function summarizeSidebarProjects(
projects: Iterable<SidebarProjectLike> | null | undefined,
): {
count: number;
projectKeys: string[];
projects: Array<{
projectKey: string;
projectName: string;
statusBucket: string;
activeCount: number;
totalWorkspaces: number;
workspaces: Array<{
workspaceId: string;
name: string;
statusBucket: string;
workspaceKind: string;
}>;
}>;
} {
const entries = Array.from(projects ?? [], (project) => ({
projectKey: project.projectKey,
projectName: project.projectName,
statusBucket: project.statusBucket,
activeCount: project.activeCount,
totalWorkspaces: project.totalWorkspaces,
workspaces: project.workspaces.map((workspace) => ({
workspaceId: workspace.workspaceId,
name: workspace.name,
statusBucket: workspace.statusBucket,
workspaceKind: workspace.workspaceKind,
})),
}));
return {
count: entries.length,
projectKeys: entries.map((project) => project.projectKey),
projects: entries,
};
}