feat: workspace source-of-truth hard cut

This commit is contained in:
Mohamed Boudra
2026-03-03 23:28:16 +07:00
parent f3796e2ba5
commit cb92f39e73
20 changed files with 1813 additions and 945 deletions
@@ -1,650 +0,0 @@
import { useCallback, useMemo, useSyncExternalStore } from 'react'
import { useQueries } from '@tanstack/react-query'
import { useSessionStore } from '@/stores/session-store'
import { getHostRuntimeStore } from '@/runtime/host-runtime'
import { resolveProjectPlacement } from '@/utils/project-placement'
import { deriveSidebarStateBucket, type SidebarStateBucket } from '@/utils/sidebar-agent-state'
import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
import { checkoutStatusQueryKey } from '@/hooks/use-checkout-status-query'
const EMPTY_ORDER: string[] = []
const EMPTY_PROJECTS: SidebarProjectEntry[] = []
interface PaseoWorktreeEntry {
worktreePath: string
createdAt: string
branchName?: string | null
head?: string | null
}
export interface SidebarWorkspaceEntry {
workspaceKey: string
serverId: string
cwd: string
branchName: string | null
createdAt: Date | null
isMainCheckout: boolean
isPaseoOwnedWorktree: boolean
statusBucket: SidebarStateBucket
}
export interface SidebarProjectEntry {
projectKey: string
projectName: string
iconWorkingDir: string
statusBucket: SidebarStateBucket
activeCount: number
totalCount: number
latestCreatedAt: Date | null
workspaces: SidebarWorkspaceEntry[]
}
export interface SidebarAgentsListResult {
projects: SidebarProjectEntry[]
isLoading: boolean
isInitialLoad: boolean
isRevalidating: boolean
refreshAll: () => void
}
interface MutableWorkspaceEntry {
workspaceKey: string
serverId: string
cwd: string
branchName: string | null
createdAt: Date | null
isMainCheckout: boolean
isPaseoOwnedWorktree: boolean
statusBucket: SidebarStateBucket
}
interface MutableProjectEntry {
projectKey: string
projectName: string
iconWorkingDir: string
statusBucket: SidebarStateBucket
activeCount: number
totalCount: number
latestCreatedAt: Date | null
repoRoots: Set<string>
workspacesByKey: Map<string, MutableWorkspaceEntry>
}
interface WorktreeRequest {
requestKey: string
serverId: string
projectKey: string
repoRoot: string
}
const SIDEBAR_BUCKET_PRIORITY: Record<SidebarStateBucket, number> = {
done: 0,
attention: 1,
running: 2,
failed: 3,
needs_input: 4,
}
function normalizePath(value: string | null | undefined): string {
if (typeof value !== 'string') {
return ''
}
return value.trim()
}
function toWorkspaceKey(serverId: string, cwd: string): string {
return `${serverId}:${cwd}`
}
function coerceBranchName(value: string | null | undefined): string | null {
const trimmed = normalizePath(value)
return trimmed.length > 0 ? trimmed : null
}
function maxDate(left: Date | null, right: Date): Date {
if (!left || right.getTime() > left.getTime()) {
return right
}
return left
}
function minDate(left: Date | null, right: Date): Date {
if (!left || right.getTime() < left.getTime()) {
return right
}
return left
}
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.isMainCheckout !== right.isMainCheckout) {
return left.isMainCheckout ? -1 : 1
}
if (left.createdAt && right.createdAt) {
const dateDelta = right.createdAt.getTime() - left.createdAt.getTime()
if (dateDelta !== 0) {
return dateDelta
}
} else if (left.createdAt || right.createdAt) {
return left.createdAt ? -1 : 1
}
const leftBranch = left.branchName ?? ''
const rightBranch = right.branchName ?? ''
const branchDelta = leftBranch.localeCompare(rightBranch, undefined, {
numeric: true,
sensitivity: 'base',
})
if (branchDelta !== 0) {
return branchDelta
}
return left.cwd.localeCompare(right.cwd, undefined, {
numeric: true,
sensitivity: 'base',
})
}
function compareProjectBaseline(left: SidebarProjectEntry, right: SidebarProjectEntry): number {
if (left.latestCreatedAt && right.latestCreatedAt) {
const dateDelta = right.latestCreatedAt.getTime() - left.latestCreatedAt.getTime()
if (dateDelta !== 0) {
return dateDelta
}
} else if (left.latestCreatedAt || right.latestCreatedAt) {
return left.latestCreatedAt ? -1 : 1
}
return left.projectName.localeCompare(right.projectName, undefined, {
numeric: true,
sensitivity: 'base',
})
}
export function applyStoredOrdering<T>(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<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 ensureWorkspace(
project: MutableProjectEntry,
input: {
serverId: string
cwd: string
branchName: string | null
createdAt: Date | null
isMainCheckout: boolean
isPaseoOwnedWorktree: boolean
}
): MutableWorkspaceEntry {
const workspaceKey = toWorkspaceKey(input.serverId, input.cwd)
const existing = project.workspacesByKey.get(workspaceKey)
if (existing) {
if (!existing.branchName && input.branchName) {
existing.branchName = input.branchName
}
if (input.createdAt) {
existing.createdAt = existing.createdAt
? minDate(existing.createdAt, input.createdAt)
: input.createdAt
}
if (input.isMainCheckout) {
existing.isMainCheckout = true
}
if (input.isPaseoOwnedWorktree) {
existing.isPaseoOwnedWorktree = true
}
return existing
}
const workspace: MutableWorkspaceEntry = {
workspaceKey,
serverId: input.serverId,
cwd: input.cwd,
branchName: input.branchName,
createdAt: input.createdAt,
isMainCheckout: input.isMainCheckout,
isPaseoOwnedWorktree: input.isPaseoOwnedWorktree,
statusBucket: 'done',
}
project.workspacesByKey.set(workspaceKey, workspace)
return workspace
}
function cloneProject(project: MutableProjectEntry): MutableProjectEntry {
return {
...project,
repoRoots: new Set(project.repoRoots),
workspacesByKey: new Map(
Array.from(project.workspacesByKey.entries()).map(([key, workspace]) => [
key,
{ ...workspace },
])
),
}
}
function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string {
return `${serverId.trim()}::${projectKey.trim()}`
}
export function useSidebarAgentsList(options?: {
serverId?: string | null
enabled?: boolean
}): SidebarAgentsListResult {
const runtime = getHostRuntimeStore()
const serverId = useMemo(() => {
const value = options?.serverId
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
}, [options?.serverId])
const enabled = options?.enabled ?? true
const persistedProjectOrder = useSidebarOrderStore((state) =>
serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER
)
const persistedWorkspaceOrderByScope = useSidebarOrderStore((state) =>
serverId ? state.workspaceOrderByServerAndProject : {}
)
const isActive = Boolean(serverId)
const liveAgents = useSessionStore((state) =>
isActive && serverId ? (state.sessions[serverId]?.agents ?? null) : null
)
const runtimeStatusSignature = useSyncExternalStore(
(onStoreChange) =>
isActive && serverId ? runtime.subscribe(serverId, onStoreChange) : () => {},
() => {
if (!isActive || !serverId) {
return 'idle:idle'
}
const snapshot = runtime.getSnapshot(serverId)
const connectionStatus = snapshot?.connectionStatus ?? 'idle'
const directoryStatus = snapshot?.agentDirectoryStatus ?? 'idle'
return `${connectionStatus}:${directoryStatus}`
},
() => {
if (!isActive || !serverId) {
return 'idle:idle'
}
const snapshot = runtime.getSnapshot(serverId)
const connectionStatus = snapshot?.connectionStatus ?? 'idle'
const directoryStatus = snapshot?.agentDirectoryStatus ?? 'idle'
return `${connectionStatus}:${directoryStatus}`
}
)
const [connectionStatus = 'idle', directoryStatus = 'idle'] = runtimeStatusSignature.split(':', 2)
const base = useMemo(() => {
if (!isActive || !serverId || !liveAgents) {
return {
projectsByKey: new Map<string, MutableProjectEntry>(),
worktreeRequests: [] as WorktreeRequest[],
hasAnyData: false,
}
}
const projectsByKey = new Map<string, MutableProjectEntry>()
for (const sourceAgent of liveAgents.values()) {
if (sourceAgent.archivedAt || sourceAgent.labels.ui !== 'true') {
continue
}
const placement = resolveProjectPlacement({
projectPlacement: sourceAgent.projectPlacement ?? null,
cwd: sourceAgent.cwd,
})
const projectKey = normalizePath(placement.projectKey)
if (!projectKey) {
continue
}
const checkoutCwd = normalizePath(placement.checkout.cwd || sourceAgent.cwd)
if (!checkoutCwd) {
continue
}
const projectName = normalizePath(placement.projectName) || projectKey
const project =
projectsByKey.get(projectKey) ??
({
projectKey,
projectName,
iconWorkingDir: checkoutCwd,
statusBucket: 'done',
activeCount: 0,
totalCount: 0,
latestCreatedAt: null,
repoRoots: new Set<string>(),
workspacesByKey: new Map<string, MutableWorkspaceEntry>(),
} satisfies MutableProjectEntry)
project.totalCount += 1
project.latestCreatedAt = maxDate(project.latestCreatedAt, sourceAgent.createdAt)
const bucket = deriveSidebarStateBucket({
status: sourceAgent.status,
pendingPermissionCount: sourceAgent.pendingPermissions.length,
requiresAttention: sourceAgent.requiresAttention,
attentionReason: sourceAgent.attentionReason,
})
project.statusBucket = aggregateBucket(project.statusBucket, bucket)
if (bucket !== 'done') {
project.activeCount += 1
}
const normalizedBranch = coerceBranchName(placement.checkout.currentBranch)
const isMainCheckout = placement.checkout.isGit && !placement.checkout.isPaseoOwnedWorktree
const workspace = ensureWorkspace(project, {
serverId,
cwd: checkoutCwd,
branchName: normalizedBranch,
// Only Paseo-managed worktrees are ephemeral enough to justify showing a timestamp.
// If the daemon doesn't provide worktree.createdAt (older daemons), fall back to an
// agent-derived timestamp so the row still has a date.
createdAt: placement.checkout.isPaseoOwnedWorktree ? sourceAgent.createdAt : null,
isMainCheckout,
isPaseoOwnedWorktree: placement.checkout.isPaseoOwnedWorktree,
})
workspace.statusBucket = aggregateBucket(workspace.statusBucket, bucket)
const explicitMainRepoRoot = normalizePath(placement.checkout.mainRepoRoot)
if (placement.checkout.isPaseoOwnedWorktree && explicitMainRepoRoot) {
project.repoRoots.add(explicitMainRepoRoot)
if (!project.iconWorkingDir) {
project.iconWorkingDir = explicitMainRepoRoot
}
}
if (isMainCheckout) {
project.repoRoots.add(workspace.cwd)
project.iconWorkingDir = workspace.cwd
}
if (!project.iconWorkingDir) {
project.iconWorkingDir = workspace.cwd
}
projectsByKey.set(projectKey, project)
}
const worktreeRequests: WorktreeRequest[] = []
for (const project of projectsByKey.values()) {
for (const repoRoot of project.repoRoots) {
if (!repoRoot) {
continue
}
const requestKey = `${serverId}:${project.projectKey}:${repoRoot}`
worktreeRequests.push({
requestKey,
serverId,
projectKey: project.projectKey,
repoRoot,
})
}
}
worktreeRequests.sort((left, right) =>
left.requestKey.localeCompare(right.requestKey, undefined, {
numeric: true,
sensitivity: 'base',
})
)
return {
projectsByKey,
worktreeRequests,
hasAnyData: projectsByKey.size > 0,
}
}, [isActive, liveAgents, serverId])
const worktreeQueries = useQueries({
queries: base.worktreeRequests.map((request) => ({
queryKey: ['sidebarPaseoWorktreeList', request.serverId, request.repoRoot],
queryFn: async (): Promise<PaseoWorktreeEntry[]> => {
const client = runtime.getClient(request.serverId)
if (!client) {
return []
}
const payload = await client.getPaseoWorktreeList({
repoRoot: request.repoRoot,
})
if (payload.error) {
return []
}
return payload.worktrees ?? []
},
enabled:
enabled &&
isActive &&
Boolean(serverId) &&
connectionStatus === 'online' &&
Boolean(runtime.getClient(request.serverId)) &&
request.repoRoot.length > 0,
staleTime: 15_000,
gcTime: 1000 * 60 * 10,
retry: false,
refetchOnMount: 'always' as const,
})),
})
const repoRootStatusQueries = useQueries({
queries: base.worktreeRequests.map((request) => ({
queryKey: checkoutStatusQueryKey(request.serverId, request.repoRoot),
queryFn: async () => {
const client = runtime.getClient(request.serverId)
if (!client) {
return null
}
try {
return await client.getCheckoutStatus(request.repoRoot)
} catch {
return null
}
},
enabled:
enabled &&
isActive &&
Boolean(serverId) &&
connectionStatus === 'online' &&
Boolean(runtime.getClient(request.serverId)) &&
request.repoRoot.length > 0,
staleTime: 15_000,
gcTime: 1000 * 60 * 10,
retry: false,
refetchOnMount: 'always' as const,
})),
})
const projects = useMemo(() => {
if (base.projectsByKey.size === 0) {
return EMPTY_PROJECTS
}
const projectsByKey = new Map<string, MutableProjectEntry>()
for (const [key, project] of base.projectsByKey.entries()) {
projectsByKey.set(key, cloneProject(project))
}
for (let i = 0; i < base.worktreeRequests.length; i += 1) {
const request = base.worktreeRequests[i]
if (!request) {
continue
}
const project = projectsByKey.get(request.projectKey)
if (!project) {
continue
}
const checkout = repoRootStatusQueries[i]?.data ?? null
const mainBranchName = coerceBranchName(checkout?.currentBranch)
ensureWorkspace(project, {
serverId: request.serverId,
cwd: request.repoRoot,
branchName: mainBranchName,
createdAt: null,
isMainCheckout: true,
isPaseoOwnedWorktree: false,
})
const worktrees = worktreeQueries[i]?.data ?? []
for (const worktree of worktrees) {
const worktreePath = normalizePath(worktree.worktreePath)
if (!worktreePath) {
continue
}
const createdAt = (() => {
const createdAtRaw = worktree.createdAt
if (typeof createdAtRaw !== 'string' || createdAtRaw.trim().length === 0) {
return null
}
const parsed = new Date(createdAtRaw)
return Number.isNaN(parsed.getTime()) ? null : parsed
})()
ensureWorkspace(project, {
serverId: request.serverId,
cwd: worktreePath,
branchName: coerceBranchName(worktree.branchName) ?? coerceBranchName(worktree.head),
createdAt,
isMainCheckout: false,
isPaseoOwnedWorktree: true,
})
}
}
const baselineProjects: SidebarProjectEntry[] = Array.from(projectsByKey.values()).map(
(project) => {
const baselineWorkspaces = Array.from(project.workspacesByKey.values()).map(
(workspace) => ({
workspaceKey: workspace.workspaceKey,
serverId: workspace.serverId,
cwd: workspace.cwd,
branchName: workspace.branchName,
createdAt: workspace.createdAt,
isMainCheckout: workspace.isMainCheckout,
isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree,
statusBucket: workspace.statusBucket,
})
)
baselineWorkspaces.sort(compareWorkspaceBaseline)
const workspaceOrderScopeKey = getWorkspaceOrderScopeKey(serverId ?? '', project.projectKey)
const orderedWorkspaces = applyStoredOrdering({
items: baselineWorkspaces,
storedOrder: persistedWorkspaceOrderByScope[workspaceOrderScopeKey] ?? EMPTY_ORDER,
getKey: (workspace) => workspace.workspaceKey,
})
return {
projectKey: project.projectKey,
projectName: project.projectName,
iconWorkingDir: project.iconWorkingDir,
statusBucket: project.statusBucket,
activeCount: project.activeCount,
totalCount: project.totalCount,
latestCreatedAt: project.latestCreatedAt,
workspaces: orderedWorkspaces,
}
}
)
baselineProjects.sort(compareProjectBaseline)
return applyStoredOrdering({
items: baselineProjects,
storedOrder: persistedProjectOrder,
getKey: (project) => project.projectKey,
})
}, [
base.projectsByKey,
base.worktreeRequests,
connectionStatus,
isActive,
persistedProjectOrder,
persistedWorkspaceOrderByScope,
repoRootStatusQueries,
serverId,
worktreeQueries,
])
const refreshAll = useCallback(() => {
if (!isActive || !serverId || connectionStatus !== 'online') {
return
}
void runtime.refreshAgentDirectory({ serverId, page: { limit: 50 } }).catch(() => undefined)
}, [connectionStatus, isActive, runtime, serverId])
const isDirectoryLoading =
isActive &&
Boolean(serverId) &&
(directoryStatus === 'initial_loading' || directoryStatus === 'revalidating')
const isInitialLoad = isDirectoryLoading && !base.hasAnyData
const isRevalidating = isDirectoryLoading && base.hasAnyData
return {
projects,
isLoading: isDirectoryLoading,
isInitialLoad,
isRevalidating,
refreshAll,
}
}
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { applyStoredOrdering } from './use-sidebar-agents-list'
import {
applyStoredOrdering,
buildSidebarProjectsFromWorkspaces,
} from './use-sidebar-workspaces-list'
import type { WorkspaceDescriptor } from '@/stores/session-store'
interface OrderedItem {
key: string
@@ -41,3 +45,29 @@ describe('applyStoredOrdering', () => {
expect(result).toBe(baseline)
})
})
describe('buildSidebarProjectsFromWorkspaces', () => {
it('uses workspace descriptor name and status directly', () => {
const workspaces: WorkspaceDescriptor[] = [
{
id: '/repo/main',
projectId: 'project-1',
name: 'feat/hard-cut',
status: 'failed',
activityAt: new Date('2026-01-01T00:00:00.000Z'),
},
]
const projects = buildSidebarProjectsFromWorkspaces({
serverId: 'srv',
workspaces,
projectOrder: [],
workspaceOrderByScope: {},
})
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')
})
})
@@ -0,0 +1,332 @@
import { useCallback, useMemo, useSyncExternalStore } from 'react'
import { 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
name: string
activityAt: Date | null
statusBucket: SidebarStateBucket
}
export interface SidebarProjectEntry {
projectKey: string
projectName: string
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<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 {
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<WorkspaceDescriptor>
projectOrder: string[]
workspaceOrderByScope: Record<string, string[]>
}): SidebarProjectEntry[] {
const byProject = new Map<string, SidebarProjectEntry>()
for (const workspace of input.workspaces) {
const project =
byProject.get(workspace.projectId) ??
({
projectKey: workspace.projectId,
projectName: projectDisplayNameFromProjectId(workspace.projectId),
iconWorkingDir: 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,
name: workspace.name,
activityAt: workspace.activityAt,
statusBucket: workspace.status,
}
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<T>(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<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 getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string {
return `${serverId.trim()}::${projectKey.trim()}`
}
function toWorkspaceDescriptor(payload: {
id: string
projectId: string
name: string
status: WorkspaceDescriptor['status']
activityAt: string | null
}): WorkspaceDescriptor {
const activityAt = payload.activityAt ? new Date(payload.activityAt) : null
return {
id: payload.id,
projectId: payload.projectId,
name: payload.name,
status: payload.status,
activityAt: activityAt && !Number.isNaN(activityAt.getTime()) ? activityAt : null,
}
}
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 enabled = options?.enabled ?? true
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(() => {
if (!sessionWorkspaces || sessionWorkspaces.size === 0 || !serverId) {
return EMPTY_PROJECTS
}
return buildSidebarProjectsFromWorkspaces({
serverId,
workspaces: sessionWorkspaces.values(),
projectOrder: persistedProjectOrder,
workspaceOrderByScope: persistedWorkspaceOrderByScope,
})
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, serverId, sessionWorkspaces])
const refreshAll = useCallback(() => {
if (!isActive || !serverId || connectionStatus !== 'online' || !enabled) {
return
}
const client = runtime.getClient(serverId)
if (!client) {
return
}
void client
.fetchWorkspaces({ page: { limit: 200 } })
.then((payload) => {
const next = new Map<string, WorkspaceDescriptor>()
for (const entry of payload.entries) {
const workspace = toWorkspaceDescriptor(entry)
next.set(workspace.id, workspace)
}
const store = useSessionStore.getState()
store.setWorkspaces(serverId, next)
store.setHasHydratedWorkspaces(serverId, true)
})
.catch(() => undefined)
}, [connectionStatus, enabled, 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,
}
}