918 lines · 38.2 KB · TypeScript
RawDownload
import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";import type { LegendListRef } from "@legendapp/list/react-native";import { HeaderHeightContext } from "@react-navigation/elements";import type {  EnvironmentId,  MessageId,  ModelSelection,  ProviderApprovalDecision,  ProviderInteractionMode,  RuntimeMode,  RuntimeRequestId,  ServerConfig as T3ServerConfig,  ThreadId,} from "@t3tools/contracts";import {  appendCodexArtifactTemplateUsePrompt,  type CodexArtifactTemplate,} from "@t3tools/client-runtime/codex-artifact-templates";import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests";import * as Haptics from "expo-haptics";import {  memo,  useCallback,  useContext,  useEffect,  useLayoutEffect,  useMemo,  useRef,  useState,} from "react";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,  ReduceMotion,  useAnimatedReaction,  useSharedValue,  withTiming,} from "react-native-reanimated";import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";import type { ComposerEditorHandle } from "../../components/ComposerEditor";import type { StatusTone } from "../../components/StatusPill";import type { DraftComposerAttachment } from "../../lib/composerImages";import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";import { scopedThreadKey } from "../../lib/scopedEntities";import { threadEnvironment } from "../../state/threads";import { useAtomCommand } from "../../state/use-atom-command";import type {  PendingApproval,  PendingUserInput,  PendingUserInputDraftAnswer,  ThreadFeedEntry,  ThreadFeedLatestRun,} from "../../lib/threadActivity";import { PendingApprovalCard } from "./PendingApprovalCard";import { PendingUserInputCard } from "./PendingUserInputCard";import {  FLOATING_WORKING_CONTROL_COVERAGE,  FloatingWorkingControl,  type FloatingWorkingStatus,} from "./floating-working-control";import {  derivePendingUserInputMaxHeight,  ESTIMATED_KEYBOARD_HEIGHT,  USER_INPUT_TOGGLE_DURATION_MS,} from "./pendingUserInputLayout";import {  COMPOSER_COLLAPSED_CHROME,  COMPOSER_EXPANDED_CHROME,  COMPOSER_LAYOUT_TRANSITION,  COMPOSER_TRANSITION_DURATION_MS,  ThreadComposer,} from "./ThreadComposer";import { ThreadFeed, type ThreadFeedHistoryControls } from "./ThreadFeed";import { ThreadRelationshipsBanner } from "./ThreadRelationshipsBanner";import { ThreadQueueControl } from "./ThreadQueueControl";import type { ThreadContentPresentation } from "./threadContentPresentation";import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; export interface ThreadDetailScreenProps {  readonly selectedThread: EnvironmentThreadShell;  readonly contentPresentation: ThreadContentPresentation;  readonly screenTone: StatusTone;  readonly connectionError: string | null;  readonly environmentLabel: string | null;  readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;  readonly activityRun: ThreadFeedLatestRun | null;  readonly activeWorkStartedAt: string | null;  readonly isCompacting: boolean;  readonly activePendingApproval: PendingApproval | null;  readonly respondingApprovalId: RuntimeRequestId | null;  readonly activePendingUserInput: PendingUserInput | null;  readonly activePendingUserInputDrafts: Record<string, PendingUserInputDraftAnswer>;  readonly activePendingUserInputAnswers: Record<string, string | ReadonlyArray<string>> | null;  readonly respondingUserInputId: RuntimeRequestId | null;  readonly draftMessage: string;  readonly draftAttachments: ReadonlyArray<DraftComposerAttachment>;  readonly connectionStateLabel: EnvironmentConnectionPhase;  /** Message sync status for the selected thread (drives the composer status pill). */  readonly threadSyncStatus?: EnvironmentThreadStatus;  /** Progressive history controls for oversized mobile thread opens. */  readonly historyControls?: ThreadFeedHistoryControls;  readonly activeThreadBusy: boolean;  readonly canStopThread: boolean;  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 onPickDraftMedia: () => Promise<void>;  readonly onPickDraftFiles: () => 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: RuntimeRequestId,    decision: ProviderApprovalDecision,  ) => Promise<unknown>;  readonly onSelectUserInputOption: (    requestId: RuntimeRequestId,    question: ThreadUserInputQuestion,    value: string,  ) => void;  readonly onChangeUserInputCustomAnswer: (    requestId: RuntimeRequestId,    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 + 44;  const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;  const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);  const composerEditorRef = useRef<ComposerEditorHandle>(null);  const draftMessageRef = useRef(props.draftMessage);  draftMessageRef.current = props.draftMessage;  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 [composerFocused, setComposerFocused] = useState(false);  const handleComposerFocusChange = useCallback(    (focused: boolean) => {      setComposerFocused(focused);      handleOwnedInputFocusChange(focused);    },    [handleOwnedInputFocusChange],  );  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.  // Dictation keeps that focus while the composer switches to its compact pill.  const composerBottomInset = (    Platform.OS === "android" ? isKeyboardVisible : composerExpanded || composerFocused  )    ? 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 threadSyncLabel = (() => {    switch (props.threadSyncStatus) {      case "empty":      case "cached":      case "synchronizing":        if (contentPresentationKind === "ready") {          return "Syncing messages...";        }        return contentPresentationKind === "loading" ? "Loading messages..." : null;      default:        return null;    }  })();  // One floating pill above the composer: it reads the sync state while  // messages load, then the working timer once the feed is settled.  const floatingStatus = ((): FloatingWorkingStatus | null => {    if (      props.connectionStateLabel !== "connected" ||      props.activePendingApproval !== null ||      props.activePendingUserInput !== null    ) {      return null;    }    if (threadSyncLabel !== null) {      return { kind: "syncing", label: threadSyncLabel };    }    if (props.isCompacting && contentPresentationKind === "ready") {      return { kind: "compacting" };    }    if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") {      return { kind: "working", startedAt: props.activeWorkStartedAt };    }    return null;  })();  const showWorkingControl = floatingStatus !== null;  const selectedThreadFeed = props.selectedThreadFeed;  const hasCompactableConversation =    selectedThreadFeed.some(      (entry) =>        entry.type === "message" &&        entry.message.role === "user" &&        ((entry.message.attachments?.length ?? 0) > 0 ||          entry.message.text.trim().toLowerCase() !== "/compact"),    ) ||    (props.historyControls?.hasMoreHistory === true &&      props.selectedThread.latestUserMessageAt !== null);  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<RuntimeRequestId | 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,    Platform.OS === "ios" ? COMPOSER_TRANSITION_DURATION_MS : 0,  );  // 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);  const floatingControlCoverage = useSharedValue(    showWorkingControl ? FLOATING_WORKING_CONTROL_COVERAGE : 0,  );  useEffect(() => {    floatingControlCoverage.value = withTiming(      showWorkingControl ? FLOATING_WORKING_CONTROL_COVERAGE : 0,      { duration: 180, reduceMotion: ReduceMotion.System },    );  }, [floatingControlCoverage, showWorkingControl]);  // 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 +      floatingControlCoverage.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 overlayRepinTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);  const previousWorkingControlStateRef = useRef({    threadKey: selectedThreadKey,    visible: false,  });  // 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 scheduleOverlayRepin = useCallback(    (delayMs: number) => {      if (overlayRepinTimerRef.current !== null) {        clearTimeout(overlayRepinTimerRef.current);      }      overlayRepinTimerRef.current = setTimeout(() => {        overlayRepinTimerRef.current = null;        if (!endFollowEnabledRef.current) {          return;        }        void scrollMessageToEnd({ animated: false, closeKeyboard: false }).catch(() => {          freeze.set(false);        });      }, delayMs);    },    [freeze, scrollMessageToEnd],  );  useEffect(    () => () => {      if (overlayRepinTimerRef.current !== null) {        clearTimeout(overlayRepinTimerRef.current);      }    },    [],  );  useEffect(() => {    const previous = previousWorkingControlStateRef.current;    const threadChanged = previous.threadKey !== selectedThreadKey;    const visibilityChanged = previous.visible !== showWorkingControl;    previousWorkingControlStateRef.current = {      threadKey: selectedThreadKey,      visible: showWorkingControl,    };    if ((!threadChanged && !visibilityChanged) || (threadChanged && !showWorkingControl)) {      return;    }

Lines 1–500 of 918.