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
This commit is contained in:
Mohamed Boudra
2026-07-23 18:24:10 +02:00
committed by GitHub
parent 8cf70d10bf
commit 8e063f0dfc
34 changed files with 3252 additions and 1806 deletions

View File

@@ -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();
});
});
});

View File

@@ -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: {

View File

@@ -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();
},

View File

@@ -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<number | null>(null);
const programmaticScrollEventBudgetRef = useRef(0);
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(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<NativeScrollEvent>) => {
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<NativeScrollEvent>) => {
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<NativeScrollEvent>) => {
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<NativeScrollEvent>) => {
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}

View File

@@ -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 <Animated.View pointerEvents="none" style={combinedStyle} />;
}
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 (
<Animated.View style={[styles.bottomSheetVisibleContent, visibleContentStyle]}>
{children}
</Animated.View>
);
}
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<ViewStyle>;
snapPoints?: string[];
testID?: string;
/** Override the max width of the desktop card. */
desktopMaxWidth?: number;
scrollable?: boolean;
presentation?: "push" | "replace";
contentContainerStyle?: StyleProp<ViewStyle>;
/** 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 = (
<>
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
{scrollable ? (
<BottomSheetScrollView
style={sizeContentToCurrentSnapPoint ? styles.bottomSheetVisibleScroll : undefined}
contentContainerStyle={bottomSheetContentStyle}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</BottomSheetScrollView>
) : (
<View style={bottomSheetStaticContentStyle}>{children}</View>
)}
{footer ? <View style={footerStyle}>{footer}</View> : null}
</>
);
return (
<IsolatedBottomSheetModal
ref={sheetRef}
@@ -616,19 +690,11 @@ export function AdaptiveModalSheet({
accessible={false}
presentation={presentation}
>
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
{scrollable ? (
<BottomSheetScrollView
contentContainerStyle={bottomSheetContentStyle}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</BottomSheetScrollView>
{sizeContentToCurrentSnapPoint ? (
<BottomSheetVisibleContent>{sheetContent}</BottomSheetVisibleContent>
) : (
<View style={bottomSheetStaticContentStyle}>{children}</View>
sheetContent
)}
{footer ? <View style={footerStyle}>{footer}</View> : null}
</IsolatedBottomSheetModal>
);
}
@@ -640,7 +706,7 @@ export function AdaptiveModalSheet({
<View style={styles.desktopScrollContainer}>
<ScrollView
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
contentContainerStyle={[styles.desktopContent, contentContainerStyle]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator
>
@@ -648,7 +714,7 @@ export function AdaptiveModalSheet({
</ScrollView>
</View>
) : (
<View style={styles.desktopStaticContent}>{children}</View>
<View style={[styles.desktopStaticContent, contentContainerStyle]}>{children}</View>
)}
{footer ? <View style={footerStyle}>{footer}</View> : null}
</>

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -7,7 +7,7 @@ interface OmpIconProps {
export function OmpIcon({ size = 16, color = "currentColor" }: OmpIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 64 64" fill={color}>
<Svg width={size} height={size} viewBox="4 4 56 56" fill={color}>
<Path d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" />
</Svg>
);

View File

@@ -7,7 +7,7 @@ interface PiIconProps {
export function PiIcon({ size = 16, color = "currentColor" }: PiIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 800 800" fill={color}>
<Svg width={size} height={size} viewBox="100 100 600 600" fill={color}>
<Path
d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"
fill={color}

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { resolveInitialModelBrowserView } from "./model-browser-view";
function provider(id: string, label: string): ProviderSelectorProvider {
return {
id,
label,
modelSelection: { kind: "models", rows: [] },
};
}
describe("model browser initial view", () => {
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" });
});
});

View File

@@ -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<string>;
}): 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" };
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<ViewStyle>;
presentation?: "push" | "replace";
open?: boolean;
onOpenChange?: (open: boolean) => void;
@@ -934,6 +937,7 @@ interface MobileBodyProps {
searchable: boolean;
hasChildren: boolean;
mobileChildrenScrollEnabled: boolean;
mobileChildrenContentContainerStyle: StyleProp<ViewStyle>;
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 ? (
<SheetHeaderView header={props.header} onClose={props.onClose} />
) : (
<>
<View style={styles.bottomSheetHeader}>
<Text key={props.titleColor} style={comboboxTitleStyle}>
{props.title}
</Text>
</View>
{props.stickyHeader}
{!props.hasChildren && props.searchable ? (
<SearchInput
placeholder={props.searchPlaceholder}
onChangeText={props.setSearchQueryWithCallback}
onSubmitEditing={props.handleSubmitSearch}
autoFocus={false}
useBottomSheetInput
resetKey={props.searchResetKey}
/>
) : null}
</>
)}
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
body
) : (
<BottomSheetScrollView
contentContainerStyle={styles.comboboxScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{body}
</BottomSheetScrollView>
)}
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
<View style={frameStyle}>
{props.header ? (
<SheetHeaderView header={props.header} onClose={props.onClose} />
) : (
<>
<View style={styles.bottomSheetHeader}>
<Text key={props.titleColor} style={comboboxTitleStyle}>
{props.title}
</Text>
</View>
{props.stickyHeader}
{!props.hasChildren && props.searchable ? (
<SearchInput
placeholder={props.searchPlaceholder}
onChangeText={props.setSearchQueryWithCallback}
onSubmitEditing={props.handleSubmitSearch}
autoFocus={false}
useBottomSheetInput
resetKey={props.searchResetKey}
/>
) : null}
</>
)}
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
body
) : (
<BottomSheetScrollView
style={styles.mobileSheetBody}
contentContainerStyle={[
styles.comboboxScrollContent,
props.mobileChildrenContentContainerStyle,
]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{body}
</BottomSheetScrollView>
)}
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
</View>
</IsolatedBottomSheetModal>
);
}
@@ -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}
</MobileComboboxBody>
@@ -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",

View File

@@ -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 ? (
<DraftAgentModeControl placement="footer" {...agentControlsWithDisabled} />
) : undefined,
[isCompact, agentControlsWithDisabled],
);
const subtitleContent = useMemo(
() => (
<View style={styles.subtitleRow}>
@@ -457,7 +446,6 @@ export function WorkspaceSetupDialog() {
commandDraftConfig={composerState?.commandDraftConfig}
agentControls={agentControlsWithDisabled}
inputWrapperStyle={styles.composerInputWrapper}
footer={composerFooter}
/>
</FileDropZone>

View File

@@ -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<AgentControlIconProps>;
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<View, AgentControlTriggerProps>(
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 (
<ComboboxTrigger
ref={ref}
collapsable={false}
disabled={disabled}
onPress={onPress}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
testID={testID}
chevron={showCaret ? undefined : null}
>
{isSheet ? (
<View style={styles.sheetGlyph}>
<Icon size={resolvedGlyphSize} color={resolvedIconColor} />
</View>
) : (
<ComposerToolbarGlyph size={resolvedGlyphSize}>
<Icon size={resolvedGlyphSize} color={resolvedIconColor} />
</ComposerToolbarGlyph>
)}
{isSheet ? (
<Text style={styles.sheetLabel} numberOfLines={1}>
{label}
</Text>
) : null}
{showValue ? (
<Text style={isSheet ? styles.sheetValue : styles.toolbarValue} numberOfLines={1}>
{value ?? label}
</Text>
) : null}
</ComboboxTrigger>
);
},
);
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,
},
}));

View File

@@ -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 (
<View
style={size >= 20 ? styles.native : styles.web}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
pointerEvents="none"
>
{children}
</View>
);
}
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",
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -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 (
<ComposerControlLayoutContext.Provider value={value}>
{children}
</ComposerControlLayoutContext.Provider>
);
}
export function useComposerControlLayout(): ComposerControlLayoutValue {
return useContext(ComposerControlLayoutContext);
}

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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<View>(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<SheetHeader>(
() => ({
title: t("agentControls.mode.title"),
@@ -238,21 +231,23 @@ function AgentModeControlView({
<>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<ComboboxTrigger
<AgentControlTrigger
ref={anchorRef}
collapsable={false}
icon={Icon}
iconColor={iconColor}
surface={surface}
label={t("agentControls.mode.title")}
value={selectedModeLabel}
showToolbarLabel={presentation.showModeLabel}
showCaret={surface === "toolbar" && presentation.showCarets}
open={open}
disabled={disabled}
onPress={handlePress}
style={pressableStyle}
accessibilityRole="button"
accessibilityLabel={t("agentControls.mode.selectWithValue", {
value: selectedModeLabel,
})}
testID="mode-control"
>
{Icon ? <Icon size={theme.iconSize.md} color={iconColor} /> : null}
<Text style={labelStyle}>{selectedModeLabel}</Text>
</ComboboxTrigger>
/>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
@@ -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 (
<AgentModeControlView
provider={slice.provider}
providerDefinitions={providerDefinitions}
modeOptions={availableModes}
selectedModeId={slice.currentModeId}
onSelectMode={handleSelectMode}
disabled={!client}
/>
);
});
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 (
<AgentModeControlView
provider={selectedProvider}
providerDefinitions={providerDefinitions}
modeOptions={modeOptions}
selectedModeId={selectedMode}
onSelectMode={onSelectMode}
disabled={disabled}
/>
);
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",

View File

@@ -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<string>;
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 ? (
<View style={styles.compactFooter} testID="agent-controls-settings-list">
<View style={styles.modelViewportDivider} />
<View style={[styles.controlsContent, styles.compactControlsContent]}>{children}</View>
</View>
) : undefined,
[children, usesBottomSheet],
);
const 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 (
<>
<ComboboxTrigger
collapsable={false}
disabled={disabled}
onPress={toggle}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={t("modelSelector.selectedModel", {
model: browser.selectedModelLabel,
})}
testID="combined-model-selector"
chevron={null}
>
{ProviderIcon ? (
<ComposerToolbarGlyph size={glyphSize}>
<ProviderIcon size={glyphSize} color={styles.providerIcon.color} />
</ComposerToolbarGlyph>
) : null}
<Text style={styles.triggerText} numberOfLines={1}>
{shortModelLabel(browser.triggerLabel)}
</Text>
</ComboboxTrigger>
<AdaptiveModalSheet
header={browser.header}
visible={isOpen}
onClose={close}
snapPoints={SNAP_POINTS}
scrollable={false}
sizeContentToCurrentSnapPoint={usesBottomSheet}
footer={compactFooter}
footerContainerStyle={usesBottomSheet ? styles.compactFooterContainer : undefined}
contentContainerStyle={styles.sheetBody}
testID="agent-controls-model-sheet"
>
<View
style={[
styles.modelViewport,
usesBottomSheet ? styles.flexibleModelViewport : styles.fixedModelViewport,
]}
testID="agent-controls-model-viewport"
>
<ModelBrowser
state={browser}
onSelect={handleSelect}
onToggleFavorite={onToggleFavorite}
onRetryProvider={onRetryProvider}
isRetryingProvider={isRetryingProvider}
scrolling="independent"
/>
</View>
{!usesBottomSheet ? (
<>
<View style={styles.modelViewportDivider} />
<ScrollView
style={styles.controlsScroll}
contentContainerStyle={styles.controlsContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
testID="agent-controls-settings-list"
>
{children}
</ScrollView>
</>
) : null}
</AdaptiveModalSheet>
</>
);
}
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],
},
}));

View File

@@ -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<typeof composerState.setProviderFromUser>[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<typeof composerState.setProviderAndModelFromUser>[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 ? (
<DraftAgentModeControl
placement="footer"
{...composerAgentControls}
isCompactLayout={isCompactComposerLayout}
/>
) : undefined,
[isCompactComposerLayout, composerAgentControls],
);
return (
<FileDropZone style={styles.container}>
<View style={styles.contentContainer}>
@@ -770,7 +690,6 @@ export function WorkspaceDraftAgentTab({
onFocusInput={handleFocusInputCallback}
commandDraftConfig={composerState.commandDraftConfig}
agentControls={composerAgentControls}
footer={composerFooter}
isCompactLayout={isCompactComposerLayout}
/>
</ReanimatedAnimated.View>

View File

@@ -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: <View style={styles.contextWindowMeterSlot}>{meter}</View>,
footerInlineContent: null,
};
reserveSlot: boolean,
): ReactNode {
return reserveSlot ? <View style={styles.contextWindowMeterSlot}>{meter}</View> : null;
}
interface RenderLeftContentArgs {
@@ -302,23 +298,6 @@ interface RenderAttachmentTrayArgs {
};
}
function renderComposerFooter(
footer: ReactNode,
footerInlineContent: ReactNode,
): ReactElement | null {
if (!footer && !footerInlineContent) return null;
return (
<View style={styles.footer}>
<View style={styles.footerContent}>
<View style={styles.footerLeft}>
{footer}
{footerInlineContent}
</View>
</View>
</View>
);
}
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({
</View>
</View>
</View>
{renderComposerFooter(footer, footerInlineContent)}
</Animated.View>
</ComposerKeyboardScopeProvider>
);
@@ -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",
},

View File

@@ -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],

View File

@@ -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"]);
});
});

View File

@@ -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;
}
}

View File

@@ -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();

View File

@@ -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 ? (
<AgentModeControl
serverId={serverId}
agentId={agentId}
placement="footer"
isCompactLayout={isCompactComposerLayout}
/>
) : undefined,
[isCompactComposerLayout, serverId, agentId],
);
return (
<ReanimatedAnimated.View style={inputAreaStyle} onLayout={onInputAreaLayout}>
<SubagentsTrack
@@ -1574,7 +1560,6 @@ function ActiveAgentComposer({
onComposerHeightChange={onComposerHeightChange}
onMessageSent={onMessageSent}
onClientSlashCommand={handleClientSlashCommand}
footer={composerFooter}
isCompactLayout={isCompactComposerLayout}
/>
</ReanimatedAnimated.View>

View File

@@ -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({

View File

@@ -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);

View File

@@ -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");
});
});

View File

@@ -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<FormPreferences["providerPreferences"]>[AgentProvider];

View File

@@ -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 ? (
<DraftAgentModeControl placement="footer" {...agentControlsWithDisabled} />
) : null,
[agentControlsWithDisabled],
);
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
return (
@@ -2154,7 +2146,6 @@ export function NewWorkspaceScreen({
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
agentControls={agentControlsWithDisabled}
footer={composerFooter}
/>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</ReanimatedAnimated.View>