The host name previously appeared only in project grouping mode, and only for projects shared across hosts. It now shows on every workspace row in both project and status grouping modes whenever the sidebar spans more than one host, so you can always tell which machine a workspace lives on. Status rows read "Project · Host". Single-host setups are unchanged.
215 lines
7.5 KiB
TypeScript
215 lines
7.5 KiB
TypeScript
import { useCallback, useEffect, useMemo } from "react";
|
|
import equal from "fast-deep-equal";
|
|
import { shallow } from "zustand/shallow";
|
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
|
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
|
import { selectWorkspace, workspaceEqualityFns } from "@/stores/session-store-hooks/selectors";
|
|
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,
|
|
createSidebarWorkspaceEntry,
|
|
deriveSidebarLoadingState,
|
|
type SidebarProjectEntry,
|
|
type SidebarWorkspaceEntry,
|
|
type SidebarWorkspacePlacement,
|
|
} from "./sidebar-workspaces-view-model";
|
|
|
|
export {
|
|
appendMissingOrderKeys,
|
|
applyStoredOrdering,
|
|
buildSidebarProjectsFromHostProjects,
|
|
buildSidebarProjectsFromStructure,
|
|
buildSidebarStatusWorkspacePlacements,
|
|
buildSidebarWorkspacePlacementModel,
|
|
computeSidebarOrderUpdates,
|
|
createSidebarWorkspaceEntry,
|
|
deriveSidebarLoadingState,
|
|
shouldShowSidebarHostLabels,
|
|
type SidebarLoadingState,
|
|
type SidebarOrderUpdates,
|
|
type SidebarStatusWorkspacePlacement,
|
|
type SidebarWorkspacePlacement,
|
|
type SidebarWorkspacePlacementModel,
|
|
type SidebarProjectEntry,
|
|
type SidebarStateBucket,
|
|
type SidebarWorkspaceEntry,
|
|
} from "./sidebar-workspaces-view-model";
|
|
|
|
export function useSidebarWorkspaceEntry(
|
|
serverId: string | null,
|
|
workspaceId: string | null,
|
|
): SidebarWorkspaceEntry | null {
|
|
// Deep-compare so that adding/removing unrelated pending creates doesn't re-render this row.
|
|
const pendingCreateAttempts = useStoreWithEqualityFn(
|
|
useCreateFlowStore,
|
|
(state) => state.pendingByDraftId,
|
|
workspaceEqualityFns.deep,
|
|
);
|
|
|
|
// Single subscription: reads workspace + agents together, computes the full entry, and
|
|
// deep-compares the output. Agents-Map identity churn (setAgents replaces the Map on every
|
|
// status transition) never causes a React re-render unless the derived entry actually changes.
|
|
return useStoreWithEqualityFn(
|
|
useSessionStore,
|
|
(state) => {
|
|
const workspace = selectWorkspace(state, serverId, workspaceId);
|
|
if (!workspace) return null;
|
|
const agents = serverId ? state.sessions[serverId]?.agents : undefined;
|
|
return createSidebarWorkspaceEntry({
|
|
serverId: serverId ?? "",
|
|
workspace,
|
|
pendingCreateAttempts,
|
|
agents,
|
|
});
|
|
},
|
|
equal,
|
|
);
|
|
}
|
|
|
|
const EMPTY_ORDER: string[] = [];
|
|
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
|
const EMPTY_WORKSPACES: SidebarWorkspacePlacement[] = [];
|
|
const EMPTY_PROJECT_NAMES = new Map<string, string>();
|
|
|
|
export interface SidebarWorkspacesListResult {
|
|
workspacePlacements: SidebarWorkspacePlacement[];
|
|
projects: SidebarProjectEntry[];
|
|
projectNamesByKey: Map<string, string>;
|
|
isLoading: boolean;
|
|
isInitialLoad: boolean;
|
|
isRevalidating: boolean;
|
|
refreshAll: () => void;
|
|
}
|
|
|
|
export function useSidebarWorkspacesList(options?: {
|
|
hostFilter?: string | null;
|
|
enabled?: boolean;
|
|
}): SidebarWorkspacesListResult {
|
|
const runtime = getHostRuntimeStore();
|
|
const allHosts = useHosts();
|
|
const hostRegistryLoaded = useHostRegistryLoaded();
|
|
const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]);
|
|
|
|
const storeHostFilter = useSidebarViewStore((state) => state.hostFilter);
|
|
const hostFilter = options?.hostFilter ?? storeHostFilter;
|
|
const reconcileHostFilter = useSidebarViewStore((state) => state.reconcileHostFilter);
|
|
const hasHostFilterMatch = hostFilter ? allServerIds.includes(hostFilter) : false;
|
|
const effectiveHostFilter =
|
|
hostFilter && (!hostRegistryLoaded || hasHostFilterMatch) ? hostFilter : null;
|
|
const isActive = options?.enabled !== false;
|
|
|
|
const serverIds = useMemo(() => {
|
|
if (effectiveHostFilter) {
|
|
return allServerIds.filter((id) => id === effectiveHostFilter);
|
|
}
|
|
return allServerIds;
|
|
}, [allServerIds, effectiveHostFilter]);
|
|
|
|
useEffect(() => {
|
|
if (!hostRegistryLoaded) {
|
|
return;
|
|
}
|
|
reconcileHostFilter(allServerIds);
|
|
}, [allServerIds, hostRegistryLoaded, reconcileHostFilter]);
|
|
|
|
const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER);
|
|
|
|
const hydratedServerIds = useStoreWithEqualityFn(
|
|
useSessionStore,
|
|
(state) => serverIds.filter((id) => state.sessions[id]?.hasHydratedWorkspaces ?? false),
|
|
shallow,
|
|
);
|
|
|
|
const hostProjects = useHostProjects(serverIds);
|
|
|
|
const sidebarModel = useMemo(
|
|
() =>
|
|
buildSidebarWorkspacePlacementModel({
|
|
projects: hostProjects,
|
|
}),
|
|
[hostProjects],
|
|
);
|
|
|
|
const projects = sidebarModel.projects.length > 0 ? sidebarModel.projects : EMPTY_PROJECTS;
|
|
const workspacePlacements =
|
|
sidebarModel.workspaces.length > 0 ? sidebarModel.workspaces : EMPTY_WORKSPACES;
|
|
const projectNamesByKey =
|
|
sidebarModel.projectNamesByKey.size > 0 ? sidebarModel.projectNamesByKey : EMPTY_PROJECT_NAMES;
|
|
|
|
useEffect(() => {
|
|
const orderStore = useSidebarOrderStore.getState();
|
|
const updates = computeSidebarOrderUpdates({
|
|
projects,
|
|
persistedProjectOrder,
|
|
getWorkspaceOrder: (projectKey) =>
|
|
orderStore.workspaceOrderByProject[projectKey] ?? EMPTY_ORDER,
|
|
});
|
|
|
|
if (updates.projectOrder) {
|
|
orderStore.setProjectOrder(updates.projectOrder);
|
|
}
|
|
for (const { projectKey, order } of updates.workspaceOrders) {
|
|
orderStore.setWorkspaceOrder(projectKey, order);
|
|
}
|
|
}, [persistedProjectOrder, projects]);
|
|
|
|
const refreshAll = useCallback(() => {
|
|
if (!isActive) return;
|
|
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
|
|
}
|
|
})();
|
|
}
|
|
}, [isActive, runtime, serverIds]);
|
|
|
|
const loadingState = deriveSidebarLoadingState({
|
|
isActive,
|
|
serverIds,
|
|
hydratedServerIds,
|
|
hasProjects: projects.length > 0,
|
|
});
|
|
|
|
return {
|
|
workspacePlacements,
|
|
projects,
|
|
projectNamesByKey,
|
|
...loadingState,
|
|
refreshAll,
|
|
};
|
|
}
|