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
@@ -90,11 +90,7 @@ async function openTabSwitcher(page: Page) {
async function openModelSelector(page: Page) { async function openModelSelector(page: Page) {
await page.getByRole("button", { name: /Select model/ }).click(); await page.getByRole("button", { name: /Select model/ }).click();
await expectBottomSheetOpen(page); await expectBottomSheetOpen(page);
await expect( await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible({ timeout: 10_000 });
page.getByLabel("Bottom Sheet", { exact: true }).getByText("Ten second stream", {
exact: true,
}),
).toBeVisible({ timeout: 10_000 });
} }
async function openAndCloseTabSwitcherTwice(page: Page) { async function openAndCloseTabSwitcherTwice(page: Page) {
@@ -124,15 +120,23 @@ test.describe("mobile bottom sheet reopen", () => {
}); });
}); });
test("model selector closes after model selection", async ({ page }) => { test("search selection returns to configuration options", async ({ page }) => {
await withMobileMockAgent(page, async () => { await withMobileMockAgent(page, async () => {
await openModelSelector(page); await openModelSelector(page);
const sheet = page.getByLabel("Bottom Sheet", { exact: true }); const sheet = page.getByTestId("agent-controls-model-sheet");
await sheet.getByText("Ten second stream", { exact: true }).click(); await page.getByTestId("model-search-all-input").click();
const model = page.getByRole("button", { name: /^Ten second stream/ });
await expect(model).toBeVisible({
timeout: 10_000,
});
await expect(sheet).not.toBeVisible({ timeout: 10_000 }); await model.click();
await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible();
await expect(sheet).toBeVisible();
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
await expect(page.getByTestId("agent-controls-model")).toContainText("Ten second stream");
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
}); });
}); });
}); });
@@ -34,7 +34,7 @@ const STREAMED_MERMAID = [
test("keeps a Mermaid diagram rendered while its message streams, completes, and reloads", async ({ test("keeps a Mermaid diagram rendered while its message streams, completes, and reloads", async ({
page, page,
}) => { }) => {
test.setTimeout(90_000); test.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({ const agent = await seedMockAgentWorkspace({
repoPrefix: "mermaid-streaming-", repoPrefix: "mermaid-streaming-",
@@ -21,21 +21,22 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
async function openProviderSettingsFromModelSelector(page: Page) { async function openProviderSettingsFromModelSelector(page: Page) {
await page.getByRole("button", { name: /Select model/ }).click(); await page.getByRole("button", { name: /Select model/ }).click();
await expect(page.getByLabel("Bottom Sheet", { exact: true })).toBeVisible({ timeout: 10_000 }); const configuration = page.getByTestId("agent-controls-model-sheet");
await expect(configuration).toBeVisible({ timeout: 10_000 });
await page.getByTestId("agent-controls-model").click();
const openCodeRow = page.getByText("OpenCode", { exact: true }).first(); const modelBrowser = page.getByTestId("agent-controls-model-browser-sheet");
if (await openCodeRow.isVisible().catch(() => false)) { await expect(modelBrowser).toBeVisible({ timeout: 10_000 });
await openCodeRow.click();
}
await page.getByRole("button", { name: /Open .* settings/ }).click(); await page.getByRole("button", { name: /Open .* settings/ }).click();
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
} }
async function expectModelSelectorVisible(page: Page) { async function expectModelBrowserVisible(page: Page) {
await expect(page.getByRole("button", { name: /Open .* settings/ })).toBeVisible({ await expect(page.getByTestId("agent-controls-model-browser-sheet")).toBeVisible({
timeout: 10_000, timeout: 10_000,
}); });
await expect(page.getByRole("button", { name: /Open .* settings/ })).toBeVisible();
} }
async function closeTopSheet(page: Page) { async function closeTopSheet(page: Page) {
@@ -180,7 +181,7 @@ test.describe("provider settings overlay stack", () => {
} }
}); });
test("provider settings and children close back through the model selector stack", async ({ test("provider settings and children close back through the model browser to configuration", async ({
page, page,
}) => { }) => {
test.setTimeout(180_000); test.setTimeout(180_000);
@@ -192,13 +193,18 @@ test.describe("provider settings overlay stack", () => {
await exerciseProviderSettingsStack(page); await exerciseProviderSettingsStack(page);
await closeSheetByHeaderButton(page, "provider-settings-sheet"); await closeSheetByHeaderButton(page, "provider-settings-sheet");
await expectModelSelectorVisible(page); await expectModelBrowserVisible(page);
await page.getByRole("button", { name: /Open .* settings/ }).click(); await page.getByRole("button", { name: /Open .* settings/ }).click();
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
await exerciseProviderSettingsStack(page); await exerciseProviderSettingsStack(page);
await closeSheetByHeaderButton(page, "provider-settings-sheet"); await closeSheetByHeaderButton(page, "provider-settings-sheet");
await expectModelSelectorVisible(page); await expectModelBrowserVisible(page);
await closeTopSheet(page);
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible({
timeout: 10_000,
});
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
await closeTopSheet(page); await closeTopSheet(page);
} finally { } finally {
await session.cleanup(); await session.cleanup();
@@ -1,11 +1,15 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout"; import {
getBottomSheetVisibleContentHeight,
getCompactSheetSafeAreaPadding,
} from "@/components/adaptive-modal-sheet-layout";
describe("getCompactSheetSafeAreaPadding", () => { describe("getCompactSheetSafeAreaPadding", () => {
it("adds the bottom inset to compact sheet footers", () => { it("adds the bottom inset to compact sheet footers", () => {
expect( expect(
getCompactSheetSafeAreaPadding({ getCompactSheetSafeAreaPadding({
isCompact: true, isCompact: true,
isKeyboardVisible: false,
hasFooter: true, hasFooter: true,
baseContentPadding: 24, baseContentPadding: 24,
baseFooterPadding: 12, baseFooterPadding: 12,
@@ -18,6 +22,7 @@ describe("getCompactSheetSafeAreaPadding", () => {
expect( expect(
getCompactSheetSafeAreaPadding({ getCompactSheetSafeAreaPadding({
isCompact: true, isCompact: true,
isKeyboardVisible: false,
hasFooter: false, hasFooter: false,
baseContentPadding: 24, baseContentPadding: 24,
baseFooterPadding: 12, baseFooterPadding: 12,
@@ -26,10 +31,24 @@ describe("getCompactSheetSafeAreaPadding", () => {
).toEqual({ contentPaddingBottom: 58 }); ).toEqual({ contentPaddingBottom: 58 });
}); });
it("does not add a safe-area band above the compact keyboard", () => {
expect(
getCompactSheetSafeAreaPadding({
isCompact: true,
isKeyboardVisible: true,
hasFooter: false,
baseContentPadding: 24,
baseFooterPadding: 12,
safeAreaBottom: 34,
}),
).toEqual({});
});
it("does not inset desktop sheets", () => { it("does not inset desktop sheets", () => {
expect( expect(
getCompactSheetSafeAreaPadding({ getCompactSheetSafeAreaPadding({
isCompact: false, isCompact: false,
isKeyboardVisible: false,
hasFooter: false, hasFooter: false,
baseContentPadding: 24, baseContentPadding: 24,
baseFooterPadding: 12, baseFooterPadding: 12,
@@ -38,3 +57,17 @@ describe("getCompactSheetSafeAreaPadding", () => {
).toEqual({}); ).toEqual({});
}); });
}); });
describe("getBottomSheetVisibleContentHeight", () => {
it("stops subtracting the retained keyboard height after the keyboard hides", () => {
const layout = {
containerHeight: 874,
contentPosition: 88,
handleHeight: 24,
keyboardHeight: 344,
};
expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: true })).toBe(418);
expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: false })).toBe(762);
});
});
@@ -1,5 +1,6 @@
export interface CompactSheetSafeAreaPaddingInput { export interface CompactSheetSafeAreaPaddingInput {
isCompact: boolean; isCompact: boolean;
isKeyboardVisible: boolean;
hasFooter: boolean; hasFooter: boolean;
baseContentPadding: number; baseContentPadding: number;
baseFooterPadding: number; baseFooterPadding: number;
@@ -11,14 +12,37 @@ export interface CompactSheetSafeAreaPadding {
footerPaddingBottom?: number; footerPaddingBottom?: number;
} }
interface BottomSheetVisibleContentHeightInput {
containerHeight: number;
contentPosition: number;
handleHeight: number;
keyboardHeight: number;
isKeyboardVisible: boolean;
}
export function getBottomSheetVisibleContentHeight({
containerHeight,
contentPosition,
handleHeight,
keyboardHeight,
isKeyboardVisible,
}: BottomSheetVisibleContentHeightInput): number {
"worklet";
return Math.max(
0,
containerHeight - contentPosition - handleHeight - (isKeyboardVisible ? keyboardHeight : 0),
);
}
export function getCompactSheetSafeAreaPadding({ export function getCompactSheetSafeAreaPadding({
isCompact, isCompact,
isKeyboardVisible,
hasFooter, hasFooter,
baseContentPadding, baseContentPadding,
baseFooterPadding, baseFooterPadding,
safeAreaBottom, safeAreaBottom,
}: CompactSheetSafeAreaPaddingInput): CompactSheetSafeAreaPadding { }: CompactSheetSafeAreaPaddingInput): CompactSheetSafeAreaPadding {
if (!isCompact || safeAreaBottom <= 0) { if (!isCompact || isKeyboardVisible || safeAreaBottom <= 0) {
return {}; return {};
} }
@@ -16,6 +16,7 @@ import {
BottomSheetBackdrop, BottomSheetBackdrop,
BottomSheetScrollView, BottomSheetScrollView,
BottomSheetTextInput, BottomSheetTextInput,
KEYBOARD_STATUS,
useBottomSheetInternal, useBottomSheetInternal,
type BottomSheetBackgroundProps, type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
@@ -25,9 +26,13 @@ import {
IsolatedBottomSheetModal, IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility, useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal"; } from "@/components/ui/isolated-bottom-sheet-modal";
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout"; import {
getBottomSheetVisibleContentHeight,
getCompactSheetSafeAreaPadding,
} from "@/components/adaptive-modal-sheet-layout";
import { createControlGeometry } from "@/components/ui/control-geometry"; import { createControlGeometry } from "@/components/ui/control-geometry";
import { isNative, isWeb } from "@/constants/platform"; import { isNative, isWeb } from "@/constants/platform";
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
// Horizontal indent token shared by the sheet header (title, back arrow, // Horizontal indent token shared by the sheet header (title, back arrow,
@@ -38,6 +43,8 @@ export const SHEET_HORIZONTAL_PADDING_SCALE = 6;
export interface SheetHeaderSearch { export interface SheetHeaderSearch {
onChange: (value: string) => void; onChange: (value: string) => void;
onFocus?: () => void;
onBlur?: () => void;
resetKey?: string | number; resetKey?: string | number;
placeholder?: string; placeholder?: string;
autoFocus?: boolean; autoFocus?: boolean;
@@ -262,14 +269,15 @@ function BottomSheetVisibleContent({ children }: { children: ReactNode }) {
? animatedPosition.get() ? animatedPosition.get()
: Math.min(animatedPosition.get(), initialDetentPosition); : Math.min(animatedPosition.get(), initialDetentPosition);
const keyboardState = animatedKeyboardState.get();
return { return {
height: Math.max( height: getBottomSheetVisibleContentHeight({
0, containerHeight,
containerHeight - contentPosition,
contentPosition - handleHeight,
handleHeight - keyboardHeight: keyboardState.heightWithinContainer,
animatedKeyboardState.get().heightWithinContainer, isKeyboardVisible: keyboardState.status === KEYBOARD_STATUS.SHOWN,
), }),
}; };
}, [animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition]); }, [animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition]);
@@ -409,6 +417,8 @@ export function SheetHeaderView({
placeholder={search.placeholder ?? t("common.actions.search")} placeholder={search.placeholder ?? t("common.actions.search")}
resetKey={search.resetKey} resetKey={search.resetKey}
onChangeText={handleSearchChange} onChangeText={handleSearchChange}
onFocus={search.onFocus}
onBlur={search.onBlur}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
autoFocus={search.autoFocus} autoFocus={search.autoFocus}
@@ -466,6 +476,8 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
placeholder={header.search.placeholder ?? t("common.actions.search")} placeholder={header.search.placeholder ?? t("common.actions.search")}
resetKey={header.search.resetKey} resetKey={header.search.resetKey}
onChangeText={header.search.onChange} onChangeText={header.search.onChange}
onFocus={header.search.onFocus}
onBlur={header.search.onBlur}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
autoFocus={header.search.autoFocus} autoFocus={header.search.autoFocus}
@@ -518,17 +530,19 @@ export function AdaptiveModalSheet({
const { t } = useTranslation(); const { t } = useTranslation();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const isKeyboardVisible = useKeyboardVisibility();
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
const compactSafeAreaPadding = useMemo( const compactSafeAreaPadding = useMemo(
() => () =>
getCompactSheetSafeAreaPadding({ getCompactSheetSafeAreaPadding({
isCompact: isMobile, isCompact: isMobile,
isKeyboardVisible,
hasFooter: Boolean(footer), hasFooter: Boolean(footer),
baseContentPadding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], baseContentPadding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
baseFooterPadding: theme.spacing[3], baseFooterPadding: theme.spacing[3],
safeAreaBottom: insets.bottom, safeAreaBottom: insets.bottom,
}), }),
[footer, insets.bottom, isMobile, theme.spacing], [footer, insets.bottom, isKeyboardVisible, isMobile, theme.spacing],
); );
const compactContentStyle = useMemo( const compactContentStyle = useMemo(
() => [ () => [
@@ -105,13 +105,35 @@ describe("model browser all view", () => {
const providers = [claude, copilot, codex]; const providers = [claude, copilot, codex];
it("browses providers while the query is empty", () => { it("browses providers while the query is empty", () => {
expect(resolveModelBrowserAllView({ providers, normalizedQuery: "" })).toEqual({ expect(
resolveModelBrowserAllView({ providers, normalizedQuery: "", isSearchFocused: false }),
).toEqual({
kind: "browse", kind: "browse",
}); });
}); });
it("shows every searchable model as soon as empty search receives focus", () => {
const view = resolveModelBrowserAllView({
providers,
normalizedQuery: "",
isSearchFocused: true,
});
expect(view.kind).toBe("searchResults");
expect(view.kind === "searchResults" ? view.rows.map((row) => row.favoriteKey) : []).toEqual([
"claude:opus-5",
"claude:sonnet-4.6",
"copilot:claude-opus-5",
"codex:gpt-5.4",
]);
});
it("ranks the same model label across every provider that offers it", () => { it("ranks the same model label across every provider that offers it", () => {
const view = resolveModelBrowserAllView({ providers, normalizedQuery: "opus" }); const view = resolveModelBrowserAllView({
providers,
normalizedQuery: "opus",
isSearchFocused: true,
});
expect(view.kind).toBe("searchResults"); expect(view.kind).toBe("searchResults");
expect(view.kind === "searchResults" ? view.rows.map((row) => row.favoriteKey) : []).toEqual([ expect(view.kind === "searchResults" ? view.rows.map((row) => row.favoriteKey) : []).toEqual([
@@ -121,7 +143,11 @@ describe("model browser all view", () => {
}); });
it("matches models by their provider label", () => { it("matches models by their provider label", () => {
const view = resolveModelBrowserAllView({ providers, normalizedQuery: "codex" }); const view = resolveModelBrowserAllView({
providers,
normalizedQuery: "codex",
isSearchFocused: true,
});
expect(view.kind === "searchResults" ? view.rows.map((row) => row.modelId) : []).toEqual([ expect(view.kind === "searchResults" ? view.rows.map((row) => row.modelId) : []).toEqual([
"gpt-5.4", "gpt-5.4",
@@ -129,9 +155,13 @@ describe("model browser all view", () => {
}); });
it("reports no matches instead of falling back to the provider list", () => { it("reports no matches instead of falling back to the provider list", () => {
expect(resolveModelBrowserAllView({ providers, normalizedQuery: "zzzz" })).toEqual({ expect(
kind: "noSearchMatches", resolveModelBrowserAllView({
}); providers,
normalizedQuery: "zzzz",
isSearchFocused: true,
}),
).toEqual({ kind: "noSearchMatches" });
}); });
it("ignores providers that are still loading or errored", () => { it("ignores providers that are still loading or errored", () => {
@@ -147,7 +177,11 @@ describe("model browser all view", () => {
}; };
expect( expect(
resolveModelBrowserAllView({ providers: [loading, failed], normalizedQuery: "opus" }), resolveModelBrowserAllView({
providers: [loading, failed],
normalizedQuery: "opus",
isSearchFocused: true,
}),
).toEqual({ kind: "noSearchMatches" }); ).toEqual({ kind: "noSearchMatches" });
}); });
}); });
@@ -18,14 +18,17 @@ export type ModelBrowserAllView =
export function resolveModelBrowserAllView({ export function resolveModelBrowserAllView({
providers, providers,
normalizedQuery, normalizedQuery,
isSearchFocused,
}: { }: {
providers: ProviderSelectorProvider[]; providers: ProviderSelectorProvider[];
normalizedQuery: string; normalizedQuery: string;
isSearchFocused: boolean;
}): ModelBrowserAllView { }): ModelBrowserAllView {
if (!normalizedQuery) { if (!normalizedQuery && !isSearchFocused) {
return { kind: "browse" }; return { kind: "browse" };
} }
const rows = filterAndRankModelRows(getAllProviderModelRows(providers), normalizedQuery); const allRows = getAllProviderModelRows(providers);
const rows = normalizedQuery ? filterAndRankModelRows(allRows, normalizedQuery) : allRows;
if (rows.length === 0) { if (rows.length === 0) {
return { kind: "noSearchMatches" }; return { kind: "noSearchMatches" };
} }
+46 -10
View File
@@ -145,6 +145,7 @@ interface ModelBrowserInput {
selectedProvider: string; selectedProvider: string;
selectedModel: string; selectedModel: string;
isLoading: boolean; isLoading: boolean;
autoFocusSearch?: boolean;
/** Pinned above the provider list on the root view. `null` hides the section. */ /** Pinned above the provider list on the root view. `null` hides the section. */
profiles?: AgentProfilePicker | null; profiles?: AgentProfilePicker | null;
serverId?: string | null; serverId?: string | null;
@@ -157,12 +158,14 @@ export interface ModelBrowserState {
profiles: AgentProfilePicker | null; profiles: AgentProfilePicker | null;
view: ModelBrowserView; view: ModelBrowserView;
searchQuery: string; searchQuery: string;
isSearchFocused: boolean;
header: SheetHeader; header: SheetHeader;
selectedModelLabel: string; selectedModelLabel: string;
triggerLabel: string; triggerLabel: string;
desktopFixedHeight: number | undefined; desktopFixedHeight: number | undefined;
isProviderView: boolean; isProviderView: boolean;
prepareToOpen: () => void; prepareToOpen: () => void;
showAll: () => void;
reset: () => void; reset: () => void;
drillDown: (providerId: string, providerLabel: string) => void; drillDown: (providerId: string, providerLabel: string) => void;
} }
@@ -176,6 +179,10 @@ interface ModelBrowserProps {
onRetryProvider?: (provider: AgentProvider) => void; onRetryProvider?: (provider: AgentProvider) => void;
isRetryingProvider?: boolean; isRetryingProvider?: boolean;
scrolling?: "sheet" | "independent"; scrolling?: "sheet" | "independent";
/** Empty focused search shows all model rows instead of the browse root. */
searchAllOnFocus?: boolean;
/** Replaces provider rows only while the all-provider search is empty. */
rootBrowseContent?: React.ReactNode;
} }
interface ModelBrowserContentProps extends Omit<ModelBrowserProps, "state" | "scrolling"> { interface ModelBrowserContentProps extends Omit<ModelBrowserProps, "state" | "scrolling"> {
@@ -184,9 +191,12 @@ interface ModelBrowserContentProps extends Omit<ModelBrowserProps, "state" | "sc
selectedProvider: string; selectedProvider: string;
selectedModel: string; selectedModel: string;
searchQuery: string; searchQuery: string;
isSearchFocused: boolean;
profiles: AgentProfilePicker | null; profiles: AgentProfilePicker | null;
onDrillDown: (providerId: string, providerLabel: string) => void; onDrillDown: (providerId: string, providerLabel: string) => void;
scrolling: "sheet" | "independent"; scrolling: "sheet" | "independent";
searchAllOnFocus: boolean;
rootBrowseContent?: React.ReactNode;
} }
type ProviderGlyphTone = "muted" | "foreground"; type ProviderGlyphTone = "muted" | "foreground";
@@ -246,12 +256,14 @@ export function useModelBrowser({
selectedProvider, selectedProvider,
selectedModel, selectedModel,
isLoading, isLoading,
autoFocusSearch = isWeb,
profiles = null, profiles = null,
serverId = null, serverId = null,
}: ModelBrowserInput): ModelBrowserState { }: ModelBrowserInput): ModelBrowserState {
const { t } = useTranslation(); const { t } = useTranslation();
const [view, setView] = useState<ModelBrowserView>({ kind: "all" }); const [view, setView] = useState<ModelBrowserView>({ kind: "all" });
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [isSearchFocused, setIsSearchFocused] = useState(false);
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0); const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
const hasProfiles = (profiles?.rows.length ?? 0) > 0; const hasProfiles = (profiles?.rows.length ?? 0) > 0;
@@ -272,10 +284,11 @@ export function useModelBrowser({
const reset = useCallback(() => { const reset = useCallback(() => {
setSearchQuery(""); setSearchQuery("");
setIsSearchFocused(false);
bumpSearchResetKey(); bumpSearchResetKey();
}, []); }, []);
const handleBackToAll = useCallback(() => { const showAll = useCallback(() => {
setView({ kind: "all" }); setView({ kind: "all" });
reset(); reset();
}, [reset]); }, [reset]);
@@ -299,9 +312,11 @@ export function useModelBrowser({
title: t("modelSelector.title"), title: t("modelSelector.title"),
search: { search: {
onChange: handleSearchQueryChange, onChange: handleSearchQueryChange,
onFocus: () => setIsSearchFocused(true),
onBlur: () => setIsSearchFocused(false),
resetKey: `all:${searchResetKey}`, resetKey: `all:${searchResetKey}`,
placeholder: t("modelSelector.searchAllPlaceholder"), placeholder: t("modelSelector.searchAllPlaceholder"),
autoFocus: isWeb, autoFocus: autoFocusSearch,
testID: "model-search-all-input", testID: "model-search-all-input",
}, },
}; };
@@ -311,7 +326,7 @@ export function useModelBrowser({
leading: ( leading: (
<ModelProviderGlyph provider={view.providerId} size={ICON_SIZE.md} tone="foreground" /> <ModelProviderGlyph provider={view.providerId} size={ICON_SIZE.md} tone="foreground" />
), ),
back: singleProviderView ? undefined : { onPress: handleBackToAll }, back: singleProviderView ? undefined : { onPress: showAll },
actions: ( actions: (
<View style={styles.headerActionRow}> <View style={styles.headerActionRow}>
<ProviderSettingsAction <ProviderSettingsAction
@@ -325,18 +340,21 @@ export function useModelBrowser({
), ),
search: { search: {
onChange: handleSearchQueryChange, onChange: handleSearchQueryChange,
onFocus: () => setIsSearchFocused(true),
onBlur: () => setIsSearchFocused(false),
resetKey: `${view.providerId}:${searchResetKey}`, resetKey: `${view.providerId}:${searchResetKey}`,
placeholder: t("modelSelector.searchPlaceholder"), placeholder: t("modelSelector.searchPlaceholder"),
autoFocus: isWeb, autoFocus: autoFocusSearch,
testID: "model-search-input", testID: "model-search-input",
}, },
}; };
}, [ }, [
handleBackToAll, autoFocusSearch,
handleSearchQueryChange, handleSearchQueryChange,
searchResetKey, searchResetKey,
serverId, serverId,
singleProviderView, singleProviderView,
showAll,
t, t,
view, view,
]); ]);
@@ -371,12 +389,14 @@ export function useModelBrowser({
profiles, profiles,
view, view,
searchQuery, searchQuery,
isSearchFocused,
header, header,
selectedModelLabel, selectedModelLabel,
triggerLabel, triggerLabel,
desktopFixedHeight, desktopFixedHeight,
isProviderView: view.kind === "provider", isProviderView: view.kind === "provider",
prepareToOpen, prepareToOpen,
showAll,
reset, reset,
drillDown, drillDown,
}; };
@@ -856,6 +876,7 @@ function IndependentModelList({
keyExtractor={getModelRowKey} keyExtractor={getModelRowKey}
style={styles.virtualizedModelList} style={styles.virtualizedModelList}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={styles.virtualizedModelListContent} contentContainerStyle={styles.virtualizedModelListContent}
nestedScrollEnabled nestedScrollEnabled
@@ -879,6 +900,7 @@ function IndependentProviderList({ children }: { children: React.ReactNode }) {
styles.virtualizedProviderListContent, styles.virtualizedProviderListContent,
]} ]}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
nestedScrollEnabled nestedScrollEnabled
testID="compact-provider-list" testID="compact-provider-list"
@@ -1078,6 +1100,7 @@ function ModelBrowserContent({
selectedProvider, selectedProvider,
selectedModel, selectedModel,
searchQuery, searchQuery,
isSearchFocused,
profiles, profiles,
onSelect, onSelect,
onApplyProfile, onApplyProfile,
@@ -1086,6 +1109,8 @@ function ModelBrowserContent({
onRetryProvider, onRetryProvider,
isRetryingProvider = false, isRetryingProvider = false,
scrolling, scrolling,
searchAllOnFocus,
rootBrowseContent,
}: ModelBrowserContentProps) { }: ModelBrowserContentProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]); const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
@@ -1097,10 +1122,15 @@ function ModelBrowserContent({
[providers, view], [providers, view],
); );
const allView = useMemo( const allView = useMemo(
() => resolveModelBrowserAllView({ providers, normalizedQuery }), () =>
[normalizedQuery, providers], resolveModelBrowserAllView({
providers,
normalizedQuery,
isSearchFocused: searchAllOnFocus && isSearchFocused,
}),
[isSearchFocused, normalizedQuery, providers, searchAllOnFocus],
); );
const hasResults = profiles !== null || providers.length > 0; const hasResults = profiles !== null || providers.length > 0 || rootBrowseContent != null;
if (view.kind === "provider") { if (view.kind === "provider") {
return ( return (
@@ -1154,7 +1184,8 @@ function ModelBrowserContent({
onEditProfiles={onEditProfiles} onEditProfiles={onEditProfiles}
/> />
) : null} ) : null}
{providers.length > 0 ? ( {rootBrowseContent ??
(providers.length > 0 ? (
<View> <View>
{profiles ? ( {profiles ? (
<View style={styles.sectionHeading}> <View style={styles.sectionHeading}>
@@ -1163,7 +1194,7 @@ function ModelBrowserContent({
) : null} ) : null}
<GroupedProviderRows providers={providers} onDrillDown={onDrillDown} /> <GroupedProviderRows providers={providers} onDrillDown={onDrillDown} />
</View> </View>
) : null} ) : null)}
{!hasResults ? <ModelSearchEmptyState /> : null} {!hasResults ? <ModelSearchEmptyState /> : null}
</View> </View>
); );
@@ -1183,6 +1214,8 @@ export function ModelBrowser({
onRetryProvider, onRetryProvider,
isRetryingProvider = false, isRetryingProvider = false,
scrolling = "sheet", scrolling = "sheet",
searchAllOnFocus = false,
rootBrowseContent,
}: ModelBrowserProps) { }: ModelBrowserProps) {
return ( return (
<ModelBrowserContent <ModelBrowserContent
@@ -1191,6 +1224,7 @@ export function ModelBrowser({
selectedProvider={state.selectedProvider} selectedProvider={state.selectedProvider}
selectedModel={state.selectedModel} selectedModel={state.selectedModel}
searchQuery={state.searchQuery} searchQuery={state.searchQuery}
isSearchFocused={state.isSearchFocused}
profiles={state.profiles} profiles={state.profiles}
onSelect={onSelect} onSelect={onSelect}
onApplyProfile={onApplyProfile} onApplyProfile={onApplyProfile}
@@ -1199,6 +1233,8 @@ export function ModelBrowser({
onRetryProvider={onRetryProvider} onRetryProvider={onRetryProvider}
isRetryingProvider={isRetryingProvider} isRetryingProvider={isRetryingProvider}
scrolling={scrolling} scrolling={scrolling}
searchAllOnFocus={searchAllOnFocus}
rootBrowseContent={rootBrowseContent}
/> />
); );
} }
@@ -772,6 +772,7 @@ function ControlledAgentControls({
modeControl={modeControl} modeControl={modeControl}
glyphSize={layoutContextValue.glyphSize} glyphSize={layoutContextValue.glyphSize}
modelSelectorServerId={modelSelectorServerId} modelSelectorServerId={modelSelectorServerId}
canSwitchProvider={Boolean(onSelectProviderAndModel)}
/> />
)} )}
</View> </View>
@@ -1086,6 +1087,7 @@ interface SheetAgentControlsContentProps {
modeControl?: AgentModeControlValue | null; modeControl?: AgentModeControlValue | null;
glyphSize: number; glyphSize: number;
modelSelectorServerId: string | null; modelSelectorServerId: string | null;
canSwitchProvider: boolean;
} }
function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
@@ -1122,6 +1124,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
modeControl, modeControl,
glyphSize, glyphSize,
modelSelectorServerId, modelSelectorServerId,
canSwitchProvider,
} = props; } = props;
const thinkingAnchorRef = useRef<View | null>(null); const thinkingAnchorRef = useRef<View | null>(null);
@@ -1193,6 +1196,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
providers={modelSelectorProviders} providers={modelSelectorProviders}
selectedProvider={provider} selectedProvider={provider}
selectedModel={selectedModelId ?? ""} selectedModel={selectedModelId ?? ""}
thinkingLabel={hasThinking ? displayThinking : null}
onSelect={handleSheetModelSelect} onSelect={handleSheetModelSelect}
profiles={agentProfiles} profiles={agentProfiles}
onApplyProfile={onApplyAgentProfile} onApplyProfile={onApplyAgentProfile}
@@ -1205,6 +1209,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
isRetryingProvider={isRetryingModelProvider} isRetryingProvider={isRetryingModelProvider}
serverId={modelSelectorServerId} serverId={modelSelectorServerId}
glyphSize={glyphSize} glyphSize={glyphSize}
canSwitchProvider={canSwitchProvider}
> >
{sheetControls} {sheetControls}
</CompactModelSheet> </CompactModelSheet>
@@ -1357,30 +1362,28 @@ function SheetFeatureItem({
); );
const sheetHeader = useMemo<SheetHeader>(() => ({ title: feature.label }), [feature.label]); 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( const handleSelectOption = useCallback(
(optionId: string) => { (optionId: string) => {
onSetFeature?.(feature.id, optionId); onSetFeature?.(feature.id, feature.type === "toggle" ? optionId === "true" : optionId);
}, },
[feature.id, onSetFeature], [feature.id, feature.type, onSetFeature],
);
const comboboxOptions = useMemo<ComboboxOption[]>(
() =>
feature.type === "select"
? feature.options.map((option) => ({ id: option.id, label: option.label }))
: [],
[feature],
); );
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") { if (feature.type === "toggle") {
const FeatureIcon = getAgentFeatureIcon(feature.icon); const FeatureIcon = getAgentFeatureIcon(feature.icon);
return ( return (
<>
<AgentControlTrigger <AgentControlTrigger
ref={featureAnchorRef}
icon={FeatureIcon} icon={FeatureIcon}
iconColor={getFeatureIconColor( iconColor={getFeatureIconColor(
feature.id, feature.id,
@@ -1391,11 +1394,24 @@ function SheetFeatureItem({
surface="sheet" surface="sheet"
label={feature.label} label={feature.label}
value={feature.value ? t("agentControls.features.on") : t("agentControls.features.off")} value={feature.value ? t("agentControls.features.on") : t("agentControls.features.off")}
open={openSelector === featureSelector}
disabled={disabled} disabled={disabled}
onPress={handleTogglePress} onPress={handleSelectPress}
accessibilityLabel={getFeatureTooltip(feature)} accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`} 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 { useTranslation } from "react-i18next";
import { Keyboard, ScrollView, Text, View, type PressableStateCallbackType } from "react-native"; import { Keyboard, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { Bot } from "lucide-react-native";
import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { AgentProfilePicker } from "@/agent-profiles"; import type { AgentProfilePicker } from "@/agent-profiles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
import { ModelBrowser, useModelBrowser } from "@/components/model-browser"; import { ModelBrowser, useModelBrowser } from "@/components/model-browser";
import { AgentControlTrigger } from "@/composer/agent-controls/control";
import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph"; 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 type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
const SNAP_POINTS = ["80%", "90%"]; const SNAP_POINTS = ["80%", "90%"];
const MODEL_LIST_TOP_INSET = 4; const MODEL_LIST_TOP_INSET = 4;
@@ -23,6 +27,7 @@ interface CompactModelSheetProps {
providers: ProviderSelectorProvider[]; providers: ProviderSelectorProvider[];
selectedProvider: string; selectedProvider: string;
selectedModel: string; selectedModel: string;
thinkingLabel: string | null;
onSelect: (provider: string, modelId: string) => void; onSelect: (provider: string, modelId: string) => void;
isLoading: boolean; isLoading: boolean;
profiles?: AgentProfilePicker | null; profiles?: AgentProfilePicker | null;
@@ -35,6 +40,7 @@ interface CompactModelSheetProps {
disabled?: boolean; disabled?: boolean;
serverId?: string | null; serverId?: string | null;
glyphSize: number; glyphSize: number;
canSwitchProvider: boolean;
children: ReactNode; children: ReactNode;
} }
@@ -47,6 +53,7 @@ export function CompactModelSheet({
providers, providers,
selectedProvider, selectedProvider,
selectedModel, selectedModel,
thinkingLabel,
onSelect, onSelect,
isLoading, isLoading,
profiles = null, profiles = null,
@@ -59,47 +66,107 @@ export function CompactModelSheet({
disabled = false, disabled = false,
serverId = null, serverId = null,
glyphSize, glyphSize,
canSwitchProvider,
children, children,
}: CompactModelSheetProps) { }: CompactModelSheetProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const usesBottomSheet = useIsCompactFormFactor(); const usesBottomSheet = useIsCompactFormFactor();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const browser = useModelBrowser({ const [isModelBrowserOpen, setIsModelBrowserOpen] = useState(false);
providers, 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, selectedProvider,
selectedModel, selectedModel,
isLoading, isLoading,
autoFocusSearch: isWeb && !usesBottomSheet,
profiles, profiles,
serverId, serverId,
}); });
const { prepareToOpen, reset } = browser; const modelBrowser = useModelBrowser({
providers: availableProviders,
selectedProvider,
selectedModel,
isLoading,
autoFocusSearch: isWeb && !usesBottomSheet,
serverId,
});
const ProviderIcon = const ProviderIcon =
selectedProvider.trim().length > 0 ? getProviderIcon(selectedProvider) : null; selectedProvider.trim().length > 0 ? getProviderIcon(selectedProvider) : null;
const compactFooter = useMemo( const ModelIcon = ProviderIcon ?? Bot;
() => const rootHeader = useMemo(
usesBottomSheet ? ( () => ({
<View style={styles.compactFooter} testID="agent-controls-settings-list"> ...rootBrowser.header,
<View style={styles.modelViewportDivider} /> title: t("modelSelector.selectModel"),
<View style={[styles.controlsContent, styles.compactControlsContent]}>{children}</View> search:
</View> rootBrowser.header.search && !canSwitchProvider
) : undefined, ? {
[children, usesBottomSheet], ...rootBrowser.header.search,
placeholder: t("modelSelector.searchPlaceholder"),
}
: rootBrowser.header.search,
}),
[canSwitchProvider, rootBrowser.header, t],
); );
const open = useCallback(() => { const open = useCallback(() => {
Keyboard.dismiss(); Keyboard.dismiss();
prepareToOpen(); rootBrowser.showAll();
setIsOpen(true); setIsOpen(true);
onOpen?.(); onOpen?.();
}, [onOpen, prepareToOpen]); }, [onOpen, rootBrowser]);
const close = useCallback(() => { const close = useCallback(() => {
setIsModelBrowserOpen(false);
setIsOpen(false); setIsOpen(false);
reset(); rootBrowser.reset();
modelBrowser.reset();
onClose?.(); 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) => { (provider: string, modelId: string) => {
onSelect(provider, modelId); onSelect(provider, modelId);
close(); close();
@@ -137,6 +204,28 @@ export function CompactModelSheet({
], ],
[disabled, isOpen], [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 ( return (
<> <>
@@ -147,7 +236,7 @@ export function CompactModelSheet({
style={triggerStyle} style={triggerStyle}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={t("modelSelector.selectedModel", { accessibilityLabel={t("modelSelector.selectedModel", {
model: browser.selectedModelLabel, model: rootBrowser.selectedModelLabel,
})} })}
testID="combined-model-selector" testID="combined-model-selector"
chevron={null} chevron={null}
@@ -157,20 +246,25 @@ export function CompactModelSheet({
<ProviderIcon size={glyphSize} color={styles.providerIcon.color} /> <ProviderIcon size={glyphSize} color={styles.providerIcon.color} />
</ComposerToolbarGlyph> </ComposerToolbarGlyph>
) : null} ) : null}
<View style={styles.triggerLabels}>
<Text style={styles.triggerText} numberOfLines={1}> <Text style={styles.triggerText} numberOfLines={1}>
{shortModelLabel(browser.triggerLabel)} {shortModelLabel(rootBrowser.triggerLabel)}
</Text> </Text>
{thinkingLabel ? (
<Text style={styles.triggerThinking} numberOfLines={1}>
{thinkingLabel}
</Text>
) : null}
</View>
</ComboboxTrigger> </ComboboxTrigger>
<AdaptiveModalSheet <AdaptiveModalSheet
header={browser.header} header={rootHeader}
visible={isOpen} visible={isOpen}
onClose={close} onClose={close}
snapPoints={SNAP_POINTS} snapPoints={SNAP_POINTS}
scrollable={false} scrollable={false}
sizeContentToCurrentSnapPoint={usesBottomSheet} sizeContentToCurrentSnapPoint={usesBottomSheet}
footer={compactFooter}
footerContainerStyle={usesBottomSheet ? styles.compactFooterContainer : undefined}
contentStyle={styles.sheetBody} contentStyle={styles.sheetBody}
testID="agent-controls-model-sheet" testID="agent-controls-model-sheet"
> >
@@ -182,13 +276,15 @@ export function CompactModelSheet({
testID="agent-controls-model-viewport" testID="agent-controls-model-viewport"
> >
<ModelBrowser <ModelBrowser
state={browser} state={rootBrowser}
onSelect={handleSelect} onSelect={usesBottomSheet ? handleSearchSelect : handleDesktopSelect}
onApplyProfile={handleApplyProfile} onApplyProfile={handleApplyProfile}
onEditProfiles={onEditProfiles ? handleEditProfiles : undefined} onEditProfiles={onEditProfiles ? handleEditProfiles : undefined}
onRetryProvider={onRetryProvider} onRetryProvider={onRetryProvider}
isRetryingProvider={isRetryingProvider} isRetryingProvider={isRetryingProvider}
scrolling="independent" scrolling="independent"
searchAllOnFocus={usesBottomSheet}
rootBrowseContent={usesBottomSheet ? mobileRootContent : undefined}
/> />
</View> </View>
{!usesBottomSheet ? ( {!usesBottomSheet ? (
@@ -206,6 +302,34 @@ export function CompactModelSheet({
</> </>
) : null} ) : null}
</AdaptiveModalSheet> </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, fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal, 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: { providerIcon: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
@@ -268,20 +405,10 @@ const styles = StyleSheet.create((theme) => ({
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
}, },
compactFooterContainer: { mobileRootContent: {
flexDirection: "column", backgroundColor: theme.colors.surfaceSidebar,
alignItems: "stretch", borderTopWidth: 1,
justifyContent: "flex-start", borderTopColor: theme.colors.border,
gap: 0,
paddingHorizontal: 0,
paddingTop: 0,
borderTopWidth: 0,
},
compactFooter: {
minWidth: 0,
},
compactControlsContent: {
paddingBottom: 0,
}, },
controlsContent: { controlsContent: {
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
@@ -0,0 +1,5 @@
import { useKeyboardState } from "react-native-keyboard-controller";
export function useKeyboardVisibility(): boolean {
return useKeyboardState((state) => state.isVisible);
}
@@ -0,0 +1,3 @@
export function useKeyboardVisibility(): boolean {
return false;
}
+1
View File
@@ -1411,6 +1411,7 @@ export const ar: TranslationResources = {
defaultModel: "تقصير", defaultModel: "تقصير",
profiles: "الملفات الشخصية", profiles: "الملفات الشخصية",
providers: "المزودون", providers: "المزودون",
model: "النموذج",
editProfiles: "تحرير", editProfiles: "تحرير",
editProfilesLabel: "تحرير ملفات الوكيل", editProfilesLabel: "تحرير ملفات الوكيل",
createProfile: "إنشاء ملف شخصي", createProfile: "إنشاء ملف شخصي",
+1
View File
@@ -1421,6 +1421,7 @@ export const en = {
defaultModel: "Default", defaultModel: "Default",
profiles: "Profiles", profiles: "Profiles",
providers: "Providers", providers: "Providers",
model: "Model",
editProfiles: "Edit", editProfiles: "Edit",
editProfilesLabel: "Edit agent profiles", editProfilesLabel: "Edit agent profiles",
createProfile: "Create profile", createProfile: "Create profile",
+1
View File
@@ -1452,6 +1452,7 @@ export const es: TranslationResources = {
defaultModel: "Por defecto", defaultModel: "Por defecto",
profiles: "Perfiles", profiles: "Perfiles",
providers: "Proveedores", providers: "Proveedores",
model: "Modelo",
editProfiles: "Editar", editProfiles: "Editar",
editProfilesLabel: "Editar perfiles de agente", editProfilesLabel: "Editar perfiles de agente",
createProfile: "Crear perfil", createProfile: "Crear perfil",
+1
View File
@@ -1456,6 +1456,7 @@ export const fr: TranslationResources = {
defaultModel: "Défaut", defaultModel: "Défaut",
profiles: "Profils", profiles: "Profils",
providers: "Fournisseurs", providers: "Fournisseurs",
model: "Modèle",
editProfiles: "Modifier", editProfiles: "Modifier",
editProfilesLabel: "Modifier les profils d'agent", editProfilesLabel: "Modifier les profils d'agent",
createProfile: "Créer un profil", createProfile: "Créer un profil",
+1
View File
@@ -1425,6 +1425,7 @@ export const ja: TranslationResources = {
defaultModel: "デフォルト", defaultModel: "デフォルト",
profiles: "プロファイル", profiles: "プロファイル",
providers: "プロバイダー", providers: "プロバイダー",
model: "モデル",
editProfiles: "編集", editProfiles: "編集",
editProfilesLabel: "エージェントプロファイルを編集", editProfilesLabel: "エージェントプロファイルを編集",
createProfile: "プロファイルを作成", createProfile: "プロファイルを作成",
+1
View File
@@ -1421,6 +1421,7 @@ export const ko: TranslationResources = {
defaultModel: "기본값", defaultModel: "기본값",
profiles: "프로필", profiles: "프로필",
providers: "제공자", providers: "제공자",
model: "모델",
editProfiles: "편집", editProfiles: "편집",
editProfilesLabel: "에이전트 프로필 편집", editProfilesLabel: "에이전트 프로필 편집",
createProfile: "프로필 만들기", createProfile: "프로필 만들기",
+1
View File
@@ -1438,6 +1438,7 @@ export const ptBR: TranslationResources = {
defaultModel: "Padrão", defaultModel: "Padrão",
profiles: "Perfis", profiles: "Perfis",
providers: "Provedores", providers: "Provedores",
model: "Modelo",
editProfiles: "Editar", editProfiles: "Editar",
editProfilesLabel: "Editar perfis de agente", editProfilesLabel: "Editar perfis de agente",
createProfile: "Criar perfil", createProfile: "Criar perfil",
+1
View File
@@ -1443,6 +1443,7 @@ export const ru: TranslationResources = {
defaultModel: "По умолчанию", defaultModel: "По умолчанию",
profiles: "Профили", profiles: "Профили",
providers: "Провайдеры", providers: "Провайдеры",
model: "Модель",
editProfiles: "Изменить", editProfiles: "Изменить",
editProfilesLabel: "Изменить профили агентов", editProfilesLabel: "Изменить профили агентов",
createProfile: "Создать профиль", createProfile: "Создать профиль",
+1
View File
@@ -1394,6 +1394,7 @@ export const zhCN: TranslationResources = {
defaultModel: "默认", defaultModel: "默认",
profiles: "配置文件", profiles: "配置文件",
providers: "提供方", providers: "提供方",
model: "模型",
editProfiles: "编辑", editProfiles: "编辑",
editProfilesLabel: "编辑智能体配置文件", editProfilesLabel: "编辑智能体配置文件",
createProfile: "创建配置文件", createProfile: "创建配置文件",
@@ -400,9 +400,11 @@ describe("createArchiveFinishedSubagents", () => {
}); });
it("dismisses finished provider rows locally without removing their descriptors", async () => { it("dismisses finished provider rows locally without removing their descriptors", async () => {
const finished = provider("finished");
const running = provider("running", "running");
const descriptors = new Map([ const descriptors = new Map([
["finished", provider("finished")], ["finished", finished],
["running", provider("running", "running")], ["running", running],
]); ]);
const dismissed: string[][] = []; const dismissed: string[][] = [];
const archive = createArchiveFinishedSubagents([...descriptors.values()], { const archive = createArchiveFinishedSubagents([...descriptors.values()], {
@@ -420,7 +422,7 @@ describe("createArchiveFinishedSubagents", () => {
}); });
expect(dismissed).toEqual([["finished"]]); expect(dismissed).toEqual([["finished"]]);
expect(descriptors.get("finished")).toEqual(provider("finished")); expect(descriptors.get("finished")).toBe(finished);
expect(descriptors.get("running")).toEqual(provider("running", "running")); expect(descriptors.get("running")).toBe(running);
}); });
}); });