feat: workspace source-of-truth hard cut
This commit is contained in:
@@ -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.
|
||||||
@@ -4,7 +4,6 @@ const fs = require("fs");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
const projectRoot = __dirname;
|
const projectRoot = __dirname;
|
||||||
const monorepoRoot = path.resolve(projectRoot, "../..");
|
|
||||||
const serverSrcRoot = path.resolve(projectRoot, "../server/src");
|
const serverSrcRoot = path.resolve(projectRoot, "../server/src");
|
||||||
const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
|
const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
|
||||||
const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
||||||
@@ -14,19 +13,8 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
|||||||
|
|
||||||
const config = getDefaultConfig(projectRoot);
|
const config = getDefaultConfig(projectRoot);
|
||||||
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
|
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
|
||||||
|
config.transformer.asyncRequireModulePath = require.resolve(
|
||||||
// This app imports TypeScript sources from sibling workspaces (server/relay).
|
"@expo/metro-config/build/async-require"
|
||||||
// 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"),
|
|
||||||
])
|
|
||||||
);
|
);
|
||||||
|
|
||||||
function isLocalModuleImport(moduleName) {
|
function isLocalModuleImport(moduleName) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { router, usePathname } from 'expo-router'
|
|||||||
import { usePanelStore } from '@/stores/panel-store'
|
import { usePanelStore } from '@/stores/panel-store'
|
||||||
import { SidebarAgentList } from './sidebar-agent-list'
|
import { SidebarAgentList } from './sidebar-agent-list'
|
||||||
import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton'
|
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 { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
|
||||||
import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window'
|
import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window'
|
||||||
import { Combobox } from '@/components/ui/combobox'
|
import { Combobox } from '@/components/ui/combobox'
|
||||||
@@ -112,7 +112,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
|||||||
// Derive isOpen from the unified panel state
|
// Derive isOpen from the unified panel state
|
||||||
const isOpen = isMobile ? mobileView === 'agent-list' : desktopAgentListOpen
|
const isOpen = isMobile ? mobileView === 'agent-list' : desktopAgentListOpen
|
||||||
|
|
||||||
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarAgentsList({
|
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
||||||
serverId: activeServerId,
|
serverId: activeServerId,
|
||||||
enabled: isOpen,
|
enabled: isOpen,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 { useQueries } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { router, usePathname, useSegments } from 'expo-router'
|
import { router, usePathname, useSegments } from 'expo-router'
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||||
import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'
|
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 { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
||||||
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
|
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
|
||||||
import { getIsTauri } from '@/constants/layout'
|
import { getIsTauri } from '@/constants/layout'
|
||||||
@@ -24,21 +24,15 @@ import {
|
|||||||
import {
|
import {
|
||||||
type SidebarProjectEntry,
|
type SidebarProjectEntry,
|
||||||
type SidebarWorkspaceEntry,
|
type SidebarWorkspaceEntry,
|
||||||
} from '@/hooks/use-sidebar-agents-list'
|
type SidebarStateBucket,
|
||||||
|
} from '@/hooks/use-sidebar-workspaces-list'
|
||||||
import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
|
import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
|
||||||
import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store'
|
|
||||||
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
|
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
|
||||||
import { formatTimeAgo } from '@/utils/time'
|
import { formatTimeAgo } from '@/utils/time'
|
||||||
import type { SidebarStateBucket } from '@/utils/sidebar-agent-state'
|
|
||||||
import { confirmDialog } from '@/utils/confirm-dialog'
|
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
|
||||||
ContextMenuItem,
|
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
useContextMenu,
|
|
||||||
} from '@/components/ui/context-menu'
|
} from '@/components/ui/context-menu'
|
||||||
import { deriveProjectDisplayName } from '@/utils/agent-grouping'
|
|
||||||
import {
|
import {
|
||||||
buildSidebarWorkspaceViewModel,
|
buildSidebarWorkspaceViewModel,
|
||||||
type SidebarWorkspaceTreeRow,
|
type SidebarWorkspaceTreeRow,
|
||||||
@@ -81,19 +75,11 @@ interface WorkspaceRowProps {
|
|||||||
drag: () => void
|
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 {
|
function resolveWorkspaceCreatedAtLabel(workspace: SidebarWorkspaceEntry): string | null {
|
||||||
if (!workspace.createdAt) {
|
if (!workspace.activityAt) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return formatTimeAgo(workspace.createdAt)
|
return formatTimeAgo(workspace.activityAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveStatusDotColor(input: { theme: ReturnType<typeof useUnistyles>['theme']; bucket: SidebarStateBucket }) {
|
function resolveStatusDotColor(input: { theme: ReturnType<typeof useUnistyles>['theme']; bucket: SidebarStateBucket }) {
|
||||||
@@ -111,21 +97,15 @@ function resolveStatusDotColor(input: { theme: ReturnType<typeof useUnistyles>['
|
|||||||
|
|
||||||
function WorkspaceStatusIndicator({
|
function WorkspaceStatusIndicator({
|
||||||
bucket,
|
bucket,
|
||||||
loading = false,
|
|
||||||
}: {
|
}: {
|
||||||
bucket: SidebarWorkspaceEntry['statusBucket']
|
bucket: SidebarWorkspaceEntry['statusBucket']
|
||||||
loading?: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles()
|
const { theme } = useUnistyles()
|
||||||
const color = resolveStatusDotColor({ theme, bucket })
|
const color = resolveStatusDotColor({ theme, bucket })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.workspaceStatusDot}>
|
<View style={styles.workspaceStatusDot}>
|
||||||
{loading ? (
|
<View style={[styles.workspaceStatusDotFill, { backgroundColor: color }]} />
|
||||||
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
|
|
||||||
) : (
|
|
||||||
<View style={[styles.workspaceStatusDotFill, { backgroundColor: color }]} />
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -198,69 +178,8 @@ function WorkspaceRow({
|
|||||||
onPress,
|
onPress,
|
||||||
drag,
|
drag,
|
||||||
}: WorkspaceRowProps) {
|
}: WorkspaceRowProps) {
|
||||||
const { theme } = useUnistyles()
|
|
||||||
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
|
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
|
||||||
|
|
||||||
const { setAnchorRect, setOpen } = useContextMenu()
|
|
||||||
const didLongPressRef = useRef(false)
|
const didLongPressRef = useRef(false)
|
||||||
const didLongPressCleanupTimerRef = useRef<ReturnType<typeof setTimeout> | 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(() => {
|
const handlePress = useCallback(() => {
|
||||||
if (didLongPressRef.current) {
|
if (didLongPressRef.current) {
|
||||||
@@ -271,67 +190,12 @@ function WorkspaceRow({
|
|||||||
}, [onPress])
|
}, [onPress])
|
||||||
|
|
||||||
const handleLongPress = useCallback(() => {
|
const handleLongPress = useCallback(() => {
|
||||||
if (longPressCancelledRef.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
didLongPressRef.current = true
|
didLongPressRef.current = true
|
||||||
longPressArmedRef.current = true
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const moveMonitorGesture = useMemo(() => {
|
const moveMonitorGesture = useMemo(() => {
|
||||||
if (Platform.OS === 'web') {
|
return Platform.OS === 'web' ? null : Gesture.Pan().manualActivation(true).runOnJS(true)
|
||||||
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])
|
|
||||||
|
|
||||||
const trigger = (
|
const trigger = (
|
||||||
<ContextMenuTrigger
|
<ContextMenuTrigger
|
||||||
@@ -342,46 +206,15 @@ function WorkspaceRow({
|
|||||||
hovered && styles.workspaceRowHovered,
|
hovered && styles.workspaceRowHovered,
|
||||||
pressed && styles.workspaceRowPressed,
|
pressed && styles.workspaceRowPressed,
|
||||||
]}
|
]}
|
||||||
onPressIn={(event) => {
|
|
||||||
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}
|
onPress={handlePress}
|
||||||
onLongPress={handleLongPress}
|
onLongPress={handleLongPress}
|
||||||
delayLongPress={200}
|
delayLongPress={200}
|
||||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||||
>
|
>
|
||||||
<View style={styles.workspaceRowLeft}>
|
<View style={styles.workspaceRowLeft}>
|
||||||
<WorkspaceStatusIndicator bucket={workspace.statusBucket} loading={isArchiving} />
|
<WorkspaceStatusIndicator bucket={workspace.statusBucket} />
|
||||||
<Text style={styles.workspaceBranchText} numberOfLines={1}>
|
<Text style={styles.workspaceBranchText} numberOfLines={1}>
|
||||||
{resolveWorkspaceBranchLabel(workspace)}
|
{workspace.name}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.workspaceRowRight}>
|
<View style={styles.workspaceRowRight}>
|
||||||
@@ -399,33 +232,10 @@ function WorkspaceRow({
|
|||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return moveMonitorGesture ? (
|
||||||
<>
|
<GestureDetector gesture={moveMonitorGesture}>{trigger}</GestureDetector>
|
||||||
{moveMonitorGesture ? (
|
) : (
|
||||||
<GestureDetector gesture={moveMonitorGesture}>{trigger}</GestureDetector>
|
trigger
|
||||||
) : (
|
|
||||||
trigger
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ContextMenuContent
|
|
||||||
align="start"
|
|
||||||
width={220}
|
|
||||||
testID={`sidebar-workspace-context-${workspace.workspaceKey}`}
|
|
||||||
>
|
|
||||||
<ContextMenuItem
|
|
||||||
leading={<Archive size={16} color={theme.colors.foregroundMuted} />}
|
|
||||||
destructive
|
|
||||||
disabled={!workspace.isPaseoOwnedWorktree}
|
|
||||||
status={workspace.isPaseoOwnedWorktree ? archiveStatus : 'idle'}
|
|
||||||
pendingLabel="Archiving…"
|
|
||||||
successLabel="Archived"
|
|
||||||
onSelect={handleArchiveWorktree}
|
|
||||||
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
|
|
||||||
>
|
|
||||||
Archive worktree
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,11 +419,7 @@ export function SidebarAgentList({
|
|||||||
buildSidebarWorkspaceViewModel({
|
buildSidebarWorkspaceViewModel({
|
||||||
projects,
|
projects,
|
||||||
collapsedProjectKeys,
|
collapsedProjectKeys,
|
||||||
getProjectDisplayName: (project) =>
|
getProjectDisplayName: (project) => project.projectName,
|
||||||
deriveProjectDisplayName({
|
|
||||||
projectKey: project.projectKey,
|
|
||||||
projectName: project.projectName,
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
[canonicalResyncNonce, collapsedProjectKeys, projects]
|
[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 navigate = shouldReplaceWorkspaceNavigation ? router.replace : router.push
|
||||||
const isSelected =
|
const isSelected =
|
||||||
Boolean(serverId) &&
|
Boolean(serverId) &&
|
||||||
activeWorkspaceSelection?.serverId === serverId &&
|
activeWorkspaceSelection?.serverId === serverId &&
|
||||||
activeWorkspaceSelection.workspaceId === item.workspace.cwd
|
activeWorkspaceSelection.workspaceId === item.workspace.workspaceId
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceRowWithMenu
|
<WorkspaceRowWithMenu
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
|||||||
import {
|
import {
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
type Agent,
|
type Agent,
|
||||||
|
type WorkspaceDescriptor,
|
||||||
type SessionState,
|
type SessionState,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
import { useDraftStore } from "@/stores/draft-store";
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
@@ -116,10 +117,32 @@ type AgentUpdatePayload = Extract<
|
|||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
{ type: "agent_update" }
|
{ type: "agent_update" }
|
||||||
>["payload"];
|
>["payload"];
|
||||||
|
type WorkspaceUpdatePayload = Extract<
|
||||||
|
SessionOutboundMessage,
|
||||||
|
{ type: "workspace_update" }
|
||||||
|
>["payload"];
|
||||||
|
|
||||||
const getAgentIdFromUpdate = (update: AgentUpdatePayload): string =>
|
const getAgentIdFromUpdate = (update: AgentUpdatePayload): string =>
|
||||||
update.kind === "remove" ? update.agentId : update.agent.id;
|
update.kind === "remove" ? update.agentId : update.agent.id;
|
||||||
|
|
||||||
|
function normalizeWorkspaceDescriptor(
|
||||||
|
payload: Extract<WorkspaceUpdatePayload, { kind: "upsert" }>["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)
|
// Module-level pending agent updates buffer (scoped by serverId)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -233,7 +256,13 @@ function SessionProviderInternal({
|
|||||||
const setHasHydratedAgents = useSessionStore(
|
const setHasHydratedAgents = useSessionStore(
|
||||||
(state) => state.setHasHydratedAgents
|
(state) => state.setHasHydratedAgents
|
||||||
);
|
);
|
||||||
|
const setHasHydratedWorkspaces = useSessionStore(
|
||||||
|
(state) => state.setHasHydratedWorkspaces
|
||||||
|
);
|
||||||
const setAgents = useSessionStore((state) => state.setAgents);
|
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(
|
const setAgentLastActivity = useSessionStore(
|
||||||
(state) => state.setAgentLastActivity
|
(state) => state.setAgentLastActivity
|
||||||
);
|
);
|
||||||
@@ -413,6 +442,38 @@ function SessionProviderInternal({
|
|||||||
}
|
}
|
||||||
}, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]);
|
}, [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<string, WorkspaceDescriptor>();
|
||||||
|
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(
|
const applyAgentUpdatePayload = useCallback(
|
||||||
(update: AgentUpdatePayload) => {
|
(update: AgentUpdatePayload) => {
|
||||||
if (update.kind === "remove") {
|
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) => {
|
const unsubStatus = client.on("status", (message) => {
|
||||||
if (message.type !== "status") return;
|
if (message.type !== "status") return;
|
||||||
const serverInfo = parseServerInfoStatusPayload(message.payload);
|
const serverInfo = parseServerInfoStatusPayload(message.payload);
|
||||||
@@ -1287,6 +1359,7 @@ function SessionProviderInternal({
|
|||||||
unsubAgentUpdate();
|
unsubAgentUpdate();
|
||||||
unsubAgentStream();
|
unsubAgentStream();
|
||||||
unsubAgentTimeline();
|
unsubAgentTimeline();
|
||||||
|
unsubWorkspaceUpdate();
|
||||||
unsubStatus();
|
unsubStatus();
|
||||||
unsubPermissionRequest();
|
unsubPermissionRequest();
|
||||||
unsubPermissionResolved();
|
unsubPermissionResolved();
|
||||||
@@ -1312,6 +1385,8 @@ function SessionProviderInternal({
|
|||||||
setAgentTimelineCursor,
|
setAgentTimelineCursor,
|
||||||
setInitializingAgents,
|
setInitializingAgents,
|
||||||
setAgents,
|
setAgents,
|
||||||
|
mergeWorkspaces,
|
||||||
|
removeWorkspace,
|
||||||
setAgentLastActivity,
|
setAgentLastActivity,
|
||||||
setPendingPermissions,
|
setPendingPermissions,
|
||||||
setHasHydratedAgents,
|
setHasHydratedAgents,
|
||||||
|
|||||||
@@ -1,650 +0,0 @@
|
|||||||
import { useCallback, useMemo, useSyncExternalStore } from 'react'
|
|
||||||
import { useQueries } from '@tanstack/react-query'
|
|
||||||
import { useSessionStore } from '@/stores/session-store'
|
|
||||||
import { getHostRuntimeStore } from '@/runtime/host-runtime'
|
|
||||||
import { resolveProjectPlacement } from '@/utils/project-placement'
|
|
||||||
import { deriveSidebarStateBucket, type SidebarStateBucket } from '@/utils/sidebar-agent-state'
|
|
||||||
import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
|
|
||||||
import { checkoutStatusQueryKey } from '@/hooks/use-checkout-status-query'
|
|
||||||
|
|
||||||
const EMPTY_ORDER: string[] = []
|
|
||||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = []
|
|
||||||
|
|
||||||
interface PaseoWorktreeEntry {
|
|
||||||
worktreePath: string
|
|
||||||
createdAt: string
|
|
||||||
branchName?: string | null
|
|
||||||
head?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SidebarWorkspaceEntry {
|
|
||||||
workspaceKey: string
|
|
||||||
serverId: string
|
|
||||||
cwd: string
|
|
||||||
branchName: string | null
|
|
||||||
createdAt: Date | null
|
|
||||||
isMainCheckout: boolean
|
|
||||||
isPaseoOwnedWorktree: boolean
|
|
||||||
statusBucket: SidebarStateBucket
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SidebarProjectEntry {
|
|
||||||
projectKey: string
|
|
||||||
projectName: string
|
|
||||||
iconWorkingDir: string
|
|
||||||
statusBucket: SidebarStateBucket
|
|
||||||
activeCount: number
|
|
||||||
totalCount: number
|
|
||||||
latestCreatedAt: Date | null
|
|
||||||
workspaces: SidebarWorkspaceEntry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SidebarAgentsListResult {
|
|
||||||
projects: SidebarProjectEntry[]
|
|
||||||
isLoading: boolean
|
|
||||||
isInitialLoad: boolean
|
|
||||||
isRevalidating: boolean
|
|
||||||
refreshAll: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MutableWorkspaceEntry {
|
|
||||||
workspaceKey: string
|
|
||||||
serverId: string
|
|
||||||
cwd: string
|
|
||||||
branchName: string | null
|
|
||||||
createdAt: Date | null
|
|
||||||
isMainCheckout: boolean
|
|
||||||
isPaseoOwnedWorktree: boolean
|
|
||||||
statusBucket: SidebarStateBucket
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MutableProjectEntry {
|
|
||||||
projectKey: string
|
|
||||||
projectName: string
|
|
||||||
iconWorkingDir: string
|
|
||||||
statusBucket: SidebarStateBucket
|
|
||||||
activeCount: number
|
|
||||||
totalCount: number
|
|
||||||
latestCreatedAt: Date | null
|
|
||||||
repoRoots: Set<string>
|
|
||||||
workspacesByKey: Map<string, MutableWorkspaceEntry>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WorktreeRequest {
|
|
||||||
requestKey: string
|
|
||||||
serverId: string
|
|
||||||
projectKey: string
|
|
||||||
repoRoot: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const SIDEBAR_BUCKET_PRIORITY: Record<SidebarStateBucket, number> = {
|
|
||||||
done: 0,
|
|
||||||
attention: 1,
|
|
||||||
running: 2,
|
|
||||||
failed: 3,
|
|
||||||
needs_input: 4,
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePath(value: string | null | undefined): string {
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
return value.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
function toWorkspaceKey(serverId: string, cwd: string): string {
|
|
||||||
return `${serverId}:${cwd}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function coerceBranchName(value: string | null | undefined): string | null {
|
|
||||||
const trimmed = normalizePath(value)
|
|
||||||
return trimmed.length > 0 ? trimmed : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function maxDate(left: Date | null, right: Date): Date {
|
|
||||||
if (!left || right.getTime() > left.getTime()) {
|
|
||||||
return right
|
|
||||||
}
|
|
||||||
return left
|
|
||||||
}
|
|
||||||
|
|
||||||
function minDate(left: Date | null, right: Date): Date {
|
|
||||||
if (!left || right.getTime() < left.getTime()) {
|
|
||||||
return right
|
|
||||||
}
|
|
||||||
return left
|
|
||||||
}
|
|
||||||
|
|
||||||
function aggregateBucket(
|
|
||||||
current: SidebarStateBucket,
|
|
||||||
candidate: SidebarStateBucket
|
|
||||||
): SidebarStateBucket {
|
|
||||||
return SIDEBAR_BUCKET_PRIORITY[candidate] > SIDEBAR_BUCKET_PRIORITY[current] ? candidate : current
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareWorkspaceBaseline(
|
|
||||||
left: SidebarWorkspaceEntry,
|
|
||||||
right: SidebarWorkspaceEntry
|
|
||||||
): number {
|
|
||||||
if (left.isMainCheckout !== right.isMainCheckout) {
|
|
||||||
return left.isMainCheckout ? -1 : 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (left.createdAt && right.createdAt) {
|
|
||||||
const dateDelta = right.createdAt.getTime() - left.createdAt.getTime()
|
|
||||||
if (dateDelta !== 0) {
|
|
||||||
return dateDelta
|
|
||||||
}
|
|
||||||
} else if (left.createdAt || right.createdAt) {
|
|
||||||
return left.createdAt ? -1 : 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const leftBranch = left.branchName ?? ''
|
|
||||||
const rightBranch = right.branchName ?? ''
|
|
||||||
const branchDelta = leftBranch.localeCompare(rightBranch, undefined, {
|
|
||||||
numeric: true,
|
|
||||||
sensitivity: 'base',
|
|
||||||
})
|
|
||||||
if (branchDelta !== 0) {
|
|
||||||
return branchDelta
|
|
||||||
}
|
|
||||||
|
|
||||||
return left.cwd.localeCompare(right.cwd, undefined, {
|
|
||||||
numeric: true,
|
|
||||||
sensitivity: 'base',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareProjectBaseline(left: SidebarProjectEntry, right: SidebarProjectEntry): number {
|
|
||||||
if (left.latestCreatedAt && right.latestCreatedAt) {
|
|
||||||
const dateDelta = right.latestCreatedAt.getTime() - left.latestCreatedAt.getTime()
|
|
||||||
if (dateDelta !== 0) {
|
|
||||||
return dateDelta
|
|
||||||
}
|
|
||||||
} else if (left.latestCreatedAt || right.latestCreatedAt) {
|
|
||||||
return left.latestCreatedAt ? -1 : 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return left.projectName.localeCompare(right.projectName, undefined, {
|
|
||||||
numeric: true,
|
|
||||||
sensitivity: 'base',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyStoredOrdering<T>(input: {
|
|
||||||
items: T[]
|
|
||||||
storedOrder: string[]
|
|
||||||
getKey: (item: T) => string
|
|
||||||
}): T[] {
|
|
||||||
if (input.items.length <= 1 || input.storedOrder.length === 0) {
|
|
||||||
return input.items
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemByKey = new Map<string, T>()
|
|
||||||
for (const item of input.items) {
|
|
||||||
itemByKey.set(input.getKey(item), item)
|
|
||||||
}
|
|
||||||
|
|
||||||
const prunedOrder: string[] = []
|
|
||||||
const seen = new Set<string>()
|
|
||||||
for (const key of input.storedOrder) {
|
|
||||||
if (!itemByKey.has(key) || seen.has(key)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen.add(key)
|
|
||||||
prunedOrder.push(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prunedOrder.length === 0) {
|
|
||||||
return input.items
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderedSet = new Set(prunedOrder)
|
|
||||||
const ordered: T[] = []
|
|
||||||
let orderedIndex = 0
|
|
||||||
|
|
||||||
for (const item of input.items) {
|
|
||||||
const key = input.getKey(item)
|
|
||||||
if (!orderedSet.has(key)) {
|
|
||||||
ordered.push(item)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetKey = prunedOrder[orderedIndex] ?? key
|
|
||||||
orderedIndex += 1
|
|
||||||
ordered.push(itemByKey.get(targetKey) ?? item)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ordered
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureWorkspace(
|
|
||||||
project: MutableProjectEntry,
|
|
||||||
input: {
|
|
||||||
serverId: string
|
|
||||||
cwd: string
|
|
||||||
branchName: string | null
|
|
||||||
createdAt: Date | null
|
|
||||||
isMainCheckout: boolean
|
|
||||||
isPaseoOwnedWorktree: boolean
|
|
||||||
}
|
|
||||||
): MutableWorkspaceEntry {
|
|
||||||
const workspaceKey = toWorkspaceKey(input.serverId, input.cwd)
|
|
||||||
const existing = project.workspacesByKey.get(workspaceKey)
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
if (!existing.branchName && input.branchName) {
|
|
||||||
existing.branchName = input.branchName
|
|
||||||
}
|
|
||||||
if (input.createdAt) {
|
|
||||||
existing.createdAt = existing.createdAt
|
|
||||||
? minDate(existing.createdAt, input.createdAt)
|
|
||||||
: input.createdAt
|
|
||||||
}
|
|
||||||
if (input.isMainCheckout) {
|
|
||||||
existing.isMainCheckout = true
|
|
||||||
}
|
|
||||||
if (input.isPaseoOwnedWorktree) {
|
|
||||||
existing.isPaseoOwnedWorktree = true
|
|
||||||
}
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspace: MutableWorkspaceEntry = {
|
|
||||||
workspaceKey,
|
|
||||||
serverId: input.serverId,
|
|
||||||
cwd: input.cwd,
|
|
||||||
branchName: input.branchName,
|
|
||||||
createdAt: input.createdAt,
|
|
||||||
isMainCheckout: input.isMainCheckout,
|
|
||||||
isPaseoOwnedWorktree: input.isPaseoOwnedWorktree,
|
|
||||||
statusBucket: 'done',
|
|
||||||
}
|
|
||||||
project.workspacesByKey.set(workspaceKey, workspace)
|
|
||||||
return workspace
|
|
||||||
}
|
|
||||||
|
|
||||||
function cloneProject(project: MutableProjectEntry): MutableProjectEntry {
|
|
||||||
return {
|
|
||||||
...project,
|
|
||||||
repoRoots: new Set(project.repoRoots),
|
|
||||||
workspacesByKey: new Map(
|
|
||||||
Array.from(project.workspacesByKey.entries()).map(([key, workspace]) => [
|
|
||||||
key,
|
|
||||||
{ ...workspace },
|
|
||||||
])
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string {
|
|
||||||
return `${serverId.trim()}::${projectKey.trim()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSidebarAgentsList(options?: {
|
|
||||||
serverId?: string | null
|
|
||||||
enabled?: boolean
|
|
||||||
}): SidebarAgentsListResult {
|
|
||||||
const runtime = getHostRuntimeStore()
|
|
||||||
|
|
||||||
const serverId = useMemo(() => {
|
|
||||||
const value = options?.serverId
|
|
||||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
|
|
||||||
}, [options?.serverId])
|
|
||||||
const enabled = options?.enabled ?? true
|
|
||||||
|
|
||||||
const persistedProjectOrder = useSidebarOrderStore((state) =>
|
|
||||||
serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER
|
|
||||||
)
|
|
||||||
const persistedWorkspaceOrderByScope = useSidebarOrderStore((state) =>
|
|
||||||
serverId ? state.workspaceOrderByServerAndProject : {}
|
|
||||||
)
|
|
||||||
|
|
||||||
const isActive = Boolean(serverId)
|
|
||||||
const liveAgents = useSessionStore((state) =>
|
|
||||||
isActive && serverId ? (state.sessions[serverId]?.agents ?? null) : null
|
|
||||||
)
|
|
||||||
|
|
||||||
const runtimeStatusSignature = useSyncExternalStore(
|
|
||||||
(onStoreChange) =>
|
|
||||||
isActive && serverId ? runtime.subscribe(serverId, onStoreChange) : () => {},
|
|
||||||
() => {
|
|
||||||
if (!isActive || !serverId) {
|
|
||||||
return 'idle:idle'
|
|
||||||
}
|
|
||||||
const snapshot = runtime.getSnapshot(serverId)
|
|
||||||
const connectionStatus = snapshot?.connectionStatus ?? 'idle'
|
|
||||||
const directoryStatus = snapshot?.agentDirectoryStatus ?? 'idle'
|
|
||||||
return `${connectionStatus}:${directoryStatus}`
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
if (!isActive || !serverId) {
|
|
||||||
return 'idle:idle'
|
|
||||||
}
|
|
||||||
const snapshot = runtime.getSnapshot(serverId)
|
|
||||||
const connectionStatus = snapshot?.connectionStatus ?? 'idle'
|
|
||||||
const directoryStatus = snapshot?.agentDirectoryStatus ?? 'idle'
|
|
||||||
return `${connectionStatus}:${directoryStatus}`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
const [connectionStatus = 'idle', directoryStatus = 'idle'] = runtimeStatusSignature.split(':', 2)
|
|
||||||
|
|
||||||
const base = useMemo(() => {
|
|
||||||
if (!isActive || !serverId || !liveAgents) {
|
|
||||||
return {
|
|
||||||
projectsByKey: new Map<string, MutableProjectEntry>(),
|
|
||||||
worktreeRequests: [] as WorktreeRequest[],
|
|
||||||
hasAnyData: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectsByKey = new Map<string, MutableProjectEntry>()
|
|
||||||
|
|
||||||
for (const sourceAgent of liveAgents.values()) {
|
|
||||||
if (sourceAgent.archivedAt || sourceAgent.labels.ui !== 'true') {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const placement = resolveProjectPlacement({
|
|
||||||
projectPlacement: sourceAgent.projectPlacement ?? null,
|
|
||||||
cwd: sourceAgent.cwd,
|
|
||||||
})
|
|
||||||
const projectKey = normalizePath(placement.projectKey)
|
|
||||||
if (!projectKey) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkoutCwd = normalizePath(placement.checkout.cwd || sourceAgent.cwd)
|
|
||||||
if (!checkoutCwd) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectName = normalizePath(placement.projectName) || projectKey
|
|
||||||
const project =
|
|
||||||
projectsByKey.get(projectKey) ??
|
|
||||||
({
|
|
||||||
projectKey,
|
|
||||||
projectName,
|
|
||||||
iconWorkingDir: checkoutCwd,
|
|
||||||
statusBucket: 'done',
|
|
||||||
activeCount: 0,
|
|
||||||
totalCount: 0,
|
|
||||||
latestCreatedAt: null,
|
|
||||||
repoRoots: new Set<string>(),
|
|
||||||
workspacesByKey: new Map<string, MutableWorkspaceEntry>(),
|
|
||||||
} satisfies MutableProjectEntry)
|
|
||||||
|
|
||||||
project.totalCount += 1
|
|
||||||
project.latestCreatedAt = maxDate(project.latestCreatedAt, sourceAgent.createdAt)
|
|
||||||
const bucket = deriveSidebarStateBucket({
|
|
||||||
status: sourceAgent.status,
|
|
||||||
pendingPermissionCount: sourceAgent.pendingPermissions.length,
|
|
||||||
requiresAttention: sourceAgent.requiresAttention,
|
|
||||||
attentionReason: sourceAgent.attentionReason,
|
|
||||||
})
|
|
||||||
project.statusBucket = aggregateBucket(project.statusBucket, bucket)
|
|
||||||
if (bucket !== 'done') {
|
|
||||||
project.activeCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedBranch = coerceBranchName(placement.checkout.currentBranch)
|
|
||||||
const isMainCheckout = placement.checkout.isGit && !placement.checkout.isPaseoOwnedWorktree
|
|
||||||
const workspace = ensureWorkspace(project, {
|
|
||||||
serverId,
|
|
||||||
cwd: checkoutCwd,
|
|
||||||
branchName: normalizedBranch,
|
|
||||||
// Only Paseo-managed worktrees are ephemeral enough to justify showing a timestamp.
|
|
||||||
// If the daemon doesn't provide worktree.createdAt (older daemons), fall back to an
|
|
||||||
// agent-derived timestamp so the row still has a date.
|
|
||||||
createdAt: placement.checkout.isPaseoOwnedWorktree ? sourceAgent.createdAt : null,
|
|
||||||
isMainCheckout,
|
|
||||||
isPaseoOwnedWorktree: placement.checkout.isPaseoOwnedWorktree,
|
|
||||||
})
|
|
||||||
workspace.statusBucket = aggregateBucket(workspace.statusBucket, bucket)
|
|
||||||
|
|
||||||
const explicitMainRepoRoot = normalizePath(placement.checkout.mainRepoRoot)
|
|
||||||
if (placement.checkout.isPaseoOwnedWorktree && explicitMainRepoRoot) {
|
|
||||||
project.repoRoots.add(explicitMainRepoRoot)
|
|
||||||
if (!project.iconWorkingDir) {
|
|
||||||
project.iconWorkingDir = explicitMainRepoRoot
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isMainCheckout) {
|
|
||||||
project.repoRoots.add(workspace.cwd)
|
|
||||||
project.iconWorkingDir = workspace.cwd
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!project.iconWorkingDir) {
|
|
||||||
project.iconWorkingDir = workspace.cwd
|
|
||||||
}
|
|
||||||
|
|
||||||
projectsByKey.set(projectKey, project)
|
|
||||||
}
|
|
||||||
|
|
||||||
const worktreeRequests: WorktreeRequest[] = []
|
|
||||||
for (const project of projectsByKey.values()) {
|
|
||||||
for (const repoRoot of project.repoRoots) {
|
|
||||||
if (!repoRoot) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const requestKey = `${serverId}:${project.projectKey}:${repoRoot}`
|
|
||||||
worktreeRequests.push({
|
|
||||||
requestKey,
|
|
||||||
serverId,
|
|
||||||
projectKey: project.projectKey,
|
|
||||||
repoRoot,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
worktreeRequests.sort((left, right) =>
|
|
||||||
left.requestKey.localeCompare(right.requestKey, undefined, {
|
|
||||||
numeric: true,
|
|
||||||
sensitivity: 'base',
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
projectsByKey,
|
|
||||||
worktreeRequests,
|
|
||||||
hasAnyData: projectsByKey.size > 0,
|
|
||||||
}
|
|
||||||
}, [isActive, liveAgents, serverId])
|
|
||||||
|
|
||||||
const worktreeQueries = useQueries({
|
|
||||||
queries: base.worktreeRequests.map((request) => ({
|
|
||||||
queryKey: ['sidebarPaseoWorktreeList', request.serverId, request.repoRoot],
|
|
||||||
queryFn: async (): Promise<PaseoWorktreeEntry[]> => {
|
|
||||||
const client = runtime.getClient(request.serverId)
|
|
||||||
if (!client) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
const payload = await client.getPaseoWorktreeList({
|
|
||||||
repoRoot: request.repoRoot,
|
|
||||||
})
|
|
||||||
if (payload.error) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return payload.worktrees ?? []
|
|
||||||
},
|
|
||||||
enabled:
|
|
||||||
enabled &&
|
|
||||||
isActive &&
|
|
||||||
Boolean(serverId) &&
|
|
||||||
connectionStatus === 'online' &&
|
|
||||||
Boolean(runtime.getClient(request.serverId)) &&
|
|
||||||
request.repoRoot.length > 0,
|
|
||||||
staleTime: 15_000,
|
|
||||||
gcTime: 1000 * 60 * 10,
|
|
||||||
retry: false,
|
|
||||||
refetchOnMount: 'always' as const,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
|
|
||||||
const repoRootStatusQueries = useQueries({
|
|
||||||
queries: base.worktreeRequests.map((request) => ({
|
|
||||||
queryKey: checkoutStatusQueryKey(request.serverId, request.repoRoot),
|
|
||||||
queryFn: async () => {
|
|
||||||
const client = runtime.getClient(request.serverId)
|
|
||||||
if (!client) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return await client.getCheckoutStatus(request.repoRoot)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled:
|
|
||||||
enabled &&
|
|
||||||
isActive &&
|
|
||||||
Boolean(serverId) &&
|
|
||||||
connectionStatus === 'online' &&
|
|
||||||
Boolean(runtime.getClient(request.serverId)) &&
|
|
||||||
request.repoRoot.length > 0,
|
|
||||||
staleTime: 15_000,
|
|
||||||
gcTime: 1000 * 60 * 10,
|
|
||||||
retry: false,
|
|
||||||
refetchOnMount: 'always' as const,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
|
|
||||||
const projects = useMemo(() => {
|
|
||||||
if (base.projectsByKey.size === 0) {
|
|
||||||
return EMPTY_PROJECTS
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectsByKey = new Map<string, MutableProjectEntry>()
|
|
||||||
for (const [key, project] of base.projectsByKey.entries()) {
|
|
||||||
projectsByKey.set(key, cloneProject(project))
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < base.worktreeRequests.length; i += 1) {
|
|
||||||
const request = base.worktreeRequests[i]
|
|
||||||
if (!request) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const project = projectsByKey.get(request.projectKey)
|
|
||||||
if (!project) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkout = repoRootStatusQueries[i]?.data ?? null
|
|
||||||
const mainBranchName = coerceBranchName(checkout?.currentBranch)
|
|
||||||
|
|
||||||
ensureWorkspace(project, {
|
|
||||||
serverId: request.serverId,
|
|
||||||
cwd: request.repoRoot,
|
|
||||||
branchName: mainBranchName,
|
|
||||||
createdAt: null,
|
|
||||||
isMainCheckout: true,
|
|
||||||
isPaseoOwnedWorktree: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const worktrees = worktreeQueries[i]?.data ?? []
|
|
||||||
for (const worktree of worktrees) {
|
|
||||||
const worktreePath = normalizePath(worktree.worktreePath)
|
|
||||||
if (!worktreePath) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdAt = (() => {
|
|
||||||
const createdAtRaw = worktree.createdAt
|
|
||||||
if (typeof createdAtRaw !== 'string' || createdAtRaw.trim().length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const parsed = new Date(createdAtRaw)
|
|
||||||
return Number.isNaN(parsed.getTime()) ? null : parsed
|
|
||||||
})()
|
|
||||||
|
|
||||||
ensureWorkspace(project, {
|
|
||||||
serverId: request.serverId,
|
|
||||||
cwd: worktreePath,
|
|
||||||
branchName: coerceBranchName(worktree.branchName) ?? coerceBranchName(worktree.head),
|
|
||||||
createdAt,
|
|
||||||
isMainCheckout: false,
|
|
||||||
isPaseoOwnedWorktree: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const baselineProjects: SidebarProjectEntry[] = Array.from(projectsByKey.values()).map(
|
|
||||||
(project) => {
|
|
||||||
const baselineWorkspaces = Array.from(project.workspacesByKey.values()).map(
|
|
||||||
(workspace) => ({
|
|
||||||
workspaceKey: workspace.workspaceKey,
|
|
||||||
serverId: workspace.serverId,
|
|
||||||
cwd: workspace.cwd,
|
|
||||||
branchName: workspace.branchName,
|
|
||||||
createdAt: workspace.createdAt,
|
|
||||||
isMainCheckout: workspace.isMainCheckout,
|
|
||||||
isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree,
|
|
||||||
statusBucket: workspace.statusBucket,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
baselineWorkspaces.sort(compareWorkspaceBaseline)
|
|
||||||
|
|
||||||
const workspaceOrderScopeKey = getWorkspaceOrderScopeKey(serverId ?? '', project.projectKey)
|
|
||||||
|
|
||||||
const orderedWorkspaces = applyStoredOrdering({
|
|
||||||
items: baselineWorkspaces,
|
|
||||||
storedOrder: persistedWorkspaceOrderByScope[workspaceOrderScopeKey] ?? EMPTY_ORDER,
|
|
||||||
getKey: (workspace) => workspace.workspaceKey,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
projectKey: project.projectKey,
|
|
||||||
projectName: project.projectName,
|
|
||||||
iconWorkingDir: project.iconWorkingDir,
|
|
||||||
statusBucket: project.statusBucket,
|
|
||||||
activeCount: project.activeCount,
|
|
||||||
totalCount: project.totalCount,
|
|
||||||
latestCreatedAt: project.latestCreatedAt,
|
|
||||||
workspaces: orderedWorkspaces,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
baselineProjects.sort(compareProjectBaseline)
|
|
||||||
|
|
||||||
return applyStoredOrdering({
|
|
||||||
items: baselineProjects,
|
|
||||||
storedOrder: persistedProjectOrder,
|
|
||||||
getKey: (project) => project.projectKey,
|
|
||||||
})
|
|
||||||
}, [
|
|
||||||
base.projectsByKey,
|
|
||||||
base.worktreeRequests,
|
|
||||||
connectionStatus,
|
|
||||||
isActive,
|
|
||||||
persistedProjectOrder,
|
|
||||||
persistedWorkspaceOrderByScope,
|
|
||||||
repoRootStatusQueries,
|
|
||||||
serverId,
|
|
||||||
worktreeQueries,
|
|
||||||
])
|
|
||||||
|
|
||||||
const refreshAll = useCallback(() => {
|
|
||||||
if (!isActive || !serverId || connectionStatus !== 'online') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void runtime.refreshAgentDirectory({ serverId, page: { limit: 50 } }).catch(() => undefined)
|
|
||||||
}, [connectionStatus, isActive, runtime, serverId])
|
|
||||||
|
|
||||||
const isDirectoryLoading =
|
|
||||||
isActive &&
|
|
||||||
Boolean(serverId) &&
|
|
||||||
(directoryStatus === 'initial_loading' || directoryStatus === 'revalidating')
|
|
||||||
const isInitialLoad = isDirectoryLoading && !base.hasAnyData
|
|
||||||
const isRevalidating = isDirectoryLoading && base.hasAnyData
|
|
||||||
|
|
||||||
return {
|
|
||||||
projects,
|
|
||||||
isLoading: isDirectoryLoading,
|
|
||||||
isInitialLoad,
|
|
||||||
isRevalidating,
|
|
||||||
refreshAll,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+31
-1
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
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 {
|
interface OrderedItem {
|
||||||
key: string
|
key: string
|
||||||
@@ -41,3 +45,29 @@ describe('applyStoredOrdering', () => {
|
|||||||
expect(result).toBe(baseline)
|
expect(result).toBe(baseline)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('buildSidebarProjectsFromWorkspaces', () => {
|
||||||
|
it('uses workspace descriptor name and status directly', () => {
|
||||||
|
const workspaces: WorkspaceDescriptor[] = [
|
||||||
|
{
|
||||||
|
id: '/repo/main',
|
||||||
|
projectId: 'project-1',
|
||||||
|
name: 'feat/hard-cut',
|
||||||
|
status: 'failed',
|
||||||
|
activityAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const projects = buildSidebarProjectsFromWorkspaces({
|
||||||
|
serverId: 'srv',
|
||||||
|
workspaces,
|
||||||
|
projectOrder: [],
|
||||||
|
workspaceOrderByScope: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(projects).toHaveLength(1)
|
||||||
|
expect(projects[0]?.statusBucket).toBe('failed')
|
||||||
|
expect(projects[0]?.workspaces[0]?.name).toBe('feat/hard-cut')
|
||||||
|
expect(projects[0]?.workspaces[0]?.statusBucket).toBe('failed')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import { useCallback, useMemo, useSyncExternalStore } from 'react'
|
||||||
|
import { useSessionStore } from '@/stores/session-store'
|
||||||
|
import { getHostRuntimeStore } from '@/runtime/host-runtime'
|
||||||
|
import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
|
||||||
|
import type { WorkspaceDescriptor } from '@/stores/session-store'
|
||||||
|
import { projectDisplayNameFromProjectId } from '@/utils/project-display-name'
|
||||||
|
|
||||||
|
const EMPTY_ORDER: string[] = []
|
||||||
|
const EMPTY_PROJECTS: SidebarProjectEntry[] = []
|
||||||
|
|
||||||
|
export type SidebarStateBucket = WorkspaceDescriptor['status']
|
||||||
|
|
||||||
|
export interface SidebarWorkspaceEntry {
|
||||||
|
workspaceKey: string
|
||||||
|
serverId: string
|
||||||
|
workspaceId: string
|
||||||
|
name: string
|
||||||
|
activityAt: Date | null
|
||||||
|
statusBucket: SidebarStateBucket
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarProjectEntry {
|
||||||
|
projectKey: string
|
||||||
|
projectName: string
|
||||||
|
iconWorkingDir: string
|
||||||
|
statusBucket: SidebarStateBucket
|
||||||
|
activeCount: number
|
||||||
|
totalWorkspaces: number
|
||||||
|
latestActivityAt: Date | null
|
||||||
|
workspaces: SidebarWorkspaceEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarWorkspacesListResult {
|
||||||
|
projects: SidebarProjectEntry[]
|
||||||
|
isLoading: boolean
|
||||||
|
isInitialLoad: boolean
|
||||||
|
isRevalidating: boolean
|
||||||
|
refreshAll: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIDEBAR_BUCKET_PRIORITY: Record<SidebarStateBucket, number> = {
|
||||||
|
done: 0,
|
||||||
|
attention: 1,
|
||||||
|
running: 2,
|
||||||
|
failed: 3,
|
||||||
|
needs_input: 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateBucket(
|
||||||
|
current: SidebarStateBucket,
|
||||||
|
candidate: SidebarStateBucket
|
||||||
|
): SidebarStateBucket {
|
||||||
|
return SIDEBAR_BUCKET_PRIORITY[candidate] > SIDEBAR_BUCKET_PRIORITY[current] ? candidate : current
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareWorkspaceBaseline(
|
||||||
|
left: SidebarWorkspaceEntry,
|
||||||
|
right: SidebarWorkspaceEntry
|
||||||
|
): number {
|
||||||
|
if (left.activityAt && right.activityAt) {
|
||||||
|
const dateDelta = right.activityAt.getTime() - left.activityAt.getTime()
|
||||||
|
if (dateDelta !== 0) {
|
||||||
|
return dateDelta
|
||||||
|
}
|
||||||
|
} else if (left.activityAt || right.activityAt) {
|
||||||
|
return left.activityAt ? -1 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameDelta = left.name.localeCompare(right.name, undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: 'base',
|
||||||
|
})
|
||||||
|
if (nameDelta !== 0) {
|
||||||
|
return nameDelta
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.workspaceId.localeCompare(right.workspaceId, undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: 'base',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareProjectBaseline(left: SidebarProjectEntry, right: SidebarProjectEntry): number {
|
||||||
|
if (left.latestActivityAt && right.latestActivityAt) {
|
||||||
|
const dateDelta = right.latestActivityAt.getTime() - left.latestActivityAt.getTime()
|
||||||
|
if (dateDelta !== 0) {
|
||||||
|
return dateDelta
|
||||||
|
}
|
||||||
|
} else if (left.latestActivityAt || right.latestActivityAt) {
|
||||||
|
return left.latestActivityAt ? -1 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.projectName.localeCompare(right.projectName, undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: 'base',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSidebarProjectsFromWorkspaces(input: {
|
||||||
|
serverId: string
|
||||||
|
workspaces: Iterable<WorkspaceDescriptor>
|
||||||
|
projectOrder: string[]
|
||||||
|
workspaceOrderByScope: Record<string, string[]>
|
||||||
|
}): SidebarProjectEntry[] {
|
||||||
|
const byProject = new Map<string, SidebarProjectEntry>()
|
||||||
|
|
||||||
|
for (const workspace of input.workspaces) {
|
||||||
|
const project =
|
||||||
|
byProject.get(workspace.projectId) ??
|
||||||
|
({
|
||||||
|
projectKey: workspace.projectId,
|
||||||
|
projectName: projectDisplayNameFromProjectId(workspace.projectId),
|
||||||
|
iconWorkingDir: workspace.id,
|
||||||
|
statusBucket: 'done',
|
||||||
|
activeCount: 0,
|
||||||
|
totalWorkspaces: 0,
|
||||||
|
latestActivityAt: null,
|
||||||
|
workspaces: [],
|
||||||
|
} satisfies SidebarProjectEntry)
|
||||||
|
|
||||||
|
const row: SidebarWorkspaceEntry = {
|
||||||
|
workspaceKey: `${input.serverId}:${workspace.id}`,
|
||||||
|
serverId: input.serverId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
name: workspace.name,
|
||||||
|
activityAt: workspace.activityAt,
|
||||||
|
statusBucket: workspace.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
project.workspaces.push(row)
|
||||||
|
project.totalWorkspaces += 1
|
||||||
|
if (workspace.status !== 'done') {
|
||||||
|
project.activeCount += 1
|
||||||
|
}
|
||||||
|
project.statusBucket = aggregateBucket(project.statusBucket, workspace.status)
|
||||||
|
if (
|
||||||
|
!project.latestActivityAt ||
|
||||||
|
(workspace.activityAt && workspace.activityAt.getTime() > project.latestActivityAt.getTime())
|
||||||
|
) {
|
||||||
|
project.latestActivityAt = workspace.activityAt
|
||||||
|
}
|
||||||
|
|
||||||
|
byProject.set(workspace.projectId, project)
|
||||||
|
}
|
||||||
|
|
||||||
|
const baselineProjects = Array.from(byProject.values()).map((project) => {
|
||||||
|
const baselineWorkspaces = [...project.workspaces]
|
||||||
|
baselineWorkspaces.sort(compareWorkspaceBaseline)
|
||||||
|
|
||||||
|
const workspaceOrderScopeKey = getWorkspaceOrderScopeKey(input.serverId, project.projectKey)
|
||||||
|
const orderedWorkspaces = applyStoredOrdering({
|
||||||
|
items: baselineWorkspaces,
|
||||||
|
storedOrder: input.workspaceOrderByScope[workspaceOrderScopeKey] ?? EMPTY_ORDER,
|
||||||
|
getKey: (workspace) => workspace.workspaceKey,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
workspaces: orderedWorkspaces,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
baselineProjects.sort(compareProjectBaseline)
|
||||||
|
|
||||||
|
return applyStoredOrdering({
|
||||||
|
items: baselineProjects,
|
||||||
|
storedOrder: input.projectOrder,
|
||||||
|
getKey: (project) => project.projectKey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyStoredOrdering<T>(input: {
|
||||||
|
items: T[]
|
||||||
|
storedOrder: string[]
|
||||||
|
getKey: (item: T) => string
|
||||||
|
}): T[] {
|
||||||
|
if (input.items.length <= 1 || input.storedOrder.length === 0) {
|
||||||
|
return input.items
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemByKey = new Map<string, T>()
|
||||||
|
for (const item of input.items) {
|
||||||
|
itemByKey.set(input.getKey(item), item)
|
||||||
|
}
|
||||||
|
|
||||||
|
const prunedOrder: string[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const key of input.storedOrder) {
|
||||||
|
if (!itemByKey.has(key) || seen.has(key)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen.add(key)
|
||||||
|
prunedOrder.push(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prunedOrder.length === 0) {
|
||||||
|
return input.items
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedSet = new Set(prunedOrder)
|
||||||
|
const ordered: T[] = []
|
||||||
|
let orderedIndex = 0
|
||||||
|
|
||||||
|
for (const item of input.items) {
|
||||||
|
const key = input.getKey(item)
|
||||||
|
if (!orderedSet.has(key)) {
|
||||||
|
ordered.push(item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetKey = prunedOrder[orderedIndex] ?? key
|
||||||
|
orderedIndex += 1
|
||||||
|
ordered.push(itemByKey.get(targetKey) ?? item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ordered
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string {
|
||||||
|
return `${serverId.trim()}::${projectKey.trim()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function toWorkspaceDescriptor(payload: {
|
||||||
|
id: string
|
||||||
|
projectId: string
|
||||||
|
name: string
|
||||||
|
status: WorkspaceDescriptor['status']
|
||||||
|
activityAt: string | null
|
||||||
|
}): WorkspaceDescriptor {
|
||||||
|
const activityAt = payload.activityAt ? new Date(payload.activityAt) : null
|
||||||
|
return {
|
||||||
|
id: payload.id,
|
||||||
|
projectId: payload.projectId,
|
||||||
|
name: payload.name,
|
||||||
|
status: payload.status,
|
||||||
|
activityAt: activityAt && !Number.isNaN(activityAt.getTime()) ? activityAt : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidebarWorkspacesList(options?: {
|
||||||
|
serverId?: string | null
|
||||||
|
enabled?: boolean
|
||||||
|
}): SidebarWorkspacesListResult {
|
||||||
|
const runtime = getHostRuntimeStore()
|
||||||
|
|
||||||
|
const serverId = useMemo(() => {
|
||||||
|
const value = options?.serverId
|
||||||
|
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
|
||||||
|
}, [options?.serverId])
|
||||||
|
const enabled = options?.enabled ?? true
|
||||||
|
|
||||||
|
const persistedProjectOrder = useSidebarOrderStore((state) =>
|
||||||
|
serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER
|
||||||
|
)
|
||||||
|
const persistedWorkspaceOrderByScope = useSidebarOrderStore((state) =>
|
||||||
|
serverId ? state.workspaceOrderByServerAndProject : {}
|
||||||
|
)
|
||||||
|
|
||||||
|
const isActive = Boolean(serverId)
|
||||||
|
const sessionWorkspaces = useSessionStore((state) =>
|
||||||
|
isActive && serverId ? (state.sessions[serverId]?.workspaces ?? null) : null
|
||||||
|
)
|
||||||
|
const hasHydratedWorkspaces = useSessionStore(
|
||||||
|
(state) => (isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false)
|
||||||
|
)
|
||||||
|
|
||||||
|
const connectionStatus = useSyncExternalStore(
|
||||||
|
(onStoreChange) =>
|
||||||
|
isActive && serverId ? runtime.subscribe(serverId, onStoreChange) : () => {},
|
||||||
|
() => {
|
||||||
|
if (!isActive || !serverId) {
|
||||||
|
return 'idle'
|
||||||
|
}
|
||||||
|
const snapshot = runtime.getSnapshot(serverId)
|
||||||
|
return snapshot?.connectionStatus ?? 'idle'
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (!isActive || !serverId) {
|
||||||
|
return 'idle'
|
||||||
|
}
|
||||||
|
const snapshot = runtime.getSnapshot(serverId)
|
||||||
|
return snapshot?.connectionStatus ?? 'idle'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const projects = useMemo(() => {
|
||||||
|
if (!sessionWorkspaces || sessionWorkspaces.size === 0 || !serverId) {
|
||||||
|
return EMPTY_PROJECTS
|
||||||
|
}
|
||||||
|
return buildSidebarProjectsFromWorkspaces({
|
||||||
|
serverId,
|
||||||
|
workspaces: sessionWorkspaces.values(),
|
||||||
|
projectOrder: persistedProjectOrder,
|
||||||
|
workspaceOrderByScope: persistedWorkspaceOrderByScope,
|
||||||
|
})
|
||||||
|
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, serverId, sessionWorkspaces])
|
||||||
|
|
||||||
|
const refreshAll = useCallback(() => {
|
||||||
|
if (!isActive || !serverId || connectionStatus !== 'online' || !enabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const client = runtime.getClient(serverId)
|
||||||
|
if (!client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void client
|
||||||
|
.fetchWorkspaces({ page: { limit: 200 } })
|
||||||
|
.then((payload) => {
|
||||||
|
const next = new Map<string, WorkspaceDescriptor>()
|
||||||
|
for (const entry of payload.entries) {
|
||||||
|
const workspace = toWorkspaceDescriptor(entry)
|
||||||
|
next.set(workspace.id, workspace)
|
||||||
|
}
|
||||||
|
const store = useSessionStore.getState()
|
||||||
|
store.setWorkspaces(serverId, next)
|
||||||
|
store.setHasHydratedWorkspaces(serverId, true)
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}, [connectionStatus, enabled, isActive, runtime, serverId])
|
||||||
|
|
||||||
|
const isLoading = isActive && Boolean(serverId) && connectionStatus === 'online' && !hasHydratedWorkspaces
|
||||||
|
const isInitialLoad = isLoading && projects.length === 0
|
||||||
|
const isRevalidating = false
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects,
|
||||||
|
isLoading,
|
||||||
|
isInitialLoad,
|
||||||
|
isRevalidating,
|
||||||
|
refreshAll,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -49,7 +49,10 @@ import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-an
|
|||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||||
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
|
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 {
|
import {
|
||||||
buildWorkspaceTabPersistenceKey,
|
buildWorkspaceTabPersistenceKey,
|
||||||
useWorkspaceTabsStore,
|
useWorkspaceTabsStore,
|
||||||
@@ -80,11 +83,9 @@ import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agen
|
|||||||
import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row";
|
import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row";
|
||||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
import {
|
import {
|
||||||
deriveProjectDisplayName,
|
resolveWorkspaceHeader,
|
||||||
deriveProjectKey,
|
shouldRenderMissingWorkspaceDescriptor,
|
||||||
deriveProjectName,
|
} from "@/screens/workspace/workspace-header-source";
|
||||||
deriveRemoteProjectKey,
|
|
||||||
} from "@/utils/agent-grouping";
|
|
||||||
|
|
||||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||||
const DROPDOWN_WIDTH = 220;
|
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 {
|
function formatProviderLabel(provider: Agent["provider"]): string {
|
||||||
if (provider === "claude") {
|
if (provider === "claude") {
|
||||||
return "Claude";
|
return "Claude";
|
||||||
@@ -407,18 +390,17 @@ function WorkspaceScreenContent({
|
|||||||
staleTime: 15_000,
|
staleTime: 15_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const workspaceName = useMemo(
|
const workspaceDescriptor = useSessionStore(
|
||||||
() => deriveWorkspaceName(normalizedWorkspaceId),
|
(state) =>
|
||||||
[normalizedWorkspaceId]
|
state.sessions[normalizedServerId]?.workspaces.get(normalizedWorkspaceId) ??
|
||||||
|
null
|
||||||
);
|
);
|
||||||
const headerProjectName = useMemo(
|
const hasHydratedWorkspaces = useSessionStore(
|
||||||
() =>
|
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false
|
||||||
deriveWorkspaceProjectDisplayName({
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
checkout: checkoutQuery.data ?? null,
|
|
||||||
}),
|
|
||||||
[checkoutQuery.data, normalizedWorkspaceId]
|
|
||||||
);
|
);
|
||||||
|
const workspaceHeader = workspaceDescriptor
|
||||||
|
? resolveWorkspaceHeader({ workspace: workspaceDescriptor })
|
||||||
|
: null;
|
||||||
|
|
||||||
const isGitCheckout = checkoutQuery.data?.isGit ?? false;
|
const isGitCheckout = checkoutQuery.data?.isGit ?? false;
|
||||||
const areWorkspaceAgentsHydrated = useSessionStore(
|
const areWorkspaceAgentsHydrated = useSessionStore(
|
||||||
@@ -1228,6 +1210,21 @@ function WorkspaceScreenContent({
|
|||||||
}, [activeAgent, normalizedServerId, router]);
|
}, [activeAgent, normalizedServerId, router]);
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
|
if (
|
||||||
|
shouldRenderMissingWorkspaceDescriptor({
|
||||||
|
workspace: workspaceDescriptor,
|
||||||
|
hasHydratedWorkspaces,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Text style={styles.emptyStateText}>
|
||||||
|
Workspace descriptor missing for this route.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const target = activeTab?.target ?? null;
|
const target = activeTab?.target ?? null;
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return (
|
return (
|
||||||
@@ -1324,10 +1321,10 @@ function WorkspaceScreenContent({
|
|||||||
<SidebarMenuToggle />
|
<SidebarMenuToggle />
|
||||||
<View style={styles.headerTitleContainer}>
|
<View style={styles.headerTitleContainer}>
|
||||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||||
{workspaceName}
|
{workspaceHeader ? workspaceHeader.title : "Loading workspace…"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.headerProjectTitle} numberOfLines={1}>
|
<Text style={styles.headerProjectTitle} numberOfLines={1}>
|
||||||
{headerProjectName}
|
{workspaceHeader ? workspaceHeader.subtitle : "Awaiting workspace descriptor"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -23,6 +23,7 @@ import type {
|
|||||||
GitSetupOptions,
|
GitSetupOptions,
|
||||||
ProjectPlacementPayload,
|
ProjectPlacementPayload,
|
||||||
ServerCapabilities,
|
ServerCapabilities,
|
||||||
|
WorkspaceDescriptorPayload,
|
||||||
} from "@server/shared/messages";
|
} from "@server/shared/messages";
|
||||||
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
||||||
import {
|
import {
|
||||||
@@ -108,6 +109,14 @@ export interface Agent {
|
|||||||
projectPlacement?: ProjectPlacementPayload | null;
|
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 ExplorerEntryKind = "file" | "directory";
|
||||||
export type ExplorerFileKind = "text" | "image" | "binary";
|
export type ExplorerFileKind = "text" | "image" | "binary";
|
||||||
export type ExplorerEncoding = "utf-8" | "base64" | "none";
|
export type ExplorerEncoding = "utf-8" | "base64" | "none";
|
||||||
@@ -180,6 +189,7 @@ export interface SessionState {
|
|||||||
|
|
||||||
// Hydration status
|
// Hydration status
|
||||||
hasHydratedAgents: boolean;
|
hasHydratedAgents: boolean;
|
||||||
|
hasHydratedWorkspaces: boolean;
|
||||||
|
|
||||||
// Audio state
|
// Audio state
|
||||||
isPlayingAudio: boolean;
|
isPlayingAudio: boolean;
|
||||||
@@ -203,6 +213,7 @@ export interface SessionState {
|
|||||||
|
|
||||||
// Agents
|
// Agents
|
||||||
agents: Map<string, Agent>;
|
agents: Map<string, Agent>;
|
||||||
|
workspaces: Map<string, WorkspaceDescriptor>;
|
||||||
|
|
||||||
// Permissions
|
// Permissions
|
||||||
pendingPermissions: Map<string, PendingPermission>;
|
pendingPermissions: Map<string, PendingPermission>;
|
||||||
@@ -267,6 +278,17 @@ interface SessionStoreActions {
|
|||||||
|
|
||||||
// Agents
|
// Agents
|
||||||
setAgents: (serverId: string, agents: Map<string, Agent> | ((prev: Map<string, Agent>) => Map<string, Agent>)) => void;
|
setAgents: (serverId: string, agents: Map<string, Agent> | ((prev: Map<string, Agent>) => Map<string, Agent>)) => void;
|
||||||
|
setWorkspaces: (
|
||||||
|
serverId: string,
|
||||||
|
workspaces:
|
||||||
|
| Map<string, WorkspaceDescriptor>
|
||||||
|
| ((prev: Map<string, WorkspaceDescriptor>) => Map<string, WorkspaceDescriptor>)
|
||||||
|
) => void;
|
||||||
|
mergeWorkspaces: (
|
||||||
|
serverId: string,
|
||||||
|
workspaces: Iterable<WorkspaceDescriptor>
|
||||||
|
) => void;
|
||||||
|
removeWorkspace: (serverId: string, workspaceId: string) => void;
|
||||||
|
|
||||||
// Agent activity timestamps
|
// Agent activity timestamps
|
||||||
setAgentLastActivity: (agentId: string, timestamp: Date) => void;
|
setAgentLastActivity: (agentId: string, timestamp: Date) => void;
|
||||||
@@ -296,6 +318,7 @@ interface SessionStoreActions {
|
|||||||
|
|
||||||
// Hydration
|
// Hydration
|
||||||
setHasHydratedAgents: (serverId: string, hydrated: boolean) => void;
|
setHasHydratedAgents: (serverId: string, hydrated: boolean) => void;
|
||||||
|
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
|
||||||
|
|
||||||
// Agent directory (derived from agents)
|
// Agent directory (derived from agents)
|
||||||
getAgentDirectory: (serverId: string) => AgentDirectoryEntry[] | undefined;
|
getAgentDirectory: (serverId: string) => AgentDirectoryEntry[] | undefined;
|
||||||
@@ -335,6 +358,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient, audio
|
|||||||
serverInfo: null,
|
serverInfo: null,
|
||||||
audioPlayer,
|
audioPlayer,
|
||||||
hasHydratedAgents: false,
|
hasHydratedAgents: false,
|
||||||
|
hasHydratedWorkspaces: false,
|
||||||
isPlayingAudio: false,
|
isPlayingAudio: false,
|
||||||
focusedAgentId: null,
|
focusedAgentId: null,
|
||||||
messages: [],
|
messages: [],
|
||||||
@@ -346,6 +370,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient, audio
|
|||||||
agentHistorySyncGeneration: new Map(),
|
agentHistorySyncGeneration: new Map(),
|
||||||
initializingAgents: new Map(),
|
initializingAgents: new Map(),
|
||||||
agents: new Map(),
|
agents: new Map(),
|
||||||
|
workspaces: new Map(),
|
||||||
pendingPermissions: new Map(),
|
pendingPermissions: new Map(),
|
||||||
fileExplorer: new Map(),
|
fileExplorer: new Map(),
|
||||||
queuedMessages: new Map(),
|
queuedMessages: new Map(),
|
||||||
@@ -851,6 +876,78 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
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)
|
// Agent activity timestamps (top-level, does NOT mutate session object)
|
||||||
setAgentLastActivity: (agentId, timestamp) => {
|
setAgentLastActivity: (agentId, timestamp) => {
|
||||||
agentLastActivityCoalescer.enqueue(agentId, timestamp);
|
agentLastActivityCoalescer.enqueue(agentId, timestamp);
|
||||||
@@ -980,6 +1077,23 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
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)
|
// Agent directory - derived from agents (computed on-demand)
|
||||||
getAgentDirectory: (serverId) => {
|
getAgentDirectory: (serverId) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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";
|
import { buildSidebarWorkspaceViewModel } from "./sidebar-shortcuts";
|
||||||
|
|
||||||
@@ -7,11 +7,9 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
|
|||||||
return {
|
return {
|
||||||
workspaceKey: `${serverId}:${cwd}`,
|
workspaceKey: `${serverId}:${cwd}`,
|
||||||
serverId,
|
serverId,
|
||||||
cwd,
|
workspaceId: cwd,
|
||||||
branchName: null,
|
name: cwd,
|
||||||
createdAt: null,
|
activityAt: null,
|
||||||
isMainCheckout: false,
|
|
||||||
isPaseoOwnedWorktree: false,
|
|
||||||
statusBucket: "done",
|
statusBucket: "done",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -20,11 +18,11 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
|
|||||||
return {
|
return {
|
||||||
projectKey,
|
projectKey,
|
||||||
projectName: projectKey,
|
projectName: projectKey,
|
||||||
iconWorkingDir: workspaces[0]?.cwd ?? "",
|
iconWorkingDir: workspaces[0]?.workspaceId ?? "",
|
||||||
statusBucket: "done",
|
statusBucket: "done",
|
||||||
activeCount: 0,
|
activeCount: 0,
|
||||||
totalCount: workspaces.length,
|
totalWorkspaces: workspaces.length,
|
||||||
latestCreatedAt: null,
|
latestActivityAt: null,
|
||||||
workspaces,
|
workspaces,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
SidebarProjectEntry,
|
SidebarProjectEntry,
|
||||||
SidebarWorkspaceEntry,
|
SidebarWorkspaceEntry,
|
||||||
} from '@/hooks/use-sidebar-agents-list'
|
} from '@/hooks/use-sidebar-workspaces-list'
|
||||||
|
|
||||||
export interface SidebarShortcutWorkspaceTarget {
|
export interface SidebarShortcutWorkspaceTarget {
|
||||||
serverId: string
|
serverId: string
|
||||||
@@ -58,7 +58,7 @@ export function buildSidebarWorkspaceViewModel(input: {
|
|||||||
if (shortcutNumber !== null) {
|
if (shortcutNumber !== null) {
|
||||||
shortcutTargets.push({
|
shortcutTargets.push({
|
||||||
serverId: workspace.serverId,
|
serverId: workspace.serverId,
|
||||||
workspaceId: workspace.cwd,
|
workspaceId: workspace.workspaceId,
|
||||||
})
|
})
|
||||||
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber)
|
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,11 @@ export type DaemonEvent =
|
|||||||
agentId: string
|
agentId: string
|
||||||
payload: Extract<SessionOutboundMessage, { type: 'agent_update' }>['payload']
|
payload: Extract<SessionOutboundMessage, { type: 'agent_update' }>['payload']
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'workspace_update'
|
||||||
|
workspaceId: string
|
||||||
|
payload: Extract<SessionOutboundMessage, { type: 'workspace_update' }>['payload']
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'agent_stream'
|
type: 'agent_stream'
|
||||||
agentId: string
|
agentId: string
|
||||||
@@ -287,6 +292,19 @@ export type FetchAgentsOptions = Omit<FetchAgentsRequest, 'type' | 'requestId'>
|
|||||||
}
|
}
|
||||||
export type FetchAgentsEntry = FetchAgentsPayload['entries'][number]
|
export type FetchAgentsEntry = FetchAgentsPayload['entries'][number]
|
||||||
export type FetchAgentsPageInfo = FetchAgentsPayload['pageInfo']
|
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<FetchWorkspacesRequest, 'type' | 'requestId'> & {
|
||||||
|
requestId?: string
|
||||||
|
}
|
||||||
|
export type FetchWorkspacesEntry = FetchWorkspacesPayload['entries'][number]
|
||||||
|
export type FetchWorkspacesPageInfo = FetchWorkspacesPayload['pageInfo']
|
||||||
|
|
||||||
export type FetchAgentResult = {
|
export type FetchAgentResult = {
|
||||||
agent: AgentSnapshotPayload
|
agent: AgentSnapshotPayload
|
||||||
@@ -1098,6 +1116,33 @@ export class DaemonClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchWorkspaces(options?: FetchWorkspacesOptions): Promise<FetchWorkspacesPayload> {
|
||||||
|
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<FetchAgentResult | null> {
|
async fetchAgent(agentId: string, requestId?: string): Promise<FetchAgentResult | null> {
|
||||||
const resolvedRequestId = this.createRequestId(requestId)
|
const resolvedRequestId = this.createRequestId(requestId)
|
||||||
const message = SessionInboundMessageSchema.parse({
|
const message = SessionInboundMessageSchema.parse({
|
||||||
@@ -3077,6 +3122,13 @@ export class DaemonClient {
|
|||||||
agentId: msg.payload.kind === 'upsert' ? msg.payload.agent.id : msg.payload.agentId,
|
agentId: msg.payload.kind === 'upsert' ? msg.payload.agent.id : msg.payload.agentId,
|
||||||
payload: msg.payload,
|
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':
|
case 'agent_stream':
|
||||||
return {
|
return {
|
||||||
type: 'agent_stream',
|
type: 'agent_stream',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import {
|
|||||||
type DirectorySuggestionsRequest,
|
type DirectorySuggestionsRequest,
|
||||||
type ProjectCheckoutLitePayload,
|
type ProjectCheckoutLitePayload,
|
||||||
type ProjectPlacementPayload,
|
type ProjectPlacementPayload,
|
||||||
|
type WorkspaceDescriptorPayload,
|
||||||
|
type WorkspaceStateBucket,
|
||||||
} from './messages.js'
|
} from './messages.js'
|
||||||
import type { TerminalManager, TerminalsChangedEvent } from '../terminal/terminal-manager.js'
|
import type { TerminalManager, TerminalsChangedEvent } from '../terminal/terminal-manager.js'
|
||||||
import type { TerminalSession } from '../terminal/terminal.js'
|
import type { TerminalSession } from '../terminal/terminal.js'
|
||||||
@@ -301,6 +303,31 @@ type FetchAgentsCursor = {
|
|||||||
values: Record<string, string | number | null>
|
values: Record<string, string | number | null>
|
||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
type FetchWorkspacesRequestMessage = Extract<
|
||||||
|
SessionInboundMessage,
|
||||||
|
{ type: 'fetch_workspaces_request' }
|
||||||
|
>
|
||||||
|
type FetchWorkspacesRequestFilter = NonNullable<FetchWorkspacesRequestMessage['filter']>
|
||||||
|
type FetchWorkspacesRequestSort = NonNullable<FetchWorkspacesRequestMessage['sort']>[number]
|
||||||
|
type FetchWorkspacesResponsePayload = Extract<
|
||||||
|
SessionOutboundMessage,
|
||||||
|
{ type: 'fetch_workspaces_response' }
|
||||||
|
>['payload']
|
||||||
|
type FetchWorkspacesResponseEntry = FetchWorkspacesResponsePayload['entries'][number]
|
||||||
|
type FetchWorkspacesResponsePageInfo = FetchWorkspacesResponsePayload['pageInfo']
|
||||||
|
type WorkspaceUpdatePayload = Extract<SessionOutboundMessage, { type: 'workspace_update' }>['payload']
|
||||||
|
type WorkspaceUpdatesFilter = FetchWorkspacesRequestFilter
|
||||||
|
type WorkspaceUpdatesSubscriptionState = {
|
||||||
|
subscriptionId: string
|
||||||
|
filter?: WorkspaceUpdatesFilter
|
||||||
|
isBootstrapping: boolean
|
||||||
|
pendingUpdatesByWorkspaceId: Map<string, WorkspaceUpdatePayload>
|
||||||
|
}
|
||||||
|
type FetchWorkspacesCursor = {
|
||||||
|
sort: FetchWorkspacesRequestSort[]
|
||||||
|
values: Record<string, string | number | null>
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
class SessionRequestError extends Error {
|
class SessionRequestError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -552,6 +579,7 @@ export class Session {
|
|||||||
private readonly providerRegistry: ReturnType<typeof buildProviderRegistry>
|
private readonly providerRegistry: ReturnType<typeof buildProviderRegistry>
|
||||||
private unsubscribeAgentEvents: (() => void) | null = null
|
private unsubscribeAgentEvents: (() => void) | null = null
|
||||||
private agentUpdatesSubscription: AgentUpdatesSubscriptionState | null = null
|
private agentUpdatesSubscription: AgentUpdatesSubscriptionState | null = null
|
||||||
|
private workspaceUpdatesSubscription: WorkspaceUpdatesSubscriptionState | null = null
|
||||||
private clientActivity: {
|
private clientActivity: {
|
||||||
deviceType: 'web' | 'mobile'
|
deviceType: 'web' | 'mobile'
|
||||||
focusedAgentId: string | null
|
focusedAgentId: string | null
|
||||||
@@ -1258,31 +1286,30 @@ export class Session {
|
|||||||
private async forwardAgentUpdate(agent: ManagedAgent): Promise<void> {
|
private async forwardAgentUpdate(agent: ManagedAgent): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const subscription = this.agentUpdatesSubscription
|
const subscription = this.agentUpdatesSubscription
|
||||||
if (!subscription) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await this.buildAgentPayload(agent)
|
const payload = await this.buildAgentPayload(agent)
|
||||||
const project = await this.buildProjectPlacement(payload.cwd)
|
if (subscription) {
|
||||||
const matches = this.matchesAgentFilter({
|
const project = await this.buildProjectPlacement(payload.cwd)
|
||||||
agent: payload,
|
const matches = this.matchesAgentFilter({
|
||||||
project,
|
|
||||||
filter: subscription.filter,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (matches) {
|
|
||||||
this.bufferOrEmitAgentUpdate(subscription, {
|
|
||||||
kind: 'upsert',
|
|
||||||
agent: payload,
|
agent: payload,
|
||||||
project,
|
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, {
|
await this.emitWorkspaceUpdateForCwd(payload.cwd)
|
||||||
kind: 'remove',
|
|
||||||
agentId: payload.id,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.sessionLogger.error({ err: error }, 'Failed to emit agent update')
|
this.sessionLogger.error({ err: error }, 'Failed to emit agent update')
|
||||||
}
|
}
|
||||||
@@ -1310,6 +1337,10 @@ export class Session {
|
|||||||
await this.handleFetchAgents(msg)
|
await this.handleFetchAgents(msg)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case 'fetch_workspaces_request':
|
||||||
|
await this.handleFetchWorkspacesRequest(msg)
|
||||||
|
break
|
||||||
|
|
||||||
case 'fetch_agent_request':
|
case 'fetch_agent_request':
|
||||||
await this.handleFetchAgent(msg.agentId, msg.requestId)
|
await this.handleFetchAgent(msg.agentId, msg.requestId)
|
||||||
break
|
break
|
||||||
@@ -1728,6 +1759,11 @@ export class Session {
|
|||||||
private async handleDeleteAgentRequest(agentId: string, requestId: string): Promise<void> {
|
private async handleDeleteAgentRequest(agentId: string, requestId: string): Promise<void> {
|
||||||
this.sessionLogger.info({ agentId }, `Deleting agent ${agentId} from registry`)
|
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.
|
// Prevent the persistence hook from re-creating the record while we close/delete.
|
||||||
this.agentStorage.beginDelete(agentId)
|
this.agentStorage.beginDelete(agentId)
|
||||||
|
|
||||||
@@ -1763,6 +1799,10 @@ export class Session {
|
|||||||
agentId,
|
agentId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (knownCwd) {
|
||||||
|
await this.emitWorkspaceUpdateForCwd(knownCwd)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise<void> {
|
private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise<void> {
|
||||||
@@ -5203,6 +5243,434 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly workspaceStatePriority: Record<WorkspaceStateBucket, number> = {
|
||||||
|
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<WorkspaceDescriptorPayload[]> {
|
||||||
|
const agents = await this.listAgentPayloads({
|
||||||
|
labels: { ui: 'true' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>()
|
||||||
|
const placementByWorkspaceId = new Map<string, Promise<ProjectPlacementPayload>>()
|
||||||
|
const getPlacement = (workspaceCwd: string): Promise<ProjectPlacementPayload> => {
|
||||||
|
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<string>()
|
||||||
|
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<string, string | number | null> = {}
|
||||||
|
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<string, string | number | null>,
|
||||||
|
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<SessionInboundMessage, { type: 'fetch_workspaces_request' }>
|
||||||
|
): 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<string, number>
|
||||||
|
}): 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<void> {
|
||||||
|
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(
|
private async handleFetchAgents(
|
||||||
request: Extract<SessionInboundMessage, { type: 'fetch_agents_request' }>
|
request: Extract<SessionInboundMessage, { type: 'fetch_agents_request' }>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -5263,6 +5731,68 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleFetchWorkspacesRequest(
|
||||||
|
request: Extract<SessionInboundMessage, { type: 'fetch_workspaces_request' }>
|
||||||
|
): Promise<void> {
|
||||||
|
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<string, number>()
|
||||||
|
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<void> {
|
private async handleFetchAgent(agentIdOrIdentifier: string, requestId: string): Promise<void> {
|
||||||
const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier)
|
const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier)
|
||||||
if (!resolved.ok) {
|
if (!resolved.ok) {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -512,6 +512,45 @@ export const FetchAgentsRequestMessageSchema = z.object({
|
|||||||
.optional(),
|
.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({
|
export const FetchAgentRequestMessageSchema = z.object({
|
||||||
type: z.literal('fetch_agent_request'),
|
type: z.literal('fetch_agent_request'),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
@@ -1068,6 +1107,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion('type', [
|
|||||||
AbortRequestMessageSchema,
|
AbortRequestMessageSchema,
|
||||||
AudioPlayedMessageSchema,
|
AudioPlayedMessageSchema,
|
||||||
FetchAgentsRequestMessageSchema,
|
FetchAgentsRequestMessageSchema,
|
||||||
|
FetchWorkspacesRequestMessageSchema,
|
||||||
FetchAgentRequestMessageSchema,
|
FetchAgentRequestMessageSchema,
|
||||||
DeleteAgentRequestMessageSchema,
|
DeleteAgentRequestMessageSchema,
|
||||||
ArchiveAgentRequestMessageSchema,
|
ArchiveAgentRequestMessageSchema,
|
||||||
@@ -1426,6 +1466,14 @@ export const ProjectPlacementPayloadSchema = z.object({
|
|||||||
checkout: ProjectCheckoutLitePayloadSchema,
|
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({
|
export const AgentUpdateMessageSchema = z.object({
|
||||||
type: z.literal('agent_update'),
|
type: z.literal('agent_update'),
|
||||||
payload: z.discriminatedUnion('kind', [
|
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({
|
export const FetchAgentResponseMessageSchema = z.object({
|
||||||
type: z.literal('fetch_agent_response'),
|
type: z.literal('fetch_agent_response'),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -2051,9 +2127,11 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion('type', [
|
|||||||
RpcErrorMessageSchema,
|
RpcErrorMessageSchema,
|
||||||
ArtifactMessageSchema,
|
ArtifactMessageSchema,
|
||||||
AgentUpdateMessageSchema,
|
AgentUpdateMessageSchema,
|
||||||
|
WorkspaceUpdateMessageSchema,
|
||||||
AgentStreamMessageSchema,
|
AgentStreamMessageSchema,
|
||||||
AgentStatusMessageSchema,
|
AgentStatusMessageSchema,
|
||||||
FetchAgentsResponseMessageSchema,
|
FetchAgentsResponseMessageSchema,
|
||||||
|
FetchWorkspacesResponseMessageSchema,
|
||||||
FetchAgentResponseMessageSchema,
|
FetchAgentResponseMessageSchema,
|
||||||
FetchAgentTimelineResponseMessageSchema,
|
FetchAgentTimelineResponseMessageSchema,
|
||||||
SendAgentMessageResponseMessageSchema,
|
SendAgentMessageResponseMessageSchema,
|
||||||
@@ -2119,7 +2197,10 @@ export type AgentStreamMessage = z.infer<typeof AgentStreamMessageSchema>
|
|||||||
export type AgentStatusMessage = z.infer<typeof AgentStatusMessageSchema>
|
export type AgentStatusMessage = z.infer<typeof AgentStatusMessageSchema>
|
||||||
export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePayloadSchema>
|
export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePayloadSchema>
|
||||||
export type ProjectPlacementPayload = z.infer<typeof ProjectPlacementPayloadSchema>
|
export type ProjectPlacementPayload = z.infer<typeof ProjectPlacementPayloadSchema>
|
||||||
|
export type WorkspaceStateBucket = z.infer<typeof WorkspaceStateBucketSchema>
|
||||||
|
export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPayloadSchema>
|
||||||
export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>
|
export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>
|
||||||
|
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>
|
||||||
export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema>
|
export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema>
|
||||||
export type FetchAgentTimelineResponseMessage = z.infer<
|
export type FetchAgentTimelineResponseMessage = z.infer<
|
||||||
typeof FetchAgentTimelineResponseMessageSchema
|
typeof FetchAgentTimelineResponseMessageSchema
|
||||||
@@ -2144,6 +2225,7 @@ export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>
|
|||||||
// Type exports for inbound message types
|
// Type exports for inbound message types
|
||||||
export type VoiceAudioChunkMessage = z.infer<typeof VoiceAudioChunkMessageSchema>
|
export type VoiceAudioChunkMessage = z.infer<typeof VoiceAudioChunkMessageSchema>
|
||||||
export type FetchAgentsRequestMessage = z.infer<typeof FetchAgentsRequestMessageSchema>
|
export type FetchAgentsRequestMessage = z.infer<typeof FetchAgentsRequestMessageSchema>
|
||||||
|
export type FetchWorkspacesRequestMessage = z.infer<typeof FetchWorkspacesRequestMessageSchema>
|
||||||
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>
|
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>
|
||||||
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>
|
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>
|
||||||
export type WaitForFinishRequest = z.infer<typeof WaitForFinishRequestSchema>
|
export type WaitForFinishRequest = z.infer<typeof WaitForFinishRequestSchema>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user