import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react' import { normalizeWorkspaceDescriptor, useSessionStore } from '@/stores/session-store' import { getHostRuntimeStore } from '@/runtime/host-runtime' import { useSidebarOrderStore } from '@/stores/sidebar-order-store' import type { WorkspaceDescriptor } from '@/stores/session-store' import { projectDisplayNameFromProjectId } from '@/utils/project-display-name' const EMPTY_ORDER: string[] = [] const EMPTY_PROJECTS: SidebarProjectEntry[] = [] export type SidebarStateBucket = WorkspaceDescriptor['status'] export interface SidebarWorkspaceEntry { workspaceKey: string serverId: string workspaceId: string workspaceKind: WorkspaceDescriptor['workspaceKind'] name: string activityAt: Date | null statusBucket: SidebarStateBucket diffStat: { additions: number; deletions: number } | null } export interface SidebarProjectEntry { projectKey: string projectName: string projectKind: WorkspaceDescriptor['projectKind'] iconWorkingDir: string statusBucket: SidebarStateBucket activeCount: number totalWorkspaces: number latestActivityAt: Date | null workspaces: SidebarWorkspaceEntry[] } export interface SidebarWorkspacesListResult { projects: SidebarProjectEntry[] isLoading: boolean isInitialLoad: boolean isRevalidating: boolean refreshAll: () => void } const SIDEBAR_BUCKET_PRIORITY: Record = { 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 { if (left.activityAt && right.activityAt) { const dateDelta = right.activityAt.getTime() - left.activityAt.getTime() if (dateDelta !== 0) { return dateDelta } } else if (left.activityAt || right.activityAt) { return left.activityAt ? -1 : 1 } const nameDelta = left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: 'base', }) if (nameDelta !== 0) { return nameDelta } return left.workspaceId.localeCompare(right.workspaceId, undefined, { numeric: true, sensitivity: 'base', }) } function compareProjectBaseline(left: SidebarProjectEntry, right: SidebarProjectEntry): number { if (left.latestActivityAt && right.latestActivityAt) { const dateDelta = right.latestActivityAt.getTime() - left.latestActivityAt.getTime() if (dateDelta !== 0) { return dateDelta } } else if (left.latestActivityAt || right.latestActivityAt) { return left.latestActivityAt ? -1 : 1 } return left.projectName.localeCompare(right.projectName, undefined, { numeric: true, sensitivity: 'base', }) } export function buildSidebarProjectsFromWorkspaces(input: { serverId: string workspaces: Iterable projectOrder: string[] workspaceOrderByScope: Record }): SidebarProjectEntry[] { const byProject = new Map() 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 || workspace.id, statusBucket: 'done', activeCount: 0, totalWorkspaces: 0, latestActivityAt: null, workspaces: [], } satisfies SidebarProjectEntry) const row: SidebarWorkspaceEntry = { workspaceKey: `${input.serverId}:${workspace.id}`, serverId: input.serverId, workspaceId: workspace.id, workspaceKind: workspace.workspaceKind, name: workspace.name, activityAt: workspace.activityAt, statusBucket: workspace.status, diffStat: workspace.diffStat, } project.workspaces.push(row) project.totalWorkspaces += 1 if (workspace.status !== 'done') { project.activeCount += 1 } project.statusBucket = aggregateBucket(project.statusBucket, workspace.status) if ( !project.latestActivityAt || (workspace.activityAt && workspace.activityAt.getTime() > project.latestActivityAt.getTime()) ) { project.latestActivityAt = workspace.activityAt } byProject.set(workspace.projectId, project) } 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, }) } export function applyStoredOrdering(input: { items: T[] storedOrder: string[] getKey: (item: T) => string }): T[] { if (input.items.length <= 1 || input.storedOrder.length === 0) { return input.items } const itemByKey = new Map() for (const item of input.items) { itemByKey.set(input.getKey(item), item) } const prunedOrder: string[] = [] const seen = new Set() 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 } export function appendMissingOrderKeys(input: { currentOrder: string[] visibleKeys: string[] }): string[] { if (input.visibleKeys.length === 0) { return input.currentOrder } const existingKeys = new Set(input.currentOrder) const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key)) if (missingKeys.length === 0) { return input.currentOrder } return [...input.currentOrder, ...missingKeys] } function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string { return `${serverId.trim()}::${projectKey.trim()}` } function toWorkspaceDescriptor(payload: { id: string projectId: string projectDisplayName: string projectRootPath: string projectKind: WorkspaceDescriptor['projectKind'] workspaceKind: WorkspaceDescriptor['workspaceKind'] name: string status: WorkspaceDescriptor['status'] activityAt: string | null }): WorkspaceDescriptor { return normalizeWorkspaceDescriptor(payload) } export function useSidebarWorkspacesList(options?: { serverId?: string | null enabled?: boolean }): SidebarWorkspacesListResult { const runtime = getHostRuntimeStore() const serverId = useMemo(() => { const value = options?.serverId return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null }, [options?.serverId]) const persistedProjectOrder = useSidebarOrderStore((state) => serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER ) const persistedWorkspaceOrderByScope = useSidebarOrderStore((state) => serverId ? state.workspaceOrderByServerAndProject : {} ) 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 connectionStatus = useSyncExternalStore( (onStoreChange) => isActive && serverId ? runtime.subscribe(serverId, onStoreChange) : () => {}, () => { if (!isActive || !serverId) { return 'idle' } const snapshot = runtime.getSnapshot(serverId) return snapshot?.connectionStatus ?? 'idle' }, () => { if (!isActive || !serverId) { return 'idle' } const snapshot = runtime.getSnapshot(serverId) return snapshot?.connectionStatus ?? 'idle' } ) const projects = useMemo(() => { console.log('[useSidebarWorkspacesList] build_projects', { serverId, hasSessionWorkspaces: Boolean(sessionWorkspaces), workspaceCount: sessionWorkspaces?.size ?? 0, projectOrderLength: persistedProjectOrder.length, workspaceOrderScopeCount: Object.keys(persistedWorkspaceOrderByScope).length, }) if (!sessionWorkspaces || sessionWorkspaces.size === 0 || !serverId) { return EMPTY_PROJECTS } return buildSidebarProjectsFromWorkspaces({ serverId, workspaces: sessionWorkspaces.values(), projectOrder: persistedProjectOrder, workspaceOrderByScope: persistedWorkspaceOrderByScope, }) }, [persistedProjectOrder, persistedWorkspaceOrderByScope, serverId, sessionWorkspaces]) useEffect(() => { console.log('[useSidebarWorkspacesList] inputs_changed', { serverId, connectionStatus, hasHydratedWorkspaces, workspaceCount: sessionWorkspaces?.size ?? 0, projectOrderLength: persistedProjectOrder.length, workspaceOrderScopeCount: Object.keys(persistedWorkspaceOrderByScope).length, }) }, [ connectionStatus, hasHydratedWorkspaces, persistedProjectOrder, persistedWorkspaceOrderByScope, serverId, sessionWorkspaces, ]) useEffect(() => { console.log('[useSidebarWorkspacesList] projects_changed', { serverId, projectCount: projects.length, projectKeys: projects.map((project) => project.projectKey), workspaceCounts: projects.map((project) => ({ projectKey: project.projectKey, workspaceCount: project.workspaces.length, })), }) }, [projects, serverId]) useEffect(() => { if (!serverId || projects.length === 0) { return } const nextProjectOrder = appendMissingOrderKeys({ currentOrder: persistedProjectOrder, visibleKeys: projects.map((project) => project.projectKey), }) if (nextProjectOrder !== persistedProjectOrder) { useSidebarOrderStore.getState().setProjectOrder(serverId, nextProjectOrder) } for (const project of projects) { const workspaceOrderScopeKey = getWorkspaceOrderScopeKey(serverId, project.projectKey) const persistedWorkspaceOrder = persistedWorkspaceOrderByScope[workspaceOrderScopeKey] ?? EMPTY_ORDER const nextWorkspaceOrder = appendMissingOrderKeys({ currentOrder: persistedWorkspaceOrder, visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey), }) if (nextWorkspaceOrder !== persistedWorkspaceOrder) { useSidebarOrderStore .getState() .setWorkspaceOrder(serverId, project.projectKey, nextWorkspaceOrder) } } }, [persistedProjectOrder, persistedWorkspaceOrderByScope, projects, serverId]) const refreshAll = useCallback(() => { if (!isActive || !serverId || connectionStatus !== 'online') { return } const client = runtime.getClient(serverId) if (!client) { return } void (async () => { const next = new Map() 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) next.set(workspace.id, workspace) } if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) { break } cursor = payload.pageInfo.nextCursor } const store = useSessionStore.getState() store.setWorkspaces(serverId, next) store.setHasHydratedWorkspaces(serverId, true) } catch { // ignore explicit refresh failures; hook keeps existing data } })() }, [connectionStatus, isActive, runtime, serverId]) const isLoading = isActive && Boolean(serverId) && connectionStatus === 'online' && !hasHydratedWorkspaces const isInitialLoad = isLoading && projects.length === 0 const isRevalidating = false return { projects, isLoading, isInitialLoad, isRevalidating, refreshAll, } }