From 9c0a0c4bc670961bd0aea23419878fe3109da6bd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 00:47:15 +0000 Subject: [PATCH 1/5] fix(mobile): long approval commands scroll inside the approval card A long command in a pending approval made the card taller than the screen, so its heading ran under the navigation header and nothing could be dragged into view. The card now takes a maxHeight from the same UI-thread budget the question card uses, measured against the visible composer and the keyboard lift of the sticky view, and scrolls its detail, reason, explanation and warnings while the heading and decision buttons stay put. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/PendingApprovalCard.tsx | 95 ++++++++++++------- .../features/threads/ThreadDetailScreen.tsx | 30 ++++-- 2 files changed, 84 insertions(+), 41 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx index b5a9d4ed27eb2..c35bf62491015 100644 --- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -9,7 +9,8 @@ import { fullAccessOverrideExplanation, } from "@t3tools/client-runtime/state/thread-requests"; import { useState } from "react"; -import { Pressable, View } from "react-native"; +import { Pressable, ScrollView, View } from "react-native"; +import Animated, { useAnimatedStyle, type DerivedValue } from "react-native-reanimated"; import { AppText as Text } from "../../components/ui/AppText"; import type { PendingApproval } from "../../lib/threadActivity"; @@ -18,6 +19,7 @@ export interface PendingApprovalCardProps { readonly canOperateThread: boolean; readonly approval: PendingApproval; readonly respondingApprovalId: RuntimeRequestId | null; + readonly maxHeight: DerivedValue; readonly onRespond: ( requestId: RuntimeRequestId, decision: ProviderApprovalDecision, @@ -37,48 +39,73 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { const canRespond = props.approval.responseCapability === "live"; const disabled = !canRespond || props.respondingApprovalId === props.approval.requestId; const [explanationOpen, setExplanationOpen] = useState(false); + const maxHeight = props.maxHeight; + const maxHeightStyle = useAnimatedStyle(() => ({ maxHeight: maxHeight.value })); + const hasBody = Boolean( + props.approval.detail || + props.approval.reason || + props.approval.fullAccessOverride || + !canRespond || + warning, + ); return ( - + Approval needed {props.approval.appName ?? props.approval.requestKind} - {props.approval.detail ? ( - - {props.approval.detail} - - ) : null} - {props.approval.reason ? ( - - {props.approval.reason} - - ) : null} - {props.approval.fullAccessOverride ? ( - setExplanationOpen((open) => !open)} + {hasBody ? ( + - - {FULL_ACCESS_OVERRIDE_QUESTION} - - {explanationOpen ? ( - - {fullAccessOverrideExplanation(props.approval.fullAccessOverride)} + {props.approval.detail ? ( + + {props.approval.detail} ) : null} - - ) : null} - {!canRespond ? ( - - The provider process for this request is no longer available. Interrupt or restart the run - to continue. - - ) : null} - {warning ? ( - {warning} + {props.approval.reason ? ( + + {props.approval.reason} + + ) : null} + {props.approval.fullAccessOverride ? ( + setExplanationOpen((open) => !open)} + > + + {FULL_ACCESS_OVERRIDE_QUESTION} + + {explanationOpen ? ( + + {fullAccessOverrideExplanation(props.approval.fullAccessOverride)} + + ) : null} + + ) : null} + {!canRespond ? ( + + The provider process for this request is no longer available. Interrupt or restart the + run to continue. + + ) : null} + {warning ? ( + + {warning} + + ) : null} + ) : null} {options.map((option) => ( @@ -102,6 +129,6 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { This connection cannot respond to approvals. ) : null} - + ); } diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 27655f503bd65..6ca363467312a 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -304,6 +304,7 @@ export interface ThreadDetailScreenProps { const USAGE_LIMITS_PANEL_GAP = 12; const USAGE_LIMITS_PANEL_MIN_HEIGHT = 44; +const PENDING_APPROVAL_SLOT_BOTTOM_PADDING = 12; const USER_INPUT_TOGGLE_TIMING = { duration: USER_INPUT_TOGGLE_DURATION_MS, @@ -863,7 +864,8 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { // The question card reads the keyboard frame on the UI thread, in the same frame the sticky // view translates, so it gets the full height while the keyboard is closed and never crosses // the header while the keyboard moves. - const { height: keyboardTranslation } = useReanimatedKeyboardAnimation(); + const { height: keyboardTranslation, progress: keyboardProgress } = + useReanimatedKeyboardAnimation(); const pendingUserInputMaxHeight = useDerivedValue(() => derivePendingUserInputMaxHeight({ windowHeight: screenAreaHeight, @@ -872,6 +874,18 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { composerOverlapHeight: composerBottomInset, }), ); + // The approval card sits above the visible composer, which the sticky view lifts by the + // keyboard less its opened offset. + const pendingApprovalMaxHeight = useDerivedValue(() => + derivePendingUserInputMaxHeight({ + windowHeight: screenAreaHeight, + keyboardHeight: keyboardStickyEnabled + ? -keyboardTranslation.value - keyboardProgress.value * composerKeyboardOffset + : 0, + navigationHeaderHeight, + composerOverlapHeight: composerOverlapHeight + PENDING_APPROVAL_SLOT_BOTTOM_PADDING, + }), + ); // The usage panel stacks with the composer and any approval card, so it gets // whatever room they leave below the header. Measured only while it is open // so typing in the composer does not re-render the screen. @@ -1494,12 +1508,13 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { ) : null} {props.activePendingApproval || props.activePendingUserInput ? ( @@ -1507,6 +1522,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { From 02cc2dd0d9f09e2ecf18b45f0c5d9ca6fefb341c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 01:08:23 +0000 Subject: [PATCH 2/5] fix(mobile): approval actions stay pinned at any card height Only the decision buttons stay outside the scroll body, so they fit the card's minimum height, and each request remounts the card so it opens at the top. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/PendingApprovalCard.tsx | 109 ++++++++---------- .../features/threads/ThreadDetailScreen.tsx | 1 + 2 files changed, 51 insertions(+), 59 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx index c35bf62491015..0905186580c27 100644 --- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -41,72 +41,63 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { const [explanationOpen, setExplanationOpen] = useState(false); const maxHeight = props.maxHeight; const maxHeightStyle = useAnimatedStyle(() => ({ maxHeight: maxHeight.value })); - const hasBody = Boolean( - props.approval.detail || - props.approval.reason || - props.approval.fullAccessOverride || - !canRespond || - warning, - ); return ( - - Approval needed - - - {props.approval.appName ?? props.approval.requestKind} - - {hasBody ? ( - - {props.approval.detail ? ( - - {props.approval.detail} - - ) : null} - {props.approval.reason ? ( - - {props.approval.reason} + + + Approval needed + + + {props.approval.appName ?? props.approval.requestKind} + + {props.approval.detail ? ( + + {props.approval.detail} + + ) : null} + {props.approval.reason ? ( + + {props.approval.reason} + + ) : null} + {props.approval.fullAccessOverride ? ( + setExplanationOpen((open) => !open)} + > + + {FULL_ACCESS_OVERRIDE_QUESTION} - ) : null} - {props.approval.fullAccessOverride ? ( - setExplanationOpen((open) => !open)} - > - - {FULL_ACCESS_OVERRIDE_QUESTION} + {explanationOpen ? ( + + {fullAccessOverrideExplanation(props.approval.fullAccessOverride)} - {explanationOpen ? ( - - {fullAccessOverrideExplanation(props.approval.fullAccessOverride)} - - ) : null} - - ) : null} - {!canRespond ? ( - - The provider process for this request is no longer available. Interrupt or restart the - run to continue. - - ) : null} - {warning ? ( - - {warning} - - ) : null} - - ) : null} + ) : null} + + ) : null} + {!canRespond ? ( + + The provider process for this request is no longer available. Interrupt or restart the + run to continue. + + ) : null} + {warning ? ( + + {warning} + + ) : null} + {options.map((option) => (