453 lines · 18.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 type {  ApprovalRequestId,  EnvironmentId,  MessageId,  ModelSelection,  OrchestrationThreadShell,  ProviderApprovalDecision,  ProviderInteractionMode,  RuntimeMode,  ServerConfig as T3ServerConfig,  ThreadId,} from "@t3tools/contracts";import * as Haptics from "expo-haptics";import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";import { Platform, View, type GestureResponderEvent } from "react-native";import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller";import Animated, { FadeInDown, FadeOut } from "react-native-reanimated";import { useSafeAreaInsets } from "react-native-safe-area-context"; 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 { scopedThreadKey } from "../../lib/scopedEntities";import type {  PendingApproval,  PendingUserInput,  PendingUserInputDraftAnswer,  ThreadFeedEntry,} from "../../lib/threadActivity";import { PendingApprovalCard } from "./PendingApprovalCard";import { PendingUserInputCard } from "./PendingUserInputCard";import {  COMPOSER_COLLAPSED_CHROME,  COMPOSER_EXPANDED_CHROME,  ThreadComposer,} from "./ThreadComposer";import { ThreadFeed } from "./ThreadFeed";import type { ThreadContentPresentation } from "./threadContentPresentation"; 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> | 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;  readonly activeThreadBusy: 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 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,    questionId: string,    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]);} export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {  const insets = useSafeAreaInsets();  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 lastScrolledAnchorMessageIdRef = useRef<MessageId | null>(null);  const [composerExpanded, setComposerExpanded] = useState(false);  const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null);  const composerBottomInset = 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;  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,  );  const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });  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);    lastScrolledAnchorMessageIdRef.current = null;    freeze.set(false);  }, [freeze, selectedThreadKey]);   useEffect(() => {    if (      anchorMessageId === null ||      lastScrolledAnchorMessageIdRef.current === anchorMessageId ||      contentPresentationKind !== "ready" ||      !selectedThreadFeed.some((entry) => entry.type === "message" && entry.id === anchorMessageId)    ) {      return;    }     const targetThreadKey = selectedThreadKey;    const frame = requestAnimationFrame(() => {      if (selectedThreadKeyRef.current !== targetThreadKey) {        return;      }      lastScrolledAnchorMessageIdRef.current = anchorMessageId;      // 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 ||            lastScrolledAnchorMessageIdRef.current !== anchorMessageId          ) {            return;          }          return scrollMessageToEnd({ animated: true, closeKeyboard: false });        })        .catch(() => {          if (            selectedThreadKeyRef.current !== targetThreadKey ||            lastScrolledAnchorMessageIdRef.current !== anchorMessageId          ) {            return;          }          lastScrolledAnchorMessageIdRef.current = null;          freeze.set(false);        });    });    return () => cancelAnimationFrame(frame);  }, [    anchorMessageId,    freeze,    contentPresentationKind,    selectedThreadFeed,    scrollMessageToEnd,    selectedThreadKey,  ]);   const handleSendMessage = useCallback(async () => {    const targetThreadKey = selectedThreadKey;    const messageId = await props.onSendMessage();    if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) {      return messageId;    }     setAnchorMessageId(messageId);    composerEditorRef.current?.blur();    return messageId;  }, [props.onSendMessage, selectedThreadKey]);   const collapseComposer = useCallback(() => {    composerEditorRef.current?.blur();  }, []);   const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => {    feedTouchStartRef.current = {      pageX: event.nativeEvent.pageX,      pageY: event.nativeEvent.pageY,    };  }, []);   const handleFeedTouchMove = useCallback((event: GestureResponderEvent) => {    const start = feedTouchStartRef.current;    if (!start) {      return;    }    const deltaX = event.nativeEvent.pageX - start.pageX;    const deltaY = event.nativeEvent.pageY - start.pageY;    if (Math.hypot(deltaX, deltaY) > 8) {      feedTouchStartRef.current = null;    }  }, []);   const handleFeedTouchEnd = useCallback(() => {    if (feedTouchStartRef.current) {      collapseComposer();    }    feedTouchStartRef.current = null;  }, [collapseComposer]);   const handleFeedTouchCancel = useCallback(() => {    feedTouchStartRef.current = null;  }, []);   return (    <View className="flex-1">      {showContent ? (        <View          className="flex-1"          onTouchStart={handleFeedTouchStart}          onTouchMove={handleFeedTouchMove}          onTouchEnd={handleFeedTouchEnd}          onTouchCancel={handleFeedTouchCancel}        >          <ThreadFeed            key={props.selectedThread.id}            environmentId={props.environmentId}            threadId={props.selectedThread.id}            workspaceRoot={props.threadCwd}            feed={props.selectedThreadFeed}            contentPresentation={props.contentPresentation}            agentLabel={agentLabel}            latestTurn={props.selectedThread.latestTurn}            activeWorkStartedAt={props.activeWorkStartedAt}            listRef={listRef}            freeze={freeze}            anchorMessageId={anchorMessageId}            contentInsetEndAdjustment={contentInsetEndAdjustment}            contentTopInset={0}            contentBottomInset={estimatedOverlayHeight}            contentMaxWidth={contentMaxWidth}            layoutVariant={layoutVariant}            usesAutomaticContentInsets={props.usesAutomaticContentInsets}            onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}            skills={selectedProviderSkills}          />        </View>      ) : (        <View className="flex-1" />      )}       {/* Floating composer — sticks to keyboard via KeyboardStickyView */}      {showContent ? (        <KeyboardStickyView          style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}          offset={{ closed: 0, opened: 0 }}        >          {/* No paddingTop here: the overlay's measured height becomes the              list's bottom inset, so any padding above the pill/composer              pushes the resting content floor up by the same amount. */}          <View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full">            <View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}>              {props.activePendingApproval || props.activePendingUserInput ? (                <Animated.View                  className="shrink-0 gap-3 px-4 pb-3"                  entering={FadeInDown.duration(220)}                  exiting={FadeOut.duration(140)}                >                  {props.activePendingApproval ? (                    <PendingApprovalCard                      approval={props.activePendingApproval}                      respondingApprovalId={props.respondingApprovalId}                      onRespond={props.onRespondToApproval}                    />                  ) : null}                  {props.activePendingUserInput ? (                    <PendingUserInputCard                      pendingUserInput={props.activePendingUserInput}                      drafts={props.activePendingUserInputDrafts}                      answers={props.activePendingUserInputAnswers}                      respondingUserInputId={props.respondingUserInputId}                      onSelectOption={props.onSelectUserInputOption}                      onChangeCustomAnswer={props.onChangeUserInputCustomAnswer}                      onSubmit={props.onSubmitUserInput}                    />                  ) : null}                </Animated.View>              ) : null}            </View>             <ThreadComposer              editorRef={composerEditorRef}              draftMessage={props.draftMessage}              draftAttachments={props.draftAttachments}              placeholder="Ask the repo agent, or run a command…"              contentMaxWidth={contentMaxWidth}              connectionState={props.connectionStateLabel}              connectionError={props.connectionError}              environmentLabel={props.environmentLabel}              threadSyncPhase={threadSyncPhase}              selectedThread={props.selectedThread}              serverConfig={props.serverConfig}              queueCount={props.selectedThreadQueueCount}              activeThreadBusy={props.activeThreadBusy}              environmentId={props.environmentId}              projectCwd={props.projectWorkspaceRoot}              bottomInset={composerBottomInset}              onChangeDraftMessage={props.onChangeDraftMessage}              onPickDraftImages={props.onPickDraftImages}              onNativePasteImages={props.onNativePasteImages}              onRemoveDraftImage={props.onRemoveDraftImage}              onStopThread={props.onStopThread}              onSendMessage={handleSendMessage}              onReconnectEnvironment={props.onReconnectEnvironment}              onUpdateModelSelection={props.onUpdateThreadModelSelection}              onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}              onUpdateInteractionMode={props.onUpdateThreadInteractionMode}              onExpandedChange={setComposerExpanded}            />          </View>        </KeyboardStickyView>      ) : null}    </View>  );});