1070 lines · 44.4 KB · TypeScript
RawDownload
import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";import {  appendCodexArtifactTemplateUsePrompt,  type CodexArtifactTemplate,} from "@t3tools/client-runtime/codex-artifact-templates";import type {  CodexFeedbackSubmission,  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 { BlurTargetView } from "expo-blur";import { GlassBlurTargetContext } from "../../lib/glassBlurTarget";import {  memo,  useCallback,  useContext,  useEffect,  useLayoutEffect,  useMemo,  useRef,  useState,} from "react";import {  Alert,  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,  useAnimatedStyle,  useSharedValue,  withTiming,} from "react-native-reanimated";import { useSafeAreaInsets } from "react-native-safe-area-context";import { useWorkspaceContentWidth } from "../layout/workspace-content-width"; 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 { editPendingThreadMessage } from "../../state/edit-pending-thread-message";import type { QueuedThreadMessage } from "../../state/thread-outbox-model";import { scopedThreadKey } from "../../lib/scopedEntities";import type {  PendingApproval,  PendingUserInput,  PendingUserInputDraftAnswer,  ThreadFeedEntry,} from "../../lib/threadActivity";import { PendingApprovalCard } from "./PendingApprovalCard";import { ComposerFeedback } from "./ComposerFeedback";import { ComposerUsageLimits } from "./ComposerUsageLimits";import { PendingUserInputCard } from "./PendingUserInputCard";import { ThreadCreationFailedCard } from "./ThreadCreationFailedCard";import {  FLOATING_WORKING_CONTROL_COVERAGE,  FloatingWorkingControl,} from "./floating-working-control";import { connectionFloatingStatus, type FloatingWorkingStatus } from "./floating-working-status";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 feedbackSubmissions: ReadonlyArray<CodexFeedbackSubmission>;  readonly onDismissFeedback: (id: MessageId) => void;  readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;  readonly activeWorkStartedAt: string | null;  readonly isCompacting: boolean;  /**   * The server has not created this thread yet. "preparing" runs while the   * queued creation is delivered (a worktree may be checking out); "failed"   * is a rejected creation whose content went back to the project draft.   */  readonly creationState:    | { readonly kind: "preparing"; readonly preparingWorktree: boolean }    | { readonly kind: "failed"; readonly reason: string; readonly onEditTask: () => void }    | 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<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 queuedMessages: ReadonlyArray<QueuedThreadMessage>;  readonly dispatchingMessageId: MessageId | null;  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 onDismissUserInput: () => 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 connection phase while  // disconnected, the sync state while messages load, then the working timer  // once the feed is settled.  const floatingStatus = ((): FloatingWorkingStatus | null => {    const connectionStatus = connectionFloatingStatus({      connectionError: props.connectionError,      connectionState: props.connectionStateLabel,      environmentLabel: props.environmentLabel,      onReconnect: props.onReconnectEnvironment,    });    if (connectionStatus !== null) {      return connectionStatus;    }    if (props.activePendingApproval !== null || props.activePendingUserInput !== null) {      return null;    }    if (props.creationState?.kind === "preparing") {      return {        kind: "preparing",        label: props.creationState.preparingWorktree ? "Setting up worktree…" : "Starting…",      };    }    if (props.creationState?.kind === "failed") {      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;  // Connection and working status occupy the same space. Keep the feed inset  // stable when reconnecting hands off to syncing and then to a running turn.  const showFloatingStatus =    showWorkingControl ||    props.connectionStateLabel !== "connected" ||    props.queuedMessages.length > 0 ||    props.selectedThreadFeed.some(      (entry) => "acknowledged" in entry && entry.acknowledged === true,    );  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.

Lines 1–500 of 1070.