From ad060e2f86ad1c635a50349640becdb6289ba50f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 21 May 2026 16:15:28 +0700 Subject: [PATCH] feat: import existing CLI sessions from the home screen Home screen now leads with action tiles (Add a project, Import session, Setup providers, Pair device) instead of a single button, giving new users a clear path in even before they've added a project. Import session is the onboarding hook: the sheet now works without a workspace context and lists recent sessions from every provider's native history (Claude, Codex, OpenCode, Pi, ACP). Picking one imports the agent and lands the user on its workspace. To make that work end-to-end, the daemon upserts the imported agent's cwd as a workspace so the sidebar surfaces it even when the user never explicitly opened the folder. Also adds a Home entry to cmd+K and a refresh button to the import sheet header for explicit re-fetch. --- .../app/src/components/command-center.tsx | 4 +- .../import-session-sheet.test.tsx} | 154 ++++++++++++-- .../import-session-sheet.tsx} | 139 ++++++++++--- packages/app/src/hooks/use-command-center.ts | 48 +++-- .../app/src/screens/open-project-screen.tsx | 191 +++++++++++++----- .../screens/workspace/workspace-screen.tsx | 6 +- packages/server/src/server/session.ts | 15 ++ .../src/server/session.workspaces.test.ts | 113 +++++++++++ 8 files changed, 557 insertions(+), 113 deletions(-) rename packages/app/src/{screens/workspace/workspace-import-sheet.test.tsx => components/import-session-sheet.test.tsx} (81%) rename packages/app/src/{screens/workspace/workspace-import-sheet.tsx => components/import-session-sheet.tsx} (84%) diff --git a/packages/app/src/components/command-center.tsx b/packages/app/src/components/command-center.tsx index 59b18ddb0..0947ff176 100644 --- a/packages/app/src/components/command-center.tsx +++ b/packages/app/src/components/command-center.tsx @@ -8,7 +8,7 @@ import { type PressableStateCallbackType, } from "react-native"; import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react"; -import { Plus, Settings } from "lucide-react-native"; +import { Home, Plus, Settings } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useCommandCenter } from "@/hooks/use-command-center"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; @@ -106,6 +106,8 @@ function CommandCenterActionRow({ actionIcon = ; } else if (action.icon === "settings") { actionIcon = ; + } else if (action.icon === "home") { + actionIcon = ; } const titleStyle = useMemo( () => [styles.title, { color: theme.colors.foreground }], diff --git a/packages/app/src/screens/workspace/workspace-import-sheet.test.tsx b/packages/app/src/components/import-session-sheet.test.tsx similarity index 81% rename from packages/app/src/screens/workspace/workspace-import-sheet.test.tsx rename to packages/app/src/components/import-session-sheet.test.tsx index f87e09337..33cfa0b79 100644 --- a/packages/app/src/screens/workspace/workspace-import-sheet.test.tsx +++ b/packages/app/src/components/import-session-sheet.test.tsx @@ -7,7 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { DaemonClient, FetchRecentProviderSessionEntry } from "@server/client/daemon-client"; import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { WorkspaceImportSheet } from "@/screens/workspace/workspace-import-sheet"; +import { ImportSessionSheet } from "@/components/import-session-sheet"; const { theme } = vi.hoisted(() => ({ theme: { @@ -69,12 +69,13 @@ vi.mock("lucide-react-native", () => { }; return { Inbox: icon("Inbox"), + RotateCw: icon("RotateCw"), }; }); vi.mock("@/components/ui/loading-spinner", () => ({ LoadingSpinner: () => - React.createElement("span", { "data-testid": "workspace-import-loading-spinner" }), + React.createElement("span", { "data-testid": "import-session-loading-spinner" }), })); vi.mock("@/components/ui/segmented-control", () => ({ @@ -111,18 +112,19 @@ vi.mock("@/components/ui/segmented-control", () => ({ vi.mock("@/components/adaptive-modal-sheet", () => ({ AdaptiveModalSheet: ({ visible, - title, + header, children, testID, }: { visible: boolean; - title: string; + header?: { title: string; actions?: ReactNode }; children: ReactNode; testID?: string; }) => visible ? (
-

