777 lines · 32.1 KB · TypeScript
RawDownload
import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";import type { LegendListRef } from "@legendapp/list/react-native";import { HeaderHeightContext } from "@react-navigation/elements";import type {  ApprovalRequestId,  EnvironmentId,  MessageId,  ModelSelection,  OrchestrationThreadShell,  ProviderApprovalDecision,  ProviderInteractionMode,  RuntimeMode,  ServerConfig as T3ServerConfig,  ThreadId,  UserInputQuestion,} from "@t3tools/contracts";import * as Haptics from "expo-haptics";import {  memo,  useCallback,  useContext,  useEffect,  useLayoutEffect,  useMemo,  useRef,  useState,} from "react";import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";import {  AppState,  Keyboard,  Platform,  useWindowDimensions,  View,  type GestureResponderEvent,} from "react-native";import {  KeyboardController,  KeyboardStickyView,  useKeyboardState,} from "react-native-keyboard-controller";import Animated, {  Easing,  FadeInDown,  FadeOut,  useAnimatedReaction,  useSharedValue,  withTiming,} from "react-native-reanimated";import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ControlPill } from "../../components/ControlPill";import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";import type { ComposerEditorHandle } from "../../components/ComposerEditor";import type { StatusTone } from "../../components/StatusPill";import type { DraftComposerImageAttachment } from "../../lib/composerImages";import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";import { scopedThreadKey } from "../../lib/scopedEntities";import type {  PendingApproval,  PendingUserInput,  PendingUserInputDraftAnswer,  ThreadFeedEntry,} from "../../lib/threadActivity";import { PendingApprovalCard } from "./PendingApprovalCard";import { PendingUserInputCard } from "./PendingUserInputCard";import {  derivePendingUserInputMaxHeight,  ESTIMATED_KEYBOARD_HEIGHT,  USER_INPUT_TOGGLE_DURATION_MS,} from "./pendingUserInputLayout";import {  COMPOSER_COLLAPSED_CHROME,  COMPOSER_EXPANDED_CHROME,  ThreadComposer,} from "./ThreadComposer";import { ThreadFeed } from "./ThreadFeed";import type { ThreadContentPresentation } from "./threadContentPresentation";import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; export interface ThreadDetailScreenProps {  readonly selectedThread: OrchestrationThreadShell;  readonly contentPresentation: ThreadContentPresentation;  readonly screenTone: StatusTone;  readonly connectionError: string | null;  readonly environmentLabel: string | null;  readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;  readonly activeWorkStartedAt: string | null;  readonly activePendingApproval: PendingApproval | null;  readonly respondingApprovalId: ApprovalRequestId | null;  readonly activePendingUserInput: PendingUserInput | null;  readonly activePendingUserInputDrafts: Record<string, PendingUserInputDraftAnswer>;  readonly activePendingUserInputAnswers: Record<string, string | ReadonlyArray<string>> | null;  readonly respondingUserInputId: ApprovalRequestId | null;  readonly draftMessage: string;  readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;  readonly connectionStateLabel: EnvironmentConnectionPhase;  /** Message sync status for the selected thread (drives the composer status pill). */  readonly threadSyncStatus?: EnvironmentThreadStatus;  /** Non-null when older turns exist beyond the loaded window. */  readonly loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null;  readonly environmentId: EnvironmentId;  readonly projectWorkspaceRoot: string | null;  readonly threadCwd: string | null;  readonly selectedThreadQueueCount: number;  readonly serverConfig: T3ServerConfig | null;  readonly layoutVariant?: LayoutVariant;  readonly usesAutomaticContentInsets?: boolean;  readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;  readonly onOpenConnectionEditor: () => void;  readonly onChangeDraftMessage: (value: string) => void;  readonly onPickDraftImages: () => Promise<void>;  readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;  readonly onRemoveDraftImage: (imageId: string) => void;  readonly onStopThread: () => void;  readonly onSendMessage: () => Promise<MessageId | null>;  readonly onReconnectEnvironment: () => void;  readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void;  readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void;  readonly onUpdateThreadInteractionMode: (interactionMode: ProviderInteractionMode) => void;  readonly onRespondToApproval: (    requestId: ApprovalRequestId,    decision: ProviderApprovalDecision,  ) => Promise<unknown>;  readonly onSelectUserInputOption: (    requestId: ApprovalRequestId,    question: UserInputQuestion,    label: string,  ) => void;  readonly onChangeUserInputCustomAnswer: (    requestId: ApprovalRequestId,    questionId: string,    customAnswer: string,  ) => void;  readonly onSubmitUserInput: () => Promise<unknown>;  readonly showContent?: boolean;} function latestStreamingAssistantMessage(  feed: ReadonlyArray<ThreadFeedEntry>,): { readonly id: string; readonly textLength: number } | null {  for (let index = feed.length - 1; index >= 0; index -= 1) {    const entry = feed[index];    if (entry?.type !== "message") {      continue;    }    if (entry.message.role !== "assistant" || !entry.message.streaming) {      continue;    }    return {      id: entry.message.id,      textLength: entry.message.text.length,    };  }   return null;} function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedEntry>) {  const lastStreamingAssistantRef = useRef<{    readonly id: string;    readonly textLength: number;  } | null>(null);  const lastStreamHapticAtRef = useRef(0);  const hydratedRef = useRef(false);  const previousThreadIdRef = useRef(threadId);   useEffect(() => {    if (previousThreadIdRef.current !== threadId) {      previousThreadIdRef.current = threadId;      hydratedRef.current = false;    }     const latestStreamingMessage = latestStreamingAssistantMessage(feed);     if (!hydratedRef.current) {      hydratedRef.current = true;      lastStreamingAssistantRef.current = latestStreamingMessage;      return;    }     if (!latestStreamingMessage) {      lastStreamingAssistantRef.current = null;      return;    }     const previousStreamingMessage = lastStreamingAssistantRef.current;    lastStreamingAssistantRef.current = latestStreamingMessage;     const isNewStream = previousStreamingMessage?.id !== latestStreamingMessage.id;    const textGrew =      previousStreamingMessage?.id === latestStreamingMessage.id &&      latestStreamingMessage.textLength > previousStreamingMessage.textLength;     if (!isNewStream && !textGrew) {      return;    }     const now = Date.now();    if (!isNewStream && now - lastStreamHapticAtRef.current < 320) {      return;    }     lastStreamHapticAtRef.current = now;    void Haptics.selectionAsync();  }, [threadId, feed]);} const USER_INPUT_TOGGLE_TIMING = {  duration: USER_INPUT_TOGGLE_DURATION_MS,  easing: Easing.out(Easing.cubic),}; export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {  const insets = useSafeAreaInsets();  const isKeyboardVisible = useKeyboardState((state) => state.isVisible);  const liveKeyboardHeight = useKeyboardState((state) => state.height);  // Android can swallow the IME hide callbacks when the app is backgrounded  // mid keyboard-hide (the reported repro: send — which blurs and starts the  // hide — then Home within a second). The keyboard library's height AND  // visibility then stay frozen open, so gating the sticky translation on  // visibility alone still strands the composer after resume. Quarantine the  // translation on every Android resume instead; any sign of a live keyboard  // stream — an owned input gaining focus, or any visibility/height movement —  // lifts it. A healthy resume sees no visual difference (the translation is  // already zero while the keyboard is closed).  const [keyboardStateSuspect, setKeyboardStateSuspect] = useState(false);  useEffect(() => {    if (Platform.OS !== "android") {      return;    }    const subscription = AppState.addEventListener("change", (state) => {      if (state === "active") {        setKeyboardStateSuspect(true);      }    });    return () => {      subscription.remove();    };  }, []);  useEffect(() => {    setKeyboardStateSuspect(false);  }, [isKeyboardVisible, liveKeyboardHeight]);  const handleOwnedInputFocusChange = useCallback((focused: boolean) => {    if (focused) {      setKeyboardStateSuspect(false);    }  }, []);  const windowHeight = useWindowDimensions().height;  const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + IOS_NAV_BAR_HEIGHT;  const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;  const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);  const composerEditorRef = useRef<ComposerEditorHandle>(null);  const composerOverlayRef = useRef<View>(null);  const listRef = useRef<LegendListRef>(null);  const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null);  const selectedThreadKeyRef = useRef(selectedThreadKey);  const lastScrolledSubmittedMessageIdRef = useRef<MessageId | null>(null);  const [composerExpanded, setComposerExpanded] = useState(false);  const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null);  const [submittedMessageId, setSubmittedMessageId] = useState<MessageId | null>(null);  const [endFollowEnabled, setEndFollowEnabled] = useState(true);  // Android keys the safe-area padding on keyboard visibility (#5988): the  // back gesture closes the keyboard while the editor stays focused, and a  // focus-keyed inset would leave the toolbar under the gesture bar. iOS must  // NOT use visibility — it only flips on keyboardDidHide, after the hide  // animation, so the composer would ride down flush to the screen edge and  // then snap up into the inset. On iOS blur precedes the hide, so the  // focus-keyed inset is already in place while the composer rides down.  const composerBottomInset = (Platform.OS === "android" ? isKeyboardVisible : composerExpanded)    ? 0    : Math.max(insets.bottom, 12);  const contentPresentationKind = props.contentPresentation.kind;  // The raw sync status enters "synchronizing" on every full fetch, cached or  // not. Whether messages are already on screen decides the pill label: no  // data yet → "Loading messages", cached data reconciling → "Syncing".  const threadSyncPhase = (() => {    switch (props.threadSyncStatus) {      case "empty":      case "cached":      case "synchronizing":        if (contentPresentationKind === "ready") {          return "syncing" as const;        }        return contentPresentationKind === "loading" ? ("loading" as const) : null;      default:        return null;    }  })();  const selectedThreadFeed = props.selectedThreadFeed;  const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;  const composerOverlapHeight = composerChrome + composerBottomInset;  // While a user-input request is pending, the questionnaire owns the  // composer slot outright: expanded it is the full card, collapsed it is a  // composer-style bar in the same place (with its own stop control). The  // composer never mounts into the transition, which keeps the collapse and  // keyboard animations coherent. Collapse state is keyed by request id so a  // new request re-expands automatically.  const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] =    useState<ApprovalRequestId | null>(null);  const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null;  const userInputCollapsed =    activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId;  // The card's height RESERVES keyboard space at all times instead of  // tracking the keyboard: transforms (the sticky translation) apply  // same-frame on the UI thread while layout props lag a Yoga pass behind,  // so any height that follows the keyboard flashes the card over the nav  // header on the way up. With a constant height the keyboard transition is  // pure translation — frame-perfect by construction — and the resting card  // stays compact over the transcript. Before the first open the reserve is  // an estimate; once a real height is known the card corrects once,  // discretely.  const [lastKnownKeyboardHeight, setLastKnownKeyboardHeight] = useState(0);  useEffect(() => {    if (liveKeyboardHeight > 0 && liveKeyboardHeight !== lastKnownKeyboardHeight) {      setLastKnownKeyboardHeight(liveKeyboardHeight);    }  }, [lastKnownKeyboardHeight, liveKeyboardHeight]);  const pendingUserInputMaxHeight = derivePendingUserInputMaxHeight({    windowHeight,    keyboardHeight:      lastKnownKeyboardHeight > 0 ? lastKnownKeyboardHeight : ESTIMATED_KEYBOARD_HEIGHT,    navigationHeaderHeight,    // The questionnaire owns the composer slot, so only the composer's    // bottom inset still overlaps.    composerOverlapHeight: composerBottomInset,  });  const estimatedOverlayHeight = composerOverlapHeight;  // The overlay's measured height includes the home-indicator inset (the  // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes  // UIKit add the safe-area bottom to the content inset AGAIN — leaving a  // dead strip between the resting content and the composer. Report the  // overlay height minus the safe area; UIKit adds it back, and ThreadFeed  // hands LegendList the same delta via contentInsetEndStaticAdjustment so  // its end-scroll math matches the real resting position.  const nativeInsetOvercount =    props.usesAutomaticContentInsets === true && Platform.OS === "ios" ? insets.bottom : 0;  const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset(    listRef,    composerOverlayRef,    Math.max(0, estimatedOverlayHeight - nativeInsetOvercount),    -nativeInsetOvercount,  );  // The expanded questionnaire is an absolute overlay on iOS, so it never  // changes the measured overlay height (that constancy is what keeps the  // feed from snapping on collapse/expand). The toggle choreography runs on  // SHARED VALUES set directly in the tap handler — one JS hop, then the  // card's rise/sink and the feed's end-inset glide animate in lockstep on  // the UI thread, keyboard-style, instead of waiting on React mount +  // onLayout + state round trips. Coverage (how far the card extends above  // the bar) is measured straight into a shared value by the card's  // onLayout, with no re-render.  const userInputCardProgress = useSharedValue(1);  const userInputInsetProgress = useSharedValue(1);  const userInputCardCoverage = useSharedValue(0);  // Android renders the expanded card in-flow (it cannot hit-test the iOS  // overlay outside the bar's bounds), so its measured overlay height already  // includes the card — the coverage extra is iOS-only.  const userInputCoverageApplies = Platform.OS === "ios" && activeUserInputRequestId !== null;  const combinedContentInsetEndAdjustment = useSharedValue(    Math.max(0, estimatedOverlayHeight - nativeInsetOvercount),  );  useAnimatedReaction(    () =>      contentInsetEndAdjustment.value +      (userInputCoverageApplies ? userInputInsetProgress.value * userInputCardCoverage.value : 0),    (value) => {      combinedContentInsetEndAdjustment.value = value;    },    [userInputCoverageApplies],  );  const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });  const endFollowEnabledRef = useRef(true);  endFollowEnabledRef.current = endFollowEnabled;  const userInputRepinTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);  // The list's own corrections for these inset changes drift on short  // content (and the error compounds across toggles), so deterministically  // re-pin the end once a toggle settles: a no-op when the resting position  // is already right, corrective when it is not. Follow state is re-checked  // inside the callback — the user may grab the list during the settle  // window, and yanking them back would override a live gesture.  const scheduleUserInputRepin = useCallback(    (delayMs: number) => {      if (userInputRepinTimerRef.current !== null) {        clearTimeout(userInputRepinTimerRef.current);      }      userInputRepinTimerRef.current = setTimeout(() => {        userInputRepinTimerRef.current = null;        if (!endFollowEnabledRef.current) {          return;        }        void scrollMessageToEnd({ animated: false, closeKeyboard: false }).catch(() => {          freeze.set(false);        });      }, delayMs);    },    [freeze, scrollMessageToEnd],  );  useEffect(    () => () => {      if (userInputRepinTimerRef.current !== null) {        clearTimeout(userInputRepinTimerRef.current);      }    },    [],  );  const handleToggleUserInputCollapsed = useCallback(() => {    if (activeUserInputRequestId === null) {      return;    }    if (userInputCollapsed) {      // Expanding: card and feed glide start NOW, on the UI thread.      userInputCardProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING);      userInputInsetProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING);      setCollapsedUserInputRequestId(null);      scheduleUserInputRepin(USER_INPUT_TOGGLE_DURATION_MS + 50);    } else {      // Collapsing hides the custom-answer inputs; release the keyboard with      // them instead of leaving it up over a dead responder.      Keyboard.dismiss();      userInputCardProgress.value = withTiming(0, USER_INPUT_TOGGLE_TIMING);      // Instant: the sinking card still covers the strip being revealed, and      // animating the inset downward is what drifted the short-content end      // anchor.      userInputInsetProgress.value = 0;      setCollapsedUserInputRequestId(activeUserInputRequestId);      scheduleUserInputRepin(60);    }  }, [    activeUserInputRequestId,    scheduleUserInputRepin,    userInputCardProgress,    userInputCollapsed,    userInputInsetProgress,  ]);  useEffect(() => {    // A new request always arrives expanded.    userInputCardProgress.value = 1;    userInputInsetProgress.value = 1;  }, [activeUserInputRequestId, userInputCardProgress, userInputInsetProgress]);  const showContent = props.showContent ?? true;  const layoutVariant = props.layoutVariant ?? "compact";  const isSplitLayout = layoutVariant === "split";  const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined;  const selectedInstanceId = props.selectedThread.modelSelection.instanceId;  useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);  const selectedProviderSkills = useMemo(    () =>      props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId)        ?.skills ?? [],    [props.serverConfig, selectedInstanceId],  );   useLayoutEffect(() => {    selectedThreadKeyRef.current = selectedThreadKey;  }, [selectedThreadKey]);   useEffect(() => {    setAnchorMessageId(null);    setSubmittedMessageId(null);    lastScrolledSubmittedMessageIdRef.current = null;    setEndFollowEnabled(true);    freeze.set(false);  }, [freeze, selectedThreadKey]);   useEffect(() => {    if (      submittedMessageId === null ||      lastScrolledSubmittedMessageIdRef.current === submittedMessageId ||      contentPresentationKind !== "ready" ||      !selectedThreadFeed.some(        (entry) => entry.type === "message" && entry.id === submittedMessageId,      )    ) {      return;    }     const targetThreadKey = selectedThreadKey;    const frame = requestAnimationFrame(() => {      if (selectedThreadKeyRef.current !== targetThreadKey) {        return;      }      lastScrolledSubmittedMessageIdRef.current = submittedMessageId;      // Wait for the keyboard dismissal (started by blur() on send) to finish      // before scrolling: scrollMessageToEnd freezes keyboard-driven inset      // updates while it runs, and a close event swallowed by that freeze      // leaves the keyboard padding permanently applied — overshooting the      // anchor and leaving a phantom bottom inset once the reply streams in.      void KeyboardController.dismiss()        .then(() => {          if (            selectedThreadKeyRef.current !== targetThreadKey ||            lastScrolledSubmittedMessageIdRef.current !== submittedMessageId          ) {            return;          }          return scrollMessageToEnd({ animated: true, closeKeyboard: false });

Lines 1–500 of 777.