Add combined model selector and refactor working indicator

This commit is contained in:
Mohamed Boudra
2026-03-19 08:55:40 +07:00
parent 22c27dd583
commit f0ba64f23b
14 changed files with 690 additions and 127 deletions
@@ -36,6 +36,7 @@ const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = {
}; };
const MODE_COLOR_MAP: Record<string, string> = { const MODE_COLOR_MAP: Record<string, string> = {
default: baseColors.blue[500],
safe: baseColors.green[500], safe: baseColors.green[500],
moderate: baseColors.amber[500], moderate: baseColors.amber[500],
dangerous: baseColors.red[500], dangerous: baseColors.red[500],
@@ -19,6 +19,9 @@ describe('resolveStatusControlMode', () => {
selectedModel: '', selectedModel: '',
onSelectModel: () => undefined, onSelectModel: () => undefined,
isModelLoading: false, isModelLoading: false,
allProviderModels: new Map(),
isAllModelsLoading: false,
onSelectProviderAndModel: () => undefined,
thinkingOptions: [], thinkingOptions: [],
selectedThinkingOptionId: '', selectedThinkingOptionId: '',
onSelectThinkingOption: () => undefined, onSelectThinkingOption: () => undefined,
+113 -84
View File
@@ -10,8 +10,8 @@ import {
ShieldOff, ShieldOff,
SlidersHorizontal, SlidersHorizontal,
} from 'lucide-react-native' } from 'lucide-react-native'
import { ClaudeIcon } from '@/components/icons/claude-icon' import { getProviderIcon } from '@/components/provider-icons'
import { CodexIcon } from '@/components/icons/codex-icon' import { CombinedModelSelector } from '@/components/combined-model-selector'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useSessionStore } from '@/stores/session-store' import { useSessionStore } from '@/stores/session-store'
import { import {
@@ -69,6 +69,9 @@ export interface DraftAgentStatusBarProps {
selectedModel: string selectedModel: string
onSelectModel: (modelId: string) => void onSelectModel: (modelId: string) => void
isModelLoading: boolean isModelLoading: boolean
allProviderModels: Map<string, AgentModelDefinition[]>
isAllModelsLoading: boolean
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void
thinkingOptions: NonNullable<AgentModelDefinition['thinkingOptions']> thinkingOptions: NonNullable<AgentModelDefinition['thinkingOptions']>
selectedThinkingOptionId: string selectedThinkingOptionId: string
onSelectThinkingOption: (thinkingOptionId: string) => void onSelectThinkingOption: (thinkingOptionId: string) => void
@@ -94,20 +97,14 @@ const MODE_ICONS = {
ShieldOff, ShieldOff,
} as const } as const
const PROVIDER_ICONS: Record<string, typeof Bot> = {
claude: ClaudeIcon as unknown as typeof Bot,
codex: CodexIcon as unknown as typeof Bot,
}
function getProviderIcon(provider: string): typeof Bot {
return PROVIDER_ICONS[provider] ?? Bot
}
function getModeIconColor( function getModeIconColor(
colorTier: AgentModeColorTier | undefined, colorTier: AgentModeColorTier | undefined,
palette: { green: { 500: string }; amber: { 500: string }; red: { 500: string }; purple: { 500: string } } palette: { blue: { 500: string }; green: { 500: string }; amber: { 500: string }; red: { 500: string }; purple: { 500: string } }
): string { ): string {
switch (colorTier) { switch (colorTier) {
case 'default':
return palette.blue[500]
case 'safe': case 'safe':
return palette.green[500] return palette.green[500]
case 'moderate': case 'moderate':
@@ -117,7 +114,7 @@ function getModeIconColor(
case 'readonly': case 'readonly':
return palette.purple[500] return palette.purple[500]
default: default:
return palette.green[500] return palette.blue[500]
} }
} }
@@ -298,35 +295,39 @@ function ControlledStatusBar({
</> </>
) : null} ) : null}
<Pressable {canSelectModel ? (
ref={modelAnchorRef} <>
collapsable={false} <Pressable
disabled={modelDisabled} ref={modelAnchorRef}
onPress={() => setOpenSelector(openSelector === 'model' ? null : 'model')} collapsable={false}
style={({ pressed, hovered }) => [ disabled={modelDisabled}
styles.modeBadge, onPress={() => setOpenSelector(openSelector === 'model' ? null : 'model')}
hovered && styles.modeBadgeHovered, style={({ pressed, hovered }) => [
(pressed || openSelector === 'model') && styles.modeBadgePressed, styles.modeBadge,
modelDisabled && styles.disabledBadge, hovered && styles.modeBadgeHovered,
]} (pressed || openSelector === 'model') && styles.modeBadgePressed,
accessibilityRole="button" modelDisabled && styles.disabledBadge,
accessibilityLabel="Select agent model" ]}
testID="agent-model-selector" accessibilityRole="button"
> accessibilityLabel="Select agent model"
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> testID="agent-model-selector"
<Text style={styles.modeBadgeText}>{displayModel}</Text> >
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> <ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</Pressable> <Text style={styles.modeBadgeText}>{displayModel}</Text>
<Combobox <ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
options={comboboxModelOptions} </Pressable>
value={selectedModelId ?? ''} <Combobox
onSelect={(id) => onSelectModel?.(id)} options={comboboxModelOptions}
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD} value={selectedModelId ?? ''}
open={openSelector === 'model'} onSelect={(id) => onSelectModel?.(id)}
onOpenChange={handleOpenChange('model')} searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
anchorRef={modelAnchorRef} open={openSelector === 'model'}
desktopPlacement="top-start" onOpenChange={handleOpenChange('model')}
/> anchorRef={modelAnchorRef}
desktopPlacement="top-start"
/>
</>
) : null}
{thinkingOptions && thinkingOptions.length > 0 ? ( {thinkingOptions && thinkingOptions.length > 0 ? (
<> <>
@@ -455,35 +456,37 @@ function ControlledStatusBar({
</View> </View>
) : null} ) : null}
<View style={styles.sheetSection}> {canSelectModel ? (
<DropdownMenu> <View style={styles.sheetSection}>
<DropdownMenuTrigger <DropdownMenu>
disabled={modelDisabled} <DropdownMenuTrigger
style={({ pressed }) => [ disabled={modelDisabled}
styles.sheetSelect, style={({ pressed }) => [
pressed && styles.sheetSelectPressed, styles.sheetSelect,
modelDisabled && styles.disabledSheetSelect, pressed && styles.sheetSelectPressed,
]} modelDisabled && styles.disabledSheetSelect,
accessibilityRole="button" ]}
accessibilityLabel="Select agent model" accessibilityRole="button"
testID="agent-preferences-model" accessibilityLabel="Select agent model"
> testID="agent-preferences-model"
<Text style={styles.sheetSelectText}>{displayModel}</Text> >
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> <Text style={styles.sheetSelectText}>{displayModel}</Text>
</DropdownMenuTrigger> <ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<DropdownMenuContent side="top" align="start"> </DropdownMenuTrigger>
{(modelOptions ?? []).map((model) => ( <DropdownMenuContent side="top" align="start">
<DropdownMenuItem {(modelOptions ?? []).map((model) => (
key={model.id} <DropdownMenuItem
selected={model.id === selectedModelId} key={model.id}
onSelect={() => onSelectModel?.(model.id)} selected={model.id === selectedModelId}
> onSelect={() => onSelectModel?.(model.id)}
{model.label} >
</DropdownMenuItem> {model.label}
))} </DropdownMenuItem>
</DropdownMenuContent> ))}
</DropdownMenu> </DropdownMenuContent>
</View> </DropdownMenu>
</View>
) : null}
{thinkingOptions && thinkingOptions.length > 0 ? ( {thinkingOptions && thinkingOptions.length > 0 ? (
<View style={styles.sheetSection}> <View style={styles.sheetSection}>
@@ -638,17 +641,15 @@ export function DraftAgentStatusBar({
selectedModel, selectedModel,
onSelectModel, onSelectModel,
isModelLoading, isModelLoading,
allProviderModels,
isAllModelsLoading,
onSelectProviderAndModel,
thinkingOptions, thinkingOptions,
selectedThinkingOptionId, selectedThinkingOptionId,
onSelectThinkingOption, onSelectThinkingOption,
disabled = false, disabled = false,
}: DraftAgentStatusBarProps) { }: DraftAgentStatusBarProps) {
const providerOptions = useMemo<StatusOption[]>(() => { const isWeb = Platform.OS === 'web'
return providerDefinitions.map((definition) => ({
id: definition.id,
label: definition.label,
}))
}, [providerDefinitions])
const mappedModeOptions = useMemo<StatusOption[]>(() => { const mappedModeOptions = useMemo<StatusOption[]>(() => {
if (modeOptions.length === 0) { if (modeOptions.length === 0) {
@@ -660,14 +661,6 @@ export function DraftAgentStatusBar({
})) }))
}, [modeOptions]) }, [modeOptions])
const modelOptions = useMemo<StatusOption[]>(() => {
const options: StatusOption[] = [{ id: '', label: 'Auto' }]
for (const model of models) {
options.push({ id: model.id, label: model.label })
}
return options
}, [models])
const mappedThinkingOptions = useMemo<StatusOption[]>(() => { const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
return thinkingOptions.map((option) => ({ id: option.id, label: option.label })) return thinkingOptions.map((option) => ({ id: option.id, label: option.label }))
}, [thinkingOptions]) }, [thinkingOptions])
@@ -676,6 +669,42 @@ export function DraftAgentStatusBar({
const effectiveSelectedThinkingOption = const effectiveSelectedThinkingOption =
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined
if (isWeb) {
return (
<View style={styles.container}>
<CombinedModelSelector
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
onSelect={onSelectProviderAndModel}
isLoading={isAllModelsLoading}
disabled={disabled}
/>
<ControlledStatusBar
provider={selectedProvider}
modeOptions={mappedModeOptions}
selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode}
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption}
disabled={disabled}
/>
</View>
)
}
const providerOptions = providerDefinitions.map((definition) => ({
id: definition.id,
label: definition.label,
}))
const modelOptions: StatusOption[] = [{ id: '', label: 'Auto' }]
for (const model of models) {
modelOptions.push({ id: model.id, label: model.label })
}
return ( return (
<ControlledStatusBar <ControlledStatusBar
provider={selectedProvider} provider={selectedProvider}
@@ -22,11 +22,10 @@ import Animated, {
FadeIn, FadeIn,
FadeOut, FadeOut,
cancelAnimation, cancelAnimation,
Easing,
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
withDelay,
withRepeat, withRepeat,
withSequence,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Check, ChevronDown, X } from "lucide-react-native"; import { Check, ChevronDown, X } from "lucide-react-native";
@@ -70,6 +69,11 @@ import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { getMarkdownListMarker } from "@/utils/markdown-list"; import { getMarkdownListMarker } from "@/utils/markdown-list";
import { buildHostWorkspaceFileRoute } from "@/utils/host-routes"; import { buildHostWorkspaceFileRoute } from "@/utils/host-routes";
import { normalizeInlinePathTarget } from "@/utils/inline-path"; import { normalizeInlinePathTarget } from "@/utils/inline-path";
import {
getWorkingIndicatorDotStrength,
WORKING_INDICATOR_CYCLE_MS,
WORKING_INDICATOR_OFFSETS,
} from "@/utils/working-indicator";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message"; const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) => const isToolSequenceItem = (item?: StreamItem) =>
@@ -659,50 +663,58 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
}); });
function WorkingIndicator() { function WorkingIndicator() {
const dotOne = useSharedValue(0); const progress = useSharedValue(0);
const dotTwo = useSharedValue(0);
const dotThree = useSharedValue(0);
const bounceDuration = 600;
const bounceDelayOffset = 160;
useEffect(() => { useEffect(() => {
const sharedValues = [dotOne, dotTwo, dotThree]; progress.value = 0;
sharedValues.forEach((value, index) => { progress.value = withRepeat(
value.value = withDelay( withTiming(1, {
index * bounceDelayOffset, duration: WORKING_INDICATOR_CYCLE_MS,
withRepeat( easing: Easing.linear,
withSequence( }),
withTiming(1, { duration: bounceDuration }), -1,
withTiming(0, { duration: bounceDuration }) false
), );
-1
)
);
});
return () => { return () => {
sharedValues.forEach((value) => { cancelAnimation(progress);
cancelAnimation(value); progress.value = 0;
value.value = 0;
});
}; };
}, [dotOne, dotTwo, dotThree]); }, [progress]);
const translateDistance = -2; const translateDistance = -2;
const dotOneStyle = useAnimatedStyle(() => ({ const dotOneStyle = useAnimatedStyle(() => {
opacity: 0.3 + dotOne.value * 0.7, const strength = getWorkingIndicatorDotStrength(
transform: [{ translateY: dotOne.value * translateDistance }], progress.value,
})); WORKING_INDICATOR_OFFSETS[0]
);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
const dotTwoStyle = useAnimatedStyle(() => ({ const dotTwoStyle = useAnimatedStyle(() => {
opacity: 0.3 + dotTwo.value * 0.7, const strength = getWorkingIndicatorDotStrength(
transform: [{ translateY: dotTwo.value * translateDistance }], progress.value,
})); WORKING_INDICATOR_OFFSETS[1]
);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
const dotThreeStyle = useAnimatedStyle(() => ({ const dotThreeStyle = useAnimatedStyle(() => {
opacity: 0.3 + dotThree.value * 0.7, const strength = getWorkingIndicatorDotStrength(
transform: [{ translateY: dotThree.value * translateDistance }], progress.value,
})); WORKING_INDICATOR_OFFSETS[2]
);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
return ( return (
<View style={stylesheet.workingIndicatorBubble}> <View style={stylesheet.workingIndicatorBubble}>
@@ -0,0 +1,372 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { View, Text, Pressable, Platform } from 'react-native'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { ArrowLeft, Check, ChevronDown, ChevronRight } from 'lucide-react-native'
import type { AgentModelDefinition, AgentProvider } from '@server/server/agent/agent-sdk-types'
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest'
import { Combobox, ComboboxItem, SearchInput } from '@/components/ui/combobox'
import { getProviderIcon } from '@/components/provider-icons'
const INLINE_MODEL_THRESHOLD = 8
type DrillDownView = { provider: string }
interface CombinedModelSelectorProps {
providerDefinitions: AgentProviderDefinition[]
allProviderModels: Map<string, AgentModelDefinition[]>
selectedProvider: string
selectedModel: string
onSelect: (provider: AgentProvider, modelId: string) => void
isLoading: boolean
disabled?: boolean
}
export function CombinedModelSelector({
providerDefinitions,
allProviderModels,
selectedProvider,
selectedModel,
onSelect,
isLoading,
disabled = false,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles()
const anchorRef = useRef<View>(null)
const [isOpen, setIsOpen] = useState(false)
const [view, setView] = useState<'groups' | DrillDownView>('groups')
const [searchQuery, setSearchQuery] = useState('')
const handleOpenChange = useCallback((open: boolean) => {
setIsOpen(open)
if (open) {
const models = allProviderModels.get(selectedProvider)
if (models && models.length > INLINE_MODEL_THRESHOLD) {
setView({ provider: selectedProvider })
}
} else {
setView('groups')
setSearchQuery('')
}
}, [allProviderModels, selectedProvider])
const handleSelect = useCallback(
(provider: string, modelId: string) => {
onSelect(provider as AgentProvider, modelId)
setIsOpen(false)
setView('groups')
setSearchQuery('')
},
[onSelect]
)
const ProviderIcon = getProviderIcon(selectedProvider)
const selectedModelLabel = useMemo(() => {
const models = allProviderModels.get(selectedProvider)
if (!models) return isLoading ? 'Loading...' : 'Auto'
const model = models.find((m) => m.id === selectedModel)
return model?.label ?? 'Auto'
}, [allProviderModels, selectedProvider, selectedModel, isLoading])
return (
<>
<Pressable
ref={anchorRef}
collapsable={false}
disabled={disabled}
onPress={() => handleOpenChange(!isOpen)}
style={({ pressed, hovered }) => [
styles.trigger,
hovered && styles.triggerHovered,
(pressed || isOpen) && styles.triggerPressed,
disabled && styles.triggerDisabled,
]}
accessibilityRole="button"
accessibilityLabel={`Select model (${selectedModelLabel})`}
testID="combined-model-selector"
>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.triggerText}>{selectedModelLabel}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<Combobox
options={[]}
value=""
onSelect={() => {}}
open={isOpen}
onOpenChange={handleOpenChange}
anchorRef={anchorRef}
desktopPlacement="top-start"
title="Select model"
>
{view === 'groups' ? (
<GroupsView
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
onSelect={handleSelect}
onDrillDown={(provider) => {
setView({ provider })
setSearchQuery('')
}}
/>
) : (
<DrillDownModelView
provider={view.provider}
providerDefinitions={providerDefinitions}
models={allProviderModels.get(view.provider) ?? []}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onSelect={handleSelect}
onBack={() => {
setView('groups')
setSearchQuery('')
}}
/>
)}
</Combobox>
</>
)
}
function GroupsView({
providerDefinitions,
allProviderModels,
selectedProvider,
selectedModel,
onSelect,
onDrillDown,
}: {
providerDefinitions: AgentProviderDefinition[]
allProviderModels: Map<string, AgentModelDefinition[]>
selectedProvider: string
selectedModel: string
onSelect: (provider: string, modelId: string) => void
onDrillDown: (provider: string) => void
}) {
const { theme } = useUnistyles()
return (
<View>
{providerDefinitions.map((def, index) => {
const models = allProviderModels.get(def.id) ?? []
const isInline = models.length <= INLINE_MODEL_THRESHOLD
const ProvIcon = getProviderIcon(def.id)
return (
<View key={def.id}>
{index > 0 ? <View style={styles.separator} /> : null}
{isInline ? (
<>
<View style={styles.sectionHeading}>
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.sectionHeadingText}>{def.label}</Text>
</View>
{models.map((model) => (
<ComboboxItem
key={model.id}
label={model.label}
selected={model.id === selectedModel && def.id === selectedProvider}
onPress={() => onSelect(def.id, model.id)}
/>
))}
</>
) : (
<Pressable
onPress={() => onDrillDown(def.id)}
style={({ pressed, hovered }) => [
styles.drillDownRow,
hovered && styles.drillDownRowHovered,
pressed && styles.drillDownRowPressed,
]}
>
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownText}>{def.label}</Text>
<View style={styles.drillDownTrailing}>
<Text style={styles.drillDownCount}>{models.length}</Text>
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
</View>
</Pressable>
)}
</View>
)
})}
</View>
)
}
function DrillDownModelView({
provider,
providerDefinitions,
models,
selectedProvider,
selectedModel,
searchQuery,
onSearchChange,
onSelect,
onBack,
}: {
provider: string
providerDefinitions: AgentProviderDefinition[]
models: AgentModelDefinition[]
selectedProvider: string
selectedModel: string
searchQuery: string
onSearchChange: (query: string) => void
onSelect: (provider: string, modelId: string) => void
onBack: () => void
}) {
const { theme } = useUnistyles()
const ProvIcon = getProviderIcon(provider)
const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider
const filteredModels = useMemo(() => {
if (!searchQuery.trim()) return models
const q = searchQuery.toLowerCase()
return models.filter(
(m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q)
)
}, [models, searchQuery])
return (
<View>
<Pressable
onPress={onBack}
style={({ pressed, hovered }) => [
styles.backButton,
hovered && styles.backButtonHovered,
pressed && styles.backButtonPressed,
]}
>
<ArrowLeft size={14} color={theme.colors.foregroundMuted} />
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.backButtonText}>{providerLabel}</Text>
</Pressable>
<SearchInput
placeholder="Search models..."
value={searchQuery}
onChangeText={onSearchChange}
autoFocus={Platform.OS === 'web'}
/>
{filteredModels.map((model) => (
<ComboboxItem
key={model.id}
label={model.label}
description={model.description}
selected={model.id === selectedModel && provider === selectedProvider}
onPress={() => onSelect(provider, model.id)}
/>
))}
{filteredModels.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>No models match your search</Text>
</View>
) : null}
</View>
)
}
const styles = StyleSheet.create((theme) => ({
trigger: {
height: 28,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'transparent',
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius['2xl'],
},
triggerHovered: {
backgroundColor: theme.colors.surface2,
},
triggerPressed: {
backgroundColor: theme.colors.surface0,
},
triggerDisabled: {
opacity: 0.5,
},
triggerText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
separator: {
height: 1,
backgroundColor: theme.colors.border,
marginVertical: theme.spacing[1],
},
sectionHeading: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
},
sectionHeadingText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
drillDownRow: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
minHeight: 36,
},
drillDownRowHovered: {
backgroundColor: theme.colors.surface1,
},
drillDownRowPressed: {
backgroundColor: theme.colors.surface2,
},
drillDownText: {
flex: 1,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
drillDownTrailing: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[1],
},
drillDownCount: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
backButtonHovered: {
backgroundColor: theme.colors.surface1,
},
backButtonPressed: {
backgroundColor: theme.colors.surface2,
},
backButtonText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
emptyState: {
paddingVertical: theme.spacing[4],
alignItems: 'center',
},
emptyStateText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
}))
@@ -0,0 +1,12 @@
import { Bot } from 'lucide-react-native'
import { ClaudeIcon } from '@/components/icons/claude-icon'
import { CodexIcon } from '@/components/icons/codex-icon'
const PROVIDER_ICONS: Record<string, typeof Bot> = {
claude: ClaudeIcon as unknown as typeof Bot,
codex: CodexIcon as unknown as typeof Bot,
}
export function getProviderIcon(provider: string): typeof Bot {
return PROVIDER_ICONS[provider] ?? Bot
}
@@ -1624,7 +1624,7 @@ const styles = StyleSheet.create((theme) => ({
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 4 },
}, },
sidebarRowSelected: { sidebarRowSelected: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface1,
}, },
workspaceRowContainer: { workspaceRowContainer: {
position: 'relative', position: 'relative',
+2 -2
View File
@@ -93,7 +93,7 @@ function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} /> return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />
} }
interface SearchInputProps { export interface SearchInputProps {
placeholder: string placeholder: string
value: string value: string
onChangeText: (text: string) => void onChangeText: (text: string) => void
@@ -101,7 +101,7 @@ interface SearchInputProps {
autoFocus?: boolean autoFocus?: boolean
} }
function SearchInput({ export function SearchInput({
placeholder, placeholder,
value, value,
onChangeText, onChangeText,
+68 -1
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueries } from "@tanstack/react-query";
import { import {
AGENT_PROVIDER_DEFINITIONS, AGENT_PROVIDER_DEFINITIONS,
type AgentProviderDefinition, type AgentProviderDefinition,
@@ -81,10 +81,13 @@ type UseAgentFormStateResult = {
agentDefinition?: AgentProviderDefinition; agentDefinition?: AgentProviderDefinition;
modeOptions: AgentMode[]; modeOptions: AgentMode[];
availableModels: AgentModelDefinition[]; availableModels: AgentModelDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
isAllModelsLoading: boolean;
availableThinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>; availableThinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
isModelLoading: boolean; isModelLoading: boolean;
modelError: string | null; modelError: string | null;
refreshProviderModels: () => void; refreshProviderModels: () => void;
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
workingDirIsEmpty: boolean; workingDirIsEmpty: boolean;
persistFormPreferences: () => Promise<void>; persistFormPreferences: () => Promise<void>;
}; };
@@ -440,6 +443,45 @@ export function useAgentFormState(
const availableModels = providerModelsQuery.data ?? null; const availableModels = providerModelsQuery.data ?? null;
const allProviderModelQueries = useQueries({
queries: providerDefinitions.map((def) => ({
queryKey: ["providerModels", formState.serverId, def.id, debouncedCwd],
enabled: Boolean(
isVisible &&
isTargetDaemonReady &&
formState.serverId &&
client &&
isConnected
),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.listProviderModels(def.id as AgentProvider, {
cwd: debouncedCwd,
});
if (payload.error) {
throw new Error(payload.error);
}
return payload.models ?? [];
},
})),
});
const allProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
for (let i = 0; i < providerDefinitions.length; i++) {
const query = allProviderModelQueries[i];
if (query?.data) {
map.set(providerDefinitions[i]!.id, query.data);
}
}
return map;
}, [allProviderModelQueries, providerDefinitions]);
const isAllModelsLoading = allProviderModelQueries.some((q) => q.isLoading);
// Combine initialValues with initialServerId for resolution // Combine initialValues with initialServerId for resolution
const combinedInitialValues = useMemo((): FormInitialValues | undefined => { const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
return combineInitialValues(initialValues, initialServerId); return combineInitialValues(initialValues, initialServerId);
@@ -568,6 +610,25 @@ export function useAgentFormState(
[preferences?.providerPreferences, providerDefinitionMap, updatePreferences] [preferences?.providerPreferences, providerDefinitionMap, updatePreferences]
); );
const setProviderAndModelFromUser = useCallback(
(provider: AgentProvider, modelId: string) => {
const providerDef = providerDefinitionMap.get(provider);
const providerPrefs = preferences?.providerPreferences?.[provider];
setFormState((prev) => ({
...prev,
provider,
model: modelId,
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
}));
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
void updatePreferences({ provider });
void updateProviderPreferences(provider, { model: modelId });
},
[preferences?.providerPreferences, providerDefinitionMap, updatePreferences, updateProviderPreferences]
);
const setModeFromUser = useCallback( const setModeFromUser = useCallback(
(modeId: string) => { (modeId: string) => {
setFormState((prev) => ({ ...prev, modeId })); setFormState((prev) => ({ ...prev, modeId }));
@@ -679,10 +740,13 @@ export function useAgentFormState(
agentDefinition, agentDefinition,
modeOptions, modeOptions,
availableModels: availableModels ?? [], availableModels: availableModels ?? [],
allProviderModels,
isAllModelsLoading,
availableThinkingOptions, availableThinkingOptions,
isModelLoading, isModelLoading,
modelError, modelError,
refreshProviderModels, refreshProviderModels,
setProviderAndModelFromUser,
workingDirIsEmpty, workingDirIsEmpty,
persistFormPreferences, persistFormPreferences,
}), }),
@@ -706,10 +770,13 @@ export function useAgentFormState(
agentDefinition, agentDefinition,
modeOptions, modeOptions,
availableModels, availableModels,
allProviderModels,
isAllModelsLoading,
availableThinkingOptions, availableThinkingOptions,
isModelLoading, isModelLoading,
modelError, modelError,
refreshProviderModels, refreshProviderModels,
setProviderAndModelFromUser,
workingDirIsEmpty, workingDirIsEmpty,
persistFormPreferences, persistFormPreferences,
] ]
@@ -213,10 +213,13 @@ function DraftAgentScreenContent({
providerDefinitions, providerDefinitions,
modeOptions, modeOptions,
availableModels, availableModels,
allProviderModels,
isAllModelsLoading,
availableThinkingOptions, availableThinkingOptions,
isModelLoading, isModelLoading,
modelError, modelError,
refreshProviderModels, refreshProviderModels,
setProviderAndModelFromUser,
persistFormPreferences, persistFormPreferences,
} = useAgentFormState({ } = useAgentFormState({
initialServerId: resolvedServerId ?? null, initialServerId: resolvedServerId ?? null,
@@ -1199,6 +1202,9 @@ function DraftAgentScreenContent({
selectedModel, selectedModel,
onSelectModel: setModelFromUser, onSelectModel: setModelFromUser,
isModelLoading, isModelLoading,
allProviderModels,
isAllModelsLoading,
onSelectProviderAndModel: setProviderAndModelFromUser,
thinkingOptions: availableThinkingOptions, thinkingOptions: availableThinkingOptions,
selectedThinkingOptionId, selectedThinkingOptionId,
onSelectThinkingOption: setThinkingOptionFromUser, onSelectThinkingOption: setThinkingOptionFromUser,
@@ -57,8 +57,11 @@ export function WorkspaceDraftAgentTab({
providerDefinitions, providerDefinitions,
modeOptions, modeOptions,
availableModels, availableModels,
allProviderModels,
isAllModelsLoading,
availableThinkingOptions, availableThinkingOptions,
isModelLoading, isModelLoading,
setProviderAndModelFromUser,
persistFormPreferences, persistFormPreferences,
} = useAgentFormState({ } = useAgentFormState({
initialServerId: serverId, initialServerId: serverId,
@@ -246,6 +249,9 @@ export function WorkspaceDraftAgentTab({
selectedModel, selectedModel,
onSelectModel: setModelFromUser, onSelectModel: setModelFromUser,
isModelLoading, isModelLoading,
allProviderModels,
isAllModelsLoading,
onSelectProviderAndModel: setProviderAndModelFromUser,
thinkingOptions: availableThinkingOptions, thinkingOptions: availableThinkingOptions,
selectedThinkingOptionId, selectedThinkingOptionId,
onSelectThinkingOption: setThinkingOptionFromUser, onSelectThinkingOption: setThinkingOptionFromUser,
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
getWorkingIndicatorDotStrength,
WORKING_INDICATOR_OFFSETS,
} from "./working-indicator";
describe("getWorkingIndicatorDotStrength", () => {
it("returns a stable triangular pulse over one cycle", () => {
expect(getWorkingIndicatorDotStrength(0, 0)).toBe(0);
expect(getWorkingIndicatorDotStrength(0.125, 0)).toBe(0.25);
expect(getWorkingIndicatorDotStrength(0.25, 0)).toBe(0.5);
expect(getWorkingIndicatorDotStrength(0.5, 0)).toBe(1);
expect(getWorkingIndicatorDotStrength(0.75, 0)).toBe(0.5);
expect(getWorkingIndicatorDotStrength(1, 0)).toBe(0);
});
it("keeps the dots phase-shifted instead of identical", () => {
const strengths = WORKING_INDICATOR_OFFSETS.map((offset) =>
getWorkingIndicatorDotStrength(0, offset)
);
expect(strengths).toEqual([0, 0.26666666666666666, 0.5333333333333333]);
});
it("wraps progress cleanly across loop boundaries", () => {
expect(getWorkingIndicatorDotStrength(1.1, 0)).toBeCloseTo(
getWorkingIndicatorDotStrength(0.1, 0)
);
expect(getWorkingIndicatorDotStrength(-0.1, 0)).toBeCloseTo(
getWorkingIndicatorDotStrength(0.9, 0)
);
});
});
@@ -0,0 +1,22 @@
export const WORKING_INDICATOR_CYCLE_MS = 1200;
export const WORKING_INDICATOR_OFFSETS = [
0,
160 / WORKING_INDICATOR_CYCLE_MS,
320 / WORKING_INDICATOR_CYCLE_MS,
] as const;
function normalizePhase(value: number): number {
const wrapped = value % 1;
return wrapped < 0 ? wrapped + 1 : wrapped;
}
export function getWorkingIndicatorDotStrength(
progress: number,
offset: number
): number {
const phase = normalizePhase(progress + offset);
if (phase <= 0.5) {
return phase * 2;
}
return (1 - phase) * 2;
}
@@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import type { AgentMode } from "./agent-sdk-types.js"; import type { AgentMode } from "./agent-sdk-types.js";
export type AgentModeColorTier = 'safe' | 'moderate' | 'dangerous' | 'readonly'; export type AgentModeColorTier = 'default' | 'safe' | 'moderate' | 'dangerous' | 'readonly';
export type AgentModeIcon = 'ShieldCheck' | 'ShieldAlert' | 'ShieldOff'; export type AgentModeIcon = 'ShieldCheck' | 'ShieldAlert' | 'ShieldOff';
export interface AgentModeVisuals { export interface AgentModeVisuals {
@@ -38,7 +38,7 @@ const CODEX_MODES: AgentProviderModeDefinition[] = [
]; ];
const OPENCODE_MODES: AgentProviderModeDefinition[] = [ const OPENCODE_MODES: AgentProviderModeDefinition[] = [
{ id: "build", label: "Build", description: "Allows edits and tool execution for implementation work", icon: "ShieldAlert", colorTier: "moderate" }, { id: "build", label: "Build", description: "Allows edits and tool execution for implementation work", icon: "ShieldCheck", colorTier: "default" },
{ id: "plan", label: "Plan", description: "Read-only planning mode that avoids file edits", icon: "ShieldCheck", colorTier: "readonly" }, { id: "plan", label: "Plan", description: "Read-only planning mode that avoids file edits", icon: "ShieldCheck", colorTier: "readonly" },
]; ];