From a945c2a2d3d0b49cfcf7b4c09c816d549a798920 Mon Sep 17 00:00:00 2001 From: caseyjkey Date: Thu, 26 Feb 2026 19:50:52 +0100 Subject: [PATCH 1/2] fix(ui): improve popover portal and scroll edge fades --- .gitignore | 6 ++++ apps/ui/metro.config.js | 14 +++++--- .../AgentInput.abortButtonVisibility.test.tsx | 1 + .../AgentInput.historyNavigation.test.tsx | 1 + .../AgentInput.machineChip.test.tsx | 1 + .../AgentInput.modelOptionsOverride.test.tsx | 1 + .../AgentInput.permissionRequests.test.tsx | 10 +++++- ...gentInput.sendButtonAccessibility.test.tsx | 1 + .../sessions/agentInput/AgentInput.tsx | 36 ++++++++++++++----- .../new/components/NewSessionSimplePanel.tsx | 1 + .../ui/overlays/FloatingOverlay.arrow.test.ts | 3 ++ .../ui/overlays/FloatingOverlay.tsx | 10 +++++- .../ui/scroll/useScrollEdgeFades.ts | 33 ++++++++++++++--- 13 files changed, 99 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index ad66cefea..81fff5159 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,12 @@ notes/ .project/qa/score-history/ .happier/local/ /output + +# Claude Code cache +.claude/cache/ + +# Android build artifacts +packages/*/android/build/ /.tmp /packages/tests/playwright-report/ /packages/tests/test-results/ diff --git a/apps/ui/metro.config.js b/apps/ui/metro.config.js index f7d0e04a7..370350b63 100644 --- a/apps/ui/metro.config.js +++ b/apps/ui/metro.config.js @@ -46,21 +46,27 @@ const fontFaceObserverWebShim = path.resolve(__dirname, "sources/platform/shims/ const defaultResolveRequest = config.resolver.resolveRequest; config.resolver.resolveRequest = (context, moduleName, platform) => { + // Fix event-target-shim/index import - exports define "." not "./index" + let resolvedModuleName = moduleName; + if (moduleName === "event-target-shim/index") { + resolvedModuleName = "event-target-shim"; + } + if ( platform === "web" && - (moduleName === "@huggingface/transformers" || - moduleName.startsWith("@huggingface/transformers/")) + (resolvedModuleName === "@huggingface/transformers" || + resolvedModuleName.startsWith("@huggingface/transformers/")) ) { return { type: "sourceFile", filePath: transformersStub }; } // expo-font uses fontfaceobserver on web with a hard-coded timeout; in practice this can // surface as unhandled errors. Use a web-safe shim that avoids throwing on timeouts. - if (platform === "web" && moduleName === "fontfaceobserver") { + if (platform === "web" && resolvedModuleName === "fontfaceobserver") { return { type: "sourceFile", filePath: fontFaceObserverWebShim }; } - if (moduleName === "kokoro-js" || moduleName.startsWith("kokoro-js/")) { + if (resolvedModuleName === "kokoro-js" || resolvedModuleName.startsWith("kokoro-js/")) { if (platform === "web") { return { type: "sourceFile", filePath: kokoroJsStub }; } diff --git a/apps/ui/sources/components/sessions/agentInput/AgentInput.abortButtonVisibility.test.tsx b/apps/ui/sources/components/sessions/agentInput/AgentInput.abortButtonVisibility.test.tsx index c5e469e43..37ce229ff 100644 --- a/apps/ui/sources/components/sessions/agentInput/AgentInput.abortButtonVisibility.test.tsx +++ b/apps/ui/sources/components/sessions/agentInput/AgentInput.abortButtonVisibility.test.tsx @@ -141,6 +141,7 @@ vi.mock('@/components/ui/scroll/useScrollEdgeFades', () => ({ onViewportLayout: () => {}, onContentSizeChange: () => {}, onScroll: () => {}, + onMomentumScrollEnd: () => {}, }), })); diff --git a/apps/ui/sources/components/sessions/agentInput/AgentInput.historyNavigation.test.tsx b/apps/ui/sources/components/sessions/agentInput/AgentInput.historyNavigation.test.tsx index 563ee3f51..7fae54f73 100644 --- a/apps/ui/sources/components/sessions/agentInput/AgentInput.historyNavigation.test.tsx +++ b/apps/ui/sources/components/sessions/agentInput/AgentInput.historyNavigation.test.tsx @@ -163,6 +163,7 @@ vi.mock('@/components/ui/scroll/useScrollEdgeFades', () => ({ onViewportLayout: () => {}, onContentSizeChange: () => {}, onScroll: () => {}, + onMomentumScrollEnd: () => {}, }), })); diff --git a/apps/ui/sources/components/sessions/agentInput/AgentInput.machineChip.test.tsx b/apps/ui/sources/components/sessions/agentInput/AgentInput.machineChip.test.tsx index 97df9e46f..3b66b7de1 100644 --- a/apps/ui/sources/components/sessions/agentInput/AgentInput.machineChip.test.tsx +++ b/apps/ui/sources/components/sessions/agentInput/AgentInput.machineChip.test.tsx @@ -159,6 +159,7 @@ vi.mock('@/components/ui/scroll/useScrollEdgeFades', () => ({ onViewportLayout: () => {}, onContentSizeChange: () => {}, onScroll: () => {}, + onMomentumScrollEnd: () => {}, }), })); diff --git a/apps/ui/sources/components/sessions/agentInput/AgentInput.modelOptionsOverride.test.tsx b/apps/ui/sources/components/sessions/agentInput/AgentInput.modelOptionsOverride.test.tsx index ec43d906e..f61ab1aaa 100644 --- a/apps/ui/sources/components/sessions/agentInput/AgentInput.modelOptionsOverride.test.tsx +++ b/apps/ui/sources/components/sessions/agentInput/AgentInput.modelOptionsOverride.test.tsx @@ -210,6 +210,7 @@ vi.mock('@/components/ui/scroll/useScrollEdgeFades', () => ({ onViewportLayout: () => {}, onContentSizeChange: () => {}, onScroll: () => {}, + onMomentumScrollEnd: () => {}, }), })); diff --git a/apps/ui/sources/components/sessions/agentInput/AgentInput.permissionRequests.test.tsx b/apps/ui/sources/components/sessions/agentInput/AgentInput.permissionRequests.test.tsx index f22851b1f..d8befaaac 100644 --- a/apps/ui/sources/components/sessions/agentInput/AgentInput.permissionRequests.test.tsx +++ b/apps/ui/sources/components/sessions/agentInput/AgentInput.permissionRequests.test.tsx @@ -223,7 +223,15 @@ vi.mock('@/components/ui/lists/ActionListSection', () => ({ })); vi.mock('@/components/ui/scroll/useScrollEdgeFades', () => ({ - useScrollEdgeFades: () => ({ scrollEdgeFadesProps: {}, onScroll: () => {}, onLayout: () => {} }), + useScrollEdgeFades: () => ({ + canScrollX: false, + canScrollY: false, + visibility: { top: false, bottom: false, left: false, right: false }, + onViewportLayout: () => {}, + onContentSizeChange: () => {}, + onScroll: () => {}, + onMomentumScrollEnd: () => {}, + }), })); vi.mock('@/sync/domains/settings/settings', () => ({ diff --git a/apps/ui/sources/components/sessions/agentInput/AgentInput.sendButtonAccessibility.test.tsx b/apps/ui/sources/components/sessions/agentInput/AgentInput.sendButtonAccessibility.test.tsx index 42d805228..1c5f2099e 100644 --- a/apps/ui/sources/components/sessions/agentInput/AgentInput.sendButtonAccessibility.test.tsx +++ b/apps/ui/sources/components/sessions/agentInput/AgentInput.sendButtonAccessibility.test.tsx @@ -150,6 +150,7 @@ vi.mock('@/components/ui/scroll/useScrollEdgeFades', () => ({ onViewportLayout: () => {}, onContentSizeChange: () => {}, onScroll: () => {}, + onMomentumScrollEnd: () => {}, }), })); diff --git a/apps/ui/sources/components/sessions/agentInput/AgentInput.tsx b/apps/ui/sources/components/sessions/agentInput/AgentInput.tsx index c58103109..de77152e9 100644 --- a/apps/ui/sources/components/sessions/agentInput/AgentInput.tsx +++ b/apps/ui/sources/components/sessions/agentInput/AgentInput.tsx @@ -70,6 +70,12 @@ import { attachActionBarMouseDragScroll } from './attachActionBarMouseDragScroll const ACTION_BAR_SCROLL_END_GUTTER_WIDTH = 24; +// Settings menu height constraints +// - Max height when no keyboard is visible or plenty of space +// - Min height ensures at least a few options are visible on cramped screens +const SETTINGS_MENU_MAX_HEIGHT = 300; +const SETTINGS_MENU_MIN_HEIGHT = 120; + export type AgentInputExtraActionChipRenderContext = Readonly<{ chipStyle: (pressed: boolean) => any; @@ -208,7 +214,7 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ alignItems: 'center', width: '100%', paddingBottom: 8, - paddingTop: 8, + zIndex: 10, }, innerContainer: { width: '100%', @@ -625,6 +631,14 @@ export const AgentInput = React.memo(React.forwardRef { + if (keyboardHeight <= 0) { + return SETTINGS_MENU_MAX_HEIGHT; + } + const availableHeight = screenHeight - keyboardHeight; + return Math.max(Math.min(Math.round(availableHeight * 0.5), SETTINGS_MENU_MAX_HEIGHT), SETTINGS_MENU_MIN_HEIGHT); + }, [screenHeight, keyboardHeight]); + const hasText = props.value.trim().length > 0; const hasSendableContent = hasText || props.hasSendableAttachments === true; const micPressHandler = voiceEnabled ? props.onMicPress : undefined; @@ -805,6 +819,9 @@ export const AgentInput = React.memo(React.forwardRef(null); const settingsAnchorRef = React.useRef(null); + // On web, anchor settings popover to the settings button for better UX + const settingsPopoverAnchorRef = Platform.OS === 'web' ? settingsAnchorRef : overlayAnchorRef; + const actionBarFades = useScrollEdgeFades({ enabledEdges: { left: true, right: true }, // Match previous behavior: require a bit of overflow before enabling scroll. @@ -1276,17 +1293,17 @@ export const AgentInput = React.memo(React.forwardRef 700 ? 12 : 16) : 0, vertical: 12, @@ -1300,6 +1317,7 @@ export const AgentInput = React.memo(React.forwardRef {/* Action shortcuts (collapsed layout) */} {actionMenuActions.length > 0 ? ( diff --git a/apps/ui/sources/components/sessions/new/components/NewSessionSimplePanel.tsx b/apps/ui/sources/components/sessions/new/components/NewSessionSimplePanel.tsx index d1c2d757d..0075301ea 100644 --- a/apps/ui/sources/components/sessions/new/components/NewSessionSimplePanel.tsx +++ b/apps/ui/sources/components/sessions/new/components/NewSessionSimplePanel.tsx @@ -176,6 +176,7 @@ export function NewSessionSimplePanel(props: Readonly<{ width: '100%', alignSelf: 'center', paddingTop: props.safeAreaTop + props.newSessionTopPadding, + ...(Platform.OS !== 'web' ? { marginTop: 'auto' } : {}), }} > {/* Session type selector only if enabled via experiments */} diff --git a/apps/ui/sources/components/ui/overlays/FloatingOverlay.arrow.test.ts b/apps/ui/sources/components/ui/overlays/FloatingOverlay.arrow.test.ts index b34c91b2a..4af70ff96 100644 --- a/apps/ui/sources/components/ui/overlays/FloatingOverlay.arrow.test.ts +++ b/apps/ui/sources/components/ui/overlays/FloatingOverlay.arrow.test.ts @@ -78,10 +78,13 @@ vi.mock('@/components/ui/scroll/ScrollEdgeIndicators', () => ({ vi.mock('@/components/ui/scroll/useScrollEdgeFades', () => ({ useScrollEdgeFades: () => ({ + canScrollX: false, + canScrollY: false, visibility: { top: false, bottom: false, left: false, right: false }, onViewportLayout: () => {}, onContentSizeChange: () => {}, onScroll: () => {}, + onMomentumScrollEnd: () => {}, }), })); diff --git a/apps/ui/sources/components/ui/overlays/FloatingOverlay.tsx b/apps/ui/sources/components/ui/overlays/FloatingOverlay.tsx index 052a11080..e94aaee87 100644 --- a/apps/ui/sources/components/ui/overlays/FloatingOverlay.tsx +++ b/apps/ui/sources/components/ui/overlays/FloatingOverlay.tsx @@ -3,7 +3,7 @@ import { Platform, type StyleProp, type ViewStyle } from 'react-native'; import Animated from 'react-native-reanimated'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { ScrollEdgeFades } from '@/components/ui/scroll/ScrollEdgeFades'; -import { useScrollEdgeFades } from '@/components/ui/scroll/useScrollEdgeFades'; +import { useScrollEdgeFades, type ScrollEdgeVisibility } from '@/components/ui/scroll/useScrollEdgeFades'; import { ScrollEdgeIndicators } from '@/components/ui/scroll/ScrollEdgeIndicators'; const stylesheet = StyleSheet.create((theme, runtime) => ({ @@ -59,6 +59,12 @@ interface FloatingOverlayProps { edgeIndicators?: boolean | Readonly<{ size?: number; opacity?: number }>; /** Optional arrow that points back to the anchor (useful for context menus). */ arrow?: FloatingOverlayArrow; + /** + * Initial visibility for scroll edge fades before measurement. + * Useful for optimistic trailing-edge fades (e.g., bottom: true for lists + * that typically have more content below). + */ + initialVisibility?: Partial; } export const FloatingOverlay = React.memo((props: FloatingOverlayProps) => { @@ -106,6 +112,7 @@ export const FloatingOverlay = React.memo((props: FloatingOverlayProps) => { }, overflowThreshold: 1, edgeThreshold: 1, + initialVisibility: props.initialVisibility, }); const arrowCfg = React.useMemo(() => { @@ -142,6 +149,7 @@ export const FloatingOverlay = React.memo((props: FloatingOverlayProps) => { onLayout={fadeCfg || indicatorCfg ? fades.onViewportLayout : undefined} onContentSizeChange={fadeCfg || indicatorCfg ? fades.onContentSizeChange : undefined} onScroll={fadeCfg || indicatorCfg ? fades.onScroll : undefined} + onMomentumScrollEnd={fadeCfg || indicatorCfg ? fades.onMomentumScrollEnd : undefined} > {children} diff --git a/apps/ui/sources/components/ui/scroll/useScrollEdgeFades.ts b/apps/ui/sources/components/ui/scroll/useScrollEdgeFades.ts index ba9c2744f..758c5a8dc 100644 --- a/apps/ui/sources/components/ui/scroll/useScrollEdgeFades.ts +++ b/apps/ui/sources/components/ui/scroll/useScrollEdgeFades.ts @@ -1,4 +1,5 @@ import * as React from 'react'; +import { type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native'; export type ScrollEdge = 'top' | 'bottom' | 'left' | 'right'; @@ -20,6 +21,11 @@ export type UseScrollEdgeFadesParams = Readonly<{ * Distance from the edge before we show the fade (px). */ edgeThreshold?: number; + /** + * Initial visibility state before measurement. Useful for optimistic trailing-edge + * fades (e.g., bottom: true for lists that typically have more content below). + */ + initialVisibility?: Partial; }>; type Size = Readonly<{ width: number; height: number }>; @@ -36,6 +42,10 @@ export function useScrollEdgeFades(params: UseScrollEdgeFadesParams) { const overflowThreshold = params.overflowThreshold ?? 1; const edgeThreshold = params.edgeThreshold ?? 1; + const initialVisibility = React.useMemo(() => { + return { ...defaultVisibility, ...params.initialVisibility }; + }, [params.initialVisibility]); + const enabled = React.useMemo(() => { return { top: Boolean(params.enabledEdges.top), @@ -51,8 +61,9 @@ export function useScrollEdgeFades(params: UseScrollEdgeFadesParams) { const [canScroll, setCanScroll] = React.useState(() => ({ x: false, y: false })); - const visibilityRef = React.useRef(defaultVisibility); - const [visibility, setVisibility] = React.useState(defaultVisibility); + const visibilityRef = React.useRef(initialVisibility); + + const [visibility, setVisibility] = React.useState(initialVisibility); const recompute = React.useCallback(() => { const viewport = viewportRef.current; @@ -93,7 +104,7 @@ export function useScrollEdgeFades(params: UseScrollEdgeFadesParams) { }); }, [edgeThreshold, enabled.bottom, enabled.left, enabled.right, enabled.top, overflowThreshold]); - const onViewportLayout = React.useCallback((e: any) => { + const onViewportLayout = React.useCallback((e: LayoutChangeEvent) => { const width = e?.nativeEvent?.layout?.width ?? 0; const height = e?.nativeEvent?.layout?.height ?? 0; viewportRef.current = { width, height }; @@ -105,7 +116,7 @@ export function useScrollEdgeFades(params: UseScrollEdgeFadesParams) { recompute(); }, [recompute]); - const onScroll = React.useCallback((e: any) => { + const onScroll = React.useCallback((e: NativeSyntheticEvent) => { const ne = e?.nativeEvent; if (!ne) return; @@ -131,6 +142,19 @@ export function useScrollEdgeFades(params: UseScrollEdgeFadesParams) { recompute(); }, [recompute]); + // Ensure final position is captured when momentum scroll ends. + // iOS/Android may not fire a final onScroll event at scroll boundaries. + const onMomentumScrollEnd = React.useCallback((e: NativeSyntheticEvent) => { + const ne = e?.nativeEvent; + if (!ne) return; + + const x = ne.contentOffset?.x ?? 0; + const y = ne.contentOffset?.y ?? 0; + offsetRef.current = { x, y }; + + recompute(); + }, [recompute]); + return { canScrollX: canScroll.x, canScrollY: canScroll.y, @@ -138,6 +162,7 @@ export function useScrollEdgeFades(params: UseScrollEdgeFadesParams) { onViewportLayout, onContentSizeChange, onScroll, + onMomentumScrollEnd, } as const; } From 14e7e3ff3fa3a83f66caa08f3003571cd589e02b Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 26 Feb 2026 19:10:02 +0100 Subject: [PATCH 2/2] fix(ui): anchor settings popover to gear - Remove native anchor-to-composer workaround - Cap width like other agent-input popovers - Add unit test covering popover props --- .../AgentInput.settingsPopoverProps.test.tsx | 318 ++++++++++++++++++ .../sessions/agentInput/AgentInput.tsx | 40 +-- 2 files changed, 330 insertions(+), 28 deletions(-) create mode 100644 apps/ui/sources/components/sessions/agentInput/AgentInput.settingsPopoverProps.test.tsx diff --git a/apps/ui/sources/components/sessions/agentInput/AgentInput.settingsPopoverProps.test.tsx b/apps/ui/sources/components/sessions/agentInput/AgentInput.settingsPopoverProps.test.tsx new file mode 100644 index 000000000..9b11ac8e3 --- /dev/null +++ b/apps/ui/sources/components/sessions/agentInput/AgentInput.settingsPopoverProps.test.tsx @@ -0,0 +1,318 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-native', async () => { + const rn = await import('@/dev/reactNativeStub'); + return { + ...rn, + View: (props: Record & { children?: React.ReactNode }) => + React.createElement('View', props, props.children), + Text: (props: Record & { children?: React.ReactNode }) => + React.createElement('Text', props, props.children), + Pressable: (props: Record & { children?: React.ReactNode }) => + React.createElement('Pressable', props, props.children), + ScrollView: (props: Record & { children?: React.ReactNode }) => + React.createElement('ScrollView', props, props.children), + ActivityIndicator: (props: Record) => React.createElement('ActivityIndicator', props, null), + Platform: { ...rn.Platform, OS: 'ios', select: (v: any) => v.ios }, + useWindowDimensions: () => ({ width: 800, height: 600 }), + Dimensions: { + get: () => ({ width: 800, height: 600, scale: 1, fontScale: 1 }), + }, + }; +}); + +vi.mock('react-native-unistyles', () => ({ + StyleSheet: { + create: (styles: any) => { + const theme = { + colors: { + input: { background: '#fff' }, + button: { + primary: { background: '#000', tint: '#fff' }, + secondary: { tint: '#000', surface: '#fff' }, + }, + radio: { active: '#000', inactive: '#ddd' }, + text: '#000', + textSecondary: '#666', + divider: '#ddd', + success: '#0a0', + textDestructive: '#a00', + surfacePressed: '#eee', + permission: { + acceptEdits: '#0a0', + bypass: '#0a0', + plan: '#0a0', + readOnly: '#0a0', + safeYolo: '#0a0', + yolo: '#0a0', + }, + surfaceHighest: '#fafafa', + }, + }; + return typeof styles === "function" ? styles(theme) : styles; + }, + }, + useUnistyles: () => ({ + theme: { + colors: { + input: { background: '#fff' }, + button: { + primary: { background: '#000', tint: '#fff' }, + secondary: { tint: '#000', surface: '#fff' }, + }, + radio: { active: '#000', inactive: '#ddd' }, + text: '#000', + textSecondary: '#666', + divider: '#ddd', + success: '#0a0', + textDestructive: '#a00', + surfacePressed: '#eee', + permission: { + acceptEdits: '#0a0', + bypass: '#0a0', + plan: '#0a0', + readOnly: '#0a0', + safeYolo: '#0a0', + yolo: '#0a0', + }, + surfaceHighest: '#fafafa', + }, + }, + }), +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: (props: Record) => React.createElement('Ionicons', props, null), + Octicons: (props: Record) => React.createElement('Octicons', props, null), +})); + +vi.mock('expo-image', () => ({ + Image: (props: Record) => React.createElement('Image', props, null), +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/components/ui/text/Text', () => ({ + Text: (props: Record & { children?: React.ReactNode }) => + React.createElement('Text', props, props.children), +})); + +vi.mock('@/components/ui/layout/layout', () => ({ + layout: { maxWidth: 800, headerMaxWidth: 800 }, +})); + +vi.mock('@/sync/domains/state/storage', () => ({ + useSetting: (key: string) => { + if (key === 'profiles') return []; + if (key === 'agentInputEnterToSend') return true; + if (key === 'agentInputActionBarLayout') return 'collapsed'; + if (key === 'agentInputChipDensity') return 'labels'; + if (key === 'sessionPermissionModeApplyTiming') return 'immediate'; + return null; + }, + useSettings: () => ({ + profiles: [], + agentInputEnterToSend: true, + agentInputActionBarLayout: 'collapsed', + agentInputChipDensity: 'labels', + sessionPermissionModeApplyTiming: 'immediate', + }), + useSessionMessages: () => ({ messages: [], isLoaded: true }), +})); + +vi.mock('@/sync/domains/state/storageStore', () => ({ + getStorage: () => (selector: any) => selector({ sessionMessages: {} }), +})); + +vi.mock('@/agents/catalog/catalog', () => ({ + AGENT_IDS: ['codex', 'claude', 'opencode', 'gemini'], + DEFAULT_AGENT_ID: 'codex', + resolveAgentIdFromFlavor: () => null, + getAgentCore: () => ({ displayNameKey: 'agents.codex', toolRendering: { hideUnknownToolsByDefault: false } }), +})); + +vi.mock('@/sync/domains/models/modelOptions', () => ({ + getModelOptionsForSession: () => [{ value: 'default', label: 'Default' }], + supportsFreeformModelSelectionForSession: () => false, +})); + +vi.mock('@/sync/domains/models/describeEffectiveModelMode', () => ({ + describeEffectiveModelMode: () => ({ effectiveModelId: 'default' }), +})); + +vi.mock('@/sync/domains/permissions/permissionModeOptions', () => ({ + getPermissionModeBadgeLabelForAgentType: () => 'Default', + getPermissionModeLabelForAgentType: () => 'Default', + getPermissionModeOptionsForSession: () => [{ value: 'default', label: 'Default' }], + getPermissionModeTitleForAgentType: () => 'Permissions', +})); + +vi.mock('@/sync/domains/permissions/describeEffectivePermissionMode', () => ({ + describeEffectivePermissionMode: () => ({ effectiveMode: 'default' }), +})); + +vi.mock('@/components/ui/forms/MultiTextInput', () => ({ + MultiTextInput: (props: Record) => React.createElement('MultiTextInput', props, null), +})); + +vi.mock('@/components/ui/buttons/PrimaryCircleIconButton', () => ({ + PrimaryCircleIconButton: () => null, +})); + +vi.mock('@/components/ui/lists/ActionListSection', () => ({ + ActionListSection: () => null, +})); + +vi.mock('@/components/ui/forms/Switch', () => ({ + Switch: (props: Record) => React.createElement('Switch', props, null), +})); + +vi.mock('@/components/ui/theme/haptics', () => ({ + hapticsLight: () => {}, + hapticsError: () => {}, +})); + +vi.mock('@/components/ui/feedback/Shaker', () => ({ + Shaker: (props: Record & { children?: React.ReactNode }) => + React.createElement(React.Fragment, null, props.children), +})); + +vi.mock('@/components/ui/status/StatusDot', () => ({ + StatusDot: () => null, +})); + +vi.mock('@/components/autocomplete/useActiveWord', () => ({ + useActiveWord: () => ({ word: '', start: 0, end: 0 }), +})); + +vi.mock('@/components/autocomplete/useActiveSuggestions', () => ({ + useActiveSuggestions: () => [[], 0, () => {}, () => {}], +})); + +vi.mock('@/components/autocomplete/applySuggestion', () => ({ + applySuggestion: (text: string) => ({ text, cursorPosition: text.length }), +})); + +type CapturedPopoverProps = Record & { + open: boolean; + anchorRef: React.RefObject; + maxHeightCap?: number; + maxWidthCap?: number; + boundaryRef?: React.RefObject | null; + portal?: { matchAnchorWidth?: boolean }; +}; + +const captured: { last: CapturedPopoverProps | null } = { last: null }; +vi.mock('@/components/ui/popover', () => ({ + Popover: (props: CapturedPopoverProps) => { + captured.last = props; + return null; + }, +})); + +vi.mock('@/components/ui/overlays/FloatingOverlay', () => ({ + FloatingOverlay: () => null, +})); + +vi.mock('@/components/ui/scroll/useScrollEdgeFades', () => ({ + useScrollEdgeFades: () => ({ + canScrollX: false, + visibility: { left: false, right: false }, + onViewportLayout: () => {}, + onContentSizeChange: () => {}, + onScroll: () => {}, + onMomentumScrollEnd: () => {}, + }), +})); + +vi.mock('@/components/ui/scroll/ScrollEdgeFades', () => ({ + ScrollEdgeFades: () => null, +})); + +vi.mock('@/components/ui/scroll/ScrollEdgeIndicators', () => ({ + ScrollEdgeIndicators: () => null, +})); + +vi.mock('@/components/sessions/sourceControl/status', () => ({ + SourceControlStatusBadge: () => null, + useHasMeaningfulScmStatus: () => false, +})); + +vi.mock('@/components/model/ModelPickerOverlay', () => ({ + ModelPickerOverlay: () => null, +})); + +vi.mock('@/hooks/ui/useKeyboardHeight', () => ({ + useKeyboardHeight: () => 0, +})); + +vi.mock('@/modal', () => ({ + Modal: { alert: vi.fn() }, +})); + +vi.mock('@/sync/acp/sessionModeControl', () => ({ + computeAcpPlanModeControl: () => null, + computeAcpSessionModePickerControl: () => null, +})); + +vi.mock('@/sync/acp/configOptionsControl', () => ({ + computeAcpConfigOptionControls: () => null, +})); + +vi.mock('./components/PermissionModePicker', () => ({ + PermissionModePicker: () => null, +})); + +describe('AgentInput (settings popover props)', () => { + it('anchors the settings popover to the gear button and sizes relative to the agent input', async () => { + const { AgentInput } = await import('./AgentInput'); + + let tree: renderer.ReactTestRenderer; + act(() => { + tree = renderer.create( + {}} + onSend={() => {}} + autocompletePrefixes={[]} + autocompleteSuggestions={async () => []} + /> + ); + }); + + const gearPressable = tree!.root + .findAll((n: any) => n?.type === 'Pressable') + .find((pressable: any) => { + const gearIcons = pressable.findAll( + (n: any) => n?.type === 'Octicons' && n?.props?.name === 'gear' + ); + return gearIcons.length > 0; + }); + + expect(gearPressable).toBeTruthy(); + expect(typeof gearPressable!.props.onPress).toBe('function'); + + captured.last = null; + act(() => { + gearPressable!.props.onPress(); + }); + + const popoverProps = captured.last; + expect(popoverProps?.open).toBe(true); + expect(popoverProps?.anchorRef).toBe(gearPressable!.props.ref); + expect(popoverProps?.boundaryRef).toBe(null); + expect(popoverProps?.maxHeightCap).toBe(400); + expect(popoverProps?.maxWidthCap).toBe(800); + expect(popoverProps?.portal?.matchAnchorWidth).toBe(false); + + act(() => tree!.unmount()); + }); +}); + diff --git a/apps/ui/sources/components/sessions/agentInput/AgentInput.tsx b/apps/ui/sources/components/sessions/agentInput/AgentInput.tsx index de77152e9..f558a0ec1 100644 --- a/apps/ui/sources/components/sessions/agentInput/AgentInput.tsx +++ b/apps/ui/sources/components/sessions/agentInput/AgentInput.tsx @@ -70,12 +70,6 @@ import { attachActionBarMouseDragScroll } from './attachActionBarMouseDragScroll const ACTION_BAR_SCROLL_END_GUTTER_WIDTH = 24; -// Settings menu height constraints -// - Max height when no keyboard is visible or plenty of space -// - Min height ensures at least a few options are visible on cramped screens -const SETTINGS_MENU_MAX_HEIGHT = 300; -const SETTINGS_MENU_MIN_HEIGHT = 120; - export type AgentInputExtraActionChipRenderContext = Readonly<{ chipStyle: (pressed: boolean) => any; @@ -214,7 +208,7 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ alignItems: 'center', width: '100%', paddingBottom: 8, - zIndex: 10, + paddingTop: 8, }, innerContainer: { width: '100%', @@ -631,14 +625,6 @@ export const AgentInput = React.memo(React.forwardRef { - if (keyboardHeight <= 0) { - return SETTINGS_MENU_MAX_HEIGHT; - } - const availableHeight = screenHeight - keyboardHeight; - return Math.max(Math.min(Math.round(availableHeight * 0.5), SETTINGS_MENU_MAX_HEIGHT), SETTINGS_MENU_MIN_HEIGHT); - }, [screenHeight, keyboardHeight]); - const hasText = props.value.trim().length > 0; const hasSendableContent = hasText || props.hasSendableAttachments === true; const micPressHandler = voiceEnabled ? props.onMicPress : undefined; @@ -819,9 +805,6 @@ export const AgentInput = React.memo(React.forwardRef(null); const settingsAnchorRef = React.useRef(null); - // On web, anchor settings popover to the settings button for better UX - const settingsPopoverAnchorRef = Platform.OS === 'web' ? settingsAnchorRef : overlayAnchorRef; - const actionBarFades = useScrollEdgeFades({ enabledEdges: { left: true, right: true }, // Match previous behavior: require a bit of overflow before enabling scroll. @@ -1290,18 +1273,19 @@ export const AgentInput = React.memo(React.forwardRef