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 { 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', () => {
+130 -64
View File
@@ -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<StatusSelector | null>(null)
const providerAnchorRef = useRef<View>(null)
const modeAnchorRef = useRef<View>(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 (
<View style={styles.container}>
{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 ? (
<>
<Pressable
ref={modeAnchorRef}
collapsable={false}
disabled={disabled || !canSelectMode}
onPress={() => 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"
<Tooltip
key={`mode-${openSelector === 'mode' ? 'open' : 'closed'}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
{ModeIconComponent ? (
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
) : (
<ShieldCheck size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
)}
</Pressable>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
ref={modeAnchorRef}
collapsable={false}
disabled={disabled || !canSelectMode}
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
options={comboboxModeOptions}
value={selectedModeId ?? ''}
@@ -297,25 +322,37 @@ function ControlledStatusBar({
{canSelectModel ? (
<>
<Pressable
ref={modelAnchorRef}
collapsable={false}
disabled={modelDisabled}
onPress={() => 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"
<Tooltip
key={`model-${openSelector === 'model' ? 'open' : 'closed'}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<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 asChild triggerRefProp="ref">
<Pressable
ref={modelAnchorRef}
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
options={comboboxModelOptions}
value={selectedModelId ?? ''}
@@ -331,25 +368,37 @@ function ControlledStatusBar({
{thinkingOptions && thinkingOptions.length > 0 ? (
<>
<Pressable
ref={thinkingAnchorRef}
collapsable={false}
disabled={disabled || !canSelectThinking}
onPress={() => 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"
<Tooltip
key={`thinking-${openSelector === 'thinking' ? 'open' : 'closed'}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<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 asChild triggerRefProp="ref">
<Pressable
ref={thinkingAnchorRef}
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
options={comboboxThinkingOptions}
value={selectedThinkingOptionId ?? ''}
@@ -386,7 +435,10 @@ function ControlledStatusBar({
>
{providerOptions && providerOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu>
<DropdownMenu
open={openSelector === 'provider'}
onOpenChange={handleOpenChange('provider')}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectProvider}
style={({ pressed }) => [
@@ -418,7 +470,10 @@ function ControlledStatusBar({
{modeOptions && modeOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu>
<DropdownMenu
open={openSelector === 'mode'}
onOpenChange={handleOpenChange('mode')}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectMode}
style={({ pressed }) => [
@@ -458,7 +513,10 @@ function ControlledStatusBar({
{canSelectModel ? (
<View style={styles.sheetSection}>
<DropdownMenu>
<DropdownMenu
open={openSelector === 'model'}
onOpenChange={handleOpenChange('model')}
>
<DropdownMenuTrigger
disabled={modelDisabled}
style={({ pressed }) => [
@@ -490,7 +548,10 @@ function ControlledStatusBar({
{thinkingOptions && thinkingOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu>
<DropdownMenu
open={openSelector === 'thinking'}
onOpenChange={handleOpenChange('thinking')}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectThinking}
style={({ pressed }) => [
@@ -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,
@@ -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') {
+6 -6
View File
@@ -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(() => {
@@ -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<TextInput>(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(() => {
+3 -3
View File
@@ -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,