Skip to content
Open
97 changes: 68 additions & 29 deletions apps/mobile/src/features/threads/PendingApprovalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import {
} from "@t3tools/client-runtime/state/thread-requests";
import { useState } from "react";
import { Pressable, View } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
type DerivedValue,
} from "react-native-reanimated";

import { AppText as Text } from "../../components/ui/AppText";
import type { PendingApproval } from "../../lib/threadActivity";
Expand All @@ -18,6 +23,7 @@ export interface PendingApprovalCardProps {
readonly canOperateThread: boolean;
readonly approval: PendingApproval;
readonly respondingApprovalId: RuntimeRequestId | null;
readonly maxHeight?: DerivedValue<number>;
readonly onRespond: (
requestId: RuntimeRequestId,
decision: ProviderApprovalDecision,
Expand All @@ -30,47 +36,80 @@ const DEFAULT_APPROVAL_OPTIONS: ReadonlyArray<ProviderApprovalOption> = [
{ decision: "decline", label: "Decline" },
];

const BODY_MIN_HEIGHT = 64;

export function PendingApprovalCard(props: PendingApprovalCardProps) {
const options: ReadonlyArray<ProviderApprovalOption> =
props.approval.options ?? DEFAULT_APPROVAL_OPTIONS;
const warning = options.find((option) => option.warning)?.warning;
const canRespond = props.approval.responseCapability === "live";
const disabled = !canRespond || props.respondingApprovalId === props.approval.requestId;
const [explanationOpen, setExplanationOpen] = useState(false);
const { maxHeight } = props;
const cardHeight = useSharedValue(0);
const bodyHeight = useSharedValue(0);
// The body takes what the pinned rows leave. Below BODY_MIN_HEIGHT the caller's capped
// ScrollView scrolls the whole card instead, so the buttons stay reachable.
const bodyHeightStyle = useAnimatedStyle(() =>
maxHeight === undefined
? {}
: {
maxHeight: Math.max(
BODY_MIN_HEIGHT,
maxHeight.value - (cardHeight.value - bodyHeight.value),
),
},
);
return (
<View className="gap-2.5 rounded-[20px] border border-border bg-card-alt p-4">
<Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-foreground-secondary">
Approval needed
</Text>
<Text className="font-t3-bold text-lg text-foreground">
{props.approval.appName ?? props.approval.requestKind}
</Text>
{props.approval.detail ? (
<Text className="font-sans text-sm leading-normal text-foreground-secondary">
{props.approval.detail}
<View
className="gap-2.5 rounded-[20px] border border-border bg-card-alt p-4"
onLayout={(event) => {
cardHeight.set(event.nativeEvent.layout.height);
}}
>
<Animated.ScrollView
bounces={false}
contentContainerClassName="gap-2.5"
keyboardShouldPersistTaps="handled"
nestedScrollEnabled
onLayout={(event) => {
bodyHeight.set(event.nativeEvent.layout.height);
}}
style={bodyHeightStyle}
>
<Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-foreground-secondary">
Approval needed
</Text>
) : null}
{props.approval.reason ? (
<Text className="font-sans text-xs leading-normal text-warning-foreground">
{props.approval.reason}
<Text className="font-t3-bold text-lg text-foreground">
{props.approval.appName ?? props.approval.requestKind}
</Text>
) : null}
{props.approval.fullAccessOverride ? (
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: explanationOpen }}
onPress={() => setExplanationOpen((open) => !open)}
>
<Text className="font-t3-bold text-xs text-foreground-secondary underline">
{FULL_ACCESS_OVERRIDE_QUESTION}
{props.approval.detail ? (
<Text className="font-sans text-sm leading-normal text-foreground-secondary">
{props.approval.detail}
</Text>
) : null}
{props.approval.reason ? (
<Text className="font-sans text-xs leading-normal text-warning-foreground">
{props.approval.reason}
</Text>
{explanationOpen ? (
<Text className="mt-1 font-sans text-xs leading-normal text-foreground-muted">
{fullAccessOverrideExplanation(props.approval.fullAccessOverride)}
) : null}
{props.approval.fullAccessOverride ? (
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: explanationOpen }}
onPress={() => setExplanationOpen((open) => !open)}
>
<Text className="font-t3-bold text-xs text-foreground-secondary underline">
{FULL_ACCESS_OVERRIDE_QUESTION}
</Text>
) : null}
</Pressable>
) : null}
{explanationOpen ? (
<Text className="mt-1 font-sans text-xs leading-normal text-foreground-muted">
{fullAccessOverrideExplanation(props.approval.fullAccessOverride)}
</Text>
) : null}
</Pressable>
) : null}
</Animated.ScrollView>
{!canRespond ? (
<Text className="font-sans text-sm leading-5 text-foreground-muted">
The provider process for this request is no longer available. Interrupt or restart the run
Expand Down
8 changes: 6 additions & 2 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1063,13 +1063,16 @@ function ThreadDetailBody(
flexGrow: 0,
}));
// Questions overlay the composer on iOS; an approval sits above it, so it gives up its height.
const approvalHeightStyle = useAnimatedStyle(() => ({
maxHeight: derivePendingUserInputMaxHeight({
const pendingApprovalMaxHeight = useDerivedValue(() =>
derivePendingUserInputMaxHeight({
windowHeight: screenAreaHeight,
keyboardHeight: keyboardStickyEnabled ? -keyboardTranslation.value : 0,
navigationHeaderHeight,
composerOverlapHeight,
}),
);
const approvalHeightStyle = useAnimatedStyle(() => ({
maxHeight: pendingApprovalMaxHeight.value,
flexGrow: 0,
}));
// The usage panel stacks with the composer and any approval card, so it gets
Expand Down Expand Up @@ -1757,6 +1760,7 @@ function ThreadDetailBody(
<PendingApprovalCard
canOperateThread={props.canOperateThread}
approval={props.activePendingApproval}
maxHeight={pendingApprovalMaxHeight}
respondingApprovalId={props.respondingApprovalId}
onRespond={props.onRespondToApproval}
/>
Expand Down
Loading