Simplify mobile agent configuration (#3424)

This commit is contained in:
Mohamed Boudra
2026-08-16 10:10:35 +02:00
committed by GitHub
parent 7f8a485a77
commit 38f176595e
25 changed files with 511 additions and 134 deletions
@@ -772,6 +772,7 @@ function ControlledAgentControls({
modeControl={modeControl}
glyphSize={layoutContextValue.glyphSize}
modelSelectorServerId={modelSelectorServerId}
canSwitchProvider={Boolean(onSelectProviderAndModel)}
/>
)}
</View>
@@ -1086,6 +1087,7 @@ interface SheetAgentControlsContentProps {
modeControl?: AgentModeControlValue | null;
glyphSize: number;
modelSelectorServerId: string | null;
canSwitchProvider: boolean;
}
function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
@@ -1122,6 +1124,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
modeControl,
glyphSize,
modelSelectorServerId,
canSwitchProvider,
} = props;
const thinkingAnchorRef = useRef<View | null>(null);
@@ -1193,6 +1196,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
providers={modelSelectorProviders}
selectedProvider={provider}
selectedModel={selectedModelId ?? ""}
thinkingLabel={hasThinking ? displayThinking : null}
onSelect={handleSheetModelSelect}
profiles={agentProfiles}
onApplyProfile={onApplyAgentProfile}
@@ -1205,6 +1209,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
isRetryingProvider={isRetryingModelProvider}
serverId={modelSelectorServerId}
glyphSize={glyphSize}
canSwitchProvider={canSwitchProvider}
>
{sheetControls}
</CompactModelSheet>
@@ -1357,45 +1362,56 @@ function SheetFeatureItem({
);
const sheetHeader = useMemo<SheetHeader>(() => ({ title: feature.label }), [feature.label]);
const handleTogglePress = useCallback(() => {
if (feature.type === "toggle") {
onSetFeature?.(feature.id, !feature.value);
}
}, [feature, onSetFeature]);
const handleSelectOption = useCallback(
(optionId: string) => {
onSetFeature?.(feature.id, optionId);
onSetFeature?.(feature.id, feature.type === "toggle" ? optionId === "true" : optionId);
},
[feature.id, onSetFeature],
);
const comboboxOptions = useMemo<ComboboxOption[]>(
() =>
feature.type === "select"
? feature.options.map((option) => ({ id: option.id, label: option.label }))
: [],
[feature],
[feature.id, feature.type, onSetFeature],
);
const comboboxOptions = useMemo<ComboboxOption[]>(() => {
if (feature.type === "select") {
return feature.options.map((option) => ({ id: option.id, label: option.label }));
}
return [
{ id: "true", label: t("agentControls.features.on") },
{ id: "false", label: t("agentControls.features.off") },
];
}, [feature, t]);
if (feature.type === "toggle") {
const FeatureIcon = getAgentFeatureIcon(feature.icon);
return (
<AgentControlTrigger
icon={FeatureIcon}
iconColor={getFeatureIconColor(
feature.id,
feature.value,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
surface="sheet"
label={feature.label}
value={feature.value ? t("agentControls.features.on") : t("agentControls.features.off")}
disabled={disabled}
onPress={handleTogglePress}
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
/>
<>
<AgentControlTrigger
ref={featureAnchorRef}
icon={FeatureIcon}
iconColor={getFeatureIconColor(
feature.id,
feature.value,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
surface="sheet"
label={feature.label}
value={feature.value ? t("agentControls.features.on") : t("agentControls.features.off")}
open={openSelector === featureSelector}
disabled={disabled}
onPress={handleSelectPress}
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
/>
<Combobox
options={comboboxOptions}
value={String(feature.value)}
onSelect={handleSelectOption}
open={openSelector === featureSelector}
onOpenChange={handleFeatureOpenChange}
anchorRef={featureAnchorRef}
presentation="push"
searchable={false}
header={sheetHeader}
/>
</>
);
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { resolveModelSheetOpening } from "./model-sheet-flow";
function provider(id: string, label: string): ProviderSelectorProvider {
return { id, label, modelSelection: { kind: "models", rows: [] } };
}
const codex = provider("codex", "Codex");
const claude = provider("claude", "Claude Code");
describe("model sheet opening", () => {
it("starts drafts at providers even when only one provider is available", () => {
expect(
resolveModelSheetOpening({
canSwitchProvider: true,
providers: [codex],
selectedProvider: "codex",
}),
).toEqual({ kind: "all" });
});
it("opens a running agent directly at its fixed provider", () => {
expect(
resolveModelSheetOpening({
canSwitchProvider: false,
providers: [claude, codex],
selectedProvider: "codex",
}),
).toEqual({ kind: "provider", providerId: "codex", providerLabel: "Codex" });
});
it("falls back to the only provider while live state catches up", () => {
expect(
resolveModelSheetOpening({
canSwitchProvider: false,
providers: [codex],
selectedProvider: "",
}),
).toEqual({ kind: "provider", providerId: "codex", providerLabel: "Codex" });
});
});
@@ -0,0 +1,19 @@
import type { ModelBrowserView } from "@/components/model-browser-view";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
export function resolveModelSheetOpening({
canSwitchProvider,
providers,
selectedProvider,
}: {
canSwitchProvider: boolean;
providers: ProviderSelectorProvider[];
selectedProvider: string;
}): ModelBrowserView {
if (canSwitchProvider) return { kind: "all" };
const provider = providers.find((entry) => entry.id === selectedProvider) ?? providers[0] ?? null;
return provider
? { kind: "provider", providerId: provider.id, providerLabel: provider.label }
: { kind: "all" };
}
@@ -2,15 +2,19 @@ import { useCallback, useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Keyboard, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Bot } from "lucide-react-native";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { AgentProfilePicker } from "@/agent-profiles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { getProviderIcon } from "@/components/provider-icons";
import { ModelBrowser, useModelBrowser } from "@/components/model-browser";
import { AgentControlTrigger } from "@/composer/agent-controls/control";
import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph";
import { resolveModelSheetOpening } from "@/composer/agent-controls/model-sheet-flow";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
const SNAP_POINTS = ["80%", "90%"];
const MODEL_LIST_TOP_INSET = 4;
@@ -23,6 +27,7 @@ interface CompactModelSheetProps {
providers: ProviderSelectorProvider[];
selectedProvider: string;
selectedModel: string;
thinkingLabel: string | null;
onSelect: (provider: string, modelId: string) => void;
isLoading: boolean;
profiles?: AgentProfilePicker | null;
@@ -35,6 +40,7 @@ interface CompactModelSheetProps {
disabled?: boolean;
serverId?: string | null;
glyphSize: number;
canSwitchProvider: boolean;
children: ReactNode;
}
@@ -47,6 +53,7 @@ export function CompactModelSheet({
providers,
selectedProvider,
selectedModel,
thinkingLabel,
onSelect,
isLoading,
profiles = null,
@@ -59,47 +66,107 @@ export function CompactModelSheet({
disabled = false,
serverId = null,
glyphSize,
canSwitchProvider,
children,
}: CompactModelSheetProps) {
const { t } = useTranslation();
const usesBottomSheet = useIsCompactFormFactor();
const [isOpen, setIsOpen] = useState(false);
const browser = useModelBrowser({
providers,
const [isModelBrowserOpen, setIsModelBrowserOpen] = useState(false);
const availableProviders = useMemo(() => {
if (canSwitchProvider) return providers;
const fixedProvider =
providers.find((entry) => entry.id === selectedProvider) ?? providers[0] ?? null;
return fixedProvider ? [fixedProvider] : [];
}, [canSwitchProvider, providers, selectedProvider]);
const rootBrowser = useModelBrowser({
providers: availableProviders,
selectedProvider,
selectedModel,
isLoading,
autoFocusSearch: isWeb && !usesBottomSheet,
profiles,
serverId,
});
const { prepareToOpen, reset } = browser;
const modelBrowser = useModelBrowser({
providers: availableProviders,
selectedProvider,
selectedModel,
isLoading,
autoFocusSearch: isWeb && !usesBottomSheet,
serverId,
});
const ProviderIcon =
selectedProvider.trim().length > 0 ? getProviderIcon(selectedProvider) : null;
const compactFooter = useMemo(
() =>
usesBottomSheet ? (
<View style={styles.compactFooter} testID="agent-controls-settings-list">
<View style={styles.modelViewportDivider} />
<View style={[styles.controlsContent, styles.compactControlsContent]}>{children}</View>
</View>
) : undefined,
[children, usesBottomSheet],
const ModelIcon = ProviderIcon ?? Bot;
const rootHeader = useMemo(
() => ({
...rootBrowser.header,
title: t("modelSelector.selectModel"),
search:
rootBrowser.header.search && !canSwitchProvider
? {
...rootBrowser.header.search,
placeholder: t("modelSelector.searchPlaceholder"),
}
: rootBrowser.header.search,
}),
[canSwitchProvider, rootBrowser.header, t],
);
const open = useCallback(() => {
Keyboard.dismiss();
prepareToOpen();
rootBrowser.showAll();
setIsOpen(true);
onOpen?.();
}, [onOpen, prepareToOpen]);
}, [onOpen, rootBrowser]);
const close = useCallback(() => {
setIsModelBrowserOpen(false);
setIsOpen(false);
reset();
rootBrowser.reset();
modelBrowser.reset();
onClose?.();
}, [onClose, reset]);
}, [modelBrowser, onClose, rootBrowser]);
const handleSelect = useCallback(
const handleSearchSelect = useCallback(
(provider: string, modelId: string) => {
onSelect(provider, modelId);
Keyboard.dismiss();
rootBrowser.showAll();
},
[onSelect, rootBrowser],
);
const openModelBrowser = useCallback(() => {
Keyboard.dismiss();
const destination = resolveModelSheetOpening({
canSwitchProvider,
providers: availableProviders,
selectedProvider,
});
if (destination.kind === "all") {
modelBrowser.showAll();
} else {
modelBrowser.drillDown(destination.providerId, destination.providerLabel);
}
setIsModelBrowserOpen(true);
}, [availableProviders, canSwitchProvider, modelBrowser, selectedProvider]);
const closeModelBrowser = useCallback(() => {
setIsModelBrowserOpen(false);
modelBrowser.reset();
}, [modelBrowser]);
const handleBrowserSelect = useCallback(
(provider: string, modelId: string) => {
onSelect(provider, modelId);
closeModelBrowser();
},
[closeModelBrowser, onSelect],
);
const handleDesktopSelect = useCallback(
(provider: string, modelId: string) => {
onSelect(provider, modelId);
close();
@@ -137,6 +204,28 @@ export function CompactModelSheet({
],
[disabled, isOpen],
);
const mobileRootContent = useMemo(
() => (
<View style={styles.mobileRootContent} testID="agent-controls-settings-list">
<View style={styles.controlsContent}>
<AgentControlTrigger
icon={ModelIcon}
surface="sheet"
label={t("modelSelector.model")}
value={rootBrowser.selectedModelLabel}
disabled={disabled}
onPress={openModelBrowser}
accessibilityLabel={t("modelSelector.selectedModel", {
model: rootBrowser.selectedModelLabel,
})}
testID="agent-controls-model"
/>
{children}
</View>
</View>
),
[ModelIcon, children, disabled, openModelBrowser, rootBrowser.selectedModelLabel, t],
);
return (
<>
@@ -147,7 +236,7 @@ export function CompactModelSheet({
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={t("modelSelector.selectedModel", {
model: browser.selectedModelLabel,
model: rootBrowser.selectedModelLabel,
})}
testID="combined-model-selector"
chevron={null}
@@ -157,20 +246,25 @@ export function CompactModelSheet({
<ProviderIcon size={glyphSize} color={styles.providerIcon.color} />
</ComposerToolbarGlyph>
) : null}
<Text style={styles.triggerText} numberOfLines={1}>
{shortModelLabel(browser.triggerLabel)}
</Text>
<View style={styles.triggerLabels}>
<Text style={styles.triggerText} numberOfLines={1}>
{shortModelLabel(rootBrowser.triggerLabel)}
</Text>
{thinkingLabel ? (
<Text style={styles.triggerThinking} numberOfLines={1}>
{thinkingLabel}
</Text>
) : null}
</View>
</ComboboxTrigger>
<AdaptiveModalSheet
header={browser.header}
header={rootHeader}
visible={isOpen}
onClose={close}
snapPoints={SNAP_POINTS}
scrollable={false}
sizeContentToCurrentSnapPoint={usesBottomSheet}
footer={compactFooter}
footerContainerStyle={usesBottomSheet ? styles.compactFooterContainer : undefined}
contentStyle={styles.sheetBody}
testID="agent-controls-model-sheet"
>
@@ -182,13 +276,15 @@ export function CompactModelSheet({
testID="agent-controls-model-viewport"
>
<ModelBrowser
state={browser}
onSelect={handleSelect}
state={rootBrowser}
onSelect={usesBottomSheet ? handleSearchSelect : handleDesktopSelect}
onApplyProfile={handleApplyProfile}
onEditProfiles={onEditProfiles ? handleEditProfiles : undefined}
onRetryProvider={onRetryProvider}
isRetryingProvider={isRetryingProvider}
scrolling="independent"
searchAllOnFocus={usesBottomSheet}
rootBrowseContent={usesBottomSheet ? mobileRootContent : undefined}
/>
</View>
{!usesBottomSheet ? (
@@ -206,6 +302,34 @@ export function CompactModelSheet({
</>
) : null}
</AdaptiveModalSheet>
{usesBottomSheet ? (
<AdaptiveModalSheet
header={modelBrowser.header}
visible={isModelBrowserOpen}
onClose={closeModelBrowser}
snapPoints={SNAP_POINTS}
scrollable={false}
sizeContentToCurrentSnapPoint
presentation="push"
contentStyle={styles.sheetBody}
testID="agent-controls-model-browser-sheet"
>
<View
style={[styles.modelViewport, styles.flexibleModelViewport]}
testID="agent-controls-model-browser-viewport"
>
<ModelBrowser
state={modelBrowser}
onSelect={handleBrowserSelect}
onRetryProvider={onRetryProvider}
isRetryingProvider={isRetryingProvider}
scrolling="independent"
searchAllOnFocus
/>
</View>
</AdaptiveModalSheet>
) : null}
</>
);
}
@@ -238,6 +362,19 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
triggerLabels: {
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
triggerThinking: {
flexShrink: 0,
color: theme.colors.foregroundExtraMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
providerIcon: {
color: theme.colors.foregroundMuted,
},
@@ -268,20 +405,10 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minHeight: 0,
},
compactFooterContainer: {
flexDirection: "column",
alignItems: "stretch",
justifyContent: "flex-start",
gap: 0,
paddingHorizontal: 0,
paddingTop: 0,
borderTopWidth: 0,
},
compactFooter: {
minWidth: 0,
},
compactControlsContent: {
paddingBottom: 0,
mobileRootContent: {
backgroundColor: theme.colors.surfaceSidebar,
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
controlsContent: {
paddingHorizontal: theme.spacing[2],