mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Update workspace and app changes
This commit is contained in:
@@ -484,6 +484,7 @@ export default function RootLayout() {
|
||||
<Stack.Screen name="h/[serverId]/index" />
|
||||
<Stack.Screen name="h/[serverId]/agents" />
|
||||
<Stack.Screen name="h/[serverId]/new-agent" />
|
||||
<Stack.Screen name="h/[serverId]/open-project" />
|
||||
<Stack.Screen name="h/[serverId]/settings" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import {
|
||||
buildHostOpenProjectRoute,
|
||||
buildHostRootRoute,
|
||||
buildHostWorkspaceAgentRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
@@ -15,10 +16,13 @@ export default function HostIndexRoute() {
|
||||
const pathname = usePathname();
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
||||
const { isLoading: preferencesLoading } = useFormPreferences();
|
||||
const sessionAgents = useSessionStore(
|
||||
(state) => (serverId ? state.sessions[serverId]?.agents : undefined)
|
||||
);
|
||||
const sessionWorkspaces = useSessionStore(
|
||||
(state) => (serverId ? state.sessions[serverId]?.workspaces : undefined)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (preferencesLoading) {
|
||||
@@ -37,14 +41,21 @@ export default function HostIndexRoute() {
|
||||
}
|
||||
|
||||
const visibleAgents = sessionAgents
|
||||
? Array.from(sessionAgents.values()).filter(
|
||||
(agent) => !agent.archivedAt
|
||||
)
|
||||
? Array.from(sessionAgents.values()).filter((agent) => !agent.archivedAt)
|
||||
: [];
|
||||
visibleAgents.sort(
|
||||
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime()
|
||||
);
|
||||
|
||||
const visibleWorkspaces = sessionWorkspaces
|
||||
? Array.from(sessionWorkspaces.values())
|
||||
: [];
|
||||
visibleWorkspaces.sort((left, right) => {
|
||||
const leftTime = left.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
|
||||
const rightTime = right.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
|
||||
return rightTime - leftTime;
|
||||
});
|
||||
|
||||
const primaryAgent = visibleAgents[0];
|
||||
if (primaryAgent?.cwd?.trim()) {
|
||||
router.replace(
|
||||
@@ -57,21 +68,23 @@ export default function HostIndexRoute() {
|
||||
return;
|
||||
}
|
||||
|
||||
const preferredWorkingDir =
|
||||
preferences.serverId === serverId ? preferences.workingDir?.trim() : "";
|
||||
const workspaceId = preferredWorkingDir || ".";
|
||||
router.replace(buildHostWorkspaceRoute(serverId, workspaceId) as any);
|
||||
const primaryWorkspace = visibleWorkspaces[0];
|
||||
if (primaryWorkspace?.id?.trim()) {
|
||||
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()) as any);
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace(buildHostOpenProjectRoute(serverId) as any);
|
||||
}, HOST_ROOT_REDIRECT_DELAY_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [
|
||||
pathname,
|
||||
preferences.serverId,
|
||||
preferences.workingDir,
|
||||
preferencesLoading,
|
||||
router,
|
||||
serverId,
|
||||
sessionAgents,
|
||||
sessionWorkspaces,
|
||||
]);
|
||||
|
||||
return null;
|
||||
|
||||
9
packages/app/src/app/h/[serverId]/open-project.tsx
Normal file
9
packages/app/src/app/h/[serverId]/open-project.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { OpenProjectScreen } from "@/screens/open-project-screen";
|
||||
|
||||
export default function HostOpenProjectRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
|
||||
return <OpenProjectScreen serverId={serverId} />;
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import { formatConnectionStatus } from '@/utils/daemons'
|
||||
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from '@/constants/layout'
|
||||
import {
|
||||
buildHostAgentsRoute,
|
||||
buildHostNewAgentRoute,
|
||||
buildHostOpenProjectRoute,
|
||||
buildHostSettingsRoute,
|
||||
mapPathnameToServer,
|
||||
parseServerIdFromPathname,
|
||||
@@ -158,23 +158,21 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
closeToAgent()
|
||||
}, [closeToAgent])
|
||||
|
||||
const handleCreateAgentClean = useCallback(() => {
|
||||
const handleOpenProject = useCallback(() => {
|
||||
if (!activeServerId) {
|
||||
return
|
||||
}
|
||||
router.push(buildHostNewAgentRoute(activeServerId) as any)
|
||||
router.push(buildHostOpenProjectRoute(activeServerId) as any)
|
||||
}, [activeServerId])
|
||||
|
||||
// Mobile: close sidebar and navigate
|
||||
const handleCreateAgentCleanMobile = useCallback(() => {
|
||||
const handleOpenProjectMobile = useCallback(() => {
|
||||
closeToAgent()
|
||||
handleCreateAgentClean()
|
||||
}, [closeToAgent, handleCreateAgentClean])
|
||||
handleOpenProject()
|
||||
}, [closeToAgent, handleOpenProject])
|
||||
|
||||
// Desktop: just navigate, don't close
|
||||
const handleCreateAgentCleanDesktop = useCallback(() => {
|
||||
handleCreateAgentClean()
|
||||
}, [handleCreateAgentClean])
|
||||
const handleOpenProjectDesktop = useCallback(() => {
|
||||
handleOpenProject()
|
||||
}, [handleOpenProject])
|
||||
|
||||
// Mobile: close sidebar and navigate
|
||||
const handleSettingsMobile = useCallback(() => {
|
||||
@@ -332,7 +330,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
<Pressable
|
||||
style={styles.newAgentButton}
|
||||
testID="sidebar-new-agent"
|
||||
onPress={handleCreateAgentCleanMobile}
|
||||
onPress={handleOpenProjectMobile}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
@@ -346,7 +344,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
hovered && styles.newAgentButtonTextHovered,
|
||||
]}
|
||||
>
|
||||
New agent
|
||||
Open project
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
@@ -459,7 +457,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
<Pressable
|
||||
style={styles.newAgentButton}
|
||||
testID="sidebar-new-agent"
|
||||
onPress={handleCreateAgentCleanDesktop}
|
||||
onPress={handleOpenProjectDesktop}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
@@ -470,7 +468,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
<Text
|
||||
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
|
||||
>
|
||||
New agent
|
||||
Open project
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { router, usePathname } from 'expo-router'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import { type GestureType } from 'react-native-gesture-handler'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react-native'
|
||||
import { ChevronDown, ChevronRight, Plus } from 'lucide-react-native'
|
||||
import { NestableScrollContainer } from 'react-native-draggable-flatlist'
|
||||
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
||||
import type { DraggableListDragHandleProps } from './draggable-list.types'
|
||||
@@ -31,6 +31,7 @@ import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runt
|
||||
import { getIsTauri } from '@/constants/layout'
|
||||
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
|
||||
import {
|
||||
buildHostNewAgentRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
} from '@/utils/host-routes'
|
||||
@@ -58,8 +59,12 @@ import { decideLongPressMove } from '@/utils/sidebar-gesture-arbitration'
|
||||
import { confirmDialog } from '@/utils/confirm-dialog'
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from '@/utils/project-display-name'
|
||||
import { shouldRenderSyncedStatusLoader } from '@/utils/status-loader'
|
||||
|
||||
const PASEO_WORKTREE_PATH_MARKER = '/.paseo/worktrees'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { buildSidebarProjectRowModel } from '@/utils/sidebar-project-row-model'
|
||||
|
||||
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
||||
if (!icon) {
|
||||
@@ -84,10 +89,15 @@ interface ProjectHeaderRowProps {
|
||||
project: SidebarProjectEntry
|
||||
displayName: string
|
||||
iconDataUri: string | null
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
workspace: SidebarWorkspaceEntry | null
|
||||
selected?: boolean
|
||||
chevron: 'expand' | 'collapse' | 'disclosure'
|
||||
onPress: () => void
|
||||
onCreateWorktree?: () => void
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
isArchiving?: boolean
|
||||
menuController: ReturnType<typeof useContextMenu> | null
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
}
|
||||
|
||||
@@ -127,16 +137,6 @@ function resolveStatusDotColor(input: {
|
||||
: 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,
|
||||
@@ -161,6 +161,82 @@ function WorkspaceStatusIndicator({
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectLeadingVisual({
|
||||
displayName,
|
||||
iconDataUri,
|
||||
workspace,
|
||||
isArchiving = false,
|
||||
}: {
|
||||
displayName: string
|
||||
iconDataUri: string | null
|
||||
workspace: SidebarWorkspaceEntry | null
|
||||
isArchiving?: boolean
|
||||
}) {
|
||||
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName)
|
||||
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase()
|
||||
const activeWorkspace = workspace
|
||||
const shouldShowWorkspaceStatus =
|
||||
activeWorkspace !== null && (isArchiving || activeWorkspace.statusBucket !== 'done')
|
||||
|
||||
return (
|
||||
<View style={styles.projectLeadingVisualSlot}>
|
||||
{shouldShowWorkspaceStatus && activeWorkspace ? (
|
||||
<WorkspaceStatusIndicator bucket={activeWorkspace.statusBucket} loading={isArchiving} />
|
||||
) : iconDataUri ? (
|
||||
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
|
||||
) : (
|
||||
<View style={styles.projectIconFallback}>
|
||||
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectInlineChevron({
|
||||
chevron,
|
||||
}: {
|
||||
chevron: 'expand' | 'collapse' | 'disclosure'
|
||||
}) {
|
||||
if (chevron === 'collapse') {
|
||||
return <ChevronDown size={14} color="#9ca3af" />
|
||||
}
|
||||
return <ChevronRight size={14} color="#9ca3af" />
|
||||
}
|
||||
|
||||
function NewWorktreeButton({
|
||||
displayName,
|
||||
onPress,
|
||||
testID,
|
||||
}: {
|
||||
displayName: string
|
||||
onPress: () => void
|
||||
testID: string
|
||||
}) {
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.projectIconActionButton,
|
||||
(hovered || pressed) && styles.projectIconActionButtonHovered,
|
||||
]}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation()
|
||||
onPress()
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Create a new worktree for ${displayName}`}
|
||||
testID={testID}
|
||||
>
|
||||
<Plus size={14} color="#9ca3af" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<Text style={styles.projectActionTooltipText}>New worktree</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function useLongPressDragInteraction(input: {
|
||||
drag: () => void
|
||||
menuController: ReturnType<typeof useContextMenu> | null
|
||||
@@ -411,33 +487,90 @@ function ProjectHeaderRow({
|
||||
project,
|
||||
displayName,
|
||||
iconDataUri,
|
||||
collapsed,
|
||||
onToggle,
|
||||
workspace,
|
||||
selected = false,
|
||||
chevron,
|
||||
onPress,
|
||||
onCreateWorktree,
|
||||
drag,
|
||||
isDragging,
|
||||
isArchiving = false,
|
||||
menuController,
|
||||
dragHandleProps,
|
||||
}: ProjectHeaderRowProps) {
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
menuController: null,
|
||||
menuController,
|
||||
debugId: `project:${project.projectKey}`,
|
||||
})
|
||||
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName)
|
||||
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase()
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (interaction.didLongPressRef.current) {
|
||||
interaction.didLongPressRef.current = false
|
||||
return
|
||||
}
|
||||
onToggle()
|
||||
}, [interaction.didLongPressRef, onToggle])
|
||||
onPress()
|
||||
}, [interaction.didLongPressRef, onPress])
|
||||
|
||||
const trigger = (
|
||||
const rowChildren = (
|
||||
<>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||
style={styles.projectRowLeft}
|
||||
>
|
||||
<ProjectLeadingVisual
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={workspace}
|
||||
isArchiving={isArchiving}
|
||||
/>
|
||||
|
||||
<Text style={styles.projectTitle} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
|
||||
<ProjectInlineChevron chevron={chevron} />
|
||||
</View>
|
||||
{onCreateWorktree ? (
|
||||
<NewWorktreeButton
|
||||
displayName={displayName}
|
||||
onPress={onCreateWorktree}
|
||||
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
|
||||
if (menuController) {
|
||||
return (
|
||||
<ContextMenuTrigger
|
||||
enabledOnMobile={false}
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.projectRow,
|
||||
isDragging && styles.projectRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
hovered && styles.projectRowHovered,
|
||||
pressed && styles.projectRowPressed,
|
||||
]}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-project-row-${project.projectKey}`}
|
||||
>
|
||||
{rowChildren}
|
||||
</ContextMenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.projectRow,
|
||||
isDragging && styles.projectRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
hovered && styles.projectRowHovered,
|
||||
pressed && styles.projectRowPressed,
|
||||
]}
|
||||
@@ -447,34 +580,9 @@ function ProjectHeaderRow({
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-project-row-${project.projectKey}`}
|
||||
>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||
style={styles.projectRowLeft}
|
||||
>
|
||||
{iconDataUri ? (
|
||||
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
|
||||
) : (
|
||||
<View style={styles.projectIconFallback}>
|
||||
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={styles.projectTitle} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
|
||||
{collapsed ? (
|
||||
<ChevronRight size={14} color="#9ca3af" />
|
||||
) : (
|
||||
<ChevronDown size={14} color="#9ca3af" />
|
||||
)}
|
||||
</View>
|
||||
{rowChildren}
|
||||
</Pressable>
|
||||
)
|
||||
|
||||
return trigger
|
||||
}
|
||||
|
||||
function WorkspaceRowInner({
|
||||
@@ -539,7 +647,7 @@ function WorkspaceRowInner({
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.workspaceRow,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
selected && styles.workspaceRowSelected,
|
||||
selected && styles.sidebarRowSelected,
|
||||
hovered && styles.workspaceRowHovered,
|
||||
pressed && styles.workspaceRowPressed,
|
||||
]}
|
||||
@@ -557,7 +665,7 @@ function WorkspaceRowInner({
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.workspaceRow,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
selected && styles.workspaceRowSelected,
|
||||
selected && styles.sidebarRowSelected,
|
||||
hovered && styles.workspaceRowHovered,
|
||||
pressed && styles.workspaceRowPressed,
|
||||
]}
|
||||
@@ -602,6 +710,7 @@ function WorkspaceRowWithMenuContent({
|
||||
const toast = useToast()
|
||||
const contextMenu = useContextMenu()
|
||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree)
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false)
|
||||
const archiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({
|
||||
serverId: workspace.serverId,
|
||||
@@ -609,7 +718,8 @@ function WorkspaceRowWithMenuContent({
|
||||
actionId: 'archive-worktree',
|
||||
})
|
||||
)
|
||||
const isArchiving = archiveStatus === 'pending'
|
||||
const isWorktree = workspace.workspaceKind === 'worktree'
|
||||
const isArchiving = isWorktree ? archiveStatus === 'pending' : isArchivingWorkspace
|
||||
|
||||
const handleArchiveWorktree = useCallback(() => {
|
||||
if (isArchiving) {
|
||||
@@ -640,6 +750,43 @@ function WorkspaceRowWithMenuContent({
|
||||
})()
|
||||
}, [archiveWorktree, isArchiving, toast, workspace.name, workspace.serverId, workspace.workspaceId])
|
||||
|
||||
const handleArchiveWorkspace = useCallback(() => {
|
||||
if (isArchivingWorkspace) {
|
||||
return
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: 'Hide workspace?',
|
||||
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`,
|
||||
confirmLabel: 'Hide',
|
||||
cancelLabel: 'Cancel',
|
||||
destructive: true,
|
||||
})
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId)
|
||||
if (!client) {
|
||||
toast.error('Host is not connected')
|
||||
return
|
||||
}
|
||||
|
||||
setIsArchivingWorkspace(true)
|
||||
try {
|
||||
const payload = await client.archiveWorkspace(workspace.workspaceId)
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to hide workspace')
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false)
|
||||
}
|
||||
})()
|
||||
}, [isArchivingWorkspace, toast, workspace.name, workspace.serverId, workspace.workspaceId])
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkspaceRowInner
|
||||
@@ -662,12 +809,12 @@ function WorkspaceRowWithMenuContent({
|
||||
>
|
||||
<ContextMenuItem
|
||||
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
|
||||
status={archiveStatus}
|
||||
pendingLabel="Archiving..."
|
||||
status={isWorktree ? archiveStatus : isArchivingWorkspace ? 'pending' : 'idle'}
|
||||
pendingLabel={isWorktree ? 'Archiving...' : 'Hiding...'}
|
||||
destructive
|
||||
onSelect={handleArchiveWorktree}
|
||||
onSelect={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
|
||||
>
|
||||
Archive worktree
|
||||
{isWorktree ? 'Archive worktree' : 'Hide from sidebar'}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</>
|
||||
@@ -709,6 +856,122 @@ function WorkspaceRowWithMenu({
|
||||
)
|
||||
}
|
||||
|
||||
function NonGitProjectRowWithMenuContent({
|
||||
project,
|
||||
displayName,
|
||||
iconDataUri,
|
||||
workspace,
|
||||
selected,
|
||||
onPress,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
project: SidebarProjectEntry
|
||||
displayName: string
|
||||
iconDataUri: string | null
|
||||
workspace: SidebarWorkspaceEntry
|
||||
selected: boolean
|
||||
onPress: () => void
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
}) {
|
||||
const toast = useToast()
|
||||
const contextMenu = useContextMenu()
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false)
|
||||
|
||||
const handleArchiveWorkspace = useCallback(() => {
|
||||
if (isArchivingWorkspace) {
|
||||
return
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: 'Hide workspace?',
|
||||
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`,
|
||||
confirmLabel: 'Hide',
|
||||
cancelLabel: 'Cancel',
|
||||
destructive: true,
|
||||
})
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId)
|
||||
if (!client) {
|
||||
toast.error('Host is not connected')
|
||||
return
|
||||
}
|
||||
|
||||
setIsArchivingWorkspace(true)
|
||||
try {
|
||||
const payload = await client.archiveWorkspace(workspace.workspaceId)
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to hide workspace')
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false)
|
||||
}
|
||||
})()
|
||||
}, [isArchivingWorkspace, toast, workspace.name, workspace.serverId, workspace.workspaceId])
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectHeaderRow
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
chevron="disclosure"
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isArchivingWorkspace}
|
||||
menuController={contextMenu}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
<ContextMenuContent
|
||||
align="start"
|
||||
width={220}
|
||||
mobileMode="sheet"
|
||||
testID={`sidebar-workspace-context-${workspace.workspaceKey}`}
|
||||
>
|
||||
<ContextMenuItem
|
||||
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
|
||||
status={isArchivingWorkspace ? 'pending' : 'idle'}
|
||||
pendingLabel="Hiding..."
|
||||
destructive
|
||||
onSelect={handleArchiveWorkspace}
|
||||
>
|
||||
Hide from sidebar
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NonGitProjectRowWithMenu(props: {
|
||||
project: SidebarProjectEntry
|
||||
displayName: string
|
||||
iconDataUri: string | null
|
||||
workspace: SidebarWorkspaceEntry
|
||||
selected: boolean
|
||||
onPress: () => void
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
}) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<NonGitProjectRowWithMenuContent {...props} />
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceRowPlain({
|
||||
workspace,
|
||||
selected,
|
||||
@@ -763,21 +1026,6 @@ function WorkspaceRow({
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
}) {
|
||||
if (!isPaseoOwnedWorktreePath(workspace.workspaceId)) {
|
||||
return (
|
||||
<WorkspaceRowPlain
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceRowWithMenu
|
||||
workspace={workspace}
|
||||
@@ -805,6 +1053,7 @@ function ProjectBlock({
|
||||
onToggleCollapsed,
|
||||
onWorkspacePress,
|
||||
onWorkspaceReorder,
|
||||
onCreateWorktree,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
@@ -822,18 +1071,26 @@ function ProjectBlock({
|
||||
onToggleCollapsed: () => void
|
||||
onWorkspacePress?: () => void
|
||||
onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void
|
||||
onCreateWorktree?: (project: SidebarProjectEntry) => void
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
useNestable: boolean
|
||||
}) {
|
||||
const renderWorkspace = useCallback(
|
||||
({
|
||||
item,
|
||||
drag: workspaceDrag,
|
||||
isActive,
|
||||
dragHandleProps: workspaceDragHandleProps,
|
||||
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) => {
|
||||
const rowModel = useMemo(
|
||||
() =>
|
||||
buildSidebarProjectRowModel({
|
||||
project,
|
||||
collapsed,
|
||||
serverId,
|
||||
activeWorkspaceSelection,
|
||||
}),
|
||||
[activeWorkspaceSelection, collapsed, project, serverId]
|
||||
)
|
||||
const flattenedWorkspace = rowModel.flattenedWorkspace
|
||||
|
||||
const renderWorkspaceRow = useCallback(
|
||||
(item: SidebarWorkspaceEntry, input?: { drag?: () => void; isDragging?: boolean; dragHandleProps?: DraggableListDragHandleProps }) => {
|
||||
const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspaceId)
|
||||
const isSelected =
|
||||
Boolean(serverId) &&
|
||||
@@ -853,9 +1110,9 @@ function ProjectBlock({
|
||||
onWorkspacePress?.()
|
||||
router.replace(workspaceRoute as any)
|
||||
}}
|
||||
drag={workspaceDrag}
|
||||
isDragging={isActive}
|
||||
dragHandleProps={workspaceDragHandleProps}
|
||||
drag={input?.drag ?? (() => {})}
|
||||
isDragging={input?.isDragging ?? false}
|
||||
dragHandleProps={input?.dragHandleProps}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -868,33 +1125,79 @@ function ProjectBlock({
|
||||
]
|
||||
)
|
||||
|
||||
const renderWorkspace = useCallback(
|
||||
({
|
||||
item,
|
||||
drag: workspaceDrag,
|
||||
isActive,
|
||||
dragHandleProps: workspaceDragHandleProps,
|
||||
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) => {
|
||||
return renderWorkspaceRow(item, {
|
||||
drag: workspaceDrag,
|
||||
isDragging: isActive,
|
||||
dragHandleProps: workspaceDragHandleProps,
|
||||
})
|
||||
},
|
||||
[renderWorkspaceRow]
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={styles.projectBlock}>
|
||||
<ProjectHeaderRow
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
collapsed={collapsed}
|
||||
onToggle={onToggleCollapsed}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
|
||||
{!collapsed ? (
|
||||
<DraggableList
|
||||
testID={`sidebar-workspace-list-${project.projectKey}`}
|
||||
data={project.workspaces}
|
||||
keyExtractor={(workspace) => workspace.workspaceKey}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={(workspaces) => onWorkspaceReorder(project.projectKey, workspaces)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
{flattenedWorkspace ? (
|
||||
<NonGitProjectRowWithMenu
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={flattenedWorkspace}
|
||||
selected={rowModel.selected}
|
||||
onPress={() => {
|
||||
if (!serverId) {
|
||||
return
|
||||
}
|
||||
onWorkspacePress?.()
|
||||
router.replace(buildHostWorkspaceRoute(serverId, flattenedWorkspace.workspaceId) as any)
|
||||
}}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
) : null}
|
||||
) : (
|
||||
<>
|
||||
<ProjectHeaderRow
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={null}
|
||||
selected={false}
|
||||
chevron={rowModel.chevron}
|
||||
onPress={onToggleCollapsed}
|
||||
onCreateWorktree={
|
||||
rowModel.trailingAction === 'new_worktree' && onCreateWorktree
|
||||
? () => onCreateWorktree(project)
|
||||
: undefined
|
||||
}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
menuController={null}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
|
||||
{!collapsed ? (
|
||||
<DraggableList
|
||||
testID={`sidebar-workspace-list-${project.projectKey}`}
|
||||
data={project.workspaces}
|
||||
keyExtractor={(workspace) => workspace.workspaceKey}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={(workspaces) => onWorkspaceReorder(project.projectKey, workspaces)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1118,6 +1421,21 @@ export function SidebarWorkspaceList({
|
||||
[getWorkspaceOrder, serverId, setWorkspaceOrder]
|
||||
)
|
||||
|
||||
const handleCreateWorktree = useCallback(
|
||||
(project: SidebarProjectEntry) => {
|
||||
if (!serverId || project.projectKind !== 'git') {
|
||||
return
|
||||
}
|
||||
router.push(
|
||||
buildHostNewAgentRoute(serverId, {
|
||||
workingDir: project.iconWorkingDir,
|
||||
worktreeMode: 'create',
|
||||
}) as any
|
||||
)
|
||||
},
|
||||
[serverId]
|
||||
)
|
||||
|
||||
const renderProject = useCallback(
|
||||
({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) => {
|
||||
return (
|
||||
@@ -1134,6 +1452,7 @@ export function SidebarWorkspaceList({
|
||||
onToggleCollapsed={() => toggleProjectCollapsed(item.projectKey)}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onWorkspaceReorder={handleWorkspaceReorder}
|
||||
onCreateWorktree={handleCreateWorktree}
|
||||
drag={drag}
|
||||
isDragging={isActive}
|
||||
dragHandleProps={dragHandleProps}
|
||||
@@ -1144,6 +1463,7 @@ export function SidebarWorkspaceList({
|
||||
[
|
||||
activeWorkspaceSelection,
|
||||
collapsedProjectKeys,
|
||||
handleCreateWorktree,
|
||||
handleWorkspaceReorder,
|
||||
onWorkspacePress,
|
||||
parentGestureRef,
|
||||
@@ -1273,13 +1593,20 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minWidth: 0,
|
||||
},
|
||||
projectIcon: {
|
||||
width: theme.iconSize.sm,
|
||||
height: theme.iconSize.sm,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
projectLeadingVisualSlot: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
flexShrink: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
projectIconFallback: {
|
||||
width: theme.iconSize.sm,
|
||||
height: theme.iconSize.sm,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
@@ -1296,6 +1623,37 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
projectActionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
flexShrink: 0,
|
||||
},
|
||||
projectActionButtonHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
projectActionButtonText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
projectIconActionButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
},
|
||||
projectIconActionButtonHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
projectActionTooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
workspaceRow: {
|
||||
minHeight: 36,
|
||||
marginBottom: theme.spacing[1],
|
||||
@@ -1338,7 +1696,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
},
|
||||
workspaceRowSelected: {
|
||||
sidebarRowSelected: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
workspaceRowContainer: {
|
||||
|
||||
@@ -34,8 +34,9 @@ import {
|
||||
import {
|
||||
useSessionStore,
|
||||
type Agent,
|
||||
type WorkspaceDescriptor,
|
||||
type SessionState,
|
||||
type WorkspaceDescriptor,
|
||||
normalizeWorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||
@@ -52,7 +53,6 @@ import {
|
||||
normalizeAgentSnapshot,
|
||||
} from "@/utils/agent-snapshots";
|
||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
|
||||
@@ -130,24 +130,6 @@ type WorkspaceUpdatePayload = Extract<
|
||||
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: normalizeWorkspaceIdentity(payload.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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
takeCommandCenterFocusRestoreElement,
|
||||
} from "@/utils/command-center-focus-restore";
|
||||
import {
|
||||
buildHostNewAgentRoute,
|
||||
buildHostOpenProjectRoute,
|
||||
buildHostWorkspaceAgentRoute,
|
||||
buildHostSettingsRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
@@ -55,10 +55,10 @@ type CommandCenterActionDefinition = {
|
||||
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
||||
{
|
||||
id: "new-agent",
|
||||
title: "New agent",
|
||||
title: "Open project",
|
||||
icon: "plus",
|
||||
shortcutKeys: ["mod", "shift", "O"],
|
||||
keywords: ["new", "new agent", "create", "start", "launch", "agent"],
|
||||
keywords: ["open", "project", "folder", "workspace", "repo"],
|
||||
buildRoute: ({ newAgentRoute }) => newAgentRoute,
|
||||
},
|
||||
{
|
||||
@@ -123,7 +123,7 @@ export function useCommandCenter() {
|
||||
const newAgentRoute = useMemo<Href>(() => {
|
||||
const serverIdFromPath =
|
||||
parseServerIdFromPathname(pathname) ?? fallbackServerId;
|
||||
return serverIdFromPath ? (buildHostNewAgentRoute(serverIdFromPath) as Href) : "/";
|
||||
return serverIdFromPath ? (buildHostOpenProjectRoute(serverIdFromPath) as Href) : "/";
|
||||
}, [fallbackServerId, pathname]);
|
||||
|
||||
const settingsRoute = useMemo<Href>(() => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useSessionStore } from "@/stores/session-store";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import {
|
||||
buildHostNewAgentRoute,
|
||||
buildHostOpenProjectRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
@@ -99,7 +99,7 @@ export function useKeyboardShortcuts({
|
||||
return true;
|
||||
};
|
||||
|
||||
const navigateToNewAgent = (): boolean => {
|
||||
const navigateToOpenProject = (): boolean => {
|
||||
let targetServerId = parseServerIdFromPathname(pathname);
|
||||
|
||||
if (!targetServerId) {
|
||||
@@ -111,7 +111,7 @@ export function useKeyboardShortcuts({
|
||||
return false;
|
||||
}
|
||||
|
||||
router.push(buildHostNewAgentRoute(targetServerId) as any);
|
||||
router.push(buildHostOpenProjectRoute(targetServerId) as any);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -171,7 +171,7 @@ export function useKeyboardShortcuts({
|
||||
}): boolean => {
|
||||
switch (input.action) {
|
||||
case "agent.new":
|
||||
return navigateToNewAgent();
|
||||
return navigateToOpenProject();
|
||||
case "workspace.tab.new":
|
||||
return requestWorkspaceTabAction({ kind: "new" });
|
||||
case "workspace.tab.close.current":
|
||||
|
||||
@@ -14,6 +14,28 @@ function item(key: string): OrderedItem {
|
||||
return { key }
|
||||
}
|
||||
|
||||
function workspace(
|
||||
input: Pick<WorkspaceDescriptor, "id" | "projectId" | "name" | "status" | "activityAt"> &
|
||||
Partial<
|
||||
Pick<
|
||||
WorkspaceDescriptor,
|
||||
"projectDisplayName" | "projectRootPath" | "projectKind" | "workspaceKind"
|
||||
>
|
||||
>
|
||||
): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId,
|
||||
projectDisplayName: input.projectDisplayName ?? input.projectId,
|
||||
projectRootPath: input.projectRootPath ?? input.id,
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "local_checkout",
|
||||
name: input.name,
|
||||
status: input.status,
|
||||
activityAt: input.activityAt,
|
||||
}
|
||||
}
|
||||
|
||||
describe('applyStoredOrdering', () => {
|
||||
it('keeps unknown items on the baseline while applying stored order', () => {
|
||||
const result = applyStoredOrdering({
|
||||
@@ -72,13 +94,13 @@ describe('appendMissingOrderKeys', () => {
|
||||
describe('buildSidebarProjectsFromWorkspaces', () => {
|
||||
it('uses workspace descriptor name and status directly', () => {
|
||||
const workspaces: WorkspaceDescriptor[] = [
|
||||
{
|
||||
workspace({
|
||||
id: '/repo/main',
|
||||
projectId: 'project-1',
|
||||
name: 'feat/hard-cut',
|
||||
status: 'failed',
|
||||
activityAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const projects = buildSidebarProjectsFromWorkspaces({
|
||||
@@ -96,20 +118,20 @@ describe('buildSidebarProjectsFromWorkspaces', () => {
|
||||
|
||||
it('preserves stored project order even when activity changes', () => {
|
||||
const initialWorkspaces: WorkspaceDescriptor[] = [
|
||||
{
|
||||
workspace({
|
||||
id: '/repo/b',
|
||||
projectId: 'project-b',
|
||||
name: 'feat/b',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
}),
|
||||
workspace({
|
||||
id: '/repo/a',
|
||||
projectId: 'project-a',
|
||||
name: 'feat/a',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const seededOrder = appendMissingOrderKeys({
|
||||
@@ -125,20 +147,20 @@ describe('buildSidebarProjectsFromWorkspaces', () => {
|
||||
const updatedProjects = buildSidebarProjectsFromWorkspaces({
|
||||
serverId: 'srv',
|
||||
workspaces: [
|
||||
{
|
||||
workspace({
|
||||
id: '/repo/b',
|
||||
projectId: 'project-b',
|
||||
name: 'feat/b',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
}),
|
||||
workspace({
|
||||
id: '/repo/a',
|
||||
projectId: 'project-a',
|
||||
name: 'feat/a',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-03T00:00:00.000Z'),
|
||||
},
|
||||
}),
|
||||
],
|
||||
projectOrder: seededOrder,
|
||||
workspaceOrderByScope: {},
|
||||
@@ -151,27 +173,27 @@ describe('buildSidebarProjectsFromWorkspaces', () => {
|
||||
const projects = buildSidebarProjectsFromWorkspaces({
|
||||
serverId: 'srv',
|
||||
workspaces: [
|
||||
{
|
||||
workspace({
|
||||
id: '/repo/c',
|
||||
projectId: 'project-c',
|
||||
name: 'feat/c',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-04T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
}),
|
||||
workspace({
|
||||
id: '/repo/b',
|
||||
projectId: 'project-b',
|
||||
name: 'feat/b',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
}),
|
||||
workspace({
|
||||
id: '/repo/a',
|
||||
projectId: 'project-a',
|
||||
name: 'feat/a',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
},
|
||||
}),
|
||||
],
|
||||
projectOrder: ['project-b', 'project-a', 'project-c'],
|
||||
workspaceOrderByScope: {},
|
||||
@@ -184,20 +206,20 @@ describe('buildSidebarProjectsFromWorkspaces', () => {
|
||||
const initialProjects = buildSidebarProjectsFromWorkspaces({
|
||||
serverId: 'srv',
|
||||
workspaces: [
|
||||
{
|
||||
workspace({
|
||||
id: '/repo/main',
|
||||
projectId: 'project-1',
|
||||
name: 'main',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
}),
|
||||
workspace({
|
||||
id: '/repo/feature',
|
||||
projectId: 'project-1',
|
||||
name: 'feature',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
},
|
||||
}),
|
||||
],
|
||||
projectOrder: ['project-1'],
|
||||
workspaceOrderByScope: {},
|
||||
@@ -211,20 +233,20 @@ describe('buildSidebarProjectsFromWorkspaces', () => {
|
||||
const projects = buildSidebarProjectsFromWorkspaces({
|
||||
serverId: 'srv',
|
||||
workspaces: [
|
||||
{
|
||||
workspace({
|
||||
id: '/repo/main',
|
||||
projectId: 'project-1',
|
||||
name: 'main',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
}),
|
||||
workspace({
|
||||
id: '/repo/feature',
|
||||
projectId: 'project-1',
|
||||
name: 'feature',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-03T00:00:00.000Z'),
|
||||
},
|
||||
}),
|
||||
],
|
||||
projectOrder: ['project-1'],
|
||||
workspaceOrderByScope: {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react'
|
||||
import { useSessionStore } from '@/stores/session-store'
|
||||
import { normalizeWorkspaceDescriptor, 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'
|
||||
import { normalizeWorkspaceIdentity } from '@/utils/workspace-identity'
|
||||
|
||||
const EMPTY_ORDER: string[] = []
|
||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = []
|
||||
@@ -15,6 +14,7 @@ export interface SidebarWorkspaceEntry {
|
||||
workspaceKey: string
|
||||
serverId: string
|
||||
workspaceId: string
|
||||
workspaceKind: WorkspaceDescriptor['workspaceKind']
|
||||
name: string
|
||||
activityAt: Date | null
|
||||
statusBucket: SidebarStateBucket
|
||||
@@ -23,6 +23,7 @@ export interface SidebarWorkspaceEntry {
|
||||
export interface SidebarProjectEntry {
|
||||
projectKey: string
|
||||
projectName: string
|
||||
projectKind: WorkspaceDescriptor['projectKind']
|
||||
iconWorkingDir: string
|
||||
statusBucket: SidebarStateBucket
|
||||
activeCount: number
|
||||
@@ -110,8 +111,9 @@ export function buildSidebarProjectsFromWorkspaces(input: {
|
||||
byProject.get(workspace.projectId) ??
|
||||
({
|
||||
projectKey: workspace.projectId,
|
||||
projectName: projectDisplayNameFromProjectId(workspace.projectId),
|
||||
iconWorkingDir: workspace.id,
|
||||
projectName: workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId),
|
||||
projectKind: workspace.projectKind,
|
||||
iconWorkingDir: workspace.projectRootPath || workspace.id,
|
||||
statusBucket: 'done',
|
||||
activeCount: 0,
|
||||
totalWorkspaces: 0,
|
||||
@@ -123,6 +125,7 @@ export function buildSidebarProjectsFromWorkspaces(input: {
|
||||
workspaceKey: `${input.serverId}:${workspace.id}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: workspace.id,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
name: workspace.name,
|
||||
activityAt: workspace.activityAt,
|
||||
statusBucket: workspace.status,
|
||||
@@ -241,18 +244,15 @@ function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string
|
||||
function toWorkspaceDescriptor(payload: {
|
||||
id: string
|
||||
projectId: string
|
||||
projectDisplayName: string
|
||||
projectRootPath: string
|
||||
projectKind: WorkspaceDescriptor['projectKind']
|
||||
workspaceKind: WorkspaceDescriptor['workspaceKind']
|
||||
name: string
|
||||
status: WorkspaceDescriptor['status']
|
||||
activityAt: string | null
|
||||
}): WorkspaceDescriptor {
|
||||
const activityAt = payload.activityAt ? new Date(payload.activityAt) : null
|
||||
return {
|
||||
id: normalizeWorkspaceIdentity(payload.id) ?? payload.id,
|
||||
projectId: payload.projectId,
|
||||
name: payload.name,
|
||||
status: payload.status,
|
||||
activityAt: activityAt && !Number.isNaN(activityAt.getTime()) ? activityAt : null,
|
||||
}
|
||||
return normalizeWorkspaceDescriptor(payload)
|
||||
}
|
||||
|
||||
export function useSidebarWorkspacesList(options?: {
|
||||
|
||||
@@ -133,7 +133,7 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
help: {
|
||||
id: "new-agent",
|
||||
section: "global",
|
||||
label: "Create new agent",
|
||||
label: "Open project",
|
||||
keys: ["mod", "shift", "O"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -104,6 +104,7 @@ type DraftAgentParams = {
|
||||
model?: string
|
||||
thinkingOptionId?: string
|
||||
workingDir?: string
|
||||
worktreeMode?: string
|
||||
}
|
||||
|
||||
type DraftAgentScreenProps = {
|
||||
@@ -160,6 +161,11 @@ function DraftAgentScreenContent({
|
||||
const resolvedModel = getParamValue(params.model)
|
||||
const resolvedThinkingOptionId = getParamValue(params.thinkingOptionId)
|
||||
const resolvedWorkingDir = getParamValue(params.workingDir)
|
||||
const resolvedWorktreeMode = getParamValue(params.worktreeMode)
|
||||
const initialWorktreeMode =
|
||||
resolvedWorktreeMode === 'create' || resolvedWorktreeMode === 'attach'
|
||||
? resolvedWorktreeMode
|
||||
: 'none'
|
||||
|
||||
const onlineServerIds = useMemo(() => {
|
||||
if (daemons.length === 0) return []
|
||||
@@ -234,7 +240,9 @@ function DraftAgentScreenContent({
|
||||
const draftIdRef = useRef(generateDraftId())
|
||||
const draftAgentIdRef = useRef(generateDraftId())
|
||||
|
||||
const [worktreeMode, setWorktreeMode] = useState<'none' | 'create' | 'attach'>('none')
|
||||
const [worktreeMode, setWorktreeMode] = useState<'none' | 'create' | 'attach'>(
|
||||
initialWorktreeMode
|
||||
)
|
||||
const [baseBranch, setBaseBranch] = useState('')
|
||||
const [worktreeSlug, setWorktreeSlug] = useState('')
|
||||
const [selectedWorktreePath, setSelectedWorktreePath] = useState('')
|
||||
|
||||
206
packages/app/src/screens/open-project-screen.tsx
Normal file
206
packages/app/src/screens/open-project-screen.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { View, Text, ScrollView } from "react-native";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { FolderOpen } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import { buildHostWorkspaceRouteWithOpenIntent } from "@/utils/host-routes";
|
||||
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
||||
|
||||
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const { client, isConnected } = useHostRuntimeSession(serverId);
|
||||
const workspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
||||
const [isDirectoryPickerOpen, setIsDirectoryPickerOpen] = useState(false);
|
||||
const [directoryQuery, setDirectoryQuery] = useState("");
|
||||
const [selectedPath, setSelectedPath] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const directoryAnchorRef = useRef<View>(null);
|
||||
|
||||
const recommendedPaths = useMemo(() => {
|
||||
if (!workspaces) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(workspaces.values()).map((workspace) => workspace.projectRootPath || workspace.id);
|
||||
}, [workspaces]);
|
||||
|
||||
const directorySuggestionsQuery = useQuery({
|
||||
queryKey: ["open-project-directory-suggestions", serverId, directoryQuery],
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
return [];
|
||||
}
|
||||
const result = await client.getDirectorySuggestions({
|
||||
query: directoryQuery,
|
||||
includeDirectories: true,
|
||||
includeFiles: false,
|
||||
limit: 30,
|
||||
});
|
||||
return result.entries?.flatMap((entry) =>
|
||||
entry.kind === "directory" ? [entry.path] : []
|
||||
) ?? [];
|
||||
},
|
||||
enabled: Boolean(client) && isConnected,
|
||||
staleTime: 15_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const directoryOptions = useMemo(
|
||||
() =>
|
||||
buildWorkingDirectorySuggestions({
|
||||
recommendedPaths,
|
||||
serverPaths: directorySuggestionsQuery.data ?? [],
|
||||
query: directoryQuery,
|
||||
}).map((path) => ({
|
||||
id: path,
|
||||
label: path,
|
||||
kind: "directory" as const,
|
||||
})),
|
||||
[directoryQuery, directorySuggestionsQuery.data, recommendedPaths]
|
||||
);
|
||||
|
||||
const handleOpenProject = useCallback(async () => {
|
||||
const trimmedPath = selectedPath.trim();
|
||||
if (!trimmedPath) {
|
||||
toast.error("Choose a project directory");
|
||||
return;
|
||||
}
|
||||
if (!client) {
|
||||
toast.error("Host is not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const payload = await client.openProject(trimmedPath);
|
||||
if (payload.error || !payload.workspace) {
|
||||
throw new Error(payload.error || "Failed to open project");
|
||||
}
|
||||
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(payload.workspace)]);
|
||||
setHasHydratedWorkspaces(serverId, true);
|
||||
router.replace(
|
||||
buildHostWorkspaceRouteWithOpenIntent(serverId, payload.workspace.id, {
|
||||
kind: "draft",
|
||||
draftId: "new",
|
||||
}) as any
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to open project");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [client, mergeWorkspaces, selectedPath, serverId, setHasHydratedWorkspaces, toast]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.eyebrow}>Workspace Registry</Text>
|
||||
<Text style={styles.title}>Open project</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Add a local folder to the sidebar, then land inside its workspace draft tab.
|
||||
</Text>
|
||||
|
||||
<FormSelectTrigger
|
||||
controlRef={directoryAnchorRef}
|
||||
containerStyle={styles.selector}
|
||||
label="Project directory"
|
||||
value={selectedPath}
|
||||
placeholder="Choose a project directory"
|
||||
onPress={() => setIsDirectoryPickerOpen(true)}
|
||||
icon={<FolderOpen size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
valueEllipsizeMode="middle"
|
||||
testID="open-project-directory-trigger"
|
||||
/>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
variant="default"
|
||||
onPress={() => void handleOpenProject()}
|
||||
disabled={isSubmitting || !isConnected}
|
||||
testID="open-project-submit"
|
||||
>
|
||||
{isSubmitting ? "Opening..." : "Open project"}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<Combobox
|
||||
options={directoryOptions}
|
||||
value={selectedPath}
|
||||
onSelect={setSelectedPath}
|
||||
onSearchQueryChange={setDirectoryQuery}
|
||||
searchPlaceholder="Search directories..."
|
||||
emptyText="No directories found"
|
||||
allowCustomValue
|
||||
customValuePrefix=""
|
||||
customValueKind="directory"
|
||||
optionsPosition="above-search"
|
||||
title="Project directory"
|
||||
open={isDirectoryPickerOpen}
|
||||
onOpenChange={setIsDirectoryPickerOpen}
|
||||
anchorRef={directoryAnchorRef}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
content: {
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
padding: theme.spacing[6],
|
||||
},
|
||||
card: {
|
||||
alignSelf: "center",
|
||||
width: "100%",
|
||||
maxWidth: 560,
|
||||
gap: theme.spacing[4],
|
||||
padding: theme.spacing[6],
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
eyebrow: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize["3xl"],
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
subtitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 20,
|
||||
},
|
||||
selector: {
|
||||
minHeight: 64,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
}));
|
||||
@@ -116,6 +116,21 @@ function decodeSegment(value: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function buildOpenIntentKey(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
openIntent?: WorkspaceOpenIntent | null;
|
||||
}): string | null {
|
||||
if (!input.openIntent) {
|
||||
return null;
|
||||
}
|
||||
const openParam = buildWorkspaceOpenIntentParam(input.openIntent);
|
||||
if (!openParam) {
|
||||
return null;
|
||||
}
|
||||
return `${input.serverId}:${input.workspaceId}:${openParam}`;
|
||||
}
|
||||
|
||||
export function WorkspaceScreen({
|
||||
serverId,
|
||||
workspaceId,
|
||||
@@ -432,18 +447,43 @@ function WorkspaceScreenContent({
|
||||
(state) => state.clearWorkspaceTabActionRequest
|
||||
);
|
||||
const consumedOpenIntentsRef = useRef(new Set<string>());
|
||||
const [resolvedOpenIntentKey, setResolvedOpenIntentKey] = useState<string | null>(null);
|
||||
const currentOpenIntentKey = useMemo(
|
||||
() =>
|
||||
buildOpenIntentKey({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
openIntent,
|
||||
}),
|
||||
[normalizedServerId, normalizedWorkspaceId, openIntent]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentOpenIntentKey) {
|
||||
if (resolvedOpenIntentKey !== null) {
|
||||
setResolvedOpenIntentKey(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolvedOpenIntentKey === currentOpenIntentKey) {
|
||||
return;
|
||||
}
|
||||
}, [currentOpenIntentKey, resolvedOpenIntentKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openIntent || !persistenceKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const openParam = buildWorkspaceOpenIntentParam(openIntent);
|
||||
if (!openParam) {
|
||||
if (!currentOpenIntentKey) {
|
||||
return;
|
||||
}
|
||||
const intentKey = `${normalizedServerId}:${normalizedWorkspaceId}:${openParam}`;
|
||||
const intentKey = currentOpenIntentKey;
|
||||
if (consumedOpenIntentsRef.current.has(intentKey)) {
|
||||
if (resolvedOpenIntentKey !== intentKey) {
|
||||
setResolvedOpenIntentKey(intentKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
consumedOpenIntentsRef.current.add(intentKey);
|
||||
@@ -468,6 +508,7 @@ function WorkspaceScreenContent({
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId,
|
||||
});
|
||||
setResolvedOpenIntentKey(intentKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -488,8 +529,10 @@ function WorkspaceScreenContent({
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId,
|
||||
});
|
||||
setResolvedOpenIntentKey(intentKey);
|
||||
}
|
||||
}, [
|
||||
currentOpenIntentKey,
|
||||
focusTab,
|
||||
openDraftTab,
|
||||
openIntent,
|
||||
@@ -497,8 +540,13 @@ function WorkspaceScreenContent({
|
||||
persistenceKey,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
resolvedOpenIntentKey,
|
||||
]);
|
||||
|
||||
const unresolvedOpenIntent = currentOpenIntentKey && resolvedOpenIntentKey !== currentOpenIntentKey
|
||||
? openIntent
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedServerId || !normalizedWorkspaceId) {
|
||||
return;
|
||||
@@ -586,17 +634,17 @@ function WorkspaceScreenContent({
|
||||
tabOrder,
|
||||
focusedTabId,
|
||||
preferredTarget:
|
||||
openIntent?.kind === "agent"
|
||||
? { kind: "agent", agentId: openIntent.agentId }
|
||||
: openIntent?.kind === "terminal"
|
||||
? { kind: "terminal", terminalId: openIntent.terminalId }
|
||||
: openIntent?.kind === "draft"
|
||||
? { kind: "draft", draftId: openIntent.draftId }
|
||||
: openIntent?.kind === "file"
|
||||
? { kind: "file", path: openIntent.path }
|
||||
unresolvedOpenIntent?.kind === "agent"
|
||||
? { kind: "agent", agentId: unresolvedOpenIntent.agentId }
|
||||
: unresolvedOpenIntent?.kind === "terminal"
|
||||
? { kind: "terminal", terminalId: unresolvedOpenIntent.terminalId }
|
||||
: unresolvedOpenIntent?.kind === "draft"
|
||||
? { kind: "draft", draftId: unresolvedOpenIntent.draftId }
|
||||
: unresolvedOpenIntent?.kind === "file"
|
||||
? { kind: "file", path: unresolvedOpenIntent.path }
|
||||
: null,
|
||||
}),
|
||||
[focusedTabId, openIntent, tabOrder, terminals, uiTabs, workspaceAgents]
|
||||
[focusedTabId, tabOrder, terminals, uiTabs, unresolvedOpenIntent, workspaceAgents]
|
||||
);
|
||||
const activeTabId = tabModel.activeTabId;
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ describe('workspace source of truth consumption', () => {
|
||||
const workspace: WorkspaceDescriptor = {
|
||||
id: '/repo/main',
|
||||
projectId: 'remote:github.com/getpaseo/paseo',
|
||||
projectDisplayName: 'getpaseo/paseo',
|
||||
projectRootPath: '/repo/main',
|
||||
projectKind: 'git',
|
||||
workspaceKind: 'local_checkout',
|
||||
name: 'feat/workspace-sot',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-03-01T00:00:00.000Z'),
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
ServerCapabilities,
|
||||
WorkspaceDescriptorPayload,
|
||||
} from "@server/shared/messages";
|
||||
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
|
||||
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
||||
import {
|
||||
createAgentLastActivityCoalescer,
|
||||
@@ -112,11 +113,33 @@ export interface Agent {
|
||||
export interface WorkspaceDescriptor {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
projectRootPath: string;
|
||||
projectKind: WorkspaceDescriptorPayload["projectKind"];
|
||||
workspaceKind: WorkspaceDescriptorPayload["workspaceKind"];
|
||||
name: string;
|
||||
status: WorkspaceDescriptorPayload["status"];
|
||||
activityAt: Date | null;
|
||||
}
|
||||
|
||||
export function normalizeWorkspaceDescriptor(
|
||||
payload: WorkspaceDescriptorPayload
|
||||
): WorkspaceDescriptor {
|
||||
const activityAt = payload.activityAt ? new Date(payload.activityAt) : null;
|
||||
return {
|
||||
id: normalizeWorkspaceIdentity(payload.id) ?? payload.id,
|
||||
projectId: payload.projectId,
|
||||
projectDisplayName: payload.projectDisplayName,
|
||||
projectRootPath: payload.projectRootPath,
|
||||
projectKind: payload.projectKind,
|
||||
workspaceKind: payload.workspaceKind,
|
||||
name: payload.name,
|
||||
status: payload.status,
|
||||
activityAt:
|
||||
activityAt && !Number.isNaN(activityAt.getTime()) ? activityAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
export type ExplorerEntryKind = "file" | "directory";
|
||||
export type ExplorerFileKind = "text" | "image" | "binary";
|
||||
export type ExplorerEncoding = "utf-8" | "base64" | "none";
|
||||
|
||||
@@ -196,4 +196,42 @@ describe("terminal-emulator-runtime", () => {
|
||||
expect(terminal.options?.theme).toEqual({ background: "after" });
|
||||
expect(refresh).toHaveBeenCalledWith(0, 11);
|
||||
});
|
||||
|
||||
it("forces a refit when the page becomes visible again", () => {
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
const fitAndEmitResize = vi.fn();
|
||||
|
||||
(runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize =
|
||||
fitAndEmitResize;
|
||||
(globalThis as { document?: { visibilityState?: string } }).document = {
|
||||
visibilityState: "visible",
|
||||
};
|
||||
|
||||
(
|
||||
runtime as unknown as {
|
||||
handleVisibilityRestore: () => void;
|
||||
}
|
||||
).handleVisibilityRestore();
|
||||
|
||||
expect(fitAndEmitResize).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("does not refit while the page is still hidden", () => {
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
const fitAndEmitResize = vi.fn();
|
||||
|
||||
(runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize =
|
||||
fitAndEmitResize;
|
||||
(globalThis as { document?: { visibilityState?: string } }).document = {
|
||||
visibilityState: "hidden",
|
||||
};
|
||||
|
||||
(
|
||||
runtime as unknown as {
|
||||
handleVisibilityRestore: () => void;
|
||||
}
|
||||
).handleVisibilityRestore();
|
||||
|
||||
expect(fitAndEmitResize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,8 @@ type TerminalEmulatorRuntimeDisposables = {
|
||||
disposeInput: () => void;
|
||||
disconnectResizeObserver: () => void;
|
||||
removeWindowResize: () => void;
|
||||
removeWindowFocus: () => void;
|
||||
removeDocumentVisibilityChange: () => void;
|
||||
removeVisualViewportResize: () => void;
|
||||
clearFitInterval: () => void;
|
||||
clearFitTimeouts: () => void;
|
||||
@@ -102,6 +104,19 @@ export class TerminalEmulatorRuntime {
|
||||
private inFlightOutputOperationTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private suppressInput = false;
|
||||
|
||||
private handleVisibilityRestore = (): void => {
|
||||
if (typeof document !== "undefined" && document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.fitAndEmitResize?.(true);
|
||||
if (typeof window.requestAnimationFrame === "function") {
|
||||
window.requestAnimationFrame(() => {
|
||||
this.fitAndEmitResize?.(true);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
setCallbacks(input: { callbacks: TerminalEmulatorRuntimeCallbacks }): void {
|
||||
this.callbacks = input.callbacks;
|
||||
}
|
||||
@@ -311,6 +326,15 @@ export class TerminalEmulatorRuntime {
|
||||
|
||||
const windowResizeHandler = () => fitAndEmitResize(false);
|
||||
window.addEventListener("resize", windowResizeHandler);
|
||||
const windowFocusHandler = () => {
|
||||
this.handleVisibilityRestore();
|
||||
};
|
||||
window.addEventListener("focus", windowFocusHandler);
|
||||
|
||||
const documentVisibilityChangeHandler = () => {
|
||||
this.handleVisibilityRestore();
|
||||
};
|
||||
document.addEventListener("visibilitychange", documentVisibilityChangeHandler);
|
||||
|
||||
const visualViewport = window.visualViewport;
|
||||
const visualViewportResizeHandler = () => fitAndEmitResize(false);
|
||||
@@ -369,6 +393,12 @@ export class TerminalEmulatorRuntime {
|
||||
removeWindowResize: () => {
|
||||
window.removeEventListener("resize", windowResizeHandler);
|
||||
},
|
||||
removeWindowFocus: () => {
|
||||
window.removeEventListener("focus", windowFocusHandler);
|
||||
},
|
||||
removeDocumentVisibilityChange: () => {
|
||||
document.removeEventListener("visibilitychange", documentVisibilityChangeHandler);
|
||||
},
|
||||
removeVisualViewportResize: () => {
|
||||
visualViewport?.removeEventListener("resize", visualViewportResizeHandler);
|
||||
},
|
||||
@@ -410,6 +440,8 @@ export class TerminalEmulatorRuntime {
|
||||
disposables.disposeInput();
|
||||
disposables.disconnectResizeObserver();
|
||||
disposables.removeWindowResize();
|
||||
disposables.removeWindowFocus();
|
||||
disposables.removeDocumentVisibilityChange();
|
||||
disposables.removeVisualViewportResize();
|
||||
disposables.clearFitInterval();
|
||||
disposables.clearFitTimeouts();
|
||||
|
||||
@@ -68,6 +68,12 @@ describe("workspace route parsing", () => {
|
||||
|
||||
it("builds host new-agent routes", () => {
|
||||
expect(buildHostNewAgentRoute("local")).toBe("/h/local/new-agent");
|
||||
expect(
|
||||
buildHostNewAgentRoute("local", {
|
||||
workingDir: "/tmp/repo",
|
||||
worktreeMode: "create",
|
||||
})
|
||||
).toBe("/h/local/new-agent?workingDir=%2Ftmp%2Frepo&worktreeMode=create");
|
||||
});
|
||||
|
||||
it("builds workspace routes with open intent query", () => {
|
||||
|
||||
@@ -399,12 +399,46 @@ export function buildHostAgentsRoute(serverId: string): string {
|
||||
return `${base}/agents`;
|
||||
}
|
||||
|
||||
export function buildHostNewAgentRoute(serverId: string): string {
|
||||
export function buildHostNewAgentRoute(
|
||||
serverId: string,
|
||||
options?: {
|
||||
workingDir?: string;
|
||||
provider?: string;
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
worktreeMode?: "create" | "attach";
|
||||
}
|
||||
): string {
|
||||
const base = buildHostRootRoute(serverId);
|
||||
if (base === "/") {
|
||||
return "/";
|
||||
}
|
||||
return `${base}/new-agent`;
|
||||
const searchParams = new URLSearchParams();
|
||||
const entries: Array<[string, string | undefined]> = [
|
||||
["workingDir", options?.workingDir],
|
||||
["provider", options?.provider],
|
||||
["modeId", options?.modeId],
|
||||
["model", options?.model],
|
||||
["thinkingOptionId", options?.thinkingOptionId],
|
||||
["worktreeMode", options?.worktreeMode],
|
||||
];
|
||||
for (const [key, value] of entries) {
|
||||
const normalized = trimNonEmpty(value);
|
||||
if (normalized) {
|
||||
searchParams.set(key, normalized);
|
||||
}
|
||||
}
|
||||
const search = searchParams.toString();
|
||||
return search ? `${base}/new-agent?${search}` : `${base}/new-agent`;
|
||||
}
|
||||
|
||||
export function buildHostOpenProjectRoute(serverId: string): string {
|
||||
const base = buildHostRootRoute(serverId);
|
||||
if (base === "/") {
|
||||
return "/";
|
||||
}
|
||||
return `${base}/open-project`;
|
||||
}
|
||||
|
||||
export function buildHostSettingsRoute(serverId: string): string {
|
||||
@@ -435,6 +469,9 @@ export function mapPathnameToServer(
|
||||
if (suffix.startsWith("new-agent")) {
|
||||
return `${base}/new-agent`;
|
||||
}
|
||||
if (suffix.startsWith("open-project")) {
|
||||
return `${base}/open-project`;
|
||||
}
|
||||
const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname);
|
||||
if (workspaceRoute) {
|
||||
const workspacePath = buildHostWorkspaceRoute(
|
||||
|
||||
103
packages/app/src/utils/sidebar-project-row-model.test.ts
Normal file
103
packages/app/src/utils/sidebar-project-row-model.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildSidebarProjectRowModel } from './sidebar-project-row-model'
|
||||
import type {
|
||||
SidebarProjectEntry,
|
||||
SidebarWorkspaceEntry,
|
||||
} from '@/hooks/use-sidebar-workspaces-list'
|
||||
|
||||
function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorkspaceEntry {
|
||||
return {
|
||||
workspaceKey: 'srv:/repo',
|
||||
serverId: 'srv',
|
||||
workspaceId: '/repo',
|
||||
workspaceKind: 'directory',
|
||||
name: 'paseo',
|
||||
activityAt: null,
|
||||
statusBucket: 'done',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function project(overrides: Partial<SidebarProjectEntry> = {}): SidebarProjectEntry {
|
||||
return {
|
||||
projectKey: 'project-1',
|
||||
projectName: 'paseo',
|
||||
projectKind: 'git',
|
||||
iconWorkingDir: '/repo',
|
||||
statusBucket: 'done',
|
||||
activeCount: 0,
|
||||
totalWorkspaces: 1,
|
||||
latestActivityAt: null,
|
||||
workspaces: [workspace()],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildSidebarProjectRowModel', () => {
|
||||
it('flattens non-git projects with one workspace into a direct workspace row model', () => {
|
||||
const flattenedWorkspace = workspace({
|
||||
workspaceId: '/repo/non-git',
|
||||
workspaceKind: 'directory',
|
||||
statusBucket: 'running',
|
||||
})
|
||||
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: 'non_git',
|
||||
workspaces: [flattenedWorkspace],
|
||||
}),
|
||||
collapsed: false,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
interaction: 'navigate',
|
||||
chevron: 'disclosure',
|
||||
trailingAction: 'none',
|
||||
flattenedWorkspace,
|
||||
selected: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('marks flattened non-git project rows as selected when their workspace is active', () => {
|
||||
const flattenedWorkspace = workspace({
|
||||
serverId: 'srv-2',
|
||||
workspaceId: '/repo/non-git',
|
||||
})
|
||||
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: 'non_git',
|
||||
workspaces: [flattenedWorkspace],
|
||||
}),
|
||||
collapsed: false,
|
||||
serverId: 'srv-2',
|
||||
activeWorkspaceSelection: {
|
||||
serverId: 'srv-2',
|
||||
workspaceId: '/repo/non-git',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.selected).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps git projects as expandable sections with a new worktree action', () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: 'git',
|
||||
workspaces: [
|
||||
workspace({ workspaceId: '/repo/main', workspaceKind: 'local_checkout' }),
|
||||
workspace({ workspaceId: '/repo/feature', workspaceKind: 'worktree' }),
|
||||
],
|
||||
}),
|
||||
collapsed: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
interaction: 'toggle',
|
||||
chevron: 'expand',
|
||||
trailingAction: 'new_worktree',
|
||||
flattenedWorkspace: null,
|
||||
selected: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
47
packages/app/src/utils/sidebar-project-row-model.ts
Normal file
47
packages/app/src/utils/sidebar-project-row-model.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
SidebarProjectEntry,
|
||||
SidebarWorkspaceEntry,
|
||||
} from '@/hooks/use-sidebar-workspaces-list'
|
||||
|
||||
export interface SidebarProjectRowModel {
|
||||
interaction: 'toggle' | 'navigate'
|
||||
chevron: 'expand' | 'collapse' | 'disclosure'
|
||||
trailingAction: 'new_worktree' | 'none'
|
||||
flattenedWorkspace: SidebarWorkspaceEntry | null
|
||||
selected: boolean
|
||||
}
|
||||
|
||||
export function buildSidebarProjectRowModel(input: {
|
||||
project: SidebarProjectEntry
|
||||
collapsed: boolean
|
||||
serverId?: string | null
|
||||
activeWorkspaceSelection?: { serverId: string; workspaceId: string } | null
|
||||
}): SidebarProjectRowModel {
|
||||
const flattenedWorkspace =
|
||||
input.project.projectKind === 'non_git' && input.project.workspaces.length === 1
|
||||
? input.project.workspaces[0] ?? null
|
||||
: null
|
||||
const selected =
|
||||
flattenedWorkspace !== null &&
|
||||
Boolean(input.serverId) &&
|
||||
input.activeWorkspaceSelection?.serverId === input.serverId &&
|
||||
input.activeWorkspaceSelection?.workspaceId === flattenedWorkspace.workspaceId
|
||||
|
||||
if (flattenedWorkspace) {
|
||||
return {
|
||||
interaction: 'navigate',
|
||||
chevron: 'disclosure',
|
||||
trailingAction: 'none',
|
||||
flattenedWorkspace,
|
||||
selected,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
interaction: 'toggle',
|
||||
chevron: input.collapsed ? 'expand' : 'collapse',
|
||||
trailingAction: input.project.projectKind === 'git' ? 'new_worktree' : 'none',
|
||||
flattenedWorkspace: null,
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
|
||||
workspaceKey: `${serverId}:${cwd}`,
|
||||
serverId,
|
||||
workspaceId: cwd,
|
||||
workspaceKind: "local_checkout",
|
||||
name: cwd,
|
||||
activityAt: null,
|
||||
statusBucket: "done",
|
||||
@@ -18,6 +19,7 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
|
||||
return {
|
||||
projectKey,
|
||||
projectName: projectKey,
|
||||
projectKind: "git",
|
||||
iconWorkingDir: workspaces[0]?.workspaceId ?? "",
|
||||
statusBucket: "done",
|
||||
activeCount: 0,
|
||||
|
||||
@@ -33,6 +33,8 @@ import type {
|
||||
PaseoWorktreeListResponse,
|
||||
PaseoWorktreeArchiveResponse,
|
||||
ProjectIconResponse,
|
||||
OpenProjectResponseMessage,
|
||||
ArchiveWorkspaceResponseMessage,
|
||||
ListCommandsResponse,
|
||||
ListProviderModelsResponseMessage,
|
||||
ListAvailableProvidersResponse,
|
||||
@@ -305,6 +307,8 @@ export type FetchWorkspacesOptions = Omit<FetchWorkspacesRequest, 'type' | 'requ
|
||||
}
|
||||
export type FetchWorkspacesEntry = FetchWorkspacesPayload['entries'][number]
|
||||
export type FetchWorkspacesPageInfo = FetchWorkspacesPayload['pageInfo']
|
||||
type OpenProjectPayload = OpenProjectResponseMessage['payload']
|
||||
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage['payload']
|
||||
|
||||
export type FetchAgentResult = {
|
||||
agent: AgentSnapshotPayload
|
||||
@@ -1143,6 +1147,30 @@ export class DaemonClient {
|
||||
})
|
||||
}
|
||||
|
||||
async openProject(cwd: string, requestId?: string): Promise<OpenProjectPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: 'open_project_request',
|
||||
cwd,
|
||||
},
|
||||
responseType: 'open_project_response',
|
||||
timeout: 10000,
|
||||
})
|
||||
}
|
||||
|
||||
async archiveWorkspace(workspaceId: string, requestId?: string): Promise<ArchiveWorkspacePayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: 'archive_workspace_request',
|
||||
workspaceId,
|
||||
},
|
||||
responseType: 'archive_workspace_response',
|
||||
timeout: 10000,
|
||||
})
|
||||
}
|
||||
|
||||
async fetchAgent(agentId: string, requestId?: string): Promise<FetchAgentResult | null> {
|
||||
const resolvedRequestId = this.createRequestId(requestId)
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
|
||||
@@ -979,6 +979,177 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("does not emit live autonomous turn events for local_agent task_started during a foreground run", async () => {
|
||||
const logger = createTestLogger();
|
||||
const keepQueryAlive = deferred<void>();
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const readPromptUuid = createPromptUuidReader(prompt);
|
||||
let step = 0;
|
||||
return {
|
||||
next: vi.fn(async () => {
|
||||
if (step === 0) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "task-started-live-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 1) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "assistant",
|
||||
message: {
|
||||
id: "tool-call-msg",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_live_1",
|
||||
name: "Agent",
|
||||
input: { description: "verify", prompt: "sub-task" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 2) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "system",
|
||||
subtype: "task_started",
|
||||
task_id: "task-live-1",
|
||||
tool_use_id: "toolu_live_1",
|
||||
description: "verify",
|
||||
task_type: "local_agent",
|
||||
session_id: "task-started-live-session",
|
||||
uuid: "task-started-live-1",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 3) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "stream_event",
|
||||
event: {
|
||||
type: "content_block_start",
|
||||
index: 2,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: "toolu_live_2",
|
||||
name: "Agent",
|
||||
input: {},
|
||||
caller: { type: "direct" },
|
||||
},
|
||||
},
|
||||
session_id: "task-started-live-session",
|
||||
parent_tool_use_id: null,
|
||||
uuid: "content-block-start-live-tool-use",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 4) {
|
||||
step += 1;
|
||||
const promptUuid = (await readPromptUuid()) ?? "missing-prompt-uuid";
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "user",
|
||||
message: { role: "user", content: "current prompt" },
|
||||
parent_tool_use_id: null,
|
||||
uuid: promptUuid,
|
||||
session_id: "task-started-live-session",
|
||||
isReplay: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 5) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: "FOREGROUND_DONE",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 6) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 7) {
|
||||
await keepQueryAlive.promise;
|
||||
return { done: true, value: undefined };
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
}),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
} satisfies QueryMock;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const foregroundEvents = await collectUntilTerminal(session.stream("current prompt"));
|
||||
const liveIterator = (
|
||||
session as unknown as {
|
||||
streamLiveEvents: () => AsyncGenerator<AgentStreamEvent>;
|
||||
}
|
||||
).streamLiveEvents();
|
||||
const timedReader = createTimedIteratorReader({ iterator: liveIterator });
|
||||
const liveEvents: AgentStreamEvent[] = [];
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
const next = await timedReader.nextWithTimeout(25);
|
||||
if (next.done) {
|
||||
break;
|
||||
}
|
||||
liveEvents.push(next.value);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(collectAssistantText(foregroundEvents)).toContain("FOREGROUND_DONE");
|
||||
expect(liveEvents.some((event) => event.type === "turn_started")).toBe(false);
|
||||
expect(liveEvents.some((event) => event.type === "turn_completed")).toBe(false);
|
||||
|
||||
keepQueryAlive.resolve(undefined);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("emits autonomous live events from SDK stream when Claude wakes itself", async () => {
|
||||
const logger = createTestLogger();
|
||||
let queryCreateCount = 0;
|
||||
|
||||
@@ -50,6 +50,11 @@ import { AgentStorage } from "./agent/agent-storage.js";
|
||||
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
|
||||
import { createAgentMcpServer } from "./agent/mcp-server.js";
|
||||
import { createAllClients, shutdownProviders } from "./agent/provider-registry.js";
|
||||
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
|
||||
import {
|
||||
FileBackedProjectRegistry,
|
||||
FileBackedWorkspaceRegistry,
|
||||
} from "./workspace-registry.js";
|
||||
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import {
|
||||
createConnectionOfferV2,
|
||||
@@ -289,6 +294,14 @@ export async function createPaseoDaemon(
|
||||
const httpServer = createHTTPServer(app);
|
||||
|
||||
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
|
||||
const projectRegistry = new FileBackedProjectRegistry(
|
||||
path.join(config.paseoHome, "projects", "projects.json"),
|
||||
logger
|
||||
);
|
||||
const workspaceRegistry = new FileBackedWorkspaceRegistry(
|
||||
path.join(config.paseoHome, "projects", "workspaces.json"),
|
||||
logger
|
||||
);
|
||||
const agentManager = new AgentManager({
|
||||
clients: {
|
||||
...createAllClients(logger, {
|
||||
@@ -308,6 +321,13 @@ export async function createPaseoDaemon(
|
||||
agentStorage
|
||||
);
|
||||
await agentStorage.initialize();
|
||||
await bootstrapWorkspaceRegistries({
|
||||
paseoHome: config.paseoHome,
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger,
|
||||
});
|
||||
const persistedRecords = await agentStorage.list();
|
||||
logger.info(
|
||||
`Agent registry loaded (${persistedRecords.length} record${persistedRecords.length === 1 ? "" : "s"}); agents will initialize on demand`
|
||||
@@ -532,7 +552,9 @@ export async function createPaseoDaemon(
|
||||
} catch (error) {
|
||||
logger.error({ err: error, intent }, "Failed to handle daemon lifecycle intent");
|
||||
}
|
||||
}
|
||||
},
|
||||
projectRegistry,
|
||||
workspaceRegistry
|
||||
);
|
||||
unsubscribeSpeechReadiness = subscribeSpeechReadiness((snapshot) => {
|
||||
wsServer?.publishSpeechReadiness(snapshot);
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
type SubscribeCheckoutDiffRequest,
|
||||
type UnsubscribeCheckoutDiffRequest,
|
||||
type DirectorySuggestionsRequest,
|
||||
type ProjectCheckoutLitePayload,
|
||||
type ProjectPlacementPayload,
|
||||
type WorkspaceDescriptorPayload,
|
||||
type WorkspaceStateBucket,
|
||||
@@ -95,6 +94,24 @@ import type {
|
||||
} from './agent/agent-sdk-types.js'
|
||||
import { AgentStorage, type StoredAgentRecord } from './agent/agent-storage.js'
|
||||
import { isValidAgentProvider, AGENT_PROVIDER_IDS } from './agent/provider-manifest.js'
|
||||
import {
|
||||
buildProjectPlacementForCwd,
|
||||
deriveProjectKind,
|
||||
deriveProjectRootPath,
|
||||
deriveWorkspaceDisplayName,
|
||||
deriveWorkspaceKind,
|
||||
normalizeWorkspaceId as normalizePersistedWorkspaceId,
|
||||
} from './workspace-registry-model.js'
|
||||
import type {
|
||||
PersistedProjectRecord,
|
||||
PersistedWorkspaceRecord,
|
||||
ProjectRegistry,
|
||||
WorkspaceRegistry,
|
||||
} from './workspace-registry.js'
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
} from './workspace-registry.js'
|
||||
import {
|
||||
buildVoiceAgentMcpServerConfig,
|
||||
buildVoiceModeSystemPrompt,
|
||||
@@ -121,7 +138,6 @@ import { createAgentWorktree, runAsyncWorktreeBootstrap } from './worktree-boots
|
||||
import {
|
||||
getCheckoutDiff,
|
||||
getCheckoutStatus,
|
||||
getCheckoutStatusLite,
|
||||
listBranchSuggestions,
|
||||
NotGitRepoError,
|
||||
MergeConflictError,
|
||||
@@ -194,82 +210,6 @@ export function resolveCreateAgentTitles(options: {
|
||||
}
|
||||
}
|
||||
|
||||
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
|
||||
if (!remoteUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
const trimmed = remoteUrl.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
let host: string | null = null
|
||||
let path: string | null = null
|
||||
|
||||
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/)
|
||||
if (scpLike) {
|
||||
host = scpLike[1] ?? null
|
||||
path = scpLike[2] ?? null
|
||||
} else if (trimmed.includes('://')) {
|
||||
try {
|
||||
const parsed = new URL(trimmed)
|
||||
host = parsed.hostname || null
|
||||
path = parsed.pathname ? parsed.pathname.replace(/^\//, '') : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (!host || !path) {
|
||||
return null
|
||||
}
|
||||
|
||||
let cleanedPath = path.trim().replace(/^\/+/, '').replace(/\/+$/, '')
|
||||
if (cleanedPath.endsWith('.git')) {
|
||||
cleanedPath = cleanedPath.slice(0, -4)
|
||||
}
|
||||
if (!cleanedPath.includes('/')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cleanedHost = host.toLowerCase()
|
||||
if (cleanedHost === 'github.com') {
|
||||
return `remote:github.com/${cleanedPath}`
|
||||
}
|
||||
|
||||
return `remote:${cleanedHost}/${cleanedPath}`
|
||||
}
|
||||
|
||||
function deriveProjectGroupingKey(options: {
|
||||
cwd: string
|
||||
remoteUrl: string | null
|
||||
isPaseoOwnedWorktree: boolean
|
||||
mainRepoRoot: string | null
|
||||
}): string {
|
||||
const remoteKey = deriveRemoteProjectKey(options.remoteUrl)
|
||||
if (remoteKey) {
|
||||
return remoteKey
|
||||
}
|
||||
|
||||
const mainRepoRoot = options.mainRepoRoot?.trim()
|
||||
if (options.isPaseoOwnedWorktree && mainRepoRoot) {
|
||||
return mainRepoRoot
|
||||
}
|
||||
|
||||
return options.cwd
|
||||
}
|
||||
|
||||
function deriveProjectGroupingName(projectKey: string): string {
|
||||
const githubRemotePrefix = 'remote:github.com/'
|
||||
if (projectKey.startsWith(githubRemotePrefix)) {
|
||||
return projectKey.slice(githubRemotePrefix.length) || projectKey
|
||||
}
|
||||
|
||||
const segments = projectKey.split(/[\\/]/).filter(Boolean)
|
||||
return segments[segments.length - 1] || projectKey
|
||||
}
|
||||
|
||||
type ProcessingPhase = 'idle' | 'transcribing'
|
||||
|
||||
type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest['compare']
|
||||
@@ -431,6 +371,8 @@ export type SessionOptions = {
|
||||
paseoHome: string
|
||||
agentManager: AgentManager
|
||||
agentStorage: AgentStorage
|
||||
projectRegistry: ProjectRegistry
|
||||
workspaceRegistry: WorkspaceRegistry
|
||||
createAgentMcpTransport: AgentMcpTransportFactory
|
||||
stt: Resolvable<SpeechToTextProvider | null>
|
||||
tts: Resolvable<TextToSpeechProvider | null>
|
||||
@@ -618,6 +560,8 @@ export class Session {
|
||||
private agentTools: ToolSet | null = null
|
||||
private agentManager: AgentManager
|
||||
private readonly agentStorage: AgentStorage
|
||||
private readonly projectRegistry: ProjectRegistry
|
||||
private readonly workspaceRegistry: WorkspaceRegistry
|
||||
private readonly createAgentMcpTransport: AgentMcpTransportFactory
|
||||
private readonly downloadTokenStore: DownloadTokenStore
|
||||
private readonly pushTokenStore: PushTokenStore
|
||||
@@ -682,6 +626,8 @@ export class Session {
|
||||
paseoHome,
|
||||
agentManager,
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
createAgentMcpTransport,
|
||||
stt,
|
||||
tts,
|
||||
@@ -701,6 +647,8 @@ export class Session {
|
||||
this.paseoHome = paseoHome
|
||||
this.agentManager = agentManager
|
||||
this.agentStorage = agentStorage
|
||||
this.projectRegistry = projectRegistry
|
||||
this.workspaceRegistry = workspaceRegistry
|
||||
this.createAgentMcpTransport = createAgentMcpTransport
|
||||
this.terminalManager = terminalManager
|
||||
if (this.terminalManager) {
|
||||
@@ -1292,65 +1240,16 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private buildFallbackProjectCheckout(cwd: string): ProjectCheckoutLitePayload {
|
||||
return {
|
||||
cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
}
|
||||
}
|
||||
|
||||
private toProjectCheckoutLite(
|
||||
cwd: string,
|
||||
status: Awaited<ReturnType<typeof getCheckoutStatusLite>>
|
||||
): ProjectCheckoutLitePayload {
|
||||
if (!status.isGit) {
|
||||
return this.buildFallbackProjectCheckout(cwd)
|
||||
}
|
||||
|
||||
if (status.isPaseoOwnedWorktree) {
|
||||
return {
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: status.currentBranch,
|
||||
remoteUrl: status.remoteUrl,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: status.mainRepoRoot,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: status.currentBranch,
|
||||
remoteUrl: status.remoteUrl,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
}
|
||||
}
|
||||
|
||||
private async buildProjectPlacement(cwd: string): Promise<ProjectPlacementPayload> {
|
||||
const checkout = await getCheckoutStatusLite(cwd, { paseoHome: this.paseoHome })
|
||||
.then((status) => this.toProjectCheckoutLite(cwd, status))
|
||||
.catch(() => this.buildFallbackProjectCheckout(cwd))
|
||||
const projectKey = deriveProjectGroupingKey({
|
||||
return buildProjectPlacementForCwd({
|
||||
cwd,
|
||||
remoteUrl: checkout.remoteUrl,
|
||||
isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree,
|
||||
mainRepoRoot: checkout.mainRepoRoot,
|
||||
paseoHome: this.paseoHome,
|
||||
})
|
||||
return {
|
||||
projectKey,
|
||||
projectName: deriveProjectGroupingName(projectKey),
|
||||
checkout,
|
||||
}
|
||||
}
|
||||
|
||||
private async forwardAgentUpdate(agent: ManagedAgent): Promise<void> {
|
||||
try {
|
||||
await this.ensureWorkspaceRegistered(agent.cwd)
|
||||
const subscription = this.agentUpdatesSubscription
|
||||
const payload = await this.buildAgentPayload(agent)
|
||||
if (subscription) {
|
||||
@@ -1572,6 +1471,14 @@ export class Session {
|
||||
await this.handlePaseoWorktreeArchiveRequest(msg)
|
||||
break
|
||||
|
||||
case 'open_project_request':
|
||||
await this.handleOpenProjectRequest(msg)
|
||||
break
|
||||
|
||||
case 'archive_workspace_request':
|
||||
await this.handleArchiveWorkspaceRequest(msg)
|
||||
break
|
||||
|
||||
case 'file_explorer_request':
|
||||
await this.handleFileExplorerRequest(msg)
|
||||
break
|
||||
@@ -1874,7 +1781,7 @@ export class Session {
|
||||
private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise<void> {
|
||||
this.sessionLogger.info({ agentId }, `Archiving agent ${agentId}`)
|
||||
|
||||
const { archivedAt, archivedRecord } = await this.archiveAgentState(agentId)
|
||||
const { archivedAt } = await this.archiveAgentState(agentId)
|
||||
|
||||
this.emit({
|
||||
type: 'agent_archived',
|
||||
@@ -1884,12 +1791,6 @@ export class Session {
|
||||
requestId,
|
||||
},
|
||||
})
|
||||
|
||||
await this.maybeArchiveWorktreeAfterLastAgentArchived({
|
||||
archivedAgentId: agentId,
|
||||
archivedAgentCwd: archivedRecord.cwd,
|
||||
requestId,
|
||||
})
|
||||
}
|
||||
|
||||
private async archiveAgentState(agentId: string): Promise<{
|
||||
@@ -2626,6 +2527,7 @@ export class Session {
|
||||
worktreeName,
|
||||
labels
|
||||
)
|
||||
await this.ensureWorkspaceRegistered(sessionConfig.cwd)
|
||||
const snapshot = await this.agentManager.createAgent(sessionConfig, undefined, { labels })
|
||||
await this.forwardAgentUpdate(snapshot)
|
||||
|
||||
@@ -4570,74 +4472,6 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async maybeArchiveWorktreeAfterLastAgentArchived(options: {
|
||||
archivedAgentId: string
|
||||
archivedAgentCwd: string
|
||||
requestId: string
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(options.archivedAgentCwd, {
|
||||
paseoHome: this.paseoHome,
|
||||
})
|
||||
if (!ownership.allowed) {
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(options.archivedAgentCwd, {
|
||||
paseoHome: this.paseoHome,
|
||||
})
|
||||
if (!resolvedWorktree) {
|
||||
return
|
||||
}
|
||||
|
||||
const records = await this.agentStorage.list()
|
||||
const recordsById = new Map(records.map((record) => [record.id, record]))
|
||||
const targetPath = resolvedWorktree.worktreePath
|
||||
const hasRemainingNonArchivedRecord = records.some((record) => {
|
||||
if (record.id === options.archivedAgentId || record.archivedAt) {
|
||||
return false
|
||||
}
|
||||
return this.isPathWithinRoot(targetPath, record.cwd)
|
||||
})
|
||||
if (hasRemainingNonArchivedRecord) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasUnknownLiveAgent = this.agentManager.listAgents().some((agent) => {
|
||||
if (agent.id === options.archivedAgentId) {
|
||||
return false
|
||||
}
|
||||
if (!this.isPathWithinRoot(targetPath, agent.cwd)) {
|
||||
return false
|
||||
}
|
||||
return !recordsById.has(agent.id)
|
||||
})
|
||||
if (hasUnknownLiveAgent) {
|
||||
return
|
||||
}
|
||||
|
||||
const repoRoot = ownership.repoRoot
|
||||
if (!repoRoot) {
|
||||
this.sessionLogger.warn(
|
||||
{ agentId: options.archivedAgentId, worktreePath: targetPath },
|
||||
'Unable to resolve repo root for auto-archive after agent archive'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
await this.archivePaseoWorktree({
|
||||
targetPath,
|
||||
repoRoot,
|
||||
requestId: options.requestId,
|
||||
})
|
||||
} catch (error: any) {
|
||||
this.sessionLogger.warn(
|
||||
{ err: error, agentId: options.archivedAgentId, cwd: options.archivedAgentCwd },
|
||||
'Failed to auto-archive worktree after agent archive'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async archivePaseoWorktree(options: {
|
||||
targetPath: string
|
||||
repoRoot: string
|
||||
@@ -4653,11 +4487,13 @@ export class Session {
|
||||
|
||||
const removedAgents = new Set<string>()
|
||||
const affectedWorkspaceCwds = new Set<string>([targetPath])
|
||||
const affectedWorkspaceIds = new Set<string>([normalizePersistedWorkspaceId(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)
|
||||
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(agent.cwd))
|
||||
try {
|
||||
await this.agentManager.closeAgent(agent.id)
|
||||
} catch {
|
||||
@@ -4676,6 +4512,7 @@ export class Session {
|
||||
if (this.isPathWithinRoot(targetPath, record.cwd)) {
|
||||
removedAgents.add(record.id)
|
||||
affectedWorkspaceCwds.add(record.cwd)
|
||||
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(record.cwd))
|
||||
try {
|
||||
await this.agentStorage.remove(record.id)
|
||||
} catch {
|
||||
@@ -4692,6 +4529,10 @@ export class Session {
|
||||
paseoHome: this.paseoHome,
|
||||
})
|
||||
|
||||
for (const workspaceId of affectedWorkspaceIds) {
|
||||
await this.archiveWorkspaceRecord(workspaceId)
|
||||
}
|
||||
|
||||
for (const agentId of removedAgents) {
|
||||
this.emit({
|
||||
type: 'agent_deleted',
|
||||
@@ -5364,14 +5205,6 @@ export class Session {
|
||||
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') {
|
||||
@@ -5389,23 +5222,6 @@ export class Session {
|
||||
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
|
||||
@@ -5425,20 +5241,55 @@ export class Session {
|
||||
return current
|
||||
}
|
||||
|
||||
private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
|
||||
const agents = await this.listAgentPayloads()
|
||||
private async describeWorkspaceRecord(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
projectRecord?: PersistedProjectRecord | null
|
||||
): Promise<WorkspaceDescriptorPayload> {
|
||||
const resolvedProjectRecord = projectRecord ?? (await this.projectRegistry.get(workspace.projectId))
|
||||
let displayName = workspace.displayName
|
||||
try {
|
||||
const placement = await this.buildProjectPlacement(workspace.cwd)
|
||||
displayName = deriveWorkspaceDisplayName({
|
||||
cwd: workspace.cwd,
|
||||
checkout: placement.checkout,
|
||||
})
|
||||
} catch {
|
||||
// Fall back to the persisted label if checkout metadata is unavailable.
|
||||
}
|
||||
|
||||
return {
|
||||
id: workspace.workspaceId,
|
||||
projectId: workspace.projectId,
|
||||
projectDisplayName: resolvedProjectRecord?.displayName ?? workspace.projectId,
|
||||
projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd,
|
||||
projectKind: resolvedProjectRecord?.kind ?? 'non_git',
|
||||
workspaceKind: workspace.kind,
|
||||
name: displayName,
|
||||
status: 'done',
|
||||
activityAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
|
||||
const [agents, persistedWorkspaces, persistedProjects] = await Promise.all([
|
||||
this.listAgentPayloads(),
|
||||
this.workspaceRegistry.list(),
|
||||
this.projectRegistry.list(),
|
||||
])
|
||||
|
||||
const activeRecords = persistedWorkspaces.filter((workspace) => !workspace.archivedAt)
|
||||
const activeProjects = new Map(
|
||||
persistedProjects
|
||||
.filter((project) => !project.archivedAt)
|
||||
.map((project) => [project.projectId, project] as const)
|
||||
)
|
||||
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 workspace of activeRecords) {
|
||||
descriptorsByWorkspaceId.set(
|
||||
workspace.workspaceId,
|
||||
await this.describeWorkspaceRecord(workspace, activeProjects.get(workspace.projectId) ?? null)
|
||||
)
|
||||
}
|
||||
|
||||
for (const agent of agents) {
|
||||
@@ -5446,21 +5297,9 @@ export class Session {
|
||||
continue
|
||||
}
|
||||
|
||||
const workspaceId = this.normalizeWorkspaceId(agent.cwd)
|
||||
const placement = await getPlacement(workspaceId)
|
||||
const workspaceId = normalizePersistedWorkspaceId(agent.cwd)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -5751,13 +5590,71 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureWorkspaceRegistered(cwd: string): Promise<PersistedWorkspaceRecord> {
|
||||
const workspaceId = normalizePersistedWorkspaceId(cwd)
|
||||
const existing = await this.workspaceRegistry.get(workspaceId)
|
||||
if (existing && !existing.archivedAt) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const placement = await this.buildProjectPlacement(workspaceId)
|
||||
const now = new Date().toISOString()
|
||||
const projectExisting = await this.projectRegistry.get(placement.projectKey)
|
||||
const projectRecord: PersistedProjectRecord = createPersistedProjectRecord({
|
||||
projectId: placement.projectKey,
|
||||
rootPath: deriveProjectRootPath({
|
||||
cwd: workspaceId,
|
||||
checkout: placement.checkout,
|
||||
}),
|
||||
kind: deriveProjectKind(placement.checkout),
|
||||
displayName: placement.projectName,
|
||||
createdAt: projectExisting?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
archivedAt: null,
|
||||
})
|
||||
await this.projectRegistry.upsert(projectRecord)
|
||||
|
||||
const workspaceRecord = createPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
projectId: placement.projectKey,
|
||||
cwd: workspaceId,
|
||||
kind: deriveWorkspaceKind(placement.checkout),
|
||||
displayName: deriveWorkspaceDisplayName({
|
||||
cwd: workspaceId,
|
||||
checkout: placement.checkout,
|
||||
}),
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
archivedAt: null,
|
||||
})
|
||||
await this.workspaceRegistry.upsert(workspaceRecord)
|
||||
return workspaceRecord
|
||||
}
|
||||
|
||||
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
|
||||
const existing = await this.workspaceRegistry.get(workspaceId)
|
||||
if (!existing || existing.archivedAt) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextArchivedAt = archivedAt ?? new Date().toISOString()
|
||||
await this.workspaceRegistry.archive(workspaceId, nextArchivedAt)
|
||||
|
||||
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||||
(workspace) => workspace.projectId === existing.projectId && !workspace.archivedAt
|
||||
)
|
||||
if (siblingWorkspaces.length === 0) {
|
||||
await this.projectRegistry.archive(existing.projectId, nextArchivedAt)
|
||||
}
|
||||
}
|
||||
|
||||
private async emitWorkspaceUpdateForCwd(cwd: string): Promise<void> {
|
||||
const subscription = this.workspaceUpdatesSubscription
|
||||
if (!subscription) {
|
||||
return
|
||||
}
|
||||
|
||||
const workspaceId = this.normalizeWorkspaceId(cwd)
|
||||
const workspaceId = normalizePersistedWorkspaceId(cwd)
|
||||
const all = await this.listWorkspaceDescriptors()
|
||||
const workspace = all.find((entry) => entry.id === workspaceId)
|
||||
if (!workspace) {
|
||||
@@ -5789,7 +5686,7 @@ export class Session {
|
||||
|
||||
const uniqueWorkspaceCwds = new Set<string>()
|
||||
for (const cwd of cwds) {
|
||||
const normalized = this.normalizeWorkspaceId(cwd)
|
||||
const normalized = normalizePersistedWorkspaceId(cwd)
|
||||
if (!normalized) {
|
||||
continue
|
||||
}
|
||||
@@ -5923,6 +5820,76 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleOpenProjectRequest(
|
||||
request: Extract<SessionInboundMessage, { type: 'open_project_request' }>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const workspace = await this.ensureWorkspaceRegistered(request.cwd)
|
||||
await this.emitWorkspaceUpdateForCwd(workspace.cwd)
|
||||
const descriptor = await this.describeWorkspaceRecord(workspace)
|
||||
this.emit({
|
||||
type: 'open_project_response',
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspace: descriptor,
|
||||
error: null,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to open project'
|
||||
this.sessionLogger.error({ err: error, cwd: request.cwd }, 'Failed to open project')
|
||||
this.emit({
|
||||
type: 'open_project_response',
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspace: null,
|
||||
error: message,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async handleArchiveWorkspaceRequest(
|
||||
request: Extract<SessionInboundMessage, { type: 'archive_workspace_request' }>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const existing = await this.workspaceRegistry.get(request.workspaceId)
|
||||
if (!existing) {
|
||||
throw new Error(`Workspace not found: ${request.workspaceId}`)
|
||||
}
|
||||
if (existing.kind === 'worktree') {
|
||||
throw new Error('Use worktree archive for Paseo worktrees')
|
||||
}
|
||||
const archivedAt = new Date().toISOString()
|
||||
await this.archiveWorkspaceRecord(request.workspaceId, archivedAt)
|
||||
await this.emitWorkspaceUpdateForCwd(existing.cwd)
|
||||
this.emit({
|
||||
type: 'archive_workspace_response',
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspaceId: request.workspaceId,
|
||||
archivedAt,
|
||||
error: null,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to archive workspace'
|
||||
this.sessionLogger.error(
|
||||
{ err: error, workspaceId: request.workspaceId },
|
||||
'Failed to archive workspace'
|
||||
)
|
||||
this.emit({
|
||||
type: 'archive_workspace_response',
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspaceId: request.workspaceId,
|
||||
archivedAt: null,
|
||||
error: message,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async handleFetchAgent(agentIdOrIdentifier: string, requestId: string): Promise<void> {
|
||||
const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier)
|
||||
if (!resolved.ok) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { Session } from './session.js'
|
||||
import type { AgentSnapshotPayload } from '../shared/messages.js'
|
||||
import { createPersistedProjectRecord, createPersistedWorkspaceRecord } from './workspace-registry.js'
|
||||
|
||||
function makeAgent(input: {
|
||||
id: string
|
||||
@@ -79,6 +80,24 @@ function createSessionForWorkspaceTests(): Session {
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
} as any,
|
||||
projectRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
} as any,
|
||||
workspaceRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
} as any,
|
||||
createAgentMcpTransport: async () => {
|
||||
throw new Error('not used')
|
||||
},
|
||||
@@ -91,6 +110,17 @@ function createSessionForWorkspaceTests(): Session {
|
||||
describe('workspace aggregation', () => {
|
||||
test('non-git workspace uses deterministic directory name and no unknown branch fallback', async () => {
|
||||
const session = createSessionForWorkspaceTests() as any
|
||||
session.workspaceRegistry.list = async () => [
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: '/tmp/non-git',
|
||||
projectId: '/tmp/non-git',
|
||||
cwd: '/tmp/non-git',
|
||||
kind: 'directory',
|
||||
displayName: 'non-git',
|
||||
createdAt: '2026-03-01T12:00:00.000Z',
|
||||
updatedAt: '2026-03-01T12:00:00.000Z',
|
||||
}),
|
||||
]
|
||||
session.listAgentPayloads = async () => [
|
||||
makeAgent({
|
||||
id: 'a1',
|
||||
@@ -99,19 +129,6 @@ describe('workspace aggregation', () => {
|
||||
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',
|
||||
@@ -124,6 +141,17 @@ describe('workspace aggregation', () => {
|
||||
|
||||
test('git branch workspace uses branch as canonical name', async () => {
|
||||
const session = createSessionForWorkspaceTests() as any
|
||||
session.workspaceRegistry.list = async () => [
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: '/tmp/repo-branch',
|
||||
projectId: '/tmp/repo-branch',
|
||||
cwd: '/tmp/repo-branch',
|
||||
kind: 'local_checkout',
|
||||
displayName: 'feature/name-from-server',
|
||||
createdAt: '2026-03-01T12:00:00.000Z',
|
||||
updatedAt: '2026-03-01T12:00:00.000Z',
|
||||
}),
|
||||
]
|
||||
session.listAgentPayloads = async () => [
|
||||
makeAgent({
|
||||
id: 'a1',
|
||||
@@ -144,7 +172,6 @@ describe('workspace aggregation', () => {
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await session.listFetchWorkspacesEntries({
|
||||
type: 'fetch_workspaces_request',
|
||||
requestId: 'req-branch',
|
||||
@@ -156,6 +183,17 @@ describe('workspace aggregation', () => {
|
||||
|
||||
test('branch/detached policies and dominant status bucket are deterministic', async () => {
|
||||
const session = createSessionForWorkspaceTests() as any
|
||||
session.workspaceRegistry.list = async () => [
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: '/tmp/repo',
|
||||
projectId: '/tmp/repo',
|
||||
cwd: '/tmp/repo',
|
||||
kind: 'local_checkout',
|
||||
displayName: 'repo',
|
||||
createdAt: '2026-03-01T12:00:00.000Z',
|
||||
updatedAt: '2026-03-01T12:00:00.000Z',
|
||||
}),
|
||||
]
|
||||
session.listAgentPayloads = async () => [
|
||||
makeAgent({
|
||||
id: 'a1',
|
||||
@@ -177,19 +215,6 @@ describe('workspace aggregation', () => {
|
||||
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',
|
||||
@@ -200,7 +225,7 @@ describe('workspace aggregation', () => {
|
||||
expect(result.entries[0]?.status).toBe('needs_input')
|
||||
})
|
||||
|
||||
test('workspace update stream emits upsert and remove on lifecycle changes', async () => {
|
||||
test('workspace update stream keeps persisted workspace visible after agents stop', async () => {
|
||||
const emitted: Array<{ type: string; payload: unknown }> = []
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
@@ -227,6 +252,24 @@ describe('workspace aggregation', () => {
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
} as any,
|
||||
projectRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
} as any,
|
||||
workspaceRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
} as any,
|
||||
createAgentMcpTransport: async () => {
|
||||
throw new Error('not used')
|
||||
},
|
||||
@@ -246,6 +289,10 @@ describe('workspace aggregation', () => {
|
||||
{
|
||||
id: '/tmp/repo',
|
||||
projectId: '/tmp/repo',
|
||||
projectDisplayName: 'repo',
|
||||
projectRootPath: '/tmp/repo',
|
||||
projectKind: 'non_git',
|
||||
workspaceKind: 'directory',
|
||||
name: 'repo',
|
||||
status: 'running',
|
||||
activityAt: '2026-03-01T12:00:00.000Z',
|
||||
@@ -253,15 +300,37 @@ describe('workspace aggregation', () => {
|
||||
]
|
||||
await session.emitWorkspaceUpdateForCwd('/tmp/repo')
|
||||
|
||||
session.listWorkspaceDescriptors = async () => []
|
||||
session.listWorkspaceDescriptors = async () => [
|
||||
{
|
||||
id: '/tmp/repo',
|
||||
projectId: '/tmp/repo',
|
||||
projectDisplayName: 'repo',
|
||||
projectRootPath: '/tmp/repo',
|
||||
projectKind: 'non_git',
|
||||
workspaceKind: 'directory',
|
||||
name: 'repo',
|
||||
status: 'done',
|
||||
activityAt: null,
|
||||
},
|
||||
]
|
||||
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',
|
||||
kind: 'upsert',
|
||||
workspace: {
|
||||
id: '/tmp/repo',
|
||||
projectId: '/tmp/repo',
|
||||
projectDisplayName: 'repo',
|
||||
projectRootPath: '/tmp/repo',
|
||||
projectKind: 'non_git',
|
||||
workspaceKind: 'directory',
|
||||
name: 'repo',
|
||||
status: 'done',
|
||||
activityAt: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -289,4 +358,80 @@ describe('workspace aggregation', () => {
|
||||
expect(emitWorkspaceUpdateForCwd).toHaveBeenNthCalledWith(1, '/tmp/repo')
|
||||
expect(emitWorkspaceUpdateForCwd).toHaveBeenNthCalledWith(2, '/tmp/repo/sub')
|
||||
})
|
||||
|
||||
test('open_project_request registers a workspace before any agent exists', async () => {
|
||||
const emitted: Array<{ type: string; payload: unknown }> = []
|
||||
const session = createSessionForWorkspaceTests() as any
|
||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>()
|
||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>()
|
||||
|
||||
session.emit = (message: any) => emitted.push(message)
|
||||
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null
|
||||
session.projectRegistry.upsert = async (record: ReturnType<typeof createPersistedProjectRecord>) => {
|
||||
projects.set(record.projectId, record)
|
||||
}
|
||||
session.workspaceRegistry.get = async (workspaceId: string) => workspaces.get(workspaceId) ?? null
|
||||
session.workspaceRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedWorkspaceRecord>
|
||||
) => {
|
||||
workspaces.set(record.workspaceId, record)
|
||||
}
|
||||
session.projectRegistry.list = async () => Array.from(projects.values())
|
||||
session.workspaceRegistry.list = async () => Array.from(workspaces.values())
|
||||
session.buildProjectPlacement = async (cwd: string) => ({
|
||||
projectKey: cwd,
|
||||
projectName: 'repo',
|
||||
checkout: {
|
||||
cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
})
|
||||
|
||||
await session.handleMessage({
|
||||
type: 'open_project_request',
|
||||
cwd: '/tmp/repo',
|
||||
requestId: 'req-open',
|
||||
})
|
||||
|
||||
expect(workspaces.get('/tmp/repo')).toBeTruthy()
|
||||
const response = emitted.find((message) => message.type === 'open_project_response') as any
|
||||
expect(response?.payload.error).toBeNull()
|
||||
expect(response?.payload.workspace?.id).toBe('/tmp/repo')
|
||||
})
|
||||
|
||||
test('archive_workspace_request hides non-destructive workspace records', async () => {
|
||||
const emitted: Array<{ type: string; payload: unknown }> = []
|
||||
const session = createSessionForWorkspaceTests() as any
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: '/tmp/repo',
|
||||
projectId: '/tmp/repo',
|
||||
cwd: '/tmp/repo',
|
||||
kind: 'directory',
|
||||
displayName: 'repo',
|
||||
createdAt: '2026-03-01T12:00:00.000Z',
|
||||
updatedAt: '2026-03-01T12:00:00.000Z',
|
||||
})
|
||||
|
||||
session.emit = (message: any) => emitted.push(message)
|
||||
session.workspaceRegistry.get = async () => workspace
|
||||
session.workspaceRegistry.archive = async (_workspaceId: string, archivedAt: string) => {
|
||||
workspace.archivedAt = archivedAt
|
||||
}
|
||||
session.workspaceRegistry.list = async () => [workspace]
|
||||
session.projectRegistry.archive = async () => {}
|
||||
|
||||
await session.handleMessage({
|
||||
type: 'archive_workspace_request',
|
||||
workspaceId: '/tmp/repo',
|
||||
requestId: 'req-archive',
|
||||
})
|
||||
|
||||
expect(workspace.archivedAt).toBeTruthy()
|
||||
const response = emitted.find((message) => message.type === 'archive_workspace_response') as any
|
||||
expect(response?.payload.error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { AgentStorage } from "./agent/agent-storage.js";
|
||||
import type { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type pino from "pino";
|
||||
import type { ProjectRegistry, WorkspaceRegistry } from "./workspace-registry.js";
|
||||
import {
|
||||
type ServerInfoStatusPayload,
|
||||
type WSHelloMessage,
|
||||
@@ -70,6 +71,30 @@ type WebSocketServerConfig = {
|
||||
allowedHosts?: AllowedHostsConfig;
|
||||
};
|
||||
|
||||
function createNoopProjectRegistry(): ProjectRegistry {
|
||||
return {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function createNoopWorkspaceRegistry(): WorkspaceRegistry {
|
||||
return {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function toServerCapabilityState(
|
||||
params: {
|
||||
state: SpeechReadinessSnapshot["dictation"];
|
||||
@@ -212,6 +237,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly daemonVersion: string;
|
||||
private readonly agentManager: AgentManager;
|
||||
private readonly agentStorage: AgentStorage;
|
||||
private readonly projectRegistry: ProjectRegistry;
|
||||
private readonly workspaceRegistry: WorkspaceRegistry;
|
||||
private readonly downloadTokenStore: DownloadTokenStore;
|
||||
private readonly paseoHome: string;
|
||||
private readonly pushTokenStore: PushTokenStore;
|
||||
@@ -295,7 +322,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
},
|
||||
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap,
|
||||
daemonVersion?: string,
|
||||
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void
|
||||
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void,
|
||||
projectRegistry?: ProjectRegistry,
|
||||
workspaceRegistry?: WorkspaceRegistry
|
||||
) {
|
||||
this.logger = logger.child({ module: "websocket-server" });
|
||||
this.serverId = serverId;
|
||||
@@ -305,6 +334,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.daemonVersion = daemonVersion.trim();
|
||||
this.agentManager = agentManager;
|
||||
this.agentStorage = agentStorage;
|
||||
this.projectRegistry = projectRegistry ?? createNoopProjectRegistry();
|
||||
this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry();
|
||||
this.downloadTokenStore = downloadTokenStore;
|
||||
this.paseoHome = paseoHome;
|
||||
this.createAgentMcpTransport = createAgentMcpTransport;
|
||||
@@ -597,6 +628,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
paseoHome: this.paseoHome,
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
projectRegistry: this.projectRegistry,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
createAgentMcpTransport: this.createAgentMcpTransport,
|
||||
stt: this.stt,
|
||||
tts: this.tts,
|
||||
|
||||
167
packages/server/src/server/workspace-registry-bootstrap.test.ts
Normal file
167
packages/server/src/server/workspace-registry-bootstrap.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest'
|
||||
|
||||
import { createTestLogger } from '../test-utils/test-logger.js'
|
||||
import { AgentStorage } from './agent/agent-storage.js'
|
||||
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from './workspace-registry.js'
|
||||
import { bootstrapWorkspaceRegistries } from './workspace-registry-bootstrap.js'
|
||||
|
||||
describe('bootstrapWorkspaceRegistries', () => {
|
||||
let tmpDir: string
|
||||
let paseoHome: string
|
||||
let agentStorage: AgentStorage
|
||||
let projectRegistry: FileBackedProjectRegistry
|
||||
let workspaceRegistry: FileBackedWorkspaceRegistry
|
||||
const logger = createTestLogger()
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(path.join(os.tmpdir(), 'workspace-bootstrap-'))
|
||||
paseoHome = path.join(tmpDir, '.paseo')
|
||||
agentStorage = new AgentStorage(path.join(paseoHome, 'agents'), logger)
|
||||
projectRegistry = new FileBackedProjectRegistry(
|
||||
path.join(paseoHome, 'projects', 'projects.json'),
|
||||
logger
|
||||
)
|
||||
workspaceRegistry = new FileBackedWorkspaceRegistry(
|
||||
path.join(paseoHome, 'projects', 'workspaces.json'),
|
||||
logger
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('materializes workspace registries from non-archived agent records', async () => {
|
||||
await agentStorage.initialize()
|
||||
await agentStorage.upsert({
|
||||
id: 'agent-1',
|
||||
provider: 'codex',
|
||||
cwd: '/tmp/non-git-project',
|
||||
createdAt: '2026-03-01T00:00:00.000Z',
|
||||
updatedAt: '2026-03-02T00:00:00.000Z',
|
||||
lastActivityAt: '2026-03-02T00:00:00.000Z',
|
||||
lastUserMessageAt: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
lastStatus: 'idle',
|
||||
lastModeId: null,
|
||||
config: null,
|
||||
runtimeInfo: { provider: 'codex', sessionId: null },
|
||||
persistence: null,
|
||||
archivedAt: null,
|
||||
})
|
||||
await agentStorage.upsert({
|
||||
id: 'agent-2',
|
||||
provider: 'codex',
|
||||
cwd: '/tmp/non-git-project',
|
||||
createdAt: '2026-03-01T01:00:00.000Z',
|
||||
updatedAt: '2026-03-03T00:00:00.000Z',
|
||||
lastActivityAt: '2026-03-03T00:00:00.000Z',
|
||||
lastUserMessageAt: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
lastStatus: 'running',
|
||||
lastModeId: null,
|
||||
config: null,
|
||||
runtimeInfo: { provider: 'codex', sessionId: null },
|
||||
persistence: null,
|
||||
archivedAt: null,
|
||||
})
|
||||
await agentStorage.upsert({
|
||||
id: 'agent-archived',
|
||||
provider: 'codex',
|
||||
cwd: '/tmp/archived-project',
|
||||
createdAt: '2026-03-01T00:00:00.000Z',
|
||||
updatedAt: '2026-03-01T00:00:00.000Z',
|
||||
lastActivityAt: '2026-03-01T00:00:00.000Z',
|
||||
lastUserMessageAt: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
lastStatus: 'idle',
|
||||
lastModeId: null,
|
||||
config: null,
|
||||
runtimeInfo: { provider: 'codex', sessionId: null },
|
||||
persistence: null,
|
||||
archivedAt: '2026-03-02T00:00:00.000Z',
|
||||
})
|
||||
|
||||
await bootstrapWorkspaceRegistries({
|
||||
paseoHome,
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger,
|
||||
})
|
||||
|
||||
const workspaces = await workspaceRegistry.list()
|
||||
expect(workspaces).toHaveLength(1)
|
||||
expect(workspaces[0]?.workspaceId).toBe('/tmp/non-git-project')
|
||||
expect(workspaces[0]?.createdAt).toBe('2026-03-01T00:00:00.000Z')
|
||||
expect(workspaces[0]?.updatedAt).toBe('2026-03-03T00:00:00.000Z')
|
||||
|
||||
const projects = await projectRegistry.list()
|
||||
expect(projects).toHaveLength(1)
|
||||
expect(projects[0]?.projectId).toBe('/tmp/non-git-project')
|
||||
expect(projects[0]?.createdAt).toBe('2026-03-01T00:00:00.000Z')
|
||||
expect(projects[0]?.updatedAt).toBe('2026-03-03T00:00:00.000Z')
|
||||
})
|
||||
|
||||
test('does not rematerialize when registry files already exist', async () => {
|
||||
await projectRegistry.initialize()
|
||||
await workspaceRegistry.initialize()
|
||||
await projectRegistry.upsert({
|
||||
projectId: '/tmp/existing',
|
||||
rootPath: '/tmp/existing',
|
||||
kind: 'non_git',
|
||||
displayName: 'existing',
|
||||
createdAt: '2026-03-01T00:00:00.000Z',
|
||||
updatedAt: '2026-03-01T00:00:00.000Z',
|
||||
archivedAt: null,
|
||||
})
|
||||
await workspaceRegistry.upsert({
|
||||
workspaceId: '/tmp/existing',
|
||||
projectId: '/tmp/existing',
|
||||
cwd: '/tmp/existing',
|
||||
kind: 'directory',
|
||||
displayName: 'existing',
|
||||
createdAt: '2026-03-01T00:00:00.000Z',
|
||||
updatedAt: '2026-03-01T00:00:00.000Z',
|
||||
archivedAt: null,
|
||||
})
|
||||
|
||||
await agentStorage.initialize()
|
||||
await agentStorage.upsert({
|
||||
id: 'agent-1',
|
||||
provider: 'codex',
|
||||
cwd: '/tmp/another-project',
|
||||
createdAt: '2026-03-02T00:00:00.000Z',
|
||||
updatedAt: '2026-03-02T00:00:00.000Z',
|
||||
lastActivityAt: '2026-03-02T00:00:00.000Z',
|
||||
lastUserMessageAt: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
lastStatus: 'idle',
|
||||
lastModeId: null,
|
||||
config: null,
|
||||
runtimeInfo: { provider: 'codex', sessionId: null },
|
||||
persistence: null,
|
||||
archivedAt: null,
|
||||
})
|
||||
|
||||
await bootstrapWorkspaceRegistries({
|
||||
paseoHome,
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger,
|
||||
})
|
||||
|
||||
expect(await projectRegistry.list()).toHaveLength(1)
|
||||
expect(await workspaceRegistry.list()).toHaveLength(1)
|
||||
expect((await workspaceRegistry.list())[0]?.workspaceId).toBe('/tmp/existing')
|
||||
})
|
||||
})
|
||||
142
packages/server/src/server/workspace-registry-bootstrap.ts
Normal file
142
packages/server/src/server/workspace-registry-bootstrap.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import type { Logger } from 'pino'
|
||||
|
||||
import type { StoredAgentRecord } from './agent/agent-storage.js'
|
||||
import type { AgentStorage } from './agent/agent-storage.js'
|
||||
import {
|
||||
buildProjectPlacementForCwd,
|
||||
deriveProjectKind,
|
||||
deriveProjectRootPath,
|
||||
deriveWorkspaceDisplayName,
|
||||
deriveWorkspaceKind,
|
||||
normalizeWorkspaceId,
|
||||
} from './workspace-registry-model.js'
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
type ProjectRegistry,
|
||||
type WorkspaceRegistry,
|
||||
} from './workspace-registry.js'
|
||||
|
||||
function minIsoDate(left: string | null, right: string | null): string | null {
|
||||
if (!left) {
|
||||
return right
|
||||
}
|
||||
if (!right) {
|
||||
return left
|
||||
}
|
||||
return Date.parse(left) <= Date.parse(right) ? left : right
|
||||
}
|
||||
|
||||
function maxIsoDate(left: string | null, right: string | null): string | null {
|
||||
if (!left) {
|
||||
return right
|
||||
}
|
||||
if (!right) {
|
||||
return left
|
||||
}
|
||||
return Date.parse(left) >= Date.parse(right) ? left : right
|
||||
}
|
||||
|
||||
function resolveAgentCreatedAt(record: StoredAgentRecord): string {
|
||||
return record.createdAt || record.updatedAt || new Date(0).toISOString()
|
||||
}
|
||||
|
||||
function resolveAgentUpdatedAt(record: StoredAgentRecord): string {
|
||||
return record.lastActivityAt || record.updatedAt || record.createdAt || new Date(0).toISOString()
|
||||
}
|
||||
|
||||
export async function bootstrapWorkspaceRegistries(options: {
|
||||
paseoHome: string
|
||||
agentStorage: AgentStorage
|
||||
projectRegistry: ProjectRegistry
|
||||
workspaceRegistry: WorkspaceRegistry
|
||||
logger: Logger
|
||||
}): Promise<void> {
|
||||
const [projectsExists, workspacesExists] = await Promise.all([
|
||||
options.projectRegistry.existsOnDisk(),
|
||||
options.workspaceRegistry.existsOnDisk(),
|
||||
])
|
||||
|
||||
await Promise.all([options.projectRegistry.initialize(), options.workspaceRegistry.initialize()])
|
||||
|
||||
if (projectsExists && workspacesExists) {
|
||||
return
|
||||
}
|
||||
|
||||
const records = await options.agentStorage.list()
|
||||
const activeRecords = records.filter((record) => !record.archivedAt)
|
||||
const recordsByWorkspaceId = new Map<string, StoredAgentRecord[]>()
|
||||
for (const record of activeRecords) {
|
||||
const workspaceId = normalizeWorkspaceId(record.cwd)
|
||||
const existing = recordsByWorkspaceId.get(workspaceId) ?? []
|
||||
existing.push(record)
|
||||
recordsByWorkspaceId.set(workspaceId, existing)
|
||||
}
|
||||
|
||||
const projectRanges = new Map<string, { createdAt: string | null; updatedAt: string | null }>()
|
||||
|
||||
for (const [workspaceId, workspaceRecords] of recordsByWorkspaceId.entries()) {
|
||||
const placement = await buildProjectPlacementForCwd({
|
||||
cwd: workspaceId,
|
||||
paseoHome: options.paseoHome,
|
||||
})
|
||||
|
||||
let workspaceCreatedAt: string | null = null
|
||||
let workspaceUpdatedAt: string | null = null
|
||||
for (const record of workspaceRecords) {
|
||||
workspaceCreatedAt = minIsoDate(workspaceCreatedAt, resolveAgentCreatedAt(record))
|
||||
workspaceUpdatedAt = maxIsoDate(workspaceUpdatedAt, resolveAgentUpdatedAt(record))
|
||||
}
|
||||
|
||||
const createdAt = workspaceCreatedAt ?? new Date().toISOString()
|
||||
const updatedAt = workspaceUpdatedAt ?? createdAt
|
||||
await options.workspaceRegistry.upsert(
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
projectId: placement.projectKey,
|
||||
cwd: workspaceId,
|
||||
kind: deriveWorkspaceKind(placement.checkout),
|
||||
displayName: deriveWorkspaceDisplayName({
|
||||
cwd: workspaceId,
|
||||
checkout: placement.checkout,
|
||||
}),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
})
|
||||
)
|
||||
|
||||
const existingProjectRange = projectRanges.get(placement.projectKey) ?? {
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
}
|
||||
existingProjectRange.createdAt = minIsoDate(existingProjectRange.createdAt, createdAt)
|
||||
existingProjectRange.updatedAt = maxIsoDate(existingProjectRange.updatedAt, updatedAt)
|
||||
projectRanges.set(placement.projectKey, existingProjectRange)
|
||||
|
||||
await options.projectRegistry.upsert(
|
||||
createPersistedProjectRecord({
|
||||
projectId: placement.projectKey,
|
||||
rootPath: deriveProjectRootPath({
|
||||
cwd: workspaceId,
|
||||
checkout: placement.checkout,
|
||||
}),
|
||||
kind: deriveProjectKind(placement.checkout),
|
||||
displayName: placement.projectName,
|
||||
createdAt: existingProjectRange.createdAt ?? createdAt,
|
||||
updatedAt: existingProjectRange.updatedAt ?? updatedAt,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
options.logger.info(
|
||||
{
|
||||
projectsFile: path.join(options.paseoHome, 'projects', 'projects.json'),
|
||||
workspacesFile: path.join(options.paseoHome, 'projects', 'workspaces.json'),
|
||||
materializedProjects: projectRanges.size,
|
||||
materializedWorkspaces: recordsByWorkspaceId.size,
|
||||
},
|
||||
'Workspace registries bootstrapped from existing agent storage'
|
||||
)
|
||||
}
|
||||
192
packages/server/src/server/workspace-registry-model.ts
Normal file
192
packages/server/src/server/workspace-registry-model.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
import { getCheckoutStatusLite } from '../utils/checkout-git.js'
|
||||
import type { ProjectCheckoutLitePayload, ProjectPlacementPayload } from '../shared/messages.js'
|
||||
|
||||
export type PersistedProjectKind = 'git' | 'non_git'
|
||||
export type PersistedWorkspaceKind = 'local_checkout' | 'worktree' | 'directory'
|
||||
|
||||
export function normalizeWorkspaceId(cwd: string): string {
|
||||
const trimmed = cwd.trim()
|
||||
if (!trimmed) {
|
||||
return cwd
|
||||
}
|
||||
return resolve(trimmed)
|
||||
}
|
||||
|
||||
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
|
||||
if (!remoteUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
const trimmed = remoteUrl.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
let host: string | null = null
|
||||
let remotePath: string | null = null
|
||||
|
||||
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/)
|
||||
if (scpLike) {
|
||||
host = scpLike[1] ?? null
|
||||
remotePath = scpLike[2] ?? null
|
||||
} else if (trimmed.includes('://')) {
|
||||
try {
|
||||
const parsed = new URL(trimmed)
|
||||
host = parsed.hostname || null
|
||||
remotePath = parsed.pathname ? parsed.pathname.replace(/^\/+/, '') : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (!host || !remotePath) {
|
||||
return null
|
||||
}
|
||||
|
||||
let cleanedPath = remotePath.trim().replace(/^\/+/, '').replace(/\/+$/, '')
|
||||
if (cleanedPath.endsWith('.git')) {
|
||||
cleanedPath = cleanedPath.slice(0, -4)
|
||||
}
|
||||
if (!cleanedPath.includes('/')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cleanedHost = host.toLowerCase()
|
||||
if (cleanedHost === 'github.com') {
|
||||
return `remote:github.com/${cleanedPath}`
|
||||
}
|
||||
|
||||
return `remote:${cleanedHost}/${cleanedPath}`
|
||||
}
|
||||
|
||||
export function deriveProjectGroupingKey(options: {
|
||||
cwd: string
|
||||
remoteUrl: string | null
|
||||
isPaseoOwnedWorktree: boolean
|
||||
mainRepoRoot: string | null
|
||||
}): string {
|
||||
const remoteKey = deriveRemoteProjectKey(options.remoteUrl)
|
||||
if (remoteKey) {
|
||||
return remoteKey
|
||||
}
|
||||
|
||||
const mainRepoRoot = options.mainRepoRoot?.trim()
|
||||
if (options.isPaseoOwnedWorktree && mainRepoRoot) {
|
||||
return mainRepoRoot
|
||||
}
|
||||
|
||||
return options.cwd
|
||||
}
|
||||
|
||||
export function deriveProjectGroupingName(projectKey: string): string {
|
||||
const githubRemotePrefix = 'remote:github.com/'
|
||||
if (projectKey.startsWith(githubRemotePrefix)) {
|
||||
return projectKey.slice(githubRemotePrefix.length) || projectKey
|
||||
}
|
||||
|
||||
const segments = projectKey.split(/[\\/]/).filter(Boolean)
|
||||
return segments[segments.length - 1] || projectKey
|
||||
}
|
||||
|
||||
function deriveWorkspaceDirectoryName(cwd: string): string {
|
||||
const normalized = cwd.replace(/\\/g, '/')
|
||||
const segments = normalized.split('/').filter(Boolean)
|
||||
return segments[segments.length - 1] ?? cwd
|
||||
}
|
||||
|
||||
export function deriveWorkspaceDisplayName(input: {
|
||||
cwd: string
|
||||
checkout: ProjectCheckoutLitePayload
|
||||
}): string {
|
||||
const branch = input.checkout.currentBranch?.trim() ?? null
|
||||
if (branch && branch.toUpperCase() !== 'HEAD') {
|
||||
return branch
|
||||
}
|
||||
return deriveWorkspaceDirectoryName(input.cwd)
|
||||
}
|
||||
|
||||
export function deriveProjectRootPath(input: {
|
||||
cwd: string
|
||||
checkout: ProjectCheckoutLitePayload
|
||||
}): string {
|
||||
if (input.checkout.isGit && input.checkout.isPaseoOwnedWorktree) {
|
||||
return input.checkout.mainRepoRoot
|
||||
}
|
||||
return input.cwd
|
||||
}
|
||||
|
||||
export function deriveProjectKind(checkout: ProjectCheckoutLitePayload): PersistedProjectKind {
|
||||
return checkout.isGit ? 'git' : 'non_git'
|
||||
}
|
||||
|
||||
export function deriveWorkspaceKind(checkout: ProjectCheckoutLitePayload): PersistedWorkspaceKind {
|
||||
if (!checkout.isGit) {
|
||||
return 'directory'
|
||||
}
|
||||
return checkout.isPaseoOwnedWorktree ? 'worktree' : 'local_checkout'
|
||||
}
|
||||
|
||||
export async function buildProjectPlacementForCwd(input: {
|
||||
cwd: string
|
||||
paseoHome: string
|
||||
}): Promise<ProjectPlacementPayload> {
|
||||
const normalizedCwd = normalizeWorkspaceId(input.cwd)
|
||||
const checkout = await getCheckoutStatusLite(normalizedCwd, { paseoHome: input.paseoHome })
|
||||
.then((status): ProjectCheckoutLitePayload => {
|
||||
if (!status.isGit) {
|
||||
return {
|
||||
cwd: normalizedCwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (status.isPaseoOwnedWorktree && status.mainRepoRoot) {
|
||||
return {
|
||||
cwd: normalizedCwd,
|
||||
isGit: true,
|
||||
currentBranch: status.currentBranch,
|
||||
remoteUrl: status.remoteUrl,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: status.mainRepoRoot,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cwd: normalizedCwd,
|
||||
isGit: true,
|
||||
currentBranch: status.currentBranch,
|
||||
remoteUrl: status.remoteUrl,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
}
|
||||
})
|
||||
.catch(
|
||||
(): ProjectCheckoutLitePayload => ({
|
||||
cwd: normalizedCwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
})
|
||||
)
|
||||
|
||||
const projectKey = deriveProjectGroupingKey({
|
||||
cwd: normalizedCwd,
|
||||
remoteUrl: checkout.remoteUrl,
|
||||
isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree,
|
||||
mainRepoRoot: checkout.mainRepoRoot,
|
||||
})
|
||||
|
||||
return {
|
||||
projectKey,
|
||||
projectName: deriveProjectGroupingName(projectKey),
|
||||
checkout,
|
||||
}
|
||||
}
|
||||
106
packages/server/src/server/workspace-registry.test.ts
Normal file
106
packages/server/src/server/workspace-registry.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
|
||||
import { beforeEach, afterEach, describe, expect, test } from 'vitest'
|
||||
|
||||
import { createTestLogger } from '../test-utils/test-logger.js'
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
FileBackedProjectRegistry,
|
||||
FileBackedWorkspaceRegistry,
|
||||
} from './workspace-registry.js'
|
||||
|
||||
describe('workspace registries', () => {
|
||||
let tmpDir: string
|
||||
let projectRegistry: FileBackedProjectRegistry
|
||||
let workspaceRegistry: FileBackedWorkspaceRegistry
|
||||
const logger = createTestLogger()
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(path.join(os.tmpdir(), 'workspace-registry-'))
|
||||
projectRegistry = new FileBackedProjectRegistry(
|
||||
path.join(tmpDir, 'projects', 'projects.json'),
|
||||
logger
|
||||
)
|
||||
workspaceRegistry = new FileBackedWorkspaceRegistry(
|
||||
path.join(tmpDir, 'projects', 'workspaces.json'),
|
||||
logger
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('creates, updates, archives, deletes, and lists project records', async () => {
|
||||
await projectRegistry.initialize()
|
||||
await projectRegistry.upsert(
|
||||
createPersistedProjectRecord({
|
||||
projectId: 'remote:github.com/acme/repo',
|
||||
rootPath: '/tmp/repo',
|
||||
kind: 'git',
|
||||
displayName: 'acme/repo',
|
||||
createdAt: '2026-03-01T00:00:00.000Z',
|
||||
updatedAt: '2026-03-01T00:00:00.000Z',
|
||||
})
|
||||
)
|
||||
|
||||
await projectRegistry.upsert(
|
||||
createPersistedProjectRecord({
|
||||
projectId: 'remote:github.com/acme/repo',
|
||||
rootPath: '/tmp/repo',
|
||||
kind: 'git',
|
||||
displayName: 'acme/repo',
|
||||
createdAt: '2026-03-01T00:00:00.000Z',
|
||||
updatedAt: '2026-03-02T00:00:00.000Z',
|
||||
})
|
||||
)
|
||||
await projectRegistry.archive('remote:github.com/acme/repo', '2026-03-03T00:00:00.000Z')
|
||||
|
||||
const archived = await projectRegistry.get('remote:github.com/acme/repo')
|
||||
expect(archived?.archivedAt).toBe('2026-03-03T00:00:00.000Z')
|
||||
expect((await projectRegistry.list())).toHaveLength(1)
|
||||
|
||||
await projectRegistry.remove('remote:github.com/acme/repo')
|
||||
expect(await projectRegistry.get('remote:github.com/acme/repo')).toBeNull()
|
||||
expect(await projectRegistry.list()).toEqual([])
|
||||
})
|
||||
|
||||
test('creates, updates, archives, deletes, and lists workspace records', async () => {
|
||||
await workspaceRegistry.initialize()
|
||||
await workspaceRegistry.upsert(
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: '/tmp/repo',
|
||||
projectId: 'remote:github.com/acme/repo',
|
||||
cwd: '/tmp/repo',
|
||||
kind: 'local_checkout',
|
||||
displayName: 'main',
|
||||
createdAt: '2026-03-01T00:00:00.000Z',
|
||||
updatedAt: '2026-03-01T00:00:00.000Z',
|
||||
})
|
||||
)
|
||||
|
||||
await workspaceRegistry.upsert(
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: '/tmp/repo',
|
||||
projectId: 'remote:github.com/acme/repo',
|
||||
cwd: '/tmp/repo',
|
||||
kind: 'local_checkout',
|
||||
displayName: 'feature/workspace',
|
||||
createdAt: '2026-03-01T00:00:00.000Z',
|
||||
updatedAt: '2026-03-02T00:00:00.000Z',
|
||||
})
|
||||
)
|
||||
await workspaceRegistry.archive('/tmp/repo', '2026-03-03T00:00:00.000Z')
|
||||
|
||||
const archived = await workspaceRegistry.get('/tmp/repo')
|
||||
expect(archived?.displayName).toBe('feature/workspace')
|
||||
expect(archived?.archivedAt).toBe('2026-03-03T00:00:00.000Z')
|
||||
|
||||
await workspaceRegistry.remove('/tmp/repo')
|
||||
expect(await workspaceRegistry.get('/tmp/repo')).toBeNull()
|
||||
expect(await workspaceRegistry.list()).toEqual([])
|
||||
})
|
||||
})
|
||||
221
packages/server/src/server/workspace-registry.ts
Normal file
221
packages/server/src/server/workspace-registry.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import type { Logger } from 'pino'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type {
|
||||
PersistedProjectKind,
|
||||
PersistedWorkspaceKind,
|
||||
} from './workspace-registry-model.js'
|
||||
|
||||
const PersistedProjectRecordSchema = z.object({
|
||||
projectId: z.string(),
|
||||
rootPath: z.string(),
|
||||
kind: z.enum(['git', 'non_git']),
|
||||
displayName: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
archivedAt: z.string().nullable(),
|
||||
})
|
||||
|
||||
const PersistedWorkspaceRecordSchema = z.object({
|
||||
workspaceId: z.string(),
|
||||
projectId: z.string(),
|
||||
cwd: z.string(),
|
||||
kind: z.enum(['local_checkout', 'worktree', 'directory']),
|
||||
displayName: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
archivedAt: z.string().nullable(),
|
||||
})
|
||||
|
||||
export type PersistedProjectRecord = z.infer<typeof PersistedProjectRecordSchema>
|
||||
export type PersistedWorkspaceRecord = z.infer<typeof PersistedWorkspaceRecordSchema>
|
||||
|
||||
export interface ProjectRegistry {
|
||||
initialize(): Promise<void>
|
||||
existsOnDisk(): Promise<boolean>
|
||||
list(): Promise<PersistedProjectRecord[]>
|
||||
get(projectId: string): Promise<PersistedProjectRecord | null>
|
||||
upsert(record: PersistedProjectRecord): Promise<void>
|
||||
archive(projectId: string, archivedAt: string): Promise<void>
|
||||
remove(projectId: string): Promise<void>
|
||||
}
|
||||
|
||||
export interface WorkspaceRegistry {
|
||||
initialize(): Promise<void>
|
||||
existsOnDisk(): Promise<boolean>
|
||||
list(): Promise<PersistedWorkspaceRecord[]>
|
||||
get(workspaceId: string): Promise<PersistedWorkspaceRecord | null>
|
||||
upsert(record: PersistedWorkspaceRecord): Promise<void>
|
||||
archive(workspaceId: string, archivedAt: string): Promise<void>
|
||||
remove(workspaceId: string): Promise<void>
|
||||
}
|
||||
|
||||
type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord
|
||||
|
||||
class FileBackedRegistry<TRecord extends RegistryRecord> {
|
||||
private readonly filePath: string
|
||||
private readonly logger: Logger
|
||||
private readonly schema: z.ZodSchema<TRecord>
|
||||
private readonly getId: (record: TRecord) => string
|
||||
private loaded = false
|
||||
private readonly cache = new Map<string, TRecord>()
|
||||
|
||||
constructor(options: {
|
||||
filePath: string
|
||||
logger: Logger
|
||||
schema: z.ZodSchema<TRecord>
|
||||
getId: (record: TRecord) => string
|
||||
component: string
|
||||
}) {
|
||||
this.filePath = options.filePath
|
||||
this.schema = options.schema
|
||||
this.getId = options.getId
|
||||
this.logger = options.logger.child({ module: 'workspace-registry', component: options.component })
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await this.load()
|
||||
}
|
||||
|
||||
async existsOnDisk(): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(this.filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async list(): Promise<TRecord[]> {
|
||||
await this.load()
|
||||
return Array.from(this.cache.values())
|
||||
}
|
||||
|
||||
async get(id: string): Promise<TRecord | null> {
|
||||
await this.load()
|
||||
return this.cache.get(id) ?? null
|
||||
}
|
||||
|
||||
async upsert(record: TRecord): Promise<void> {
|
||||
await this.load()
|
||||
const parsed = this.schema.parse(record)
|
||||
this.cache.set(this.getId(parsed), parsed)
|
||||
await this.persist()
|
||||
}
|
||||
|
||||
async archive(id: string, archivedAt: string): Promise<void> {
|
||||
await this.load()
|
||||
const existing = this.cache.get(id)
|
||||
if (!existing) {
|
||||
return
|
||||
}
|
||||
const next = this.schema.parse({
|
||||
...existing,
|
||||
updatedAt: archivedAt,
|
||||
archivedAt,
|
||||
})
|
||||
this.cache.set(id, next)
|
||||
await this.persist()
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.load()
|
||||
if (!this.cache.delete(id)) {
|
||||
return
|
||||
}
|
||||
await this.persist()
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
if (this.loaded) {
|
||||
return
|
||||
}
|
||||
|
||||
this.cache.clear()
|
||||
try {
|
||||
const raw = await fs.readFile(this.filePath, 'utf8')
|
||||
const parsed = z.array(this.schema).parse(JSON.parse(raw))
|
||||
for (const record of parsed) {
|
||||
this.cache.set(this.getId(record), record)
|
||||
}
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code !== 'ENOENT') {
|
||||
this.logger.error({ err: error, filePath: this.filePath }, 'Failed to load registry file')
|
||||
}
|
||||
}
|
||||
this.loaded = true
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
const records = Array.from(this.cache.values())
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true })
|
||||
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`
|
||||
await fs.writeFile(tempPath, JSON.stringify(records, null, 2), 'utf8')
|
||||
await fs.rename(tempPath, this.filePath)
|
||||
}
|
||||
}
|
||||
|
||||
export class FileBackedProjectRegistry
|
||||
extends FileBackedRegistry<PersistedProjectRecord>
|
||||
implements ProjectRegistry
|
||||
{
|
||||
constructor(filePath: string, logger: Logger) {
|
||||
super({
|
||||
filePath,
|
||||
logger,
|
||||
schema: PersistedProjectRecordSchema,
|
||||
getId: (record) => record.projectId,
|
||||
component: 'projects',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class FileBackedWorkspaceRegistry
|
||||
extends FileBackedRegistry<PersistedWorkspaceRecord>
|
||||
implements WorkspaceRegistry
|
||||
{
|
||||
constructor(filePath: string, logger: Logger) {
|
||||
super({
|
||||
filePath,
|
||||
logger,
|
||||
schema: PersistedWorkspaceRecordSchema,
|
||||
getId: (record) => record.workspaceId,
|
||||
component: 'workspaces',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function createPersistedProjectRecord(input: {
|
||||
projectId: string
|
||||
rootPath: string
|
||||
kind: PersistedProjectKind
|
||||
displayName: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
archivedAt?: string | null
|
||||
}): PersistedProjectRecord {
|
||||
return PersistedProjectRecordSchema.parse({
|
||||
...input,
|
||||
archivedAt: input.archivedAt ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export function createPersistedWorkspaceRecord(input: {
|
||||
workspaceId: string
|
||||
projectId: string
|
||||
cwd: string
|
||||
kind: PersistedWorkspaceKind
|
||||
displayName: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
archivedAt?: string | null
|
||||
}): PersistedWorkspaceRecord {
|
||||
return PersistedWorkspaceRecordSchema.parse({
|
||||
...input,
|
||||
archivedAt: input.archivedAt ?? null,
|
||||
})
|
||||
}
|
||||
@@ -910,6 +910,18 @@ export const PaseoWorktreeArchiveRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
})
|
||||
|
||||
export const OpenProjectRequestSchema = z.object({
|
||||
type: z.literal('open_project_request'),
|
||||
cwd: z.string(),
|
||||
requestId: z.string(),
|
||||
})
|
||||
|
||||
export const ArchiveWorkspaceRequestSchema = z.object({
|
||||
type: z.literal('archive_workspace_request'),
|
||||
workspaceId: z.string(),
|
||||
requestId: z.string(),
|
||||
})
|
||||
|
||||
// Highlighted diff token schema
|
||||
// Note: style can be a compound class name (e.g., "heading meta") from the syntax highlighter
|
||||
const HighlightTokenSchema = z.object({
|
||||
@@ -1148,6 +1160,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion('type', [
|
||||
DirectorySuggestionsRequestSchema,
|
||||
PaseoWorktreeListRequestSchema,
|
||||
PaseoWorktreeArchiveRequestSchema,
|
||||
OpenProjectRequestSchema,
|
||||
ArchiveWorkspaceRequestSchema,
|
||||
FileExplorerRequestSchema,
|
||||
ProjectIconRequestSchema,
|
||||
FileDownloadTokenRequestSchema,
|
||||
@@ -1469,6 +1483,10 @@ export const ProjectPlacementPayloadSchema = z.object({
|
||||
export const WorkspaceDescriptorPayloadSchema = z.object({
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
projectDisplayName: z.string(),
|
||||
projectRootPath: z.string(),
|
||||
projectKind: z.enum(['git', 'non_git']),
|
||||
workspaceKind: z.enum(['local_checkout', 'worktree', 'directory']),
|
||||
name: z.string(),
|
||||
status: WorkspaceStateBucketSchema,
|
||||
activityAt: z.string().nullable(),
|
||||
@@ -1564,6 +1582,25 @@ export const WorkspaceUpdateMessageSchema = z.object({
|
||||
]),
|
||||
})
|
||||
|
||||
export const OpenProjectResponseMessageSchema = z.object({
|
||||
type: z.literal('open_project_response'),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
workspace: WorkspaceDescriptorPayloadSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
})
|
||||
|
||||
export const ArchiveWorkspaceResponseMessageSchema = z.object({
|
||||
type: z.literal('archive_workspace_response'),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
workspaceId: z.string(),
|
||||
archivedAt: z.string().nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
})
|
||||
|
||||
export const FetchAgentResponseMessageSchema = z.object({
|
||||
type: z.literal('fetch_agent_response'),
|
||||
payload: z.object({
|
||||
@@ -2133,6 +2170,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion('type', [
|
||||
AgentStatusMessageSchema,
|
||||
FetchAgentsResponseMessageSchema,
|
||||
FetchWorkspacesResponseMessageSchema,
|
||||
OpenProjectResponseMessageSchema,
|
||||
ArchiveWorkspaceResponseMessageSchema,
|
||||
FetchAgentResponseMessageSchema,
|
||||
FetchAgentTimelineResponseMessageSchema,
|
||||
SendAgentMessageResponseMessageSchema,
|
||||
@@ -2202,6 +2241,8 @@ export type WorkspaceStateBucket = z.infer<typeof WorkspaceStateBucketSchema>
|
||||
export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPayloadSchema>
|
||||
export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>
|
||||
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>
|
||||
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>
|
||||
export type ArchiveWorkspaceResponseMessage = z.infer<typeof ArchiveWorkspaceResponseMessageSchema>
|
||||
export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema>
|
||||
export type FetchAgentTimelineResponseMessage = z.infer<
|
||||
typeof FetchAgentTimelineResponseMessageSchema
|
||||
@@ -2278,6 +2319,8 @@ export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSc
|
||||
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>
|
||||
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>
|
||||
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>
|
||||
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>
|
||||
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>
|
||||
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>
|
||||
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>
|
||||
export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema>
|
||||
|
||||
@@ -22,6 +22,16 @@ describe('workspace message schemas', () => {
|
||||
expect(parsed.type).toBe('fetch_workspaces_request')
|
||||
})
|
||||
|
||||
test('parses open_project_request', () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: 'open_project_request',
|
||||
cwd: '/tmp/repo',
|
||||
requestId: 'req-open',
|
||||
})
|
||||
|
||||
expect(parsed.type).toBe('open_project_request')
|
||||
})
|
||||
|
||||
test('rejects invalid workspace update payload', () => {
|
||||
const result = SessionOutboundMessageSchema.safeParse({
|
||||
type: 'workspace_update',
|
||||
@@ -30,6 +40,10 @@ describe('workspace message schemas', () => {
|
||||
workspace: {
|
||||
id: '/repo',
|
||||
projectId: '/repo',
|
||||
projectDisplayName: 'repo',
|
||||
projectRootPath: '/repo',
|
||||
projectKind: 'non_git',
|
||||
workspaceKind: 'directory',
|
||||
name: '',
|
||||
status: 'not-a-bucket',
|
||||
activityAt: null,
|
||||
|
||||
Reference in New Issue
Block a user