Add a global New workspace picker (#1324)

* Add global New workspace project picker

* Clean up New workspace picker state
This commit is contained in:
Mohamed Boudra
2026-06-04 15:16:41 +08:00
committed by GitHub
parent 0b62e3bd7f
commit 38f8179bc1
28 changed files with 1392 additions and 424 deletions
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceStructureProject } from "@/stores/session-store-hooks";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
import {
appendMissingOrderKeys,
applyStoredOrdering,
@@ -1,6 +1,10 @@
import type { PrHint } from "@/git/use-pr-status-query";
import {
canCreateWorktreeForProjectKind,
type HostProjectListItem,
} from "@/projects/host-project-model";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/stores/session-store-hooks";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
@@ -32,12 +36,13 @@ export interface SidebarProjectEntry {
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
canCreateWorktree: boolean;
workspaces: SidebarWorkspaceEntry[];
}
function createStructuralWorkspaceEntry(input: {
serverId: string;
project: WorkspaceStructureProject;
project: HostProjectListItem;
workspaceId: string;
}): SidebarWorkspaceEntry {
return {
@@ -65,6 +70,22 @@ function createStructuralWorkspaceEntry(input: {
export function buildSidebarProjectsFromStructure(input: {
serverId: string;
projects: WorkspaceStructureProject[];
}): SidebarProjectEntry[] {
return buildSidebarProjectsFromHostProjects({
projects: input.projects.map((project) => ({
serverId: input.serverId,
projectKey: project.projectKey,
projectName: project.projectName,
projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir,
workspaceKeys: project.workspaceKeys,
canCreateWorktree: canCreateWorktreeForProjectKind(project.projectKind),
})),
});
}
export function buildSidebarProjectsFromHostProjects(input: {
projects: readonly HostProjectListItem[];
}): SidebarProjectEntry[] {
if (input.projects.length === 0) {
return EMPTY_PROJECTS;
@@ -75,9 +96,10 @@ export function buildSidebarProjectsFromStructure(input: {
projectName: project.projectName,
projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir,
canCreateWorktree: project.canCreateWorktree,
workspaces: project.workspaceKeys.map((workspaceId) =>
createStructuralWorkspaceEntry({
serverId: input.serverId,
serverId: project.serverId,
project,
workspaceId,
}),
+1 -1
View File
@@ -6,7 +6,7 @@ import {
type ProjectsHostInput,
type ProjectsRuntime,
type ProjectsRuntimeSnapshot,
} from "./use-projects";
} from "@/projects/aggregated-projects";
type FetchWorkspaces = DaemonClient["fetchWorkspaces"];
type FetchWorkspacesResult = Awaited<ReturnType<FetchWorkspaces>>;
+12 -119
View File
@@ -1,18 +1,21 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
import type { ProjectSummary } from "@/utils/projects";
import {
fetchAggregatedProjects,
type ProjectHostError,
type ProjectsHostInput,
} from "@/projects/aggregated-projects";
export type {
ProjectHostError,
ProjectsHostInput,
ProjectsRuntime,
} from "@/projects/aggregated-projects";
export const projectsQueryKey = ["projects"] as const;
export interface ProjectHostError {
serverId: string;
serverName: string;
message: string;
}
export interface UseProjectsResult {
projects: ProjectSummary[];
hostErrors: ProjectHostError[];
@@ -21,116 +24,6 @@ export interface UseProjectsResult {
refetch: () => void;
}
export interface ProjectsRuntimeSnapshot {
connectionStatus: string;
}
export interface ProjectsRuntime {
getClient(serverId: string): Pick<DaemonClient, "fetchWorkspaces"> | null;
getSnapshot(serverId: string): ProjectsRuntimeSnapshot | null | undefined;
}
export interface ProjectsHostInput {
serverId: string;
serverName: string;
}
export interface FetchAggregatedProjectsInput {
hosts: ProjectsHostInput[];
runtime: ProjectsRuntime;
}
export interface FetchAggregatedProjectsResult {
projects: ProjectSummary[];
hostErrors: ProjectHostError[];
}
interface HostWorkspacesResult {
host: ProjectHost;
error: ProjectHostError | null;
}
function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function fetchAllWorkspaceDescriptors(
client: Pick<DaemonClient, "fetchWorkspaces">,
): Promise<WorkspaceDescriptor[]> {
const entries: WorkspaceDescriptor[] = [];
let cursor: string | null = null;
while (true) {
const payload = await client.fetchWorkspaces({
sort: [{ key: "name", direction: "asc" }],
page: cursor ? { limit: 200, cursor } : { limit: 200 },
});
entries.push(...payload.entries.map((entry) => normalizeWorkspaceDescriptor(entry)));
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
break;
}
cursor = payload.pageInfo.nextCursor;
}
return entries;
}
export async function fetchAggregatedProjects(
input: FetchAggregatedProjectsInput,
): Promise<FetchAggregatedProjectsResult> {
const results = await Promise.all(
input.hosts.map(async (host): Promise<HostWorkspacesResult> => {
const snapshot = input.runtime.getSnapshot(host.serverId);
const isOnline = snapshot?.connectionStatus === "online";
const client = input.runtime.getClient(host.serverId);
if (!client || !isOnline) {
return {
host: {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
workspaces: [],
},
error: null,
};
}
try {
return {
host: {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
workspaces: await fetchAllWorkspaceDescriptors(client),
},
error: null,
};
} catch (error) {
return {
host: {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
workspaces: [],
},
error: {
serverId: host.serverId,
serverName: host.serverName,
message: toErrorMessage(error),
},
};
}
}),
);
const hostErrors = results.flatMap((result) => (result.error ? [result.error] : []));
return {
...buildProjects({ hosts: results.map((result) => result.host) }),
hostErrors,
};
}
export function useProjects(): UseProjectsResult {
const hosts = useHosts();
const runtime = getHostRuntimeStore();
@@ -1,17 +1,13 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import {
normalizeWorkspaceDescriptor,
useSessionStore,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { selectPrHintFromStatus } from "@/git/use-pr-status-query";
import { useWorkspaceStructure } from "@/stores/session-store-hooks";
import { useHostProjects } from "@/projects/host-projects";
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts";
import {
buildSidebarProjectsFromStructure,
buildSidebarProjectsFromHostProjects,
computeSidebarOrderUpdates,
deriveSidebarLoadingState,
type SidebarProjectEntry,
@@ -21,6 +17,7 @@ import {
export {
appendMissingOrderKeys,
applyStoredOrdering,
buildSidebarProjectsFromHostProjects,
buildSidebarProjectsFromStructure,
computeSidebarOrderUpdates,
deriveSidebarLoadingState,
@@ -68,10 +65,6 @@ export interface SidebarWorkspacesListResult {
refreshAll: () => void;
}
function toWorkspaceDescriptor(payload: WorkspaceDescriptorPayload): WorkspaceDescriptor {
return normalizeWorkspaceDescriptor(payload);
}
export function useSidebarWorkspacesList(options?: {
serverId?: string | null;
enabled?: boolean;
@@ -89,7 +82,7 @@ export function useSidebarWorkspacesList(options?: {
const hasHydratedWorkspaces = useSessionStore((state) =>
isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
);
const workspaceStructure = useWorkspaceStructure(isActive ? serverId : null);
const hostProjects = useHostProjects(isActive ? serverId : null);
const connectionStatus = useSyncExternalStore(
(onStoreChange) =>
@@ -111,14 +104,13 @@ export function useSidebarWorkspacesList(options?: {
);
const projects = useMemo(() => {
if (!serverId || workspaceStructure.projects.length === 0) {
if (!serverId || hostProjects.length === 0) {
return EMPTY_PROJECTS;
}
return buildSidebarProjectsFromStructure({
serverId,
projects: workspaceStructure.projects,
return buildSidebarProjectsFromHostProjects({
projects: hostProjects,
});
}, [serverId, workspaceStructure]);
}, [hostProjects, serverId]);
useEffect(() => {
if (!serverId) {
@@ -156,24 +148,16 @@ export function useSidebarWorkspacesList(options?: {
}
void (async () => {
const next = new Map<string, WorkspaceDescriptor>();
let cursor: string | null = null;
try {
while (true) {
const payload = await client.fetchWorkspaces({
sort: [{ key: "activity_at", direction: "desc" }],
page: cursor ? { limit: 200, cursor } : { limit: 200 },
});
for (const entry of payload.entries) {
const workspace = toWorkspaceDescriptor(entry);
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
continue;
}
next.set(workspace.id, workspace);
const workspaces = await fetchAllWorkspaceDescriptors({
client,
sort: [{ key: "activity_at", direction: "desc" }],
});
for (const workspace of workspaces) {
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
continue;
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
break;
}
cursor = payload.pageInfo.nextCursor;
next.set(workspace.id, workspace);
}
const store = useSessionStore.getState();
store.setWorkspaces(serverId, next);
@@ -181,7 +165,6 @@ export function useSidebarWorkspacesList(options?: {
} catch (error) {
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
serverId,
cursor,
error,
});
// ignore explicit refresh failures; hook keeps existing data