feat: rename sidebar-agent-list to sidebar-workspace-list and emit workspace updates on agent removal

This commit is contained in:
Mohamed Boudra
2026-03-04 10:01:11 +07:00
parent e9b57a5ac6
commit bf1cded8a1
4 changed files with 232 additions and 25 deletions
+3 -3
View File
@@ -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 ? (
<SidebarAgentListSkeleton />
) : (
<SidebarAgentList
<SidebarWorkspaceList
isOpen={isOpen}
serverId={activeServerId}
projects={projects}
@@ -489,7 +489,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarAgentList
<SidebarWorkspaceList
isOpen={isOpen}
serverId={activeServerId}
projects={projects}
@@ -1,4 +1,4 @@
import { View, Text, Pressable, Image, Platform } from 'react-native'
import { View, Text, Pressable, Image, Platform, ActivityIndicator, Alert } from 'react-native'
import { useQueries } from '@tanstack/react-query'
import {
useCallback,
@@ -31,12 +31,18 @@ import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
import { formatTimeAgo } from '@/utils/time'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import { useToast } from '@/contexts/toast-context'
import {
buildSidebarWorkspaceViewModel,
type SidebarWorkspaceTreeRow,
} from '@/utils/sidebar-shortcuts'
import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store'
const PASEO_WORKTREE_PATH_MARKER = '/.paseo/worktrees'
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
@@ -45,7 +51,7 @@ function toProjectIconDataUri(icon: { mimeType: string; data: string } | null):
return `data:${icon.mimeType};base64,${icon.data}`
}
interface SidebarAgentListProps {
interface SidebarWorkspaceListProps {
isOpen?: boolean
projects: SidebarProjectEntry[]
serverId: string | null
@@ -73,6 +79,8 @@ interface WorkspaceRowProps {
showShortcutBadge: boolean
onPress: () => void
drag: () => void
isArchiving: boolean
useContextMenuTrigger?: boolean
}
function resolveWorkspaceCreatedAtLabel(workspace: SidebarWorkspaceEntry): string | null {
@@ -95,17 +103,33 @@ function resolveStatusDotColor(input: { theme: ReturnType<typeof useUnistyles>['
: 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 (
<View style={styles.workspaceStatusDot}>
<View style={[styles.workspaceStatusDotFill, { backgroundColor: color }]} />
{loading ? (
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
) : (
<View style={[styles.workspaceStatusDotFill, { backgroundColor: color }]} />
)}
</View>
)
}
@@ -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 = (
<ContextMenuTrigger
enabledOnMobile={false}
style={({ pressed, hovered = false }) => [
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 = (
<>
<View style={styles.workspaceRowLeft}>
<WorkspaceStatusIndicator bucket={workspace.statusBucket} />
<WorkspaceStatusIndicator bucket={workspace.statusBucket} loading={isArchiving} />
<Text style={styles.workspaceBranchText} numberOfLines={1}>
{workspace.name}
</Text>
@@ -229,14 +244,61 @@ function WorkspaceRow({
</View>
) : null}
</View>
</ContextMenuTrigger>
</>
)
return moveMonitorGesture ? (
const trigger = useContextMenuTrigger ? (
<ContextMenuTrigger
enabledOnMobile={false}
disabled={isArchiving}
style={({ pressed, hovered = false }) => [
styles.workspaceRow,
selected && styles.workspaceRowSelected,
hovered && styles.workspaceRowHovered,
pressed && styles.workspaceRowPressed,
]}
onPress={handlePress}
onLongPress={handleLongPress}
delayLongPress={200}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
{rowChildren}
</ContextMenuTrigger>
) : (
<Pressable
disabled={isArchiving}
style={({ pressed, hovered = false }) => [
styles.workspaceRow,
selected && styles.workspaceRowSelected,
hovered && styles.workspaceRowHovered,
pressed && styles.workspaceRowPressed,
]}
onPress={handlePress}
onLongPress={handleLongPress}
delayLongPress={200}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
{rowChildren}
</Pressable>
)
const content = moveMonitorGesture ? (
<GestureDetector gesture={moveMonitorGesture}>{trigger}</GestureDetector>
) : (
trigger
)
return (
<View style={styles.workspaceRowContainer}>
{content}
{isArchiving ? (
<View style={styles.workspaceArchivingOverlay} testID={`sidebar-workspace-archiving-${workspace.workspaceKey}`}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={styles.workspaceArchivingText}>Archiving</Text>
</View>
) : null}
</View>
)
}
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 (
<WorkspaceRow
workspace={workspace}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={onPress}
drag={drag}
isArchiving={false}
useContextMenuTrigger={false}
/>
)
}
return (
<ContextMenu>
<WorkspaceRow
@@ -263,7 +391,19 @@ function WorkspaceRowWithMenu({
showShortcutBadge={showShortcutBadge}
onPress={onPress}
drag={drag}
isArchiving={isArchiving}
/>
<ContextMenuContent align="start" width={220} testID={`sidebar-workspace-context-${workspace.workspaceKey}`}>
<ContextMenuItem
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
status={archiveStatus}
pendingLabel="Archiving..."
destructive
onSelect={handleArchiveWorktree}
>
Archive worktree
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
@@ -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,
+24
View File
@@ -4544,10 +4544,12 @@ export class Session {
}
const removedAgents = new Set<string>()
const affectedWorkspaceCwds = new Set<string>([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<string>): Promise<void> {
if (!this.workspaceUpdatesSubscription) {
return
}
const uniqueWorkspaceCwds = new Set<string>()
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<SessionInboundMessage, { type: 'fetch_agents_request' }>
): Promise<void> {
@@ -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')
})
})