Update workspace and app changes

This commit is contained in:
Mohamed Boudra
2026-03-07 19:09:00 +07:00
parent 1d1c7058f1
commit 7d76da3249
40 changed files with 2693 additions and 976 deletions
+1
View File
@@ -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>
+23 -10
View File
@@ -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;
@@ -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} />;
}
+13 -15
View File
@@ -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: {
+2 -20
View File
@@ -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)
// ---------------------------------------------------------------------------
+4 -4
View File
@@ -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('')
@@ -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'),
+23
View File
@@ -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", () => {
+39 -2
View File
@@ -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(
@@ -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,
})
})
})
@@ -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,