refactor app draft status controls into input area

This commit is contained in:
Mohamed Boudra
2026-03-05 15:07:56 +07:00
parent 8371ea0c7d
commit 885b575d14
14 changed files with 1370 additions and 894 deletions
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { resolveStatusControlMode } from './agent-input-area.status-controls'
describe('resolveStatusControlMode', () => {
it('uses ready mode when no controlled status controls are provided', () => {
expect(resolveStatusControlMode(undefined)).toBe('ready')
})
it('uses draft mode when controlled status controls are provided', () => {
expect(
resolveStatusControlMode({
providerDefinitions: [],
selectedProvider: 'codex',
onSelectProvider: () => undefined,
modeOptions: [],
selectedMode: '',
onSelectMode: () => undefined,
models: [],
selectedModel: '',
onSelectModel: () => undefined,
isModelLoading: false,
thinkingOptions: [],
selectedThinkingOptionId: '',
onSelectThinkingOption: () => undefined,
})
).toBe('draft')
})
})
@@ -0,0 +1,5 @@
import type { DraftAgentStatusBarProps } from './agent-status-bar'
export function resolveStatusControlMode(statusControls?: DraftAgentStatusBarProps) {
return statusControls ? 'draft' : 'ready'
}
@@ -7,7 +7,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useIsFocused } from '@react-navigation/native'
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from '@/constants/layout'
import { generateMessageId, type StreamItem } from '@/types/stream'
import { AgentStatusBar } from './agent-status-bar'
import { AgentStatusBar, DraftAgentStatusBar, type DraftAgentStatusBarProps } from './agent-status-bar'
import { useImageAttachmentPicker } from '@/hooks/use-image-attachment-picker'
import { useSessionStore } from '@/stores/session-store'
import { useDraftStore } from '@/stores/draft-store'
@@ -34,6 +34,7 @@ import {
deleteAttachments,
persistAttachmentFromFileUri,
} from '@/attachments/service'
import { resolveStatusControlMode } from '@/components/agent-input-area.status-controls'
import { shouldSkipDraftPersist } from '@/components/agent-input-area.draft-persist-guard'
import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation'
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
@@ -63,6 +64,8 @@ interface AgentInputAreaProps {
commandDraftConfig?: DraftCommandConfig
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
onMessageSent?: () => void
/** Controlled status controls rendered in input area (draft flows). */
statusControls?: DraftAgentStatusBarProps
}
const EMPTY_ARRAY: readonly QueuedMessage[] = []
@@ -80,6 +83,7 @@ export function AgentInputArea({
onAddImages,
commandDraftConfig,
onMessageSent,
statusControls,
}: AgentInputAreaProps) {
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`)
const { theme } = useUnistyles()
@@ -710,7 +714,12 @@ export function AgentInputArea({
</View>
)
const leftContent = <AgentStatusBar agentId={agentId} serverId={serverId} />
const leftContent =
resolveStatusControlMode(statusControls) === 'draft' && statusControls ? (
<DraftAgentStatusBar {...statusControls} />
) : (
<AgentStatusBar agentId={agentId} serverId={serverId} />
)
return (
<Animated.View
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { normalizeModelId, resolveAgentModelSelection } from './agent-status-bar.utils'
describe('normalizeModelId', () => {
it('treats empty and default values as unset', () => {
expect(normalizeModelId('')).toBeNull()
expect(normalizeModelId(' default ')).toBeNull()
expect(normalizeModelId(undefined)).toBeNull()
})
it('returns trimmed model ids', () => {
expect(normalizeModelId(' gpt-5.1-codex ')).toBe('gpt-5.1-codex')
})
})
describe('resolveAgentModelSelection', () => {
it('prefers runtime model over configured model', () => {
const selection = resolveAgentModelSelection({
models: [
{
id: 'a',
provider: 'codex',
label: 'Model A',
thinkingOptions: [{ id: 'low', label: 'Low' }],
defaultThinkingOptionId: 'low',
},
],
runtimeModelId: 'a',
configuredModelId: 'b',
explicitThinkingOptionId: null,
})
expect(selection.activeModelId).toBe('a')
expect(selection.displayModel).toBe('Model A')
expect(selection.selectedThinkingId).toBe('low')
})
it('uses explicit thinking option when provided', () => {
const selection = resolveAgentModelSelection({
models: [
{
id: 'a',
provider: 'codex',
label: 'Model A',
thinkingOptions: [
{ id: 'low', label: 'Low' },
{ id: 'high', label: 'High' },
],
defaultThinkingOptionId: 'low',
},
],
runtimeModelId: 'a',
configuredModelId: null,
explicitThinkingOptionId: 'high',
})
expect(selection.selectedThinkingId).toBe('high')
expect(selection.displayThinking).toBe('High')
})
})
+458 -248
View File
@@ -1,162 +1,208 @@
import { View, Text, Platform, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Brain, ChevronDown, SlidersHorizontal } from "lucide-react-native";
import { useSessionStore } from "@/stores/session-store";
import { useMemo, useState } from 'react'
import { View, Text, Platform, Pressable } from 'react-native'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { Brain, ChevronDown, SlidersHorizontal } from 'lucide-react-native'
import { useQuery } from '@tanstack/react-query'
import { useSessionStore } from '@/stores/session-store'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
} from '@/components/ui/dropdown-menu'
import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet'
import type {
AgentMode,
AgentModelDefinition,
AgentProvider,
} from '@server/server/agent/agent-sdk-types'
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest'
import { normalizeModelId, resolveAgentModelSelection } from '@/components/agent-status-bar.utils'
type StatusOption = {
id: string
label: string
}
type ControlledAgentStatusBarProps = {
providerOptions?: StatusOption[]
selectedProviderId?: string
onSelectProvider?: (providerId: string) => void
modeOptions?: StatusOption[]
selectedModeId?: string
onSelectMode?: (modeId: string) => void
modelOptions?: StatusOption[]
selectedModelId?: string
onSelectModel?: (modelId: string) => void
thinkingOptions?: StatusOption[]
selectedThinkingOptionId?: string
onSelectThinkingOption?: (thinkingOptionId: string) => void
disabled?: boolean
isModelLoading?: boolean
}
export interface DraftAgentStatusBarProps {
providerDefinitions: AgentProviderDefinition[]
selectedProvider: AgentProvider
onSelectProvider: (provider: AgentProvider) => void
modeOptions: AgentMode[]
selectedMode: string
onSelectMode: (modeId: string) => void
models: AgentModelDefinition[]
selectedModel: string
onSelectModel: (modelId: string) => void
isModelLoading: boolean
thinkingOptions: NonNullable<AgentModelDefinition['thinkingOptions']>
selectedThinkingOptionId: string
onSelectThinkingOption: (thinkingOptionId: string) => void
disabled?: boolean
}
interface AgentStatusBarProps {
agentId: string;
serverId: string;
agentId: string
serverId: string
}
function normalizeModelId(modelId: string | null | undefined): string | null {
const normalized = typeof modelId === "string" ? modelId.trim() : "";
if (!normalized || normalized.toLowerCase() === "default") {
return null;
function findOptionLabel(options: StatusOption[] | undefined, selectedId: string | undefined, fallback: string) {
if (!options || options.length === 0) {
return fallback
}
return normalized;
const selected = options.find((option) => option.id === selectedId)
return selected?.label ?? fallback
}
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const { theme } = useUnistyles();
const IS_WEB = Platform.OS === "web";
const [prefsOpen, setPrefsOpen] = useState(false);
const dropdownMaxWidth = IS_WEB ? 360 : undefined;
function ControlledStatusBar({
providerOptions,
selectedProviderId,
onSelectProvider,
modeOptions,
selectedModeId,
onSelectMode,
modelOptions,
selectedModelId,
onSelectModel,
thinkingOptions,
selectedThinkingOptionId,
onSelectThinkingOption,
disabled = false,
isModelLoading = false,
}: ControlledAgentStatusBarProps) {
const { theme } = useUnistyles()
const isWeb = Platform.OS === 'web'
const [prefsOpen, setPrefsOpen] = useState(false)
const dropdownMaxWidth = isWeb ? 360 : undefined
// Select only the specific agent (not all agents)
const agent = useSessionStore((state) =>
state.sessions[serverId]?.agents?.get(agentId)
);
const canSelectProvider = Boolean(onSelectProvider && providerOptions && providerOptions.length > 0)
const canSelectMode = Boolean(onSelectMode && modeOptions && modeOptions.length > 0)
const canSelectModel = Boolean(onSelectModel)
const canSelectThinking = Boolean(
onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0
)
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const displayProvider = findOptionLabel(providerOptions, selectedProviderId, 'Provider')
const displayMode = findOptionLabel(modeOptions, selectedModeId, 'Default')
const displayModel =
isModelLoading && (!modelOptions || modelOptions.length === 0)
? 'Loading models...'
: findOptionLabel(modelOptions, selectedModelId, 'Auto')
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, 'auto')
const canFetchModels =
Boolean(client) && Boolean(agent?.provider) && (IS_WEB || prefsOpen);
const modelsQuery = useQuery({
queryKey: [
"providerModels",
serverId,
agent?.provider ?? "__missing_provider__",
agent?.cwd ?? "__missing_cwd__",
],
enabled: canFetchModels,
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client || !agent) {
throw new Error("Daemon client unavailable");
}
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
if (payload.error) {
throw new Error(payload.error);
}
return payload.models ?? [];
},
});
const models = modelsQuery.data ?? null;
const hasAnyControl =
Boolean(providerOptions?.length) ||
Boolean(modeOptions?.length) ||
canSelectModel ||
Boolean(thinkingOptions?.length)
function handleModeChange(modeId: string) {
if (!client || !agent) {
return;
}
void client.setAgentMode(agentId, modeId).catch((error) => {
console.warn("[AgentStatusBar] setAgentMode failed", error);
});
if (!hasAnyControl) {
return null
}
const normalizedRuntimeModelId = normalizeModelId(agent?.runtimeInfo?.model);
const normalizedConfiguredModelId = normalizeModelId(agent?.model);
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId;
const selectedModel = useMemo(() => {
if (!models || !preferredModelId) return null;
return models.find((m) => m.id === preferredModelId) ?? null;
}, [models, preferredModelId]);
const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
const displayModel = selectedModel ? selectedModel.label : preferredModelId ?? "Auto";
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
const explicitThinkingId =
agent?.thinkingOptionId && agent.thinkingOptionId !== "default"
? agent.thinkingOptionId
: null;
const selectedThinkingId =
explicitThinkingId ?? selectedModel?.defaultThinkingOptionId ?? null;
const selectedThinking = thinkingOptions?.find((o) => o.id === selectedThinkingId) ?? null;
const displayThinking =
selectedThinking?.label ??
(selectedThinkingId === "default" ? "Model default" : selectedThinkingId ?? "auto");
const displayMode =
agent?.availableModes?.find((m) => m.id === agent.currentModeId)?.label ||
agent?.currentModeId ||
"default";
if (!agent) {
return null;
}
const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0
return (
<View style={[styles.container, IS_WEB && { marginBottom: -theme.spacing[1] }]}>
{/* Agent Mode Badge (desktop only — on mobile, mode is in the preferences sheet) */}
{IS_WEB && agent.availableModes && agent.availableModes.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
style={({ pressed, hovered, open }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || open) && styles.modeBadgePressed,
]}
accessibilityRole="button"
accessibilityLabel="Select agent mode"
testID="agent-mode-selector"
>
<Text style={styles.modeBadgeText}>
{agent.availableModes?.find((m) => m.id === agent.currentModeId)
?.label ||
agent.currentModeId ||
"default"}
</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
maxWidth={dropdownMaxWidth}
testID="agent-mode-menu"
>
{agent.availableModes.map((mode) => {
const isActive = mode.id === agent.currentModeId;
return (
<DropdownMenuItem
key={mode.id}
selected={isActive}
onSelect={() => handleModeChange(mode.id)}
>
{mode.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Desktop: inline dropdowns for model/thinking */}
{IS_WEB && (
<View style={[styles.container, isWeb && { marginBottom: -theme.spacing[1] }]}>
{isWeb ? (
<>
{providerOptions && providerOptions.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger
disabled={disabled || !canSelectProvider}
style={({ pressed, hovered, open }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || open) && styles.modeBadgePressed,
(disabled || !canSelectProvider) && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel="Select agent provider"
testID="agent-provider-selector"
>
<Text style={styles.modeBadgeText}>{displayProvider}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
maxWidth={dropdownMaxWidth}
testID="agent-provider-menu"
>
{providerOptions.map((provider) => (
<DropdownMenuItem
key={provider.id}
selected={provider.id === selectedProviderId}
onSelect={() => onSelectProvider?.(provider.id)}
>
{provider.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{modeOptions && modeOptions.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger
disabled={disabled || !canSelectMode}
style={({ pressed, hovered, open }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || open) && styles.modeBadgePressed,
(disabled || !canSelectMode) && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel="Select agent mode"
testID="agent-mode-selector"
>
<Text style={styles.modeBadgeText}>{displayMode}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
maxWidth={dropdownMaxWidth}
testID="agent-mode-menu"
>
{modeOptions.map((mode) => (
<DropdownMenuItem
key={mode.id}
selected={mode.id === selectedModeId}
onSelect={() => onSelectMode?.(mode.id)}
>
{mode.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<DropdownMenu>
<DropdownMenuTrigger
disabled={modelDisabled}
style={({ pressed, hovered, open }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || open) && styles.modeBadgePressed,
modelDisabled && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel="Select agent model"
@@ -171,35 +217,27 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
maxWidth={dropdownMaxWidth}
testID="agent-model-menu"
>
{models?.map((model) => {
const isActive = model.id === activeModelId;
return (
<DropdownMenuItem
key={model.id}
selected={isActive}
onSelect={() => {
if (!client) {
return;
}
void client.setAgentModel(agentId, model.id).catch((error) => {
console.warn("[AgentStatusBar] setAgentModel failed", error);
});
}}
>
{model.label}
</DropdownMenuItem>
);
})}
{(modelOptions ?? []).map((model) => (
<DropdownMenuItem
key={model.id}
selected={model.id === selectedModelId}
onSelect={() => onSelectModel?.(model.id)}
>
{model.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{thinkingOptions && thinkingOptions.length > 1 && (
{thinkingOptions && thinkingOptions.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger
disabled={disabled || !canSelectThinking}
style={({ pressed, hovered, open }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || open) && styles.modeBadgePressed,
(disabled || !canSelectThinking) && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel="Select thinking option"
@@ -219,35 +257,20 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
maxWidth={dropdownMaxWidth}
testID="agent-thinking-menu"
>
{thinkingOptions.map((opt) => {
const isActive = opt.id === selectedThinkingId;
return (
<DropdownMenuItem
key={opt.id}
selected={isActive}
onSelect={() => {
if (!client) {
return;
}
void client
.setAgentThinkingOption(agentId, opt.id)
.catch((error) => {
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
});
}}
>
{opt.label}
</DropdownMenuItem>
);
})}
{thinkingOptions.map((thinking) => (
<DropdownMenuItem
key={thinking.id}
selected={thinking.id === selectedThinkingOptionId}
onSelect={() => onSelectThinkingOption?.(thinking.id)}
>
{thinking.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
) : null}
</>
)}
{/* Mobile: preferences button opens a bottom sheet */}
{!IS_WEB && (
) : (
<>
<Pressable
onPress={() => setPrefsOpen(true)}
@@ -268,13 +291,47 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
onClose={() => setPrefsOpen(false)}
testID="agent-preferences-sheet"
>
{agent.availableModes && agent.availableModes.length > 0 && (
{providerOptions && providerOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu>
<DropdownMenuTrigger
disabled={disabled || !canSelectProvider}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
(disabled || !canSelectProvider) && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel="Select agent provider"
testID="agent-preferences-provider"
>
<Text style={styles.sheetSelectText}>{displayProvider}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{providerOptions.map((provider) => (
<DropdownMenuItem
key={provider.id}
selected={provider.id === selectedProviderId}
onSelect={() => onSelectProvider?.(provider.id)}
>
{provider.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
{modeOptions && modeOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu>
<DropdownMenuTrigger
disabled={disabled || !canSelectMode}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
(disabled || !canSelectMode) && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel="Select agent mode"
@@ -284,29 +341,28 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{agent.availableModes.map((mode) => {
const isActive = mode.id === agent.currentModeId;
return (
<DropdownMenuItem
key={mode.id}
selected={isActive}
onSelect={() => handleModeChange(mode.id)}
>
{mode.label}
</DropdownMenuItem>
);
})}
{modeOptions.map((mode) => (
<DropdownMenuItem
key={mode.id}
selected={mode.id === selectedModeId}
onSelect={() => onSelectMode?.(mode.id)}
>
{mode.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
)}
) : null}
<View style={styles.sheetSection}>
<DropdownMenu>
<DropdownMenuTrigger
disabled={modelDisabled}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
modelDisabled && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel="Select agent model"
@@ -316,36 +372,28 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{models?.map((model) => {
const isActive = model.id === activeModelId;
return (
<DropdownMenuItem
key={model.id}
selected={isActive}
onSelect={() => {
if (!client) {
return;
}
void client.setAgentModel(agentId, model.id).catch((error) => {
console.warn("[AgentStatusBar] setAgentModel failed", error);
});
}}
>
{model.label}
</DropdownMenuItem>
);
})}
{(modelOptions ?? []).map((model) => (
<DropdownMenuItem
key={model.id}
selected={model.id === selectedModelId}
onSelect={() => onSelectModel?.(model.id)}
>
{model.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
{thinkingOptions && thinkingOptions.length > 1 && (
{thinkingOptions && thinkingOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu>
<DropdownMenuTrigger
disabled={disabled || !canSelectThinking}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
(disabled || !canSelectThinking) && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel="Select thinking option"
@@ -355,53 +403,209 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{thinkingOptions.map((opt) => {
const isActive = opt.id === selectedThinkingId;
return (
<DropdownMenuItem
key={opt.id}
selected={isActive}
onSelect={() => {
if (!client) {
return;
}
void client
.setAgentThinkingOption(agentId, opt.id)
.catch((error) => {
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
});
}}
>
{opt.label}
</DropdownMenuItem>
);
})}
{thinkingOptions.map((thinking) => (
<DropdownMenuItem
key={thinking.id}
selected={thinking.id === selectedThinkingOptionId}
onSelect={() => onSelectThinkingOption?.(thinking.id)}
>
{thinking.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
)}
) : null}
</AdaptiveModalSheet>
</>
)}
</View>
);
)
}
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId))
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null)
const modelsQuery = useQuery({
queryKey: [
'providerModels',
serverId,
agent?.provider ?? '__missing_provider__',
agent?.cwd ?? '__missing_cwd__',
],
enabled: Boolean(client && agent?.provider),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client || !agent) {
throw new Error('Daemon client unavailable')
}
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd })
if (payload.error) {
throw new Error(payload.error)
}
return payload.models ?? []
},
})
const models = modelsQuery.data ?? null
const displayMode =
agent?.availableModes?.find((mode) => mode.id === agent.currentModeId)?.label ||
agent?.currentModeId ||
'default'
const modelSelection = resolveAgentModelSelection({
models,
runtimeModelId: agent?.runtimeInfo?.model,
configuredModelId: agent?.model,
explicitThinkingOptionId: agent?.thinkingOptionId,
})
const modeOptions = useMemo<StatusOption[]>(() => {
return (agent?.availableModes ?? []).map((mode) => ({
id: mode.id,
label: mode.label,
}))
}, [agent?.availableModes])
const modelOptions = useMemo<StatusOption[]>(() => {
return (models ?? []).map((model) => ({ id: model.id, label: model.label }))
}, [models])
const thinkingOptions = useMemo<StatusOption[]>(() => {
return (modelSelection.thinkingOptions ?? []).map((option) => ({
id: option.id,
label: option.label,
}))
}, [modelSelection.thinkingOptions])
if (!agent) {
return null
}
return (
<ControlledStatusBar
modeOptions={
modeOptions.length > 0
? modeOptions
: [{ id: agent.currentModeId ?? '', label: displayMode }]
}
selectedModeId={agent.currentModeId ?? undefined}
onSelectMode={(modeId) => {
if (!client) {
return
}
void client.setAgentMode(agentId, modeId).catch((error) => {
console.warn('[AgentStatusBar] setAgentMode failed', error)
})
}}
modelOptions={modelOptions}
selectedModelId={modelSelection.activeModelId ?? undefined}
onSelectModel={(modelId) => {
if (!client) {
return
}
void client.setAgentModel(agentId, modelId).catch((error) => {
console.warn('[AgentStatusBar] setAgentModel failed', error)
})
}}
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
onSelectThinkingOption={(thinkingOptionId) => {
if (!client) {
return
}
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
console.warn('[AgentStatusBar] setAgentThinkingOption failed', error)
})
}}
isModelLoading={modelsQuery.isPending || modelsQuery.isFetching}
disabled={!client}
/>
)
}
export function DraftAgentStatusBar({
providerDefinitions,
selectedProvider,
onSelectProvider,
modeOptions,
selectedMode,
onSelectMode,
models,
selectedModel,
onSelectModel,
isModelLoading,
thinkingOptions,
selectedThinkingOptionId,
onSelectThinkingOption,
disabled = false,
}: DraftAgentStatusBarProps) {
const providerOptions = useMemo<StatusOption[]>(() => {
return providerDefinitions.map((definition) => ({
id: definition.id,
label: definition.label,
}))
}, [providerDefinitions])
const mappedModeOptions = useMemo<StatusOption[]>(() => {
if (modeOptions.length === 0) {
return [{ id: '', label: 'Default' }]
}
return modeOptions.map((mode) => ({ id: mode.id, label: mode.label }))
}, [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[]>(() => {
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }))
}, [thinkingOptions])
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || ''
const effectiveSelectedThinkingOption =
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined
return (
<ControlledStatusBar
providerOptions={providerOptions}
selectedProviderId={selectedProvider}
onSelectProvider={(providerId) => onSelectProvider(providerId as AgentProvider)}
modeOptions={mappedModeOptions}
selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode}
modelOptions={modelOptions}
selectedModelId={selectedModel}
onSelectModel={onSelectModel}
isModelLoading={isModelLoading}
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption}
disabled={disabled}
/>
)
}
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "center",
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[1],
},
modeBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "transparent",
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'transparent',
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius["2xl"],
borderRadius: theme.borderRadius['2xl'],
},
modeBadgeHovered: {
backgroundColor: theme.colors.surface2,
@@ -409,6 +613,9 @@ const styles = StyleSheet.create((theme) => ({
modeBadgePressed: {
backgroundColor: theme.colors.surface0,
},
disabledBadge: {
opacity: 0.5,
},
modeBadgeText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
@@ -418,8 +625,8 @@ const styles = StyleSheet.create((theme) => ({
width: 34,
height: 34,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
alignItems: 'center',
justifyContent: 'center',
},
prefsButtonPressed: {
backgroundColor: theme.colors.surface0,
@@ -428,9 +635,9 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
sheetSelect: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
@@ -442,10 +649,13 @@ const styles = StyleSheet.create((theme) => ({
sheetSelectPressed: {
backgroundColor: theme.colors.surface2,
},
disabledSheetSelect: {
opacity: 0.5,
},
sheetSelectText: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
}));
}))
@@ -0,0 +1,45 @@
import type { AgentModelDefinition } from '@server/server/agent/agent-sdk-types'
export function normalizeModelId(modelId: string | null | undefined): string | null {
const normalized = typeof modelId === 'string' ? modelId.trim() : ''
if (!normalized || normalized.toLowerCase() === 'default') {
return null
}
return normalized
}
export function resolveAgentModelSelection(input: {
models: AgentModelDefinition[] | null
runtimeModelId: string | null | undefined
configuredModelId: string | null | undefined
explicitThinkingOptionId: string | null | undefined
}) {
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId)
const normalizedConfiguredModelId = normalizeModelId(configuredModelId)
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId
const selectedModel =
models && preferredModelId ? models.find((model) => model.id === preferredModelId) ?? null : null
const activeModelId = selectedModel?.id ?? preferredModelId ?? null
const displayModel = selectedModel?.label ?? preferredModelId ?? 'Auto'
const thinkingOptions = selectedModel?.thinkingOptions ?? null
const selectedThinkingId =
explicitThinkingOptionId && explicitThinkingOptionId !== 'default'
? explicitThinkingOptionId
: selectedModel?.defaultThinkingOptionId ?? null
const selectedThinking = thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null
const displayThinking =
selectedThinking?.label ??
(selectedThinkingId === 'default' ? 'Model default' : selectedThinkingId ?? 'auto')
return {
selectedModel,
activeModelId,
displayModel,
thinkingOptions,
selectedThinkingId,
displayThinking,
}
}