Simplify mobile agent configuration (#3424)
This commit is contained in:
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user