377 lines · 14.7 KB · TypeScript
RawDownload
import { QuestionAttachments } from "./QuestionAttachments";import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts";import { useCallback, useRef } from "react";import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native";import Animated, {  Easing,  FadeInUp,  FadeOutDown,  LinearTransition,  useAnimatedStyle,  useSharedValue,  withTiming,  type SharedValue,} from "react-native-reanimated"; import { USER_INPUT_TOGGLE_DURATION_MS } from "./pendingUserInputLayout"; import { SymbolView } from "../../components/AppSymbol";import { AppText as Text } from "../../components/AppText";import { ControlPill } from "../../components/ControlPill";import { cn } from "../../lib/cn";import {  isPendingUserInputOptionSelected,  type PendingUserInput,  type PendingUserInputDraftAnswer,} from "../../lib/threadActivity"; export interface PendingUserInputCardProps {  readonly pendingUserInput: PendingUserInput;  /**   * Constant while a request is pending (it reserves keyboard space), so the   * keyboard transition is pure translation; changes only on rare discrete   * corrections, which the layout transition smooths.   */  readonly maxHeight: number;  readonly collapsed: boolean;  readonly onToggleCollapsed: () => void;  /** Renders a stop control on the collapsed bar, which replaces the composer. */  readonly onStopThread?: () => void;  /**   * 0 collapsed → 1 expanded. Slides the iOS overlay card down behind the   * collapsed bar (inside a clipping window) on the UI thread; the host   * animates it directly from the tap handler so the card and the feed   * inset glide start the same frame.   */  readonly cardProgress?: SharedValue<number>;  /**   * Receives how far the expanded card extends above the bar footprint   * (written from onLayout with no re-render); the host adds it to the   * thread feed's end inset so the end of the chat stays visible above the   * card.   */  readonly cardCoverage?: SharedValue<number>;  /** Fires on custom-answer focus/blur; hosts use it to vet stale keyboard state. */  readonly onInputFocusChange?: (focused: boolean) => void;  readonly drafts: Record<string, PendingUserInputDraftAnswer>;  readonly answers: Record<string, string | ReadonlyArray<string>> | null;  readonly respondingUserInputId: ApprovalRequestId | null;  readonly onSelectOption: (    requestId: ApprovalRequestId,    question: UserInputQuestion,    value: string,  ) => void;  readonly onChangeCustomAnswer: (    requestId: ApprovalRequestId,    questionId: string,    customAnswer: string,  ) => void;  readonly onSubmit: () => Promise<unknown>;  /** Closes an async question without a reply. Hidden for native callback questions. */  readonly onDismiss: () => Promise<unknown>;} /** * On iOS the collapsed bar is the PERMANENT in-flow footprint — the expanded * card is an absolutely-positioned overlay rising above it. The overlay's * measured height (which drives the thread feed's bottom inset) therefore * never changes on collapse/expand, so the transcript stays perfectly still * while the card animates over it. * * Android cannot use the overlay: it does not hit-test touches outside a * parent's bounds, which made everything above the bar-sized wrapper * untouchable. There the expanded card renders in-flow instead (the wrapper * grows with it, and the host skips the coverage inset since the measured * overlay already includes the card). */const EXPANDED_CARD_IS_OVERLAY = Platform.OS === "ios"; const CARD_LAYOUT_TRANSITION = LinearTransition.duration(200); export function PendingUserInputCard(props: PendingUserInputCardProps) {  const questionCount = props.pendingUserInput.questions.length;   const cardCoverage = props.cardCoverage;  const barHeightRef = useRef(0);  const cardHeightRef = useRef(0);  // Measured card height, written straight from onLayout: the collapse slide  // distance. Not animated — it only changes on discrete relayouts.  const cardHeight = useSharedValue(0);  const notifyCoverage = useCallback(() => {    if (!cardCoverage) {      return;    }    const coverage = Math.max(0, cardHeightRef.current - barHeightRef.current);    if (coverage === cardCoverage.value) {      return;    }    if (cardCoverage.value === 0) {      // First measurement lands while the list is doing its initial      // end-pin (thread opened onto a pending request); animating it from      // zero would move the end anchor out from under that scroll.      cardCoverage.value = coverage;      return;    }    // Animated so a coverage change at rest (discrete max-height    // corrections) glides the feed instead of stepping it; toggle timing is    // owned by the host's progress values.    cardCoverage.value = withTiming(coverage, {      duration: USER_INPUT_TOGGLE_DURATION_MS,      easing: Easing.out(Easing.cubic),    });  }, [cardCoverage]);  const handleBarLayout = useCallback(    (event: LayoutChangeEvent) => {      barHeightRef.current = event.nativeEvent.layout.height;      notifyCoverage();    },    [notifyCoverage],  );  const handleCardLayout = useCallback(    (event: LayoutChangeEvent) => {      cardHeightRef.current = event.nativeEvent.layout.height;      cardHeight.value = event.nativeEvent.layout.height;      notifyCoverage();    },    [cardHeight, notifyCoverage],  );  const cardProgress = props.cardProgress;  // No opacity: fading an opaque card over the live transcript reads as a  // crossfade (card text, transcript, and bar all half-visible at once).  // Instead the card stays opaque and slides its full height down past the  // clipping window's bottom edge, so the transcript is only revealed where  // the card has physically left.  const cardAnimatedStyle = useAnimatedStyle(() => {    const progress = cardProgress === undefined ? 1 : cardProgress.value;    return {      transform: [{ translateY: (1 - progress) * cardHeight.value }],    };  });   // On iOS the card stays MOUNTED while collapsed (hidden via the animated  // style): expanding animates existing views on the UI thread the same  // frame the host starts the progress timing, instead of paying a React  // mount + layout before anything moves.  const renderCard = EXPANDED_CARD_IS_OVERLAY || !props.collapsed;  const showBar = props.collapsed || EXPANDED_CARD_IS_OVERLAY;  // The bar renders UNDER the card (earlier in JSX), always opaque: while  // expanded the opaque card covers it, and during the collapse slide the  // card's top edge wipes past and reveals it — no opacity handoff, so no  // crossfade frames.  const bar = showBar ? (    <View      onLayout={handleBarLayout}      pointerEvents={props.collapsed ? "auto" : "none"}      accessibilityElementsHidden={!props.collapsed}      importantForAccessibility={props.collapsed ? "auto" : "no-hide-descendants"}      className="flex-row items-center gap-2 rounded-full border border-border bg-card-alt py-1.5 pl-4 pr-1.5"    >      <Pressable        accessibilityRole="button"        accessibilityLabel={`Expand user input, ${questionCount} question${          questionCount === 1 ? "" : "s"        }`}        onPress={props.onToggleCollapsed}        className="min-h-10 flex-1 flex-row items-center gap-2 active:opacity-70"      >        <Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-foreground-secondary">          User input needed        </Text>        <Text className="font-sans text-xs text-foreground-muted">          {questionCount} question{questionCount === 1 ? "" : "s"}        </Text>        <View className="flex-1" />        <SymbolView          name="chevron.up"          size={12}          tintColorClassName={"accent-icon-subtle"}          type="monochrome"        />      </Pressable>      {props.onStopThread ? (        <ControlPill          accessibilityLabel="Stop"          icon="stop.fill"          variant="danger"          className="h-9 w-9"          onPress={props.onStopThread}        />      ) : null}    </View>  ) : null;  const card = renderCard ? (    // The surface is opaque on purpose: the card floats over the thread    // feed with no blur behind it, so a translucent background renders    // the questions on top of whatever message happens to sit underneath.    <Animated.View      onLayout={handleCardLayout}      pointerEvents={props.collapsed ? "none" : "auto"}      accessibilityElementsHidden={props.collapsed}      importantForAccessibility={props.collapsed ? "no-hide-descendants" : "auto"}      entering={        EXPANDED_CARD_IS_OVERLAY          ? undefined          : FadeInUp.duration(USER_INPUT_TOGGLE_DURATION_MS).easing(Easing.out(Easing.cubic))      }      exiting={        EXPANDED_CARD_IS_OVERLAY          ? undefined          : FadeOutDown.duration(USER_INPUT_TOGGLE_DURATION_MS).easing(Easing.out(Easing.cubic))      }      layout={CARD_LAYOUT_TRANSITION}      className="overflow-hidden gap-2.5 rounded-[20px] border border-border bg-card-alt p-4"      style={        EXPANDED_CARD_IS_OVERLAY          ? [{ maxHeight: props.maxHeight }, cardAnimatedStyle]          : { maxHeight: props.maxHeight }      }    >      <Pressable        accessibilityRole="button"        accessibilityLabel="Collapse user input"        onPress={props.onToggleCollapsed}        className="flex-row items-start gap-2"      >        <View className="flex-1 gap-2.5">          <Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-foreground-secondary">            User input needed          </Text>          <Text className="font-t3-bold text-lg text-foreground">Fill in the pending answers</Text>        </View>        <View className="h-8 w-8 items-center justify-center rounded-full bg-subtle-strong">          <SymbolView            name="chevron.down"            size={13}            tintColorClassName={"accent-icon-subtle"}            type="monochrome"          />        </View>      </Pressable>      <ScrollView        bounces={false}        className="min-h-0"        contentContainerClassName="gap-2.5 pb-1"        keyboardShouldPersistTaps="handled"        nestedScrollEnabled        showsVerticalScrollIndicator        style={{ flexShrink: 1 }}      >        {props.pendingUserInput.questions.map((question) => {          const draft = props.drafts[question.id];          return (            <View key={question.id} className="gap-2 pt-1">              <Text className="font-t3-bold text-xs uppercase tracking-[1px] text-foreground-muted">                {question.header}              </Text>              <Text className="font-sans text-base leading-snug text-foreground">                {question.question}              </Text>              <View className="gap-2">                {question.options.map((option) => {                  const optionValue = option.value ?? option.label.trim();                  const selected = isPendingUserInputOptionSelected(question, draft, optionValue);                  const description =                    option.description !== option.label ? option.description : undefined;                  return (                    <Pressable                      key={optionValue}                      className={cn(                        "min-h-12 w-full rounded-2xl border px-3.5 py-3",                        selected ? "border-primary bg-primary/10" : "border-border bg-input",                      )}                      onPress={() =>                        props.onSelectOption(                          props.pendingUserInput.requestId,                          question,                          optionValue,                        )                      }                    >                      <View className="min-w-0 flex-1 gap-0.5">                        <Text                          className={cn(                            "font-t3-bold text-sm",                            selected ? "text-foreground" : "text-foreground-secondary",                          )}                        >                          {option.label}                        </Text>                        {description ? (                          <Text className="font-sans text-sm leading-5 text-foreground-muted">                            {description}                          </Text>                        ) : null}                      </View>                    </Pressable>                  );                })}              </View>              <QuestionAttachments                requestId={props.pendingUserInput.requestId}                question={question}                questions={props.pendingUserInput.questions}                disabled={props.respondingUserInputId === props.pendingUserInput.requestId}                value={draft?.customAnswer ?? ""}                onChangeText={(value) =>                  props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value)                }                onInputFocusChange={props.onInputFocusChange}              />            </View>          );        })}      </ScrollView>      <Pressable        className={cn(          "items-center justify-center rounded-2xl px-4 py-3.5",          props.answers ? "bg-primary" : "bg-subtle-strong",        )}        disabled={          props.answers === null || props.respondingUserInputId === props.pendingUserInput.requestId        }        onPress={() => void props.onSubmit()}      >        <Text          className={cn(            "font-t3-extrabold text-sm",            props.answers ? "text-primary-foreground" : "text-foreground-muted",          )}        >          Submit answers        </Text>      </Pressable>      {props.pendingUserInput.dismissible ? (        <Pressable          accessibilityRole="button"          className="items-center justify-center rounded-2xl px-4 py-2.5 active:opacity-70"          disabled={props.respondingUserInputId === props.pendingUserInput.requestId}          onPress={() => void props.onDismiss()}        >          <Text className="font-t3-bold text-sm text-foreground-muted">            Dismiss without answering          </Text>        </Pressable>      ) : null}    </Animated.View>  ) : null;  return (    <View className="relative">      {bar}      {EXPANDED_CARD_IS_OVERLAY ? (        // Clipping window for the collapse slide: same footprint as the        // expanded card, bottom edge on the bar's bottom edge. The sliding        // card exits through the bottom edge instead of drawing over the        // composer area, wiping the bar (and the transcript) into view.        <View          pointerEvents={props.collapsed ? "none" : "box-none"}          className="absolute inset-x-0 bottom-0 justify-end overflow-hidden"          style={{ height: props.maxHeight }}        >          {card}        </View>      ) : (        card      )}    </View>  );}