Refactor project opening and add status bar tooltips

This commit is contained in:
Mohamed Boudra
2026-03-19 19:04:01 +07:00
parent efb3df8233
commit 3d4ac57bd0
15 changed files with 420 additions and 167 deletions
@@ -1,5 +1,17 @@
import { describe, expect, it } from 'vitest' 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', () => { describe('normalizeModelId', () => {
it('treats empty and default values as unset', () => { it('treats empty and default values as unset', () => {
+130 -64
View File
@@ -22,6 +22,7 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Combobox, ComboboxItem, type ComboboxOption } from '@/components/ui/combobox' import { Combobox, ComboboxItem, type ComboboxOption } from '@/components/ui/combobox'
import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet' import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { import type {
AgentMode, AgentMode,
AgentModelDefinition, AgentModelDefinition,
@@ -33,13 +34,18 @@ import {
type AgentModeColorTier, type AgentModeColorTier,
type AgentModeIcon, type AgentModeIcon,
} from '@server/server/agent/provider-manifest' } 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 = { type StatusOption = {
id: string id: string
label: string label: string
} }
type StatusSelector = 'provider' | 'mode' | 'model' | 'thinking'
type ControlledAgentStatusBarProps = { type ControlledAgentStatusBarProps = {
provider: string provider: string
providerOptions?: StatusOption[] providerOptions?: StatusOption[]
@@ -139,7 +145,7 @@ function ControlledStatusBar({
const { theme } = useUnistyles() const { theme } = useUnistyles()
const isWeb = Platform.OS === 'web' const isWeb = Platform.OS === 'web'
const [prefsOpen, setPrefsOpen] = useState(false) const [prefsOpen, setPrefsOpen] = useState(false)
const [openSelector, setOpenSelector] = useState<'provider' | 'mode' | 'model' | 'thinking' | null>(null) const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null)
const providerAnchorRef = useRef<View>(null) const providerAnchorRef = useRef<View>(null)
const modeAnchorRef = useRef<View>(null) const modeAnchorRef = useRef<View>(null)
@@ -215,12 +221,19 @@ function ControlledStatusBar({
) )
const handleOpenChange = useCallback( const handleOpenChange = useCallback(
(selector: 'provider' | 'mode' | 'model' | 'thinking') => (nextOpen: boolean) => { (selector: StatusSelector) => (nextOpen: boolean) => {
setOpenSelector(nextOpen ? selector : null) setOpenSelector(nextOpen ? selector : null)
}, },
[] []
) )
const handleSelectorPress = useCallback(
(selector: StatusSelector) => {
handleOpenChange(selector)(openSelector !== selector)
},
[handleOpenChange, openSelector]
)
return ( return (
<View style={styles.container}> <View style={styles.container}>
{isWeb ? ( {isWeb ? (
@@ -231,7 +244,7 @@ function ControlledStatusBar({
ref={providerAnchorRef} ref={providerAnchorRef}
collapsable={false} collapsable={false}
disabled={disabled || !canSelectProvider} disabled={disabled || !canSelectProvider}
onPress={() => setOpenSelector(openSelector === 'provider' ? null : 'provider')} onPress={() => handleSelectorPress('provider')}
style={({ pressed, hovered }) => [ style={({ pressed, hovered }) => [
styles.modeBadge, styles.modeBadge,
hovered && styles.modeBadgeHovered, hovered && styles.modeBadgeHovered,
@@ -260,27 +273,39 @@ function ControlledStatusBar({
{modeOptions && modeOptions.length > 0 ? ( {modeOptions && modeOptions.length > 0 ? (
<> <>
<Pressable <Tooltip
ref={modeAnchorRef} key={`mode-${openSelector === 'mode' ? 'open' : 'closed'}`}
collapsable={false} delayDuration={0}
disabled={disabled || !canSelectMode} enabledOnDesktop
onPress={() => setOpenSelector(openSelector === 'mode' ? null : 'mode')} enabledOnMobile={false}
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 ? ( <TooltipTrigger asChild triggerRefProp="ref">
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} /> <Pressable
) : ( ref={modeAnchorRef}
<ShieldCheck size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> collapsable={false}
)} disabled={disabled || !canSelectMode}
</Pressable> onPress={() => 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 ? (
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
) : (
<ShieldCheck size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint('mode')}</Text>
</TooltipContent>
</Tooltip>
<Combobox <Combobox
options={comboboxModeOptions} options={comboboxModeOptions}
value={selectedModeId ?? ''} value={selectedModeId ?? ''}
@@ -297,25 +322,37 @@ function ControlledStatusBar({
{canSelectModel ? ( {canSelectModel ? (
<> <>
<Pressable <Tooltip
ref={modelAnchorRef} key={`model-${openSelector === 'model' ? 'open' : 'closed'}`}
collapsable={false} delayDuration={0}
disabled={modelDisabled} enabledOnDesktop
onPress={() => setOpenSelector(openSelector === 'model' ? null : 'model')} enabledOnMobile={false}
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"
> >
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> <TooltipTrigger asChild triggerRefProp="ref">
<Text style={styles.modeBadgeText}>{displayModel}</Text> <Pressable
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> ref={modelAnchorRef}
</Pressable> collapsable={false}
disabled={modelDisabled}
onPress={() => 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"
>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.modeBadgeText}>{displayModel}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint('model')}</Text>
</TooltipContent>
</Tooltip>
<Combobox <Combobox
options={comboboxModelOptions} options={comboboxModelOptions}
value={selectedModelId ?? ''} value={selectedModelId ?? ''}
@@ -331,25 +368,37 @@ function ControlledStatusBar({
{thinkingOptions && thinkingOptions.length > 0 ? ( {thinkingOptions && thinkingOptions.length > 0 ? (
<> <>
<Pressable <Tooltip
ref={thinkingAnchorRef} key={`thinking-${openSelector === 'thinking' ? 'open' : 'closed'}`}
collapsable={false} delayDuration={0}
disabled={disabled || !canSelectThinking} enabledOnDesktop
onPress={() => setOpenSelector(openSelector === 'thinking' ? null : 'thinking')} enabledOnMobile={false}
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"
> >
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> <TooltipTrigger asChild triggerRefProp="ref">
<Text style={styles.modeBadgeText}>{displayThinking}</Text> <Pressable
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> ref={thinkingAnchorRef}
</Pressable> collapsable={false}
disabled={disabled || !canSelectThinking}
onPress={() => 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"
>
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.modeBadgeText}>{displayThinking}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint('thinking')}</Text>
</TooltipContent>
</Tooltip>
<Combobox <Combobox
options={comboboxThinkingOptions} options={comboboxThinkingOptions}
value={selectedThinkingOptionId ?? ''} value={selectedThinkingOptionId ?? ''}
@@ -386,7 +435,10 @@ function ControlledStatusBar({
> >
{providerOptions && providerOptions.length > 0 ? ( {providerOptions && providerOptions.length > 0 ? (
<View style={styles.sheetSection}> <View style={styles.sheetSection}>
<DropdownMenu> <DropdownMenu
open={openSelector === 'provider'}
onOpenChange={handleOpenChange('provider')}
>
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={disabled || !canSelectProvider} disabled={disabled || !canSelectProvider}
style={({ pressed }) => [ style={({ pressed }) => [
@@ -418,7 +470,10 @@ function ControlledStatusBar({
{modeOptions && modeOptions.length > 0 ? ( {modeOptions && modeOptions.length > 0 ? (
<View style={styles.sheetSection}> <View style={styles.sheetSection}>
<DropdownMenu> <DropdownMenu
open={openSelector === 'mode'}
onOpenChange={handleOpenChange('mode')}
>
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={disabled || !canSelectMode} disabled={disabled || !canSelectMode}
style={({ pressed }) => [ style={({ pressed }) => [
@@ -458,7 +513,10 @@ function ControlledStatusBar({
{canSelectModel ? ( {canSelectModel ? (
<View style={styles.sheetSection}> <View style={styles.sheetSection}>
<DropdownMenu> <DropdownMenu
open={openSelector === 'model'}
onOpenChange={handleOpenChange('model')}
>
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={modelDisabled} disabled={modelDisabled}
style={({ pressed }) => [ style={({ pressed }) => [
@@ -490,7 +548,10 @@ function ControlledStatusBar({
{thinkingOptions && thinkingOptions.length > 0 ? ( {thinkingOptions && thinkingOptions.length > 0 ? (
<View style={styles.sheetSection}> <View style={styles.sheetSection}>
<DropdownMenu> <DropdownMenu
open={openSelector === 'thinking'}
onOpenChange={handleOpenChange('thinking')}
>
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={disabled || !canSelectThinking} disabled={disabled || !canSelectThinking}
style={({ pressed }) => [ style={({ pressed }) => [
@@ -763,6 +824,11 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
}, },
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.4,
},
prefsButton: { prefsButton: {
width: 28, width: 28,
height: 28, height: 28,
@@ -1,5 +1,18 @@
import type { AgentModelDefinition } from '@server/server/agent/agent-sdk-types' 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 { export function normalizeModelId(modelId: string | null | undefined): string | null {
const normalized = typeof modelId === 'string' ? modelId.trim() : '' const normalized = typeof modelId === 'string' ? modelId.trim() : ''
if (!normalized || normalized.toLowerCase() === 'default') { if (!normalized || normalized.toLowerCase() === 'default') {
+6 -6
View File
@@ -31,7 +31,7 @@ import {
mapPathnameToServer, mapPathnameToServer,
parseServerIdFromPathname, parseServerIdFromPathname,
} from '@/utils/host-routes' } from '@/utils/host-routes'
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store' import { useOpenProjectPicker } from '@/hooks/use-open-project-picker'
const DESKTOP_SIDEBAR_WIDTH = 320 const DESKTOP_SIDEBAR_WIDTH = 320
@@ -154,16 +154,16 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
closeToAgent() closeToAgent()
}, [closeToAgent]) }, [closeToAgent])
const setProjectPickerOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen) const openProjectPicker = useOpenProjectPicker(activeServerId)
const handleOpenProjectMobile = useCallback(() => { const handleOpenProjectMobile = useCallback(() => {
closeToAgent() closeToAgent()
setProjectPickerOpen(true) void openProjectPicker()
}, [closeToAgent, setProjectPickerOpen]) }, [closeToAgent, openProjectPicker])
const handleOpenProjectDesktop = useCallback(() => { const handleOpenProjectDesktop = useCallback(() => {
setProjectPickerOpen(true) void openProjectPicker()
}, [setProjectPickerOpen]) }, [openProjectPicker])
// Mobile: close sidebar and navigate // Mobile: close sidebar and navigate
const handleSettingsMobile = useCallback(() => { const handleSettingsMobile = useCallback(() => {
@@ -11,22 +11,17 @@ import {
import { Folder } from "lucide-react-native"; import { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query"; 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 { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { shortenPath } from "@/utils/shorten-path"; import { shortenPath } from "@/utils/shorten-path";
import { import { useSessionStore } from "@/stores/session-store";
normalizeWorkspaceDescriptor,
useSessionStore,
} from "@/stores/session-store";
import { useHosts, useHostRuntimeSession } from "@/runtime/host-runtime"; 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 { parseServerIdFromPathname } from "@/utils/host-routes";
import { buildHostWorkspaceRouteWithOpenIntent } from "@/utils/host-routes";
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions"; import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
export function ProjectPickerModal() { export function ProjectPickerModal() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const toast = useToast();
const pathname = usePathname(); const pathname = usePathname();
const daemons = useHosts(); const daemons = useHosts();
@@ -43,15 +38,12 @@ export function ProjectPickerModal() {
const workspaces = useSessionStore((state) => const workspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined serverId ? state.sessions[serverId]?.workspaces : undefined
); );
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const setHasHydratedWorkspaces = useSessionStore(
(state) => state.setHasHydratedWorkspaces
);
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const openProject = useOpenProject(serverId);
const recommendedPaths = useMemo(() => { const recommendedPaths = useMemo(() => {
if (!workspaces) return []; if (!workspaces) return [];
@@ -102,31 +94,15 @@ export function ProjectPickerModal() {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
const payload = await client.openProject(trimmed); const didOpenProject = await openProject(trimmed);
if (payload.error || !payload.workspace) { if (didOpenProject) {
throw new Error(payload.error || "Failed to open project"); 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 { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}, },
[client, mergeWorkspaces, serverId, setHasHydratedWorkspaces, setOpen, toast] [client, openProject, serverId, setOpen]
); );
const handleSubmitCustom = useCallback(() => { const handleSubmitCustom = useCallback(() => {
+3 -3
View File
@@ -289,13 +289,13 @@ const styles = StyleSheet.create((theme) => ({
maxWidth: "92%", maxWidth: "92%",
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[3], gap: theme.spacing[2],
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.full,
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border, borderColor: theme.colors.border,
paddingVertical: theme.spacing[3], paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[3],
shadowColor: "#000", shadowColor: "#000",
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15, shadowOpacity: 0.15,
@@ -38,9 +38,13 @@ import {
export interface LocalDaemonSectionProps { export interface LocalDaemonSectionProps {
appVersion: string | null appVersion: string | null
showLifecycleControls: boolean
} }
export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) { export function LocalDaemonSection({
appVersion,
showLifecycleControls,
}: LocalDaemonSectionProps) {
const { theme } = useUnistyles() const { theme } = useUnistyles()
const showSection = shouldUseManagedDesktopDaemon() const showSection = shouldUseManagedDesktopDaemon()
const { settings, updateSettings } = useAppSettings() const { settings, updateSettings } = useAppSettings()
@@ -347,57 +351,61 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text> <Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
</View> </View>
</View> </View>
<View style={[styles.row, styles.rowBorder]}> {showLifecycleControls ? (
<View style={styles.rowContent}> <>
<Text style={styles.rowTitle}>Daemon management</Text> <View style={[styles.row, styles.rowBorder]}>
<Text style={styles.hintText}> <View style={styles.rowContent}>
{isDaemonManagementPaused <Text style={styles.rowTitle}>Daemon management</Text>
? 'Paused. The built-in daemon stays stopped until you start it again.' <Text style={styles.hintText}>
: 'Enabled. Paseo can manage the built-in daemon from the desktop app.'} {isDaemonManagementPaused
</Text> ? 'Paused. The built-in daemon stays stopped until you start it again.'
</View> : 'Enabled. Paseo can manage the built-in daemon from the desktop app.'}
<Button </Text>
variant="outline" </View>
size="sm" <Button
leftIcon={ variant="outline"
isDaemonManagementPaused ? ( size="sm"
<Play size={theme.iconSize.sm} color={theme.colors.foreground} /> leftIcon={
) : ( isDaemonManagementPaused ? (
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} /> <Play size={theme.iconSize.sm} color={theme.colors.foreground} />
) ) : (
} <Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
onPress={handleToggleDaemonManagement} )
disabled={isUpdatingDaemonManagement} }
> onPress={handleToggleDaemonManagement}
{isUpdatingDaemonManagement disabled={isUpdatingDaemonManagement}
? isDaemonManagementPaused >
? 'Resuming...' {isUpdatingDaemonManagement
: 'Pausing...' ? isDaemonManagementPaused
: isDaemonManagementPaused ? 'Resuming...'
? 'Resume' : 'Pausing...'
: 'Pause'} : isDaemonManagementPaused
</Button> ? 'Resume'
</View> : 'Pause'}
<View style={[styles.row, styles.rowBorder]}> </Button>
<View style={styles.rowContent}> </View>
<Text style={styles.rowTitle}>{daemonActionLabel}</Text> <View style={[styles.row, styles.rowBorder]}>
<Text style={styles.hintText}>{daemonActionMessage}</Text> <View style={styles.rowContent}>
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null} <Text style={styles.rowTitle}>{daemonActionLabel}</Text>
</View> <Text style={styles.hintText}>{daemonActionMessage}</Text>
<Button {statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
variant="outline" </View>
size="sm" <Button
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />} variant="outline"
onPress={handleUpdateLocalDaemon} size="sm"
disabled={isRestartingDaemon} leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
> onPress={handleUpdateLocalDaemon}
{isRestartingDaemon disabled={isRestartingDaemon}
? managedStatus?.status === 'running' >
? 'Restarting...' {isRestartingDaemon
: 'Starting...' ? managedStatus?.status === 'running'
: daemonActionLabel} ? 'Restarting...'
</Button> : 'Starting...'
</View> : daemonActionLabel}
</Button>
</View>
</>
) : null}
<View style={[styles.row, styles.rowBorder]}> <View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}> <View style={styles.rowContent}>
<Text style={styles.rowTitle}>Command line (CLI)</Text> <Text style={styles.rowTitle}>Command line (CLI)</Text>
@@ -0,0 +1,23 @@
import { getTauri } from '@/utils/tauri'
export async function pickDirectory(): Promise<string | null> {
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.')
}
+4 -3
View File
@@ -6,6 +6,7 @@ import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"
import { useHosts } from "@/runtime/host-runtime"; import { useHosts } from "@/runtime/host-runtime";
import { useAllAgentsList } from "@/hooks/use-all-agents-list"; import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { import {
clearCommandCenterFocusRestoreElement, clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement,
@@ -217,18 +218,18 @@ export function useCommandCenter() {
[pathname, setOpen] [pathname, setOpen]
); );
const setProjectPickerOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen); const openProjectPicker = useOpenProjectPicker(activeServerId);
const handleSelectAction = useCallback((action: CommandCenterActionItem) => { const handleSelectAction = useCallback((action: CommandCenterActionItem) => {
clearCommandCenterFocusRestoreElement(); clearCommandCenterFocusRestoreElement();
setOpen(false); setOpen(false);
if (action.id === "new-agent") { if (action.id === "new-agent") {
setProjectPickerOpen(true); void openProjectPicker();
return; return;
} }
didNavigateRef.current = true; didNavigateRef.current = true;
router.push(action.route); router.push(action.route);
}, [setOpen, setProjectPickerOpen]); }, [openProjectPicker, setOpen]);
const handleSelectItem = useCallback( const handleSelectItem = useCallback(
(item: CommandCenterItem) => { (item: CommandCenterItem) => {
@@ -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<ManagedDaemonServerIdResult> {
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
}
@@ -2,10 +2,12 @@ import { useEffect } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { usePathname } from "expo-router"; import { usePathname } from "expo-router";
import { getIsTauri } from "@/constants/layout"; import { getIsTauri } from "@/constants/layout";
import { useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { import {
parseHostAgentRouteFromPathname, parseHostAgentRouteFromPathname,
parseServerIdFromPathname,
parseHostWorkspaceRouteFromPathname, parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; 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 { resolveKeyboardShortcut } from "@/keyboard/keyboard-shortcuts";
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope"; import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
export function useKeyboardShortcuts({ export function useKeyboardShortcuts({
enabled, enabled,
@@ -33,7 +36,14 @@ export function useKeyboardShortcuts({
toggleFileExplorer?: () => void; toggleFileExplorer?: () => void;
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
const hosts = useHosts();
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers); 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(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;
@@ -92,7 +102,7 @@ export function useKeyboardShortcuts({
}; };
const openProjectPicker = (): boolean => { const openProjectPicker = (): boolean => {
useKeyboardShortcutsStore.getState().setProjectPickerOpen(true); void openProjectPickerAction();
return true; return true;
}; };
@@ -326,6 +336,7 @@ export function useKeyboardShortcuts({
}, [ }, [
enabled, enabled,
isMobile, isMobile,
openProjectPickerAction,
pathname, pathname,
resetModifiers, resetModifiers,
selectedAgentId, selectedAgentId,
@@ -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<void> {
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]);
}
@@ -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<boolean> {
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,
]
);
}
@@ -5,18 +5,18 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { FolderOpen } from "lucide-react-native"; import { FolderOpen } from "lucide-react-native";
import { PaseoLogo } from "@/components/icons/paseo-logo"; import { PaseoLogo } from "@/components/icons/paseo-logo";
import { SidebarMenuToggle } from "@/components/headers/menu-header"; 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 { usePanelStore } from "@/stores/panel-store";
import { getIsTauriMac } from "@/constants/layout"; import { getIsTauriMac } from "@/constants/layout";
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window"; import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
export function OpenProjectScreen({ serverId: _serverId }: { serverId: string }) { export function OpenProjectScreen({ serverId }: { serverId: string }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const trafficLightPadding = useTrafficLightPadding(); const trafficLightPadding = useTrafficLightPadding();
const desktopAgentListOpen = usePanelStore((s) => s.desktop.agentListOpen); const desktopAgentListOpen = usePanelStore((s) => s.desktop.agentListOpen);
const openAgentList = usePanelStore((s) => s.openAgentList); 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 isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsTauriMac(); const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsTauriMac();
@@ -42,7 +42,9 @@ export function OpenProjectScreen({ serverId: _serverId }: { serverId: string })
styles.openButton, styles.openButton,
hovered && styles.openButtonHovered, hovered && styles.openButtonHovered,
]} ]}
onPress={() => setProjectPickerOpen(true)} onPress={() => {
void openProjectPicker();
}}
testID="open-project-submit" testID="open-project-submit"
> >
<FolderOpen size={16} color={theme.colors.foregroundMuted} /> <FolderOpen size={16} color={theme.colors.foregroundMuted} />
+8 -1
View File
@@ -47,6 +47,7 @@ import { resolveAppVersion } from "@/utils/app-version";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm"; import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context"; import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
const delay = (ms: number) => const delay = (ms: number) =>
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
@@ -513,6 +514,7 @@ export default function SettingsScreen() {
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
const lastHandledEditHostRef = useRef<string | null>(null); const lastHandledEditHostRef = useRef<string | null>(null);
const isDesktop = Platform.OS === "web"; const isDesktop = Platform.OS === "web";
const isLocalDaemon = useIsLocalDaemon(routeServerId);
const appVersion = resolveAppVersion(); const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion); const appVersionText = formatVersionWithPrefix(appVersion);
const editingServerId = editingDaemon?.serverId ?? null; const editingServerId = editingDaemon?.serverId ?? null;
@@ -906,7 +908,12 @@ export default function SettingsScreen() {
</View> </View>
{isDesktop ? <DesktopPermissionsSection /> : null} {isDesktop ? <DesktopPermissionsSection /> : null}
{isDesktop ? <LocalDaemonSection appVersion={appVersion} /> : null} {isDesktop ? (
<LocalDaemonSection
appVersion={appVersion}
showLifecycleControls={isLocalDaemon}
/>
) : null}
<View style={settingsStyles.section}> <View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>Diagnostics</Text> <Text style={settingsStyles.sectionTitle}>Diagnostics</Text>