Restore project grouping across hosts (#2565)

* fix(projects): restore cross-host grouping

Random host-local project IDs replaced the remote-derived value that had also served as grouping identity. Persist a separate opaque group key and let normal boot reconciliation backfill older records without a migration.

* fix(projects): keep ambiguous clones separate

A shared remote can identify a project across hosts, but it cannot distinguish two independent clones on one host. Treat those keys as ambiguous so host-local project IDs remain separate.

* fix(projects): preserve host-local project identity

* fix(projects): strengthen grouping identity

* test(app): update project host shape contract

* fix(projects): preserve selected host identity

* fix(projects): close grouped routing gaps

* fix(projects): stabilize legacy subproject grouping

* fix(projects): preserve selected project identity

* fix(projects): isolate legacy host identity

* fix(projects): preserve symlinked bootstrap roots

* fix(projects): reconcile grouped custom names

* fix(projects): stabilize settings and path identity

* fix(projects): normalize default remote ports

* fix(projects): harden host and remote identity

* fix(projects): preserve host-scoped actions

* fix(projects): preserve remote path semantics

* fix(projects): model SCP remote semantics

* fix(projects): keep settings on routed host

* fix(projects): preserve SSH URL path semantics

* fix(projects): separate identity from presentation

* test(projects): use grouped identity in UI specs

* fix(projects): harden grouped project routing

* test(projects): expect framed settings route

* fix(projects): preserve exact placement identity

* fix(projects): harden grouping identity and routing

* fix(projects): restore project key as grouping identity

Random host-local project IDs had replaced the persisted cross-host value carried by projectKey. Keep projectId for routing while reconciliation owns and persists projectKey for grouping.

* fix(projects): prevent project key collisions

Preserve platform path semantics and generic remote transports in cross-host keys, and compare legacy checkout roots using filesystem equivalence before materializing ownership.

* test(projects): assert host-scoped project keys

* fix(projects): preserve project key compatibility

Resolve legacy workspace project references at the daemon boundary while keeping projectKey as the cross-host identity. Complete generic SSH normalization without collapsing distinct repositories.

* fix(projects): harden project reference identity

* fix(projects): retain archived reference errors

* fix(projects): scope legacy paths to hosts

* test(app): model persisted project keys

* fix(projects): retain absolute scp users

* fix(projects): resolve legacy mutation aliases

* fix(projects): preserve conservative identity boundaries

* fix(projects): preserve legacy placement identity

* test(projects): assert legacy placement identity

* test(projects): expect canonical Windows keys

* fix(projects): separate bootstrap identity fields

* fix(projects): scope legacy paths by host

* fix(projects): stabilize opaque project identity

* fix(projects): preserve remaining opaque identities

* fix(projects): classify forward-slash UNC roots

* fix(projects): preserve opaque selection keys

* fix(projects): frame host-local project keys

* fix(projects): canonicalize framed identities

* test(projects): isolate repeated remote fixtures

* fix(projects): keep project key as UI identity

* fix(projects): reconcile hydrated project references

* fix(projects): preserve archived project identity

* refactor(projects): group projects from project records, not workspaces

Cross-host grouping had been built by hanging a projectKey off every
workspace descriptor. That scoped the key to the wrong entity: the app
then had to reconstruct project identity per workspace in six modules,
which forced projectKey fallbacks into RPC arguments and a server-side
resolver that turned grouping keys back into project ids.

A workspace has a projectId. The key belongs to the project, and
grouping is a display concern, so the daemon now publishes projects
over project.list plus the existing project.update channel and the app
groups them in one place. Projects are addressed by host-local
projectId everywhere, including the settings route.

Projects persisted without a key are backfilled by ordinary boot
reconciliation, with no migration. Old daemons advertise no projectList
capability and simply do not group.

* test(projects): seed projects alongside workspaces in fixtures

Sidebar fixtures seeded workspaces without their projects, which the
daemon cannot produce now that projects arrive on their own channel, so
nothing rendered. Adds a seeding helper that supplies both.

Also drops the provisioning test for accepting a project key where a
project id belongs; that resolver was removed deliberately.

* fix(projects): keep derived project identity stable

Four gaps left by shrinking the key derivation:

Local keys used a bare path resolve, so one directory reached by a
different spelling produced a different key and reset grouped identity.
Windows CI caught this with the 8.3 and long-name forms of one temp
path. Canonicalize through the shared realpath-aware normalization.

Subproject display names lost their selected-path input, so every
subproject of a monorepo rendered under the same remote-derived name.

Reconciliation only ever backfilled a missing key, so a project whose
remote changed stayed grouped with hosts on the old remote.

Project settings kept a disconnected host selected and rendered a dead
end with no way back to an editable host.

* fix(projects): read scheme remotes as URLs, not scp form

An `ssh://user@host:22/org/repo` remote matched the scp-form pattern
first, because `[^@]+` happily eats `ssh://git`, so the port landed in
the path and the project keyed as `remote:host/22/org/repo`. Two
spellings of one remote therefore never grouped.

Try scp form only when there is no scheme, and drop protocol-default
ports so an explicitly written `:22` matches the same remote without it.
This commit is contained in:
Mohamed Boudra
2026-07-29 23:36:31 +02:00
committed by GitHub
parent 9e5accee3c
commit 20e46e3e3f
113 changed files with 3073 additions and 2038 deletions
+7 -10
View File
@@ -4,10 +4,7 @@ import type {
ProjectAddResponse,
WorkspaceProjectDescriptorPayload,
} from "@getpaseo/protocol/messages";
import {
normalizeEmptyProjectDescriptor as normalizeProjectWithoutWorkspacesDescriptor,
type EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor,
} from "@/stores/session-store";
import { normalizeProjectDescriptor, type ProjectDescriptor } from "@/stores/session-store";
type OpenProjectPayload = ProjectAddResponse["payload"];
type OpenProjectErrorCode = NonNullable<OpenProjectPayload["errorCode"]>;
@@ -47,28 +44,28 @@ export interface OpenProjectDirectlyInput {
isConnected: boolean;
canAddProject: boolean;
client: Pick<DaemonClient, "addProject"> | null;
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
}
interface ProjectRegistrationCallbacks {
serverId: string;
isConnected: boolean;
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
}
export interface RegisterProjectDescriptorInput {
serverId: string;
project: WorkspaceProjectDescriptorPayload;
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
}
export function registerProjectDescriptor(input: RegisterProjectDescriptorInput): boolean {
const serverId = input.serverId.trim();
if (!serverId) return false;
input.addEmptyProject(serverId, normalizeProjectWithoutWorkspacesDescriptor(input.project));
input.upsertProject(serverId, normalizeProjectDescriptor(input.project));
input.setHasHydratedWorkspaces(serverId, true);
return true;
}
@@ -109,7 +106,7 @@ export async function openProjectDirectly(
const registered = registerProjectDescriptor({
serverId: normalizedServerId,
project: payload.project,
addEmptyProject: input.addEmptyProject,
upsertProject: input.upsertProject,
setHasHydratedWorkspaces: input.setHasHydratedWorkspaces,
});
return registered
@@ -145,7 +142,7 @@ export async function cloneGithubProjectDirectly(
const registered = registerProjectDescriptor({
serverId: normalizedServerId,
project: payload.project,
addEmptyProject: input.addEmptyProject,
upsertProject: input.upsertProject,
setHasHydratedWorkspaces: input.setHasHydratedWorkspaces,
});
return registered
@@ -78,20 +78,25 @@ function project(input: {
projectKind?: WorkspaceStructureProject["projectKind"];
iconWorkingDir?: string;
workspaceKeys: string[];
hosts?: WorkspaceStructureProject["hosts"];
hosts?: Array<
Omit<WorkspaceStructureProject["hosts"][number], "projectId"> & { projectId?: string }
>;
}): WorkspaceStructureProject {
return {
projectKey: input.projectKey,
projectName: input.projectName ?? input.projectKey,
projectKind: input.projectKind ?? "git",
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
hosts: input.hosts ?? [
{
serverId: "srv",
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
canCreateWorktree: true,
},
],
hosts: Array.from(
input.hosts ?? [
{
serverId: "srv",
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
canCreateWorktree: true,
},
],
(host) => Object.assign({}, host, { projectId: host.projectId ?? input.projectKey }),
),
workspaceKeys: input.workspaceKeys,
};
}
@@ -324,8 +329,18 @@ describe("shared sidebar workspace model", () => {
expect.objectContaining({
projectKey: "getpaseo/paseo",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
{ serverId: "host-b", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
{
serverId: "host-a",
projectId: "getpaseo/paseo",
iconWorkingDir: "/repo/getpaseo/paseo",
canCreateWorktree: true,
},
{
serverId: "host-b",
projectId: "getpaseo/paseo",
iconWorkingDir: "/repo/getpaseo/paseo",
canCreateWorktree: true,
},
],
workspaces: [
expect.objectContaining({
@@ -401,6 +416,35 @@ describe("shared sidebar workspace model", () => {
expect(nextEntries.get("srv:one")).toBe(previousEntries.get("srv:one"));
expect(nextEntries.get("srv:two")).not.toBe(previousEntries.get("srv:two"));
});
it("keeps a structurally disambiguated project key in status entries", () => {
const projectKey = "host:srv:project:prj_a";
const model = buildSidebarWorkspacePlacementModel({
projects: [project({ projectKey, projectName: "Clone A", workspaceKeys: ["srv:clone-a"] })],
});
const entries = buildSidebarWorkspaceEntries({
placements: model.workspaces,
sessions: [
{
serverId: "srv",
workspaceAgentActivity: new Map(),
workspaces: new Map([
[
"clone-a",
workspace({
id: "clone-a",
name: "main",
projectId: "prj_a",
projectDisplayName: "acme/app",
}),
],
]),
},
],
});
expect(entries.get("srv:clone-a")?.projectKey).toBe(projectKey);
});
});
describe("shouldShowSidebarHostLabels", () => {
@@ -141,10 +141,11 @@ function normalizeCurrentBranch(currentBranch: string | null | undefined): strin
export function createSidebarWorkspaceEntry(input: {
serverId: string;
workspace: WorkspaceDescriptor;
projectKey?: string;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
}): SidebarWorkspaceEntry {
const projectKey = input.workspace.project?.projectKey ?? input.workspace.projectId;
const projectKey = input.projectKey ?? input.workspace.projectId;
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
return {
workspaceKey: `${input.serverId}:${input.workspace.id}`,
@@ -323,6 +324,7 @@ export function buildSidebarWorkspaceEntries(input: {
const entry = createSidebarWorkspaceEntry({
serverId: placement.serverId,
workspace,
projectKey: placement.projectKey,
pendingCreateAttempts: input.pendingCreateAttempts,
workspaceAgentActivity: session.workspaceAgentActivity,
});
@@ -4,7 +4,7 @@ import {
getOpenProjectFailureReason,
openProjectDirectly,
} from "@/hooks/open-project";
import type { EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor } from "@/stores/session-store";
import type { ProjectDescriptor } from "@/stores/session-store";
const SERVER_ID = "server-1";
const PROJECT_PATH = "/repo/project";
@@ -20,7 +20,7 @@ function buildProjectPayload() {
interface RecordedProject {
serverId: string;
project: ProjectWithoutWorkspacesDescriptor;
project: ProjectDescriptor;
}
interface RecordedHydrated {
@@ -40,7 +40,7 @@ function createFakeSession() {
return {
projects,
hydrated,
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => {
upsertProject: (serverId: string, project: ProjectDescriptor) => {
projects.push({ serverId, project });
},
setHasHydratedWorkspaces: (serverId: string, value: boolean) => {
@@ -83,7 +83,7 @@ describe("openProjectDirectly", () => {
project: projectPayload,
}),
},
addEmptyProject: session.addEmptyProject,
upsertProject: session.upsertProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
});
@@ -93,6 +93,7 @@ describe("openProjectDirectly", () => {
serverId: SERVER_ID,
project: {
projectId: "project-1",
projectKey: null,
projectDisplayName: "project",
projectCustomName: null,
projectKind: "git",
@@ -117,7 +118,7 @@ describe("openProjectDirectly", () => {
project: buildProjectPayload(),
}),
},
addEmptyProject: session.addEmptyProject,
upsertProject: session.upsertProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
});
@@ -146,7 +147,7 @@ describe("openProjectDirectly", () => {
project: null,
}),
},
addEmptyProject: session.addEmptyProject,
upsertProject: session.upsertProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
});
@@ -173,7 +174,7 @@ describe("cloneGithubProjectDirectly", () => {
cloneProtocol: "https",
isConnected: true,
client: github,
addEmptyProject: session.addEmptyProject,
upsertProject: session.upsertProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
});
@@ -191,6 +192,7 @@ describe("cloneGithubProjectDirectly", () => {
project: {
...projectPayload,
projectCustomName: null,
projectKey: null,
},
},
]);
@@ -208,7 +210,7 @@ describe("cloneGithubProjectDirectly", () => {
cloneProtocol: "https",
isConnected: true,
client: github,
addEmptyProject: session.addEmptyProject,
upsertProject: session.upsertProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
});
+6 -6
View File
@@ -20,7 +20,7 @@ export function useOpenProject(
state.sessions[normalizedServerId]?.serverInfo?.features?.stableProjectIdentity === true
: false,
);
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
const upsertProject = useSessionStore((state) => state.upsertProject);
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
return useCallback(
@@ -31,13 +31,13 @@ export function useOpenProject(
isConnected,
canAddProject,
client,
addEmptyProject,
upsertProject,
setHasHydratedWorkspaces,
});
return result;
},
[
addEmptyProject,
upsertProject,
canAddProject,
client,
isConnected,
@@ -57,7 +57,7 @@ export function useCloneGithubProject(
const normalizedServerId = serverId?.trim() ?? "";
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
const upsertProject = useSessionStore((state) => state.upsertProject);
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
return useCallback(
@@ -69,10 +69,10 @@ export function useCloneGithubProject(
...(cloneProtocol ? { cloneProtocol } : {}),
isConnected,
client,
addEmptyProject,
upsertProject,
setHasHydratedWorkspaces,
});
},
[addEmptyProject, client, isConnected, normalizedServerId, setHasHydratedWorkspaces],
[client, isConnected, normalizedServerId, setHasHydratedWorkspaces, upsertProject],
);
}
+40 -192
View File
@@ -1,242 +1,90 @@
import { describe, expect, it } from "vitest";
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import type { ProjectDescriptor } from "@/stores/session-store";
import {
deriveProjectsFromReplica,
type ProjectHostReplica,
type ProjectHostRuntimeState,
} from "@/hooks/use-projects";
function runtimeState(input: {
serverId: string;
isOnline: boolean;
isLoading?: boolean;
isFetching?: boolean;
error?: string | null;
}): ProjectHostRuntimeState {
function project(id: string, name: string, root: string): ProjectDescriptor {
return {
projectId: id,
projectKey: `remote:github.com/${name}`,
projectDisplayName: name,
projectCustomName: null,
projectRootPath: root,
projectKind: "git",
};
}
function runtimeState(input: Partial<ProjectHostRuntimeState> & { serverId: string }) {
return {
serverId: input.serverId,
isOnline: input.isOnline,
isOnline: input.isOnline ?? true,
isLoading: input.isLoading ?? false,
isFetching: input.isFetching ?? false,
error: input.error ?? null,
};
}
function workspace(input: {
id: string;
projectKey: string;
projectName: string;
cwd: string;
remoteUrl: string | null;
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectKey,
projectDisplayName: input.projectName,
projectCustomName: null,
projectRootPath: input.cwd,
workspaceDirectory: input.cwd,
projectKind: "git",
workspaceKind: "local_checkout",
name: input.id,
title: null,
archivingAt: null,
status: "done",
statusEnteredAt: null,
diffStat: null,
scripts: [],
gitRuntime: {
currentBranch: "main",
remoteUrl: input.remoteUrl,
isPaseoOwnedWorktree: false,
isDirty: false,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
},
githubRuntime: null,
project: {
projectKey: input.projectKey,
projectName: input.projectName,
checkout: {
cwd: input.cwd,
isGit: true,
currentBranch: "main",
remoteUrl: input.remoteUrl,
worktreeRoot: input.cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
};
}
function emptyProject(input: {
projectKey: string;
projectName: string;
cwd: string;
}): EmptyProjectDescriptor {
return {
projectId: input.projectKey,
projectDisplayName: input.projectName,
projectCustomName: null,
projectRootPath: input.cwd,
projectKind: "git",
};
}
describe("deriveProjectsFromReplica", () => {
it("aggregates store workspaces and empty projects sorted by display name", () => {
it("derives projects from the project descriptor replica", () => {
const replicas: ProjectHostReplica[] = [
{
serverId: "local",
serverName: "Local",
workspaces: [
workspace({
id: "z-main",
projectKey: "remote:github.com/acme/zeta",
projectName: "acme/zeta",
cwd: "/repo/zeta",
remoteUrl: "https://github.com/acme/zeta.git",
}),
],
emptyProjects: [],
workspaces: [],
projects: [project("prj_zeta", "acme/zeta", "/repo/zeta")],
},
{
serverId: "laptop",
serverName: "Laptop",
workspaces: [],
emptyProjects: [
emptyProject({
projectKey: "remote:github.com/acme/alpha",
projectName: "acme/alpha",
cwd: "/repo/alpha",
}),
],
projects: [project("prj_alpha", "acme/alpha", "/repo/alpha")],
},
];
const result = deriveProjectsFromReplica({
replicas,
runtimeStates: [
runtimeState({ serverId: "local", isOnline: true }),
runtimeState({ serverId: "local" }),
runtimeState({ serverId: "laptop", isOnline: false }),
],
});
expect(result.projects.map((project) => project.projectName)).toEqual([
"acme/alpha",
"acme/zeta",
]);
expect(result.projects[0]?.hosts).toEqual([
expect.objectContaining({
serverId: "laptop",
isOnline: false,
repoRoot: "/repo/alpha",
workspaceCount: 0,
}),
]);
expect(result.hostErrors).toEqual([]);
});
it("maps runtime directory status into loading and host errors", () => {
const replicas: ProjectHostReplica[] = [
{
serverId: "local",
serverName: "Local",
workspaces: [
workspace({
id: "main",
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
],
emptyProjects: [],
},
{
serverId: "laptop",
serverName: "Laptop",
workspaces: [],
emptyProjects: [],
},
];
const result = deriveProjectsFromReplica({
replicas,
runtimeStates: [
runtimeState({ serverId: "local", isOnline: true, isFetching: true }),
runtimeState({
serverId: "laptop",
isOnline: true,
isLoading: true,
error: "laptop unavailable",
}),
],
expect(result.projects.map((item) => item.projectName)).toEqual(["acme/alpha", "acme/zeta"]);
expect(result.projects[0]?.hosts[0]).toMatchObject({
serverId: "laptop",
projectId: "prj_alpha",
repoRoot: "/repo/alpha",
workspaceCount: 0,
});
expect(result.projects).toEqual([
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
]);
expect(result.hostErrors).toEqual([
{
serverId: "laptop",
serverName: "Laptop",
message: "laptop unavailable",
},
]);
expect(result.isLoading).toBe(true);
expect(result.isFetching).toBe(true);
});
it("returns only the stable public project and host entry shapes", () => {
it("preserves runtime loading and error state", () => {
const result = deriveProjectsFromReplica({
replicas: [
{
serverId: "local",
serverName: "Local",
workspaces: [
workspace({
id: "main",
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
],
emptyProjects: [],
workspaces: [],
projects: [project("prj_app", "acme/app", "/repo/app")],
},
],
runtimeStates: [runtimeState({ serverId: "local", isOnline: true })],
runtimeStates: [
runtimeState({
serverId: "local",
isLoading: true,
isFetching: true,
error: "unavailable",
}),
],
});
expect(Object.keys(result.projects[0] ?? {}).sort()).toEqual([
"githubUrl",
"hostCount",
"hosts",
"onlineHostCount",
"projectCustomName",
"projectKey",
"projectName",
"totalWorkspaceCount",
]);
expect(Object.keys(result.projects[0]?.hosts[0] ?? {}).sort()).toEqual([
"gitRuntime",
"githubRuntime",
"isOnline",
"repoRoot",
"serverId",
"serverName",
"workspaceCount",
"workspaces",
]);
expect(Object.keys(result.projects[0]?.hosts[0]?.workspaces[0] ?? {}).sort()).toEqual([
"currentBranch",
"id",
"name",
"status",
"workspaceKind",
expect(result.isLoading).toBe(true);
expect(result.isFetching).toBe(true);
expect(result.hostErrors).toEqual([
{ serverId: "local", serverName: "Local", message: "unavailable" },
]);
});
});
+4 -4
View File
@@ -9,7 +9,7 @@ import {
} from "@/runtime/host-runtime";
import {
useSessionStore,
type EmptyProjectDescriptor,
type ProjectDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
@@ -24,7 +24,7 @@ export interface ProjectHostReplica {
serverId: string;
serverName: string;
workspaces: WorkspaceDescriptor[];
emptyProjects: EmptyProjectDescriptor[];
projects: ProjectDescriptor[];
}
export interface ProjectHostRuntimeState {
@@ -87,7 +87,7 @@ function selectProjectHostReplicas(
serverId: host.serverId,
serverName: host.label,
workspaces: Array.from(session?.workspaces.values() ?? []),
emptyProjects: Array.from(session?.emptyProjects.values() ?? []),
projects: Array.from(session?.projects.values() ?? []),
};
});
}
@@ -106,7 +106,7 @@ export function deriveProjectsFromReplica(input: {
serverName: replica.serverName,
isOnline: runtimeState?.isOnline ?? false,
workspaces: replica.workspaces,
emptyProjects: replica.emptyProjects,
projects: replica.projects,
};
});
const hostErrors = input.replicas.flatMap((replica) => {
@@ -1,12 +1,9 @@
import { useCallback, useEffect, useMemo } from "react";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useHydratedWorkspaceServerIds } from "@/stores/session-store-hooks";
import { useHostProjects } from "@/projects/host-projects";
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts";
import {
buildSidebarWorkspacePlacementModel,
computeSidebarOrderUpdates,
@@ -128,35 +125,12 @@ export function useSidebarWorkspacesList(options?: {
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
if (snapshot?.connectionStatus !== "online") continue;
const client = runtime.getClient(serverId);
if (!client) continue;
void (async () => {
const next = new Map<string, WorkspaceDescriptor>();
try {
const { workspaces, emptyProjects } = await fetchAllWorkspaceDescriptors({
client,
sort: [{ key: "activity_at", direction: "desc" }],
});
for (const workspace of workspaces) {
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
continue;
}
next.set(workspace.id, workspace);
}
const store = useSessionStore.getState();
store.setWorkspaces(serverId, next);
// Keep parents with no workspaces yet, so a manual refresh doesn't drop
// a freshly-added project from the sidebar.
store.setEmptyProjects(serverId, emptyProjects);
store.setHasHydratedWorkspaces(serverId, true);
} catch (error) {
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
serverId,
error,
});
// ignore explicit refresh failures; hook keeps existing data
}
})();
void runtime.refreshDirectories(serverId).catch((error) => {
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
serverId,
error,
});
});
}
}, [isActive, runtime, serverIds]);