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();