diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx index 58102e6ac..7484dc5fa 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -36,6 +36,7 @@ const MODE_ICON_MAP: Record = { }; const MODE_COLOR_MAP: Record = { + default: baseColors.blue[500], safe: baseColors.green[500], moderate: baseColors.amber[500], dangerous: baseColors.red[500], diff --git a/packages/app/src/components/agent-input-area.status-controls.test.ts b/packages/app/src/components/agent-input-area.status-controls.test.ts index 9a24de7fa..0fa896ebd 100644 --- a/packages/app/src/components/agent-input-area.status-controls.test.ts +++ b/packages/app/src/components/agent-input-area.status-controls.test.ts @@ -19,6 +19,9 @@ describe('resolveStatusControlMode', () => { selectedModel: '', onSelectModel: () => undefined, isModelLoading: false, + allProviderModels: new Map(), + isAllModelsLoading: false, + onSelectProviderAndModel: () => undefined, thinkingOptions: [], selectedThinkingOptionId: '', onSelectThinkingOption: () => undefined, diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 45328d81e..e8089c580 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -10,8 +10,8 @@ import { ShieldOff, SlidersHorizontal, } from 'lucide-react-native' -import { ClaudeIcon } from '@/components/icons/claude-icon' -import { CodexIcon } from '@/components/icons/codex-icon' +import { getProviderIcon } from '@/components/provider-icons' +import { CombinedModelSelector } from '@/components/combined-model-selector' import { useQuery } from '@tanstack/react-query' import { useSessionStore } from '@/stores/session-store' import { @@ -69,6 +69,9 @@ export interface DraftAgentStatusBarProps { selectedModel: string onSelectModel: (modelId: string) => void isModelLoading: boolean + allProviderModels: Map + isAllModelsLoading: boolean + onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void thinkingOptions: NonNullable selectedThinkingOptionId: string onSelectThinkingOption: (thinkingOptionId: string) => void @@ -94,20 +97,14 @@ const MODE_ICONS = { ShieldOff, } as const -const PROVIDER_ICONS: Record = { - 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( 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 { switch (colorTier) { + case 'default': + return palette.blue[500] case 'safe': return palette.green[500] case 'moderate': @@ -117,7 +114,7 @@ function getModeIconColor( case 'readonly': return palette.purple[500] default: - return palette.green[500] + return palette.blue[500] } } @@ -298,35 +295,39 @@ function ControlledStatusBar({ ) : null} - setOpenSelector(openSelector === 'model' ? null : 'model')} - style={({ pressed, hovered }) => [ - styles.modeBadge, - hovered && styles.modeBadgeHovered, - (pressed || openSelector === 'model') && styles.modeBadgePressed, - modelDisabled && styles.disabledBadge, - ]} - accessibilityRole="button" - accessibilityLabel="Select agent model" - testID="agent-model-selector" - > - - {displayModel} - - - onSelectModel?.(id)} - searchable={comboboxModelOptions.length > SEARCH_THRESHOLD} - open={openSelector === 'model'} - onOpenChange={handleOpenChange('model')} - anchorRef={modelAnchorRef} - desktopPlacement="top-start" - /> + {canSelectModel ? ( + <> + setOpenSelector(openSelector === 'model' ? null : 'model')} + style={({ pressed, hovered }) => [ + styles.modeBadge, + hovered && styles.modeBadgeHovered, + (pressed || openSelector === 'model') && styles.modeBadgePressed, + modelDisabled && styles.disabledBadge, + ]} + accessibilityRole="button" + accessibilityLabel="Select agent model" + testID="agent-model-selector" + > + + {displayModel} + + + onSelectModel?.(id)} + searchable={comboboxModelOptions.length > SEARCH_THRESHOLD} + open={openSelector === 'model'} + onOpenChange={handleOpenChange('model')} + anchorRef={modelAnchorRef} + desktopPlacement="top-start" + /> + + ) : null} {thinkingOptions && thinkingOptions.length > 0 ? ( <> @@ -455,35 +456,37 @@ function ControlledStatusBar({ ) : null} - - - [ - styles.sheetSelect, - pressed && styles.sheetSelectPressed, - modelDisabled && styles.disabledSheetSelect, - ]} - accessibilityRole="button" - accessibilityLabel="Select agent model" - testID="agent-preferences-model" - > - {displayModel} - - - - {(modelOptions ?? []).map((model) => ( - onSelectModel?.(model.id)} - > - {model.label} - - ))} - - - + {canSelectModel ? ( + + + [ + styles.sheetSelect, + pressed && styles.sheetSelectPressed, + modelDisabled && styles.disabledSheetSelect, + ]} + accessibilityRole="button" + accessibilityLabel="Select agent model" + testID="agent-preferences-model" + > + {displayModel} + + + + {(modelOptions ?? []).map((model) => ( + onSelectModel?.(model.id)} + > + {model.label} + + ))} + + + + ) : null} {thinkingOptions && thinkingOptions.length > 0 ? ( @@ -638,17 +641,15 @@ export function DraftAgentStatusBar({ selectedModel, onSelectModel, isModelLoading, + allProviderModels, + isAllModelsLoading, + onSelectProviderAndModel, thinkingOptions, selectedThinkingOptionId, onSelectThinkingOption, disabled = false, }: DraftAgentStatusBarProps) { - const providerOptions = useMemo(() => { - return providerDefinitions.map((definition) => ({ - id: definition.id, - label: definition.label, - })) - }, [providerDefinitions]) + const isWeb = Platform.OS === 'web' const mappedModeOptions = useMemo(() => { if (modeOptions.length === 0) { @@ -660,14 +661,6 @@ export function DraftAgentStatusBar({ })) }, [modeOptions]) - const modelOptions = useMemo(() => { - 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(() => { return thinkingOptions.map((option) => ({ id: option.id, label: option.label })) }, [thinkingOptions]) @@ -676,6 +669,42 @@ export function DraftAgentStatusBar({ const effectiveSelectedThinkingOption = selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined + if (isWeb) { + return ( + + + 0 ? mappedThinkingOptions : undefined} + selectedThinkingOptionId={effectiveSelectedThinkingOption} + onSelectThinkingOption={onSelectThinkingOption} + disabled={disabled} + /> + + ) + } + + 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 ( item?.kind === "user_message"; const isToolSequenceItem = (item?: StreamItem) => @@ -659,50 +663,58 @@ export const AgentStreamView = forwardRef { - const sharedValues = [dotOne, dotTwo, dotThree]; - sharedValues.forEach((value, index) => { - value.value = withDelay( - index * bounceDelayOffset, - withRepeat( - withSequence( - withTiming(1, { duration: bounceDuration }), - withTiming(0, { duration: bounceDuration }) - ), - -1 - ) - ); - }); + progress.value = 0; + progress.value = withRepeat( + withTiming(1, { + duration: WORKING_INDICATOR_CYCLE_MS, + easing: Easing.linear, + }), + -1, + false + ); return () => { - sharedValues.forEach((value) => { - cancelAnimation(value); - value.value = 0; - }); + cancelAnimation(progress); + progress.value = 0; }; - }, [dotOne, dotTwo, dotThree]); + }, [progress]); const translateDistance = -2; - const dotOneStyle = useAnimatedStyle(() => ({ - opacity: 0.3 + dotOne.value * 0.7, - transform: [{ translateY: dotOne.value * translateDistance }], - })); + const dotOneStyle = useAnimatedStyle(() => { + const strength = getWorkingIndicatorDotStrength( + progress.value, + WORKING_INDICATOR_OFFSETS[0] + ); + return { + opacity: 0.3 + strength * 0.7, + transform: [{ translateY: strength * translateDistance }], + }; + }); - const dotTwoStyle = useAnimatedStyle(() => ({ - opacity: 0.3 + dotTwo.value * 0.7, - transform: [{ translateY: dotTwo.value * translateDistance }], - })); + const dotTwoStyle = useAnimatedStyle(() => { + const strength = getWorkingIndicatorDotStrength( + progress.value, + WORKING_INDICATOR_OFFSETS[1] + ); + return { + opacity: 0.3 + strength * 0.7, + transform: [{ translateY: strength * translateDistance }], + }; + }); - const dotThreeStyle = useAnimatedStyle(() => ({ - opacity: 0.3 + dotThree.value * 0.7, - transform: [{ translateY: dotThree.value * translateDistance }], - })); + const dotThreeStyle = useAnimatedStyle(() => { + const strength = getWorkingIndicatorDotStrength( + progress.value, + WORKING_INDICATOR_OFFSETS[2] + ); + return { + opacity: 0.3 + strength * 0.7, + transform: [{ translateY: strength * translateDistance }], + }; + }); return ( diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx new file mode 100644 index 000000000..beda9ffbd --- /dev/null +++ b/packages/app/src/components/combined-model-selector.tsx @@ -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 + 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(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 ( + <> + 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" + > + + {selectedModelLabel} + + + {}} + open={isOpen} + onOpenChange={handleOpenChange} + anchorRef={anchorRef} + desktopPlacement="top-start" + title="Select model" + > + {view === 'groups' ? ( + { + setView({ provider }) + setSearchQuery('') + }} + /> + ) : ( + { + setView('groups') + setSearchQuery('') + }} + /> + )} + + + ) +} + +function GroupsView({ + providerDefinitions, + allProviderModels, + selectedProvider, + selectedModel, + onSelect, + onDrillDown, +}: { + providerDefinitions: AgentProviderDefinition[] + allProviderModels: Map + selectedProvider: string + selectedModel: string + onSelect: (provider: string, modelId: string) => void + onDrillDown: (provider: string) => void +}) { + const { theme } = useUnistyles() + + return ( + + {providerDefinitions.map((def, index) => { + const models = allProviderModels.get(def.id) ?? [] + const isInline = models.length <= INLINE_MODEL_THRESHOLD + const ProvIcon = getProviderIcon(def.id) + + return ( + + {index > 0 ? : null} + + {isInline ? ( + <> + + + {def.label} + + {models.map((model) => ( + onSelect(def.id, model.id)} + /> + ))} + + ) : ( + onDrillDown(def.id)} + style={({ pressed, hovered }) => [ + styles.drillDownRow, + hovered && styles.drillDownRowHovered, + pressed && styles.drillDownRowPressed, + ]} + > + + {def.label} + + {models.length} + + + + )} + + ) + })} + + ) +} + +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 ( + + [ + styles.backButton, + hovered && styles.backButtonHovered, + pressed && styles.backButtonPressed, + ]} + > + + + {providerLabel} + + + + + {filteredModels.map((model) => ( + onSelect(provider, model.id)} + /> + ))} + + {filteredModels.length === 0 ? ( + + No models match your search + + ) : null} + + ) +} + +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, + }, +})) diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts new file mode 100644 index 000000000..4bb850589 --- /dev/null +++ b/packages/app/src/components/provider-icons.ts @@ -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 = { + 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 +} diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 9fe3ce2aa..723aad733 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -1624,7 +1624,7 @@ const styles = StyleSheet.create((theme) => ({ shadowOffset: { width: 0, height: 4 }, }, sidebarRowSelected: { - backgroundColor: theme.colors.surface2, + backgroundColor: theme.colors.surface1, }, workspaceRowContainer: { position: 'relative', diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 661893178..90303159b 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -93,7 +93,7 @@ function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) { return } -interface SearchInputProps { +export interface SearchInputProps { placeholder: string value: string onChangeText: (text: string) => void @@ -101,7 +101,7 @@ interface SearchInputProps { autoFocus?: boolean } -function SearchInput({ +export function SearchInput({ placeholder, value, onChangeText, diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 40ec499f3..82c68a532 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueries } from "@tanstack/react-query"; import { AGENT_PROVIDER_DEFINITIONS, type AgentProviderDefinition, @@ -81,10 +81,13 @@ type UseAgentFormStateResult = { agentDefinition?: AgentProviderDefinition; modeOptions: AgentMode[]; availableModels: AgentModelDefinition[]; + allProviderModels: Map; + isAllModelsLoading: boolean; availableThinkingOptions: NonNullable; isModelLoading: boolean; modelError: string | null; refreshProviderModels: () => void; + setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void; workingDirIsEmpty: boolean; persistFormPreferences: () => Promise; }; @@ -440,6 +443,45 @@ export function useAgentFormState( 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(); + 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 const combinedInitialValues = useMemo((): FormInitialValues | undefined => { return combineInitialValues(initialValues, initialServerId); @@ -568,6 +610,25 @@ export function useAgentFormState( [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( (modeId: string) => { setFormState((prev) => ({ ...prev, modeId })); @@ -679,10 +740,13 @@ export function useAgentFormState( agentDefinition, modeOptions, availableModels: availableModels ?? [], + allProviderModels, + isAllModelsLoading, availableThinkingOptions, isModelLoading, modelError, refreshProviderModels, + setProviderAndModelFromUser, workingDirIsEmpty, persistFormPreferences, }), @@ -706,10 +770,13 @@ export function useAgentFormState( agentDefinition, modeOptions, availableModels, + allProviderModels, + isAllModelsLoading, availableThinkingOptions, isModelLoading, modelError, refreshProviderModels, + setProviderAndModelFromUser, workingDirIsEmpty, persistFormPreferences, ] diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index a91a9ad4f..01e042361 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -213,10 +213,13 @@ function DraftAgentScreenContent({ providerDefinitions, modeOptions, availableModels, + allProviderModels, + isAllModelsLoading, availableThinkingOptions, isModelLoading, modelError, refreshProviderModels, + setProviderAndModelFromUser, persistFormPreferences, } = useAgentFormState({ initialServerId: resolvedServerId ?? null, @@ -1199,6 +1202,9 @@ function DraftAgentScreenContent({ selectedModel, onSelectModel: setModelFromUser, isModelLoading, + allProviderModels, + isAllModelsLoading, + onSelectProviderAndModel: setProviderAndModelFromUser, thinkingOptions: availableThinkingOptions, selectedThinkingOptionId, onSelectThinkingOption: setThinkingOptionFromUser, diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index 1e84f5b6e..721417b99 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -57,8 +57,11 @@ export function WorkspaceDraftAgentTab({ providerDefinitions, modeOptions, availableModels, + allProviderModels, + isAllModelsLoading, availableThinkingOptions, isModelLoading, + setProviderAndModelFromUser, persistFormPreferences, } = useAgentFormState({ initialServerId: serverId, @@ -246,6 +249,9 @@ export function WorkspaceDraftAgentTab({ selectedModel, onSelectModel: setModelFromUser, isModelLoading, + allProviderModels, + isAllModelsLoading, + onSelectProviderAndModel: setProviderAndModelFromUser, thinkingOptions: availableThinkingOptions, selectedThinkingOptionId, onSelectThinkingOption: setThinkingOptionFromUser, diff --git a/packages/app/src/utils/working-indicator.test.ts b/packages/app/src/utils/working-indicator.test.ts new file mode 100644 index 000000000..a6cd7c3c3 --- /dev/null +++ b/packages/app/src/utils/working-indicator.test.ts @@ -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) + ); + }); +}); diff --git a/packages/app/src/utils/working-indicator.ts b/packages/app/src/utils/working-indicator.ts new file mode 100644 index 000000000..44e5466a0 --- /dev/null +++ b/packages/app/src/utils/working-indicator.ts @@ -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; +} diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts index 3eb950c7e..0b461acac 100644 --- a/packages/server/src/server/agent/provider-manifest.ts +++ b/packages/server/src/server/agent/provider-manifest.ts @@ -1,7 +1,7 @@ import { z } from "zod"; 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 interface AgentModeVisuals { @@ -38,7 +38,7 @@ const CODEX_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" }, ];