Add combined model selector and refactor working indicator
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user