From 3d4ac57bd0a51361ba0278b00edacf609f1c7075 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 19 Mar 2026 19:04:01 +0700 Subject: [PATCH] Refactor project opening and add status bar tooltips --- .../src/components/agent-status-bar.test.ts | 14 +- .../app/src/components/agent-status-bar.tsx | 194 ++++++++++++------ .../src/components/agent-status-bar.utils.ts | 13 ++ packages/app/src/components/left-sidebar.tsx | 12 +- .../src/components/project-picker-modal.tsx | 40 +--- packages/app/src/components/toast-host.tsx | 6 +- .../components/desktop-updates-section.tsx | 112 +++++----- packages/app/src/desktop/pick-directory.ts | 23 +++ packages/app/src/hooks/use-command-center.ts | 7 +- packages/app/src/hooks/use-is-local-daemon.ts | 40 ++++ .../app/src/hooks/use-keyboard-shortcuts.ts | 13 +- .../app/src/hooks/use-open-project-picker.ts | 32 +++ packages/app/src/hooks/use-open-project.ts | 62 ++++++ .../app/src/screens/open-project-screen.tsx | 10 +- packages/app/src/screens/settings-screen.tsx | 9 +- 15 files changed, 420 insertions(+), 167 deletions(-) create mode 100644 packages/app/src/desktop/pick-directory.ts create mode 100644 packages/app/src/hooks/use-is-local-daemon.ts create mode 100644 packages/app/src/hooks/use-open-project-picker.ts create mode 100644 packages/app/src/hooks/use-open-project.ts diff --git a/packages/app/src/components/agent-status-bar.test.ts b/packages/app/src/components/agent-status-bar.test.ts index 6afab6add..68d55c0a2 100644 --- a/packages/app/src/components/agent-status-bar.test.ts +++ b/packages/app/src/components/agent-status-bar.test.ts @@ -1,5 +1,17 @@ import { describe, expect, it } from 'vitest' -import { normalizeModelId, resolveAgentModelSelection } from './agent-status-bar.utils' +import { + getStatusSelectorHint, + normalizeModelId, + resolveAgentModelSelection, +} from './agent-status-bar.utils' + +describe('getStatusSelectorHint', () => { + it('explains what each editable status control does', () => { + expect(getStatusSelectorHint('thinking')).toBe('Thinking mode') + expect(getStatusSelectorHint('model')).toBe('Change model') + expect(getStatusSelectorHint('mode')).toBe('Change permission mode') + }) +}) describe('normalizeModelId', () => { it('treats empty and default values as unset', () => { diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index e8089c580..4feefe70d 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -22,6 +22,7 @@ import { } from '@/components/ui/dropdown-menu' import { Combobox, ComboboxItem, type ComboboxOption } from '@/components/ui/combobox' import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import type { AgentMode, AgentModelDefinition, @@ -33,13 +34,18 @@ import { type AgentModeColorTier, type AgentModeIcon, } from '@server/server/agent/provider-manifest' -import { normalizeModelId, resolveAgentModelSelection } from '@/components/agent-status-bar.utils' +import { + getStatusSelectorHint, + resolveAgentModelSelection, +} from '@/components/agent-status-bar.utils' type StatusOption = { id: string label: string } +type StatusSelector = 'provider' | 'mode' | 'model' | 'thinking' + type ControlledAgentStatusBarProps = { provider: string providerOptions?: StatusOption[] @@ -139,7 +145,7 @@ function ControlledStatusBar({ const { theme } = useUnistyles() const isWeb = Platform.OS === 'web' const [prefsOpen, setPrefsOpen] = useState(false) - const [openSelector, setOpenSelector] = useState<'provider' | 'mode' | 'model' | 'thinking' | null>(null) + const [openSelector, setOpenSelector] = useState(null) const providerAnchorRef = useRef(null) const modeAnchorRef = useRef(null) @@ -215,12 +221,19 @@ function ControlledStatusBar({ ) const handleOpenChange = useCallback( - (selector: 'provider' | 'mode' | 'model' | 'thinking') => (nextOpen: boolean) => { + (selector: StatusSelector) => (nextOpen: boolean) => { setOpenSelector(nextOpen ? selector : null) }, [] ) + const handleSelectorPress = useCallback( + (selector: StatusSelector) => { + handleOpenChange(selector)(openSelector !== selector) + }, + [handleOpenChange, openSelector] + ) + return ( {isWeb ? ( @@ -231,7 +244,7 @@ function ControlledStatusBar({ ref={providerAnchorRef} collapsable={false} disabled={disabled || !canSelectProvider} - onPress={() => setOpenSelector(openSelector === 'provider' ? null : 'provider')} + onPress={() => handleSelectorPress('provider')} style={({ pressed, hovered }) => [ styles.modeBadge, hovered && styles.modeBadgeHovered, @@ -260,27 +273,39 @@ function ControlledStatusBar({ {modeOptions && modeOptions.length > 0 ? ( <> - setOpenSelector(openSelector === 'mode' ? null : 'mode')} - style={({ pressed, hovered }) => [ - styles.modeIconBadge, - hovered && styles.modeBadgeHovered, - (pressed || openSelector === 'mode') && styles.modeBadgePressed, - (disabled || !canSelectMode) && styles.disabledBadge, - ]} - accessibilityRole="button" - accessibilityLabel={`Select agent mode (${displayMode})`} - testID="agent-mode-selector" + - {ModeIconComponent ? ( - - ) : ( - - )} - + + handleSelectorPress('mode')} + style={({ pressed, hovered }) => [ + styles.modeIconBadge, + hovered && styles.modeBadgeHovered, + (pressed || openSelector === 'mode') && styles.modeBadgePressed, + (disabled || !canSelectMode) && styles.disabledBadge, + ]} + accessibilityRole="button" + accessibilityLabel={`Select agent mode (${displayMode})`} + testID="agent-mode-selector" + > + {ModeIconComponent ? ( + + ) : ( + + )} + + + + {getStatusSelectorHint('mode')} + + - setOpenSelector(openSelector === 'model' ? null : 'model')} - style={({ pressed, hovered }) => [ - styles.modeBadge, - hovered && styles.modeBadgeHovered, - (pressed || openSelector === 'model') && styles.modeBadgePressed, - modelDisabled && styles.disabledBadge, - ]} - accessibilityRole="button" - accessibilityLabel="Select agent model" - testID="agent-model-selector" + - - {displayModel} - - + + handleSelectorPress('model')} + style={({ pressed, hovered }) => [ + styles.modeBadge, + hovered && styles.modeBadgeHovered, + (pressed || openSelector === 'model') && styles.modeBadgePressed, + modelDisabled && styles.disabledBadge, + ]} + accessibilityRole="button" + accessibilityLabel="Select agent model" + testID="agent-model-selector" + > + + {displayModel} + + + + + {getStatusSelectorHint('model')} + + 0 ? ( <> - setOpenSelector(openSelector === 'thinking' ? null : 'thinking')} - style={({ pressed, hovered }) => [ - styles.modeBadge, - hovered && styles.modeBadgeHovered, - (pressed || openSelector === 'thinking') && styles.modeBadgePressed, - (disabled || !canSelectThinking) && styles.disabledBadge, - ]} - accessibilityRole="button" - accessibilityLabel={`Select thinking option (${displayThinking})`} - testID="agent-thinking-selector" + - - {displayThinking} - - + + handleSelectorPress('thinking')} + style={({ pressed, hovered }) => [ + styles.modeBadge, + hovered && styles.modeBadgeHovered, + (pressed || openSelector === 'thinking') && styles.modeBadgePressed, + (disabled || !canSelectThinking) && styles.disabledBadge, + ]} + accessibilityRole="button" + accessibilityLabel={`Select thinking option (${displayThinking})`} + testID="agent-thinking-selector" + > + + {displayThinking} + + + + + {getStatusSelectorHint('thinking')} + + {providerOptions && providerOptions.length > 0 ? ( - + [ @@ -418,7 +470,10 @@ function ControlledStatusBar({ {modeOptions && modeOptions.length > 0 ? ( - + [ @@ -458,7 +513,10 @@ function ControlledStatusBar({ {canSelectModel ? ( - + [ @@ -490,7 +548,10 @@ function ControlledStatusBar({ {thinkingOptions && thinkingOptions.length > 0 ? ( - + [ @@ -763,6 +824,11 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, }, + tooltipText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + lineHeight: theme.fontSize.sm * 1.4, + }, prefsButton: { width: 28, height: 28, diff --git a/packages/app/src/components/agent-status-bar.utils.ts b/packages/app/src/components/agent-status-bar.utils.ts index 000b0cadb..70e27252f 100644 --- a/packages/app/src/components/agent-status-bar.utils.ts +++ b/packages/app/src/components/agent-status-bar.utils.ts @@ -1,5 +1,18 @@ import type { AgentModelDefinition } from '@server/server/agent/agent-sdk-types' +export type ExplainedStatusSelector = 'mode' | 'model' | 'thinking' + +export function getStatusSelectorHint(selector: ExplainedStatusSelector): string { + switch (selector) { + case 'thinking': + return 'Thinking mode' + case 'model': + return 'Change model' + case 'mode': + return 'Change permission mode' + } +} + export function normalizeModelId(modelId: string | null | undefined): string | null { const normalized = typeof modelId === 'string' ? modelId.trim() : '' if (!normalized || normalized.toLowerCase() === 'default') { diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 5d131df52..927be8135 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -31,7 +31,7 @@ import { mapPathnameToServer, parseServerIdFromPathname, } from '@/utils/host-routes' -import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store' +import { useOpenProjectPicker } from '@/hooks/use-open-project-picker' const DESKTOP_SIDEBAR_WIDTH = 320 @@ -154,16 +154,16 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr closeToAgent() }, [closeToAgent]) - const setProjectPickerOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen) + const openProjectPicker = useOpenProjectPicker(activeServerId) const handleOpenProjectMobile = useCallback(() => { closeToAgent() - setProjectPickerOpen(true) - }, [closeToAgent, setProjectPickerOpen]) + void openProjectPicker() + }, [closeToAgent, openProjectPicker]) const handleOpenProjectDesktop = useCallback(() => { - setProjectPickerOpen(true) - }, [setProjectPickerOpen]) + void openProjectPicker() + }, [openProjectPicker]) // 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 index a0141e333..a1547ae43 100644 --- a/packages/app/src/components/project-picker-modal.tsx +++ b/packages/app/src/components/project-picker-modal.tsx @@ -11,22 +11,17 @@ import { 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 { usePathname } from "expo-router"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { shortenPath } from "@/utils/shorten-path"; -import { - normalizeWorkspaceDescriptor, - useSessionStore, -} from "@/stores/session-store"; +import { useSessionStore } from "@/stores/session-store"; import { useHosts, useHostRuntimeSession } from "@/runtime/host-runtime"; -import { useToast } from "@/contexts/toast-context"; +import { useOpenProject } from "@/hooks/use-open-project"; 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 = useHosts(); @@ -43,15 +38,12 @@ export function ProjectPickerModal() { 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 openProject = useOpenProject(serverId); const recommendedPaths = useMemo(() => { if (!workspaces) return []; @@ -102,31 +94,15 @@ export function ProjectPickerModal() { setIsSubmitting(true); try { - const payload = await client.openProject(trimmed); - if (payload.error || !payload.workspace) { - throw new Error(payload.error || "Failed to open project"); + const didOpenProject = await openProject(trimmed); + if (didOpenProject) { + setOpen(false); } - 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] + [client, openProject, serverId, setOpen] ); const handleSubmitCustom = useCallback(() => { diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx index bb8f2c326..5be6f8c1a 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -289,13 +289,13 @@ const styles = StyleSheet.create((theme) => ({ maxWidth: "92%", flexDirection: "row", alignItems: "center", - gap: theme.spacing[3], + gap: theme.spacing[2], backgroundColor: theme.colors.surface0, borderRadius: theme.borderRadius.full, borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, - paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], shadowColor: "#000", shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.15, diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index 3876c667c..01e530c50 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -38,9 +38,13 @@ import { export interface LocalDaemonSectionProps { appVersion: string | null + showLifecycleControls: boolean } -export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) { +export function LocalDaemonSection({ + appVersion, + showLifecycleControls, +}: LocalDaemonSectionProps) { const { theme } = useUnistyles() const showSection = shouldUseManagedDesktopDaemon() const { settings, updateSettings } = useAppSettings() @@ -347,57 +351,61 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) { {daemonStatusDetailText} - - - Daemon management - - {isDaemonManagementPaused - ? 'Paused. The built-in daemon stays stopped until you start it again.' - : 'Enabled. Paseo can manage the built-in daemon from the desktop app.'} - - - - - - - {daemonActionLabel} - {daemonActionMessage} - {statusMessage ? {statusMessage} : null} - - - + {showLifecycleControls ? ( + <> + + + Daemon management + + {isDaemonManagementPaused + ? 'Paused. The built-in daemon stays stopped until you start it again.' + : 'Enabled. Paseo can manage the built-in daemon from the desktop app.'} + + + + + + + {daemonActionLabel} + {daemonActionMessage} + {statusMessage ? {statusMessage} : null} + + + + + ) : null} Command line (CLI) diff --git a/packages/app/src/desktop/pick-directory.ts b/packages/app/src/desktop/pick-directory.ts new file mode 100644 index 000000000..9ce554411 --- /dev/null +++ b/packages/app/src/desktop/pick-directory.ts @@ -0,0 +1,23 @@ +import { getTauri } from '@/utils/tauri' + +export async function pickDirectory(): Promise { + const open = getTauri()?.dialog?.open + if (typeof open !== 'function') { + throw new Error('Tauri dialog open() is unavailable in this environment.') + } + + const selection = await open({ + directory: true, + multiple: false, + }) + + if (selection === null) { + return null + } + + if (typeof selection === 'string') { + return selection + } + + throw new Error('Unexpected directory picker response.') +} diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 46a31a6b8..0c8765245 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -6,6 +6,7 @@ import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher" import { useHosts } from "@/runtime/host-runtime"; import { useAllAgentsList } from "@/hooks/use-all-agents-list"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; +import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { clearCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement, @@ -217,18 +218,18 @@ export function useCommandCenter() { [pathname, setOpen] ); - const setProjectPickerOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen); + const openProjectPicker = useOpenProjectPicker(activeServerId); const handleSelectAction = useCallback((action: CommandCenterActionItem) => { clearCommandCenterFocusRestoreElement(); setOpen(false); if (action.id === "new-agent") { - setProjectPickerOpen(true); + void openProjectPicker(); return; } didNavigateRef.current = true; router.push(action.route); - }, [setOpen, setProjectPickerOpen]); + }, [openProjectPicker, setOpen]); const handleSelectItem = useCallback( (item: CommandCenterItem) => { diff --git a/packages/app/src/hooks/use-is-local-daemon.ts b/packages/app/src/hooks/use-is-local-daemon.ts new file mode 100644 index 000000000..634c0d9ce --- /dev/null +++ b/packages/app/src/hooks/use-is-local-daemon.ts @@ -0,0 +1,40 @@ +import { useQuery } from '@tanstack/react-query' +import { getManagedDaemonStatus } from '@/desktop/managed-runtime/managed-runtime' +import { isTauriEnvironment } from '@/utils/tauri' + +const MANAGED_DAEMON_SERVER_ID_QUERY_KEY = ['managed-daemon-server-id'] as const + +interface ManagedDaemonServerIdResult { + serverId: string | null +} + +async function loadManagedDaemonServerId(): Promise { + const status = await getManagedDaemonStatus() + const serverId = status.serverId.trim() + return { + serverId: serverId.length > 0 ? serverId : null, + } +} + +export function useIsLocalDaemon(serverId: string): boolean { + const normalizedServerId = serverId.trim() + const isDesktop = isTauriEnvironment() + + const query = useQuery({ + queryKey: MANAGED_DAEMON_SERVER_ID_QUERY_KEY, + queryFn: loadManagedDaemonServerId, + enabled: isDesktop, + staleTime: Infinity, + gcTime: Infinity, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + retry: false, + }) + + if (!isDesktop || normalizedServerId.length === 0) { + return false + } + + return query.data?.serverId === normalizedServerId +} diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 883c55ea9..a5b2ccea7 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -2,10 +2,12 @@ import { useEffect } from "react"; import { Platform } from "react-native"; import { usePathname } from "expo-router"; import { getIsTauri } from "@/constants/layout"; +import { useHosts } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { parseHostAgentRouteFromPathname, + parseServerIdFromPathname, parseHostWorkspaceRouteFromPathname, } from "@/utils/host-routes"; import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; @@ -18,6 +20,7 @@ import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher" import { resolveKeyboardShortcut } from "@/keyboard/keyboard-shortcuts"; import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope"; import { getShortcutOs } from "@/utils/shortcut-platform"; +import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; export function useKeyboardShortcuts({ enabled, @@ -33,7 +36,14 @@ export function useKeyboardShortcuts({ toggleFileExplorer?: () => void; }) { const pathname = usePathname(); + const hosts = useHosts(); const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers); + const activeServerIdFromPath = parseServerIdFromPathname(pathname); + const activeServerId = + hosts.find((host) => host.serverId === activeServerIdFromPath)?.serverId ?? + hosts[0]?.serverId ?? + null; + const openProjectPickerAction = useOpenProjectPicker(activeServerId); useEffect(() => { if (!enabled) return; @@ -92,7 +102,7 @@ export function useKeyboardShortcuts({ }; const openProjectPicker = (): boolean => { - useKeyboardShortcutsStore.getState().setProjectPickerOpen(true); + void openProjectPickerAction(); return true; }; @@ -326,6 +336,7 @@ export function useKeyboardShortcuts({ }, [ enabled, isMobile, + openProjectPickerAction, pathname, resetModifiers, selectedAgentId, diff --git a/packages/app/src/hooks/use-open-project-picker.ts b/packages/app/src/hooks/use-open-project-picker.ts new file mode 100644 index 000000000..d96bb6d31 --- /dev/null +++ b/packages/app/src/hooks/use-open-project-picker.ts @@ -0,0 +1,32 @@ +import { useCallback } from "react"; +import { pickDirectory } from "@/desktop/pick-directory"; +import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; +import { useIsLocalDaemon } from "./use-is-local-daemon"; +import { useOpenProject } from "./use-open-project"; + +export function useOpenProjectPicker(serverId: string | null): () => Promise { + const normalizedServerId = serverId?.trim() ?? ""; + const isLocalDaemon = useIsLocalDaemon(normalizedServerId); + const setProjectPickerOpen = useKeyboardShortcutsStore( + (state) => state.setProjectPickerOpen + ); + const openProject = useOpenProject(serverId); + + return useCallback(async () => { + if (!normalizedServerId) { + return; + } + + if (!isLocalDaemon) { + setProjectPickerOpen(true); + return; + } + + const path = await pickDirectory(); + if (path === null) { + return; + } + + await openProject(path); + }, [isLocalDaemon, normalizedServerId, openProject, setProjectPickerOpen]); +} diff --git a/packages/app/src/hooks/use-open-project.ts b/packages/app/src/hooks/use-open-project.ts new file mode 100644 index 000000000..8cad27f3b --- /dev/null +++ b/packages/app/src/hooks/use-open-project.ts @@ -0,0 +1,62 @@ +import { router } from "expo-router"; +import { useCallback } from "react"; +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"; + +export function useOpenProject( + serverId: string | null +): (path: string) => Promise { + const normalizedServerId = serverId?.trim() ?? ""; + const toast = useToast(); + const { client } = useHostRuntimeSession(normalizedServerId); + const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); + const setHasHydratedWorkspaces = useSessionStore( + (state) => state.setHasHydratedWorkspaces + ); + + return useCallback( + async (path: string) => { + const trimmedPath = path.trim(); + if (!trimmedPath || !client || !normalizedServerId) { + return false; + } + + try { + const payload = await client.openProject(trimmedPath); + if (payload.error || !payload.workspace) { + throw new Error(payload.error || "Failed to open project"); + } + + mergeWorkspaces(normalizedServerId, [ + normalizeWorkspaceDescriptor(payload.workspace), + ]); + setHasHydratedWorkspaces(normalizedServerId, true); + router.replace( + buildHostWorkspaceRouteWithOpenIntent( + normalizedServerId, + payload.workspace.id, + { kind: "draft", draftId: "new" } + ) as any + ); + return true; + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to open project" + ); + return false; + } + }, + [ + client, + mergeWorkspaces, + normalizedServerId, + setHasHydratedWorkspaces, + toast, + ] + ); +} diff --git a/packages/app/src/screens/open-project-screen.tsx b/packages/app/src/screens/open-project-screen.tsx index 3ab7fbf45..16816f27f 100644 --- a/packages/app/src/screens/open-project-screen.tsx +++ b/packages/app/src/screens/open-project-screen.tsx @@ -5,18 +5,18 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { FolderOpen } from "lucide-react-native"; import { PaseoLogo } from "@/components/icons/paseo-logo"; import { SidebarMenuToggle } from "@/components/headers/menu-header"; -import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; +import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { usePanelStore } from "@/stores/panel-store"; import { getIsTauriMac } from "@/constants/layout"; import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window"; -export function OpenProjectScreen({ serverId: _serverId }: { serverId: string }) { +export function OpenProjectScreen({ serverId }: { serverId: string }) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const trafficLightPadding = useTrafficLightPadding(); const desktopAgentListOpen = usePanelStore((s) => s.desktop.agentListOpen); const openAgentList = usePanelStore((s) => s.openAgentList); - const setProjectPickerOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen); + const openProjectPicker = useOpenProjectPicker(serverId); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsTauriMac(); @@ -42,7 +42,9 @@ export function OpenProjectScreen({ serverId: _serverId }: { serverId: string }) styles.openButton, hovered && styles.openButtonHovered, ]} - onPress={() => setProjectPickerOpen(true)} + onPress={() => { + void openProjectPicker(); + }} testID="open-project-submit" > diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index d23d4d068..9d04fdd9b 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -47,6 +47,7 @@ import { resolveAppVersion } from "@/utils/app-version"; import { settingsStyles } from "@/styles/settings"; import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm"; import { useVoiceAudioEngineOptional } from "@/contexts/voice-context"; +import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; const delay = (ms: number) => new Promise((resolve) => { @@ -513,6 +514,7 @@ export default function SettingsScreen() { const isMountedRef = useRef(true); const lastHandledEditHostRef = useRef(null); const isDesktop = Platform.OS === "web"; + const isLocalDaemon = useIsLocalDaemon(routeServerId); const appVersion = resolveAppVersion(); const appVersionText = formatVersionWithPrefix(appVersion); const editingServerId = editingDaemon?.serverId ?? null; @@ -906,7 +908,12 @@ export default function SettingsScreen() { {isDesktop ? : null} - {isDesktop ? : null} + {isDesktop ? ( + + ) : null} Diagnostics