diff --git a/PLAN_WORKSPACE_SOT_CLEANUP.md b/PLAN_WORKSPACE_SOT_CLEANUP.md new file mode 100644 index 000000000..3145e77ba --- /dev/null +++ b/PLAN_WORKSPACE_SOT_CLEANUP.md @@ -0,0 +1,130 @@ +# Workspace Source-Of-Truth Cleanup Plan + +## Scope + +Only this worktree: +`/Users/moboudra/.paseo/worktrees/1luy0po7/workspace-sot-hard-cut` + +## Objectives + +1. One server-owned source of truth for workspace identity. +2. Header and sidebar render the same workspace name field. +3. Remove client-side workspace construction from agent data. +4. Hard cut legacy paths (no dual-read compatibility layer). + +## Non-Objectives + +1. No UI-only taxonomy in canonical model (`subtitle`, `titleKind`). +2. No React derivation from branch/path basename for workspace naming. +3. No silent fallbacks masking missing state (`?? ""`, cwd fallback label). + +## Phase 0: Audit Existing Worktree Changes + +1. Inventory all modified files in this worktree. +2. For each hunk, classify as `keep`, `delete`, or `rewrite`. +3. Build a bad-pattern list and remove all of these: +- Workspace naming fallback in React. +- Canonical payload fields that are presentation-only. +- Any primary label logic equivalent to `Unknown branch`. +- Any sidebar workspace list constructed from live agent rows. + +### Acceptance + +1. Full inventory exists before further refactor work. +2. Every bad pattern has a mapped removal location. + +## Phase 1: Canonical Model Simplification + +Define a minimal server canonical workspace entity: + +- `id`: normalized cwd (single identity field) +- `projectId`: stable grouping key +- `name`: canonical workspace name (server computed) +- `status`: dominant state bucket +- `activityAt`: nullable ISO timestamp + +Do not include UI/presentation fields in canonical payload. + +### Acceptance + +1. No duplicate identity fields (`workspaceId` + `cwd`) in canonical contract. +2. No subtitle/title-kind fields in canonical contract. + +## Phase 2: Server Aggregation and RPC + +1. Build workspace aggregation in `session.ts` from authoritative server state. +2. Compute workspace `name` once in server policy code. +3. Provide: +- `fetch_workspaces_request/response` snapshot RPC +- `workspace_update` incremental upsert/remove stream +4. Keep update semantics aligned with existing directory subscription behavior. + +### Acceptance + +1. Non-git and git workspaces both resolve deterministic `name`. +2. No `Unknown branch` emitted by server. +3. Workspace update stream reflects agent lifecycle/state changes. + +## Phase 3: Client Store Wiring + +1. Add workspace map in session store keyed by canonical `id`. +2. Hydrate only from `fetch_workspaces`. +3. Apply only `workspace_update` events for incremental updates. +4. Keep workspace state channel separate from agent-list derivation. + +### Acceptance + +1. Workspace map is populated strictly from workspace RPCs. +2. No workspace assembly logic from agent lists in runtime/store. + +## Phase 4: UI Hard Cut + +1. Sidebar row label reads only `workspace.name`. +2. Header title reads only `workspace.name`. +3. Remove all local workspace naming helpers based on cwd/branch. +4. Missing descriptor after hydration must render explicit missing-state UI, not fallback text. + +### Acceptance + +1. Header and sidebar names are guaranteed identical source field. +2. No `?? ""`/cwd/branch fallback paths for workspace name. + +## Phase 5: Legacy Deletion + +1. Delete prior client sidebar workspace-building path(s). +2. Remove `Unknown branch` primary naming logic. +3. Remove any query/helper used only for old workspace naming. +4. Delete dead types/helpers from failed intermediate designs. + +### Acceptance + +1. No app code path builds workspace rows from agent lists. +2. Legacy naming/building code fully removed in same change set. + +## Phase 6: Verification + +1. Protocol/schema tests for workspace request/response/update. +2. Server aggregation tests: +- non-git workspace +- git branch workspace +- detached/headless workspace +- removal/update behavior +3. UI tests: +- selected workspace header name equals sidebar workspace name +- explicit missing-state rendering when descriptor absent post-hydration +4. Run `npm run typecheck`. +5. Manual validation in this worktree daemon+expo stack. + +### Acceptance + +1. Tests pass. +2. Typecheck passes. +3. Manual verification confirms name parity and status correctness. + +## Execution Guardrails + +1. Work only in `workspace-sot-hard-cut`. +2. No destructive git operations. +3. No fallback naming logic in React. +4. One naming policy and source of truth: server. +5. Hard cut means old code removed, not left dormant. diff --git a/packages/app/metro.config.js b/packages/app/metro.config.js index bd77c2a6c..687456092 100644 --- a/packages/app/metro.config.js +++ b/packages/app/metro.config.js @@ -4,7 +4,6 @@ const fs = require("fs"); const path = require("path"); const projectRoot = __dirname; -const monorepoRoot = path.resolve(projectRoot, "../.."); const serverSrcRoot = path.resolve(projectRoot, "../server/src"); const relaySrcRoot = path.resolve(projectRoot, "../relay/src"); const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "") @@ -14,19 +13,8 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "") const config = getDefaultConfig(projectRoot); const defaultResolveRequest = config.resolver.resolveRequest ?? resolve; - -// This app imports TypeScript sources from sibling workspaces (server/relay). -// Metro's default hierarchical lookup won't find hoisted deps when resolving -// from those out-of-tree source files, so point Metro at the monorepo root. -config.watchFolders = Array.from( - new Set([...(config.watchFolders ?? []), monorepoRoot]) -); -config.resolver.nodeModulesPaths = Array.from( - new Set([ - ...(config.resolver.nodeModulesPaths ?? []), - path.join(projectRoot, "node_modules"), - path.join(monorepoRoot, "node_modules"), - ]) +config.transformer.asyncRequireModulePath = require.resolve( + "@expo/metro-config/build/async-require" ); function isLocalModuleImport(moduleName) { diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index d42045cb1..ced54e761 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -15,7 +15,7 @@ import { router, usePathname } from 'expo-router' import { usePanelStore } from '@/stores/panel-store' import { SidebarAgentList } from './sidebar-agent-list' import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton' -import { useSidebarAgentsList } from '@/hooks/use-sidebar-agents-list' +import { useSidebarWorkspacesList } from '@/hooks/use-sidebar-workspaces-list' import { useSidebarAnimation } from '@/contexts/sidebar-animation-context' import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window' import { Combobox } from '@/components/ui/combobox' @@ -112,7 +112,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { // Derive isOpen from the unified panel state const isOpen = isMobile ? mobileView === 'agent-list' : desktopAgentListOpen - const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarAgentsList({ + const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({ serverId: activeServerId, enabled: isOpen, }) diff --git a/packages/app/src/components/sidebar-agent-list.tsx b/packages/app/src/components/sidebar-agent-list.tsx index 60e3ca04d..42d1103ac 100644 --- a/packages/app/src/components/sidebar-agent-list.tsx +++ b/packages/app/src/components/sidebar-agent-list.tsx @@ -1,4 +1,4 @@ -import { View, Text, Pressable, Image, Platform, Alert, ActivityIndicator, StatusBar } from 'react-native' +import { View, Text, Pressable, Image, Platform } from 'react-native' import { useQueries } from '@tanstack/react-query' import { useCallback, @@ -12,7 +12,7 @@ import { import { router, usePathname, useSegments } from 'expo-router' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler' -import { Archive, ChevronDown, ChevronRight } from 'lucide-react-native' +import { ChevronDown, ChevronRight } from 'lucide-react-native' import { DraggableList, type DraggableRenderItemInfo } from './draggable-list' import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime' import { getIsTauri } from '@/constants/layout' @@ -24,21 +24,15 @@ import { import { type SidebarProjectEntry, type SidebarWorkspaceEntry, -} from '@/hooks/use-sidebar-agents-list' + type SidebarStateBucket, +} from '@/hooks/use-sidebar-workspaces-list' import { useSidebarOrderStore } from '@/stores/sidebar-order-store' -import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store' import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store' import { formatTimeAgo } from '@/utils/time' -import type { SidebarStateBucket } from '@/utils/sidebar-agent-state' -import { confirmDialog } from '@/utils/confirm-dialog' import { ContextMenu, - ContextMenuContent, - ContextMenuItem, ContextMenuTrigger, - useContextMenu, } from '@/components/ui/context-menu' -import { deriveProjectDisplayName } from '@/utils/agent-grouping' import { buildSidebarWorkspaceViewModel, type SidebarWorkspaceTreeRow, @@ -81,19 +75,11 @@ interface WorkspaceRowProps { drag: () => void } -function resolveWorkspaceBranchLabel(workspace: SidebarWorkspaceEntry): string { - const branch = workspace.branchName?.trim() - if (branch && branch.length > 0) { - return branch - } - return 'Unknown branch' -} - function resolveWorkspaceCreatedAtLabel(workspace: SidebarWorkspaceEntry): string | null { - if (!workspace.createdAt) { + if (!workspace.activityAt) { return null } - return formatTimeAgo(workspace.createdAt) + return formatTimeAgo(workspace.activityAt) } function resolveStatusDotColor(input: { theme: ReturnType['theme']; bucket: SidebarStateBucket }) { @@ -111,21 +97,15 @@ function resolveStatusDotColor(input: { theme: ReturnType[' function WorkspaceStatusIndicator({ bucket, - loading = false, }: { bucket: SidebarWorkspaceEntry['statusBucket'] - loading?: boolean }) { const { theme } = useUnistyles() const color = resolveStatusDotColor({ theme, bucket }) return ( - {loading ? ( - - ) : ( - - )} + ) } @@ -198,69 +178,8 @@ function WorkspaceRow({ onPress, drag, }: WorkspaceRowProps) { - const { theme } = useUnistyles() const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace) - - const { setAnchorRect, setOpen } = useContextMenu() const didLongPressRef = useRef(false) - const didLongPressCleanupTimerRef = useRef | null>(null) - const longPressArmedRef = useRef(false) - const longPressCancelledRef = useRef(false) - const didStartDragRef = useRef(false) - const touchStartRef = useRef<{ x: number; y: number } | null>(null) - - const archiveStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ - serverId: workspace.serverId, - cwd: workspace.cwd, - actionId: 'archive-worktree', - }) - ) - const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree) - const isArchiving = archiveStatus === 'pending' - - const openContextMenuAtTouchStart = useCallback(() => { - const point = touchStartRef.current - if (!point) { - return - } - const statusBarHeight = Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) : 0 - setAnchorRect({ - x: point.x, - y: point.y + statusBarHeight, - width: 0, - height: 0, - }) - setOpen(true) - }, [setAnchorRect, setOpen]) - - const handleArchiveWorktree = useCallback(() => { - if (!workspace.isPaseoOwnedWorktree) { - return - } - - void confirmDialog({ - title: 'Archive worktree?', - message: `Archive this worktree?\n\n${workspace.cwd}`, - confirmLabel: 'Archive', - cancelLabel: 'Cancel', - destructive: true, - }) - .then((confirmed) => { - if (!confirmed) { - return - } - return runArchiveWorktree({ - serverId: workspace.serverId, - cwd: workspace.cwd, - worktreePath: workspace.cwd, - }) - }) - .catch((err) => { - const message = err instanceof Error ? err.message : 'Failed to archive worktree' - Alert.alert('Error', message) - }) - }, [runArchiveWorktree, workspace.cwd, workspace.isPaseoOwnedWorktree, workspace.serverId]) const handlePress = useCallback(() => { if (didLongPressRef.current) { @@ -271,67 +190,12 @@ function WorkspaceRow({ }, [onPress]) const handleLongPress = useCallback(() => { - if (longPressCancelledRef.current) { - return - } didLongPressRef.current = true - longPressArmedRef.current = true }, []) const moveMonitorGesture = useMemo(() => { - if (Platform.OS === 'web') { - return null - } - - const CANCEL_SLOP_PX = 10 - const DRAG_SLOP_PX = 8 - - return Gesture.Pan() - .manualActivation(true) - .runOnJS(true) - .onTouchesDown((event) => { - const touch = event.changedTouches[0] - if (!touch) { - return - } - touchStartRef.current = { x: touch.absoluteX, y: touch.absoluteY } - }) - .onTouchesMove((event, stateManager) => { - const touch = event.changedTouches[0] - if (!touch || event.numberOfTouches !== 1) { - stateManager.fail() - return - } - - const start = touchStartRef.current - if (!start) { - touchStartRef.current = { x: touch.absoluteX, y: touch.absoluteY } - return - } - - const dx = touch.absoluteX - start.x - const dy = touch.absoluteY - start.y - const distance = Math.sqrt(dx * dx + dy * dy) - - if (!longPressArmedRef.current) { - if (distance > CANCEL_SLOP_PX) { - longPressCancelledRef.current = true - stateManager.fail() - } - return - } - - if (didStartDragRef.current) { - return - } - - if (distance > DRAG_SLOP_PX) { - didStartDragRef.current = true - drag() - stateManager.fail() - } - }) - }, [drag]) + return Platform.OS === 'web' ? null : Gesture.Pan().manualActivation(true).runOnJS(true) + }, []) const trigger = ( { - if (didLongPressCleanupTimerRef.current) { - clearTimeout(didLongPressCleanupTimerRef.current) - didLongPressCleanupTimerRef.current = null - } - longPressCancelledRef.current = false - longPressArmedRef.current = false - didStartDragRef.current = false - touchStartRef.current = { x: event.nativeEvent.pageX, y: event.nativeEvent.pageY } - }} - onPressOut={() => { - if (Platform.OS === 'web') { - return - } - if (!longPressArmedRef.current || didStartDragRef.current) { - longPressCancelledRef.current = false - longPressArmedRef.current = false - didStartDragRef.current = false - touchStartRef.current = null - return - } - openContextMenuAtTouchStart() - didLongPressCleanupTimerRef.current = setTimeout(() => { - didLongPressRef.current = false - didLongPressCleanupTimerRef.current = null - }, 0) - longPressCancelledRef.current = false - longPressArmedRef.current = false - didStartDragRef.current = false - touchStartRef.current = null - }} onPress={handlePress} onLongPress={handleLongPress} delayLongPress={200} testID={`sidebar-workspace-row-${workspace.workspaceKey}`} > - + - {resolveWorkspaceBranchLabel(workspace)} + {workspace.name} @@ -399,33 +232,10 @@ function WorkspaceRow({ ) - return ( - <> - {moveMonitorGesture ? ( - {trigger} - ) : ( - trigger - )} - - - } - destructive - disabled={!workspace.isPaseoOwnedWorktree} - status={workspace.isPaseoOwnedWorktree ? archiveStatus : 'idle'} - pendingLabel="Archiving…" - successLabel="Archived" - onSelect={handleArchiveWorktree} - testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`} - > - Archive worktree - - - + return moveMonitorGesture ? ( + {trigger} + ) : ( + trigger ) } @@ -609,11 +419,7 @@ export function SidebarAgentList({ buildSidebarWorkspaceViewModel({ projects, collapsedProjectKeys, - getProjectDisplayName: (project) => - deriveProjectDisplayName({ - projectKey: project.projectKey, - projectName: project.projectName, - }), + getProjectDisplayName: (project) => project.projectName, }), [canonicalResyncNonce, collapsedProjectKeys, projects] ) @@ -655,12 +461,12 @@ export function SidebarAgentList({ ) } - const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspace.cwd) + const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspace.workspaceId) const navigate = shouldReplaceWorkspaceNavigation ? router.replace : router.push const isSelected = Boolean(serverId) && activeWorkspaceSelection?.serverId === serverId && - activeWorkspaceSelection.workspaceId === item.workspace.cwd + activeWorkspaceSelection.workspaceId === item.workspace.workspaceId return ( ["payload"]; +type WorkspaceUpdatePayload = Extract< + SessionOutboundMessage, + { type: "workspace_update" } +>["payload"]; const getAgentIdFromUpdate = (update: AgentUpdatePayload): string => update.kind === "remove" ? update.agentId : update.agent.id; +function normalizeWorkspaceDescriptor( + payload: Extract["workspace"] +): 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, + }; +} + // --------------------------------------------------------------------------- // Module-level pending agent updates buffer (scoped by serverId) // --------------------------------------------------------------------------- @@ -233,7 +256,13 @@ function SessionProviderInternal({ const setHasHydratedAgents = useSessionStore( (state) => state.setHasHydratedAgents ); + const setHasHydratedWorkspaces = useSessionStore( + (state) => state.setHasHydratedWorkspaces + ); const setAgents = useSessionStore((state) => state.setAgents); + const setWorkspaces = useSessionStore((state) => state.setWorkspaces); + const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); + const removeWorkspace = useSessionStore((state) => state.removeWorkspace); const setAgentLastActivity = useSessionStore( (state) => state.setAgentLastActivity ); @@ -413,6 +442,38 @@ function SessionProviderInternal({ } }, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]); + useEffect(() => { + if (!client || !isConnected) { + return; + } + + let cancelled = false; + void client + .fetchWorkspaces({ + subscribe: {}, + page: { limit: 200 }, + }) + .then((payload) => { + if (cancelled) { + return; + } + const workspaces = new Map(); + for (const entry of payload.entries) { + const workspace = normalizeWorkspaceDescriptor(entry); + workspaces.set(workspace.id, workspace); + } + setWorkspaces(serverId, workspaces); + setHasHydratedWorkspaces(serverId, true); + }) + .catch((error) => { + console.error("[Session] Failed to hydrate workspaces:", error); + }); + + return () => { + cancelled = true; + }; + }, [client, isConnected, serverId, setHasHydratedWorkspaces, setWorkspaces]); + const applyAgentUpdatePayload = useCallback( (update: AgentUpdatePayload) => { if (update.kind === "remove") { @@ -891,6 +952,17 @@ function SessionProviderInternal({ } ); + const unsubWorkspaceUpdate = client.on("workspace_update", (message) => { + if (message.type !== "workspace_update") return; + if (message.payload.kind === "remove") { + removeWorkspace(serverId, message.payload.id); + return; + } + mergeWorkspaces(serverId, [ + normalizeWorkspaceDescriptor(message.payload.workspace), + ]); + }); + const unsubStatus = client.on("status", (message) => { if (message.type !== "status") return; const serverInfo = parseServerInfoStatusPayload(message.payload); @@ -1287,6 +1359,7 @@ function SessionProviderInternal({ unsubAgentUpdate(); unsubAgentStream(); unsubAgentTimeline(); + unsubWorkspaceUpdate(); unsubStatus(); unsubPermissionRequest(); unsubPermissionResolved(); @@ -1312,6 +1385,8 @@ function SessionProviderInternal({ setAgentTimelineCursor, setInitializingAgents, setAgents, + mergeWorkspaces, + removeWorkspace, setAgentLastActivity, setPendingPermissions, setHasHydratedAgents, diff --git a/packages/app/src/hooks/use-sidebar-agents-list.ts b/packages/app/src/hooks/use-sidebar-agents-list.ts deleted file mode 100644 index 79723979e..000000000 --- a/packages/app/src/hooks/use-sidebar-agents-list.ts +++ /dev/null @@ -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 - workspacesByKey: Map -} - -interface WorktreeRequest { - requestKey: string - serverId: string - projectKey: string - repoRoot: string -} - -const SIDEBAR_BUCKET_PRIORITY: Record = { - 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(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 -} - -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(), - worktreeRequests: [] as WorktreeRequest[], - hasAnyData: false, - } - } - - const projectsByKey = new Map() - - 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(), - workspacesByKey: new Map(), - } 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 => { - 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() - 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, - } -} diff --git a/packages/app/src/hooks/use-sidebar-agents-list.test.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts similarity index 53% rename from packages/app/src/hooks/use-sidebar-agents-list.test.ts rename to packages/app/src/hooks/use-sidebar-workspaces-list.test.ts index 41d1e29f3..ea587763d 100644 --- a/packages/app/src/hooks/use-sidebar-agents-list.test.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts @@ -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') + }) +}) diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts new file mode 100644 index 000000000..dfeae31c9 --- /dev/null +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -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 = { + 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: 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(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 +} + +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() + 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, + } +} diff --git a/packages/app/src/screens/workspace/workspace-header-source.ts b/packages/app/src/screens/workspace/workspace-header-source.ts new file mode 100644 index 000000000..323322fab --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-header-source.ts @@ -0,0 +1,18 @@ +import type { WorkspaceDescriptor } from '@/stores/session-store' +import { projectDisplayNameFromProjectId } from '@/utils/project-display-name' + +export function resolveWorkspaceHeader(input: { + workspace: WorkspaceDescriptor +}): { title: string; subtitle: string } { + return { + title: input.workspace.name, + subtitle: projectDisplayNameFromProjectId(input.workspace.projectId), + } +} + +export function shouldRenderMissingWorkspaceDescriptor(input: { + workspace: WorkspaceDescriptor | null + hasHydratedWorkspaces: boolean +}): boolean { + return !input.workspace && input.hasHydratedWorkspaces +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 7dc850eab..41420d2ec 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -49,7 +49,10 @@ import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-an import { useToast } from "@/contexts/toast-context"; import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store"; -import { useSessionStore, type Agent } from "@/stores/session-store"; +import { + useSessionStore, + type Agent, +} from "@/stores/session-store"; import { buildWorkspaceTabPersistenceKey, useWorkspaceTabsStore, @@ -80,11 +83,9 @@ import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agen import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import { - deriveProjectDisplayName, - deriveProjectKey, - deriveProjectName, - deriveRemoteProjectKey, -} from "@/utils/agent-grouping"; + resolveWorkspaceHeader, + shouldRenderMissingWorkspaceDescriptor, +} from "@/screens/workspace/workspace-header-source"; const TERMINALS_QUERY_STALE_TIME = 5_000; const DROPDOWN_WIDTH = 220; @@ -152,24 +153,6 @@ function decodeSegment(value: string): string { } } -function deriveWorkspaceName(workspaceId: string): string { - const normalized = workspaceId.replace(/\\/g, "/"); - const segments = normalized.split("/").filter(Boolean); - const last = segments[segments.length - 1]; - return last ?? workspaceId; -} - -function deriveWorkspaceProjectDisplayName(input: { - workspaceId: string; - checkout: CheckoutStatusPayload | null; -}): string { - const projectKey = - deriveRemoteProjectKey(input.checkout?.remoteUrl ?? null) ?? - deriveProjectKey(input.workspaceId); - const projectName = deriveProjectName(projectKey); - return deriveProjectDisplayName({ projectKey, projectName }); -} - function formatProviderLabel(provider: Agent["provider"]): string { if (provider === "claude") { return "Claude"; @@ -407,18 +390,17 @@ function WorkspaceScreenContent({ staleTime: 15_000, }); - const workspaceName = useMemo( - () => deriveWorkspaceName(normalizedWorkspaceId), - [normalizedWorkspaceId] + const workspaceDescriptor = useSessionStore( + (state) => + state.sessions[normalizedServerId]?.workspaces.get(normalizedWorkspaceId) ?? + null ); - const headerProjectName = useMemo( - () => - deriveWorkspaceProjectDisplayName({ - workspaceId: normalizedWorkspaceId, - checkout: checkoutQuery.data ?? null, - }), - [checkoutQuery.data, normalizedWorkspaceId] + const hasHydratedWorkspaces = useSessionStore( + (state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false ); + const workspaceHeader = workspaceDescriptor + ? resolveWorkspaceHeader({ workspace: workspaceDescriptor }) + : null; const isGitCheckout = checkoutQuery.data?.isGit ?? false; const areWorkspaceAgentsHydrated = useSessionStore( @@ -1228,6 +1210,21 @@ function WorkspaceScreenContent({ }, [activeAgent, normalizedServerId, router]); const renderContent = () => { + if ( + shouldRenderMissingWorkspaceDescriptor({ + workspace: workspaceDescriptor, + hasHydratedWorkspaces, + }) + ) { + return ( + + + Workspace descriptor missing for this route. + + + ); + } + const target = activeTab?.target ?? null; if (!target) { return ( @@ -1324,10 +1321,10 @@ function WorkspaceScreenContent({ - {workspaceName} + {workspaceHeader ? workspaceHeader.title : "Loading workspace…"} - {headerProjectName} + {workspaceHeader ? workspaceHeader.subtitle : "Awaiting workspace descriptor"} diff --git a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts new file mode 100644 index 000000000..c5959efc2 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { + resolveWorkspaceHeader, + shouldRenderMissingWorkspaceDescriptor, +} from './workspace-header-source' +import { buildSidebarProjectsFromWorkspaces } from '@/hooks/use-sidebar-workspaces-list' +import type { WorkspaceDescriptor } from '@/stores/session-store' + +describe('workspace source of truth consumption', () => { + it('uses the same descriptor name in header and sidebar row', () => { + const workspace: WorkspaceDescriptor = { + id: '/repo/main', + projectId: 'remote:github.com/getpaseo/paseo', + name: 'feat/workspace-sot', + status: 'running', + activityAt: new Date('2026-03-01T00:00:00.000Z'), + } + + const header = resolveWorkspaceHeader({ workspace }) + const sidebarProjects = buildSidebarProjectsFromWorkspaces({ + serverId: 'srv', + workspaces: [workspace], + projectOrder: [], + workspaceOrderByScope: {}, + }) + + expect(header.title).toBe('feat/workspace-sot') + expect(header.subtitle).toBe('getpaseo/paseo') + expect(sidebarProjects[0]?.workspaces[0]?.name).toBe(header.title) + expect(sidebarProjects[0]?.workspaces[0]?.statusBucket).toBe('running') + }) + + it('renders explicit missing state only after workspace hydration', () => { + expect( + shouldRenderMissingWorkspaceDescriptor({ + workspace: null, + hasHydratedWorkspaces: true, + }) + ).toBe(true) + + expect( + shouldRenderMissingWorkspaceDescriptor({ + workspace: null, + hasHydratedWorkspaces: false, + }) + ).toBe(false) + }) +}) diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 07fe57c2b..0088786b9 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -23,6 +23,7 @@ import type { GitSetupOptions, ProjectPlacementPayload, ServerCapabilities, + WorkspaceDescriptorPayload, } from "@server/shared/messages"; import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf"; import { @@ -108,6 +109,14 @@ export interface Agent { projectPlacement?: ProjectPlacementPayload | null; } +export interface WorkspaceDescriptor { + id: string; + projectId: string; + name: string; + status: WorkspaceDescriptorPayload["status"]; + activityAt: Date | null; +} + export type ExplorerEntryKind = "file" | "directory"; export type ExplorerFileKind = "text" | "image" | "binary"; export type ExplorerEncoding = "utf-8" | "base64" | "none"; @@ -180,6 +189,7 @@ export interface SessionState { // Hydration status hasHydratedAgents: boolean; + hasHydratedWorkspaces: boolean; // Audio state isPlayingAudio: boolean; @@ -203,6 +213,7 @@ export interface SessionState { // Agents agents: Map; + workspaces: Map; // Permissions pendingPermissions: Map; @@ -267,6 +278,17 @@ interface SessionStoreActions { // Agents setAgents: (serverId: string, agents: Map | ((prev: Map) => Map)) => void; + setWorkspaces: ( + serverId: string, + workspaces: + | Map + | ((prev: Map) => Map) + ) => void; + mergeWorkspaces: ( + serverId: string, + workspaces: Iterable + ) => void; + removeWorkspace: (serverId: string, workspaceId: string) => void; // Agent activity timestamps setAgentLastActivity: (agentId: string, timestamp: Date) => void; @@ -296,6 +318,7 @@ interface SessionStoreActions { // Hydration setHasHydratedAgents: (serverId: string, hydrated: boolean) => void; + setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void; // Agent directory (derived from agents) getAgentDirectory: (serverId: string) => AgentDirectoryEntry[] | undefined; @@ -335,6 +358,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient, audio serverInfo: null, audioPlayer, hasHydratedAgents: false, + hasHydratedWorkspaces: false, isPlayingAudio: false, focusedAgentId: null, messages: [], @@ -346,6 +370,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient, audio agentHistorySyncGeneration: new Map(), initializingAgents: new Map(), agents: new Map(), + workspaces: new Map(), pendingPermissions: new Map(), fileExplorer: new Map(), queuedMessages: new Map(), @@ -851,6 +876,78 @@ export const useSessionStore = create()( }); }, + setWorkspaces: (serverId, workspaces) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) { + return prev; + } + const nextWorkspaces = + typeof workspaces === "function" ? workspaces(session.workspaces) : workspaces; + if (session.workspaces === nextWorkspaces) { + return prev; + } + logSessionStoreUpdate("setWorkspaces", serverId, { count: nextWorkspaces.size }); + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, workspaces: nextWorkspaces }, + }, + }; + }); + }, + + mergeWorkspaces: (serverId, workspaces) => { + const nextEntries = Array.from(workspaces); + set((prev) => { + const session = prev.sessions[serverId]; + if (!session || nextEntries.length === 0) { + return prev; + } + const next = new Map(session.workspaces); + let changed = false; + for (const workspace of nextEntries) { + const existing = next.get(workspace.id); + if (existing === workspace) { + continue; + } + next.set(workspace.id, workspace); + changed = true; + } + if (!changed) { + return prev; + } + logSessionStoreUpdate("mergeWorkspaces", serverId, { count: nextEntries.length }); + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, workspaces: next }, + }, + }; + }); + }, + + removeWorkspace: (serverId, workspaceId) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session || !session.workspaces.has(workspaceId)) { + return prev; + } + const next = new Map(session.workspaces); + next.delete(workspaceId); + logSessionStoreUpdate("removeWorkspace", serverId, { workspaceId }); + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, workspaces: next }, + }, + }; + }); + }, + // Agent activity timestamps (top-level, does NOT mutate session object) setAgentLastActivity: (agentId, timestamp) => { agentLastActivityCoalescer.enqueue(agentId, timestamp); @@ -980,6 +1077,23 @@ export const useSessionStore = create()( }); }, + setHasHydratedWorkspaces: (serverId, hydrated) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session || session.hasHydratedWorkspaces === hydrated) { + return prev; + } + logSessionStoreUpdate("setHasHydratedWorkspaces", serverId, { hydrated }); + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, hasHydratedWorkspaces: hydrated }, + }, + }; + }); + }, + // Agent directory - derived from agents (computed on-demand) getAgentDirectory: (serverId) => { const state = get(); diff --git a/packages/app/src/utils/project-display-name.ts b/packages/app/src/utils/project-display-name.ts new file mode 100644 index 000000000..9dce1588a --- /dev/null +++ b/packages/app/src/utils/project-display-name.ts @@ -0,0 +1,9 @@ +export function projectDisplayNameFromProjectId(projectId: string): string { + const githubRemotePrefix = "remote:github.com/"; + if (projectId.startsWith(githubRemotePrefix)) { + return projectId.slice(githubRemotePrefix.length) || projectId; + } + + const segments = projectId.split(/[\\/]/).filter(Boolean); + return segments[segments.length - 1] || projectId; +} diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index e1b6a77cf..6c7b566cc 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { SidebarProjectEntry, SidebarWorkspaceEntry } from "@/hooks/use-sidebar-agents-list"; +import type { SidebarProjectEntry, SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import { buildSidebarWorkspaceViewModel } from "./sidebar-shortcuts"; @@ -7,11 +7,9 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry { return { workspaceKey: `${serverId}:${cwd}`, serverId, - cwd, - branchName: null, - createdAt: null, - isMainCheckout: false, - isPaseoOwnedWorktree: false, + workspaceId: cwd, + name: cwd, + activityAt: null, statusBucket: "done", }; } @@ -20,11 +18,11 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb return { projectKey, projectName: projectKey, - iconWorkingDir: workspaces[0]?.cwd ?? "", + iconWorkingDir: workspaces[0]?.workspaceId ?? "", statusBucket: "done", activeCount: 0, - totalCount: workspaces.length, - latestCreatedAt: null, + totalWorkspaces: workspaces.length, + latestActivityAt: null, workspaces, }; } diff --git a/packages/app/src/utils/sidebar-shortcuts.ts b/packages/app/src/utils/sidebar-shortcuts.ts index 9f4d5d881..f721782f4 100644 --- a/packages/app/src/utils/sidebar-shortcuts.ts +++ b/packages/app/src/utils/sidebar-shortcuts.ts @@ -1,7 +1,7 @@ import type { SidebarProjectEntry, SidebarWorkspaceEntry, -} from '@/hooks/use-sidebar-agents-list' +} from '@/hooks/use-sidebar-workspaces-list' export interface SidebarShortcutWorkspaceTarget { serverId: string @@ -58,7 +58,7 @@ export function buildSidebarWorkspaceViewModel(input: { if (shortcutNumber !== null) { shortcutTargets.push({ serverId: workspace.serverId, - workspaceId: workspace.cwd, + workspaceId: workspace.workspaceId, }) shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber) } diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 92acf32c7..e1ac5b7d1 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -127,6 +127,11 @@ export type DaemonEvent = agentId: string payload: Extract['payload'] } + | { + type: 'workspace_update' + workspaceId: string + payload: Extract['payload'] + } | { type: 'agent_stream' agentId: string @@ -287,6 +292,19 @@ export type FetchAgentsOptions = Omit } export type FetchAgentsEntry = FetchAgentsPayload['entries'][number] export type FetchAgentsPageInfo = FetchAgentsPayload['pageInfo'] +type FetchWorkspacesPayload = Extract< + SessionOutboundMessage, + { type: 'fetch_workspaces_response' } +>['payload'] +type FetchWorkspacesRequest = Extract< + SessionInboundMessage, + { type: 'fetch_workspaces_request' } +> +export type FetchWorkspacesOptions = Omit & { + requestId?: string +} +export type FetchWorkspacesEntry = FetchWorkspacesPayload['entries'][number] +export type FetchWorkspacesPageInfo = FetchWorkspacesPayload['pageInfo'] export type FetchAgentResult = { agent: AgentSnapshotPayload @@ -1098,6 +1116,33 @@ export class DaemonClient { }) } + async fetchWorkspaces(options?: FetchWorkspacesOptions): Promise { + const resolvedRequestId = this.createRequestId(options?.requestId) + const message = SessionInboundMessageSchema.parse({ + type: 'fetch_workspaces_request', + requestId: resolvedRequestId, + ...(options?.filter ? { filter: options.filter } : {}), + ...(options?.sort ? { sort: options.sort } : {}), + ...(options?.page ? { page: options.page } : {}), + ...(options?.subscribe ? { subscribe: options.subscribe } : {}), + }) + return this.sendRequest({ + requestId: resolvedRequestId, + message, + timeout: 10000, + options: { skipQueue: true }, + select: (msg) => { + if (msg.type !== 'fetch_workspaces_response') { + return null + } + if (msg.payload.requestId !== resolvedRequestId) { + return null + } + return msg.payload + }, + }) + } + async fetchAgent(agentId: string, requestId?: string): Promise { const resolvedRequestId = this.createRequestId(requestId) const message = SessionInboundMessageSchema.parse({ @@ -3077,6 +3122,13 @@ export class DaemonClient { agentId: msg.payload.kind === 'upsert' ? msg.payload.agent.id : msg.payload.agentId, payload: msg.payload, } + case 'workspace_update': + return { + type: 'workspace_update', + workspaceId: + msg.payload.kind === 'upsert' ? msg.payload.workspace.id : msg.payload.id, + payload: msg.payload, + } case 'agent_stream': return { type: 'agent_stream', diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 148a2689e..f336801d5 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -30,6 +30,8 @@ import { type DirectorySuggestionsRequest, type ProjectCheckoutLitePayload, type ProjectPlacementPayload, + type WorkspaceDescriptorPayload, + type WorkspaceStateBucket, } from './messages.js' import type { TerminalManager, TerminalsChangedEvent } from '../terminal/terminal-manager.js' import type { TerminalSession } from '../terminal/terminal.js' @@ -301,6 +303,31 @@ type FetchAgentsCursor = { values: Record id: string } +type FetchWorkspacesRequestMessage = Extract< + SessionInboundMessage, + { type: 'fetch_workspaces_request' } +> +type FetchWorkspacesRequestFilter = NonNullable +type FetchWorkspacesRequestSort = NonNullable[number] +type FetchWorkspacesResponsePayload = Extract< + SessionOutboundMessage, + { type: 'fetch_workspaces_response' } +>['payload'] +type FetchWorkspacesResponseEntry = FetchWorkspacesResponsePayload['entries'][number] +type FetchWorkspacesResponsePageInfo = FetchWorkspacesResponsePayload['pageInfo'] +type WorkspaceUpdatePayload = Extract['payload'] +type WorkspaceUpdatesFilter = FetchWorkspacesRequestFilter +type WorkspaceUpdatesSubscriptionState = { + subscriptionId: string + filter?: WorkspaceUpdatesFilter + isBootstrapping: boolean + pendingUpdatesByWorkspaceId: Map +} +type FetchWorkspacesCursor = { + sort: FetchWorkspacesRequestSort[] + values: Record + id: string +} class SessionRequestError extends Error { constructor( @@ -552,6 +579,7 @@ export class Session { private readonly providerRegistry: ReturnType private unsubscribeAgentEvents: (() => void) | null = null private agentUpdatesSubscription: AgentUpdatesSubscriptionState | null = null + private workspaceUpdatesSubscription: WorkspaceUpdatesSubscriptionState | null = null private clientActivity: { deviceType: 'web' | 'mobile' focusedAgentId: string | null @@ -1258,31 +1286,30 @@ export class Session { private async forwardAgentUpdate(agent: ManagedAgent): Promise { try { const subscription = this.agentUpdatesSubscription - if (!subscription) { - return - } - const payload = await this.buildAgentPayload(agent) - const project = await this.buildProjectPlacement(payload.cwd) - const matches = this.matchesAgentFilter({ - agent: payload, - project, - filter: subscription.filter, - }) - - if (matches) { - this.bufferOrEmitAgentUpdate(subscription, { - kind: 'upsert', + if (subscription) { + const project = await this.buildProjectPlacement(payload.cwd) + const matches = this.matchesAgentFilter({ agent: payload, project, + filter: subscription.filter, }) - return + + if (matches) { + this.bufferOrEmitAgentUpdate(subscription, { + kind: 'upsert', + agent: payload, + project, + }) + } else { + this.bufferOrEmitAgentUpdate(subscription, { + kind: 'remove', + agentId: payload.id, + }) + } } - this.bufferOrEmitAgentUpdate(subscription, { - kind: 'remove', - agentId: payload.id, - }) + await this.emitWorkspaceUpdateForCwd(payload.cwd) } catch (error) { this.sessionLogger.error({ err: error }, 'Failed to emit agent update') } @@ -1310,6 +1337,10 @@ export class Session { await this.handleFetchAgents(msg) break + case 'fetch_workspaces_request': + await this.handleFetchWorkspacesRequest(msg) + break + case 'fetch_agent_request': await this.handleFetchAgent(msg.agentId, msg.requestId) break @@ -1728,6 +1759,11 @@ export class Session { private async handleDeleteAgentRequest(agentId: string, requestId: string): Promise { this.sessionLogger.info({ agentId }, `Deleting agent ${agentId} from registry`) + const knownCwd = + this.agentManager.getAgent(agentId)?.cwd ?? + (await this.agentStorage.get(agentId))?.cwd ?? + null + // Prevent the persistence hook from re-creating the record while we close/delete. this.agentStorage.beginDelete(agentId) @@ -1763,6 +1799,10 @@ export class Session { agentId, }) } + + if (knownCwd) { + await this.emitWorkspaceUpdateForCwd(knownCwd) + } } private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise { @@ -5203,6 +5243,434 @@ export class Session { } } + private readonly workspaceStatePriority: Record = { + needs_input: 0, + failed: 1, + running: 2, + attention: 3, + done: 4, + } + + private normalizeWorkspaceId(cwd: string): string { + const trimmed = cwd.trim() + if (!trimmed) { + return cwd + } + return resolve(trimmed) + } + + private deriveWorkspaceStateBucket(agent: AgentSnapshotPayload): WorkspaceStateBucket { + const pendingPermissionCount = agent.pendingPermissions?.length ?? 0 + if (pendingPermissionCount > 0 || agent.attentionReason === 'permission') { + return 'needs_input' + } + if (agent.status === 'error' || agent.attentionReason === 'error') { + return 'failed' + } + if (agent.status === 'running' || agent.status === 'initializing') { + return 'running' + } + if (agent.requiresAttention) { + return 'attention' + } + return 'done' + } + + private deriveWorkspaceDirectoryName(cwd: string): string { + const normalized = cwd.replace(/\\/g, '/') + const segments = normalized.split('/').filter(Boolean) + return segments[segments.length - 1] ?? cwd + } + + private deriveWorkspaceName(input: { + cwd: string + checkout: ProjectCheckoutLitePayload + }): string { + const branch = input.checkout.currentBranch?.trim() ?? null + if (branch && branch.toUpperCase() !== 'HEAD') { + return branch + } + return this.deriveWorkspaceDirectoryName(input.cwd) + } + + private accumulateLatestActivityAt( + current: string | null, + agent: AgentSnapshotPayload + ): string | null { + const candidateRaw = agent.lastUserMessageAt ?? agent.updatedAt + const candidateMs = Date.parse(candidateRaw) + if (Number.isNaN(candidateMs)) { + return current + } + if (!current) { + return new Date(candidateMs).toISOString() + } + const currentMs = Date.parse(current) + if (Number.isNaN(currentMs) || candidateMs > currentMs) { + return new Date(candidateMs).toISOString() + } + return current + } + + private async listWorkspaceDescriptors(): Promise { + const agents = await this.listAgentPayloads({ + labels: { ui: 'true' }, + }) + + const descriptorsByWorkspaceId = new Map() + const placementByWorkspaceId = new Map>() + const getPlacement = (workspaceCwd: string): Promise => { + const key = this.normalizeWorkspaceId(workspaceCwd) + const existing = placementByWorkspaceId.get(key) + if (existing) { + return existing + } + const next = this.buildProjectPlacement(workspaceCwd) + placementByWorkspaceId.set(key, next) + return next + } + + for (const agent of agents) { + if (agent.archivedAt) { + continue + } + + const workspaceId = this.normalizeWorkspaceId(agent.cwd) + const placement = await getPlacement(workspaceId) + const existing = descriptorsByWorkspaceId.get(workspaceId) + if (!existing) { + const bucket = this.deriveWorkspaceStateBucket(agent) + descriptorsByWorkspaceId.set(workspaceId, { + id: workspaceId, + projectId: placement.projectKey, + name: this.deriveWorkspaceName({ + cwd: workspaceId, + checkout: placement.checkout, + }), + status: bucket, + activityAt: this.accumulateLatestActivityAt(null, agent), + }) + continue + } + + const bucket = this.deriveWorkspaceStateBucket(agent) + if (this.workspaceStatePriority[bucket] < this.workspaceStatePriority[existing.status]) { + existing.status = bucket + } + existing.activityAt = this.accumulateLatestActivityAt(existing.activityAt, agent) + } + + return Array.from(descriptorsByWorkspaceId.values()) + } + + private normalizeFetchWorkspacesSort( + sort: FetchWorkspacesRequestSort[] | undefined + ): FetchWorkspacesRequestSort[] { + const fallback: FetchWorkspacesRequestSort[] = [{ key: 'activity_at', direction: 'desc' }] + if (!sort || sort.length === 0) { + return fallback + } + const deduped: FetchWorkspacesRequestSort[] = [] + const seen = new Set() + for (const entry of sort) { + if (seen.has(entry.key)) { + continue + } + seen.add(entry.key) + deduped.push(entry) + } + return deduped.length > 0 ? deduped : fallback + } + + private getFetchWorkspacesSortValue( + workspace: WorkspaceDescriptorPayload, + key: FetchWorkspacesRequestSort['key'] + ): string | number | null { + switch (key) { + case 'status_priority': + return this.workspaceStatePriority[workspace.status] + case 'activity_at': + return workspace.activityAt ? Date.parse(workspace.activityAt) : null + case 'name': + return workspace.name.toLocaleLowerCase() + case 'project_id': + return workspace.projectId.toLocaleLowerCase() + } + } + + private compareFetchWorkspacesEntries( + left: WorkspaceDescriptorPayload, + right: WorkspaceDescriptorPayload, + sort: FetchWorkspacesRequestSort[] + ): number { + for (const spec of sort) { + const leftValue = this.getFetchWorkspacesSortValue(left, spec.key) + const rightValue = this.getFetchWorkspacesSortValue(right, spec.key) + const base = this.compareSortValues(leftValue, rightValue) + if (base === 0) { + continue + } + return spec.direction === 'asc' ? base : -base + } + return left.id.localeCompare(right.id) + } + + private encodeFetchWorkspacesCursor( + entry: FetchWorkspacesResponseEntry, + sort: FetchWorkspacesRequestSort[] + ): string { + const values: Record = {} + for (const spec of sort) { + values[spec.key] = this.getFetchWorkspacesSortValue(entry, spec.key) + } + return Buffer.from( + JSON.stringify({ + sort, + values, + id: entry.id, + }), + 'utf8' + ).toString('base64url') + } + + private decodeFetchWorkspacesCursor( + cursor: string, + sort: FetchWorkspacesRequestSort[] + ): FetchWorkspacesCursor { + let parsed: unknown + try { + parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) + } catch { + throw new SessionRequestError('invalid_cursor', 'Invalid fetch_workspaces cursor') + } + + if (!parsed || typeof parsed !== 'object') { + throw new SessionRequestError('invalid_cursor', 'Invalid fetch_workspaces cursor') + } + + const payload = parsed as { + sort?: unknown + values?: unknown + id?: unknown + } + + if (!Array.isArray(payload.sort) || typeof payload.id !== 'string') { + throw new SessionRequestError('invalid_cursor', 'Invalid fetch_workspaces cursor') + } + if (!payload.values || typeof payload.values !== 'object') { + throw new SessionRequestError('invalid_cursor', 'Invalid fetch_workspaces cursor') + } + + const cursorSort: FetchWorkspacesRequestSort[] = [] + for (const item of payload.sort) { + if ( + !item || + typeof item !== 'object' || + typeof (item as { key?: unknown }).key !== 'string' || + typeof (item as { direction?: unknown }).direction !== 'string' + ) { + throw new SessionRequestError('invalid_cursor', 'Invalid fetch_workspaces cursor') + } + + const key = (item as { key: string }).key + const direction = (item as { direction: string }).direction + if ( + (key !== 'status_priority' && + key !== 'activity_at' && + key !== 'name' && + key !== 'project_id') || + (direction !== 'asc' && direction !== 'desc') + ) { + throw new SessionRequestError('invalid_cursor', 'Invalid fetch_workspaces cursor') + } + cursorSort.push({ key, direction }) + } + + if ( + cursorSort.length !== sort.length || + cursorSort.some( + (entry, index) => + entry.key !== sort[index]?.key || entry.direction !== sort[index]?.direction + ) + ) { + throw new SessionRequestError( + 'invalid_cursor', + 'fetch_workspaces cursor does not match current sort' + ) + } + + return { + sort: cursorSort, + values: payload.values as Record, + id: payload.id, + } + } + + private compareWorkspaceWithCursor( + workspace: WorkspaceDescriptorPayload, + cursor: FetchWorkspacesCursor, + sort: FetchWorkspacesRequestSort[] + ): number { + for (const spec of sort) { + const leftValue = this.getFetchWorkspacesSortValue(workspace, spec.key) + const rightValue = + cursor.values[spec.key] !== undefined ? (cursor.values[spec.key] ?? null) : null + const base = this.compareSortValues(leftValue, rightValue) + if (base === 0) { + continue + } + return spec.direction === 'asc' ? base : -base + } + return workspace.id.localeCompare(cursor.id) + } + + private matchesWorkspaceFilter(input: { + workspace: WorkspaceDescriptorPayload + filter: FetchWorkspacesRequestFilter | undefined + }): boolean { + const { workspace, filter } = input + if (!filter) { + return true + } + + if (filter.projectId && filter.projectId.trim().length > 0) { + if (workspace.projectId !== filter.projectId.trim()) { + return false + } + } + + if (filter.idPrefix && filter.idPrefix.trim().length > 0) { + if (!workspace.id.startsWith(filter.idPrefix.trim())) { + return false + } + } + + if (filter.query && filter.query.trim().length > 0) { + const query = filter.query.trim().toLocaleLowerCase() + const haystacks = [workspace.name, workspace.projectId, workspace.id] + if (!haystacks.some((value) => value.toLocaleLowerCase().includes(query))) { + return false + } + } + + return true + } + + private async listFetchWorkspacesEntries( + request: Extract + ): Promise<{ + entries: FetchWorkspacesResponseEntry[] + pageInfo: FetchWorkspacesResponsePageInfo + }> { + const filter = request.filter + const sort = this.normalizeFetchWorkspacesSort(request.sort) + let entries = await this.listWorkspaceDescriptors() + entries = entries.filter((workspace) => this.matchesWorkspaceFilter({ workspace, filter })) + entries.sort((left, right) => this.compareFetchWorkspacesEntries(left, right, sort)) + + const cursorToken = request.page?.cursor + if (cursorToken) { + const cursor = this.decodeFetchWorkspacesCursor(cursorToken, sort) + entries = entries.filter((workspace) => this.compareWorkspaceWithCursor(workspace, cursor, sort) > 0) + } + + const limit = request.page?.limit ?? 200 + const pagedEntries = entries.slice(0, limit) + const hasMore = entries.length > limit + const nextCursor = + hasMore && pagedEntries.length > 0 + ? this.encodeFetchWorkspacesCursor(pagedEntries[pagedEntries.length - 1], sort) + : null + + return { + entries: pagedEntries, + pageInfo: { + nextCursor, + prevCursor: request.page?.cursor ?? null, + hasMore, + }, + } + } + + private bufferOrEmitWorkspaceUpdate( + subscription: WorkspaceUpdatesSubscriptionState, + payload: WorkspaceUpdatePayload + ): void { + if (subscription.isBootstrapping) { + const workspaceId = payload.kind === 'upsert' ? payload.workspace.id : payload.id + subscription.pendingUpdatesByWorkspaceId.set(workspaceId, payload) + return + } + this.emit({ + type: 'workspace_update', + payload, + }) + } + + private flushBootstrappedWorkspaceUpdates(options?: { + snapshotLatestActivityByWorkspaceId?: Map + }): void { + const subscription = this.workspaceUpdatesSubscription + if (!subscription || !subscription.isBootstrapping) { + return + } + + subscription.isBootstrapping = false + const pending = Array.from(subscription.pendingUpdatesByWorkspaceId.values()) + subscription.pendingUpdatesByWorkspaceId.clear() + + for (const payload of pending) { + if (payload.kind === 'upsert') { + const snapshotLatestActivity = options?.snapshotLatestActivityByWorkspaceId?.get( + payload.workspace.id + ) + if (typeof snapshotLatestActivity === 'number') { + const updateLatestActivity = payload.workspace.activityAt + ? Date.parse(payload.workspace.activityAt) + : Number.NEGATIVE_INFINITY + if (!Number.isNaN(updateLatestActivity) && updateLatestActivity <= snapshotLatestActivity) { + continue + } + } + } + this.emit({ + type: 'workspace_update', + payload, + }) + } + } + + private async emitWorkspaceUpdateForCwd(cwd: string): Promise { + const subscription = this.workspaceUpdatesSubscription + if (!subscription) { + return + } + + const workspaceId = this.normalizeWorkspaceId(cwd) + const all = await this.listWorkspaceDescriptors() + const workspace = all.find((entry) => entry.id === workspaceId) + if (!workspace) { + this.bufferOrEmitWorkspaceUpdate(subscription, { + kind: 'remove', + id: workspaceId, + }) + return + } + + if (!this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })) { + this.bufferOrEmitWorkspaceUpdate(subscription, { + kind: 'remove', + id: workspaceId, + }) + return + } + + this.bufferOrEmitWorkspaceUpdate(subscription, { + kind: 'upsert', + workspace, + }) + } + private async handleFetchAgents( request: Extract ): Promise { @@ -5263,6 +5731,68 @@ export class Session { } } + private async handleFetchWorkspacesRequest( + request: Extract + ): Promise { + const requestedSubscriptionId = request.subscribe?.subscriptionId?.trim() + const subscriptionId = request.subscribe + ? requestedSubscriptionId && requestedSubscriptionId.length > 0 + ? requestedSubscriptionId + : uuidv4() + : null + + try { + if (subscriptionId) { + this.workspaceUpdatesSubscription = { + subscriptionId, + filter: request.filter, + isBootstrapping: true, + pendingUpdatesByWorkspaceId: new Map(), + } + } + + const payload = await this.listFetchWorkspacesEntries(request) + const snapshotLatestActivityByWorkspaceId = new Map() + for (const entry of payload.entries) { + const parsedLatestActivity = entry.activityAt + ? Date.parse(entry.activityAt) + : Number.NEGATIVE_INFINITY + if (!Number.isNaN(parsedLatestActivity)) { + snapshotLatestActivityByWorkspaceId.set(entry.id, parsedLatestActivity) + } + } + + this.emit({ + type: 'fetch_workspaces_response', + payload: { + requestId: request.requestId, + ...(subscriptionId ? { subscriptionId } : {}), + ...payload, + }, + }) + + if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) { + this.flushBootstrappedWorkspaceUpdates({ snapshotLatestActivityByWorkspaceId }) + } + } catch (error) { + if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) { + this.workspaceUpdatesSubscription = null + } + const code = error instanceof SessionRequestError ? error.code : 'fetch_workspaces_failed' + const message = error instanceof Error ? error.message : 'Failed to fetch workspaces' + this.sessionLogger.error({ err: error }, 'Failed to handle fetch_workspaces_request') + this.emit({ + type: 'rpc_error', + payload: { + requestId: request.requestId, + requestType: request.type, + error: message, + code, + }, + }) + } + } + private async handleFetchAgent(agentIdOrIdentifier: string, requestId: string): Promise { const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier) if (!resolved.ok) { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts new file mode 100644 index 000000000..e8b23439c --- /dev/null +++ b/packages/server/src/server/session.workspaces.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, test, vi } from 'vitest' +import { Session } from './session.js' +import type { AgentSnapshotPayload } from '../shared/messages.js' + +function makeAgent(input: { + id: string + cwd: string + status: AgentSnapshotPayload['status'] + updatedAt: string + pendingPermissions?: number + requiresAttention?: boolean + attentionReason?: AgentSnapshotPayload['attentionReason'] +}): AgentSnapshotPayload { + const pendingPermissionCount = input.pendingPermissions ?? 0 + return { + id: input.id, + provider: 'codex', + cwd: input.cwd, + model: null, + thinkingOptionId: null, + effectiveThinkingOptionId: null, + createdAt: input.updatedAt, + updatedAt: input.updatedAt, + lastUserMessageAt: null, + status: input.status, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: Array.from({ length: pendingPermissionCount }, (_, index) => ({ + id: `perm-${input.id}-${index}`, + provider: 'codex', + name: 'tool', + kind: 'tool', + })), + persistence: null, + runtimeInfo: { + provider: 'codex', + sessionId: null, + }, + title: null, + labels: { ui: 'true' }, + requiresAttention: input.requiresAttention ?? false, + attentionReason: input.attentionReason ?? null, + attentionTimestamp: null, + archivedAt: null, + } +} + +function createSessionForWorkspaceTests(): Session { + const logger = { + child: () => logger, + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } + + return new Session({ + clientId: 'test-client', + onMessage: vi.fn(), + logger: logger as any, + downloadTokenStore: {} as any, + pushTokenStore: {} as any, + paseoHome: '/tmp/paseo-test', + agentManager: { + subscribe: () => () => {}, + listAgents: () => [], + getAgent: () => null, + } as any, + agentStorage: { + list: async () => [], + get: async () => null, + } as any, + createAgentMcpTransport: async () => { + throw new Error('not used') + }, + stt: null, + tts: null, + terminalManager: null, + }) +} + +describe('workspace aggregation', () => { + test('non-git workspace uses deterministic directory name and no unknown branch fallback', async () => { + const session = createSessionForWorkspaceTests() as any + session.listAgentPayloads = async () => [ + makeAgent({ + id: 'a1', + cwd: '/tmp/non-git', + status: 'idle', + updatedAt: '2026-03-01T12:00:00.000Z', + }), + ] + session.buildProjectPlacement = async (cwd: string) => ({ + projectKey: cwd, + projectName: 'non-git', + checkout: { + cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }) + + const result = await session.listFetchWorkspacesEntries({ + type: 'fetch_workspaces_request', + requestId: 'req-1', + }) + + expect(result.entries).toHaveLength(1) + expect(result.entries[0]?.name).toBe('non-git') + expect(result.entries[0]?.name).not.toBe('Unknown branch') + }) + + test('git branch workspace uses branch as canonical name', async () => { + const session = createSessionForWorkspaceTests() as any + session.listAgentPayloads = async () => [ + makeAgent({ + id: 'a1', + cwd: '/tmp/repo-branch', + status: 'running', + updatedAt: '2026-03-01T12:00:00.000Z', + }), + ] + session.buildProjectPlacement = async (cwd: string) => ({ + projectKey: cwd, + projectName: 'repo-branch', + checkout: { + cwd, + isGit: true, + currentBranch: 'feature/name-from-server', + remoteUrl: 'https://github.com/acme/repo-branch.git', + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }) + + const result = await session.listFetchWorkspacesEntries({ + type: 'fetch_workspaces_request', + requestId: 'req-branch', + }) + + expect(result.entries).toHaveLength(1) + expect(result.entries[0]?.name).toBe('feature/name-from-server') + }) + + test('branch/detached policies and dominant status bucket are deterministic', async () => { + const session = createSessionForWorkspaceTests() as any + session.listAgentPayloads = async () => [ + makeAgent({ + id: 'a1', + cwd: '/tmp/repo', + status: 'running', + updatedAt: '2026-03-01T12:00:00.000Z', + }), + makeAgent({ + id: 'a2', + cwd: '/tmp/repo', + status: 'error', + updatedAt: '2026-03-01T12:01:00.000Z', + }), + makeAgent({ + id: 'a3', + cwd: '/tmp/repo', + status: 'idle', + updatedAt: '2026-03-01T12:02:00.000Z', + pendingPermissions: 1, + }), + ] + session.buildProjectPlacement = async (cwd: string) => ({ + projectKey: cwd, + projectName: 'repo', + checkout: { + cwd, + isGit: true, + currentBranch: 'HEAD', + remoteUrl: 'https://github.com/acme/repo.git', + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }) + + const result = await session.listFetchWorkspacesEntries({ + type: 'fetch_workspaces_request', + requestId: 'req-2', + }) + + expect(result.entries).toHaveLength(1) + expect(result.entries[0]?.name).toBe('repo') + expect(result.entries[0]?.status).toBe('needs_input') + }) + + test('workspace update stream emits upsert and remove on lifecycle changes', async () => { + const emitted: Array<{ type: string; payload: unknown }> = [] + const logger = { + child: () => logger, + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } + + const session = new Session({ + clientId: 'test-client', + onMessage: (message) => emitted.push(message as any), + logger: logger as any, + downloadTokenStore: {} as any, + pushTokenStore: {} as any, + paseoHome: '/tmp/paseo-test', + agentManager: { + subscribe: () => () => {}, + listAgents: () => [], + getAgent: () => null, + } as any, + agentStorage: { + list: async () => [], + get: async () => null, + } as any, + createAgentMcpTransport: async () => { + throw new Error('not used') + }, + stt: null, + tts: null, + terminalManager: null, + }) as any + + session.workspaceUpdatesSubscription = { + subscriptionId: 'sub-1', + filter: undefined, + isBootstrapping: false, + pendingUpdatesByWorkspaceId: new Map(), + } + + session.listWorkspaceDescriptors = async () => [ + { + id: '/tmp/repo', + projectId: '/tmp/repo', + name: 'repo', + status: 'running', + activityAt: '2026-03-01T12:00:00.000Z', + }, + ] + await session.emitWorkspaceUpdateForCwd('/tmp/repo') + + session.listWorkspaceDescriptors = async () => [] + await session.emitWorkspaceUpdateForCwd('/tmp/repo') + + const workspaceUpdates = emitted.filter((message) => message.type === 'workspace_update') + expect(workspaceUpdates).toHaveLength(2) + expect((workspaceUpdates[0] as any).payload.kind).toBe('upsert') + expect((workspaceUpdates[1] as any).payload).toEqual({ + kind: 'remove', + id: '/tmp/repo', + }) + }) +}) diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index f48f1ab66..4155181bc 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -512,6 +512,45 @@ export const FetchAgentsRequestMessageSchema = z.object({ .optional(), }) +const WorkspaceStateBucketSchema = z.enum([ + 'needs_input', + 'failed', + 'running', + 'attention', + 'done', +]) + +export const FetchWorkspacesRequestMessageSchema = z.object({ + type: z.literal('fetch_workspaces_request'), + requestId: z.string(), + filter: z + .object({ + query: z.string().optional(), + projectId: z.string().optional(), + idPrefix: z.string().optional(), + }) + .optional(), + sort: z + .array( + z.object({ + key: z.enum(['status_priority', 'activity_at', 'name', 'project_id']), + direction: z.enum(['asc', 'desc']), + }) + ) + .optional(), + page: z + .object({ + limit: z.number().int().positive().max(200), + cursor: z.string().min(1).optional(), + }) + .optional(), + subscribe: z + .object({ + subscriptionId: z.string().optional(), + }) + .optional(), +}) + export const FetchAgentRequestMessageSchema = z.object({ type: z.literal('fetch_agent_request'), requestId: z.string(), @@ -1068,6 +1107,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion('type', [ AbortRequestMessageSchema, AudioPlayedMessageSchema, FetchAgentsRequestMessageSchema, + FetchWorkspacesRequestMessageSchema, FetchAgentRequestMessageSchema, DeleteAgentRequestMessageSchema, ArchiveAgentRequestMessageSchema, @@ -1426,6 +1466,14 @@ export const ProjectPlacementPayloadSchema = z.object({ checkout: ProjectCheckoutLitePayloadSchema, }) +export const WorkspaceDescriptorPayloadSchema = z.object({ + id: z.string(), + projectId: z.string(), + name: z.string(), + status: WorkspaceStateBucketSchema, + activityAt: z.string().nullable(), +}) + export const AgentUpdateMessageSchema = z.object({ type: z.literal('agent_update'), payload: z.discriminatedUnion('kind', [ @@ -1488,6 +1536,34 @@ export const FetchAgentsResponseMessageSchema = z.object({ }), }) +export const FetchWorkspacesResponseMessageSchema = z.object({ + type: z.literal('fetch_workspaces_response'), + payload: z.object({ + requestId: z.string(), + subscriptionId: z.string().nullable().optional(), + entries: z.array(WorkspaceDescriptorPayloadSchema), + pageInfo: z.object({ + nextCursor: z.string().nullable(), + prevCursor: z.string().nullable(), + hasMore: z.boolean(), + }), + }), +}) + +export const WorkspaceUpdateMessageSchema = z.object({ + type: z.literal('workspace_update'), + payload: z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('upsert'), + workspace: WorkspaceDescriptorPayloadSchema, + }), + z.object({ + kind: z.literal('remove'), + id: z.string(), + }), + ]), +}) + export const FetchAgentResponseMessageSchema = z.object({ type: z.literal('fetch_agent_response'), payload: z.object({ @@ -2051,9 +2127,11 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion('type', [ RpcErrorMessageSchema, ArtifactMessageSchema, AgentUpdateMessageSchema, + WorkspaceUpdateMessageSchema, AgentStreamMessageSchema, AgentStatusMessageSchema, FetchAgentsResponseMessageSchema, + FetchWorkspacesResponseMessageSchema, FetchAgentResponseMessageSchema, FetchAgentTimelineResponseMessageSchema, SendAgentMessageResponseMessageSchema, @@ -2119,7 +2197,10 @@ export type AgentStreamMessage = z.infer export type AgentStatusMessage = z.infer export type ProjectCheckoutLitePayload = z.infer export type ProjectPlacementPayload = z.infer +export type WorkspaceStateBucket = z.infer +export type WorkspaceDescriptorPayload = z.infer export type FetchAgentsResponseMessage = z.infer +export type FetchWorkspacesResponseMessage = z.infer export type FetchAgentResponseMessage = z.infer export type FetchAgentTimelineResponseMessage = z.infer< typeof FetchAgentTimelineResponseMessageSchema @@ -2144,6 +2225,7 @@ export type ActivityLogPayload = z.infer // Type exports for inbound message types export type VoiceAudioChunkMessage = z.infer export type FetchAgentsRequestMessage = z.infer +export type FetchWorkspacesRequestMessage = z.infer export type FetchAgentRequestMessage = z.infer export type SendAgentMessageRequest = z.infer export type WaitForFinishRequest = z.infer diff --git a/packages/server/src/shared/messages.workspaces.test.ts b/packages/server/src/shared/messages.workspaces.test.ts new file mode 100644 index 000000000..dbbb136f8 --- /dev/null +++ b/packages/server/src/shared/messages.workspaces.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'vitest' +import { + SessionInboundMessageSchema, + SessionOutboundMessageSchema, +} from './messages.js' + +describe('workspace message schemas', () => { + test('parses fetch_workspaces_request', () => { + const parsed = SessionInboundMessageSchema.parse({ + type: 'fetch_workspaces_request', + requestId: 'req-1', + filter: { + query: 'repo', + projectId: 'remote:github.com/acme/repo', + idPrefix: '/Users/me', + }, + sort: [{ key: 'activity_at', direction: 'desc' }], + page: { limit: 50 }, + subscribe: {}, + }) + + expect(parsed.type).toBe('fetch_workspaces_request') + }) + + test('rejects invalid workspace update payload', () => { + const result = SessionOutboundMessageSchema.safeParse({ + type: 'workspace_update', + payload: { + kind: 'upsert', + workspace: { + id: '/repo', + projectId: '/repo', + name: '', + status: 'not-a-bucket', + activityAt: null, + }, + }, + }) + + expect(result.success).toBe(false) + }) +})