946 lines · 39.0 KB · TypeScript
RawDownload
import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";import {  appendCodexArtifactTemplateUsePrompt,  type CodexArtifactTemplate,} from "@t3tools/client-runtime/codex-artifact-templates";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 {  ApprovalRequestId,  EnvironmentId,  MessageId,  ModelSelection,  OrchestrationThreadShell,  ProviderApprovalDecision,  ProviderInteractionMode,  RuntimeMode,  ServerConfig as T3ServerConfig,  ThreadId,  UsageLimitsReport,  UserInputQuestion,} from "@t3tools/contracts";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 { collectProviderUsageLimits } from "@t3tools/shared/usageLimits";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 { 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 { ComposerUsageLimits } from "./ComposerUsageLimits";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 } 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 isCompacting: boolean;  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<DraftComposerAttachment>;  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 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: ApprovalRequestId,    decision: ProviderApprovalDecision,  ) => Promise<unknown>;  readonly onSelectUserInputOption: (    requestId: ApprovalRequestId,    question: UserInputQuestion,    value: 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 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"),    ) ||    (Boolean(props.loadEarlier) && 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<ApprovalRequestId | null>(null);  const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null;  // The open /usage-limits panel for this thread, model and turn. Only the open  // moment is stored: the rows read live provider data, so a redeemed reset  // credit or refreshed probe shows through. Anything that spends quota closes  // it: a new turn from any source, or the agent resuming after an approval or  // answered question.  const [usageLimitsPanel, setUsageLimitsPanel] = useState<{    readonly key: string;    readonly threadKey: string;    readonly now: number;  } | null>(null);  // A pending approval or question is part of the key: once it is answered,  // from this client or any other, the agent resumes and spends quota.  const usageLimitsKey = [    selectedThreadKey,    props.selectedThread.modelSelection.instanceId,    props.selectedThread.latestTurn?.turnId ?? "",    props.activePendingApproval?.requestId ?? props.activePendingUserInput?.requestId ?? "",  ].join(":");  // Drop the snapshot as soon as the key changes so it cannot resurface stale.  if (usageLimitsPanel !== null && usageLimitsPanel.key !== usageLimitsKey) {    setUsageLimitsPanel(null);  }  const usageLimitsReport = useMemo(    () =>      usageLimitsPanel !== null && usageLimitsPanel.key === usageLimitsKey        ? collectProviderUsageLimits(            props.selectedThread.modelSelection.instanceId,            props.serverConfig?.providers ?? [],            props.serverConfig?.usageLimitSources ?? [],            usageLimitsPanel.now,          )        : null,    [      props.selectedThread.modelSelection.instanceId,      props.serverConfig,      usageLimitsKey,      usageLimitsPanel,    ],  );  const showUsageLimits = useCallback(    (report: UsageLimitsReport | null) =>      setUsageLimitsPanel(        report === null          ? null          : {              key: usageLimitsKey,              threadKey: selectedThreadKey,              now: Date.parse(report.createdAt),            },      ),    [selectedThreadKey, usageLimitsKey],  );  const dismissUsageLimits = useCallback(() => setUsageLimitsPanel(null), []);  // A send may resolve after navigating away, so only the originating  // thread's panel is cleared; a panel opened elsewhere in the meantime stays.  const clearUsageLimitsFor = useCallback(    (threadKey: string) =>      setUsageLimitsPanel((current) =>        current !== null && current.threadKey === threadKey ? null : current,      ),    [],  );  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 +

Lines 1–500 of 946.