From 8e063f0dfcf9137a8d9f7a77ceabad1e93adf83d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 23 Jul 2026 18:24:10 +0200 Subject: [PATCH] Fix compact composer controls and native scrolling (#2361) * fix(app): refine compact composer controls and native scrolling Consolidate responsive agent controls and model browsing inside the compact sheet. Preserve manual native scrolling by suspending sticky bottom maintenance while the user owns the viewport. * fix(app): suppress recoverable resume refresh errors Resume revalidation can race host reconnection and reject while the host is offline. Treat the refresh as deferred so development LogBox does not surface a recoverable disconnect. * fix(app): stabilize compact model sheet loading Let the model list consume the sheet space above persistent controls as the sheet expands. Keep unresolved defaults in a loading state so Select model only represents a genuinely empty selection. * fix(app): preserve route anchors during native scroll * fix(app): preserve native scroll intent and sheet dismissal * fix(app): keep compact sheet styles composable --- packages/app/e2e/bottom-sheet-reopen.spec.ts | 12 + .../bottom-anchor-controller.test.ts | 222 +++- .../agent-stream/bottom-anchor-controller.ts | 58 + .../app/src/agent-stream/strategy-native.tsx | 76 +- .../src/components/adaptive-modal-sheet.tsx | 108 +- .../components/combined-model-selector.tsx | 926 ++----------- .../src/components/context-window-meter.tsx | 22 +- .../app/src/components/icons/omp-icon.tsx | 2 +- packages/app/src/components/icons/pi-icon.tsx | 2 +- .../src/components/model-browser-view.test.ts | 49 + .../app/src/components/model-browser-view.ts | 40 + packages/app/src/components/model-browser.tsx | 1169 +++++++++++++++++ packages/app/src/components/ui/combobox.tsx | 95 +- .../src/components/workspace-setup-dialog.tsx | 12 - .../src/composer/agent-controls/control.tsx | 173 +++ .../app/src/composer/agent-controls/glyph.tsx | 32 + .../app/src/composer/agent-controls/index.tsx | 1004 +++++++------- .../agent-controls/layout-context.tsx | 37 + .../composer/agent-controls/layout.test.ts | 168 +++ .../app/src/composer/agent-controls/layout.ts | 134 ++ .../composer/agent-controls/mode-control.tsx | 176 +-- .../composer/agent-controls/model-sheet.tsx | 275 ++++ .../app/src/composer/draft/workspace-tab.tsx | 83 +- packages/app/src/composer/index.tsx | 92 +- packages/app/src/contexts/session-context.tsx | 5 - .../session-resume-revalidation.test.ts | 17 + .../contexts/session-resume-revalidation.ts | 10 +- .../app/src/hooks/use-agent-form-state.ts | 6 +- packages/app/src/panels/agent-panel.tsx | 15 - .../provider-selection.test.ts | 19 + .../provider-selection/provider-selection.ts | 4 +- .../resolve-agent-form.test.ts | 4 +- .../provider-selection/resolve-agent-form.ts | 2 +- .../app/src/screens/new-workspace-screen.tsx | 9 - 34 files changed, 3252 insertions(+), 1806 deletions(-) create mode 100644 packages/app/src/components/model-browser-view.test.ts create mode 100644 packages/app/src/components/model-browser-view.ts create mode 100644 packages/app/src/components/model-browser.tsx create mode 100644 packages/app/src/composer/agent-controls/control.tsx create mode 100644 packages/app/src/composer/agent-controls/glyph.tsx create mode 100644 packages/app/src/composer/agent-controls/layout-context.tsx create mode 100644 packages/app/src/composer/agent-controls/layout.test.ts create mode 100644 packages/app/src/composer/agent-controls/layout.ts create mode 100644 packages/app/src/composer/agent-controls/model-sheet.tsx 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}