2249 lines · 80.1 KB · TypeScript
RawDownload
import * as Haptics from "expo-haptics";import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";import { type LegendListRef } from "@legendapp/list/react-native";import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";import { formatElapsed } from "@t3tools/shared/orchestrationTiming";import { SymbolView } from "../../components/AppSymbol";import { HeaderHeightContext } from "@react-navigation/elements";import { useNavigation } from "@react-navigation/native";import {  memo,  useCallback,  useContext,  useEffect,  useLayoutEffect,  useMemo,  useRef,  useState,  type ReactNode,  type RefObject,} from "react";import {  Markdown,  type CustomRenderers,  type NodeStyleOverrides,  type PartialMarkdownTheme,} from "react-native-nitro-markdown";import {  ActivityIndicator,  Image,  Platform,  type LayoutChangeEvent,  type NativeScrollEvent,  type NativeSyntheticEvent,  Pressable,  ScrollView,  StyleSheet,  Text as NativeText,  type ColorValue,  useWindowDimensions,  View,  type ViewStyle,} from "react-native";import { TouchableOpacity } from "react-native-gesture-handler";import ImageViewing from "react-native-image-viewing";import { useSafeAreaInsets } from "react-native-safe-area-context";import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated";import { useThemeColor } from "../../lib/useThemeColor";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 { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";import {  hasNativeSelectableMarkdownText,  SelectableMarkdownText,  type MarkdownImageRenderer,  type NativeMarkdownTextStyle,  type SelectableMarkdownSkill,} from "../../native/SelectableMarkdownText"; import { AppText as Text } from "../../components/AppText";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,  type LayoutVariant,} from "../../lib/layout";import {  resolveMarkdownFontSizes,  resolveNativeMarkdownTypography,  scaledTypographyLineHeight,} from "../../lib/appearancePreferences";import { MOBILE_TYPOGRAPHY } from "../../lib/typography";import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";import {  deriveThreadFeedPresentation,  type ThreadFeedEntry,  type ThreadFeedLatestTurn,} from "../../lib/threadActivity";import type { ThreadContentPresentation } from "./threadContentPresentation";import {  resolveThreadFeedLiveFollow,  type ThreadFeedLiveFollowEvent,} from "./thread-feed-live-follow";import {  collapsedWorkLogHeight,  ThreadWorkGroupToggle,  ThreadWorkLog,  WORK_GROUP_TOGGLE_HEIGHT,} from "./thread-work-log";import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";import { useAssetUrl, useAssetUrlState } from "../../state/assets";import { resolveWorkspaceRelativeFilePath } from "../files/filePath";import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; const WIDE_MARKDOWN_BLOCK_OPTIONS = {  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);} // Pre-measurement heights for getFixedItemSize, mirroring renderFeedEntry's// classNames. The fold row's min-h-11 (44px) stays taller than its single// text-sm line at every supported base font size (26px at the 22pt maximum),// so its height is a constant; a drifted value costs one correction on// measure, not a persistent offset.const TURN_FOLD_HEIGHT = 56; // min-h-11 (44) + mb-3 (12)// The working row has no min-height clamp — its height follows the scaled// text-xs line height (see workingRowHeight in ThreadFeed).const WORKING_ROW_VERTICAL_EXTRAS = 24; // py-1 (8) + mb-4 (16) // 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 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>;  /** 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 className: string;  readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;}) {  const uri = useAssetUrl(props.environmentId, {    _tag: "attachment",    attachmentId: props.attachmentId,  });   if (uri === null) {    return (      <View className={`${props.className} items-center justify-center`}>        <ActivityIndicator />      </View>    );  }   return (    <TouchableOpacity activeOpacity={0.7} onPress={() => props.onPressImage(uri)}>      <Image source={{ uri }} className={props.className} resizeMode="cover" />    </TouchableOpacity>  );} function ThreadMarkdownImageView(props: {  readonly uri: string | null;  readonly sourceKey: string;  readonly unavailable: boolean;  readonly alt: string | null;  readonly onPressImage: (uri: string) => void;}) {  const codeBackground = useThemeColor("--color-md-code-bg");  const [availableWidth, setAvailableWidth] = useState(0);  const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null);  const [failedUri, setFailedUri] = useState<string | null>(null);   useEffect(() => {    setSourceSize(null);  }, [props.sourceKey]);   useEffect(() => {    setFailedUri(null);  }, [props.uri]);   const displaySize =    sourceSize === null      ? null      : resolveMarkdownImageDisplaySize({          sourceWidth: sourceSize.width,          sourceHeight: sourceSize.height,          availableWidth,        });  const failed = props.unavailable || (props.uri !== null && failedUri === props.uri);  const placeholderWidth: ViewStyle["width"] =    availableWidth > 0 ? Math.min(availableWidth, MARKDOWN_IMAGE_MAX_WIDTH) : "100%";  const frameStyle: ViewStyle = displaySize ?? { width: placeholderWidth, aspectRatio: 16 / 9 };   return (    <View      onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)}      style={{ alignSelf: "stretch", gap: 6 }}    >      {props.uri === null || failed ? (        <View          style={{            ...frameStyle,            borderRadius: 10,            backgroundColor: codeBackground,            alignItems: "center",            justifyContent: "center",          }}        >          {failed ? (            <Text className="text-xs text-foreground-muted">Image unavailable</Text>          ) : (            <ActivityIndicator />          )}        </View>      ) : (        <TouchableOpacity          accessibilityRole="imagebutton"          accessibilityLabel={props.alt ?? "Markdown image"}          activeOpacity={0.7}          onPress={() => props.onPressImage(props.uri!)}          style={{ alignSelf: "flex-start" }}        >          <View            style={{              ...frameStyle,              borderRadius: 10,              backgroundColor: codeBackground,              alignItems: "center",              justifyContent: "center",              overflow: "hidden",            }}          >            <ThreadMarkdownImageRequest              key={props.uri}              uri={props.uri}              onLoad={setSourceSize}              onError={() => setFailedUri(props.uri)}            />          </View>        </TouchableOpacity>      )}      {props.alt ? (        <Text selectable className="text-xs text-foreground-muted">          {props.alt}        </Text>      ) : null}    </View>  );} function ThreadMarkdownImageRequest(props: {  readonly uri: string;  readonly onLoad: (sourceSize: { width: number; height: number }) => void;  readonly onError: () => void;}) {  const [loaded, setLoaded] = useState(false);   return (    <>      <Image        source={{ uri: props.uri }}        resizeMode="contain"        accessible={false}        onLoad={(event) => {          setLoaded(true);          props.onLoad(event.nativeEvent.source);        }}        onError={props.onError}        style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }}      />      {loaded ? null : (        <View          pointerEvents="none"          style={[StyleSheet.absoluteFill, { alignItems: "center", justifyContent: "center" }]}        >          <Text className="text-xs text-foreground-muted">Loading image…</Text>        </View>      )}    </>  );} /** Markdown image whose src is a workspace file — loads through a signed asset URL. */function ThreadMarkdownImage(props: {  readonly environmentId: EnvironmentId;  readonly threadId: ThreadId;  readonly path: string;  readonly alt: string | null;  readonly onPressImage: (uri: string) => void;}) {  const assetUrl = useAssetUrlState(props.environmentId, {    _tag: "workspace-file",    threadId: props.threadId,    path: props.path,  });   return (    <ThreadMarkdownImageView      uri={assetUrl._tag === "Success" ? assetUrl.url : null}      sourceKey={props.path}      unavailable={assetUrl._tag === "Failure"}      alt={props.alt}      onPressImage={props.onPressImage}    />  );} function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) {  return (    <ThreadMarkdownImageView      uri={null}      sourceKey="unavailable"      unavailable      alt={props.alt}      onPressImage={() => undefined}    />  );} const MARKDOWN_MONO_FONT = Platform.select({  ios: "ui-monospace",  android: "monospace",  default: "monospace",}); interface MarkdownStyleSets {  readonly user: MarkdownStyleSet;  readonly assistant: MarkdownStyleSet;} interface MarkdownStyleSet {  readonly theme: PartialMarkdownTheme;  readonly styles: NodeStyleOverrides;  readonly renderers: CustomRenderers;  readonly nativeTextStyle: NativeMarkdownTextStyle;} interface ReviewCommentColors {  readonly background: ColorValue;  readonly border: ColorValue;  readonly mutedBackground: ColorValue;  readonly text: ColorValue;  readonly mutedText: ColorValue;  readonly codeBackground: ColorValue;} const failedMarkdownFaviconHosts = new Set<string>();const markdownLinkStyles = StyleSheet.create({  inlineIcon: {    width: 14,    height: 14,    marginHorizontal: 3,    transform: [{ translateY: 2 }],  },  favicon: {    borderRadius: 3,  },}); const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {  readonly children: ReactNode;  readonly color: string;  readonly host: string;  readonly href: string;}) {  const [failed, setFailed] = useState(() => failedMarkdownFaviconHosts.has(props.host));   return (    <NativeText      className="font-sans"      onPress={() => {        void tryOpenExternalUrl(props.href, "markdown-link");      }}      style={{        color: props.color,        textDecorationLine: "none",      }}    >      {!failed ? (        <Image          source={{            uri: `https://www.google.com/s2/favicons?domain=${encodeURIComponent(props.host)}&sz=32`,          }}          style={[markdownLinkStyles.inlineIcon, markdownLinkStyles.favicon]}          onError={() => {            failedMarkdownFaviconHosts.add(props.host);            setFailed(true);          }}        />      ) : (        <NativeText style={{ color: props.color }}>{" ◉ "}</NativeText>      )}      {props.children}    </NativeText>  );}); function MarkdownCodeBlock(props: {  readonly backgroundColor: string;  readonly borderColor: string;  readonly content: string;  readonly copyTintColor: ColorValue;  readonly headerTextColor: string;  readonly fontSize: number;  readonly highlightCode: boolean;  readonly language?: string | null;  readonly lineHeight: number;  readonly textColor: string;  readonly theme: ReviewDiffTheme;}) {  const content = props.content.replace(/\n$/, "");  const languageLabel = props.language?.trim() || "text";  const highlighted = useMarkdownCodeHighlight({    code: content,    enabled: props.highlightCode && Boolean(props.language?.trim()),    language: props.language,    theme: props.theme,  });  let tokenOffset = 0;   return (    <View      className="my-3 min-w-0 max-w-full self-stretch overflow-hidden rounded-lg border"      style={{ backgroundColor: props.backgroundColor, borderColor: props.borderColor }}    >      <View        className="flex-row items-center justify-between gap-2 border-b py-1 pr-1.5 pl-3.5"        style={{ borderBottomColor: props.borderColor }}      >        <NativeText          className="flex-1 font-mono uppercase opacity-70"          numberOfLines={1}          style={{            color: props.headerTextColor,            fontSize: props.fontSize,            ...(Platform.OS === "android" ? { includeFontPadding: false } : null),          }}        >          {languageLabel}        </NativeText>        <CopyTextButton          accessibilityLabel="Copy code"          text={content}          tintColor={props.copyTintColor}          buttonSize={32}          iconSize={16}        />      </View>      <ScrollView        horizontal        bounces={false}        nestedScrollEnabled={Platform.OS === "android"}        showsHorizontalScrollIndicator={false}        contentContainerClassName="px-3.5 py-3"      >        <NativeText

Lines 1–500 of 2249.