From bf1cded8a1ac7b6839aabb9262cd549c10006471 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 4 Mar 2026 10:01:11 +0700 Subject: [PATCH] feat: rename sidebar-agent-list to sidebar-workspace-list and emit workspace updates on agent removal --- packages/app/src/components/left-sidebar.tsx | 6 +- ...nt-list.tsx => sidebar-workspace-list.tsx} | 202 ++++++++++++++++-- packages/server/src/server/session.ts | 24 +++ .../src/server/session.workspaces.test.ts | 25 +++ 4 files changed, 232 insertions(+), 25 deletions(-) rename packages/app/src/components/{sidebar-agent-list.tsx => sidebar-workspace-list.tsx} (80%) diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index ced54e761..cf4eb428c 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -13,7 +13,7 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyl import { Plus, Settings, Users } from 'lucide-react-native' import { router, usePathname } from 'expo-router' import { usePanelStore } from '@/stores/panel-store' -import { SidebarAgentList } from './sidebar-agent-list' +import { SidebarWorkspaceList } from './sidebar-workspace-list' import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton' import { useSidebarWorkspacesList } from '@/hooks/use-sidebar-workspaces-list' import { useSidebarAnimation } from '@/contexts/sidebar-animation-context' @@ -365,7 +365,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { {isInitialLoad ? ( ) : ( - ) : ( - void drag: () => void + isArchiving: boolean + useContextMenuTrigger?: boolean } function resolveWorkspaceCreatedAtLabel(workspace: SidebarWorkspaceEntry): string | null { @@ -95,17 +103,33 @@ function resolveStatusDotColor(input: { theme: ReturnType[' : theme.colors.border } +function isPaseoOwnedWorktreePath(path: string): boolean { + const normalizedPath = path.replace(/\\/g, '/') + const markerIndex = normalizedPath.indexOf(PASEO_WORKTREE_PATH_MARKER) + if (markerIndex <= 0) { + return false + } + const nextChar = normalizedPath[markerIndex + PASEO_WORKTREE_PATH_MARKER.length] + return !nextChar || nextChar === '/' +} + function WorkspaceStatusIndicator({ bucket, + loading = false, }: { bucket: SidebarWorkspaceEntry['statusBucket'] + loading?: boolean }) { const { theme } = useUnistyles() const color = resolveStatusDotColor({ theme, bucket }) return ( - + {loading ? ( + + ) : ( + + )} ) } @@ -177,7 +201,10 @@ function WorkspaceRow({ showShortcutBadge, onPress, drag, + isArchiving, + useContextMenuTrigger = true, }: WorkspaceRowProps) { + const { theme } = useUnistyles() const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace) const didLongPressRef = useRef(false) @@ -197,22 +224,10 @@ function WorkspaceRow({ return Platform.OS === 'web' ? null : Gesture.Pan().manualActivation(true).runOnJS(true) }, []) - const trigger = ( - [ - styles.workspaceRow, - selected && styles.workspaceRowSelected, - hovered && styles.workspaceRowHovered, - pressed && styles.workspaceRowPressed, - ]} - onPress={handlePress} - onLongPress={handleLongPress} - delayLongPress={200} - testID={`sidebar-workspace-row-${workspace.workspaceKey}`} - > + const rowChildren = ( + <> - + {workspace.name} @@ -229,14 +244,61 @@ function WorkspaceRow({ ) : null} - + ) - return moveMonitorGesture ? ( + const trigger = useContextMenuTrigger ? ( + [ + styles.workspaceRow, + selected && styles.workspaceRowSelected, + hovered && styles.workspaceRowHovered, + pressed && styles.workspaceRowPressed, + ]} + onPress={handlePress} + onLongPress={handleLongPress} + delayLongPress={200} + testID={`sidebar-workspace-row-${workspace.workspaceKey}`} + > + {rowChildren} + + ) : ( + [ + styles.workspaceRow, + selected && styles.workspaceRowSelected, + hovered && styles.workspaceRowHovered, + pressed && styles.workspaceRowPressed, + ]} + onPress={handlePress} + onLongPress={handleLongPress} + delayLongPress={200} + testID={`sidebar-workspace-row-${workspace.workspaceKey}`} + > + {rowChildren} + + ) + + const content = moveMonitorGesture ? ( {trigger} ) : ( trigger ) + + return ( + + {content} + {isArchiving ? ( + + + Archiving + + ) : null} + + ) } function WorkspaceRowWithMenu({ @@ -254,6 +316,72 @@ function WorkspaceRowWithMenu({ onPress: () => void drag: () => void }) { + const toast = useToast() + const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree) + const archiveStatus = useCheckoutGitActionsStore((state) => + state.getStatus({ + serverId: workspace.serverId, + cwd: workspace.workspaceId, + actionId: 'archive-worktree', + }) + ) + const isArchiving = archiveStatus === 'pending' + const isPaseoOwnedWorktree = isPaseoOwnedWorktreePath(workspace.workspaceId) + + const handleArchiveWorktree = useCallback(() => { + if (isArchiving) { + return + } + + Alert.alert( + 'Archive worktree?', + `Archive "${workspace.name}"?\n\nThis removes the worktree from the sidebar.`, + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Archive', + style: 'destructive', + onPress: () => { + void archiveWorktree({ + serverId: workspace.serverId, + cwd: workspace.workspaceId, + worktreePath: workspace.workspaceId, + }).catch((error) => { + const message = error instanceof Error ? error.message : 'Failed to archive worktree' + toast.error(message) + }) + }, + }, + ], + { cancelable: true } + ) + }, [ + archiveWorktree, + isArchiving, + toast, + workspace.name, + workspace.serverId, + workspace.workspaceId, + ]) + + if (!isPaseoOwnedWorktree) { + return ( + + ) + } + return ( + + + Archive worktree + + ) } @@ -290,7 +430,7 @@ function hasVisibleOrderChanged(input: { return input.reorderedVisibleKeys.some((key, index) => currentVisible[index] !== key) } -export function SidebarAgentList({ +export function SidebarWorkspaceList({ isOpen = true, projects, serverId, @@ -299,7 +439,7 @@ export function SidebarAgentList({ onWorkspacePress, listFooterComponent, parentGestureRef, -}: SidebarAgentListProps) { +}: SidebarWorkspaceListProps) { const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' const showDesktopWebScrollbar = Platform.OS === 'web' && !isMobile const segments = useSegments() @@ -692,6 +832,9 @@ const styles = StyleSheet.create((theme) => ({ workspaceRowSelected: { backgroundColor: theme.colors.surface2, }, + workspaceRowContainer: { + position: 'relative', + }, workspaceStatusDot: { width: 8, height: 8, @@ -706,6 +849,21 @@ const styles = StyleSheet.create((theme) => ({ height: 8, borderRadius: theme.borderRadius.full, }, + workspaceArchivingOverlay: { + ...StyleSheet.absoluteFillObject, + borderRadius: theme.borderRadius.lg, + backgroundColor: `${theme.colors.surface0}cc`, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + gap: theme.spacing[2], + zIndex: 1, + }, + workspaceArchivingText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + fontWeight: '600', + }, workspaceBranchText: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index f336801d5..4f94730dc 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -4544,10 +4544,12 @@ export class Session { } const removedAgents = new Set() + const affectedWorkspaceCwds = new Set([targetPath]) const agents = this.agentManager.listAgents() for (const agent of agents) { if (this.isPathWithinRoot(targetPath, agent.cwd)) { removedAgents.add(agent.id) + affectedWorkspaceCwds.add(agent.cwd) try { await this.agentManager.closeAgent(agent.id) } catch { @@ -4565,6 +4567,7 @@ export class Session { for (const record of registryRecords) { if (this.isPathWithinRoot(targetPath, record.cwd)) { removedAgents.add(record.id) + affectedWorkspaceCwds.add(record.cwd) try { await this.agentStorage.remove(record.id) } catch { @@ -4591,6 +4594,8 @@ export class Session { }) } + await this.emitWorkspaceUpdatesForCwds(affectedWorkspaceCwds) + return Array.from(removedAgents) } @@ -5671,6 +5676,25 @@ export class Session { }) } + private async emitWorkspaceUpdatesForCwds(cwds: Iterable): Promise { + if (!this.workspaceUpdatesSubscription) { + return + } + + const uniqueWorkspaceCwds = new Set() + for (const cwd of cwds) { + const normalized = this.normalizeWorkspaceId(cwd) + if (!normalized) { + continue + } + uniqueWorkspaceCwds.add(normalized) + } + + for (const workspaceCwd of uniqueWorkspaceCwds) { + await this.emitWorkspaceUpdateForCwd(workspaceCwd) + } + } + private async handleFetchAgents( request: Extract ): Promise { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index e8b23439c..22e657e25 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -264,4 +264,29 @@ describe('workspace aggregation', () => { id: '/tmp/repo', }) }) + + test('workspace update fanout for multiple cwd values is deduplicated', async () => { + const session = createSessionForWorkspaceTests() as any + session.workspaceUpdatesSubscription = { + subscriptionId: 'sub-dedupe', + filter: undefined, + isBootstrapping: false, + pendingUpdatesByWorkspaceId: new Map(), + } + + const emitWorkspaceUpdateForCwd = vi.fn(async () => {}) + session.emitWorkspaceUpdateForCwd = emitWorkspaceUpdateForCwd + + await session.emitWorkspaceUpdatesForCwds([ + '/tmp/repo', + '/tmp/repo/', + ' /tmp/repo ', + '/tmp/repo/sub', + '/tmp/repo/sub/', + ]) + + expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledTimes(2) + expect(emitWorkspaceUpdateForCwd).toHaveBeenNthCalledWith(1, '/tmp/repo') + expect(emitWorkspaceUpdateForCwd).toHaveBeenNthCalledWith(2, '/tmp/repo/sub') + }) })