feat: add live model and thinking option preferences UI (#8)

* feat: add live model and thinking option preferences UI

* refactor: remove agent variant support and clean up thinking options

* feat: live model + thinking presets (RPC)
This commit is contained in:
Mohamed Boudra
2026-02-04 08:18:15 +07:00
committed by GitHub
parent 03f383b908
commit 469a25c724
50 changed files with 1976 additions and 894 deletions
@@ -27,6 +27,7 @@ import {
import { Theme } from "@/styles/theme";
import { CommandAutocomplete } from "./command-autocomplete";
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images";
type QueuedMessage = {
id: string;
@@ -85,11 +86,8 @@ export function AgentInputArea({
);
const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY;
const methods = useSessionStore((state) => state.sessions[serverId]?.methods);
const sendAgentMessage = methods?.sendAgentMessage;
const cancelAgentRun = methods?.cancelAgentRun;
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const { isVoiceMode, isMuted: isVoiceMuted, toggleMute: toggleVoiceMute } = useVoice();
@@ -129,7 +127,9 @@ export function AgentInputArea({
const { pickImages } = useImageAttachmentPicker();
const agentIdRef = useRef(agentId);
const sendAgentMessageRef = useRef(sendAgentMessage);
const sendAgentMessageRef = useRef<
((agentId: string, text: string, images?: ImageAttachment[]) => Promise<void>) | null
>(null);
const onSubmitMessageRef = useRef(onSubmitMessage);
const messageInputRef = useRef<MessageInputRef>(null);
@@ -148,7 +148,10 @@ export function AgentInputArea({
await onSubmitMessageRef.current({ text, images });
return;
}
await sendAgentMessageRef.current?.(agentIdRef.current, text, images);
if (!sendAgentMessageRef.current) {
throw new Error("Host is not connected");
}
await sendAgentMessageRef.current(agentIdRef.current, text, images);
},
[]
);
@@ -158,8 +161,36 @@ export function AgentInputArea({
}, [agentId]);
useEffect(() => {
sendAgentMessageRef.current = sendAgentMessage;
}, [sendAgentMessage]);
sendAgentMessageRef.current = async (
agentId: string,
text: string,
images?: ImageAttachment[]
) => {
if (!client) {
throw new Error("Host is not connected");
}
const messageId = generateMessageId();
setAgentStreamTail(serverId, (prev) => {
const currentStream = prev.get(agentId) || [];
const nextItem: any = {
kind: "user_message",
id: messageId,
text,
timestamp: new Date(),
};
const updated = new Map(prev);
updated.set(agentId, [...currentStream, nextItem]);
return updated;
});
const imagesData = await encodeImages(images);
await client.sendAgentMessage(agentId, text, {
messageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
});
};
}, [client, serverId, setAgentStreamTail]);
useEffect(() => {
onSubmitMessageRef.current = onSubmitMessage;
@@ -213,7 +244,7 @@ export function AgentInputArea({
// decide what to do even if the socket is currently disconnected (so we
// don't no-op and lose deterministic error handling in the UI/tests).
if (!onSubmitMessageRef.current && !socketConnected) return;
if (!sendAgentMessage && !onSubmitMessageRef.current) return;
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
if (agent?.status === "running" && !forceSend) {
queueMessage(trimmedMessage, imageAttachments);
@@ -334,11 +365,11 @@ export function AgentInputArea({
if (!agent || agent.status !== "running" || isCancellingAgent) {
return;
}
if (!isConnected || !cancelAgentRun) {
if (!isConnected || !client) {
return;
}
setIsCancellingAgent(true);
cancelAgentRun(agentIdRef.current);
void client.cancelAgent(agentIdRef.current);
messageInputRef.current?.focus();
}
@@ -354,7 +385,7 @@ export function AgentInputArea({
async function handleSendQueuedNow(id: string) {
const item = queuedMessages.find((q) => q.id === id);
if (!item || !isConnected) return;
if (!sendAgentMessage && !onSubmitMessageRef.current) return;
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
updateQueue((current) => current.filter((q) => q.id !== id));
+8 -13
View File
@@ -85,16 +85,12 @@ export function AgentList({
const insets = useSafeAreaInsets();
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
// Get the methods for the specific server
const methods = useSessionStore((state) =>
actionAgent?.serverId
? state.sessions[actionAgent.serverId]?.methods
: undefined
const actionClient = useSessionStore((state) =>
actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.client ?? null : null
);
const archiveAgent = methods?.archiveAgent;
const isActionSheetVisible = actionAgent !== null;
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !methods);
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient);
const handleAgentPress = useCallback(
(serverId: string, agentId: string) => {
@@ -134,12 +130,12 @@ export function AgentList({
}, []);
const handleArchiveAgent = useCallback(() => {
if (!actionAgent || !archiveAgent) {
if (!actionAgent || !actionClient) {
return;
}
archiveAgent(actionAgent.id);
void actionClient.archiveAgent(actionAgent.id);
setActionAgent(null);
}, [actionAgent, archiveAgent]);
}, [actionAgent, actionClient]);
const viewabilityConfig = useMemo(
() => ({ itemVisiblePercentThreshold: 30 }),
@@ -347,7 +343,7 @@ export function AgentList({
<Text style={styles.sheetCancelText}>Cancel</Text>
</Pressable>
<Pressable
disabled={!archiveAgent || isActionDaemonUnavailable}
disabled={isActionDaemonUnavailable}
style={[styles.sheetButton, styles.sheetArchiveButton]}
onPress={handleArchiveAgent}
testID="agent-action-archive"
@@ -355,8 +351,7 @@ export function AgentList({
<Text
style={[
styles.sheetArchiveText,
(!archiveAgent || isActionDaemonUnavailable) &&
styles.sheetArchiveTextDisabled,
isActionDaemonUnavailable && styles.sheetArchiveTextDisabled,
]}
>
Archive
@@ -1,6 +1,6 @@
import { View, Text } from "react-native";
import { View, Text, Platform, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronDown } from "lucide-react-native";
import { ChevronDown, SlidersHorizontal } from "lucide-react-native";
import { useSessionStore } from "@/stores/session-store";
import {
DropdownMenu,
@@ -9,6 +9,9 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
interface AgentStatusBarProps {
agentId: string;
@@ -17,27 +20,62 @@ interface AgentStatusBarProps {
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const { theme } = useUnistyles();
const IS_WEB = Platform.OS === "web";
const [prefsOpen, setPrefsOpen] = useState(false);
// Select only the specific agent (not all agents)
const agent = useSessionStore((state) =>
state.sessions[serverId]?.agents?.get(agentId)
);
// Get the setAgentMode action (actions are stable, won't cause rerenders)
const setAgentMode = useSessionStore(
(state) => state.sessions[serverId]?.methods?.setAgentMode
);
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
if (!agent) {
return null;
}
const canFetchModels = Boolean(client) && Boolean(agent.provider) && (IS_WEB || prefsOpen);
const modelsQuery = useQuery({
queryKey: ["providerModels", serverId, agent.provider, agent.cwd],
enabled: canFetchModels,
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client) {
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;
function handleModeChange(modeId: string) {
if (setAgentMode) {
setAgentMode(agentId, modeId);
if (!client) {
return;
}
void client.setAgentMode(agentId, modeId).catch((error) => {
console.warn("[AgentStatusBar] setAgentMode failed", error);
});
}
const selectedModel = useMemo(() => {
if (!models || !agent.model) return null;
return models.find((m) => m.id === agent.model) ?? null;
}, [models, agent.model]);
const displayModel = selectedModel?.label ?? agent.model ?? "default";
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
const selectedThinkingId =
agent.thinkingOptionId ??
selectedModel?.defaultThinkingOptionId ??
"default";
const selectedThinking = thinkingOptions?.find((o) => o.id === selectedThinkingId) ?? null;
const displayThinking = selectedThinking?.label ?? selectedThinkingId ?? "default";
return (
<View style={styles.container}>
{/* Agent Mode Badge */}
@@ -83,6 +121,208 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Desktop: inline dropdowns for model/thinking */}
{IS_WEB && (
<>
<DropdownMenu>
<DropdownMenuTrigger
style={({ pressed }) => [
styles.modeBadge,
pressed && styles.modeBadgePressed,
]}
accessibilityRole="button"
accessibilityLabel="Select agent model"
testID="agent-model-selector"
>
<Text style={styles.modeBadgeText}>{displayModel}</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" testID="agent-model-menu">
<DropdownMenuLabel>Model</DropdownMenuLabel>
{models?.map((model) => {
const isActive = model.id === agent.model;
return (
<DropdownMenuItem
key={model.id}
selected={isActive}
selectedVariant="accent"
description={model.description}
onSelect={() => {
if (!client) {
return;
}
void client.setAgentModel(agentId, model.id).catch((error) => {
console.warn("[AgentStatusBar] setAgentModel failed", error);
});
}}
>
{model.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{thinkingOptions && thinkingOptions.length > 1 && (
<DropdownMenu>
<DropdownMenuTrigger
style={({ pressed }) => [
styles.modeBadge,
pressed && styles.modeBadgePressed,
]}
accessibilityRole="button"
accessibilityLabel="Select thinking option"
testID="agent-thinking-selector"
>
<Text style={styles.modeBadgeText}>{displayThinking}</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" testID="agent-thinking-menu">
<DropdownMenuLabel>Thinking</DropdownMenuLabel>
{thinkingOptions.map((opt) => {
const isActive = opt.id === selectedThinkingId;
return (
<DropdownMenuItem
key={opt.id}
selected={isActive}
selectedVariant="accent"
description={opt.description}
onSelect={() => {
if (!client) {
return;
}
void client
.setAgentThinkingOption(agentId, opt.id === "default" ? null : opt.id)
.catch((error) => {
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
});
}}
>
{opt.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
</>
)}
{/* Mobile: preferences button opens a bottom sheet */}
{!IS_WEB && (
<>
<Pressable
onPress={() => setPrefsOpen(true)}
style={({ pressed }) => [
styles.prefsButton,
pressed && styles.prefsButtonPressed,
]}
accessibilityRole="button"
accessibilityLabel="Agent preferences"
testID="agent-preferences-button"
>
<SlidersHorizontal size={16} color={theme.colors.foregroundMuted} />
</Pressable>
<AdaptiveModalSheet
title="Preferences"
visible={prefsOpen}
onClose={() => setPrefsOpen(false)}
testID="agent-preferences-sheet"
>
<View style={styles.sheetSection}>
<Text style={styles.sheetLabel}>Model</Text>
<DropdownMenu>
<DropdownMenuTrigger
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
]}
accessibilityRole="button"
accessibilityLabel="Select agent model"
testID="agent-preferences-model"
>
<Text style={styles.sheetSelectText}>{displayModel}</Text>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
<DropdownMenuLabel>Model</DropdownMenuLabel>
{models?.map((model) => {
const isActive = model.id === agent.model;
return (
<DropdownMenuItem
key={model.id}
selected={isActive}
selectedVariant="accent"
description={model.description}
onSelect={() => {
if (!client) {
return;
}
void client.setAgentModel(agentId, model.id).catch((error) => {
console.warn("[AgentStatusBar] setAgentModel failed", error);
});
}}
>
{model.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</View>
{thinkingOptions && thinkingOptions.length > 1 && (
<View style={styles.sheetSection}>
<Text style={styles.sheetLabel}>Thinking</Text>
<DropdownMenu>
<DropdownMenuTrigger
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
]}
accessibilityRole="button"
accessibilityLabel="Select thinking option"
testID="agent-preferences-thinking"
>
<Text style={styles.sheetSelectText}>{displayThinking}</Text>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
<DropdownMenuLabel>Thinking</DropdownMenuLabel>
{thinkingOptions.map((opt) => {
const isActive = opt.id === selectedThinkingId;
return (
<DropdownMenuItem
key={opt.id}
selected={isActive}
selectedVariant="accent"
description={opt.description}
onSelect={() => {
if (!client) {
return;
}
void client
.setAgentThinkingOption(agentId, opt.id === "default" ? null : opt.id)
.catch((error) => {
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
});
}}
>
{opt.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</View>
)}
</AdaptiveModalSheet>
</>
)}
</View>
);
}
@@ -111,4 +351,44 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.semibold,
textTransform: "capitalize",
},
prefsButton: {
width: 32,
height: 32,
borderRadius: theme.borderRadius["2xl"],
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.surface2,
},
prefsButtonPressed: {
backgroundColor: theme.colors.surface0,
},
sheetSection: {
gap: theme.spacing[2],
},
sheetLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
sheetSelect: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.surface2,
backgroundColor: theme.colors.surface0,
},
sheetSelectPressed: {
backgroundColor: theme.colors.surface2,
},
sheetSelectText: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
}));
@@ -44,6 +44,7 @@ import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
import type { Agent } from "@/contexts/session-context";
import { useSessionStore } from "@/stores/session-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
import { parseToolCallDisplay } from "@/utils/tool-call-parsers";
import { ToolCallDetailsContent } from "./tool-call-details";
@@ -94,15 +95,7 @@ export function AgentStreamView({
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId)
);
// Get methods for file operations
const methods = useSessionStore(
(state) => state.sessions[resolvedServerId]?.methods
);
const requestDirectoryListing = methods?.requestDirectoryListing;
const requestFilePreview = methods?.requestFilePreview;
const requestDirectoryListingOrInert = requestDirectoryListing ?? (() => {});
const requestFilePreviewOrInert = requestFilePreview ?? (() => {});
const { requestDirectoryListing, requestFilePreview } = useFileExplorerActions(resolvedServerId);
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
// tracked in react-native-reanimated#8422.
const shouldDisableEntryExitAnimations = Platform.OS === "android";
@@ -130,9 +123,9 @@ export function AgentStreamView({
return;
}
requestDirectoryListingOrInert(agentId, normalized.directory);
requestDirectoryListing(agentId, normalized.directory);
if (normalized.file) {
requestFilePreviewOrInert(agentId, normalized.file);
requestFilePreview(agentId, normalized.file);
}
setExplorerTab("files");
@@ -141,8 +134,8 @@ export function AgentStreamView({
[
agent.cwd,
agentId,
requestDirectoryListingOrInert,
requestFilePreviewOrInert,
requestDirectoryListing,
requestFilePreview,
setExplorerTab,
openFileExplorer,
]
@@ -39,6 +39,7 @@ import type { ExplorerEntry } from "@/stores/session-store";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionStore } from "@/stores/session-store";
import { useDownloadStore } from "@/stores/download-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import {
usePanelStore,
type SortOption,
@@ -81,11 +82,12 @@ export function FileExplorerPane({
: undefined
);
const methods = useSessionStore((state) => state.sessions[serverId]?.methods);
const requestDirectoryListing = methods?.requestDirectoryListing;
const requestFilePreview = methods?.requestFilePreview;
const requestFileDownloadToken = methods?.requestFileDownloadToken;
const navigateExplorerBack = methods?.navigateExplorerBack;
const {
requestDirectoryListing,
requestFilePreview,
requestFileDownloadToken,
navigateExplorerBack,
} = useFileExplorerActions(serverId);
const viewMode = usePanelStore((state) => state.explorerViewMode);
const sortOption = usePanelStore((state) => state.explorerSortOption);
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
@@ -213,16 +213,12 @@ export function GroupedAgentList({
new Set()
);
// Get the methods for the specific server
const methods = useSessionStore((state) =>
actionAgent?.serverId
? state.sessions[actionAgent.serverId]?.methods
: undefined
const actionClient = useSessionStore((state) =>
actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.client ?? null : null
);
const archiveAgent = methods?.archiveAgent;
const isActionSheetVisible = actionAgent !== null;
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !methods);
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient);
const handleAgentPress = useCallback(
(serverId: string, agentId: string) => {
@@ -262,12 +258,12 @@ export function GroupedAgentList({
}, []);
const handleArchiveFromSheet = useCallback(() => {
if (!actionAgent || !archiveAgent) {
if (!actionAgent || !actionClient) {
return;
}
archiveAgent(actionAgent.id);
void actionClient.archiveAgent(actionAgent.id);
setActionAgent(null);
}, [actionAgent, archiveAgent]);
}, [actionAgent, actionClient]);
const toggleSection = useCallback((sectionKey: string) => {
setCollapsedSections((prev) => {
@@ -413,9 +409,9 @@ export function GroupedAgentList({
(e: { stopPropagation: () => void }, agent: AggregatedAgent) => {
e.stopPropagation();
const session = useSessionStore.getState().sessions[agent.serverId];
const archiveMethod = session?.methods?.archiveAgent;
if (archiveMethod) {
archiveMethod(agent.id);
const client = session?.client ?? null;
if (client) {
void client.archiveAgent(agent.id);
}
},
[]
@@ -595,7 +591,7 @@ export function GroupedAgentList({
<Text style={styles.sheetCancelText}>Cancel</Text>
</Pressable>
<Pressable
disabled={!archiveAgent || isActionDaemonUnavailable}
disabled={isActionDaemonUnavailable}
style={[styles.sheetButton, styles.sheetArchiveButton]}
onPress={handleArchiveFromSheet}
testID="agent-action-archive"
@@ -603,8 +599,7 @@ export function GroupedAgentList({
<Text
style={[
styles.sheetArchiveText,
(!archiveAgent || isActionDaemonUnavailable) &&
styles.sheetArchiveTextDisabled,
isActionDaemonUnavailable && styles.sheetArchiveTextDisabled,
]}
>
Archive