{title}

+

{header?.title}

+ {header?.actions} {children}
) : null, @@ -157,6 +159,8 @@ interface RenderOptions { visible?: boolean; onClose?: () => void; onImportedAgent?: (agentId: string) => void; + onImported?: (agent: Awaited>) => void; + cwd?: string | null; snapshot?: { entries?: ProviderSnapshotEntry[]; supportsSnapshot?: boolean; @@ -179,15 +183,18 @@ function renderSheet( }, }); + const cwd = options && "cwd" in options ? (options.cwd ?? undefined) : "/repo/paseo"; + return render( - , ); @@ -265,7 +272,7 @@ function createSnapshotEntry( }; } -describe("WorkspaceImportSheet", () => { +describe("ImportSessionSheet", () => { afterEach(() => { cleanup(); vi.clearAllMocks(); @@ -431,11 +438,11 @@ describe("WorkspaceImportSheet", () => { function TestSheet({ visible }: { visible: boolean }) { return ( - @@ -483,7 +490,7 @@ describe("WorkspaceImportSheet", () => { }, ); - fireEvent.click(await screen.findByTestId("workspace-import-session-claude-provider-thread-1")); + fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1")); await waitFor(() => { expect(importAgent).toHaveBeenCalledWith({ @@ -519,7 +526,7 @@ describe("WorkspaceImportSheet", () => { }, ); - fireEvent.click(await screen.findByTestId("workspace-import-session-claude-provider-thread-1")); + fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1")); await screen.findByText("Could not import selected session."); expect(importAgent).toHaveBeenCalledWith({ @@ -665,12 +672,12 @@ describe("WorkspaceImportSheet", () => { await screen.findByText("Session claude"); await screen.findByText("Session codex"); - fireEvent.click(screen.getByTestId("workspace-import-filter-codex")); + fireEvent.click(screen.getByTestId("import-session-filter-codex")); screen.getByText("Session codex"); expect(screen.queryByText("Session claude")).toBeNull(); - fireEvent.click(screen.getByTestId("workspace-import-filter-all")); + fireEvent.click(screen.getByTestId("import-session-filter-all")); screen.getByText("Session claude"); screen.getByText("Session codex"); @@ -702,8 +709,8 @@ describe("WorkspaceImportSheet", () => { await waitFor(() => { expect(fetchRecentProviderSessions).toHaveBeenCalled(); }); - expect(screen.queryByTestId("workspace-import-filters")).toBeNull(); - expect(screen.queryByTestId("workspace-import-filter-all")).toBeNull(); + expect(screen.queryByTestId("import-session-filters")).toBeNull(); + expect(screen.queryByTestId("import-session-filter-all")).toBeNull(); }); it("shows a no-importable-providers message when snapshot has no enabled importable providers", async () => { @@ -731,4 +738,119 @@ describe("WorkspaceImportSheet", () => { await screen.findByText("No importable providers are enabled."); expect(fetchRecentProviderSessions).not.toHaveBeenCalled(); }); + + it("omits cwd from fetch and renders the session cwd on each row when cwd is unset", async () => { + const fetchRecentProviderSessions = vi.fn(async () => ({ + requestId: "recent-provider-sessions", + entries: [ + createProviderSessionEntry({ + providerId: "claude", + providerLabel: "Claude Code", + cwd: "/home/me/work/other-project", + title: "Cross-project session", + }), + ], + })); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + cwd: null, + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }, + ); + + await waitFor(() => { + expect(fetchRecentProviderSessions).toHaveBeenCalledWith({ + providers: ["claude"], + limit: 15, + }); + }); + expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith( + expect.objectContaining({ cwd: expect.anything() }), + ); + await screen.findByText("/home/me/work/other-project"); + }); + + it("uses the session's cwd when importing in cwd-less mode and fires onImported", async () => { + const fetchRecentProviderSessions = vi.fn(async () => ({ + requestId: "recent-provider-sessions", + entries: [ + createProviderSessionEntry({ + providerId: "claude", + providerLabel: "Claude Code", + cwd: "/home/me/work/other-project", + }), + ], + })); + const importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported")); + const onImported = vi.fn(); + const onImportedAgent = vi.fn(); + const onClose = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + cwd: null, + onClose, + onImported, + onImportedAgent, + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }, + ); + + fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1")); + + await waitFor(() => { + expect(importAgent).toHaveBeenCalledWith({ + providerId: "claude", + providerHandleId: "provider-thread-1", + cwd: "/home/me/work/other-project", + }); + }); + expect(onImported).toHaveBeenCalledTimes(1); + expect(onImported).toHaveBeenCalledWith(expect.objectContaining({ id: "agent-imported" })); + expect(onImportedAgent).toHaveBeenCalledWith("agent-imported"); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("refetches sessions when the refresh button is clicked", async () => { + const fetchRecentProviderSessions = vi.fn(async () => ({ + requestId: "recent-provider-sessions", + entries: [ + createProviderSessionEntry({ + providerId: "claude", + providerLabel: "Claude Code", + title: "Refreshable session", + }), + ], + })); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }, + ); + + await screen.findByText("Refreshable session"); + expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByTestId("import-session-refresh")); + + await waitFor(() => { + expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/packages/app/src/screens/workspace/workspace-import-sheet.tsx b/packages/app/src/components/import-session-sheet.tsx similarity index 84% rename from packages/app/src/screens/workspace/workspace-import-sheet.tsx rename to packages/app/src/components/import-session-sheet.tsx index 6797aca3c..b82959199 100644 --- a/packages/app/src/screens/workspace/workspace-import-sheet.tsx +++ b/packages/app/src/components/import-session-sheet.tsx @@ -4,7 +4,7 @@ import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; import type { DaemonClient, FetchRecentProviderSessionEntry } from "@server/client/daemon-client"; import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; import { IMPORTABLE_PROVIDERS } from "@server/shared/importable-providers"; -import { Inbox } from "lucide-react-native"; +import { Inbox, RotateCw } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; @@ -14,7 +14,6 @@ import { formatTimeAgo } from "@/utils/time"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; const IMPORTABLE_PROVIDER_IDS: Set = new Set(IMPORTABLE_PROVIDERS); -const IMPORT_SESSION_HEADER: SheetHeader = { title: "Import session" }; const PER_PROVIDER_LIMIT = 15; const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"]; const DISABLED_ACCESSIBILITY_STATE = { disabled: true }; @@ -25,13 +24,16 @@ type RecentProviderSessionsClient = Pick< "fetchRecentProviderSessions" | "importAgent" >; -interface WorkspaceImportSheetProps { +type ImportedAgent = Awaited>; + +interface ImportSessionSheetProps { visible: boolean; client: RecentProviderSessionsClient | null; serverId: string | null; - workspaceDirectory: string | null; + cwd?: string | null; onClose: () => void; - onImportedAgent: (agentId: string) => void; + onImportedAgent?: (agentId: string) => void; + onImported?: (agent: ImportedAgent) => void; } type RecentSessionsResponse = Awaited< @@ -84,20 +86,20 @@ function buildSessionsQueriesConfig(args: { sessionsQueryRoot: ReadonlyArray; visible: boolean; client: RecentProviderSessionsClient | null; - workspaceDirectory: string | null; + cwd: string | null | undefined; }): SessionsQueryConfig[] { - const { providersToFetch, sessionsQueryRoot, visible, client, workspaceDirectory } = args; + const { providersToFetch, sessionsQueryRoot, visible, client, cwd } = args; if (providersToFetch === null) return []; - const enabled = visible && Boolean(client && workspaceDirectory); + const enabled = visible && Boolean(client); return providersToFetch.map((provider) => ({ queryKey: [...sessionsQueryRoot, provider], enabled, queryFn: async () => { - if (!client || !workspaceDirectory) { + if (!client) { throw new Error("Host is not connected"); } return await client.fetchRecentProviderSessions({ - cwd: workspaceDirectory, + ...(cwd ? { cwd } : {}), providers: [provider], limit: PER_PROVIDER_LIMIT, }); @@ -186,7 +188,7 @@ function SheetStatusMessages({ }: SheetStatusMessagesProps) { const { theme } = useUnistyles(); if (!isClientReady) { - return Connect to a workspace to import sessions; + return Connect to a host to import sessions; } if (isSnapshotUnsupported) { return Update the host to import sessions.; @@ -253,10 +255,39 @@ function computeEmptyState(input: EmptyStateInputs): { return { showEmptyState, emptyStateTitle: "No recent sessions to import." }; } +function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) { + const { theme } = useUnistyles(); + const pressableStyle = useCallback( + ({ pressed }: PressableStateCallbackType) => [ + styles.refreshButton, + pressed && styles.refreshButtonPressed, + ], + [], + ); + return ( + + + {isRefreshing ? ( + + ) : ( + + )} + + + ); +} + function SheetEmptyState({ title }: { title: string }) { const { theme } = useUnistyles(); return ( - + @@ -270,29 +301,31 @@ function buildProviderFilterOptions( providerLabelById: ReadonlyMap, ): SegmentedControlOption[] { const options: SegmentedControlOption[] = [ - { value: ALL_FILTER_VALUE, label: "All", testID: "workspace-import-filter-all" }, + { value: ALL_FILTER_VALUE, label: "All", testID: "import-session-filter-all" }, ]; for (const provider of providers) { const ProviderIcon = getProviderIcon(provider); options.push({ value: provider, label: providerLabelById.get(provider) ?? provider, - testID: `workspace-import-filter-${provider}`, + testID: `import-session-filter-${provider}`, icon: ({ color, size }) => , }); } return options; } -function WorkspaceImportSheetRow({ +function ImportSessionSheetRow({ entry, disabled, importing, + showCwd, onImportSession, }: { entry: FetchRecentProviderSessionEntry; disabled: boolean; importing: boolean; + showCwd: boolean; onImportSession: (entry: FetchRecentProviderSessionEntry) => void; }) { const { theme } = useUnistyles(); @@ -323,7 +356,7 @@ function WorkspaceImportSheetRow({ accessibilityRole="button" accessibilityState={accessibilityState} style={pressableStyle} - testID={`workspace-import-session-${entry.providerId}-${entry.providerHandleId}`} + testID={`import-session-session-${entry.providerId}-${entry.providerHandleId}`} > @@ -338,19 +371,25 @@ function WorkspaceImportSheetRow({ {promptPreview} + {showCwd && entry.cwd ? ( + + {entry.cwd} + + ) : null} ); } -export function WorkspaceImportSheet({ +export function ImportSessionSheet({ visible, client, serverId, - workspaceDirectory, + cwd, onClose, onImportedAgent, -}: WorkspaceImportSheetProps) { + onImported, +}: ImportSessionSheetProps) { const queryClient = useQueryClient(); const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, { @@ -368,8 +407,8 @@ export function WorkspaceImportSheet({ ); const sessionsQueryRoot = useMemo( - () => ["recent-provider-sessions", workspaceDirectory] as const, - [workspaceDirectory], + () => ["recent-provider-sessions", cwd ?? null] as const, + [cwd], ); const queriesConfig = useMemo( @@ -379,9 +418,9 @@ export function WorkspaceImportSheet({ sessionsQueryRoot, visible, client, - workspaceDirectory, + cwd, }), - [providersToFetch, sessionsQueryRoot, visible, client, workspaceDirectory], + [providersToFetch, sessionsQueryRoot, visible, client, cwd], ); const queries = useQueries({ queries: queriesConfig }); @@ -417,20 +456,25 @@ export function WorkspaceImportSheet({ const importMutation = useMutation({ mutationFn: async (entry: FetchRecentProviderSessionEntry) => { - if (!client || !workspaceDirectory) { + if (!client) { throw new Error("Host is not connected"); } + const effectiveCwd = cwd ?? entry.cwd; + if (!effectiveCwd) { + throw new Error("Session is missing a working directory"); + } const agent = await client.importAgent({ providerId: entry.providerId, providerHandleId: entry.providerHandleId, - cwd: workspaceDirectory, + cwd: effectiveCwd, }); return agent; }, onSuccess: async (agent) => { await queryClient.invalidateQueries({ queryKey: sessionsQueryRoot }); onClose(); - onImportedAgent(agent.id); + onImportedAgent?.(agent.id); + onImported?.(agent); }, }); @@ -451,6 +495,20 @@ export function WorkspaceImportSheet({ [queries, providersToFetch, providerLabelById], ); + const isRefreshing = queries.some((query) => query.isFetching); + + const handleRefresh = useCallback(() => { + void queryClient.invalidateQueries({ queryKey: sessionsQueryRoot }); + }, [queryClient, sessionsQueryRoot]); + + const header = useMemo( + () => ({ + title: "Import session", + actions: , + }), + [isRefreshing, handleRefresh], + ); + const isSnapshotUnsupported = !supportsSnapshot; const isWaitingForSnapshot = supportsSnapshot && snapshotEntries === undefined; const hasNoImportableProviders = providersToFetch !== null && providersToFetch.length === 0; @@ -478,8 +536,8 @@ export function WorkspaceImportSheet({ @@ -490,7 +548,7 @@ export function WorkspaceImportSheet({ contentContainerStyle={styles.filterRow} > ) : null} 0 ? ( {visibleEntries.map((entry) => ( - ))} @@ -580,6 +639,10 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, lineHeight: 20, }, + rowCwd: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, statusRow: { flexDirection: "row", alignItems: "center", @@ -606,4 +669,18 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, textAlign: "center", }, + refreshButton: { + padding: theme.spacing[2], + marginRight: theme.spacing[1], + borderRadius: theme.borderRadius.lg, + }, + refreshButtonPressed: { + backgroundColor: theme.colors.surface2, + }, + refreshIconSlot: { + width: 16, + height: 16, + alignItems: "center", + justifyContent: "center", + }, })); diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 3b1a0ac9d..69706da70 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -10,7 +10,7 @@ import { clearCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement, } from "@/utils/command-center-focus-restore"; -import { buildSettingsRoute } from "@/utils/host-routes"; +import { buildHostOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string"; import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts"; @@ -52,10 +52,10 @@ function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number { interface CommandCenterActionDefinition { id: string; title: string; - icon?: "plus" | "settings"; + icon?: "plus" | "settings" | "home"; actionId?: string; keywords: string[]; - routeKind: "settings" | "none"; + routeKind: "settings" | "home" | "none"; } const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [ @@ -67,6 +67,13 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [ keywords: ["open", "project", "folder", "workspace", "repo"], routeKind: "none", }, + { + id: "home", + title: "Home", + icon: "home", + keywords: ["home", "start", "import", "session", "pair", "device", "providers"], + routeKind: "home", + }, { id: "settings", title: "Settings", @@ -89,7 +96,7 @@ export interface CommandCenterActionItem { kind: "action"; id: string; title: string; - icon?: "plus" | "settings"; + icon?: "plus" | "settings" | "home"; route?: Href; shortcutKeys?: ShortcutKey[][]; } @@ -155,21 +162,32 @@ export function useCommandCenter() { return buildSettingsRoute(); }, []); + const homeRoute = useMemo(() => { + if (!routeActiveServerId) return undefined; + return buildHostOpenProjectRoute(routeActiveServerId) as Href; + }, [routeActiveServerId]); + const actionItems = useMemo(() => { if (!open) { return EMPTY_ACTION_ITEMS; } - return COMMAND_CENTER_ACTIONS.filter((action) => - matchesActionQuery(query, action), - ).map((action) => ({ - kind: "action", - id: action.id, - title: action.title, - icon: action.icon, - route: action.routeKind === "settings" ? settingsRoute : undefined, - shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides), - })); - }, [open, query, settingsRoute, overrides]); + return COMMAND_CENTER_ACTIONS.filter((action) => { + if (action.routeKind === "home" && !homeRoute) return false; + return matchesActionQuery(query, action); + }).map((action) => { + let route: Href | undefined; + if (action.routeKind === "settings") route = settingsRoute; + else if (action.routeKind === "home") route = homeRoute; + return { + kind: "action", + id: action.id, + title: action.title, + icon: action.icon, + route, + shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides), + }; + }); + }, [open, query, settingsRoute, homeRoute, overrides]); const items = useMemo(() => { if (!open) { diff --git a/packages/app/src/screens/open-project-screen.tsx b/packages/app/src/screens/open-project-screen.tsx index 13c4ba777..e23baffcd 100644 --- a/packages/app/src/screens/open-project-screen.tsx +++ b/packages/app/src/screens/open-project-screen.tsx @@ -1,14 +1,12 @@ -import { useCallback, useEffect, useState } from "react"; -import { View, Text } from "react-native"; -import { StyleSheet } from "react-native-unistyles"; -import { FolderOpen, Smartphone } from "lucide-react-native"; +import { useCallback, useEffect, useState, type ComponentType } from "react"; +import { View, Text, Pressable } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useRouter } from "expo-router"; +import { FolderOpen, Inbox, Plug, Smartphone } from "lucide-react-native"; import { PaseoLogo } from "@/components/icons/paseo-logo"; -import { Button } from "@/components/ui/button"; import { MenuHeader } from "@/components/headers/menu-header"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { usePanelStore } from "@/stores/panel-store"; -import { useSessionStore } from "@/stores/session-store"; -import { useHasWorkspaces } from "@/stores/session-store-hooks"; import { useIsCompactFormFactor, HEADER_INNER_HEIGHT, @@ -18,14 +16,21 @@ import { import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { PairDeviceModal } from "@/desktop/components/pair-device-modal"; +import { buildHostAgentDetailRoute, buildSettingsHostRoute } from "@/utils/host-routes"; +import { ImportSessionSheet } from "@/components/import-session-sheet"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; +import { useOpenProject } from "@/hooks/use-open-project"; +import type { Href } from "expo-router"; export function OpenProjectScreen({ serverId }: { serverId: string }) { + const router = useRouter(); const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList); const openProjectPicker = useOpenProjectPicker(serverId); - const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false); - const hasProjects = useHasWorkspaces(serverId); const isLocalDaemon = useIsLocalDaemon(serverId); + const client = useHostRuntimeClient(serverId); + const openProject = useOpenProject(serverId); const [isPairDeviceOpen, setIsPairDeviceOpen] = useState(false); + const [isImportSheetOpen, setIsImportSheetOpen] = useState(false); const isCompactLayout = useIsCompactFormFactor(); @@ -42,40 +47,62 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) { const handleOpenPairDevice = useCallback(() => setIsPairDeviceOpen(true), []); const handleClosePairDevice = useCallback(() => setIsPairDeviceOpen(false), []); + const handleOpenImportSession = useCallback(() => setIsImportSheetOpen(true), []); + const handleCloseImportSession = useCallback(() => setIsImportSheetOpen(false), []); + + const handleImported = useCallback( + (agent: { id: string; cwd: string }) => { + void (async () => { + await openProject(agent.cwd); + router.push(buildHostAgentDetailRoute(serverId, agent.id) as Href); + })(); + }, + [openProject, router, serverId], + ); + + const handleOpenProviders = useCallback(() => { + router.push(buildSettingsHostRoute(serverId)); + }, [router, serverId]); + return ( - + - - What shall we build today? - {hasHydrated && !hasProjects ? ( - - Add a project folder to start running agents on your codebase - - ) : null} - - - + accent + /> + + {isLocalDaemon ? ( - + /> ) : null} @@ -84,10 +111,61 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) { onClose={handleClosePairDevice} testID="open-project-pair-device-modal" /> + ); } +interface HomeTileProps { + icon: ComponentType<{ size: number; color: string }>; + title: string; + description: string; + onPress: () => void; + testID?: string; + accent?: boolean; +} + +function HomeTile({ icon: Icon, title, description, onPress, testID, accent }: HomeTileProps) { + // useUnistyles is acceptable here: leaf component, off the hot path (home screen renders once). + const { theme } = useUnistyles(); + const [hovered, setHovered] = useState(false); + const handleHoverIn = useCallback(() => setHovered(true), []); + const handleHoverOut = useCallback(() => setHovered(false), []); + + const iconColor = accent ? theme.colors.accent : theme.colors.foregroundMuted; + + const pressableStyle = useCallback( + ({ pressed }: { pressed: boolean }) => [ + styles.tile, + hovered && styles.tileHovered, + pressed && styles.tilePressed, + ], + [hovered], + ); + + return ( + + + + {title} + {description} + + + ); +} + const styles = StyleSheet.create((theme) => ({ container: { flex: 1, @@ -97,10 +175,11 @@ const styles = StyleSheet.create((theme) => ({ content: { position: "relative", flex: 1, - justifyContent: "center", + justifyContent: { xs: "flex-start", md: "center" }, alignItems: "center", gap: 0, padding: theme.spacing[6], + paddingTop: { xs: theme.spacing[12], md: theme.spacing[6] }, paddingBottom: { xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[6], md: HEADER_INNER_HEIGHT + theme.spacing[6], @@ -109,25 +188,43 @@ const styles = StyleSheet.create((theme) => ({ logo: { marginBottom: theme.spacing[8], }, - headingGroup: { - alignItems: "center", + tiles: { + marginTop: { xs: theme.spacing[6], md: theme.spacing[12] }, + width: "100%", + maxWidth: 452, + flexDirection: "row", + flexWrap: "wrap", + justifyContent: "flex-start", gap: theme.spacing[3], }, - cta: { - marginTop: theme.spacing[12], - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], + tile: { + width: { xs: "100%", md: 220 }, + minHeight: { xs: 0, md: 132 }, + padding: theme.spacing[4], + backgroundColor: theme.colors.surface1, + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.xl, + gap: theme.spacing[3], }, - heading: { + tileHovered: { + backgroundColor: theme.colors.surface2, + borderColor: theme.colors.borderAccent, + }, + tilePressed: { + opacity: 0.85, + }, + tileText: { + gap: theme.spacing[1], + }, + tileTitle: { color: theme.colors.foreground, - fontSize: theme.fontSize["2xl"], - fontWeight: theme.fontWeight.normal, - textAlign: "center", - }, - subtitle: { - color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, - textAlign: "center", + fontWeight: theme.fontWeight.normal, + }, + tileDescription: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + lineHeight: 18, }, })); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 33fe3b300..fa3ab1333 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -59,7 +59,7 @@ import { SourceControlPanelIcon } from "@/components/icons/source-control-panel- import { WorkspaceGitActions } from "@/git/workspace-actions"; import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button"; import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button"; -import { WorkspaceImportSheet } from "@/screens/workspace/workspace-import-sheet"; +import { ImportSessionSheet } from "@/components/import-session-sheet"; import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; import { useToast } from "@/contexts/toast-context"; import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; @@ -3358,11 +3358,11 @@ function WorkspaceScreenContent({ /> ) : null} - diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index a1eca86b8..c7f41183c 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -3389,6 +3389,7 @@ export class Session { paseoHome: this.paseoHome, logger: this.sessionLogger, }); + await this.registerWorkspaceForImportedAgent(snapshot.cwd); await this.forwardAgentUpdate(snapshot); const agentPayload = await this.buildAgentPayload(snapshot); this.emit({ @@ -7143,6 +7144,20 @@ export class Session { } } + private async registerWorkspaceForImportedAgent(cwd: string): Promise { + try { + const workspace = await this.findOrCreateWorkspaceForDirectory(cwd); + await this.syncWorkspaceGitObserverForWorkspace(workspace); + await this.describeWorkspaceRecord(workspace); + await this.emitWorkspaceUpdateForCwd(workspace.cwd); + } catch (error) { + this.sessionLogger.warn( + { err: error, cwd }, + "Failed to register workspace for imported agent", + ); + } + } + private async handleOpenProjectRequest( request: Extract, ): Promise { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 05a4ae0eb..902bddd07 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -66,6 +66,20 @@ interface SessionTestAccess { agentManager: { listAgents(): unknown[]; listImportablePersistedAgents(options?: unknown): Promise; + findPersistedAgent( + provider: string, + providerHandleId: string, + options?: unknown, + ): Promise; + resumeAgentFromPersistence( + handle: unknown, + overrides?: unknown, + preferredId?: string, + extras?: unknown, + ): Promise; + hydrateTimelineFromProvider(agentId: string): Promise; + getTimeline(agentId: string): readonly unknown[]; + setTitle(agentId: string, title: string): Promise; }; workspaceRegistry: { list(...args: unknown[]): Promise; @@ -2660,6 +2674,105 @@ test("open_project_request registers a workspace before any agent exists", async expect(response?.payload.workspace?.id).toBe(REPO_CWD); }); +test("import_agent_request registers a workspace for a never-seen cwd", async () => { + const emitted: SessionOutboundMessage[] = []; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => { + if (isSessionOutboundMessage(message)) emitted.push(message); + }, + }); + const projects = new Map>(); + const workspaces = new Map>(); + const importedCwd = path.resolve("/tmp/imported-project"); + + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; + session.projectRegistry.upsert = async ( + record: ReturnType, + ) => { + projects.set(record.projectId, record); + }; + session.workspaceRegistry.get = async (workspaceId: string) => + workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.upsert = async ( + record: ReturnType, + ) => { + 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: "imported", + checkout: { + cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + + const managed = makeManagedAgent({ + id: "imported-agent", + cwd: importedCwd, + lifecycle: "idle", + updatedAt: "2026-05-21T00:00:00.000Z", + }); + session.agentManager.listAgents = () => [managed]; + session.agentManager.findPersistedAgent = async () => null; + session.agentManager.resumeAgentFromPersistence = async () => managed; + session.agentManager.hydrateTimelineFromProvider = async () => undefined; + session.agentManager.getTimeline = () => []; + session.agentManager.setTitle = async () => undefined; + session.agentStorage.list = async () => []; + session.agentStorage.get = async () => null; + session.forwardAgentUpdate = async () => undefined; + + session.workspaceUpdatesSubscription = { + subscriptionId: "sub-import", + filter: undefined, + isBootstrapping: false, + pendingUpdatesByWorkspaceId: new Map(), + lastEmittedByWorkspaceId: new Map(), + }; + session.buildWorkspaceDescriptorMap = async () => + new Map([ + [ + importedCwd, + { + id: importedCwd, + projectId: importedCwd, + projectDisplayName: "imported-project", + projectRootPath: importedCwd, + projectKind: "non_git", + workspaceKind: "directory", + name: "imported-project", + status: "done", + activityAt: null, + }, + ], + ]); + + await session.handleMessage({ + type: "import_agent_request", + requestId: "req-import", + providerId: "codex", + providerHandleId: "session-xyz", + cwd: importedCwd, + }); + + expect(workspaces.get(importedCwd)).toBeTruthy(); + const workspaceUpdates = filterByType(emitted, "workspace_update"); + expect(workspaceUpdates.length).toBeGreaterThan(0); + expect( + workspaceUpdates.some( + (update) => update.payload.kind === "upsert" && update.payload.workspace.id === importedCwd, + ), + ).toBe(true); +}); + test("open_project_response returns immediately even when the GitHub fetch is slow", async () => { const emitted: SessionOutboundMessage[] = []; const session = createSessionForWorkspaceTests();