feat: workspace source-of-truth hard cut
This commit is contained in:
@@ -15,7 +15,7 @@ import { router, usePathname } from 'expo-router'
|
||||
import { usePanelStore } from '@/stores/panel-store'
|
||||
import { SidebarAgentList } from './sidebar-agent-list'
|
||||
import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton'
|
||||
import { useSidebarAgentsList } from '@/hooks/use-sidebar-agents-list'
|
||||
import { useSidebarWorkspacesList } from '@/hooks/use-sidebar-workspaces-list'
|
||||
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
|
||||
import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window'
|
||||
import { Combobox } from '@/components/ui/combobox'
|
||||
@@ -112,7 +112,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
||||
// Derive isOpen from the unified panel state
|
||||
const isOpen = isMobile ? mobileView === 'agent-list' : desktopAgentListOpen
|
||||
|
||||
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarAgentsList({
|
||||
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
||||
serverId: activeServerId,
|
||||
enabled: isOpen,
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { View, Text, Pressable, Image, Platform, Alert, ActivityIndicator, StatusBar } from 'react-native'
|
||||
import { View, Text, Pressable, Image, Platform } from 'react-native'
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import {
|
||||
useCallback,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { router, usePathname, useSegments } from 'expo-router'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'
|
||||
import { Archive, ChevronDown, ChevronRight } from 'lucide-react-native'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react-native'
|
||||
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
|
||||
import { getIsTauri } from '@/constants/layout'
|
||||
@@ -24,21 +24,15 @@ import {
|
||||
import {
|
||||
type SidebarProjectEntry,
|
||||
type SidebarWorkspaceEntry,
|
||||
} from '@/hooks/use-sidebar-agents-list'
|
||||
type SidebarStateBucket,
|
||||
} from '@/hooks/use-sidebar-workspaces-list'
|
||||
import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
|
||||
import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store'
|
||||
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
|
||||
import { formatTimeAgo } from '@/utils/time'
|
||||
import type { SidebarStateBucket } from '@/utils/sidebar-agent-state'
|
||||
import { confirmDialog } from '@/utils/confirm-dialog'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
useContextMenu,
|
||||
} from '@/components/ui/context-menu'
|
||||
import { deriveProjectDisplayName } from '@/utils/agent-grouping'
|
||||
import {
|
||||
buildSidebarWorkspaceViewModel,
|
||||
type SidebarWorkspaceTreeRow,
|
||||
@@ -81,19 +75,11 @@ interface WorkspaceRowProps {
|
||||
drag: () => void
|
||||
}
|
||||
|
||||
function resolveWorkspaceBranchLabel(workspace: SidebarWorkspaceEntry): string {
|
||||
const branch = workspace.branchName?.trim()
|
||||
if (branch && branch.length > 0) {
|
||||
return branch
|
||||
}
|
||||
return 'Unknown branch'
|
||||
}
|
||||
|
||||
function resolveWorkspaceCreatedAtLabel(workspace: SidebarWorkspaceEntry): string | null {
|
||||
if (!workspace.createdAt) {
|
||||
if (!workspace.activityAt) {
|
||||
return null
|
||||
}
|
||||
return formatTimeAgo(workspace.createdAt)
|
||||
return formatTimeAgo(workspace.activityAt)
|
||||
}
|
||||
|
||||
function resolveStatusDotColor(input: { theme: ReturnType<typeof useUnistyles>['theme']; bucket: SidebarStateBucket }) {
|
||||
@@ -111,21 +97,15 @@ function resolveStatusDotColor(input: { theme: ReturnType<typeof useUnistyles>['
|
||||
|
||||
function WorkspaceStatusIndicator({
|
||||
bucket,
|
||||
loading = false,
|
||||
}: {
|
||||
bucket: SidebarWorkspaceEntry['statusBucket']
|
||||
loading?: boolean
|
||||
}) {
|
||||
const { theme } = useUnistyles()
|
||||
const color = resolveStatusDotColor({ theme, bucket })
|
||||
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot}>
|
||||
{loading ? (
|
||||
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<View style={[styles.workspaceStatusDotFill, { backgroundColor: color }]} />
|
||||
)}
|
||||
<View style={[styles.workspaceStatusDotFill, { backgroundColor: color }]} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -198,69 +178,8 @@ function WorkspaceRow({
|
||||
onPress,
|
||||
drag,
|
||||
}: WorkspaceRowProps) {
|
||||
const { theme } = useUnistyles()
|
||||
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
|
||||
|
||||
const { setAnchorRect, setOpen } = useContextMenu()
|
||||
const didLongPressRef = useRef(false)
|
||||
const didLongPressCleanupTimerRef = useRef<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(() => {
|
||||
if (didLongPressRef.current) {
|
||||
@@ -271,67 +190,12 @@ function WorkspaceRow({
|
||||
}, [onPress])
|
||||
|
||||
const handleLongPress = useCallback(() => {
|
||||
if (longPressCancelledRef.current) {
|
||||
return
|
||||
}
|
||||
didLongPressRef.current = true
|
||||
longPressArmedRef.current = true
|
||||
}, [])
|
||||
|
||||
const moveMonitorGesture = useMemo(() => {
|
||||
if (Platform.OS === 'web') {
|
||||
return null
|
||||
}
|
||||
|
||||
const CANCEL_SLOP_PX = 10
|
||||
const DRAG_SLOP_PX = 8
|
||||
|
||||
return Gesture.Pan()
|
||||
.manualActivation(true)
|
||||
.runOnJS(true)
|
||||
.onTouchesDown((event) => {
|
||||
const touch = event.changedTouches[0]
|
||||
if (!touch) {
|
||||
return
|
||||
}
|
||||
touchStartRef.current = { x: touch.absoluteX, y: touch.absoluteY }
|
||||
})
|
||||
.onTouchesMove((event, stateManager) => {
|
||||
const touch = event.changedTouches[0]
|
||||
if (!touch || event.numberOfTouches !== 1) {
|
||||
stateManager.fail()
|
||||
return
|
||||
}
|
||||
|
||||
const start = touchStartRef.current
|
||||
if (!start) {
|
||||
touchStartRef.current = { x: touch.absoluteX, y: touch.absoluteY }
|
||||
return
|
||||
}
|
||||
|
||||
const dx = touch.absoluteX - start.x
|
||||
const dy = touch.absoluteY - start.y
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if (!longPressArmedRef.current) {
|
||||
if (distance > CANCEL_SLOP_PX) {
|
||||
longPressCancelledRef.current = true
|
||||
stateManager.fail()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (didStartDragRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (distance > DRAG_SLOP_PX) {
|
||||
didStartDragRef.current = true
|
||||
drag()
|
||||
stateManager.fail()
|
||||
}
|
||||
})
|
||||
}, [drag])
|
||||
return Platform.OS === 'web' ? null : Gesture.Pan().manualActivation(true).runOnJS(true)
|
||||
}, [])
|
||||
|
||||
const trigger = (
|
||||
<ContextMenuTrigger
|
||||
@@ -342,46 +206,15 @@ function WorkspaceRow({
|
||||
hovered && styles.workspaceRowHovered,
|
||||
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}
|
||||
onLongPress={handleLongPress}
|
||||
delayLongPress={200}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
<View style={styles.workspaceRowLeft}>
|
||||
<WorkspaceStatusIndicator bucket={workspace.statusBucket} loading={isArchiving} />
|
||||
<WorkspaceStatusIndicator bucket={workspace.statusBucket} />
|
||||
<Text style={styles.workspaceBranchText} numberOfLines={1}>
|
||||
{resolveWorkspaceBranchLabel(workspace)}
|
||||
{workspace.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.workspaceRowRight}>
|
||||
@@ -399,33 +232,10 @@ function WorkspaceRow({
|
||||
</ContextMenuTrigger>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{moveMonitorGesture ? (
|
||||
<GestureDetector gesture={moveMonitorGesture}>{trigger}</GestureDetector>
|
||||
) : (
|
||||
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>
|
||||
</>
|
||||
return moveMonitorGesture ? (
|
||||
<GestureDetector gesture={moveMonitorGesture}>{trigger}</GestureDetector>
|
||||
) : (
|
||||
trigger
|
||||
)
|
||||
}
|
||||
|
||||
@@ -609,11 +419,7 @@ export function SidebarAgentList({
|
||||
buildSidebarWorkspaceViewModel({
|
||||
projects,
|
||||
collapsedProjectKeys,
|
||||
getProjectDisplayName: (project) =>
|
||||
deriveProjectDisplayName({
|
||||
projectKey: project.projectKey,
|
||||
projectName: project.projectName,
|
||||
}),
|
||||
getProjectDisplayName: (project) => project.projectName,
|
||||
}),
|
||||
[canonicalResyncNonce, collapsedProjectKeys, projects]
|
||||
)
|
||||
@@ -655,12 +461,12 @@ export function SidebarAgentList({
|
||||
)
|
||||
}
|
||||
|
||||
const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspace.cwd)
|
||||
const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspace.workspaceId)
|
||||
const navigate = shouldReplaceWorkspaceNavigation ? router.replace : router.push
|
||||
const isSelected =
|
||||
Boolean(serverId) &&
|
||||
activeWorkspaceSelection?.serverId === serverId &&
|
||||
activeWorkspaceSelection.workspaceId === item.workspace.cwd
|
||||
activeWorkspaceSelection.workspaceId === item.workspace.workspaceId
|
||||
|
||||
return (
|
||||
<WorkspaceRowWithMenu
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
import {
|
||||
useSessionStore,
|
||||
type Agent,
|
||||
type WorkspaceDescriptor,
|
||||
type SessionState,
|
||||
} from "@/stores/session-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
@@ -116,10 +117,32 @@ type AgentUpdatePayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "agent_update" }
|
||||
>["payload"];
|
||||
type WorkspaceUpdatePayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "workspace_update" }
|
||||
>["payload"];
|
||||
|
||||
const getAgentIdFromUpdate = (update: AgentUpdatePayload): string =>
|
||||
update.kind === "remove" ? update.agentId : update.agent.id;
|
||||
|
||||
function normalizeWorkspaceDescriptor(
|
||||
payload: Extract<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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -233,7 +256,13 @@ function SessionProviderInternal({
|
||||
const setHasHydratedAgents = useSessionStore(
|
||||
(state) => state.setHasHydratedAgents
|
||||
);
|
||||
const setHasHydratedWorkspaces = useSessionStore(
|
||||
(state) => state.setHasHydratedWorkspaces
|
||||
);
|
||||
const setAgents = useSessionStore((state) => state.setAgents);
|
||||
const setWorkspaces = useSessionStore((state) => state.setWorkspaces);
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const removeWorkspace = useSessionStore((state) => state.removeWorkspace);
|
||||
const setAgentLastActivity = useSessionStore(
|
||||
(state) => state.setAgentLastActivity
|
||||
);
|
||||
@@ -413,6 +442,38 @@ function SessionProviderInternal({
|
||||
}
|
||||
}, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void client
|
||||
.fetchWorkspaces({
|
||||
subscribe: {},
|
||||
page: { limit: 200 },
|
||||
})
|
||||
.then((payload) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const workspaces = new Map<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(
|
||||
(update: AgentUpdatePayload) => {
|
||||
if (update.kind === "remove") {
|
||||
@@ -891,6 +952,17 @@ function SessionProviderInternal({
|
||||
}
|
||||
);
|
||||
|
||||
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
|
||||
if (message.type !== "workspace_update") return;
|
||||
if (message.payload.kind === "remove") {
|
||||
removeWorkspace(serverId, message.payload.id);
|
||||
return;
|
||||
}
|
||||
mergeWorkspaces(serverId, [
|
||||
normalizeWorkspaceDescriptor(message.payload.workspace),
|
||||
]);
|
||||
});
|
||||
|
||||
const unsubStatus = client.on("status", (message) => {
|
||||
if (message.type !== "status") return;
|
||||
const serverInfo = parseServerInfoStatusPayload(message.payload);
|
||||
@@ -1287,6 +1359,7 @@ function SessionProviderInternal({
|
||||
unsubAgentUpdate();
|
||||
unsubAgentStream();
|
||||
unsubAgentTimeline();
|
||||
unsubWorkspaceUpdate();
|
||||
unsubStatus();
|
||||
unsubPermissionRequest();
|
||||
unsubPermissionResolved();
|
||||
@@ -1312,6 +1385,8 @@ function SessionProviderInternal({
|
||||
setAgentTimelineCursor,
|
||||
setInitializingAgents,
|
||||
setAgents,
|
||||
mergeWorkspaces,
|
||||
removeWorkspace,
|
||||
setAgentLastActivity,
|
||||
setPendingPermissions,
|
||||
setHasHydratedAgents,
|
||||
|
||||
@@ -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 { applyStoredOrdering } from './use-sidebar-agents-list'
|
||||
import {
|
||||
applyStoredOrdering,
|
||||
buildSidebarProjectsFromWorkspaces,
|
||||
} from './use-sidebar-workspaces-list'
|
||||
import type { WorkspaceDescriptor } from '@/stores/session-store'
|
||||
|
||||
interface OrderedItem {
|
||||
key: string
|
||||
@@ -41,3 +45,29 @@ describe('applyStoredOrdering', () => {
|
||||
expect(result).toBe(baseline)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSidebarProjectsFromWorkspaces', () => {
|
||||
it('uses workspace descriptor name and status directly', () => {
|
||||
const workspaces: WorkspaceDescriptor[] = [
|
||||
{
|
||||
id: '/repo/main',
|
||||
projectId: 'project-1',
|
||||
name: 'feat/hard-cut',
|
||||
status: 'failed',
|
||||
activityAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
},
|
||||
]
|
||||
|
||||
const projects = buildSidebarProjectsFromWorkspaces({
|
||||
serverId: 'srv',
|
||||
workspaces,
|
||||
projectOrder: [],
|
||||
workspaceOrderByScope: {},
|
||||
})
|
||||
|
||||
expect(projects).toHaveLength(1)
|
||||
expect(projects[0]?.statusBucket).toBe('failed')
|
||||
expect(projects[0]?.workspaces[0]?.name).toBe('feat/hard-cut')
|
||||
expect(projects[0]?.workspaces[0]?.statusBucket).toBe('failed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useCallback, useMemo, useSyncExternalStore } from 'react'
|
||||
import { useSessionStore } from '@/stores/session-store'
|
||||
import { getHostRuntimeStore } from '@/runtime/host-runtime'
|
||||
import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
|
||||
import type { WorkspaceDescriptor } from '@/stores/session-store'
|
||||
import { projectDisplayNameFromProjectId } from '@/utils/project-display-name'
|
||||
|
||||
const EMPTY_ORDER: string[] = []
|
||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = []
|
||||
|
||||
export type SidebarStateBucket = WorkspaceDescriptor['status']
|
||||
|
||||
export interface SidebarWorkspaceEntry {
|
||||
workspaceKey: string
|
||||
serverId: string
|
||||
workspaceId: string
|
||||
name: string
|
||||
activityAt: Date | null
|
||||
statusBucket: SidebarStateBucket
|
||||
}
|
||||
|
||||
export interface SidebarProjectEntry {
|
||||
projectKey: string
|
||||
projectName: string
|
||||
iconWorkingDir: string
|
||||
statusBucket: SidebarStateBucket
|
||||
activeCount: number
|
||||
totalWorkspaces: number
|
||||
latestActivityAt: Date | null
|
||||
workspaces: SidebarWorkspaceEntry[]
|
||||
}
|
||||
|
||||
export interface SidebarWorkspacesListResult {
|
||||
projects: SidebarProjectEntry[]
|
||||
isLoading: boolean
|
||||
isInitialLoad: boolean
|
||||
isRevalidating: boolean
|
||||
refreshAll: () => void
|
||||
}
|
||||
|
||||
const SIDEBAR_BUCKET_PRIORITY: Record<SidebarStateBucket, number> = {
|
||||
done: 0,
|
||||
attention: 1,
|
||||
running: 2,
|
||||
failed: 3,
|
||||
needs_input: 4,
|
||||
}
|
||||
|
||||
function aggregateBucket(
|
||||
current: SidebarStateBucket,
|
||||
candidate: SidebarStateBucket
|
||||
): SidebarStateBucket {
|
||||
return SIDEBAR_BUCKET_PRIORITY[candidate] > SIDEBAR_BUCKET_PRIORITY[current] ? candidate : current
|
||||
}
|
||||
|
||||
function compareWorkspaceBaseline(
|
||||
left: SidebarWorkspaceEntry,
|
||||
right: SidebarWorkspaceEntry
|
||||
): number {
|
||||
if (left.activityAt && right.activityAt) {
|
||||
const dateDelta = right.activityAt.getTime() - left.activityAt.getTime()
|
||||
if (dateDelta !== 0) {
|
||||
return dateDelta
|
||||
}
|
||||
} else if (left.activityAt || right.activityAt) {
|
||||
return left.activityAt ? -1 : 1
|
||||
}
|
||||
|
||||
const nameDelta = left.name.localeCompare(right.name, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
})
|
||||
if (nameDelta !== 0) {
|
||||
return nameDelta
|
||||
}
|
||||
|
||||
return left.workspaceId.localeCompare(right.workspaceId, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
})
|
||||
}
|
||||
|
||||
function compareProjectBaseline(left: SidebarProjectEntry, right: SidebarProjectEntry): number {
|
||||
if (left.latestActivityAt && right.latestActivityAt) {
|
||||
const dateDelta = right.latestActivityAt.getTime() - left.latestActivityAt.getTime()
|
||||
if (dateDelta !== 0) {
|
||||
return dateDelta
|
||||
}
|
||||
} else if (left.latestActivityAt || right.latestActivityAt) {
|
||||
return left.latestActivityAt ? -1 : 1
|
||||
}
|
||||
|
||||
return left.projectName.localeCompare(right.projectName, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
})
|
||||
}
|
||||
|
||||
export function buildSidebarProjectsFromWorkspaces(input: {
|
||||
serverId: string
|
||||
workspaces: Iterable<WorkspaceDescriptor>
|
||||
projectOrder: string[]
|
||||
workspaceOrderByScope: Record<string, string[]>
|
||||
}): SidebarProjectEntry[] {
|
||||
const byProject = new Map<string, SidebarProjectEntry>()
|
||||
|
||||
for (const workspace of input.workspaces) {
|
||||
const project =
|
||||
byProject.get(workspace.projectId) ??
|
||||
({
|
||||
projectKey: workspace.projectId,
|
||||
projectName: projectDisplayNameFromProjectId(workspace.projectId),
|
||||
iconWorkingDir: workspace.id,
|
||||
statusBucket: 'done',
|
||||
activeCount: 0,
|
||||
totalWorkspaces: 0,
|
||||
latestActivityAt: null,
|
||||
workspaces: [],
|
||||
} satisfies SidebarProjectEntry)
|
||||
|
||||
const row: SidebarWorkspaceEntry = {
|
||||
workspaceKey: `${input.serverId}:${workspace.id}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: workspace.id,
|
||||
name: workspace.name,
|
||||
activityAt: workspace.activityAt,
|
||||
statusBucket: workspace.status,
|
||||
}
|
||||
|
||||
project.workspaces.push(row)
|
||||
project.totalWorkspaces += 1
|
||||
if (workspace.status !== 'done') {
|
||||
project.activeCount += 1
|
||||
}
|
||||
project.statusBucket = aggregateBucket(project.statusBucket, workspace.status)
|
||||
if (
|
||||
!project.latestActivityAt ||
|
||||
(workspace.activityAt && workspace.activityAt.getTime() > project.latestActivityAt.getTime())
|
||||
) {
|
||||
project.latestActivityAt = workspace.activityAt
|
||||
}
|
||||
|
||||
byProject.set(workspace.projectId, project)
|
||||
}
|
||||
|
||||
const baselineProjects = Array.from(byProject.values()).map((project) => {
|
||||
const baselineWorkspaces = [...project.workspaces]
|
||||
baselineWorkspaces.sort(compareWorkspaceBaseline)
|
||||
|
||||
const workspaceOrderScopeKey = getWorkspaceOrderScopeKey(input.serverId, project.projectKey)
|
||||
const orderedWorkspaces = applyStoredOrdering({
|
||||
items: baselineWorkspaces,
|
||||
storedOrder: input.workspaceOrderByScope[workspaceOrderScopeKey] ?? EMPTY_ORDER,
|
||||
getKey: (workspace) => workspace.workspaceKey,
|
||||
})
|
||||
|
||||
return {
|
||||
...project,
|
||||
workspaces: orderedWorkspaces,
|
||||
}
|
||||
})
|
||||
|
||||
baselineProjects.sort(compareProjectBaseline)
|
||||
|
||||
return applyStoredOrdering({
|
||||
items: baselineProjects,
|
||||
storedOrder: input.projectOrder,
|
||||
getKey: (project) => project.projectKey,
|
||||
})
|
||||
}
|
||||
|
||||
export function applyStoredOrdering<T>(input: {
|
||||
items: T[]
|
||||
storedOrder: string[]
|
||||
getKey: (item: T) => string
|
||||
}): T[] {
|
||||
if (input.items.length <= 1 || input.storedOrder.length === 0) {
|
||||
return input.items
|
||||
}
|
||||
|
||||
const itemByKey = new Map<string, T>()
|
||||
for (const item of input.items) {
|
||||
itemByKey.set(input.getKey(item), item)
|
||||
}
|
||||
|
||||
const prunedOrder: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const key of input.storedOrder) {
|
||||
if (!itemByKey.has(key) || seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
prunedOrder.push(key)
|
||||
}
|
||||
|
||||
if (prunedOrder.length === 0) {
|
||||
return input.items
|
||||
}
|
||||
|
||||
const orderedSet = new Set(prunedOrder)
|
||||
const ordered: T[] = []
|
||||
let orderedIndex = 0
|
||||
|
||||
for (const item of input.items) {
|
||||
const key = input.getKey(item)
|
||||
if (!orderedSet.has(key)) {
|
||||
ordered.push(item)
|
||||
continue
|
||||
}
|
||||
|
||||
const targetKey = prunedOrder[orderedIndex] ?? key
|
||||
orderedIndex += 1
|
||||
ordered.push(itemByKey.get(targetKey) ?? item)
|
||||
}
|
||||
|
||||
return ordered
|
||||
}
|
||||
|
||||
function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string {
|
||||
return `${serverId.trim()}::${projectKey.trim()}`
|
||||
}
|
||||
|
||||
function toWorkspaceDescriptor(payload: {
|
||||
id: string
|
||||
projectId: string
|
||||
name: string
|
||||
status: WorkspaceDescriptor['status']
|
||||
activityAt: string | null
|
||||
}): WorkspaceDescriptor {
|
||||
const activityAt = payload.activityAt ? new Date(payload.activityAt) : null
|
||||
return {
|
||||
id: payload.id,
|
||||
projectId: payload.projectId,
|
||||
name: payload.name,
|
||||
status: payload.status,
|
||||
activityAt: activityAt && !Number.isNaN(activityAt.getTime()) ? activityAt : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function useSidebarWorkspacesList(options?: {
|
||||
serverId?: string | null
|
||||
enabled?: boolean
|
||||
}): SidebarWorkspacesListResult {
|
||||
const runtime = getHostRuntimeStore()
|
||||
|
||||
const serverId = useMemo(() => {
|
||||
const value = options?.serverId
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
|
||||
}, [options?.serverId])
|
||||
const enabled = options?.enabled ?? true
|
||||
|
||||
const persistedProjectOrder = useSidebarOrderStore((state) =>
|
||||
serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER
|
||||
)
|
||||
const persistedWorkspaceOrderByScope = useSidebarOrderStore((state) =>
|
||||
serverId ? state.workspaceOrderByServerAndProject : {}
|
||||
)
|
||||
|
||||
const isActive = Boolean(serverId)
|
||||
const sessionWorkspaces = useSessionStore((state) =>
|
||||
isActive && serverId ? (state.sessions[serverId]?.workspaces ?? null) : null
|
||||
)
|
||||
const hasHydratedWorkspaces = useSessionStore(
|
||||
(state) => (isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false)
|
||||
)
|
||||
|
||||
const connectionStatus = useSyncExternalStore(
|
||||
(onStoreChange) =>
|
||||
isActive && serverId ? runtime.subscribe(serverId, onStoreChange) : () => {},
|
||||
() => {
|
||||
if (!isActive || !serverId) {
|
||||
return 'idle'
|
||||
}
|
||||
const snapshot = runtime.getSnapshot(serverId)
|
||||
return snapshot?.connectionStatus ?? 'idle'
|
||||
},
|
||||
() => {
|
||||
if (!isActive || !serverId) {
|
||||
return 'idle'
|
||||
}
|
||||
const snapshot = runtime.getSnapshot(serverId)
|
||||
return snapshot?.connectionStatus ?? 'idle'
|
||||
}
|
||||
)
|
||||
|
||||
const projects = useMemo(() => {
|
||||
if (!sessionWorkspaces || sessionWorkspaces.size === 0 || !serverId) {
|
||||
return EMPTY_PROJECTS
|
||||
}
|
||||
return buildSidebarProjectsFromWorkspaces({
|
||||
serverId,
|
||||
workspaces: sessionWorkspaces.values(),
|
||||
projectOrder: persistedProjectOrder,
|
||||
workspaceOrderByScope: persistedWorkspaceOrderByScope,
|
||||
})
|
||||
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, serverId, sessionWorkspaces])
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
if (!isActive || !serverId || connectionStatus !== 'online' || !enabled) {
|
||||
return
|
||||
}
|
||||
const client = runtime.getClient(serverId)
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
void client
|
||||
.fetchWorkspaces({ page: { limit: 200 } })
|
||||
.then((payload) => {
|
||||
const next = new Map<string, WorkspaceDescriptor>()
|
||||
for (const entry of payload.entries) {
|
||||
const workspace = toWorkspaceDescriptor(entry)
|
||||
next.set(workspace.id, workspace)
|
||||
}
|
||||
const store = useSessionStore.getState()
|
||||
store.setWorkspaces(serverId, next)
|
||||
store.setHasHydratedWorkspaces(serverId, true)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}, [connectionStatus, enabled, isActive, runtime, serverId])
|
||||
|
||||
const isLoading = isActive && Boolean(serverId) && connectionStatus === 'online' && !hasHydratedWorkspaces
|
||||
const isInitialLoad = isLoading && projects.length === 0
|
||||
const isRevalidating = false
|
||||
|
||||
return {
|
||||
projects,
|
||||
isLoading,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
refreshAll,
|
||||
}
|
||||
}
|
||||
@@ -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 { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import {
|
||||
useSessionStore,
|
||||
type Agent,
|
||||
} from "@/stores/session-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
useWorkspaceTabsStore,
|
||||
@@ -80,11 +83,9 @@ import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agen
|
||||
import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
deriveProjectDisplayName,
|
||||
deriveProjectKey,
|
||||
deriveProjectName,
|
||||
deriveRemoteProjectKey,
|
||||
} from "@/utils/agent-grouping";
|
||||
resolveWorkspaceHeader,
|
||||
shouldRenderMissingWorkspaceDescriptor,
|
||||
} from "@/screens/workspace/workspace-header-source";
|
||||
|
||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
@@ -152,24 +153,6 @@ function decodeSegment(value: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function deriveWorkspaceName(workspaceId: string): string {
|
||||
const normalized = workspaceId.replace(/\\/g, "/");
|
||||
const segments = normalized.split("/").filter(Boolean);
|
||||
const last = segments[segments.length - 1];
|
||||
return last ?? workspaceId;
|
||||
}
|
||||
|
||||
function deriveWorkspaceProjectDisplayName(input: {
|
||||
workspaceId: string;
|
||||
checkout: CheckoutStatusPayload | null;
|
||||
}): string {
|
||||
const projectKey =
|
||||
deriveRemoteProjectKey(input.checkout?.remoteUrl ?? null) ??
|
||||
deriveProjectKey(input.workspaceId);
|
||||
const projectName = deriveProjectName(projectKey);
|
||||
return deriveProjectDisplayName({ projectKey, projectName });
|
||||
}
|
||||
|
||||
function formatProviderLabel(provider: Agent["provider"]): string {
|
||||
if (provider === "claude") {
|
||||
return "Claude";
|
||||
@@ -407,18 +390,17 @@ function WorkspaceScreenContent({
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
const workspaceName = useMemo(
|
||||
() => deriveWorkspaceName(normalizedWorkspaceId),
|
||||
[normalizedWorkspaceId]
|
||||
const workspaceDescriptor = useSessionStore(
|
||||
(state) =>
|
||||
state.sessions[normalizedServerId]?.workspaces.get(normalizedWorkspaceId) ??
|
||||
null
|
||||
);
|
||||
const headerProjectName = useMemo(
|
||||
() =>
|
||||
deriveWorkspaceProjectDisplayName({
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
checkout: checkoutQuery.data ?? null,
|
||||
}),
|
||||
[checkoutQuery.data, normalizedWorkspaceId]
|
||||
const hasHydratedWorkspaces = useSessionStore(
|
||||
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false
|
||||
);
|
||||
const workspaceHeader = workspaceDescriptor
|
||||
? resolveWorkspaceHeader({ workspace: workspaceDescriptor })
|
||||
: null;
|
||||
|
||||
const isGitCheckout = checkoutQuery.data?.isGit ?? false;
|
||||
const areWorkspaceAgentsHydrated = useSessionStore(
|
||||
@@ -1228,6 +1210,21 @@ function WorkspaceScreenContent({
|
||||
}, [activeAgent, normalizedServerId, router]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (
|
||||
shouldRenderMissingWorkspaceDescriptor({
|
||||
workspace: workspaceDescriptor,
|
||||
hasHydratedWorkspaces,
|
||||
})
|
||||
) {
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyStateText}>
|
||||
Workspace descriptor missing for this route.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const target = activeTab?.target ?? null;
|
||||
if (!target) {
|
||||
return (
|
||||
@@ -1324,10 +1321,10 @@ function WorkspaceScreenContent({
|
||||
<SidebarMenuToggle />
|
||||
<View style={styles.headerTitleContainer}>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||
{workspaceName}
|
||||
{workspaceHeader ? workspaceHeader.title : "Loading workspace…"}
|
||||
</Text>
|
||||
<Text style={styles.headerProjectTitle} numberOfLines={1}>
|
||||
{headerProjectName}
|
||||
{workspaceHeader ? workspaceHeader.subtitle : "Awaiting workspace descriptor"}
|
||||
</Text>
|
||||
</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,
|
||||
ProjectPlacementPayload,
|
||||
ServerCapabilities,
|
||||
WorkspaceDescriptorPayload,
|
||||
} from "@server/shared/messages";
|
||||
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
||||
import {
|
||||
@@ -108,6 +109,14 @@ export interface Agent {
|
||||
projectPlacement?: ProjectPlacementPayload | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceDescriptor {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
status: WorkspaceDescriptorPayload["status"];
|
||||
activityAt: Date | null;
|
||||
}
|
||||
|
||||
export type ExplorerEntryKind = "file" | "directory";
|
||||
export type ExplorerFileKind = "text" | "image" | "binary";
|
||||
export type ExplorerEncoding = "utf-8" | "base64" | "none";
|
||||
@@ -180,6 +189,7 @@ export interface SessionState {
|
||||
|
||||
// Hydration status
|
||||
hasHydratedAgents: boolean;
|
||||
hasHydratedWorkspaces: boolean;
|
||||
|
||||
// Audio state
|
||||
isPlayingAudio: boolean;
|
||||
@@ -203,6 +213,7 @@ export interface SessionState {
|
||||
|
||||
// Agents
|
||||
agents: Map<string, Agent>;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
|
||||
// Permissions
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
@@ -267,6 +278,17 @@ interface SessionStoreActions {
|
||||
|
||||
// Agents
|
||||
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
|
||||
setAgentLastActivity: (agentId: string, timestamp: Date) => void;
|
||||
@@ -296,6 +318,7 @@ interface SessionStoreActions {
|
||||
|
||||
// Hydration
|
||||
setHasHydratedAgents: (serverId: string, hydrated: boolean) => void;
|
||||
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
|
||||
|
||||
// Agent directory (derived from agents)
|
||||
getAgentDirectory: (serverId: string) => AgentDirectoryEntry[] | undefined;
|
||||
@@ -335,6 +358,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient, audio
|
||||
serverInfo: null,
|
||||
audioPlayer,
|
||||
hasHydratedAgents: false,
|
||||
hasHydratedWorkspaces: false,
|
||||
isPlayingAudio: false,
|
||||
focusedAgentId: null,
|
||||
messages: [],
|
||||
@@ -346,6 +370,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient, audio
|
||||
agentHistorySyncGeneration: new Map(),
|
||||
initializingAgents: new Map(),
|
||||
agents: new Map(),
|
||||
workspaces: new Map(),
|
||||
pendingPermissions: new Map(),
|
||||
fileExplorer: new Map(),
|
||||
queuedMessages: new Map(),
|
||||
@@ -851,6 +876,78 @@ export const useSessionStore = create<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)
|
||||
setAgentLastActivity: (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)
|
||||
getAgentDirectory: (serverId) => {
|
||||
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 type { SidebarProjectEntry, SidebarWorkspaceEntry } from "@/hooks/use-sidebar-agents-list";
|
||||
import type { SidebarProjectEntry, SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
|
||||
import { buildSidebarWorkspaceViewModel } from "./sidebar-shortcuts";
|
||||
|
||||
@@ -7,11 +7,9 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
|
||||
return {
|
||||
workspaceKey: `${serverId}:${cwd}`,
|
||||
serverId,
|
||||
cwd,
|
||||
branchName: null,
|
||||
createdAt: null,
|
||||
isMainCheckout: false,
|
||||
isPaseoOwnedWorktree: false,
|
||||
workspaceId: cwd,
|
||||
name: cwd,
|
||||
activityAt: null,
|
||||
statusBucket: "done",
|
||||
};
|
||||
}
|
||||
@@ -20,11 +18,11 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
|
||||
return {
|
||||
projectKey,
|
||||
projectName: projectKey,
|
||||
iconWorkingDir: workspaces[0]?.cwd ?? "",
|
||||
iconWorkingDir: workspaces[0]?.workspaceId ?? "",
|
||||
statusBucket: "done",
|
||||
activeCount: 0,
|
||||
totalCount: workspaces.length,
|
||||
latestCreatedAt: null,
|
||||
totalWorkspaces: workspaces.length,
|
||||
latestActivityAt: null,
|
||||
workspaces,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
SidebarProjectEntry,
|
||||
SidebarWorkspaceEntry,
|
||||
} from '@/hooks/use-sidebar-agents-list'
|
||||
} from '@/hooks/use-sidebar-workspaces-list'
|
||||
|
||||
export interface SidebarShortcutWorkspaceTarget {
|
||||
serverId: string
|
||||
@@ -58,7 +58,7 @@ export function buildSidebarWorkspaceViewModel(input: {
|
||||
if (shortcutNumber !== null) {
|
||||
shortcutTargets.push({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.cwd,
|
||||
workspaceId: workspace.workspaceId,
|
||||
})
|
||||
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user