From 356faa056330fa32f7d2e0fafb687e9021dbef20 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 7 Mar 2026 21:33:13 +0700 Subject: [PATCH] feat(app): add project picker modal and simplify open project screen --- packages/app/src/app/_layout.tsx | 2 + .../app/src/components/icons/paseo-logo.tsx | 17 + packages/app/src/components/left-sidebar.tsx | 17 +- .../src/components/project-picker-modal.tsx | 379 ++++++++++++++++++ packages/app/src/hooks/use-command-center.ts | 10 +- .../app/src/hooks/use-keyboard-shortcuts.ts | 20 +- .../app/src/screens/open-project-screen.tsx | 220 ++-------- .../src/stores/keyboard-shortcuts-store.ts | 4 + 8 files changed, 462 insertions(+), 207 deletions(-) create mode 100644 packages/app/src/components/icons/paseo-logo.tsx create mode 100644 packages/app/src/components/project-picker-modal.tsx diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index e1bbb9fba..33274fc64 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -34,6 +34,7 @@ import { } from "@/contexts/horizontal-scroll-context"; import { getIsTauri } from "@/constants/layout"; import { CommandCenter } from "@/components/command-center"; +import { ProjectPickerModal } from "@/components/project-picker-modal"; import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { queryClient } from "@/query/query-client"; @@ -286,6 +287,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { {isMobile && chromeEnabled && } + ); diff --git a/packages/app/src/components/icons/paseo-logo.tsx b/packages/app/src/components/icons/paseo-logo.tsx new file mode 100644 index 000000000..b484f5163 --- /dev/null +++ b/packages/app/src/components/icons/paseo-logo.tsx @@ -0,0 +1,17 @@ +import Svg, { Path } from "react-native-svg"; + +interface PaseoLogoProps { + size?: number; + color?: string; +} + +export function PaseoLogo({ size = 64, color = "white" }: PaseoLogoProps) { + return ( + + + + ); +} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 20a3ab7a1..60e2ed55e 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -25,11 +25,11 @@ import { formatConnectionStatus } from '@/utils/daemons' import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from '@/constants/layout' import { buildHostAgentsRoute, - buildHostOpenProjectRoute, buildHostSettingsRoute, mapPathnameToServer, parseServerIdFromPathname, } from '@/utils/host-routes' +import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store' const DESKTOP_SIDEBAR_WIDTH = 320 const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__) @@ -158,21 +158,16 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr closeToAgent() }, [closeToAgent]) - const handleOpenProject = useCallback(() => { - if (!activeServerId) { - return - } - router.push(buildHostOpenProjectRoute(activeServerId) as any) - }, [activeServerId]) + const setProjectPickerOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen) const handleOpenProjectMobile = useCallback(() => { closeToAgent() - handleOpenProject() - }, [closeToAgent, handleOpenProject]) + setProjectPickerOpen(true) + }, [closeToAgent, setProjectPickerOpen]) const handleOpenProjectDesktop = useCallback(() => { - handleOpenProject() - }, [handleOpenProject]) + setProjectPickerOpen(true) + }, [setProjectPickerOpen]) // Mobile: close sidebar and navigate const handleSettingsMobile = useCallback(() => { diff --git a/packages/app/src/components/project-picker-modal.tsx b/packages/app/src/components/project-picker-modal.tsx new file mode 100644 index 000000000..591eec5a2 --- /dev/null +++ b/packages/app/src/components/project-picker-modal.tsx @@ -0,0 +1,379 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Modal, + Pressable, + ScrollView, + Text, + TextInput, + View, + Platform, +} from "react-native"; +import { Folder } from "lucide-react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useQuery } from "@tanstack/react-query"; +import { router, usePathname } from "expo-router"; +import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; +import { + normalizeWorkspaceDescriptor, + useSessionStore, +} from "@/stores/session-store"; +import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; +import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { useToast } from "@/contexts/toast-context"; +import { parseServerIdFromPathname } from "@/utils/host-routes"; +import { buildHostWorkspaceRouteWithOpenIntent } from "@/utils/host-routes"; +import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions"; + +export function ProjectPickerModal() { + const { theme } = useUnistyles(); + const toast = useToast(); + const pathname = usePathname(); + const { daemons } = useDaemonRegistry(); + + const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen); + const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen); + + const serverId = useMemo(() => { + const fromPath = parseServerIdFromPathname(pathname); + if (fromPath) return fromPath; + return daemons[0]?.serverId ?? null; + }, [pathname, daemons]); + + const { client, isConnected } = useHostRuntimeSession(serverId ?? ""); + const workspaces = useSessionStore((state) => + serverId ? state.sessions[serverId]?.workspaces : undefined + ); + const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); + const setHasHydratedWorkspaces = useSessionStore( + (state) => state.setHasHydratedWorkspaces + ); + + const inputRef = useRef(null); + const [query, setQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + const [isSubmitting, setIsSubmitting] = useState(false); + + const recommendedPaths = useMemo(() => { + if (!workspaces) return []; + return Array.from(workspaces.values()).map( + (workspace) => workspace.projectRootPath || workspace.id + ); + }, [workspaces]); + + const directorySuggestionsQuery = useQuery({ + queryKey: ["project-picker-directory-suggestions", serverId, query], + queryFn: async () => { + if (!client) return []; + const result = await client.getDirectorySuggestions({ + query, + includeDirectories: true, + includeFiles: false, + limit: 30, + }); + return ( + result.entries?.flatMap((entry) => + entry.kind === "directory" ? [entry.path] : [] + ) ?? [] + ); + }, + enabled: Boolean(client) && isConnected && open, + staleTime: 15_000, + retry: false, + }); + + const options = useMemo( + () => + buildWorkingDirectorySuggestions({ + recommendedPaths, + serverPaths: directorySuggestionsQuery.data ?? [], + query, + }), + [query, directorySuggestionsQuery.data, recommendedPaths] + ); + + const handleClose = useCallback(() => { + setOpen(false); + }, [setOpen]); + + const handleSelectPath = useCallback( + async (path: string) => { + const trimmed = path.trim(); + if (!trimmed || !client || !serverId) return; + + setIsSubmitting(true); + try { + const payload = await client.openProject(trimmed); + if (payload.error || !payload.workspace) { + throw new Error(payload.error || "Failed to open project"); + } + mergeWorkspaces(serverId, [ + normalizeWorkspaceDescriptor(payload.workspace), + ]); + setHasHydratedWorkspaces(serverId, true); + setOpen(false); + 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, serverId, setHasHydratedWorkspaces, setOpen, toast] + ); + + const handleSubmitCustom = useCallback(() => { + const trimmed = query.trim(); + if (!trimmed) return; + void handleSelectPath(trimmed); + }, [handleSelectPath, query]); + + // Reset state when opening/closing + useEffect(() => { + if (open) { + setQuery(""); + setActiveIndex(0); + const id = setTimeout(() => inputRef.current?.focus(), 0); + return () => clearTimeout(id); + } + }, [open]); + + // Clamp active index + useEffect(() => { + if (!open) return; + if (activeIndex >= options.length) { + setActiveIndex(options.length > 0 ? options.length - 1 : 0); + } + }, [activeIndex, options.length, open]); + + // Keyboard navigation + useEffect(() => { + if (!open) return; + + function handler(event: KeyboardEvent) { + const key = event.key; + if ( + key !== "ArrowDown" && + key !== "ArrowUp" && + key !== "Enter" && + key !== "Escape" + ) + return; + + if (key === "Escape") { + event.preventDefault(); + setOpen(false); + return; + } + + if (key === "Enter") { + event.preventDefault(); + if (options.length > 0 && activeIndex < options.length) { + void handleSelectPath(options[activeIndex]!); + } else if (query.trim()) { + handleSubmitCustom(); + } + return; + } + + if (key === "ArrowDown" || key === "ArrowUp") { + if (options.length === 0) return; + event.preventDefault(); + setActiveIndex((current) => { + const delta = key === "ArrowDown" ? 1 : -1; + const next = current + delta; + if (next < 0) return options.length - 1; + if (next >= options.length) return 0; + return next; + }); + } + } + + window.addEventListener("keydown", handler, true); + return () => window.removeEventListener("keydown", handler, true); + }, [activeIndex, handleSelectPath, handleSubmitCustom, open, options, query, setOpen]); + + if (Platform.OS !== "web") return null; + if (!serverId) return null; + + return ( + + + + + + + { + setQuery(text); + setActiveIndex(0); + }} + placeholder="Type a directory path..." + placeholderTextColor={theme.colors.foregroundMuted} + style={[styles.input, { color: theme.colors.foreground }]} + autoCapitalize="none" + autoCorrect={false} + autoFocus + editable={!isSubmitting} + /> + + + + {isSubmitting ? ( + + Opening project... + + ) : options.length === 0 && !query.trim() ? ( + + Start typing a path + + ) : ( + <> + {options.map((path, index) => { + const active = index === activeIndex; + return ( + [ + styles.row, + (hovered || pressed || active) && { + backgroundColor: theme.colors.surface1, + }, + ]} + onPress={() => void handleSelectPath(path)} + > + + + + + + {path} + + + + ); + })} + + )} + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + overlay: { + flex: 1, + justifyContent: "flex-start", + alignItems: "center", + paddingTop: theme.spacing[12], + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(0, 0, 0, 0.5)", + }, + panel: { + width: 640, + maxWidth: "92%", + maxHeight: "80%", + borderWidth: 1, + borderRadius: theme.borderRadius.lg, + overflow: "hidden", + shadowColor: "#000", + shadowOpacity: 0.4, + shadowRadius: 24, + shadowOffset: { width: 0, height: 12 }, + }, + header: { + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + borderBottomWidth: 1, + }, + input: { + fontSize: theme.fontSize.lg, + paddingVertical: theme.spacing[1], + outlineStyle: "none", + } as any, + results: { + flexGrow: 0, + }, + resultsContent: { + paddingVertical: theme.spacing[2], + }, + row: { + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[2], + }, + rowContent: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + }, + iconSlot: { + width: 16, + height: 20, + alignItems: "center", + justifyContent: "center", + }, + rowText: { + fontSize: theme.fontSize.base, + fontWeight: "400", + lineHeight: 20, + flexShrink: 1, + }, + emptyText: { + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[4], + fontSize: theme.fontSize.base, + }, +})); diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 7f4701e5b..33e845aa1 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -185,12 +185,18 @@ export function useCommandCenter() { [pathname, setOpen] ); + const setProjectPickerOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen); + const handleSelectAction = useCallback((action: CommandCenterActionItem) => { - didNavigateRef.current = true; clearCommandCenterFocusRestoreElement(); setOpen(false); + if (action.id === "new-agent") { + setProjectPickerOpen(true); + return; + } + didNavigateRef.current = true; router.push(action.route); - }, [setOpen]); + }, [setOpen, setProjectPickerOpen]); const handleSelectItem = useCallback( (item: CommandCenterItem) => { diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index a2c6b21e9..675d30fbf 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -2,15 +2,12 @@ import { useEffect } from "react"; import { Platform } from "react-native"; import { usePathname, useRouter } from "expo-router"; import { getIsTauri } from "@/constants/layout"; -import { useSessionStore } from "@/stores/session-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { - buildHostOpenProjectRoute, buildHostWorkspaceRoute, parseHostAgentRouteFromPathname, parseHostWorkspaceRouteFromPathname, - parseServerIdFromPathname, } from "@/utils/host-routes"; import { type MessageInputKeyboardActionKind, @@ -99,19 +96,8 @@ export function useKeyboardShortcuts({ return true; }; - const navigateToOpenProject = (): boolean => { - let targetServerId = parseServerIdFromPathname(pathname); - - if (!targetServerId) { - const sessionServerIds = Object.keys(useSessionStore.getState().sessions); - targetServerId = sessionServerIds[0] ?? null; - } - - if (!targetServerId) { - return false; - } - - router.push(buildHostOpenProjectRoute(targetServerId) as any); + const openProjectPicker = (): boolean => { + useKeyboardShortcutsStore.getState().setProjectPickerOpen(true); return true; }; @@ -171,7 +157,7 @@ export function useKeyboardShortcuts({ }): boolean => { switch (input.action) { case "agent.new": - return navigateToOpenProject(); + return openProjectPicker(); case "workspace.tab.new": return requestWorkspaceTabAction({ kind: "new" }); case "workspace.tab.close.current": diff --git a/packages/app/src/screens/open-project-screen.tsx b/packages/app/src/screens/open-project-screen.tsx index 9362ce225..468e869ea 100644 --- a/packages/app/src/screens/open-project-screen.tsx +++ b/packages/app/src/screens/open-project-screen.tsx @@ -1,160 +1,32 @@ -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 { View, Text, Pressable } from "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"; +import { FolderOpen } from "lucide-react-native"; +import { PaseoLogo } from "@/components/icons/paseo-logo"; +import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; -export function OpenProjectScreen({ serverId }: { serverId: string }) { +export function OpenProjectScreen({ serverId: _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(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 setProjectPickerOpen = useKeyboardShortcutsStore( + (s) => s.setProjectPickerOpen ); - 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 ( - - - Workspace Registry - Open project - - Add a local folder to the sidebar, then land inside its workspace draft tab. - - - setIsDirectoryPickerOpen(true)} - icon={} - showLabel={false} - valueEllipsizeMode="middle" - testID="open-project-directory-trigger" - /> - - - - - - - - + + + What shall we build today? + [ + styles.openButton, + hovered && styles.openButtonHovered, + ]} + onPress={() => setProjectPickerOpen(true)} + testID="open-project-submit" + > + + Open a project + + ); } @@ -167,40 +39,34 @@ const styles = StyleSheet.create((theme) => ({ content: { flexGrow: 1, justifyContent: "center", + alignItems: "center", + gap: theme.spacing[6], 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, + heading: { + color: theme.colors.foreground, + fontSize: theme.fontSize["2xl"], + fontWeight: theme.fontWeight.normal, + textAlign: "center", + }, + openButton: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + borderRadius: theme.borderRadius.lg, borderWidth: 1, borderColor: theme.colors.border, + backgroundColor: "transparent", }, - eyebrow: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - textTransform: "uppercase", - letterSpacing: 1, + openButtonHovered: { + borderColor: theme.colors.borderAccent, + backgroundColor: theme.colors.surface1, }, - title: { - color: theme.colors.foreground, - fontSize: theme.fontSize["3xl"], - fontWeight: theme.fontWeight.semibold, - }, - subtitle: { + openButtonText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, - lineHeight: 20, - }, - selector: { - minHeight: 64, - }, - actions: { - flexDirection: "row", - justifyContent: "flex-start", + fontWeight: theme.fontWeight.normal, }, })); diff --git a/packages/app/src/stores/keyboard-shortcuts-store.ts b/packages/app/src/stores/keyboard-shortcuts-store.ts index 9353e1666..bddc4f583 100644 --- a/packages/app/src/stores/keyboard-shortcuts-store.ts +++ b/packages/app/src/stores/keyboard-shortcuts-store.ts @@ -25,6 +25,7 @@ export type WorkspaceTabActionRequest = interface KeyboardShortcutsState { commandCenterOpen: boolean; + projectPickerOpen: boolean; shortcutsDialogOpen: boolean; altDown: boolean; cmdOrCtrlDown: boolean; @@ -36,6 +37,7 @@ interface KeyboardShortcutsState { nextWorkspaceTabActionRequestId: number; setCommandCenterOpen: (open: boolean) => void; + setProjectPickerOpen: (open: boolean) => void; setShortcutsDialogOpen: (open: boolean) => void; setAltDown: (down: boolean) => void; setCmdOrCtrlDown: (down: boolean) => void; @@ -67,6 +69,7 @@ interface KeyboardShortcutsState { export const useKeyboardShortcutsStore = create( (set, get) => ({ commandCenterOpen: false, + projectPickerOpen: false, shortcutsDialogOpen: false, altDown: false, cmdOrCtrlDown: false, @@ -76,6 +79,7 @@ export const useKeyboardShortcutsStore = create( nextWorkspaceTabActionRequestId: 1, setCommandCenterOpen: (open) => set({ commandCenterOpen: open }), + setProjectPickerOpen: (open) => set({ projectPickerOpen: open }), setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }), setAltDown: (down) => set({ altDown: down }), setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),