diff --git a/packages/app/e2e/bottom-sheet-reopen.spec.ts b/packages/app/e2e/bottom-sheet-reopen.spec.ts index 8c42a5123..10ad84ff2 100644 --- a/packages/app/e2e/bottom-sheet-reopen.spec.ts +++ b/packages/app/e2e/bottom-sheet-reopen.spec.ts @@ -123,4 +123,16 @@ test.describe("mobile bottom sheet reopen", () => { await openAndCloseModelSelectorTwice(page); }); }); + + test("model selector closes after model selection", async ({ page }) => { + await withMobileMockAgent(page, async () => { + await openModelSelector(page); + const sheet = page.getByLabel("Bottom Sheet", { exact: true }); + + await sheet.getByText("Ten second stream", { exact: true }).click(); + + await expect(sheet).not.toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible(); + }); + }); }); diff --git a/packages/app/src/agent-stream/bottom-anchor-controller.test.ts b/packages/app/src/agent-stream/bottom-anchor-controller.test.ts index f37b79a23..17b6d4445 100644 --- a/packages/app/src/agent-stream/bottom-anchor-controller.test.ts +++ b/packages/app/src/agent-stream/bottom-anchor-controller.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { __private__, deriveBottomAnchorBlockedReason, @@ -126,10 +126,15 @@ function createDriverHarness(input?: { measurementState, nearBottom: input?.isNearBottom ?? true, }; - const scrollToBottom = vi.fn(() => { + const scrollAttempts: boolean[] = []; + let scrollToBottomBehavior = () => { context.nearBottom = true; context.measurementState.offsetY = 720; - }); + }; + const scrollToBottom = (animated: boolean) => { + scrollAttempts.push(animated); + scrollToBottomBehavior(); + }; const modeChanges: BottomAnchorMode[] = []; const driver = __private__.createBottomAnchorControllerDriver({ getAgentId: () => context.agentId, @@ -150,7 +155,10 @@ function createDriverHarness(input?: { context, driver, scheduler, - scrollToBottom, + scrollAttempts, + setScrollToBottomBehavior(next: () => void) { + scrollToBottomBehavior = next; + }, modeChanges, }; } @@ -210,7 +218,7 @@ describe("bottom anchor controller driver", () => { }); harness.scheduler.flushAll(); - expect(harness.scrollToBottom).not.toHaveBeenCalled(); + expect(harness.scrollAttempts).toHaveLength(0); expect(harness.driver.getSnapshot()).toMatchObject({ mode: "sticky-bottom", blockedReason: "waiting_for_history_readiness", @@ -229,7 +237,7 @@ describe("bottom anchor controller driver", () => { harness.driver.reevaluate(); harness.scheduler.flushAll(); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + expect(harness.scrollAttempts).toHaveLength(1); expect(harness.driver.getSnapshot()).toMatchObject({ blockedReason: null, pendingRequest: null, @@ -237,6 +245,97 @@ describe("bottom anchor controller driver", () => { }); }); + it("preserves a blocked route anchor while a user scroll ends at the bottom", () => { + const harness = createDriverHarness({ authoritativeReady: false }); + + harness.driver.applyRouteRequest({ + agentId: "agent-1", + reason: "initial-entry", + requestKey: "route:agent-1:initial-entry", + }); + harness.scheduler.flushAll(); + + harness.driver.beginUserScroll(); + harness.context.authoritativeReady = true; + harness.driver.notifyAuthoritativeHistoryMaybeChanged(); + harness.driver.reevaluate(); + harness.scheduler.flushAll(); + + expect(harness.scrollAttempts).toHaveLength(0); + + harness.driver.endUserScroll({ isNearBottom: true }); + harness.scheduler.flushAll(); + + expect(harness.scrollAttempts).toHaveLength(1); + expect(harness.driver.getSnapshot()).toMatchObject({ + mode: "sticky-bottom", + blockedReason: null, + pendingRequest: null, + pendingVerification: null, + }); + }); + + it("preserves a blocked route anchor when layout moves after drag release", () => { + const harness = createDriverHarness({ authoritativeReady: false }); + + harness.driver.applyRouteRequest({ + agentId: "agent-1", + reason: "resume", + requestKey: "route:agent-1:resume", + }); + harness.scheduler.flushAll(); + + harness.driver.beginUserScroll(); + harness.context.nearBottom = false; + harness.driver.handleScrollNearBottomChange({ + nextIsNearBottom: false, + scrollDelta: 0, + }); + harness.context.authoritativeReady = true; + harness.driver.notifyAuthoritativeHistoryMaybeChanged(); + harness.driver.endUserScroll({ isNearBottom: true }); + harness.scheduler.flushAll(); + + expect(harness.scrollAttempts).toHaveLength(1); + expect(harness.driver.getSnapshot()).toMatchObject({ + mode: "sticky-bottom", + blockedReason: null, + pendingRequest: null, + pendingVerification: null, + }); + }); + + it("lets a user scroll away supersede a blocked route anchor", () => { + const harness = createDriverHarness({ authoritativeReady: false }); + + harness.driver.applyRouteRequest({ + agentId: "agent-1", + reason: "resume", + requestKey: "route:agent-1:resume", + }); + harness.scheduler.flushAll(); + + harness.driver.beginUserScroll(); + harness.context.nearBottom = false; + harness.driver.handleScrollNearBottomChange({ + nextIsNearBottom: false, + scrollDelta: 48, + }); + harness.driver.endUserScroll({ isNearBottom: false }); + harness.context.authoritativeReady = true; + harness.driver.notifyAuthoritativeHistoryMaybeChanged(); + harness.driver.reevaluate(); + harness.scheduler.flushAll(); + + expect(harness.scrollAttempts).toHaveLength(0); + expect(harness.driver.getSnapshot()).toMatchObject({ + mode: "detached", + blockedReason: null, + pendingRequest: null, + pendingVerification: null, + }); + }); + it("suppresses sticky maintenance while detached", () => { const harness = createDriverHarness(); @@ -254,7 +353,74 @@ describe("bottom anchor controller driver", () => { harness.scheduler.flushAll(); expect(harness.driver.getSnapshot().mode).toBe("detached"); - expect(harness.scrollToBottom).not.toHaveBeenCalled(); + expect(harness.scrollAttempts).toHaveLength(0); + }); + + it("pauses sticky maintenance while a user scroll owns the viewport", () => { + const harness = createDriverHarness({ + transportBehavior: { + verificationDelayFrames: 2, + verificationRetryMode: "recheck", + }, + }); + + harness.driver.prepareForStickyContentChange(); + harness.driver.beginUserScroll(); + harness.driver.handleContentSizeChange({ + previousContentHeight: 1200, + contentHeight: 1400, + }); + harness.context.nearBottom = false; + harness.driver.handleScrollNearBottomChange({ + nextIsNearBottom: false, + scrollDelta: 1, + }); + harness.scheduler.flushAll(); + + expect(harness.scrollAttempts).toHaveLength(1); + expect(harness.driver.getSnapshot()).toMatchObject({ + mode: "sticky-bottom", + pendingRequest: null, + pendingVerification: null, + }); + + harness.driver.endUserScroll({ isNearBottom: false }); + + expect(harness.driver.getSnapshot().mode).toBe("detached"); + }); + + it("restores sticky maintenance when a user scroll returns to the bottom", () => { + const harness = createDriverHarness({ + transportBehavior: { + verificationDelayFrames: 2, + verificationRetryMode: "recheck", + }, + }); + + harness.driver.beginUserScroll(); + harness.context.nearBottom = false; + harness.driver.handleScrollNearBottomChange({ + nextIsNearBottom: false, + scrollDelta: 48, + }); + harness.driver.handleContentSizeChange({ + previousContentHeight: 1200, + contentHeight: 1400, + }); + harness.context.nearBottom = true; + harness.driver.handleScrollNearBottomChange({ + nextIsNearBottom: true, + scrollDelta: -48, + }); + harness.driver.endUserScroll({ isNearBottom: true }); + harness.scheduler.flushAll(); + + expect(harness.driver.getSnapshot()).toMatchObject({ + mode: "sticky-bottom", + pendingRequest: null, + pendingVerification: null, + }); + expect(harness.scrollAttempts).toHaveLength(1); }); it("switches back to sticky-bottom for explicit jump-to-bottom", () => { @@ -271,7 +437,7 @@ describe("bottom anchor controller driver", () => { expect(harness.modeChanges).toContain("detached"); expect(harness.modeChanges).toContain("sticky-bottom"); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + expect(harness.scrollAttempts).toHaveLength(1); expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom"); }); @@ -292,7 +458,7 @@ describe("bottom anchor controller driver", () => { }); harness.scheduler.flushAll(); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(2); + expect(harness.scrollAttempts).toHaveLength(2); }); it("keeps a pending request blocked when stale container measurements arrive", () => { @@ -313,7 +479,7 @@ describe("bottom anchor controller driver", () => { }); harness.scheduler.flushAll(); - expect(harness.scrollToBottom).not.toHaveBeenCalled(); + expect(harness.scrollAttempts).toHaveLength(0); expect(harness.driver.getSnapshot()).toMatchObject({ blockedReason: "waiting_for_measurable_viewport", pendingRequest: { @@ -326,7 +492,7 @@ describe("bottom anchor controller driver", () => { harness.driver.reevaluate(); harness.scheduler.flushAll(); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + expect(harness.scrollAttempts).toHaveLength(1); expect(harness.driver.getSnapshot().pendingRequest).toBeNull(); }); @@ -338,7 +504,7 @@ describe("bottom anchor controller driver", () => { }, isNearBottom: false, }); - harness.scrollToBottom.mockImplementation(() => { + harness.setScrollToBottomBehavior(() => { harness.context.measurementState.offsetY = 0; }); @@ -348,16 +514,16 @@ describe("bottom anchor controller driver", () => { }); harness.scheduler.flushFrame(); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + expect(harness.scrollAttempts).toHaveLength(1); harness.scheduler.flushFrame(); harness.scheduler.flushFrame(); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + expect(harness.scrollAttempts).toHaveLength(1); harness.context.nearBottom = true; harness.scheduler.flushAll(); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + expect(harness.scrollAttempts).toHaveLength(1); expect(harness.driver.getSnapshot().pendingRequest).toBeNull(); }); @@ -375,7 +541,7 @@ describe("bottom anchor controller driver", () => { isNearBottom: false, }); - harness.scrollToBottom.mockImplementation(() => { + harness.setScrollToBottomBehavior(() => { harness.context.measurementState.offsetY = 13476; }); @@ -386,7 +552,7 @@ describe("bottom anchor controller driver", () => { }); harness.scheduler.flushFrame(); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + expect(harness.scrollAttempts).toHaveLength(1); harness.context.measurementState.contentHeight = 14804; harness.context.nearBottom = false; @@ -409,7 +575,7 @@ describe("bottom anchor controller driver", () => { harness.scheduler.flushFrame(); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(2); + expect(harness.scrollAttempts).toHaveLength(2); expect(harness.driver.getSnapshot()).toMatchObject({ blockedReason: "waiting_for_post_layout_verification", pendingRequest: { @@ -435,7 +601,7 @@ describe("bottom anchor controller driver", () => { isNearBottom: false, }); - harness.scrollToBottom.mockImplementation(() => { + harness.setScrollToBottomBehavior(() => { harness.context.measurementState.offsetY = Math.max( 0, harness.context.measurementState.contentHeight - @@ -471,7 +637,7 @@ describe("bottom anchor controller driver", () => { harness.scheduler.flushFrame(); harness.scheduler.flushFrame(); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(2); + expect(harness.scrollAttempts).toHaveLength(2); expect(harness.driver.getSnapshot().pendingRequest).toMatchObject({ reason: "resume", }); @@ -480,7 +646,7 @@ describe("bottom anchor controller driver", () => { it("keeps sticky-bottom during viewport growth until bottom is re-verified", () => { const harness = createDriverHarness(); harness.context.nearBottom = false; - harness.scrollToBottom.mockImplementation(() => { + harness.setScrollToBottomBehavior(() => { harness.context.measurementState.offsetY = 720; }); @@ -492,7 +658,7 @@ describe("bottom anchor controller driver", () => { }); harness.scheduler.flushAll(); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(4); + expect(harness.scrollAttempts).toHaveLength(4); expect(harness.driver.getSnapshot()).toMatchObject({ mode: "sticky-bottom", pendingRequest: null, @@ -523,7 +689,7 @@ describe("bottom anchor controller driver", () => { it("keeps sticky-bottom during streaming growth until bottom is re-verified", () => { const harness = createDriverHarness(); harness.context.nearBottom = false; - harness.scrollToBottom.mockImplementation(() => { + harness.setScrollToBottomBehavior(() => { harness.context.measurementState.offsetY = 900; }); @@ -533,7 +699,7 @@ describe("bottom anchor controller driver", () => { }); harness.scheduler.flushAll(); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(4); + expect(harness.scrollAttempts).toHaveLength(4); expect(harness.driver.getSnapshot()).toMatchObject({ mode: "sticky-bottom", pendingRequest: null, @@ -577,7 +743,7 @@ describe("bottom anchor controller driver", () => { contentMeasuredForKey: null, }), }); - harness.scrollToBottom.mockImplementation(() => { + harness.setScrollToBottomBehavior(() => { harness.context.measurementState.offsetY = 0; }); @@ -588,7 +754,7 @@ describe("bottom anchor controller driver", () => { contentHeight: 1348, }); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + expect(harness.scrollAttempts).toHaveLength(1); expect(harness.driver.getSnapshot()).toMatchObject({ mode: "sticky-bottom", pendingVerification: { @@ -625,14 +791,14 @@ describe("bottom anchor controller driver", () => { contentMeasuredForKey: "native-virtualized", }), }); - harness.scrollToBottom.mockImplementation(() => { + harness.setScrollToBottomBehavior(() => { harness.context.measurementState.offsetY = 0; harness.context.nearBottom = true; }); harness.driver.prepareForStickyContentChange(); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + expect(harness.scrollAttempts).toHaveLength(1); expect(harness.driver.getSnapshot()).toMatchObject({ mode: "sticky-bottom", pendingVerification: { diff --git a/packages/app/src/agent-stream/bottom-anchor-controller.ts b/packages/app/src/agent-stream/bottom-anchor-controller.ts index b9f77c13c..35bd6e6b6 100644 --- a/packages/app/src/agent-stream/bottom-anchor-controller.ts +++ b/packages/app/src/agent-stream/bottom-anchor-controller.ts @@ -68,6 +68,8 @@ interface BottomAnchorControllerDriver { resetForAgent: () => void; applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void; requestLocalAnchor: (request: BottomAnchorLocalRequest) => void; + beginUserScroll: () => void; + endUserScroll: (params: { isNearBottom: boolean }) => void; detachByUser: () => void; handleViewportMetricsChange: (params: { previousViewportWidth: number; @@ -243,6 +245,7 @@ function createBottomAnchorControllerDriver( let lastRouteRequestKey: string | null = null; let stickyMeasurementRevision = 0; let lastVerifiedStickyMeasurementRevision = 0; + let isUserScrollActive = false; const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => { if (blockedReason === nextBlockedReason) { @@ -411,10 +414,14 @@ function createBottomAnchorControllerDriver( | "viewport_change" | "content_size_change" | "scroll_near_bottom_change" + | "user_scroll_end" | "history_readiness_change" | "manual_reevaluate" | "retry_scroll", ) => { + if (isUserScrollActive) { + return; + } if (attemptHandle) { return; } @@ -481,6 +488,7 @@ function createBottomAnchorControllerDriver( cancelPendingAttempt(); stickyMeasurementRevision = 0; lastVerifiedStickyMeasurementRevision = 0; + isUserScrollActive = false; mode = "sticky-bottom"; input.onModeChange("sticky-bottom"); }, @@ -497,6 +505,38 @@ function createBottomAnchorControllerDriver( requestLocalAnchor(request) { createRequest(request); }, + beginUserScroll() { + isUserScrollActive = true; + cancelPendingAttempt(); + }, + endUserScroll(params) { + isUserScrollActive = false; + if (params.isNearBottom) { + if (mode === "detached") { + setModeInternal("sticky-bottom"); + pendingVerification = { requestId: null, retries: 0 }; + evaluate(false, "user_scroll_end"); + return; + } + if (pendingRequest) { + evaluate(false, "user_scroll_end"); + return; + } + if ( + !input.isNearBottom() || + stickyMeasurementRevision !== lastVerifiedStickyMeasurementRevision + ) { + pendingVerification = { requestId: null, retries: 0 }; + evaluate(false, "user_scroll_end"); + return; + } + markStickyMeasurementVerified(); + return; + } + if (mode === "sticky-bottom") { + this.detachByUser(); + } + }, detachByUser() { if (mode === "detached") { return; @@ -511,6 +551,9 @@ function createBottomAnchorControllerDriver( ) { markStickyMeasurementChanged(); } + if (isUserScrollActive) { + return; + } const shouldRestick = __private__.shouldRestickOnViewportChange({ mode, previousViewportWidth: params.previousViewportWidth, @@ -529,6 +572,9 @@ function createBottomAnchorControllerDriver( if (params.previousContentHeight !== params.contentHeight) { markStickyMeasurementChanged(); } + if (isUserScrollActive) { + return; + } const shouldRestick = __private__.shouldRestickOnContentChange({ mode, previousContentHeight: params.previousContentHeight, @@ -558,6 +604,9 @@ function createBottomAnchorControllerDriver( return; } markStickyMeasurementChanged(); + if (isUserScrollActive) { + return; + } if (!pendingRequest) { pendingVerification = { requestId: null, retries: 0 }; if (attemptHandle) { @@ -571,6 +620,9 @@ function createBottomAnchorControllerDriver( }, handleScrollNearBottomChange(params) { const { nextIsNearBottom, scrollDelta } = params; + if (isUserScrollActive) { + return; + } if ( nextIsNearBottom && mode === "sticky-bottom" && @@ -737,6 +789,12 @@ export function useBottomAnchorController(input: { requestLocalAnchor(request: BottomAnchorLocalRequest) { driverRef.current?.requestLocalAnchor(request); }, + beginUserScroll() { + driverRef.current?.beginUserScroll(); + }, + endUserScroll(params: { isNearBottom: boolean }) { + driverRef.current?.endUserScroll(params); + }, detachByUser() { driverRef.current?.detachByUser(); }, diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 4463aae6f..a28c611e5 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -89,6 +89,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat contentMeasuredForKey: null as string | null, }); const scrollOffsetYRef = useRef(0); + const isUserScrollActiveRef = useRef(false); + const userScrollEndFrameIdRef = useRef(null); const programmaticScrollEventBudgetRef = useRef(0); const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false); const nativeViewportSettlingFrameIdRef = useRef(null); @@ -129,6 +131,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat } }, []); + const clearPendingUserScrollEnd = useCallback(() => { + if (userScrollEndFrameIdRef.current !== null) { + cancelAnimationFrame(userScrollEndFrameIdRef.current); + userScrollEndFrameIdRef.current = null; + } + }, []); + const markNativeViewportSettling = useCallback(() => { clearNativeViewportSettling(); setIsNativeViewportSettling(true); @@ -208,6 +217,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat contentMeasuredForKey: null, }; scrollOffsetYRef.current = 0; + isUserScrollActiveRef.current = false; + clearPendingUserScrollEnd(); clearNativeViewportSettling(); setIsNativeViewportSettling(false); historyStartReadyRef.current = false; @@ -216,8 +227,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }); return () => { cancelAnimationFrame(frame); + clearPendingUserScrollEnd(); }; - }, [agentId, clearNativeViewportSettling]); + }, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd]); useEffect(() => { const keyboardEvents = [ @@ -266,6 +278,19 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }; }, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]); + const isScrollEventNearBottom = useStableEvent( + (event: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + return isNearBottomForStreamRenderStrategy({ + strategy, + offsetY: contentOffset.y, + threshold: 32, + contentHeight: contentSize.height, + viewportHeight: layoutMeasurement.height, + }); + }, + ); + const handleScroll = useStableEvent((event: NativeSyntheticEvent) => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; const previousOffsetY = scrollOffsetYRef.current; @@ -280,13 +305,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat contentMeasuredForKey: "native-virtualized", }; - const nearBottom = isNearBottomForStreamRenderStrategy({ - strategy, - offsetY: contentOffset.y, - threshold: 32, - contentHeight: streamViewportMetricsRef.current.contentHeight, - viewportHeight: streamViewportMetricsRef.current.viewportHeight, - }); + const nearBottom = isScrollEventNearBottom(event); onNearBottomChange(nearBottom); const distanceFromOldestEdge = @@ -301,7 +320,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat onNearHistoryStart(); } - if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) { + if ( + !isUserScrollActiveRef.current && + programmaticScrollEventBudgetRef.current > 0 && + contentOffset.y <= 8 + ) { programmaticScrollEventBudgetRef.current -= 1; } else { programmaticScrollEventBudgetRef.current = 0; @@ -312,6 +335,37 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat } }); + const handleScrollBeginDrag = useStableEvent(() => { + clearPendingUserScrollEnd(); + isUserScrollActiveRef.current = true; + bottomAnchorController.beginUserScroll(); + }); + + // Defer drag end so momentum can take ownership, but capture the terminal + // gesture position now because layout may move the viewport in the meantime. + const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent) => { + const isNearBottom = isScrollEventNearBottom(event); + clearPendingUserScrollEnd(); + userScrollEndFrameIdRef.current = requestAnimationFrame(() => { + userScrollEndFrameIdRef.current = null; + isUserScrollActiveRef.current = false; + bottomAnchorController.endUserScroll({ isNearBottom }); + }); + }); + + const handleMomentumScrollBegin = useStableEvent(() => { + clearPendingUserScrollEnd(); + }); + + const handleMomentumScrollEnd = useStableEvent( + (event: NativeSyntheticEvent) => { + const isNearBottom = isScrollEventNearBottom(event); + clearPendingUserScrollEnd(); + isUserScrollActiveRef.current = false; + bottomAnchorController.endUserScroll({ isNearBottom }); + }, + ); + const handleListLayout = useStableEvent((event: LayoutChangeEvent) => { const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth; const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight; @@ -419,6 +473,10 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat style={listStyle} onLayout={handleListLayout} onScroll={handleScroll} + onScrollBeginDrag={handleScrollBeginDrag} + onScrollEndDrag={handleScrollEndDrag} + onMomentumScrollBegin={handleMomentumScrollBegin} + onMomentumScrollEnd={handleMomentumScrollEnd} scrollEventThrottle={16} onContentSizeChange={handleContentSizeChange} maintainVisibleContentPosition={maintainVisibleContentPosition} diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 9b91a375e..07c157fa8 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -3,7 +3,7 @@ import type { ReactNode, Ref } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native"; -import type { TextInputProps } from "react-native"; +import type { StyleProp, TextInputProps, ViewStyle } from "react-native"; import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root"; @@ -11,9 +11,10 @@ import { BottomSheetBackdrop, BottomSheetScrollView, BottomSheetTextInput, + useBottomSheetInternal, type BottomSheetBackgroundProps, } from "@gorhom/bottom-sheet"; -import Animated from "react-native-reanimated"; +import Animated, { useAnimatedStyle } from "react-native-reanimated"; import { ArrowLeft, Search, X } from "lucide-react-native"; import { IsolatedBottomSheetModal, @@ -203,6 +204,14 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[4], minHeight: 0, }, + bottomSheetVisibleContent: { + minHeight: 0, + overflow: "hidden", + }, + bottomSheetVisibleScroll: { + flex: 1, + minHeight: 0, + }, desktopStaticContent: { flexShrink: 1, minHeight: 0, @@ -248,6 +257,39 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) { return ; } +function BottomSheetVisibleContent({ children }: { children: ReactNode }) { + const { animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition } = + useBottomSheetInternal(); + const visibleContentStyle = useAnimatedStyle(() => { + const { containerHeight, handleHeight } = animatedLayoutState.get(); + if (containerHeight < 0 || handleHeight < 0) { + return { height: 0 }; + } + + const initialDetentPosition = animatedDetentsState.get().detents?.[0]; + const contentPosition = + initialDetentPosition == null + ? animatedPosition.get() + : Math.min(animatedPosition.get(), initialDetentPosition); + + return { + height: Math.max( + 0, + containerHeight - + contentPosition - + handleHeight - + animatedKeyboardState.get().heightWithinContainer, + ), + }; + }, [animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition]); + + return ( + + {children} + + ); +} + export type AdaptiveTextInputProps = TextInputProps & { initialValue?: string; resetKey?: string | number; @@ -452,12 +494,16 @@ export interface AdaptiveModalSheetProps { children: ReactNode; /** Sticky footer rendered below the scrollable content. */ footer?: ReactNode; + footerContainerStyle?: StyleProp; snapPoints?: string[]; testID?: string; /** Override the max width of the desktop card. */ desktopMaxWidth?: number; scrollable?: boolean; presentation?: "push" | "replace"; + contentContainerStyle?: StyleProp; + /** Size compact sheet content to the live snap height instead of its largest snap point. */ + sizeContentToCurrentSnapPoint?: boolean; } export function AdaptiveModalSheet({ @@ -467,11 +513,14 @@ export function AdaptiveModalSheet({ onDismiss, children, footer, + footerContainerStyle, snapPoints, testID, desktopMaxWidth, scrollable = true, presentation, + contentContainerStyle, + sizeContentToCurrentSnapPoint = false, }: AdaptiveModalSheetProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); @@ -490,31 +539,37 @@ export function AdaptiveModalSheet({ [footer, insets.bottom, isMobile, theme.spacing], ); const bottomSheetContentStyle = useMemo( + // Gorhom spreads this outer array into StyleSheet.compose, which accepts two arguments on web. () => [ styles.bottomSheetContent, - compactSafeAreaPadding.contentPaddingBottom != null - ? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom } - : null, + [ + contentContainerStyle, + compactSafeAreaPadding.contentPaddingBottom != null + ? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom } + : null, + ], ], - [compactSafeAreaPadding.contentPaddingBottom], + [compactSafeAreaPadding.contentPaddingBottom, contentContainerStyle], ); const bottomSheetStaticContentStyle = useMemo( () => [ styles.bottomSheetStaticContent, + contentContainerStyle, compactSafeAreaPadding.contentPaddingBottom != null ? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom } : null, ], - [compactSafeAreaPadding.contentPaddingBottom], + [compactSafeAreaPadding.contentPaddingBottom, contentContainerStyle], ); const footerStyle = useMemo( () => [ styles.footer, + footerContainerStyle, compactSafeAreaPadding.footerPaddingBottom != null ? { paddingBottom: compactSafeAreaPadding.footerPaddingBottom } : null, ], - [compactSafeAreaPadding.footerPaddingBottom], + [compactSafeAreaPadding.footerPaddingBottom, footerContainerStyle], ); const handleIndicatorStyle = useMemo( () => ({ backgroundColor: theme.colors.palette.zinc[600] }), @@ -599,6 +654,25 @@ export function AdaptiveModalSheet({ }, [visible, isMobile, notifyNativeModalDismiss]); if (isMobile) { + const sheetContent = ( + <> + + {scrollable ? ( + + {children} + + ) : ( + {children} + )} + {footer ? {footer} : null} + + ); + return ( - - {scrollable ? ( - - {children} - + {sizeContentToCurrentSnapPoint ? ( + {sheetContent} ) : ( - {children} + sheetContent )} - {footer ? {footer} : null} ); } @@ -640,7 +706,7 @@ export function AdaptiveModalSheet({ @@ -648,7 +714,7 @@ export function AdaptiveModalSheet({ ) : ( - {children} + {children} )} {footer ? {footer} : null} diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index c11a11a18..179e53132 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -1,131 +1,25 @@ -import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { useTranslation } from "react-i18next"; -import { - View, - Text, - Pressable, - type GestureResponderEvent, - type PressableStateCallbackType, -} from "react-native"; -import { BottomSheetFlatList } from "@gorhom/bottom-sheet"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { useIsCompactFormFactor } from "@/constants/layout"; -import { isNative, isWeb as platformIsWeb } from "@/constants/platform"; -import { AlertTriangle, ChevronRight, Search, Settings, Star } from "lucide-react-native"; +import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; -import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -import type { SheetHeader } from "@/components/adaptive-modal-sheet"; -import { useProviderSettingsStore } from "@/stores/provider-settings-store"; -import { Button } from "@/components/ui/button"; +import { Combobox, type ComboboxOption, type ComboboxProps } from "@/components/ui/combobox"; +import { ModelBrowser, ModelProviderGlyph, useModelBrowser } from "@/components/model-browser"; +import { isNative, isWeb } from "@/constants/platform"; +import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; import { ICON_SIZE, type Theme } from "@/styles/theme"; -import { - Combobox, - ComboboxItem, - type ComboboxOption, - type ComboboxProps, -} from "@/components/ui/combobox"; -import { getProviderIcon } from "@/components/provider-icons"; -import { - buildSelectedTriggerLabel, - filterAndRankModelRows, - getAllProviderModelRows, - getProviderModelRows, - resolveSelectedModelLabel, - type ProviderSelectionModelRow, - type ProviderSelectorProvider, -} from "@/provider-selection/provider-selection"; -const IS_WEB = platformIsWeb; const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = []; - -function noop() {} - -function favoriteButtonStyle({ - hovered, - pressed, -}: PressableStateCallbackType & { hovered?: boolean }) { - return [ - styles.favoriteButton, - Boolean(hovered) && styles.favoriteButtonHovered, - pressed && styles.favoriteButtonPressed, - ]; -} - -function drillDownRowStyle({ - hovered, - pressed, -}: PressableStateCallbackType & { hovered?: boolean }) { - return [ - styles.drillDownRow, - Boolean(hovered) && styles.drillDownRowHovered, - pressed && styles.drillDownRowPressed, - ]; -} - -const DESKTOP_PROVIDER_VIEW_MIN_HEIGHT = 220; -const DESKTOP_PROVIDER_VIEW_MAX_HEIGHT = 400; -const DESKTOP_PROVIDER_VIEW_BASE_HEIGHT = 80; -const DESKTOP_MODEL_ROW_HEIGHT = 40; - -const ThemedAlertTriangle = withUnistyles(AlertTriangle); -const ThemedChevronRight = withUnistyles(ChevronRight); +const EMPTY_FAVORITE_KEYS = new Set(); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); -const ThemedSearch = withUnistyles(Search); -const ThemedSettings = withUnistyles(Settings); -const ThemedStar = withUnistyles(Star); const foregroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); -const headerSettingsMapping = (disabled: boolean) => (theme: Theme) => ({ - color: disabled ? theme.colors.border : theme.colors.foregroundMuted, -}); - -const favoriteStarMapping = - (isFavorite: boolean, hovered: boolean) => - (theme: Theme): { color: string; fill: string } => { - const favoriteColor = theme.colors.palette.amber[500]; - if (isFavorite) { - return { color: favoriteColor, fill: favoriteColor }; - } - return { - color: hovered ? theme.colors.foregroundMuted : theme.colors.border, - fill: "transparent", - }; - }; - -type ProviderGlyphTone = "muted" | "foreground"; - -function ProviderGlyph({ - provider, - size, - tone = "muted", -}: { - provider: string; - size: number; - tone?: ProviderGlyphTone; -}) { - const Icon = getProviderIcon(provider); - const color = - tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color; - return ; -} - -function HeaderSettingsIcon({ disabled }: { disabled: boolean }) { - const uniProps = useMemo(() => headerSettingsMapping(disabled), [disabled]); - return ; -} - -function FavoriteStar({ isFavorite, hovered }: { isFavorite: boolean; hovered: boolean }) { - const uniProps = useMemo(() => favoriteStarMapping(isFavorite, hovered), [hovered, isFavorite]); - return ; -} - -type SelectorView = - | { kind: "all" } - | { kind: "provider"; providerId: string; providerLabel: string }; +function noop() {} interface CombinedModelSelectorProps { providers: ProviderSelectorProvider[]; @@ -160,444 +54,10 @@ interface CombinedModelSelectorProps { * (the composer's layout). */ triggerFill?: boolean; -} - -interface SelectorContentProps { - view: SelectorView; - providers: ProviderSelectorProvider[]; - selectedProvider: string; - selectedModel: string; - searchQuery: string; - favoriteKeys: Set; - onSelect: (provider: string, modelId: string) => void; - onToggleFavorite?: (provider: string, modelId: string) => void; - onDrillDown: (providerId: string, providerLabel: string) => void; - onRetryProvider?: (provider: AgentProvider) => void; - isRetryingProvider: boolean; -} - -function normalizeSearchQuery(value: string): string { - return value.trim().toLowerCase(); -} - -function sortFavoritesFirst( - rows: ProviderSelectionModelRow[], - favoriteKeys: Set, -): ProviderSelectionModelRow[] { - const favorites: ProviderSelectionModelRow[] = []; - const rest: ProviderSelectionModelRow[] = []; - for (const row of rows) { - if (favoriteKeys.has(row.favoriteKey)) { - favorites.push(row); - } else { - rest.push(row); - } - } - return [...favorites, ...rest]; -} - -function ModelRow({ - row, - isSelected, - isFavorite, - elevated = false, - onPress, - onToggleFavorite, -}: { - row: ProviderSelectionModelRow; - isSelected: boolean; - isFavorite: boolean; - elevated?: boolean; - onPress: () => void; - onToggleFavorite?: (provider: string, modelId: string) => void; -}) { - const { t } = useTranslation(); - - const handleToggleFavorite = useCallback( - (event: GestureResponderEvent) => { - event.stopPropagation(); - onToggleFavorite?.(row.provider, row.modelId); - }, - [onToggleFavorite, row.modelId, row.provider], - ); - - const leadingSlot = useMemo( - () => , - [row.provider], - ); - const trailingSlot = useMemo( - () => - onToggleFavorite ? ( - - {({ hovered }) => } - - ) : null, - [onToggleFavorite, handleToggleFavorite, isFavorite, row.provider, row.modelId, t], - ); - - return ( - - ); -} - -interface SelectableModelRowProps { - row: ProviderSelectionModelRow; - isSelected: boolean; - isFavorite: boolean; - elevated?: boolean; - onSelect: (provider: string, modelId: string) => void; - onToggleFavorite?: (provider: string, modelId: string) => void; -} - -function SelectableModelRow({ - row, - isSelected, - isFavorite, - elevated, - onSelect, - onToggleFavorite, -}: SelectableModelRowProps) { - const handlePress = useCallback(() => { - onSelect(row.provider, row.modelId); - }, [onSelect, row.provider, row.modelId]); - return ( - - ); -} - -function FavoritesSection({ - favoriteRows, - selectedProvider, - selectedModel, - favoriteKeys, - onSelect, - onToggleFavorite, -}: { - favoriteRows: ProviderSelectionModelRow[]; - selectedProvider: string; - selectedModel: string; - favoriteKeys: Set; - onSelect: (provider: string, modelId: string) => void; - onToggleFavorite?: (provider: string, modelId: string) => void; -}) { - const { t } = useTranslation(); - if (favoriteRows.length === 0) { - return null; - } - - return ( - - - {t("modelSelector.favorites")} - - {favoriteRows.map((row) => ( - - ))} - - ); -} - -interface GroupProviderButtonProps { - provider: ProviderSelectorProvider; - onDrillDown: (providerId: string, providerLabel: string) => void; -} - -function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) { - return [ - styles.rowIconButton, - Boolean(hovered) && styles.rowIconButtonHovered, - pressed && styles.rowIconButtonPressed, - ]; -} - -function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) { - const { t } = useTranslation(); - const selection = provider.modelSelection; - - const handlePress = useCallback(() => { - onDrillDown(provider.id, provider.label); - }, [onDrillDown, provider.id, provider.label]); - - let stateNode: React.ReactNode; - if (selection.kind === "models") { - const count = selection.rows.length; - stateNode = ( - - {t(count === 1 ? "modelSelector.modelCount" : "modelSelector.modelCountPlural", { - count, - })} - - ); - } else if (selection.kind === "loading") { - stateNode = ( - - - - - {t("modelSelector.loadingShort")} - - ); - } else { - stateNode = ( - - - {t("modelSelector.error")} - - ); - } - - return ( - - - {provider.label} - - {stateNode} - - - - ); -} - -function GroupedProviderRows({ - providers, - onDrillDown, -}: { - providers: ProviderSelectorProvider[]; - onDrillDown: (providerId: string, providerLabel: string) => void; -}) { - return ( - - {providers.map((provider, index) => ( - - {index > 0 ? : null} - - - ))} - - ); -} - -function ProviderModelRows({ - rows, - selectedProvider, - selectedModel, - favoriteKeys, - onSelect, - onToggleFavorite, - normalizedQuery, -}: { - rows: ProviderSelectionModelRow[]; - selectedProvider: string; - selectedModel: string; - favoriteKeys: Set; - onSelect: (provider: string, modelId: string) => void; - onToggleFavorite?: (provider: string, modelId: string) => void; - normalizedQuery: string; -}) { - const isMobile = useIsCompactFormFactor(); - const useVirtualizedList = isMobile && isNative; - const displayRows = useMemo( - () => (normalizedQuery ? rows : sortFavoritesFirst(rows, favoriteKeys)), - [favoriteKeys, normalizedQuery, rows], - ); - const renderItem = useCallback( - ({ item }: { item: ProviderSelectionModelRow }) => ( - - ), - [favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider], - ); - const keyExtractor = useCallback((row: ProviderSelectionModelRow) => row.favoriteKey, []); - - if (useVirtualizedList) { - return ( - - ); - } - - return ( - - {displayRows.map((row) => ( - {renderItem({ item: row })} - ))} - - ); -} - -function ProviderErrorEmptyState({ - providerId, - message, - onRetryProvider, - isRetryingProvider, -}: { - providerId: string; - message: string; - onRetryProvider?: (provider: AgentProvider) => void; - isRetryingProvider: boolean; -}) { - const { t } = useTranslation(); - const handleRetry = useCallback(() => { - onRetryProvider?.(providerId); - }, [onRetryProvider, providerId]); - return ( - - - {message} - {onRetryProvider ? ( - - ) : null} - - ); -} - -function SelectorContent({ - view, - providers, - selectedProvider, - selectedModel, - searchQuery, - favoriteKeys, - onSelect, - onToggleFavorite, - onDrillDown, - onRetryProvider, - isRetryingProvider, -}: SelectorContentProps) { - const { t } = useTranslation(); - const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]); - const selectedViewProvider = useMemo( - () => - view.kind === "provider" - ? providers.find((provider) => provider.id === view.providerId) - : null, - [providers, view], - ); - const visibleRows = useMemo( - () => - selectedViewProvider - ? filterAndRankModelRows(getProviderModelRows(selectedViewProvider), normalizedQuery) - : [], - [normalizedQuery, selectedViewProvider], - ); - const favoriteRows = useMemo( - () => getAllProviderModelRows(providers).filter((row) => favoriteKeys.has(row.favoriteKey)), - [favoriteKeys, providers], - ); - const hasResults = favoriteRows.length > 0 || providers.length > 0; - const emptyState = ( - - - {t("modelSelector.noMatches")} - - ); - - if (view.kind === "provider") { - if (!selectedViewProvider) { - return emptyState; - } - const drillSelection = selectedViewProvider.modelSelection; - if (drillSelection.kind === "loading") { - return ( - - - - - {t("modelSelector.loadingShort")} - - ); - } - if (drillSelection.kind === "error") { - return ( - - ); - } - if (visibleRows.length === 0) { - return emptyState; - } - - return ( - - ); - } - - return ( - - - - {providers.length > 0 ? ( - - ) : null} - - {!hasResults ? emptyState : null} - - ); + toolbar?: { + glyphSize: number; + showCaret: boolean; + }; } export function CombinedModelSelector({ @@ -606,7 +66,7 @@ export function CombinedModelSelector({ selectedModel, onSelect, isLoading, - favoriteKeys = new Set(), + favoriteKeys = EMPTY_FAVORITE_KEYS, onToggleFavorite, renderTrigger, onOpen, @@ -618,115 +78,53 @@ export function CombinedModelSelector({ desktopPlacement, desktopMinWidth, triggerFill = false, + toolbar, }: CombinedModelSelectorProps) { const { t } = useTranslation(); const anchorRef = useRef(null); const [isOpen, setIsOpen] = useState(false); - const [isContentReady, setIsContentReady] = useState(platformIsWeb); - const [view, setView] = useState({ kind: "all" }); - const [searchQuery, setSearchQuery] = useState(""); - const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0); - - // Single-provider mode: only one provider → skip Level 1 entirely - const singleProviderView = useMemo(() => { - if (providers.length !== 1) return null; - const provider = providers[0]; - if (!provider) return null; - return { kind: "provider", providerId: provider.id, providerLabel: provider.label }; - }, [providers]); - - const computeInitialView = useCallback((): SelectorView => { - if (singleProviderView) return singleProviderView; - - const selectedFavoriteKey = `${selectedProvider}:${selectedModel}`; - if (selectedProvider && selectedModel && !favoriteKeys.has(selectedFavoriteKey)) { - const provider = providers.find((entry) => entry.id === selectedProvider); - if (provider) - return { kind: "provider", providerId: provider.id, providerLabel: provider.label }; - } - - return { kind: "all" }; - }, [singleProviderView, selectedProvider, selectedModel, favoriteKeys, providers]); + const [isContentReady, setIsContentReady] = useState(isWeb); + const browser = useModelBrowser({ + providers, + selectedProvider, + selectedModel, + isLoading, + favoriteKeys, + serverId, + }); + const { prepareToOpen, reset } = browser; const handleOpenChange = useCallback( (open: boolean) => { setIsOpen(open); - setView(computeInitialView()); if (open) { + prepareToOpen(); onOpen?.(); - } else { - setSearchQuery(""); - bumpSearchResetKey(); - onClose?.(); + return; } + reset(); + onClose?.(); }, - [onOpen, onClose, computeInitialView], + [onClose, onOpen, prepareToOpen, reset], ); const handleSelect = useCallback( (provider: string, modelId: string) => { onSelect(provider, modelId); - setIsOpen(false); - setSearchQuery(""); - bumpSearchResetKey(); + handleOpenChange(false); }, - [onSelect], + [handleOpenChange, onSelect], ); - const hasSelectedProvider = selectedProvider.trim().length > 0; - - const selectedModelLabel = useMemo(() => { - return resolveSelectedModelLabel({ - providers, - selectedProvider, - selectedModel, - isLoading, - }); - }, [isLoading, providers, selectedModel, selectedProvider]); - - const desktopFixedHeight = useMemo(() => { - if (view.kind !== "provider") { - return undefined; - } - const provider = providers.find((entry) => entry.id === view.providerId); - if (!provider || provider.modelSelection.kind !== "models") { - return DESKTOP_PROVIDER_VIEW_MIN_HEIGHT; - } - const modelCount = getProviderModelRows(provider).length; - return Math.min( - Math.max( - DESKTOP_PROVIDER_VIEW_MIN_HEIGHT, - DESKTOP_PROVIDER_VIEW_BASE_HEIGHT + modelCount * DESKTOP_MODEL_ROW_HEIGHT, - ), - DESKTOP_PROVIDER_VIEW_MAX_HEIGHT, - ); - }, [providers, view]); - - const triggerLabel = useMemo(() => { - if ( - selectedModelLabel === t("modelSelector.loading") || - selectedModelLabel === t("modelSelector.selectModel") - ) { - return selectedModelLabel; - } - - return buildSelectedTriggerLabel(selectedModelLabel); - }, [selectedModelLabel, t]); - useEffect(() => { - if (platformIsWeb) { - return () => {}; - } - + if (isWeb) return () => {}; if (!isOpen) { setIsContentReady(false); return () => {}; } - const frame = requestAnimationFrame(() => { setIsContentReady(true); }); - return () => cancelAnimationFrame(frame); }, [isOpen]); @@ -736,8 +134,6 @@ export function CombinedModelSelector({ const triggerStyle = useCallback( ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => { - // Fill mode: transparent full-width passthrough. The trigger paints its own - // hover/pressed state from the args, so the wrapper must not double-paint. if (triggerFill) { return [ styles.trigger, @@ -757,67 +153,20 @@ export function CombinedModelSelector({ [disabled, isOpen, renderTrigger, triggerFill], ); - const handleBackToAll = useCallback(() => { - setView({ kind: "all" }); - setSearchQuery(""); - bumpSearchResetKey(); - }, []); - - const handleDrillDown = useCallback((providerId: string, providerLabel: string) => { - setView({ kind: "provider", providerId, providerLabel }); - }, []); - - const handleSearchQueryChange = useCallback((value: string) => { - setSearchQuery(value); - }, []); - - const openProviderSettings = useCallback(() => { - if (!serverId || view.kind !== "provider") return; - useProviderSettingsStore.getState().open({ serverId, provider: view.providerId }); - }, [serverId, view]); - - const sheetHeader = useMemo(() => { - if (view.kind === "all") { - return { title: t("modelSelector.title") }; - } - const headerActions = ( - - - - ); - return { - title: view.providerLabel, - leading: , - back: singleProviderView ? undefined : { onPress: handleBackToAll }, - actions: headerActions, - search: { - onChange: handleSearchQueryChange, - resetKey: `${view.providerId}:${searchResetKey}`, - placeholder: t("modelSelector.searchPlaceholder"), - autoFocus: platformIsWeb, - testID: "model-search-input", - }, - }; - }, [ - view, - singleProviderView, - serverId, - openProviderSettings, - handleBackToAll, - handleSearchQueryChange, - searchResetKey, - t, - ]); + const selectorBody = isContentReady ? ( + + ) : ( + + + {t("modelSelector.loadingSelector")} + + ); return ( <> @@ -829,12 +178,14 @@ export function CombinedModelSelector({ onPress={handleTriggerPress} style={triggerStyle} accessibilityRole="button" - accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })} + accessibilityLabel={t("modelSelector.selectedModel", { + model: browser.selectedModelLabel, + })} testID="combined-model-selector" > {({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => renderTrigger({ - selectedModelLabel: triggerLabel, + selectedModelLabel: browser.triggerLabel, onPress: handleTriggerPress, disabled, isOpen, @@ -851,14 +202,22 @@ export function CombinedModelSelector({ onPress={handleTriggerPress} style={triggerStyle} accessibilityRole="button" - accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })} + accessibilityLabel={t("modelSelector.selectedModel", { + model: browser.selectedModelLabel, + })} testID="combined-model-selector" + chevron={toolbar?.showCaret === false ? null : undefined} > - {hasSelectedProvider ? ( - + {selectedProvider.trim().length > 0 ? ( + + + ) : null} - {triggerLabel} + {browser.triggerLabel} )} @@ -871,36 +230,21 @@ export function CombinedModelSelector({ anchorRef={anchorRef} desktopPlacement={desktopPlacement} desktopMinWidth={desktopMinWidth} - desktopFixedHeight={desktopFixedHeight} - header={sheetHeader} - mobileChildrenScrollEnabled={view.kind !== "provider" || !isNative} + desktopFixedHeight={browser.desktopFixedHeight} + header={browser.header} + mobileChildrenScrollEnabled={!browser.isProviderView || !isNative} + mobileChildrenContentContainerStyle={styles.mobileBrowserContent} > - {isContentReady ? ( - - ) : ( - - - {t("modelSelector.loadingSelector")} - - )} + {selectorBody} ); } const styles = StyleSheet.create((theme) => ({ + mobileBrowserContent: { + paddingHorizontal: 0, + }, trigger: { height: 28, minWidth: 0, @@ -915,6 +259,16 @@ const styles = StyleSheet.create((theme) => ({ triggerHovered: { backgroundColor: theme.colors.surface2, }, + toolbarGlyph16: { + width: 16, + height: 16, + flexShrink: 0, + }, + toolbarGlyph20: { + width: 20, + height: 20, + flexShrink: 0, + }, triggerPressed: { backgroundColor: theme.colors.surface0, }, @@ -933,8 +287,6 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: 0, height: "auto", }, - // Stretch the wrapper (and, via column + stretch, its single child) to the - // full width of the field, with no background or rounding of its own. triggerFill: { alignSelf: "stretch", flexShrink: 0, @@ -943,116 +295,6 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: "transparent", borderRadius: 0, }, - favoritesContainer: { - backgroundColor: theme.colors.surface1, - borderBottomWidth: 1, - borderBottomColor: theme.colors.border, - }, - separator: { - height: 1, - backgroundColor: theme.colors.border, - }, - sectionHeading: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - paddingTop: theme.spacing[2], - paddingBottom: theme.spacing[1], - ...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }), - }, - sectionHeadingText: { - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.normal, - color: theme.colors.foregroundMuted, - }, - drillDownRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - minHeight: 36, - ...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }), - }, - drillDownRowHovered: { - backgroundColor: theme.colors.surface1, - }, - drillDownRowPressed: { - backgroundColor: theme.colors.surface2, - }, - drillDownText: { - flex: 1, - fontSize: theme.fontSize.sm, - color: theme.colors.foreground, - }, - drillDownTrailing: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - }, - drillDownCount: { - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, - }, - rowStateInline: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - flexShrink: 1, - minWidth: 0, - }, - rowErrorText: { - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, - maxWidth: 140, - }, - rowIconButton: { - width: 24, - height: 24, - borderRadius: theme.borderRadius.full, - alignItems: "center", - justifyContent: "center", - }, - rowSpinner: { - transform: [{ scale: 0.7 }], - }, - rowIconButtonHovered: { - backgroundColor: theme.colors.surface2, - }, - rowIconButtonPressed: { - backgroundColor: theme.colors.surface1, - }, - emptyState: { - paddingVertical: theme.spacing[4], - alignItems: "center", - gap: theme.spacing[2], - }, - emptyStateText: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - }, - virtualizedModelList: { - flex: 1, - }, - virtualizedModelListContent: { - paddingHorizontal: theme.spacing[2], - paddingTop: theme.spacing[1], - paddingBottom: theme.spacing[8], - }, - favoriteButton: { - width: 24, - height: 24, - borderRadius: theme.borderRadius.full, - alignItems: "center", - justifyContent: "center", - }, - favoriteButtonHovered: { - backgroundColor: theme.colors.surface2, - }, - favoriteButtonPressed: { - backgroundColor: theme.colors.surface1, - }, sheetLoadingState: { minHeight: 160, justifyContent: "center", @@ -1063,10 +305,4 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, - providerIconMuted: { - color: theme.colors.foregroundMuted, - }, - providerIconForeground: { - color: theme.colors.foreground, - }, })); diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx index c7955d8a1..1046d11e9 100644 --- a/packages/app/src/components/context-window-meter.tsx +++ b/packages/app/src/components/context-window-meter.tsx @@ -18,17 +18,16 @@ interface ContextWindowMeterProps { provider?: string | null; /** Reserve the meter footprint and show a loading ring while usage is pending. */ pending?: boolean; + /** Optional glyph envelope for icon-toolbar alignment. */ + glyphSize?: number; } const SVG_SIZE = 14; const COMPACT_SVG_SIZE = 12; -const CENTER = SVG_SIZE / 2; const COMPACT_CENTER = COMPACT_SVG_SIZE / 2; -const RADIUS = 6; const COMPACT_RADIUS = 5; const STROKE_WIDTH = 2; const COMPACT_STROKE_WIDTH = 1.75; -const CIRCUMFERENCE = 2 * Math.PI * RADIUS; const COMPACT_CIRCUMFERENCE = 2 * Math.PI * COMPACT_RADIUS; function isValidMaxTokens(value: number): boolean { @@ -74,7 +73,7 @@ function getMeterColors( return { progress: theme.colors.foregroundMuted, track }; } -function getMeterGeometry(showPercentage: boolean) { +function getMeterGeometry(showPercentage: boolean, glyphSize?: number) { if (showPercentage) { return { svgSize: COMPACT_SVG_SIZE, @@ -85,12 +84,14 @@ function getMeterGeometry(showPercentage: boolean) { containerStyle: styles.containerWithLabel, }; } + const resolvedSize = glyphSize ?? SVG_SIZE; + const resolvedStrokeWidth = glyphSize ? 2 : STROKE_WIDTH; return { - svgSize: SVG_SIZE, - center: CENTER, - radius: RADIUS, - strokeWidth: STROKE_WIDTH, - circumference: CIRCUMFERENCE, + svgSize: resolvedSize, + center: resolvedSize / 2, + radius: (resolvedSize - resolvedStrokeWidth) / 2, + strokeWidth: resolvedStrokeWidth, + circumference: Math.PI * (resolvedSize - resolvedStrokeWidth), containerStyle: styles.container, }; } @@ -103,6 +104,7 @@ export function ContextWindowMeter({ serverId, provider, pending = false, + glyphSize, }: ContextWindowMeterProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); @@ -123,7 +125,7 @@ export function ContextWindowMeter({ [refreshProviderUsage], ); - const geometry = getMeterGeometry(showPercentage); + const geometry = getMeterGeometry(showPercentage, glyphSize); // No usage yet: reserve the footprint with a track-only ring while a session is // active so the real ring fades in without shifting siblings. Render nothing when diff --git a/packages/app/src/components/icons/omp-icon.tsx b/packages/app/src/components/icons/omp-icon.tsx index 23780a5a8..76c6169d8 100644 --- a/packages/app/src/components/icons/omp-icon.tsx +++ b/packages/app/src/components/icons/omp-icon.tsx @@ -7,7 +7,7 @@ interface OmpIconProps { export function OmpIcon({ size = 16, color = "currentColor" }: OmpIconProps) { return ( - + ); diff --git a/packages/app/src/components/icons/pi-icon.tsx b/packages/app/src/components/icons/pi-icon.tsx index 6dc97fa29..81d098347 100644 --- a/packages/app/src/components/icons/pi-icon.tsx +++ b/packages/app/src/components/icons/pi-icon.tsx @@ -7,7 +7,7 @@ interface PiIconProps { export function PiIcon({ size = 16, color = "currentColor" }: PiIconProps) { return ( - + { + const codex = provider("codex", "Codex"); + const pi = provider("pi", "Pi"); + + it("opens a sole provider directly", () => { + expect( + resolveInitialModelBrowserView({ + providers: [pi], + selectedProvider: "", + selectedModel: "", + favoriteKeys: new Set(), + }), + ).toEqual({ kind: "provider", providerId: "pi", providerLabel: "Pi" }); + }); + + it("opens the selected provider when its model is not a favorite", () => { + expect( + resolveInitialModelBrowserView({ + providers: [codex, pi], + selectedProvider: "pi", + selectedModel: "pi-pro", + favoriteKeys: new Set(), + }), + ).toEqual({ kind: "provider", providerId: "pi", providerLabel: "Pi" }); + }); + + it("opens the provider overview when the selected model is a favorite", () => { + expect( + resolveInitialModelBrowserView({ + providers: [codex, pi], + selectedProvider: "pi", + selectedModel: "pi-pro", + favoriteKeys: new Set(["pi:pi-pro"]), + }), + ).toEqual({ kind: "all" }); + }); +}); diff --git a/packages/app/src/components/model-browser-view.ts b/packages/app/src/components/model-browser-view.ts new file mode 100644 index 000000000..9452b956d --- /dev/null +++ b/packages/app/src/components/model-browser-view.ts @@ -0,0 +1,40 @@ +import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; + +export type ModelBrowserView = + | { kind: "all" } + | { kind: "provider"; providerId: string; providerLabel: string }; + +export function resolveInitialModelBrowserView({ + providers, + selectedProvider, + selectedModel, + favoriteKeys, +}: { + providers: ProviderSelectorProvider[]; + selectedProvider: string; + selectedModel: string; + favoriteKeys: Set; +}): ModelBrowserView { + const singleProvider = providers.length === 1 ? providers[0] : undefined; + if (singleProvider) { + return { + kind: "provider", + providerId: singleProvider.id, + providerLabel: singleProvider.label, + }; + } + + const selectedFavoriteKey = `${selectedProvider}:${selectedModel}`; + const shouldOpenSelectedProvider = + selectedProvider.length > 0 && + selectedModel.length > 0 && + !favoriteKeys.has(selectedFavoriteKey); + if (shouldOpenSelectedProvider) { + const provider = providers.find((entry) => entry.id === selectedProvider); + if (provider) { + return { kind: "provider", providerId: provider.id, providerLabel: provider.label }; + } + } + + return { kind: "all" }; +} diff --git a/packages/app/src/components/model-browser.tsx b/packages/app/src/components/model-browser.tsx new file mode 100644 index 000000000..7f656c8c4 --- /dev/null +++ b/packages/app/src/components/model-browser.tsx @@ -0,0 +1,1169 @@ +import { createContext, useCallback, useContext, useMemo, useReducer, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + FlatList, + Platform, + Pressable, + ScrollView, + Text, + View, + type AccessibilityActionEvent, + type GestureResponderEvent, + type PressableStateCallbackType, + type StyleProp, + type ViewStyle, +} from "react-native"; +import { BottomSheetFlatList } from "@gorhom/bottom-sheet"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { AlertTriangle, Check, ChevronRight, Search, Settings, Star } from "lucide-react-native"; +import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import type { SheetHeader } from "@/components/adaptive-modal-sheet"; +import { Button } from "@/components/ui/button"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { getProviderIcon } from "@/components/provider-icons"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { isNative, isWeb } from "@/constants/platform"; +import { + buildSelectedTriggerLabel, + filterAndRankModelRows, + getAllProviderModelRows, + getProviderModelRows, + resolveSelectedModelLabel, + type ProviderSelectionModelRow, + type ProviderSelectorProvider, +} from "@/provider-selection/provider-selection"; +import { useProviderSettingsStore } from "@/stores/provider-settings-store"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { + resolveInitialModelBrowserView, + type ModelBrowserView, +} from "@/components/model-browser-view"; + +const DESKTOP_PROVIDER_VIEW_MIN_HEIGHT = 220; +const DESKTOP_PROVIDER_VIEW_MAX_HEIGHT = 400; +const DESKTOP_PROVIDER_VIEW_BASE_HEIGHT = 80; +const DESKTOP_MODEL_ROW_HEIGHT = 40; + +const ThemedAlertTriangle = withUnistyles(AlertTriangle); +const ThemedCheck = withUnistyles(Check); +const ThemedChevronRight = withUnistyles(ChevronRight); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const ThemedSearch = withUnistyles(Search); +const ThemedSettings = withUnistyles(Settings); +const ThemedStar = withUnistyles(Star); + +const IndependentScrollGestureContext = createContext | null>( + null, +); + +const foregroundMutedMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); + +const headerSettingsMapping = (disabled: boolean) => (theme: Theme) => ({ + color: disabled ? theme.colors.border : theme.colors.foregroundMuted, +}); + +const favoriteStarMapping = + (isFavorite: boolean, hovered: boolean) => + (theme: Theme): { color: string; fill: string } => { + const favoriteColor = theme.colors.palette.amber[500]; + if (isFavorite) { + return { color: favoriteColor, fill: favoriteColor }; + } + return { + color: hovered ? theme.colors.foregroundMuted : theme.colors.border, + fill: "transparent", + }; + }; + +interface ModelBrowserInput { + providers: ProviderSelectorProvider[]; + selectedProvider: string; + selectedModel: string; + isLoading: boolean; + favoriteKeys: Set; + serverId?: string | null; +} + +export interface ModelBrowserState { + providers: ProviderSelectorProvider[]; + selectedProvider: string; + selectedModel: string; + favoriteKeys: Set; + view: ModelBrowserView; + searchQuery: string; + header: SheetHeader; + selectedModelLabel: string; + triggerLabel: string; + desktopFixedHeight: number | undefined; + isProviderView: boolean; + prepareToOpen: () => void; + reset: () => void; + drillDown: (providerId: string, providerLabel: string) => void; +} + +interface ModelBrowserProps { + state: ModelBrowserState; + onSelect: (provider: string, modelId: string) => void; + onToggleFavorite?: (provider: string, modelId: string) => void; + onRetryProvider?: (provider: AgentProvider) => void; + isRetryingProvider?: boolean; + scrolling?: "sheet" | "independent"; +} + +interface ModelBrowserContentProps extends Omit { + view: ModelBrowserView; + providers: ProviderSelectorProvider[]; + selectedProvider: string; + selectedModel: string; + searchQuery: string; + favoriteKeys: Set; + onDrillDown: (providerId: string, providerLabel: string) => void; + scrolling: "sheet" | "independent"; +} + +type ProviderGlyphTone = "muted" | "foreground"; + +export function ModelProviderGlyph({ + provider, + size, + tone = "muted", +}: { + provider: string; + size: number; + tone?: ProviderGlyphTone; +}) { + const Icon = getProviderIcon(provider); + const color = + tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color; + return ; +} + +function HeaderSettingsIcon({ disabled }: { disabled: boolean }) { + const uniProps = useMemo(() => headerSettingsMapping(disabled), [disabled]); + return ; +} + +function FavoriteStar({ isFavorite, hovered }: { isFavorite: boolean; hovered: boolean }) { + const uniProps = useMemo(() => favoriteStarMapping(isFavorite, hovered), [hovered, isFavorite]); + return ; +} + +function favoriteButtonStyle({ + hovered, + pressed, +}: PressableStateCallbackType & { hovered?: boolean }) { + return [ + styles.favoriteButton, + Boolean(hovered) && styles.favoriteButtonHovered, + pressed && styles.favoriteButtonPressed, + ]; +} + +function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) { + return [ + styles.rowIconButton, + Boolean(hovered) && styles.rowIconButtonHovered, + pressed && styles.rowIconButtonPressed, + ]; +} + +function resolveDesktopFixedHeight( + view: ModelBrowserView, + providers: ProviderSelectorProvider[], +): number | undefined { + if (view.kind !== "provider") { + return undefined; + } + const provider = providers.find((entry) => entry.id === view.providerId); + if (!provider || provider.modelSelection.kind !== "models") { + return DESKTOP_PROVIDER_VIEW_MIN_HEIGHT; + } + const modelCount = getProviderModelRows(provider).length; + return Math.min( + Math.max( + DESKTOP_PROVIDER_VIEW_MIN_HEIGHT, + DESKTOP_PROVIDER_VIEW_BASE_HEIGHT + modelCount * DESKTOP_MODEL_ROW_HEIGHT, + ), + DESKTOP_PROVIDER_VIEW_MAX_HEIGHT, + ); +} + +export function useModelBrowser({ + providers, + selectedProvider, + selectedModel, + isLoading, + favoriteKeys, + serverId = null, +}: ModelBrowserInput): ModelBrowserState { + const { t } = useTranslation(); + const [view, setView] = useState({ kind: "all" }); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0); + + const initialView = useMemo( + () => + resolveInitialModelBrowserView({ + providers, + selectedProvider, + selectedModel, + favoriteKeys, + }), + [favoriteKeys, providers, selectedModel, selectedProvider], + ); + + const prepareToOpen = useCallback(() => { + setView(initialView); + }, [initialView]); + + const reset = useCallback(() => { + setSearchQuery(""); + bumpSearchResetKey(); + }, []); + + const handleBackToAll = useCallback(() => { + setView({ kind: "all" }); + reset(); + }, [reset]); + + const drillDown = useCallback((providerId: string, providerLabel: string) => { + setView({ kind: "provider", providerId, providerLabel }); + }, []); + + const handleSearchQueryChange = useCallback((value: string) => { + setSearchQuery(value); + }, []); + + const openProviderSettings = useCallback(() => { + if (!serverId || view.kind !== "provider") return; + useProviderSettingsStore.getState().open({ serverId, provider: view.providerId }); + }, [serverId, view]); + + const singleProviderView = providers.length === 1; + const header = useMemo(() => { + if (view.kind === "all") { + return { title: t("modelSelector.title") }; + } + return { + title: view.providerLabel, + leading: ( + + ), + back: singleProviderView ? undefined : { onPress: handleBackToAll }, + actions: ( + + + + ), + search: { + onChange: handleSearchQueryChange, + resetKey: `${view.providerId}:${searchResetKey}`, + placeholder: t("modelSelector.searchPlaceholder"), + autoFocus: isWeb, + testID: "model-search-input", + }, + }; + }, [ + handleBackToAll, + handleSearchQueryChange, + openProviderSettings, + searchResetKey, + serverId, + singleProviderView, + t, + view, + ]); + + const selectedModelLabel = useMemo( + () => + resolveSelectedModelLabel({ + providers, + selectedProvider, + selectedModel, + isLoading, + }), + [isLoading, providers, selectedModel, selectedProvider], + ); + + const triggerLabel = useMemo(() => { + const isPlaceholder = + selectedModelLabel === t("modelSelector.loading") || + selectedModelLabel === t("modelSelector.selectModel"); + return isPlaceholder ? selectedModelLabel : buildSelectedTriggerLabel(selectedModelLabel); + }, [selectedModelLabel, t]); + + const desktopFixedHeight = useMemo( + () => resolveDesktopFixedHeight(view, providers), + [providers, view], + ); + + return { + providers, + selectedProvider, + selectedModel, + favoriteKeys, + view, + searchQuery, + header, + selectedModelLabel, + triggerLabel, + desktopFixedHeight, + isProviderView: view.kind === "provider", + prepareToOpen, + reset, + drillDown, + }; +} + +function normalizeSearchQuery(value: string): string { + return value.trim().toLowerCase(); +} + +function sortFavoritesFirst( + rows: ProviderSelectionModelRow[], + favoriteKeys: Set, +): ProviderSelectionModelRow[] { + const favorites: ProviderSelectionModelRow[] = []; + const rest: ProviderSelectionModelRow[] = []; + for (const row of rows) { + if (favoriteKeys.has(row.favoriteKey)) { + favorites.push(row); + } else { + rest.push(row); + } + } + return [...favorites, ...rest]; +} + +interface ModelBrowserPressableProps { + children: React.ReactNode | ((state: PressableStateCallbackType) => React.ReactNode); + style?: + | StyleProp + | ((state: PressableStateCallbackType & { hovered?: boolean }) => StyleProp); + onPress: () => void; + hitSlop?: number; + accessibilityLabel?: string; + testID?: string; +} + +function ModelBrowserPressable({ + children, + style, + onPress, + hitSlop, + accessibilityLabel, + testID, +}: ModelBrowserPressableProps) { + const independentScrollGesture = useContext(IndependentScrollGestureContext); + const [pressed, setPressed] = useState(false); + // Android's scroll handler must keep the pointer stream until release so a + // fling survives leaving the short viewport. A simultaneous Tap keeps rows + // interactive, while maxDistance makes a real scroll fail instead of select. + const tapGesture = useMemo(() => { + const gesture = Gesture.Tap() + .maxDistance(8) + .shouldCancelWhenOutside(true) + .runOnJS(true) + .onBegin(() => setPressed(true)) + .onEnd((_event, success) => { + if (success) onPress(); + }) + .onFinalize(() => setPressed(false)); + if (hitSlop !== undefined) gesture.hitSlop(hitSlop); + if (independentScrollGesture) { + gesture.simultaneousWithExternalGesture(independentScrollGesture); + } + return gesture; + }, [hitSlop, independentScrollGesture, onPress]); + const handlePress = useCallback( + (event: GestureResponderEvent) => { + event.stopPropagation(); + onPress(); + }, + [onPress], + ); + const handleAccessibilityAction = useCallback( + (event: AccessibilityActionEvent) => { + if (event.nativeEvent.actionName === "activate") onPress(); + }, + [onPress], + ); + + if (!independentScrollGesture) { + return ( + + {children} + + ); + } + + const state = { pressed }; + const resolvedStyle = typeof style === "function" ? style(state) : style; + const resolvedChildren = typeof children === "function" ? children(state) : children; + return ( + + + {resolvedChildren} + + + ); +} + +type ModelBrowserRowTone = "default" | "elevated" | "drillDown"; + +function ModelBrowserRow({ + label, + description, + leadingSlot, + trailingSlot, + selected = false, + selectionIndicator = false, + tone = "default", + spacing = "model", + onPress, + testID, +}: { + label: string; + description?: string; + leadingSlot: React.ReactNode; + trailingSlot?: React.ReactNode; + selected?: boolean; + selectionIndicator?: boolean; + tone?: ModelBrowserRowTone; + spacing?: "model" | "provider"; + onPress: () => void; + testID?: string; +}) { + const pressableStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.browserRow, + spacing === "model" && styles.browserModelRow, + Boolean(hovered) && + (tone === "elevated" ? styles.browserRowHoveredElevated : styles.browserRowHovered), + pressed && (tone === "default" ? styles.browserRowPressed : styles.browserRowPressedElevated), + ], + [spacing, tone], + ); + const contentStyle = useMemo( + () => [styles.browserRowText, description && styles.browserRowTextInline], + [description], + ); + const hasTrailing = selected || trailingSlot; + + return ( + + + {leadingSlot} + + + {label} + + {description ? ( + + {description} + + ) : null} + + {hasTrailing ? ( + + {selectionIndicator ? ( + + {selected ? ( + + ) : null} + + ) : null} + {trailingSlot} + + ) : null} + + + ); +} + +function ModelRow({ + row, + isSelected, + isFavorite, + elevated = false, + onPress, + onToggleFavorite, +}: { + row: ProviderSelectionModelRow; + isSelected: boolean; + isFavorite: boolean; + elevated?: boolean; + onPress: () => void; + onToggleFavorite?: (provider: string, modelId: string) => void; +}) { + const { t } = useTranslation(); + const handleToggleFavorite = useCallback( + () => onToggleFavorite?.(row.provider, row.modelId), + [onToggleFavorite, row.modelId, row.provider], + ); + const leadingSlot = useMemo( + () => , + [row.provider], + ); + const trailingSlot = useMemo( + () => + onToggleFavorite ? ( + + {({ hovered }) => } + + ) : null, + [handleToggleFavorite, isFavorite, onToggleFavorite, row.modelId, row.provider, t], + ); + + return ( + + ); +} + +function SelectableModelRow({ + row, + isSelected, + isFavorite, + elevated, + onSelect, + onToggleFavorite, +}: { + row: ProviderSelectionModelRow; + isSelected: boolean; + isFavorite: boolean; + elevated?: boolean; + onSelect: (provider: string, modelId: string) => void; + onToggleFavorite?: (provider: string, modelId: string) => void; +}) { + const handlePress = useCallback(() => { + onSelect(row.provider, row.modelId); + }, [onSelect, row.modelId, row.provider]); + return ( + + ); +} + +function FavoritesSection({ + favoriteRows, + selectedProvider, + selectedModel, + favoriteKeys, + onSelect, + onToggleFavorite, +}: { + favoriteRows: ProviderSelectionModelRow[]; + selectedProvider: string; + selectedModel: string; + favoriteKeys: Set; + onSelect: (provider: string, modelId: string) => void; + onToggleFavorite?: (provider: string, modelId: string) => void; +}) { + const { t } = useTranslation(); + if (favoriteRows.length === 0) return null; + return ( + + + {t("modelSelector.favorites")} + + {favoriteRows.map((row) => ( + + ))} + + ); +} + +function GroupProviderButton({ + provider, + onDrillDown, +}: { + provider: ProviderSelectorProvider; + onDrillDown: (providerId: string, providerLabel: string) => void; +}) { + const { t } = useTranslation(); + const selection = provider.modelSelection; + const handlePress = useCallback(() => { + onDrillDown(provider.id, provider.label); + }, [onDrillDown, provider.id, provider.label]); + + const stateNode = useMemo(() => { + if (selection.kind === "models") { + const count = selection.rows.length; + return ( + + {t(count === 1 ? "modelSelector.modelCount" : "modelSelector.modelCountPlural", { + count, + })} + + ); + } + if (selection.kind === "loading") { + return ( + + + + + {t("modelSelector.loadingShort")} + + ); + } + return ( + + + {t("modelSelector.error")} + + ); + }, [selection, t]); + const leadingSlot = useMemo( + () => , + [provider.id], + ); + const trailingSlot = useMemo( + () => ( + + {stateNode} + + + ), + [stateNode], + ); + + return ( + + ); +} + +function GroupedProviderRows({ + providers, + onDrillDown, +}: { + providers: ProviderSelectorProvider[]; + onDrillDown: (providerId: string, providerLabel: string) => void; +}) { + return ( + + {providers.map((provider, index) => ( + + {index > 0 ? : null} + + + ))} + + ); +} + +function IndependentScrollBoundary({ children }: { children: React.ReactElement }) { + // Prevent the parent sheet from cancelling Android's native scroll when the + // finger crosses this viewport; receiving ACTION_UP is what preserves fling. + const nativeScrollGesture = useMemo( + () => + Gesture.Native() + .shouldActivateOnStart(true) + .shouldCancelWhenOutside(false) + .disallowInterruption(true), + [], + ); + + if (Platform.OS !== "android") { + return children; + } + + return ( + + {children} + + ); +} + +function IndependentModelList({ + rows, + renderItem, +}: { + rows: ProviderSelectionModelRow[]; + renderItem: ({ item }: { item: ProviderSelectionModelRow }) => React.ReactElement; +}) { + return ( + + + + ); +} + +function getModelRowKey(row: ProviderSelectionModelRow): string { + return row.favoriteKey; +} + +function IndependentProviderList({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} + +function ProviderModelRows({ + rows, + selectedProvider, + selectedModel, + favoriteKeys, + onSelect, + onToggleFavorite, + normalizedQuery, + scrolling, +}: { + rows: ProviderSelectionModelRow[]; + selectedProvider: string; + selectedModel: string; + favoriteKeys: Set; + onSelect: (provider: string, modelId: string) => void; + onToggleFavorite?: (provider: string, modelId: string) => void; + normalizedQuery: string; + scrolling: "sheet" | "independent"; +}) { + const isCompact = useIsCompactFormFactor(); + const displayRows = useMemo( + () => (normalizedQuery ? rows : sortFavoritesFirst(rows, favoriteKeys)), + [favoriteKeys, normalizedQuery, rows], + ); + const renderItem = useCallback( + ({ item }: { item: ProviderSelectionModelRow }) => ( + + ), + [favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider], + ); + const keyExtractor = useCallback((row: ProviderSelectionModelRow) => row.favoriteKey, []); + + if (scrolling === "independent") { + return ; + } + + if (isCompact && isNative) { + return ( + + ); + } + + return ( + + {displayRows.map((row) => ( + {renderItem({ item: row })} + ))} + + ); +} + +function ProviderErrorEmptyState({ + providerId, + message, + onRetryProvider, + isRetryingProvider, +}: { + providerId: string; + message: string; + onRetryProvider?: (provider: AgentProvider) => void; + isRetryingProvider: boolean; +}) { + const { t } = useTranslation(); + const handleRetry = useCallback(() => { + onRetryProvider?.(providerId); + }, [onRetryProvider, providerId]); + return ( + + + {message} + {onRetryProvider ? ( + + ) : null} + + ); +} + +function ModelBrowserContent({ + view, + providers, + selectedProvider, + selectedModel, + searchQuery, + favoriteKeys, + onSelect, + onToggleFavorite, + onDrillDown, + onRetryProvider, + isRetryingProvider = false, + scrolling, +}: ModelBrowserContentProps) { + const { t } = useTranslation(); + const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]); + const selectedViewProvider = useMemo( + () => + view.kind === "provider" + ? providers.find((provider) => provider.id === view.providerId) + : null, + [providers, view], + ); + const visibleRows = useMemo( + () => + selectedViewProvider + ? filterAndRankModelRows(getProviderModelRows(selectedViewProvider), normalizedQuery) + : [], + [normalizedQuery, selectedViewProvider], + ); + const favoriteRows = useMemo( + () => getAllProviderModelRows(providers).filter((row) => favoriteKeys.has(row.favoriteKey)), + [favoriteKeys, providers], + ); + const hasResults = favoriteRows.length > 0 || providers.length > 0; + const emptyState = ( + + + {t("modelSelector.noMatches")} + + ); + + if (view.kind === "provider") { + if (!selectedViewProvider) return emptyState; + const selection = selectedViewProvider.modelSelection; + if (selection.kind === "loading") { + return ( + + + + + {t("modelSelector.loadingShort")} + + ); + } + if (selection.kind === "error") { + return ( + + ); + } + if (visibleRows.length === 0) return emptyState; + return ( + + ); + } + + const allProvidersContent = ( + + + {providers.length > 0 ? ( + + ) : null} + {!hasResults ? emptyState : null} + + ); + + return scrolling === "independent" ? ( + {allProvidersContent} + ) : ( + allProvidersContent + ); +} + +export function ModelBrowser({ + state, + onSelect, + onToggleFavorite, + onRetryProvider, + isRetryingProvider = false, + scrolling = "sheet", +}: ModelBrowserProps) { + return ( + + ); +} + +const styles = StyleSheet.create((theme) => ({ + favoritesContainer: { + backgroundColor: theme.colors.surface1, + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, + separator: { + height: 1, + backgroundColor: theme.colors.border, + }, + sectionHeading: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: isWeb ? theme.spacing[3] : theme.spacing[6], + paddingTop: theme.spacing[2], + paddingBottom: theme.spacing[1], + }, + sectionHeadingText: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foregroundMuted, + }, + browserRow: { + flexDirection: "row", + paddingVertical: theme.spacing[2], + minHeight: 36, + }, + browserModelRow: isWeb ? {} : { marginBottom: theme.spacing[1] }, + browserRowHovered: { + backgroundColor: theme.colors.surface1, + }, + browserRowHoveredElevated: { + backgroundColor: theme.colors.surface2, + }, + browserRowPressed: { + backgroundColor: theme.colors.surface1, + }, + browserRowPressedElevated: { + backgroundColor: theme.colors.surface2, + }, + browserRowContent: { + flex: 1, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: isWeb ? theme.spacing[3] : theme.spacing[6], + }, + browserRowLeading: { + width: 16, + alignItems: "center", + justifyContent: "center", + }, + browserRowText: { + flex: 1, + flexShrink: 1, + }, + browserRowTextInline: { + flexDirection: "row", + alignItems: "baseline", + gap: theme.spacing[2], + }, + browserRowLabel: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + flexShrink: 0, + }, + browserRowDescription: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + flexShrink: 1, + }, + browserRowTrailing: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + marginLeft: "auto", + }, + browserRowSelection: { + width: 16, + alignItems: "center", + justifyContent: "center", + }, + drillDownTrailing: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + drillDownCount: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + rowStateInline: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + flexShrink: 1, + minWidth: 0, + }, + rowIconButton: { + width: 24, + height: 24, + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", + }, + rowSpinner: { + transform: [{ scale: 0.7 }], + }, + rowIconButtonHovered: { + backgroundColor: theme.colors.surface2, + }, + rowIconButtonPressed: { + backgroundColor: theme.colors.surface1, + }, + emptyState: { + paddingVertical: theme.spacing[4], + alignItems: "center", + gap: theme.spacing[2], + }, + emptyStateText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + virtualizedModelList: { + flex: 1, + }, + virtualizedModelListContent: { + paddingTop: theme.spacing[1], + paddingBottom: theme.spacing[8], + }, + virtualizedProviderListContent: { + paddingTop: 0, + }, + favoriteButton: { + width: 24, + height: 24, + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", + }, + favoriteButtonHovered: { + backgroundColor: theme.colors.surface2, + }, + favoriteButtonPressed: { + backgroundColor: theme.colors.surface1, + }, + providerIconMuted: { + color: theme.colors.foregroundMuted, + }, + providerIconForeground: { + color: theme.colors.foreground, + }, +})); diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 81c8f0ef0..2d862a133 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -24,6 +24,7 @@ import { type ViewStyle, } from "react-native"; import { useTranslation } from "react-i18next"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { @@ -100,6 +101,8 @@ export interface ComboboxProps { */ header?: SheetHeader; mobileChildrenScrollEnabled?: boolean; + /** Overrides the mobile scroll container spacing for custom child content. */ + mobileChildrenContentContainerStyle?: StyleProp; presentation?: "push" | "replace"; open?: boolean; onOpenChange?: (open: boolean) => void; @@ -934,6 +937,7 @@ interface MobileBodyProps { searchable: boolean; hasChildren: boolean; mobileChildrenScrollEnabled: boolean; + mobileChildrenContentContainerStyle: StyleProp; presentation?: "push" | "replace"; searchResetKey: number; searchPlaceholder: string; @@ -947,6 +951,7 @@ interface MobileBodyProps { handleSelect: (id: string) => void; renderOption: RenderOptionFn | undefined; children: ReactNode; + safeAreaBottom: number; } function MobileComboboxBody(props: MobileBodyProps): ReactElement { @@ -966,6 +971,10 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement { () => [styles.comboboxTitle, { color: props.titleColor }], [props.titleColor], ); + const frameStyle = useMemo( + () => [styles.mobileSheetFrame, { paddingBottom: props.safeAreaBottom }], + [props.safeAreaBottom], + ); const body = props.hasChildren ? ( props.children @@ -996,40 +1005,46 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement { keyboardBlurBehavior="none" presentation={props.presentation} > - {props.header ? ( - - ) : ( - <> - - - {props.title} - - - {props.stickyHeader} - {!props.hasChildren && props.searchable ? ( - - ) : null} - - )} - {props.hasChildren && !props.mobileChildrenScrollEnabled ? ( - body - ) : ( - - {body} - - )} - {props.footer ? {props.footer} : null} + + {props.header ? ( + + ) : ( + <> + + + {props.title} + + + {props.stickyHeader} + {!props.hasChildren && props.searchable ? ( + + ) : null} + + )} + {props.hasChildren && !props.mobileChildrenScrollEnabled ? ( + body + ) : ( + + {body} + + )} + {props.footer ? {props.footer} : null} + ); } @@ -1222,6 +1237,7 @@ export function Combobox({ title, header, mobileChildrenScrollEnabled = true, + mobileChildrenContentContainerStyle, presentation, open, onOpenChange, @@ -1241,6 +1257,7 @@ export function Combobox({ const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch"); const resolvedTitle = title ?? t("common.actions.select"); const isMobile = useIsCompactFormFactor(); + const safeAreaInsets = useSafeAreaInsets(); const titleColor = theme.colors.foreground; const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition); const isDesktopAboveSearch = resolveIsDesktopAboveSearch(isMobile, effectiveOptionsPosition); @@ -1515,6 +1532,7 @@ export function Combobox({ searchable={searchable} hasChildren={hasChildren} mobileChildrenScrollEnabled={mobileChildrenScrollEnabled} + mobileChildrenContentContainerStyle={mobileChildrenContentContainerStyle} presentation={presentation} searchResetKey={searchResetKey} searchPlaceholder={effectiveSearchPlaceholder} @@ -1527,6 +1545,7 @@ export function Combobox({ emptyText={resolvedEmptyText} handleSelect={handleSelect} renderOption={renderOption} + safeAreaBottom={safeAreaInsets.bottom} > {children} @@ -1569,6 +1588,14 @@ export function Combobox({ } const styles = StyleSheet.create((theme) => ({ + mobileSheetFrame: { + flex: 1, + minHeight: 0, + }, + mobileSheetBody: { + flex: 1, + minHeight: 0, + }, searchInputContainer: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 1ebe3ea9c..e26cfedbc 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -6,11 +6,9 @@ import { createNameId } from "mnemonic-id"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { Composer } from "@/composer"; -import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control"; import { useToast } from "@/contexts/toast-context"; import { useAgentInputDraft } from "@/composer/draft/input-draft"; import { useProjectIconQuery } from "@/hooks/use-project-icon-query"; -import { useIsCompactFormFactor } from "@/constants/layout"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; @@ -382,7 +380,6 @@ export function WorkspaceSetupDialog() { const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(workspaceTitle); const placeholderInitial = placeholderLabel.charAt(0).toUpperCase(); - const isCompact = useIsCompactFormFactor(); const iconSource = useMemo(() => (iconDataUri ? { uri: iconDataUri } : null), [iconDataUri]); const agentControlsWithDisabled = useMemo( () => @@ -395,14 +392,6 @@ export function WorkspaceSetupDialog() { [composerState, pendingAction], ); - const composerFooter = useMemo( - () => - isCompact && agentControlsWithDisabled ? ( - - ) : undefined, - [isCompact, agentControlsWithDisabled], - ); - const subtitleContent = useMemo( () => ( @@ -457,7 +446,6 @@ export function WorkspaceSetupDialog() { commandDraftConfig={composerState?.commandDraftConfig} agentControls={agentControlsWithDisabled} inputWrapperStyle={styles.composerInputWrapper} - footer={composerFooter} /> diff --git a/packages/app/src/composer/agent-controls/control.tsx b/packages/app/src/composer/agent-controls/control.tsx new file mode 100644 index 000000000..f8d23390f --- /dev/null +++ b/packages/app/src/composer/agent-controls/control.tsx @@ -0,0 +1,173 @@ +import { forwardRef, useCallback, type ComponentType } from "react"; +import { Text, View, type PressableStateCallbackType } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; +import { useComposerControlLayout } from "@/composer/agent-controls/layout-context"; +import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph"; + +export interface AgentControlIconProps { + size?: number; + color?: string; +} + +export type AgentControlIcon = ComponentType; + +interface AgentControlTriggerProps { + icon: AgentControlIcon; + iconColor?: string; + surface: "toolbar" | "sheet"; + label: string; + value?: string; + showToolbarLabel?: boolean; + showCaret?: boolean; + open?: boolean; + disabled?: boolean; + onPress: () => void; + accessibilityLabel: string; + testID?: string; +} + +export const AgentControlTrigger = forwardRef( + function AgentControlTrigger( + { + icon: Icon, + iconColor, + surface, + label, + value, + showToolbarLabel = true, + showCaret = false, + open = false, + disabled = false, + onPress, + accessibilityLabel, + testID, + }, + ref, + ) { + const { glyphSize } = useComposerControlLayout(); + const isSheet = surface === "sheet"; + const resolvedGlyphSize = isSheet ? 16 : glyphSize; + const resolvedIconColor = iconColor ?? styles.iconColor.color; + const showValue = isSheet || showToolbarLabel; + const triggerStyle = useCallback( + ({ pressed, hovered }: PressableStateCallbackType) => [ + isSheet ? styles.sheetRow : styles.toolbarControl, + !isSheet && !showToolbarLabel && styles.toolbarIconOnly, + hovered && (isSheet ? styles.sheetRowInteractive : styles.hovered), + (pressed || open) && (isSheet ? styles.sheetRowInteractive : styles.pressed), + disabled && styles.disabled, + ], + [disabled, isSheet, open, showToolbarLabel], + ); + + return ( + + {isSheet ? ( + + + + ) : ( + + + + )} + {isSheet ? ( + + {label} + + ) : null} + {showValue ? ( + + {value ?? label} + + ) : null} + + ); + }, +); + +const styles = StyleSheet.create((theme) => ({ + toolbarControl: { + height: 28, + minWidth: 0, + flexShrink: 1, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius["2xl"], + backgroundColor: "transparent", + }, + toolbarIconOnly: { + width: 28, + flexShrink: 0, + paddingHorizontal: 0, + justifyContent: "center", + }, + toolbarValue: { + minWidth: 0, + flexShrink: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + }, + sheetRow: { + minHeight: 44, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + marginHorizontal: -theme.spacing[1], + paddingHorizontal: theme.spacing[4], + borderRadius: theme.borderRadius["2xl"], + backgroundColor: theme.colors.surface1, + }, + sheetRowInteractive: { + backgroundColor: theme.colors.surface2, + }, + sheetGlyph: { + width: 20, + height: 20, + flexShrink: 0, + alignItems: "center", + justifyContent: "center", + }, + sheetLabel: { + flex: 1, + minWidth: 0, + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + }, + sheetValue: { + maxWidth: "45%", + minWidth: 0, + flexShrink: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + }, + hovered: { + backgroundColor: theme.colors.surface2, + }, + pressed: { + backgroundColor: theme.colors.surface0, + }, + disabled: { + opacity: 0.5, + }, + iconColor: { + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/composer/agent-controls/glyph.tsx b/packages/app/src/composer/agent-controls/glyph.tsx new file mode 100644 index 000000000..73b2a220c --- /dev/null +++ b/packages/app/src/composer/agent-controls/glyph.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; +import { StyleSheet, View } from "react-native"; + +export function ComposerToolbarGlyph({ children, size }: { children: ReactNode; size: number }) { + return ( + = 20 ? styles.native : styles.web} + accessibilityElementsHidden + importantForAccessibility="no-hide-descendants" + pointerEvents="none" + > + {children} + + ); +} + +const styles = StyleSheet.create({ + web: { + width: 16, + height: 16, + flexShrink: 0, + alignItems: "center", + justifyContent: "center", + }, + native: { + width: 20, + height: 20, + flexShrink: 0, + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index a7ca3f624..40b109f96 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -1,11 +1,11 @@ import { memo, useCallback, + useEffect, useMemo, useRef, useState, type ReactElement, - type ReactNode, type RefObject, } from "react"; import { useTranslation } from "react-i18next"; @@ -14,6 +14,8 @@ import { Text, Pressable, Keyboard, + useWindowDimensions, + type LayoutChangeEvent, type PressableStateCallbackType, type StyleProp, type ViewStyle, @@ -21,9 +23,7 @@ import { import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; import { Brain, ListTodo, Settings2, ShieldCheck, Zap } from "lucide-react-native"; -import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; -import { getProviderIcon } from "@/components/provider-icons"; import { CombinedModelSelector } from "@/components/combined-model-selector"; import { buildProviderSelectorProviders, @@ -39,9 +39,12 @@ import { toggleFavoriteModel, useFormPreferences, } from "@/hooks/use-form-preferences"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; -import { DraftAgentModeControl, AgentModeControl } from "@/composer/agent-controls/mode-control"; +import { + AgentModeControl, + useLiveAgentModeControl, + type AgentModeControlValue, +} from "@/composer/agent-controls/mode-control"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { @@ -65,6 +68,18 @@ import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; import { useCommandCenterActions } from "@/command-center/provider"; import { buildModelChoiceContributions } from "@/command-center/model-contributions"; import { getCommandCenterProviderIcon } from "@/command-center/provider-icon"; +import { isNative } from "@/constants/platform"; +import { + resolveComposerControlDensity, + resolveComposerControlPresentation, + resolveComposerToolbarGlyphSize, + type ComposerControlDensity, + type ComposerControlPresentation, +} from "@/composer/agent-controls/layout"; +import { ComposerControlLayoutProvider } from "@/composer/agent-controls/layout-context"; +import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph"; +import { AgentControlTrigger } from "@/composer/agent-controls/control"; +import { CompactModelSheet } from "@/composer/agent-controls/model-sheet"; interface AgentControlOption { id: string; @@ -96,8 +111,7 @@ interface ControlledAgentControlsProps { onModelSelectorOpen?: () => void; onRetryModelProvider?: (provider: AgentProvider) => void; isRetryingModelProvider?: boolean; - /** Extra elements rendered inline with the agent controls (desktop only). */ - desktopExtras?: ReactNode; + modeControl?: AgentModeControlValue | null; modelSelectorServerId?: string | null; isCompactLayout?: boolean; } @@ -186,14 +200,6 @@ function getFeatureIconColor( } } -// Mobile agent controls only — strip namespace prefix so providers like OpenCode -// show "gpt-5.5" instead of "openrouter/gpt-5.5". Full label still appears in -// the model picker. -function shortModelLabel(label: string): string { - const i = label.lastIndexOf("/"); - return i === -1 ? label : label.slice(i + 1); -} - type ActiveSheet = "thinking" | "features" | null; function resolveHasAnyControl({ @@ -201,20 +207,20 @@ function resolveHasAnyControl({ canSelectModel, thinkingOptions, features, - hasDesktopExtras, + hasMode, }: { providerOptions: AgentControlOption[] | undefined; canSelectModel: boolean; thinkingOptions: AgentControlOption[] | undefined; features: AgentFeature[] | undefined; - hasDesktopExtras: boolean; + hasMode: boolean; }) { return ( Boolean(providerOptions?.length) || canSelectModel || Boolean(thinkingOptions?.length) || Boolean(features?.length) || - hasDesktopExtras + hasMode ); } @@ -298,24 +304,23 @@ function pickDesktopModel({ modelId, currentProvider, onSelectModel, + onSelectProviderAndModel, }: { nextProviderId: string; modelId: string; currentProvider: string; onSelectModel?: (modelId: string) => void; + onSelectProviderAndModel?: (provider: string, modelId: string) => void; }) { + if (onSelectProviderAndModel) { + onSelectProviderAndModel(nextProviderId, modelId); + return; + } if (nextProviderId === currentProvider) { onSelectModel?.(modelId); } } -function resolveProviderIcon(provider: string) { - if (provider.trim().length === 0) { - return null; - } - return getProviderIcon(provider); -} - type AgentControlsSlice = { provider: string; cwd: string | null; @@ -413,7 +418,7 @@ function ControlledAgentControls({ onModelSelectorOpen, onRetryModelProvider, isRetryingModelProvider = false, - desktopExtras, + modeControl, modelSelectorServerId = null, isCompactLayout, }: ControlledAgentControlsProps) { @@ -421,8 +426,13 @@ function ControlledAgentControls({ const { t } = useTranslation(); const isCompactFormFactor = useIsCompactFormFactor(); const isCompact = isCompactLayout ?? isCompactFormFactor; + const { fontScale } = useWindowDimensions(); const [activeSheet, setActiveSheet] = useState(null); const [openSelector, setOpenSelector] = useState(null); + const initialDensity: ComposerControlDensity = isCompact ? "tight" : "full"; + const [density, setDensity] = useState(initialDensity); + const densityRef = useRef(initialDensity); + const availableWidthRef = useRef(0); const providerAnchorRef = useRef(null); const _modelAnchorRef = useRef(null); @@ -451,15 +461,72 @@ function ControlledAgentControls({ formattedThinkingOptions[0]?.label ?? t("agentControls.thinking.unknown"), ); - const ProviderIcon = resolveProviderIcon(provider); - const hasAnyControl = resolveHasAnyControl({ providerOptions, canSelectModel, thinkingOptions, features, - hasDesktopExtras: desktopExtras !== null && desktopExtras !== undefined, + hasMode: modeControl !== null && modeControl !== undefined, }); + const featureControls = useMemo( + () => + (features ?? []).map((feature) => { + if (feature.type === "toggle") return { type: "toggle" as const }; + const selectedOption = feature.options.find((option) => option.id === feature.value); + return { + type: "select" as const, + label: selectedOption?.label ?? feature.label, + }; + }), + [features], + ); + const controlPresence = useMemo( + () => ({ + hasModel: canSelectModel, + hasThinking: canSelectThinking, + hasMode: modeControl !== null && modeControl !== undefined, + features: featureControls, + fontScale, + }), + [canSelectModel, canSelectThinking, featureControls, fontScale, modeControl], + ); + const presentation = useMemo(() => resolveComposerControlPresentation(density), [density]); + const layoutContextValue = useMemo( + () => ({ + glyphSize: resolveComposerToolbarGlyphSize(isNative ? "native" : "web"), + presentation, + }), + [presentation], + ); + + const updateDensityForWidth = useCallback( + (availableWidth: number) => { + const nextDensity = resolveComposerControlDensity({ + availableWidth, + currentDensity: densityRef.current, + controls: controlPresence, + }); + if (nextDensity === densityRef.current) return; + densityRef.current = nextDensity; + setDensity(nextDensity); + }, + [controlPresence], + ); + + const handleLayout = useCallback( + (event: LayoutChangeEvent) => { + const availableWidth = event.nativeEvent.layout.width; + availableWidthRef.current = availableWidth; + updateDensityForWidth(availableWidth); + }, + [updateDensityForWidth], + ); + + useEffect(() => { + if (availableWidthRef.current > 0) { + updateDensityForWidth(availableWidthRef.current); + } + }, [updateDensityForWidth]); const modelDisabled = disabled; @@ -495,6 +562,12 @@ function ControlledAgentControls({ buildOpenChangeHandler(selector, setOpenSelector, onDropdownClose), [onDropdownClose], ); + const handleSheetOpenChange = useCallback( + (selector: AgentControlSelector) => (nextOpen: boolean) => { + setOpenSelector(nextOpen ? selector : null); + }, + [], + ); const handleProviderPress = useCallback(() => { handleOpenChange("provider")(openSelector !== "provider"); @@ -518,9 +591,15 @@ function ControlledAgentControls({ const handleDesktopModelSelect = useCallback( (nextProviderId: string, modelId: string) => { - pickDesktopModel({ nextProviderId, modelId, currentProvider: provider, onSelectModel }); + pickDesktopModel({ + nextProviderId, + modelId, + currentProvider: provider, + onSelectModel, + onSelectProviderAndModel, + }); }, - [onSelectModel, provider], + [onSelectModel, onSelectProviderAndModel, provider], ); const providerPressableStyle = useMemo( @@ -534,17 +613,6 @@ function ControlledAgentControls({ [canSelectProvider, disabled, openSelector], ); - const thinkingPressableStyle = useMemo( - () => - makeBadgePressableStyle( - styles.modeBadge, - styles.disabledBadge, - disabled || !canSelectThinking, - openSelector === "thinking", - ), - [canSelectThinking, disabled, openSelector], - ); - const handleOpenSheet = useCallback((sheet: Exclude) => { Keyboard.dismiss(); setActiveSheet(sheet); @@ -552,7 +620,8 @@ function ControlledAgentControls({ const handleCloseSheet = useCallback(() => { setActiveSheet(null); - }, []); + if (!isCompact) onDropdownClose?.(); + }, [isCompact, onDropdownClose]); const handleSelectThinkingAndClose = useCallback( (thinkingOptionId: string) => { @@ -581,85 +650,94 @@ function ControlledAgentControls({ } return ( - - {!isCompact ? ( - - ) : ( - - )} - + + + {!isCompact ? ( + + ) : ( + + )} + + ); } @@ -694,7 +772,6 @@ interface DesktopAgentControlsContentProps { providerAnchorRef: RefObject; thinkingAnchorRef: RefObject; providerPressableStyle: (state: PressableStateCallbackType) => StyleProp; - thinkingPressableStyle: (state: PressableStateCallbackType) => StyleProp; handleProviderPress: () => void; handleThinkingPress: () => void; handleProviderSelect: (id: string) => void; @@ -703,13 +780,19 @@ interface DesktopAgentControlsContentProps { handleProviderOpenChange: (open: boolean) => void; handleThinkingOpenChange: (open: boolean) => void; handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; + handleNestedOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; renderThinkingOption: (args: { option: ComboboxOption; selected: boolean; active: boolean; onPress: () => void; }) => ReactElement; - extras?: ReactNode; + modeControl?: AgentModeControlValue | null; + presentation: ComposerControlPresentation; + glyphSize: number; + activeSheet: ActiveSheet; + handleOpenSheet: (sheet: Exclude) => void; + handleCloseSheet: () => void; modelSelectorServerId: string | null; } @@ -748,7 +831,6 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { providerAnchorRef, thinkingAnchorRef, providerPressableStyle, - thinkingPressableStyle, handleProviderPress, handleThinkingPress, handleProviderSelect, @@ -757,11 +839,25 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { handleProviderOpenChange, handleThinkingOpenChange, handleOpenChange, + handleNestedOpenChange, renderThinkingOption, - extras, + modeControl, + presentation, + glyphSize, + activeSheet, + handleOpenSheet, + handleCloseSheet, modelSelectorServerId, } = props; - + const modelToolbar = useMemo( + () => ({ glyphSize, showCaret: presentation.showCarets }), + [glyphSize, presentation.showCarets], + ); + const featuresSheetHeader = useMemo( + () => ({ title: t("agentControls.features.title") }), + [t], + ); + const handleOpenFeatures = useCallback(() => handleOpenSheet("features"), [handleOpenSheet]); return ( <> {providerOptions && providerOptions.length > 0 ? ( @@ -794,7 +890,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { {canSelectModel ? ( - + @@ -824,21 +921,22 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { <> - - - {displayThinking} - + /> {t(getAgentControlHintKey("thinking"))} @@ -858,18 +956,53 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { ) : null} - {extras} + {modeControl ? : null} - {features?.map((feature) => ( - - ))} + {presentation.aggregateFeatures && features?.length ? ( + <> + + + + + + + {features.map((feature) => ( + + ))} + + + ) : ( + features?.map((feature) => ( + + )) + )} ); } @@ -894,7 +1027,7 @@ interface SheetAgentControlsContentProps { modelDisabled: boolean; comboboxThinkingOptions: ComboboxOption[]; openSelector: AgentControlSelector | null; - ProviderIcon: ReturnType | null; + displayThinking: string; activeSheet: ActiveSheet; handleOpenSheet: (sheet: Exclude) => void; handleCloseSheet: () => void; @@ -907,11 +1040,12 @@ interface SheetAgentControlsContentProps { active: boolean; onPress: () => void; }) => ReactElement; + modeControl?: AgentModeControlValue | null; + glyphSize: number; modelSelectorServerId: string | null; } function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { - const { theme } = useUnistyles(); const { t } = useTranslation(); const { provider, @@ -933,7 +1067,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { modelDisabled, comboboxThinkingOptions, openSelector, - ProviderIcon, + displayThinking, activeSheet, handleOpenSheet, handleCloseSheet, @@ -941,20 +1075,16 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { handleSelectThinkingAndClose, handleOpenChange, renderThinkingOption, + modeControl, + glyphSize, modelSelectorServerId, } = props; const thinkingAnchorRef = useRef(null); const hasThinking = comboboxThinkingOptions.length > 0; - const hasFeatures = Boolean(features && features.length > 0); - const featuresSheetHeader = useMemo( - () => ({ title: t("agentControls.features.title") }), - [t], - ); const handleOpenThinking = useCallback(() => handleOpenSheet("thinking"), [handleOpenSheet]); - const handleOpenFeatures = useCallback(() => handleOpenSheet("features"), [handleOpenSheet]); const handleThinkingSheetOpenChange = useCallback( (nextOpen: boolean) => { if (nextOpen) { @@ -966,123 +1096,74 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { [handleCloseSheet, handleOpenSheet], ); - const renderModelTrigger = useCallback( - ({ - selectedModelLabel, - }: { - selectedModelLabel: string; - onPress: () => void; - disabled: boolean; - isOpen: boolean; - }) => ( - - {ProviderIcon ? ( - - ) : null} - - {shortModelLabel(selectedModelLabel)} - - - ), - [ProviderIcon, theme.iconSize.lg, theme.colors.foregroundMuted], - ); - - const thinkingButtonStyle = makeBadgePressableStyle( - styles.modeIconBadge, - styles.disabledBadge, - disabled || !canSelectThinking, - activeSheet === "thinking", - ); - const featuresButtonStyle = makeBadgePressableStyle( - styles.modeIconBadge, - styles.disabledBadge, - disabled, - activeSheet === "features", - ); - - return ( - <> - {canSelectModel ? ( - - ) : null} - + const sheetControls = ( + {hasThinking ? ( - - - - ) : null} - - {hasFeatures ? ( - - - - ) : null} - - {hasThinking ? ( - - ) : null} - - - {(features ?? []).map((feature) => ( - + - ))} - - + + + ) : null} + + {modeControl ? : null} + + {(features ?? []).map((feature) => ( + + ))} + ); + + return canSelectModel ? ( + + {sheetControls} + + ) : null; } function DesktopFeatureItem({ @@ -1091,26 +1172,34 @@ function DesktopFeatureItem({ openSelector, handleOpenChange, onSetFeature, + onActionComplete, }: { feature: AgentFeature; disabled: boolean; openSelector: AgentControlSelector | null; handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; onSetFeature?: (featureId: string, value: unknown) => void; + onActionComplete?: () => void; }) { const { theme } = useUnistyles(); const featureSelector: AgentControlSelector = `feature-${feature.id}`; + const featureAnchorRef = useRef(null); const handleFeatureOpenChange = useMemo( () => handleOpenChange(featureSelector), [handleOpenChange, featureSelector], ); + const handleSelectPress = useCallback( + () => handleFeatureOpenChange(openSelector !== featureSelector), + [featureSelector, handleFeatureOpenChange, openSelector], + ); const handleTogglePress = useCallback(() => { if (feature.type === "toggle") { onSetFeature?.(feature.id, !feature.value); + onActionComplete?.(); } - }, [feature, onSetFeature]); + }, [feature, onActionComplete, onSetFeature]); const handleSelectOption = useCallback( (optionId: string) => { @@ -1118,25 +1207,12 @@ function DesktopFeatureItem({ }, [feature.id, onSetFeature], ); - - const togglePressableStyle = useCallback( - ({ pressed, hovered }: PressableStateCallbackType) => [ - styles.modeIconBadge, - hovered && styles.modeBadgeHovered, - pressed && styles.modeBadgePressed, - disabled && styles.disabledBadge, - ], - [disabled], - ); - - const selectPressableStyle = useCallback( - ({ pressed, hovered }: PressableStateCallbackType) => [ - styles.modeBadge, - hovered && styles.modeBadgeHovered, - (pressed || openSelector === featureSelector) && styles.modeBadgePressed, - disabled && styles.disabledBadge, - ], - [disabled, openSelector, featureSelector], + const comboboxOptions = useMemo( + () => + feature.type === "select" + ? feature.options.map((option) => ({ id: option.id, label: option.label })) + : [], + [feature], ); if (feature.type === "toggle") { @@ -1144,24 +1220,22 @@ function DesktopFeatureItem({ return ( - - - + /> {getFeatureTooltip(feature)} @@ -1174,35 +1248,36 @@ function DesktopFeatureItem({ const FeatureIcon = getFeatureIcon(feature.icon); const selectedOption = feature.options.find((o) => o.id === feature.value); return ( - + <> - - - {selectedOption?.label ?? feature.label} - + /> {getFeatureTooltip(feature)} - - {feature.options.map((option) => ( - - ))} - - + + ); } @@ -1225,11 +1300,17 @@ function SheetFeatureItem({ const { theme } = useUnistyles(); const { t } = useTranslation(); const featureSelector: AgentControlSelector = `feature-${feature.id}`; + const featureAnchorRef = useRef(null); const handleFeatureOpenChange = useMemo( () => handleOpenChange(featureSelector), [handleOpenChange, featureSelector], ); + const handleSelectPress = useCallback( + () => handleFeatureOpenChange(openSelector !== featureSelector), + [featureSelector, handleFeatureOpenChange, openSelector], + ); + const sheetHeader = useMemo(() => ({ title: feature.label }), [feature.label]); const handleTogglePress = useCallback(() => { if (feature.type === "toggle") { @@ -1243,101 +1324,70 @@ function SheetFeatureItem({ }, [feature.id, onSetFeature], ); - - const togglePressableStyle = useCallback( - ({ pressed }: PressableStateCallbackType) => [ - styles.sheetSelect, - pressed && styles.sheetSelectPressed, - disabled && styles.disabledSheetSelect, - ], - [disabled], + const comboboxOptions = useMemo( + () => + feature.type === "select" + ? feature.options.map((option) => ({ id: option.id, label: option.label })) + : [], + [feature], ); if (feature.type === "toggle") { const FeatureIcon = getFeatureIcon(feature.icon); return ( - - - - {feature.label} - - {feature.value ? t("agentControls.features.on") : t("agentControls.features.off")} - - - + ); } if (feature.type === "select") { + const FeatureIcon = getFeatureIcon(feature.icon); const selectedOption = feature.options.find((o) => o.id === feature.value); return ( - - + + - - {selectedOption?.label ?? feature.label} - - - {feature.options.map((option) => ( - - ))} - - - + anchorRef={featureAnchorRef} + presentation="push" + header={sheetHeader} + /> + ); } return null; } -function FeatureOptionMenuItem({ - option, - selected, - onSelect, -}: { - option: { id: string; label: string }; - selected: boolean; - onSelect: (optionId: string) => void; -}) { - const handleSelect = useCallback(() => { - onSelect(option.id); - }, [onSelect, option.id]); - - return ( - - {option.label} - - ); -} - function ThinkingComboboxOption({ option, selected, @@ -1377,6 +1427,7 @@ export const AgentControls = memo(function AgentControls({ ); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const toast = useToast(); + const modeControl = useLiveAgentModeControl(serverId, agentId); const { entries: snapshotEntries, @@ -1565,18 +1616,6 @@ export const AgentControls = memo(function AgentControls({ [refreshSnapshot], ); - const modeChip = useMemo( - () => ( - - ), - [serverId, agentId, isCompactLayout], - ); - if (!agent) { return null; } @@ -1601,7 +1640,7 @@ export const AgentControls = memo(function AgentControls({ isRetryingModelProvider={snapshotIsRefreshing} onDropdownClose={onDropdownClose} disabled={!client} - desktopExtras={modeChip} + modeControl={modeControl} modelSelectorServerId={serverId} isCompactLayout={isCompactLayout} /> @@ -1636,9 +1675,6 @@ export function DraftAgentControls({ isCompactLayout, }: DraftAgentControlsProps) { const { preferences, updatePreferences } = useFormPreferences(); - const isCompactFormFactor = useIsCompactFormFactor(); - const isCompact = isCompactLayout ?? isCompactFormFactor; - const mappedThinkingOptions = useMemo(() => { return toThinkingControlOptions(thinkingOptions); }, [thinkingOptions]); @@ -1673,70 +1709,21 @@ export function DraftAgentControls({ [updatePreferences], ); - const draftModeChip = useMemo( - () => ( - - ), - [ - selectedProvider, - providerDefinitions, - modeOptions, - selectedMode, - onSelectMode, - disabled, - isCompactLayout, - ], + const modeControl = useMemo( + () => + selectedProvider && modeOptions.length > 0 + ? { + provider: selectedProvider, + providerDefinitions, + modeOptions, + selectedModeId: selectedMode, + onSelectMode, + disabled, + } + : null, + [selectedProvider, providerDefinitions, modeOptions, selectedMode, onSelectMode, disabled], ); - if (!isCompact) { - return ( - - - {selectedProvider ? ( - 0 ? mappedThinkingOptions : undefined} - selectedThinkingOptionId={effectiveSelectedThinkingOption} - onSelectThinkingOption={onSelectThinkingOption} - features={features} - onSetFeature={onSetFeature} - onDropdownClose={onDropdownClose} - onRetryModelProvider={onRetryModelProvider} - isRetryingModelProvider={isRetryingModelProvider} - disabled={disabled} - desktopExtras={draftModeChip} - isCompactLayout={isCompactLayout} - /> - ) : null} - - ); - } - return ( @@ -1765,9 +1754,13 @@ export function DraftAgentControls({ const styles = StyleSheet.create((theme) => ({ container: { + minWidth: 0, + flexGrow: 1, + flexShrink: 1, flexDirection: "row", - alignItems: "flex-end", + alignItems: "center", gap: theme.spacing[1], + overflow: "hidden", }, modeBadge: { height: 28, @@ -1778,11 +1771,22 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius["2xl"], }, + modelControl: { + minWidth: 0, + flexShrink: 1, + }, + toolbarCaret: { + width: 14, + height: 14, + flexShrink: 0, + }, modeIconBadge: { width: 28, height: 28, alignItems: "center", justifyContent: "center", + paddingHorizontal: 0, + flexShrink: 0, backgroundColor: "transparent", borderRadius: theme.borderRadius.full, }, @@ -1796,6 +1800,8 @@ const styles = StyleSheet.create((theme) => ({ opacity: 0.5, }, modeBadgeText: { + minWidth: 0, + flexShrink: 1, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, @@ -1805,47 +1811,7 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, lineHeight: theme.fontSize.sm * 1.4, }, - prefsButton: { - height: 28, - minWidth: 0, - flexShrink: 1, - flexDirection: "row", - alignItems: "center", + combinedSheetControls: { gap: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius["2xl"], - }, - prefsButtonText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, - flexShrink: 1, - }, - sheetSection: { - gap: theme.spacing[2], - }, - sheetSelect: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - gap: theme.spacing[3], - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.surface2, - backgroundColor: theme.colors.surface0, - }, - sheetSelectPressed: { - backgroundColor: theme.colors.surface2, - }, - disabledSheetSelect: { - opacity: 0.5, - }, - sheetSelectText: { - flex: 1, - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.semibold, }, })); diff --git a/packages/app/src/composer/agent-controls/layout-context.tsx b/packages/app/src/composer/agent-controls/layout-context.tsx new file mode 100644 index 000000000..edf8fec7a --- /dev/null +++ b/packages/app/src/composer/agent-controls/layout-context.tsx @@ -0,0 +1,37 @@ +import { createContext, useContext, type ReactNode } from "react"; +import type { ComposerControlPresentation } from "@/composer/agent-controls/layout"; + +interface ComposerControlLayoutValue { + glyphSize: number; + presentation: ComposerControlPresentation; +} + +const DEFAULT_LAYOUT: ComposerControlLayoutValue = { + glyphSize: 16, + presentation: { + showCarets: true, + showThinkingLabel: true, + showModeLabel: true, + aggregateFeatures: false, + }, +}; + +const ComposerControlLayoutContext = createContext(DEFAULT_LAYOUT); + +export function ComposerControlLayoutProvider({ + value, + children, +}: { + value: ComposerControlLayoutValue; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useComposerControlLayout(): ComposerControlLayoutValue { + return useContext(ComposerControlLayoutContext); +} diff --git a/packages/app/src/composer/agent-controls/layout.test.ts b/packages/app/src/composer/agent-controls/layout.test.ts new file mode 100644 index 000000000..2e52972b1 --- /dev/null +++ b/packages/app/src/composer/agent-controls/layout.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { + COMPOSER_TOOLBAR_GEOMETRY, + resolveComposerControlDensity, + resolveComposerControlPresentation, + resolveComposerToolbarGlyphSize, +} from "./layout"; + +describe("composer control layout", () => { + it("removes labels in priority order as the toolbar narrows", () => { + expect(resolveComposerControlPresentation("full")).toEqual({ + showCarets: true, + showThinkingLabel: true, + showModeLabel: true, + aggregateFeatures: false, + }); + expect(resolveComposerControlPresentation("condensed")).toEqual({ + showCarets: false, + showThinkingLabel: false, + showModeLabel: true, + aggregateFeatures: true, + }); + expect(resolveComposerControlPresentation("tight")).toEqual({ + showCarets: false, + showThinkingLabel: false, + showModeLabel: false, + aggregateFeatures: true, + }); + }); + + it("uses local available width and hysteresis to avoid density churn", () => { + const controls = { + hasModel: true, + hasThinking: true, + hasMode: true, + features: [{ type: "toggle" as const }], + fontScale: 1, + }; + + expect( + resolveComposerControlDensity({ + availableWidth: 420, + currentDensity: "full", + controls, + }), + ).toBe("full"); + expect( + resolveComposerControlDensity({ + availableWidth: 380, + currentDensity: "full", + controls, + }), + ).toBe("condensed"); + expect( + resolveComposerControlDensity({ + availableWidth: 290, + currentDensity: "condensed", + controls, + }), + ).toBe("condensed"); + expect( + resolveComposerControlDensity({ + availableWidth: 280, + currentDensity: "condensed", + controls, + }), + ).toBe("tight"); + expect( + resolveComposerControlDensity({ + availableWidth: 300, + currentDensity: "tight", + controls, + }), + ).toBe("tight"); + expect( + resolveComposerControlDensity({ + availableWidth: 312, + currentDensity: "tight", + controls, + }), + ).toBe("condensed"); + }); + + it("budgets extra features and larger text before restoring full labels", () => { + const base = { + availableWidth: 430, + currentDensity: "condensed" as const, + }; + + expect( + resolveComposerControlDensity({ + ...base, + controls: { + hasModel: true, + hasThinking: true, + hasMode: true, + features: [{ type: "toggle" }], + fontScale: 1, + }, + }), + ).toBe("full"); + expect( + resolveComposerControlDensity({ + ...base, + controls: { + hasModel: true, + hasThinking: true, + hasMode: true, + features: [{ type: "toggle" }, { type: "select", label: "Tools" }], + fontScale: 1, + }, + }), + ).toBe("condensed"); + expect( + resolveComposerControlDensity({ + ...base, + controls: { + hasModel: true, + hasThinking: true, + hasMode: true, + features: [{ type: "toggle" }], + fontScale: 1.25, + }, + }), + ).toBe("condensed"); + }); + + it("condenses before a labeled feature would overflow", () => { + const base = { + availableWidth: 430, + currentDensity: "full" as const, + controls: { + hasModel: true, + hasThinking: true, + hasMode: true, + fontScale: 1, + }, + }; + + expect( + resolveComposerControlDensity({ + ...base, + controls: { ...base.controls, features: [{ type: "toggle" }] }, + }), + ).toBe("full"); + expect( + resolveComposerControlDensity({ + ...base, + controls: { + ...base.controls, + features: [{ type: "select", label: "A much longer localized feature label" }], + }, + }), + ).toBe("condensed"); + }); + + it("gives every toolbar control one shell and one platform glyph envelope", () => { + expect(COMPOSER_TOOLBAR_GEOMETRY).toEqual({ + controlSize: 28, + controlGap: 4, + iconLabelGap: 4, + labelPadding: 8, + caretSize: 14, + }); + expect(resolveComposerToolbarGlyphSize("web")).toBe(16); + expect(resolveComposerToolbarGlyphSize("native")).toBe(20); + }); +}); diff --git a/packages/app/src/composer/agent-controls/layout.ts b/packages/app/src/composer/agent-controls/layout.ts new file mode 100644 index 000000000..6f7c78cc7 --- /dev/null +++ b/packages/app/src/composer/agent-controls/layout.ts @@ -0,0 +1,134 @@ +export type ComposerControlDensity = "full" | "condensed" | "tight"; + +export interface ComposerControlPresence { + hasModel: boolean; + hasThinking: boolean; + hasMode: boolean; + features: readonly ComposerFeatureControlPresence[]; + fontScale: number; +} + +export type ComposerFeatureControlPresence = { type: "toggle" } | { type: "select"; label: string }; + +export interface ComposerControlPresentation { + showCarets: boolean; + showThinkingLabel: boolean; + showModeLabel: boolean; + aggregateFeatures: boolean; +} + +export const COMPOSER_TOOLBAR_GEOMETRY = { + controlSize: 28, + controlGap: 4, + iconLabelGap: 4, + labelPadding: 8, + caretSize: 14, +} as const; + +const DENSITY_HYSTERESIS = 12; + +function normalizedFontScale(fontScale: number): number { + return Number.isFinite(fontScale) ? Math.max(1, fontScale) : 1; +} + +function sumControlWidths(widths: number[]): number { + if (widths.length === 0) return 0; + return ( + widths.reduce((total, width) => total + width, 0) + + (widths.length - 1) * COMPOSER_TOOLBAR_GEOMETRY.controlGap + ); +} + +function estimateLabelWidth(label: string, fontScale: number): number { + return Array.from(label).length * 7 * fontScale; +} + +function resolveFeatureControlWidth( + feature: ComposerFeatureControlPresence, + fontScale: number, +): number { + if (feature.type === "toggle") return COMPOSER_TOOLBAR_GEOMETRY.controlSize; + return ( + COMPOSER_TOOLBAR_GEOMETRY.controlSize + + COMPOSER_TOOLBAR_GEOMETRY.iconLabelGap + + COMPOSER_TOOLBAR_GEOMETRY.labelPadding * 2 + + estimateLabelWidth(feature.label, fontScale) + ); +} + +function resolveCondensedFloor(controls: ComposerControlPresence): number { + const fontScale = normalizedFontScale(controls.fontScale); + const widths: number[] = []; + if (controls.hasModel) widths.push(36 + 60 * fontScale); + if (controls.hasThinking) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize); + if (controls.hasMode) widths.push(36 + 96 * fontScale); + if (controls.features.length > 0) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize); + return sumControlWidths(widths); +} + +function resolveFullFloor(controls: ComposerControlPresence): number { + const fontScale = normalizedFontScale(controls.fontScale); + const widths: number[] = []; + if (controls.hasModel) widths.push(50 + 70 * fontScale); + if (controls.hasThinking) widths.push(54 + 48 * fontScale); + if (controls.hasMode) widths.push(54 + 96 * fontScale); + for (const feature of controls.features) { + widths.push(resolveFeatureControlWidth(feature, fontScale)); + } + return sumControlWidths(widths); +} + +export function resolveComposerControlDensity(input: { + availableWidth: number; + currentDensity: ComposerControlDensity; + controls: ComposerControlPresence; +}): ComposerControlDensity { + const fullFloor = resolveFullFloor(input.controls); + const condensedFloor = resolveCondensedFloor(input.controls); + + if (input.currentDensity === "full") { + if (input.availableWidth >= fullFloor - DENSITY_HYSTERESIS) return "full"; + return input.availableWidth >= condensedFloor ? "condensed" : "tight"; + } + + if (input.currentDensity === "condensed") { + if (input.availableWidth >= fullFloor + DENSITY_HYSTERESIS) return "full"; + if (input.availableWidth < condensedFloor - DENSITY_HYSTERESIS) return "tight"; + return "condensed"; + } + + if (input.availableWidth >= fullFloor + DENSITY_HYSTERESIS) return "full"; + if (input.availableWidth >= condensedFloor + DENSITY_HYSTERESIS) return "condensed"; + return "tight"; +} + +export function resolveComposerControlPresentation( + density: ComposerControlDensity, +): ComposerControlPresentation { + if (density === "full") { + return { + showCarets: true, + showThinkingLabel: true, + showModeLabel: true, + aggregateFeatures: false, + }; + } + if (density === "condensed") { + return { + showCarets: false, + showThinkingLabel: false, + showModeLabel: true, + aggregateFeatures: true, + }; + } + return { + showCarets: false, + showThinkingLabel: false, + showModeLabel: false, + aggregateFeatures: true, + }; +} + +export function resolveComposerToolbarGlyphSize(platform: "web" | "native"): number { + return platform === "native" ? 20 : 16; +} diff --git a/packages/app/src/composer/agent-controls/mode-control.tsx b/packages/app/src/composer/agent-controls/mode-control.tsx index e129b1e53..d80e289f8 100644 --- a/packages/app/src/composer/agent-controls/mode-control.tsx +++ b/packages/app/src/composer/agent-controls/mode-control.tsx @@ -1,5 +1,4 @@ import { - memo, useCallback, useMemo, useRef, @@ -8,7 +7,7 @@ import { type ReactElement, } from "react"; import { useTranslation } from "react-i18next"; -import { Text, View, type PressableStateCallbackType } from "react-native"; +import { Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; @@ -22,7 +21,6 @@ import { ShieldPlus, ShieldQuestionMark, } from "lucide-react-native"; -import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -32,7 +30,6 @@ import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences"; import { resolveProviderDefinition } from "@/utils/provider-definitions"; import { useToast } from "@/contexts/toast-context"; -import { useIsCompactFormFactor } from "@/constants/layout"; import { toErrorMessage } from "@/utils/error-messages"; import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; import { formatAgentModeLabel, getAgentControlHintKey } from "@/composer/agent-controls/utils"; @@ -41,15 +38,11 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import { resolveNextAgentModeId } from "@/composer/agent-controls/mode"; import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; -import type { AgentMode, AgentProvider } from "@getpaseo/protocol/agent-types"; +import { useComposerControlLayout } from "@/composer/agent-controls/layout-context"; +import { AgentControlTrigger } from "@/composer/agent-controls/control"; +import type { AgentMode } from "@getpaseo/protocol/agent-types"; import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; -export type AgentModeControlPlacement = "toolbar" | "footer"; - -function shouldRenderForPlacement(placement: AgentModeControlPlacement, isCompact: boolean) { - return placement === "footer" ? isCompact : !isCompact; -} - interface ModeIconProps { size?: number; color?: string; @@ -102,7 +95,7 @@ function ModeComboboxOption({ ); } -interface AgentModeControlViewProps { +export interface AgentModeControlValue { provider: string; providerDefinitions: AgentProviderDefinition[]; modeOptions: AgentMode[]; @@ -115,20 +108,24 @@ function normalizeSearchQuery(value: string): string { return value.trim().toLowerCase(); } -function AgentModeControlView({ +export function AgentModeControl({ provider, providerDefinitions, modeOptions, selectedModeId, onSelectMode, disabled = false, -}: AgentModeControlViewProps) { + surface = "toolbar", + onClose, +}: AgentModeControlValue & { surface?: "toolbar" | "sheet"; onClose?: () => void }) { const { theme } = useUnistyles(); + const { presentation } = useComposerControlLayout(); const { t } = useTranslation(); const { isActiveComposer } = useComposerKeyboardScope(); const cycleShortcutKeys = useShortcutKeys("cycle-agent-mode"); const anchorRef = useRef(null); const keyboardHandlerIdRef = useRef(`mode-control:${Math.random().toString(36).slice(2)}`); + const openRef = useRef(false); const [open, setOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -140,7 +137,7 @@ function AgentModeControlView({ const visuals = selectedMode ? getModeVisuals(provider, selectedMode.id, providerDefinitions) : undefined; - const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : undefined; + const Icon = visuals?.icon ? (MODE_ICONS[visuals.icon] ?? Bot) : Bot; const iconColor = theme.colors.foregroundMuted; const selectedModeLabel = selectedMode ? formatAgentModeLabel(selectedMode) : ""; @@ -154,10 +151,18 @@ function AgentModeControlView({ return allOptions.filter((o) => o.label.toLowerCase().includes(q)); }, [allOptions, searchQuery]); - const handleOpenChange = useCallback((next: boolean) => { - setOpen(next); - if (!next) setSearchQuery(""); - }, []); + const handleOpenChange = useCallback( + (next: boolean) => { + const wasOpen = openRef.current; + openRef.current = next; + setOpen(next); + if (!next) { + setSearchQuery(""); + if (wasOpen) onClose?.(); + } + }, + [onClose], + ); const handlePress = useCallback(() => handleOpenChange(!open), [handleOpenChange, open]); const handleSelect = useCallback( @@ -208,18 +213,6 @@ function AgentModeControlView({ [provider, providerDefinitions, theme.colors.foreground], ); - const pressableStyle = useCallback( - ({ pressed, hovered }: PressableStateCallbackType) => [ - styles.chip, - hovered && styles.chipHovered, - (pressed || open) && styles.chipPressed, - disabled && styles.chipDisabled, - ], - [open, disabled], - ); - - const labelStyle = styles.chipLabel; - const sheetHeader = useMemo( () => ({ title: t("agentControls.mode.title"), @@ -238,21 +231,23 @@ function AgentModeControlView({ <> - - {Icon ? : null} - {selectedModeLabel} - + /> @@ -282,21 +277,10 @@ function compareAvailableModes(a: AgentMode[], b: AgentMode[]): boolean { return a === b || JSON.stringify(a) === JSON.stringify(b); } -interface AgentModeControlProps { - serverId: string; - agentId: string; - placement: AgentModeControlPlacement; - isCompactLayout?: boolean; -} - -export const AgentModeControl = memo(function AgentModeControl({ - serverId, - agentId, - placement, - isCompactLayout, -}: AgentModeControlProps) { - const isCompactFormFactor = useIsCompactFormFactor(); - const isCompact = isCompactLayout ?? isCompactFormFactor; +export function useLiveAgentModeControl( + serverId: string, + agentId: string, +): AgentModeControlValue | null { const slice = useSessionStore( useShallow((state) => { const agent = state.sessions[serverId]?.agents?.get(agentId); @@ -349,82 +333,20 @@ export const AgentModeControl = memo(function AgentModeControl({ [agentId, client, slice?.provider, toast, updatePreferences], ); - if (!slice || availableModes.length === 0) return null; - if (!shouldRenderForPlacement(placement, isCompact)) return null; - - return ( - - ); -}); - -export interface DraftAgentModeControlProps { - selectedProvider: AgentProvider | null; - providerDefinitions: AgentProviderDefinition[]; - modeOptions: AgentMode[]; - selectedMode: string; - onSelectMode: (modeId: string) => void; - disabled?: boolean; - placement: AgentModeControlPlacement; - isCompactLayout?: boolean; -} - -export function DraftAgentModeControl({ - selectedProvider, - providerDefinitions, - modeOptions, - selectedMode, - onSelectMode, - disabled, - placement, - isCompactLayout, -}: DraftAgentModeControlProps) { - const isCompactFormFactor = useIsCompactFormFactor(); - const isCompact = isCompactLayout ?? isCompactFormFactor; - if (!selectedProvider || modeOptions.length === 0) return null; - if (!shouldRenderForPlacement(placement, isCompact)) return null; - return ( - - ); + return useMemo(() => { + if (!slice || availableModes.length === 0) return null; + return { + provider: slice.provider, + providerDefinitions, + modeOptions: availableModes, + selectedModeId: slice.currentModeId, + onSelectMode: handleSelectMode, + disabled: !client, + }; + }, [availableModes, client, handleSelectMode, providerDefinitions, slice]); } const styles = StyleSheet.create((theme) => ({ - chip: { - height: 28, - flexDirection: "row", - alignItems: "center", - backgroundColor: "transparent", - gap: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius["2xl"], - }, - chipHovered: { - backgroundColor: theme.colors.surface2, - }, - chipPressed: { - backgroundColor: theme.colors.surface0, - }, - chipDisabled: { - opacity: 0.5, - }, - chipLabel: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, - }, tooltipRow: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/composer/agent-controls/model-sheet.tsx b/packages/app/src/composer/agent-controls/model-sheet.tsx new file mode 100644 index 000000000..b3ba7f1ee --- /dev/null +++ b/packages/app/src/composer/agent-controls/model-sheet.tsx @@ -0,0 +1,275 @@ +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 type { AgentProvider } from "@getpaseo/protocol/agent-types"; +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 { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph"; +import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; +import { useIsCompactFormFactor } from "@/constants/layout"; + +const SNAP_POINTS = ["80%", "90%"]; +const MODEL_LIST_TOP_INSET = 4; +const MODEL_ROW_STRIDE = 44; +const MODEL_VIEWPORT_VISIBLE_ROWS = 4.5; +const FIXED_MODEL_VIEWPORT_HEIGHT = + MODEL_LIST_TOP_INSET + MODEL_ROW_STRIDE * MODEL_VIEWPORT_VISIBLE_ROWS; + +interface CompactModelSheetProps { + providers: ProviderSelectorProvider[]; + selectedProvider: string; + selectedModel: string; + onSelect: (provider: string, modelId: string) => void; + isLoading: boolean; + favoriteKeys: Set; + onToggleFavorite?: (provider: string, modelId: string) => void; + onOpen?: () => void; + onClose?: () => void; + onRetryProvider?: (provider: AgentProvider) => void; + isRetryingProvider?: boolean; + disabled?: boolean; + serverId?: string | null; + glyphSize: number; + children: ReactNode; +} + +function shortModelLabel(label: string): string { + const separatorIndex = label.lastIndexOf("/"); + return separatorIndex === -1 ? label : label.slice(separatorIndex + 1); +} + +export function CompactModelSheet({ + providers, + selectedProvider, + selectedModel, + onSelect, + isLoading, + favoriteKeys, + onToggleFavorite, + onOpen, + onClose, + onRetryProvider, + isRetryingProvider = false, + disabled = false, + serverId = null, + glyphSize, + children, +}: CompactModelSheetProps) { + const { t } = useTranslation(); + const usesBottomSheet = useIsCompactFormFactor(); + const [isOpen, setIsOpen] = useState(false); + const browser = useModelBrowser({ + providers, + selectedProvider, + selectedModel, + isLoading, + favoriteKeys, + serverId, + }); + const { prepareToOpen, reset } = browser; + const ProviderIcon = + selectedProvider.trim().length > 0 ? getProviderIcon(selectedProvider) : null; + const compactFooter = useMemo( + () => + usesBottomSheet ? ( + + + {children} + + ) : undefined, + [children, usesBottomSheet], + ); + + const open = useCallback(() => { + Keyboard.dismiss(); + prepareToOpen(); + setIsOpen(true); + onOpen?.(); + }, [onOpen, prepareToOpen]); + + const close = useCallback(() => { + setIsOpen(false); + reset(); + onClose?.(); + }, [onClose, reset]); + + const handleSelect = useCallback( + (provider: string, modelId: string) => { + onSelect(provider, modelId); + close(); + }, + [close, onSelect], + ); + + const toggle = useCallback(() => { + if (isOpen) { + close(); + return; + } + open(); + }, [close, isOpen, open]); + + const triggerStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType) => [ + styles.trigger, + hovered && styles.triggerHovered, + (pressed || isOpen) && styles.triggerPressed, + disabled && styles.triggerDisabled, + ], + [disabled, isOpen], + ); + + return ( + <> + + {ProviderIcon ? ( + + + + ) : null} + + {shortModelLabel(browser.triggerLabel)} + + + + + + + + {!usesBottomSheet ? ( + <> + + + {children} + + + ) : null} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + trigger: { + height: 28, + minWidth: 0, + flexShrink: 1, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius["2xl"], + backgroundColor: "transparent", + }, + triggerHovered: { + backgroundColor: theme.colors.surface2, + }, + triggerPressed: { + backgroundColor: theme.colors.surface0, + }, + triggerDisabled: { + opacity: 0.5, + }, + triggerText: { + minWidth: 0, + flexShrink: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + }, + providerIcon: { + color: theme.colors.foregroundMuted, + }, + sheetBody: { + paddingHorizontal: 0, + paddingTop: 0, + paddingBottom: 0, + gap: 0, + }, + modelViewport: { + overflow: "hidden", + backgroundColor: theme.colors.surfaceSidebar, + }, + flexibleModelViewport: { + flex: 1, + minHeight: 0, + }, + fixedModelViewport: { + height: FIXED_MODEL_VIEWPORT_HEIGHT, + minHeight: FIXED_MODEL_VIEWPORT_HEIGHT, + }, + modelViewportDivider: { + height: 1, + flexShrink: 0, + backgroundColor: theme.colors.border, + }, + controlsScroll: { + 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, + }, + controlsContent: { + paddingHorizontal: theme.spacing[2], + paddingTop: theme.spacing[3], + paddingBottom: theme.spacing[3], + gap: theme.spacing[1], + }, +})); diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 975b51dde..490b0a495 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -9,7 +9,6 @@ import { useContainerWidthBelow } from "@/hooks/use-container-width"; import invariant from "tiny-invariant"; import { Composer } from "@/composer"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; -import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control"; import { ComposerImportPill } from "@/composer/draft/import-pill"; import { AgentStreamView } from "@/agent-stream/view"; import { composerWorkspaceAttachment } from "@/composer/attachments/workspace"; @@ -609,57 +608,6 @@ export function WorkspaceDraftAgentTab({ focusInputRef.current = focus; }, []); - const handleProviderSelectWithFocus = useCallback( - (provider: Parameters[0]) => { - composerState.setProviderFromUser(provider); - focusInputRef.current?.(); - }, - [composerState], - ); - - const handleModeSelectWithFocus = useCallback( - (modeId: string) => { - composerState.setModeFromUser(modeId); - focusInputRef.current?.(); - }, - [composerState], - ); - - const handleModelSelectWithFocus = useCallback( - (modelId: string) => { - composerState.setModelFromUser(modelId); - focusInputRef.current?.(); - }, - [composerState], - ); - - const handleProviderAndModelSelectWithFocus = useCallback( - ( - provider: Parameters[0], - modelId: string, - ) => { - composerState.setProviderAndModelFromUser(provider, modelId); - focusInputRef.current?.(); - }, - [composerState], - ); - - const handleThinkingOptionSelectWithFocus = useCallback( - (optionId: string) => { - composerState.setThinkingOptionFromUser(optionId); - focusInputRef.current?.(); - }, - [composerState], - ); - - const handleSetFeatureWithFocus = useCallback( - (featureId: string, value: unknown) => { - composerState.agentControls.onSetFeature?.(featureId, value); - focusInputRef.current?.(); - }, - [composerState], - ); - const { style: composerKeyboardStyle } = useKeyboardShiftStyle({ mode: "translate", }); @@ -680,39 +628,11 @@ export function WorkspaceDraftAgentTab({ const composerAgentControls = useMemo( () => ({ ...composerState.agentControls, - onSelectProvider: handleProviderSelectWithFocus, - onSelectMode: handleModeSelectWithFocus, - onSelectModel: handleModelSelectWithFocus, - onSelectProviderAndModel: handleProviderAndModelSelectWithFocus, - onSelectThinkingOption: handleThinkingOptionSelectWithFocus, - onSetFeature: handleSetFeatureWithFocus, onDropdownClose: handleDropdownCloseFocus, disabled: isSubmitting, }), - [ - composerState.agentControls, - handleProviderSelectWithFocus, - handleModeSelectWithFocus, - handleModelSelectWithFocus, - handleProviderAndModelSelectWithFocus, - handleThinkingOptionSelectWithFocus, - handleSetFeatureWithFocus, - handleDropdownCloseFocus, - isSubmitting, - ], + [composerState.agentControls, handleDropdownCloseFocus, isSubmitting], ); - const composerFooter = useMemo( - () => - isCompactComposerLayout ? ( - - ) : undefined, - [isCompactComposerLayout, composerAgentControls], - ); - return ( @@ -770,7 +690,6 @@ export function WorkspaceDraftAgentTab({ onFocusInput={handleFocusInputCallback} commandDraftConfig={composerState.commandDraftConfig} agentControls={composerAgentControls} - footer={composerFooter} isCompactLayout={isCompactComposerLayout} /> diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index a920cbc99..c7e9cc38a 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -232,6 +232,7 @@ function renderContextWindowMeter( serverId: string, provider: string | null, pending: boolean, + glyphSize: number, ): ReactElement | null { const hasData = contextWindowMaxTokens !== null && contextWindowUsedTokens !== null; if (!hasData && !pending) { @@ -246,21 +247,16 @@ function renderContextWindowMeter( serverId={serverId} provider={provider} pending={pending} + glyphSize={glyphSize} /> ); } function resolveContextWindowPlacement( meter: ReactElement | null, - isMobile: boolean, -): { beforeVoiceContent: ReactNode; footerInlineContent: ReactNode } { - if (isMobile) { - return { beforeVoiceContent: null, footerInlineContent: meter }; - } - return { - beforeVoiceContent: {meter}, - footerInlineContent: null, - }; + reserveSlot: boolean, +): ReactNode { + return reserveSlot ? {meter} : null; } interface RenderLeftContentArgs { @@ -302,23 +298,6 @@ interface RenderAttachmentTrayArgs { }; } -function renderComposerFooter( - footer: ReactNode, - footerInlineContent: ReactNode, -): ReactElement | null { - if (!footer && !footerInlineContent) return null; - return ( - - - - {footer} - {footerInlineContent} - - - - ); -} - function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null { const { selectedAttachments, @@ -861,8 +840,6 @@ interface ComposerProps { agentControls?: DraftAgentControlsProps; /** Extra styles merged onto the message input wrapper (e.g. elevated background). */ inputWrapperStyle?: import("react-native").ViewStyle; - /** Rendered below the input, inside the keyboard-shifted container. */ - footer?: ReactNode; /** When true, a parent wrapper owns the keyboard shift, so the composer skips its own. */ externalKeyboardShift?: boolean; /** Optional panel/container layout breakpoint. Defaults to the screen breakpoint. */ @@ -1072,7 +1049,6 @@ export function Composer({ onAttentionPromptSend, agentControls, inputWrapperStyle, - footer, externalKeyboardShift, isCompactLayout: isCompactLayoutOverride, }: ComposerProps) { @@ -1800,6 +1776,7 @@ export function Composer({ const contextWindowPending = agentState.status === "initializing" || agentState.status === "running"; + const contextWindowMeterGlyphSize = isCompactLayout ? ICON_SIZE.md : buttonIconSize; const contextWindowMeter = useMemo( () => @@ -1807,24 +1784,25 @@ export function Composer({ contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, - isCompactLayout, + false, serverId, agentState.provider, contextWindowPending, + contextWindowMeterGlyphSize, ), [ contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, - isCompactLayout, serverId, agentState.provider, contextWindowPending, + contextWindowMeterGlyphSize, ], ); - const { beforeVoiceContent, footerInlineContent } = useMemo( - () => resolveContextWindowPlacement(contextWindowMeter, isCompactLayout), - [contextWindowMeter, isCompactLayout], + const beforeVoiceContent = useMemo( + () => resolveContextWindowPlacement(contextWindowMeter, hasAgent), + [contextWindowMeter, hasAgent], ); const hasGithubAttachment = useMemo( @@ -2155,7 +2133,6 @@ export function Composer({ - {renderComposerFooter(footer, footerInlineContent)} ); @@ -2191,50 +2168,6 @@ const styles = StyleSheet.create((theme: Theme) => ({ maxWidth: MAX_CONTENT_WIDTH, gap: theme.spacing[3], }, - footer: { - width: "100%", - paddingHorizontal: theme.spacing[4], - // Negative margin pulls the footer up against the input area's paddingBottom. - // On mobile, leave a 3px gap (no token sits below spacing[1]); desktop keeps more. - marginTop: { - xs: -(theme.spacing[4] - 3), - md: -theme.spacing[3], - }, - alignItems: "center", - paddingBottom: { - xs: 0, - md: theme.spacing[2], - }, - }, - footerContent: { - width: "100%", - maxWidth: MAX_CONTENT_WIDTH, - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - // On mobile, the negative margins below cancel each glyph's internal padding - // to reach the composer border; this inset adds a small visual gap from it. - paddingLeft: { - xs: 5, - md: 10, - }, - paddingRight: { - xs: 5, - md: 10, - }, - }, - footerLeft: { - flexShrink: 1, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - // On mobile, cancel the leading glyph's internal padding (chip paddingHorizontal) - // so its icon aligns to the composer border before the footer inset is applied. - marginLeft: { - xs: -theme.spacing[2], - md: 0, - }, - }, messageInputContainer: { position: "relative", width: "100%", @@ -2257,6 +2190,7 @@ const styles = StyleSheet.create((theme: Theme) => ({ contextWindowMeterSlot: { width: 28, height: 28, + flexShrink: 0, alignItems: "center", justifyContent: "center", }, diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index e0819ad7a..14d36f183 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -485,11 +485,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider serverId, bumpHistorySyncGeneration, refreshDirectories: () => getHostRuntimeStore().refreshDirectories(serverId), - }).catch((error) => { - console.error("[SessionProvider] resume revalidation failed", { - serverId, - error: toErrorMessage(error), - }); }); }, [bumpHistorySyncGeneration, serverId], diff --git a/packages/app/src/contexts/session-resume-revalidation.test.ts b/packages/app/src/contexts/session-resume-revalidation.test.ts index ebc90b68a..8f3cc858c 100644 --- a/packages/app/src/contexts/session-resume-revalidation.test.ts +++ b/packages/app/src/contexts/session-resume-revalidation.test.ts @@ -32,4 +32,21 @@ describe("session resume revalidation", () => { expect(revalidated).toBe(false); expect(calls).toEqual([]); }); + + it("defers stale resume revalidation while the host is disconnected", async () => { + const calls: string[] = []; + + const revalidated = await revalidateSessionAfterResume({ + awayMs: SESSION_STALE_AFTER_MS, + serverId: "server", + bumpHistorySyncGeneration: (serverId) => calls.push(`history:${serverId}`), + refreshDirectories: async () => { + calls.push("directories"); + throw new Error("Host server is not connected"); + }, + }); + + expect(revalidated).toBe(false); + expect(calls).toEqual(["history:server", "directories"]); + }); }); diff --git a/packages/app/src/contexts/session-resume-revalidation.ts b/packages/app/src/contexts/session-resume-revalidation.ts index 8360316f5..139bf3e01 100644 --- a/packages/app/src/contexts/session-resume-revalidation.ts +++ b/packages/app/src/contexts/session-resume-revalidation.ts @@ -10,7 +10,11 @@ export async function revalidateSessionAfterResume(input: { return false; } - input.bumpHistorySyncGeneration(input.serverId); - await input.refreshDirectories(); - return true; + try { + input.bumpHistorySyncGeneration(input.serverId); + await input.refreshDirectories(); + return true; + } catch { + return false; + } } diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index e20c6a90b..af650e338 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -308,7 +308,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const modelSelectorProviders = snapshotModelSelectorProviders; const availableModels = snapshotSelectedProviderModels; const modeOptions = snapshotSelectedProviderModes; - const isAllModelsLoading = snapshotIsLoading || selectedProviderIsLoading; + const isModelSelectionLoading = + resolution.status === "pending" || snapshotIsLoading || selectedProviderIsLoading; + const isAllModelsLoading = isModelSelectionLoading; const combinedInitialValues = useMemo( () => combineInitialValues(initialValues, initialServerId), @@ -567,7 +569,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg () => availableThinkingOptionsRaw ?? [], [availableThinkingOptionsRaw], ); - const isModelLoading = snapshotIsLoading || selectedProviderIsLoading; + const isModelLoading = isModelSelectionLoading; const modelError = snapshotError; const workingDirIsEmpty = !formState.workingDir.trim(); diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 2c3dab2d8..508900401 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -24,7 +24,6 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { SidebarCallout } from "@/components/sidebar-callout"; import { Composer } from "@/composer"; -import { AgentModeControl } from "@/composer/agent-controls/mode-control"; import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore"; import { getProviderIcon } from "@/components/provider-icons"; import { @@ -1529,19 +1528,6 @@ function ActiveAgentComposer({ [insets.bottom, composerKeyboardStyle], ); - const composerFooter = useMemo( - () => - isCompactComposerLayout ? ( - - ) : undefined, - [isCompactComposerLayout, serverId, agentId], - ); - return ( diff --git a/packages/app/src/provider-selection/provider-selection.test.ts b/packages/app/src/provider-selection/provider-selection.test.ts index 94417485b..06c76f74b 100644 --- a/packages/app/src/provider-selection/provider-selection.test.ts +++ b/packages/app/src/provider-selection/provider-selection.test.ts @@ -260,6 +260,25 @@ describe("combined model selector data", () => { ).toBe("Default"); }); + it("distinguishes a loading selection from a resolved empty selection", () => { + expect( + resolveSelectedModelLabel({ + providers: [], + selectedProvider: "", + selectedModel: "", + isLoading: true, + }), + ).toBe("Loading..."); + expect( + resolveSelectedModelLabel({ + providers: [], + selectedProvider: "", + selectedModel: "", + isLoading: false, + }), + ).toBe("Select model"); + }); + it("keeps a stored selected model visible when current snapshot rows no longer offer it", () => { const providers = buildSelectableProviderSelectorProviders([ snapshotEntry({ diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts index f6ecb32c9..c100ecc67 100644 --- a/packages/app/src/provider-selection/provider-selection.ts +++ b/packages/app/src/provider-selection/provider-selection.ts @@ -157,7 +157,9 @@ export function resolveSelectedModelLabel(input: { }): string { const selectedProvider = input.selectedProvider.trim(); if (!selectedProvider) { - return i18n.t("providerSelection.selectModel"); + return input.isLoading + ? i18n.t("providerSelection.loading") + : i18n.t("providerSelection.selectModel"); } const provider = input.providers.find((entry) => entry.id === selectedProvider); diff --git a/packages/app/src/provider-selection/resolve-agent-form.test.ts b/packages/app/src/provider-selection/resolve-agent-form.test.ts index a10de7b12..b58ee145b 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.test.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.test.ts @@ -1096,7 +1096,7 @@ describe("resolveAgentForm", () => { }); describe("RESET", () => { - it("resets userModified flags while keeping form state", () => { + it("keeps form values but marks them unresolved for the next open", () => { const state = makeState( { provider: "codex", modeId: "full-access", model: "gpt-5.3-codex" }, { provider: true, modeId: true, model: true }, @@ -1106,7 +1106,7 @@ describe("resolveAgentForm", () => { expect(next.userModified).toEqual(INITIAL_USER_MODIFIED); expect(next.form).toEqual(state.form); - expect(next.resolution.status).toBe("completed"); + expect(next.resolution.status).toBe("pending"); }); }); diff --git a/packages/app/src/provider-selection/resolve-agent-form.ts b/packages/app/src/provider-selection/resolve-agent-form.ts index 56e84f5b1..7c818ee60 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.ts @@ -56,8 +56,8 @@ export const INITIAL_USER_MODIFIED: UserModifiedFields = { workingDir: false, }; -export const INITIAL_AGENT_FORM_RESOLUTION: AgentFormResolutionState = { status: "completed" }; export const PENDING_AGENT_FORM_RESOLUTION: AgentFormResolutionState = { status: "pending" }; +export const INITIAL_AGENT_FORM_RESOLUTION = PENDING_AGENT_FORM_RESOLUTION; type ProviderPrefs = NonNullable[AgentProvider]; diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index e8863d0d2..5c0f60cb8 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -12,7 +12,6 @@ import { useQuery } from "@tanstack/react-query"; import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native"; import { Composer } from "@/composer"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; -import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control"; import { resolveComposerAttachmentSubmitFormat, splitComposerAttachmentsForSubmit, @@ -2109,13 +2108,6 @@ export function NewWorkspaceScreen({ }, }); - const composerFooter = useMemo( - () => - agentControlsWithDisabled ? ( - - ) : null, - [agentControlsWithDisabled], - ); const screenHeaderLeft = useMemo(() => , []); return ( @@ -2154,7 +2146,6 @@ export function NewWorkspaceScreen({ autoFocus commandDraftConfig={composerState?.commandDraftConfig} agentControls={agentControlsWithDisabled} - footer={composerFooter} /> {errorMessage ? {errorMessage} : null}