2955 lines · 107.7 KB · TypeScript
RawDownload
import * as Haptics from "expo-haptics";import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native";import type {  ChatAttachment,  ChatFileAttachment,  ChatImageAttachment,  EnvironmentId,  MessageId,  ThreadId,  TurnId,} from "@t3tools/contracts";import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations";import {  codexArtifactTemplatePresentationLabel,  type CodexArtifactTemplate,} from "@t3tools/client-runtime/codex-artifact-templates";import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";import {  classifyMarkdownImageSource,  markdownImageSourceFragment,} from "@t3tools/client-runtime/markdown-images";import { resolveViewedImageAsset } from "@t3tools/client-runtime/work-log/presentation";import {  renderCodexFileCitationsAsMarkdown,  splitCodexArtifactTemplateMarkdown,} from "@t3tools/client-runtime/codex-markdown-directives";import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";import { videoMimeType } from "@t3tools/shared/video";import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";import { HeaderHeightContext } from "@react-navigation/elements";import { useFocusEffect, useNavigation } from "@react-navigation/native";import {  createContext,  memo,  useCallback,  useContext,  useEffect,  useLayoutEffect,  useMemo,  useRef,  useState,  useId,  type ReactNode,  type RefObject,} from "react";import {  Markdown,  type CustomRenderers,  type NodeStyleOverrides,  type PartialMarkdownTheme,} from "react-native-nitro-markdown";import {  ActivityIndicator,  Alert,  Image,  Platform,  type LayoutChangeEvent,  type NativeScrollEvent,  type NativeSyntheticEvent,  Pressable,  ScrollView,  StyleSheet,  Text as NativeText,  type ColorValue,  useWindowDimensions,  View,} from "react-native";import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";import { isPdfFile } from "../../lib/filePreview";import { PresentationSource } from "../../components/NativePresentation";import { useSafeAreaInsets } from "react-native-safe-area-context";import Animated, { FadeIn, LinearTransition, type SharedValue } from "react-native-reanimated";import { useUniwindTheme } from "../../lib/useUniwindTheme";import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";import { useFontFamily } from "../../lib/useFontFamily";import { scopedThreadKey } from "../../lib/scopedEntities";import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";import { tryOpenExternalUrl } from "../../lib/openExternalUrl";import { downloadAndShareAttachment } from "../../lib/attachmentDownload";import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";import { faviconUrlForOrigin } from "@t3tools/shared/favicon";import {  hasNativeSelectableMarkdownText,  SelectableMarkdownText,  type MarkdownFileContextMenu,  type MarkdownImageRenderer,  type NativeMarkdownTextStyle,  type SelectableMarkdownSkill,} from "../../native/SelectableMarkdownText"; import { AppText as Text } from "../../components/AppText";import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";import { VideoAttachmentTile } from "../../components/VideoAttachmentTile";import { MediaVideoPlayer } from "../../components/MediaVideoPlayer";import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia";import {  attachmentVideoPreviewSource,  mediaVideoPreviewUri,  mediaVideoThumbnailKey,  type MediaVideoPreviewSource,} from "../../lib/videoPreviewSource";import { CopyTextButton } from "../../components/CopyTextButton";import {  parseReviewCommentMessageSegments,  type ReviewInlineComment,} from "../review/reviewCommentSelection";import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter";import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface";import {  buildNativeReviewDiffData,  createNativeReviewDiffTheme,  NATIVE_REVIEW_DIFF_CONTENT_WIDTH,} from "../review/nativeReviewDiffAdapter";import { buildReviewParsedDiff } from "../review/reviewModel";import { cn } from "../../lib/cn";import {  deriveCenteredContentHorizontalPadding,  deriveThreadFeedInitialContentInset,  deriveThreadWorkLogSizing,  type LayoutVariant,} from "../../lib/layout";import {  resolveMarkdownFontSizes,  resolveNativeMarkdownTypography,} from "../../lib/appearancePreferences";import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";import { PierreEntryIcon } from "../../components/PierreEntryIcon";import { markdownLinkIconSource } from "@t3tools/mobile-markdown-text/link-icons";import {  normalizeNativeMarkdownUrl,  resolveMarkdownInlineCodePresentation,  resolveMarkdownLinkIcon,  resolveMarkdownLinkPresentation,} from "@t3tools/mobile-markdown-text/links";import {  deriveThreadFeedPresentation,  isContextCompactionActivityGroup,  type ThreadFeedEntry,  type ThreadFeedLatestTurn,} from "../../lib/threadActivity";import type { ThreadContentPresentation } from "./threadContentPresentation";import {  resolveThreadFeedLiveFollow,  type ThreadFeedLiveFollowEvent,  type ThreadWorkGroupScrollPosition,} from "./thread-feed-live-follow";import {  collapsedWorkLogHeight,  ThreadAgentSpawnCard,  ThreadDisclosureChevron,  ThreadWorkGroupToggle,  ThreadThinkingRow,  ThreadWorkLog,  THREAD_DISCLOSURE_TRANSITION_MS,  WORK_GROUP_TOGGLE_HEIGHT,} from "./thread-work-log";import { appendPendingThreadMessages, type PendingThreadFeedEntry } from "./pending-thread-feed";import type { QueuedThreadMessage } from "../../state/thread-outbox-model";import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";import {  assetEnvironment,  useAssetUrl,  useAssetUrlState,  useRefreshAssetUrl,} from "../../state/assets";import { useAtomQueryRunner } from "../../state/use-atom-query-runner";import { usePreparedConnection } from "../../state/session";import * as Option from "effect/Option";import {  basename,  fileRoutePathSegments,  isAbsolutePath,  resolveWorkspaceRelativeFilePath,} from "../files/filePath";import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu";import { useFileChipShare } from "./useFileChipShare";import {  MarkdownImageAvailableWidthContext,  ThreadMarkdownImage,  ThreadMarkdownImageUnavailable,  ThreadMarkdownImageView,} from "./ThreadMarkdownImage"; const WIDE_MARKDOWN_BLOCK_OPTIONS = {  // Native iOS blockquotes and adjacent selectable text are separate layout  // chunks. Giving their shrink-to-fit bubble a definite width keeps both  // chunks measured against the width at which UIKit draws them.  includeBlockquotes: Platform.OS === "ios",  includeOrderedLists: Platform.OS === "android",} as const; const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {  hour: "numeric",  minute: "2-digit",});function formatMessageTime(input: string): string {  const timestamp = Date.parse(input);  if (Number.isNaN(timestamp)) {    return "";  }  return MESSAGE_TIME_FORMATTER.format(timestamp);} // Fixed heights mirror renderFeedEntry's classNames and are only used while// text fits at the current font settings. Larger accessibility text is measured.const TURN_FOLD_HEIGHT = 42; // min-h-11 (38.5) + mb-1 (3.5), with the mobile 14px remconst THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);const THREAD_FEED_IMMEDIATE_TRANSITION = LinearTransition.duration(0);// Tailwind spacing on the mobile 14px rem: px-3.5 on the user bubble, px-1 on// assistant rows. Images size their frame from these before their own layout.const USER_BUBBLE_HORIZONTAL_PADDING = 3.5 * 3.5;const ASSISTANT_ROW_HORIZONTAL_PADDING = 3.5;// Let neighboring rows move out of the new rows' space before showing their text.const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay(  THREAD_DISCLOSURE_TRANSITION_MS,).duration(140); // Entering animations must only play for rows born just now — LegendList// remounts rows when they scroll back into view, and replaying an entrance for// old content would be its own kind of jank.const FRESH_ENTRY_WINDOW_MS = 3_000;function isFreshTimestamp(input: string): boolean {  const timestamp = Date.parse(input);  return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ENTRY_WINDOW_MS;} export interface ThreadFeedProps {  readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>;  readonly dispatchingMessageId: MessageId | null;  readonly onEditPendingMessage: (message: QueuedThreadMessage) => void;  readonly environmentId: EnvironmentId;  readonly threadId: ThreadId;  readonly workspaceRoot?: string | null;  readonly feed: ReadonlyArray<ThreadFeedEntry>;  readonly contentPresentation: ThreadContentPresentation;  readonly agentLabel: string;  readonly latestTurn: ThreadFeedLatestTurn | null;  readonly activeWorkStartedAt: string | null;  readonly listRef: RefObject<LegendListRef | null>;  readonly freeze: SharedValue<boolean>;  readonly anchorMessageId: MessageId | null;  readonly submittedMessageId: MessageId | null;  readonly contentInsetEndAdjustment: SharedValue<number>;  readonly contentTopInset?: number;  readonly contentBottomInset?: number;  readonly contentMaxWidth?: number;  readonly layoutVariant?: LayoutVariant;  readonly usesAutomaticContentInsets?: boolean;  readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;  readonly onEndFollowEnabledChange?: (enabled: boolean) => void;  readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;  readonly onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;  /** Non-null when older turns exist beyond the loaded window. */  readonly loadEarlier?: {    readonly loading: boolean;    readonly onLoadEarlier: () => void;  } | null;} function MessageAttachmentImage(props: {  readonly environmentId: EnvironmentId;  readonly attachmentId: string;  readonly name: string;  readonly mimeType: string;  readonly className: string;  readonly onPressPreview: (source: FilePreviewSource) => void;}) {  const sourceIdentifier = useId();  const resource = useMemo(    () => ({      _tag: "attachment" as const,      attachmentId: props.attachmentId,      fileName: props.name,      mimeType: props.mimeType,    }),    [props.attachmentId, props.name, props.mimeType],  );  const uri = useAssetUrl(props.environmentId, resource);   if (uri === null) {    return (      <View className={`${props.className} items-center justify-center`}>        <ActivityIndicator />      </View>    );  }   return (    <PresentationSource identifier={sourceIdentifier}>      <Pressable        accessibilityRole="imagebutton"        accessibilityLabel={`Open ${props.name}`}        onPress={() =>          // The viewer mints its own URL from the resource so the image survives a refresh.          props.onPressPreview({            kind: "image",            environmentId: props.environmentId,            resource,            name: props.name,            sourceIdentifier,            actionsSource: {              name: props.name,              mimeType: props.mimeType,              environmentId: props.environmentId,              resource,            },          })        }      >        <Image source={{ uri }} className={props.className} resizeMode="cover" />      </Pressable>    </PresentationSource>  );} // The attachment union has an open member (`type: string` for attachment// types from newer servers), so literal comparisons do not narrow it. Split// with guards and render unknown types as inert rows, never crash.function isImageAttachment(attachment: ChatAttachment): attachment is ChatImageAttachment {  return attachment.type === "image";} function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAttachment {  return attachment.type === "file";} function MessageAttachmentFile(props: {  readonly environmentId: EnvironmentId;  readonly attachment: ChatFileAttachment;  readonly onPressPreview: (source: FilePreviewSource) => void;  readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;}) {  const sourceIdentifier = useId();  const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {    refresh: true,    reportFailure: false,  });  const preparedConnection = usePreparedConnection(props.environmentId);  const { attachment } = props;  const videoType = videoMimeType(attachment);  const isPdf = isPdfFile(attachment);  const fileTypeLabel = isPdf    ? "PDF"    : (attachment.name.match(/\.([a-z0-9]{1,8})$/i)?.[1]?.toUpperCase() ?? "File");  const sizeLabel = formatAttachmentSize(attachment.sizeBytes);  const thumbnailUrl = useAssetUrl(    props.environmentId,    videoType === null      ? null      : {          _tag: "attachment",          attachmentId: attachment.id,          fileName: attachment.name,          mimeType: videoType,        },  );  const httpBaseUrl = Option.isSome(preparedConnection)    ? preparedConnection.value.httpBaseUrl    : null;  const openingRef = useRef<AbortController | null>(null);  const [opening, setOpening] = useState(false);   useFocusEffect(    useCallback(() => {      setOpening(false);      return () => {        openingRef.current?.abort();        openingRef.current = null;      };    }, [props.environmentId, attachment.id, httpBaseUrl]),  );   const shareFile = (sourceIdentifier?: string) => {    if (httpBaseUrl === null || openingRef.current) return;    const controller = new AbortController();    openingRef.current = controller;    setOpening(true);    void (async () => {      try {        const result = await createAssetUrl({          environmentId: props.environmentId,          input: {            resource: {              _tag: "attachment",              attachmentId: attachment.id,              fileName: attachment.name,              mimeType: attachment.mimeType,            },          },        });        if (controller.signal.aborted) return;        if (result._tag === "Failure") {          throw squashAtomCommandFailure(result);        }        const url = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl);        if (url === null) {          throw new Error("The attachment could not be opened.");        }        await downloadAndShareAttachment({          url,          attachment,          signal: controller.signal,          sourceIdentifier,        });      } catch (error) {        if (!controller.signal.aborted) {          Alert.alert(            "Could not open attachment",            error instanceof Error ? error.message : "The attachment is unavailable.",          );        }      } finally {        if (openingRef.current === controller) {          openingRef.current = null;          setOpening(false);        }      }    })();  };   if (videoType !== null) {    const sourceIdentifier = `attachment:${props.environmentId}:${attachment.id}`;    return (      <VideoAttachmentTile        name={attachment.name}        sourceIdentifier={sourceIdentifier}        thumbnailSource={thumbnailUrl}        actionsSource={          attachmentVideoPreviewSource(props.environmentId, attachment, sourceIdentifier)            .actionsSource        }        disabled={opening || httpBaseUrl === null}        onPress={(sourceIdentifier) => props.onPressVideo(attachment, sourceIdentifier)}        className="my-1 rounded-2xl"        style={{ width: 224, maxWidth: "100%", aspectRatio: 16 / 9 }}      />    );  }   return (    <PresentationSource      identifier={sourceIdentifier}      className="my-1"      style={{ width: 280, maxWidth: "100%" }}    >      <Pressable        accessibilityRole="button"        accessibilityLabel={`Open ${attachment.name}`}        accessibilityValue={{ text: `${fileTypeLabel}, ${sizeLabel}` }}        accessibilityState={{ disabled: opening || httpBaseUrl === null, busy: opening }}        disabled={opening || httpBaseUrl === null}        className="min-w-0 flex-row items-center gap-3 rounded-xl border border-border bg-card p-3 active:bg-subtle"        onPress={() =>          isPdf            ? props.onPressPreview({                kind: "pdf",                name: attachment.name,                environmentId: props.environmentId,                resource: {                  _tag: "attachment",                  attachmentId: attachment.id,                  fileName: attachment.name,                  mimeType: "application/pdf",                },                sourceIdentifier,              })            : shareFile(sourceIdentifier)        }      >        <View className="h-12 w-10 shrink-0 items-center justify-center rounded-lg bg-subtle">          {opening ? (            <ActivityIndicator size="small" />          ) : (            <PierreEntryIcon path={attachment.name} kind="file" size={26} />          )}        </View>        <View className="min-w-0 flex-1 gap-1">          <Text className="font-t3-medium text-sm text-foreground" numberOfLines={2}>            {attachment.name}          </Text>          <Text className="text-xs text-foreground-muted" numberOfLines={1}>            {fileTypeLabel} · {sizeLabel}          </Text>        </View>        <SymbolView          name="chevron.right"          size={12}          tintColorClassName="accent-foreground-muted"          type="monochrome"        />      </Pressable>    </PresentationSource>  );} 

Lines 1–500 of 2955.