diff --git a/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts b/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts index 5e6abb0ad..2d017b42a 100644 --- a/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts +++ b/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts @@ -90,11 +90,7 @@ async function openTabSwitcher(page: Page) { async function openModelSelector(page: Page) { await page.getByRole("button", { name: /Select model/ }).click(); await expectBottomSheetOpen(page); - await expect( - page.getByLabel("Bottom Sheet", { exact: true }).getByText("Ten second stream", { - exact: true, - }), - ).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible({ timeout: 10_000 }); } 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 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 expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible(); + await model.click(); + + 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(); }); }); }); diff --git a/packages/app/e2e/browser/mermaid-streaming.spec.ts b/packages/app/e2e/browser/mermaid-streaming.spec.ts index 184242689..89357e0e5 100644 --- a/packages/app/e2e/browser/mermaid-streaming.spec.ts +++ b/packages/app/e2e/browser/mermaid-streaming.spec.ts @@ -34,7 +34,7 @@ const STREAMED_MERMAID = [ test("keeps a Mermaid diagram rendered while its message streams, completes, and reloads", async ({ page, }) => { - test.setTimeout(90_000); + test.setTimeout(120_000); const agent = await seedMockAgentWorkspace({ repoPrefix: "mermaid-streaming-", diff --git a/packages/app/e2e/browser/provider-settings-refresh.spec.ts b/packages/app/e2e/browser/provider-settings-refresh.spec.ts index 62e8e793b..5f3555504 100644 --- a/packages/app/e2e/browser/provider-settings-refresh.spec.ts +++ b/packages/app/e2e/browser/provider-settings-refresh.spec.ts @@ -21,21 +21,22 @@ async function openMockAgentAtMobileBreakpoint(page: Page) { async function openProviderSettingsFromModelSelector(page: Page) { 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(); - if (await openCodeRow.isVisible().catch(() => false)) { - await openCodeRow.click(); - } + const modelBrowser = page.getByTestId("agent-controls-model-browser-sheet"); + await expect(modelBrowser).toBeVisible({ timeout: 10_000 }); await page.getByRole("button", { name: /Open .* settings/ }).click(); await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 }); } -async function expectModelSelectorVisible(page: Page) { - await expect(page.getByRole("button", { name: /Open .* settings/ })).toBeVisible({ +async function expectModelBrowserVisible(page: Page) { + await expect(page.getByTestId("agent-controls-model-browser-sheet")).toBeVisible({ timeout: 10_000, }); + await expect(page.getByRole("button", { name: /Open .* settings/ })).toBeVisible(); } 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, }) => { test.setTimeout(180_000); @@ -192,13 +193,18 @@ test.describe("provider settings overlay stack", () => { await exerciseProviderSettingsStack(page); await closeSheetByHeaderButton(page, "provider-settings-sheet"); - await expectModelSelectorVisible(page); + await expectModelBrowserVisible(page); await page.getByRole("button", { name: /Open .* settings/ }).click(); await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 }); await exerciseProviderSettingsStack(page); 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); } finally { await session.cleanup(); diff --git a/packages/app/src/components/adaptive-modal-sheet-layout.test.ts b/packages/app/src/components/adaptive-modal-sheet-layout.test.ts index 7fc2ba342..308193d9d 100644 --- a/packages/app/src/components/adaptive-modal-sheet-layout.test.ts +++ b/packages/app/src/components/adaptive-modal-sheet-layout.test.ts @@ -1,11 +1,15 @@ 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", () => { it("adds the bottom inset to compact sheet footers", () => { expect( getCompactSheetSafeAreaPadding({ isCompact: true, + isKeyboardVisible: false, hasFooter: true, baseContentPadding: 24, baseFooterPadding: 12, @@ -18,6 +22,7 @@ describe("getCompactSheetSafeAreaPadding", () => { expect( getCompactSheetSafeAreaPadding({ isCompact: true, + isKeyboardVisible: false, hasFooter: false, baseContentPadding: 24, baseFooterPadding: 12, @@ -26,10 +31,24 @@ describe("getCompactSheetSafeAreaPadding", () => { ).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", () => { expect( getCompactSheetSafeAreaPadding({ isCompact: false, + isKeyboardVisible: false, hasFooter: false, baseContentPadding: 24, baseFooterPadding: 12, @@ -38,3 +57,17 @@ describe("getCompactSheetSafeAreaPadding", () => { ).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); + }); +}); diff --git a/packages/app/src/components/adaptive-modal-sheet-layout.ts b/packages/app/src/components/adaptive-modal-sheet-layout.ts index faeae9101..123c0d337 100644 --- a/packages/app/src/components/adaptive-modal-sheet-layout.ts +++ b/packages/app/src/components/adaptive-modal-sheet-layout.ts @@ -1,5 +1,6 @@ export interface CompactSheetSafeAreaPaddingInput { isCompact: boolean; + isKeyboardVisible: boolean; hasFooter: boolean; baseContentPadding: number; baseFooterPadding: number; @@ -11,14 +12,37 @@ export interface CompactSheetSafeAreaPadding { 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({ isCompact, + isKeyboardVisible, hasFooter, baseContentPadding, baseFooterPadding, safeAreaBottom, }: CompactSheetSafeAreaPaddingInput): CompactSheetSafeAreaPadding { - if (!isCompact || safeAreaBottom <= 0) { + if (!isCompact || isKeyboardVisible || safeAreaBottom <= 0) { return {}; } diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 94191f4eb..9a487aa49 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -16,6 +16,7 @@ import { BottomSheetBackdrop, BottomSheetScrollView, BottomSheetTextInput, + KEYBOARD_STATUS, useBottomSheetInternal, type BottomSheetBackgroundProps, } from "@gorhom/bottom-sheet"; @@ -25,9 +26,13 @@ import { IsolatedBottomSheetModal, useIsolatedBottomSheetVisibility, } 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 { isNative, isWeb } from "@/constants/platform"; +import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility"; import { useSafeAreaInsets } from "react-native-safe-area-context"; // 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 { onChange: (value: string) => void; + onFocus?: () => void; + onBlur?: () => void; resetKey?: string | number; placeholder?: string; autoFocus?: boolean; @@ -262,14 +269,15 @@ function BottomSheetVisibleContent({ children }: { children: ReactNode }) { ? animatedPosition.get() : Math.min(animatedPosition.get(), initialDetentPosition); + const keyboardState = animatedKeyboardState.get(); return { - height: Math.max( - 0, - containerHeight - - contentPosition - - handleHeight - - animatedKeyboardState.get().heightWithinContainer, - ), + height: getBottomSheetVisibleContentHeight({ + containerHeight, + contentPosition, + handleHeight, + keyboardHeight: keyboardState.heightWithinContainer, + isKeyboardVisible: keyboardState.status === KEYBOARD_STATUS.SHOWN, + }), }; }, [animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition]); @@ -409,6 +417,8 @@ export function SheetHeaderView({ placeholder={search.placeholder ?? t("common.actions.search")} resetKey={search.resetKey} onChangeText={handleSearchChange} + onFocus={search.onFocus} + onBlur={search.onBlur} autoCapitalize="none" autoCorrect={false} autoFocus={search.autoFocus} @@ -466,6 +476,8 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) { placeholder={header.search.placeholder ?? t("common.actions.search")} resetKey={header.search.resetKey} onChangeText={header.search.onChange} + onFocus={header.search.onFocus} + onBlur={header.search.onBlur} autoCapitalize="none" autoCorrect={false} autoFocus={header.search.autoFocus} @@ -518,17 +530,19 @@ export function AdaptiveModalSheet({ const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); const insets = useSafeAreaInsets(); + const isKeyboardVisible = useKeyboardVisibility(); const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); const compactSafeAreaPadding = useMemo( () => getCompactSheetSafeAreaPadding({ isCompact: isMobile, + isKeyboardVisible, hasFooter: Boolean(footer), baseContentPadding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], baseFooterPadding: theme.spacing[3], safeAreaBottom: insets.bottom, }), - [footer, insets.bottom, isMobile, theme.spacing], + [footer, insets.bottom, isKeyboardVisible, isMobile, theme.spacing], ); const compactContentStyle = useMemo( () => [ diff --git a/packages/app/src/components/model-browser-view.test.ts b/packages/app/src/components/model-browser-view.test.ts index f1e1998eb..3c5e2830c 100644 --- a/packages/app/src/components/model-browser-view.test.ts +++ b/packages/app/src/components/model-browser-view.test.ts @@ -105,13 +105,35 @@ describe("model browser all view", () => { const providers = [claude, copilot, codex]; it("browses providers while the query is empty", () => { - expect(resolveModelBrowserAllView({ providers, normalizedQuery: "" })).toEqual({ + expect( + resolveModelBrowserAllView({ providers, normalizedQuery: "", isSearchFocused: false }), + ).toEqual({ 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", () => { - const view = resolveModelBrowserAllView({ providers, normalizedQuery: "opus" }); + const view = resolveModelBrowserAllView({ + providers, + normalizedQuery: "opus", + isSearchFocused: true, + }); expect(view.kind).toBe("searchResults"); 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", () => { - 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([ "gpt-5.4", @@ -129,9 +155,13 @@ describe("model browser all view", () => { }); it("reports no matches instead of falling back to the provider list", () => { - expect(resolveModelBrowserAllView({ providers, normalizedQuery: "zzzz" })).toEqual({ - kind: "noSearchMatches", - }); + expect( + resolveModelBrowserAllView({ + providers, + normalizedQuery: "zzzz", + isSearchFocused: true, + }), + ).toEqual({ kind: "noSearchMatches" }); }); it("ignores providers that are still loading or errored", () => { @@ -147,7 +177,11 @@ describe("model browser all view", () => { }; expect( - resolveModelBrowserAllView({ providers: [loading, failed], normalizedQuery: "opus" }), + resolveModelBrowserAllView({ + providers: [loading, failed], + normalizedQuery: "opus", + isSearchFocused: true, + }), ).toEqual({ kind: "noSearchMatches" }); }); }); diff --git a/packages/app/src/components/model-browser-view.ts b/packages/app/src/components/model-browser-view.ts index 75b1c7541..707b1c49f 100644 --- a/packages/app/src/components/model-browser-view.ts +++ b/packages/app/src/components/model-browser-view.ts @@ -18,14 +18,17 @@ export type ModelBrowserAllView = export function resolveModelBrowserAllView({ providers, normalizedQuery, + isSearchFocused, }: { providers: ProviderSelectorProvider[]; normalizedQuery: string; + isSearchFocused: boolean; }): ModelBrowserAllView { - if (!normalizedQuery) { + if (!normalizedQuery && !isSearchFocused) { return { kind: "browse" }; } - const rows = filterAndRankModelRows(getAllProviderModelRows(providers), normalizedQuery); + const allRows = getAllProviderModelRows(providers); + const rows = normalizedQuery ? filterAndRankModelRows(allRows, normalizedQuery) : allRows; if (rows.length === 0) { return { kind: "noSearchMatches" }; } diff --git a/packages/app/src/components/model-browser.tsx b/packages/app/src/components/model-browser.tsx index 49a37bfe0..1b476c911 100644 --- a/packages/app/src/components/model-browser.tsx +++ b/packages/app/src/components/model-browser.tsx @@ -145,6 +145,7 @@ interface ModelBrowserInput { selectedProvider: string; selectedModel: string; isLoading: boolean; + autoFocusSearch?: boolean; /** Pinned above the provider list on the root view. `null` hides the section. */ profiles?: AgentProfilePicker | null; serverId?: string | null; @@ -157,12 +158,14 @@ export interface ModelBrowserState { profiles: AgentProfilePicker | null; view: ModelBrowserView; searchQuery: string; + isSearchFocused: boolean; header: SheetHeader; selectedModelLabel: string; triggerLabel: string; desktopFixedHeight: number | undefined; isProviderView: boolean; prepareToOpen: () => void; + showAll: () => void; reset: () => void; drillDown: (providerId: string, providerLabel: string) => void; } @@ -176,6 +179,10 @@ interface ModelBrowserProps { onRetryProvider?: (provider: AgentProvider) => void; isRetryingProvider?: boolean; 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 { @@ -184,9 +191,12 @@ interface ModelBrowserContentProps extends Omit void; scrolling: "sheet" | "independent"; + searchAllOnFocus: boolean; + rootBrowseContent?: React.ReactNode; } type ProviderGlyphTone = "muted" | "foreground"; @@ -246,12 +256,14 @@ export function useModelBrowser({ selectedProvider, selectedModel, isLoading, + autoFocusSearch = isWeb, profiles = null, serverId = null, }: ModelBrowserInput): ModelBrowserState { const { t } = useTranslation(); const [view, setView] = useState({ kind: "all" }); const [searchQuery, setSearchQuery] = useState(""); + const [isSearchFocused, setIsSearchFocused] = useState(false); const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0); const hasProfiles = (profiles?.rows.length ?? 0) > 0; @@ -272,10 +284,11 @@ export function useModelBrowser({ const reset = useCallback(() => { setSearchQuery(""); + setIsSearchFocused(false); bumpSearchResetKey(); }, []); - const handleBackToAll = useCallback(() => { + const showAll = useCallback(() => { setView({ kind: "all" }); reset(); }, [reset]); @@ -299,9 +312,11 @@ export function useModelBrowser({ title: t("modelSelector.title"), search: { onChange: handleSearchQueryChange, + onFocus: () => setIsSearchFocused(true), + onBlur: () => setIsSearchFocused(false), resetKey: `all:${searchResetKey}`, placeholder: t("modelSelector.searchAllPlaceholder"), - autoFocus: isWeb, + autoFocus: autoFocusSearch, testID: "model-search-all-input", }, }; @@ -311,7 +326,7 @@ export function useModelBrowser({ leading: ( ), - back: singleProviderView ? undefined : { onPress: handleBackToAll }, + back: singleProviderView ? undefined : { onPress: showAll }, actions: ( setIsSearchFocused(true), + onBlur: () => setIsSearchFocused(false), resetKey: `${view.providerId}:${searchResetKey}`, placeholder: t("modelSelector.searchPlaceholder"), - autoFocus: isWeb, + autoFocus: autoFocusSearch, testID: "model-search-input", }, }; }, [ - handleBackToAll, + autoFocusSearch, handleSearchQueryChange, searchResetKey, serverId, singleProviderView, + showAll, t, view, ]); @@ -371,12 +389,14 @@ export function useModelBrowser({ profiles, view, searchQuery, + isSearchFocused, header, selectedModelLabel, triggerLabel, desktopFixedHeight, isProviderView: view.kind === "provider", prepareToOpen, + showAll, reset, drillDown, }; @@ -856,6 +876,7 @@ function IndependentModelList({ keyExtractor={getModelRowKey} style={styles.virtualizedModelList} keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" showsVerticalScrollIndicator={false} contentContainerStyle={styles.virtualizedModelListContent} nestedScrollEnabled @@ -879,6 +900,7 @@ function IndependentProviderList({ children }: { children: React.ReactNode }) { styles.virtualizedProviderListContent, ]} keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" showsVerticalScrollIndicator={false} nestedScrollEnabled testID="compact-provider-list" @@ -1078,6 +1100,7 @@ function ModelBrowserContent({ selectedProvider, selectedModel, searchQuery, + isSearchFocused, profiles, onSelect, onApplyProfile, @@ -1086,6 +1109,8 @@ function ModelBrowserContent({ onRetryProvider, isRetryingProvider = false, scrolling, + searchAllOnFocus, + rootBrowseContent, }: ModelBrowserContentProps) { const { t } = useTranslation(); const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]); @@ -1097,10 +1122,15 @@ function ModelBrowserContent({ [providers, view], ); 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") { return ( @@ -1154,16 +1184,17 @@ function ModelBrowserContent({ onEditProfiles={onEditProfiles} /> ) : null} - {providers.length > 0 ? ( - - {profiles ? ( - - {t("modelSelector.providers")} - - ) : null} - - - ) : null} + {rootBrowseContent ?? + (providers.length > 0 ? ( + + {profiles ? ( + + {t("modelSelector.providers")} + + ) : null} + + + ) : null)} {!hasResults ? : null} ); @@ -1183,6 +1214,8 @@ export function ModelBrowser({ onRetryProvider, isRetryingProvider = false, scrolling = "sheet", + searchAllOnFocus = false, + rootBrowseContent, }: ModelBrowserProps) { return ( ); } diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 5494de61b..f611387e2 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -772,6 +772,7 @@ function ControlledAgentControls({ modeControl={modeControl} glyphSize={layoutContextValue.glyphSize} modelSelectorServerId={modelSelectorServerId} + canSwitchProvider={Boolean(onSelectProviderAndModel)} /> )} @@ -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(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} @@ -1357,45 +1362,56 @@ function SheetFeatureItem({ ); const sheetHeader = useMemo(() => ({ 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( - () => - feature.type === "select" - ? feature.options.map((option) => ({ id: option.id, label: option.label })) - : [], - [feature], + [feature.id, feature.type, onSetFeature], ); + const comboboxOptions = useMemo(() => { + 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 ( - + <> + + + ); } diff --git a/packages/app/src/composer/agent-controls/model-sheet-flow.test.ts b/packages/app/src/composer/agent-controls/model-sheet-flow.test.ts new file mode 100644 index 000000000..a3f41b791 --- /dev/null +++ b/packages/app/src/composer/agent-controls/model-sheet-flow.test.ts @@ -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" }); + }); +}); diff --git a/packages/app/src/composer/agent-controls/model-sheet-flow.ts b/packages/app/src/composer/agent-controls/model-sheet-flow.ts new file mode 100644 index 000000000..192689e78 --- /dev/null +++ b/packages/app/src/composer/agent-controls/model-sheet-flow.ts @@ -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" }; +} diff --git a/packages/app/src/composer/agent-controls/model-sheet.tsx b/packages/app/src/composer/agent-controls/model-sheet.tsx index b49813dd4..1959e4171 100644 --- a/packages/app/src/composer/agent-controls/model-sheet.tsx +++ b/packages/app/src/composer/agent-controls/model-sheet.tsx @@ -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 ? ( - - - {children} - - ) : 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( + () => ( + + + + {children} + + + ), + [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({ ) : null} - - {shortModelLabel(browser.triggerLabel)} - + + + {shortModelLabel(rootBrowser.triggerLabel)} + + {thinkingLabel ? ( + + {thinkingLabel} + + ) : null} + @@ -182,13 +276,15 @@ export function CompactModelSheet({ testID="agent-controls-model-viewport" > {!usesBottomSheet ? ( @@ -206,6 +302,34 @@ export function CompactModelSheet({ ) : null} + + {usesBottomSheet ? ( + + + + + + ) : 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], diff --git a/packages/app/src/hooks/use-keyboard-visibility.native.ts b/packages/app/src/hooks/use-keyboard-visibility.native.ts new file mode 100644 index 000000000..cb222d9e0 --- /dev/null +++ b/packages/app/src/hooks/use-keyboard-visibility.native.ts @@ -0,0 +1,5 @@ +import { useKeyboardState } from "react-native-keyboard-controller"; + +export function useKeyboardVisibility(): boolean { + return useKeyboardState((state) => state.isVisible); +} diff --git a/packages/app/src/hooks/use-keyboard-visibility.ts b/packages/app/src/hooks/use-keyboard-visibility.ts new file mode 100644 index 000000000..793a521b6 --- /dev/null +++ b/packages/app/src/hooks/use-keyboard-visibility.ts @@ -0,0 +1,3 @@ +export function useKeyboardVisibility(): boolean { + return false; +} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 923642968..a864715ed 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1411,6 +1411,7 @@ export const ar: TranslationResources = { defaultModel: "تقصير", profiles: "الملفات الشخصية", providers: "المزودون", + model: "النموذج", editProfiles: "تحرير", editProfilesLabel: "تحرير ملفات الوكيل", createProfile: "إنشاء ملف شخصي", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index c12ab440d..cc01b209b 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1421,6 +1421,7 @@ export const en = { defaultModel: "Default", profiles: "Profiles", providers: "Providers", + model: "Model", editProfiles: "Edit", editProfilesLabel: "Edit agent profiles", createProfile: "Create profile", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index f28516005..2e00d8fb7 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1452,6 +1452,7 @@ export const es: TranslationResources = { defaultModel: "Por defecto", profiles: "Perfiles", providers: "Proveedores", + model: "Modelo", editProfiles: "Editar", editProfilesLabel: "Editar perfiles de agente", createProfile: "Crear perfil", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 0bda488b2..bb2154e87 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1456,6 +1456,7 @@ export const fr: TranslationResources = { defaultModel: "Défaut", profiles: "Profils", providers: "Fournisseurs", + model: "Modèle", editProfiles: "Modifier", editProfilesLabel: "Modifier les profils d'agent", createProfile: "Créer un profil", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index c20a905be..62e46e8dd 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1425,6 +1425,7 @@ export const ja: TranslationResources = { defaultModel: "デフォルト", profiles: "プロファイル", providers: "プロバイダー", + model: "モデル", editProfiles: "編集", editProfilesLabel: "エージェントプロファイルを編集", createProfile: "プロファイルを作成", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 99f427833..0f3652207 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1421,6 +1421,7 @@ export const ko: TranslationResources = { defaultModel: "기본값", profiles: "프로필", providers: "제공자", + model: "모델", editProfiles: "편집", editProfilesLabel: "에이전트 프로필 편집", createProfile: "프로필 만들기", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index f0a09e4d8..bbec62c18 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1438,6 +1438,7 @@ export const ptBR: TranslationResources = { defaultModel: "Padrão", profiles: "Perfis", providers: "Provedores", + model: "Modelo", editProfiles: "Editar", editProfilesLabel: "Editar perfis de agente", createProfile: "Criar perfil", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 430a1448c..7e6f3de80 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1443,6 +1443,7 @@ export const ru: TranslationResources = { defaultModel: "По умолчанию", profiles: "Профили", providers: "Провайдеры", + model: "Модель", editProfiles: "Изменить", editProfilesLabel: "Изменить профили агентов", createProfile: "Создать профиль", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 333fd8a0c..02c479538 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1394,6 +1394,7 @@ export const zhCN: TranslationResources = { defaultModel: "默认", profiles: "配置文件", providers: "提供方", + model: "模型", editProfiles: "编辑", editProfilesLabel: "编辑智能体配置文件", createProfile: "创建配置文件", diff --git a/packages/app/src/subagents/archive-finished.test.ts b/packages/app/src/subagents/archive-finished.test.ts index 6611b93f8..42a3e51ca 100644 --- a/packages/app/src/subagents/archive-finished.test.ts +++ b/packages/app/src/subagents/archive-finished.test.ts @@ -400,9 +400,11 @@ describe("createArchiveFinishedSubagents", () => { }); it("dismisses finished provider rows locally without removing their descriptors", async () => { + const finished = provider("finished"); + const running = provider("running", "running"); const descriptors = new Map([ - ["finished", provider("finished")], - ["running", provider("running", "running")], + ["finished", finished], + ["running", running], ]); const dismissed: string[][] = []; const archive = createArchiveFinishedSubagents([...descriptors.values()], { @@ -420,7 +422,7 @@ describe("createArchiveFinishedSubagents", () => { }); expect(dismissed).toEqual([["finished"]]); - expect(descriptors.get("finished")).toEqual(provider("finished")); - expect(descriptors.get("running")).toEqual(provider("running", "running")); + expect(descriptors.get("finished")).toBe(finished); + expect(descriptors.get("running")).toBe(running); }); });