985 lines · 31.1 KB · TypeScript
RawDownload
import * as Clipboard from "expo-clipboard";import * as Haptics from "expo-haptics";import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard";import { type LegendListRef } from "@legendapp/list/react-native";import type { ThreadId } from "@t3tools/contracts";import { SymbolView } from "expo-symbols";import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";import {  Markdown,  type CustomRenderers,  type NodeStyleOverrides,  type PartialMarkdownTheme,} from "react-native-nitro-markdown";import {  Image,  Pressable,  ScrollView,  StyleSheet,  Text as NativeText,  type ColorValue,  useColorScheme,  useWindowDimensions,  View,} 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 { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText";import { EmptyState } from "../../components/EmptyState";import {  parseReviewCommentMessageSegments,  type ReviewInlineComment,} from "../review/reviewCommentSelection";import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface";import {  buildNativeReviewDiffData,  createNativeReviewDiffTheme,  NATIVE_REVIEW_DIFF_CONTENT_WIDTH,  NATIVE_REVIEW_DIFF_ROW_HEIGHT,  NATIVE_REVIEW_DIFF_STYLE,} from "../review/nativeReviewDiffAdapter";import { buildReviewParsedDiff } from "../review/reviewModel";import { cn } from "../../lib/cn";import type { MobileLayoutVariant } from "../../lib/mobileLayout";import type { ThreadFeedEntry } from "../../lib/threadActivity";import { relativeTime } from "../../lib/time";import { messageImageUrl } from "./threadPresentation"; export interface ThreadFeedProps {  readonly threadId: ThreadId;  readonly feed: ReadonlyArray<ThreadFeedEntry>;  readonly httpBaseUrl: string | null;  readonly bearerToken: string | null;  readonly agentLabel: string;  readonly contentBottomInset?: number;  readonly layoutVariant?: MobileLayoutVariant;  readonly composerExpanded?: boolean;} function stripShellWrapper(value: string): string {  const trimmed = value.trim();  const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/);  return (match?.[1] ?? trimmed).trim();} function compactActivityDetail(detail: string | null): string | null {  if (!detail) {    return null;  }   const cleaned = stripShellWrapper(detail).replace(/\s+/g, " ").trim();  return cleaned.length > 0 ? cleaned : null;} function buildActivityRows(  activities: ReadonlyArray<{    readonly id: string;    readonly createdAt: string;    readonly summary: string;    readonly detail: string | null;    readonly status: string | null;  }>,) {  return activities.map<{    id: string;    createdAt: string;    summary: string;    detail: string | null;    status: string | null;  }>((activity) => ({    id: activity.id,    createdAt: activity.createdAt,    summary: activity.summary,    detail: compactActivityDetail(activity.detail),    status: activity.status,  }));} const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; function toMarkdownThemeColor(value: ColorValue): string {  return value as string;} interface MarkdownStyleSets {  readonly user: MarkdownStyleSet;  readonly assistant: MarkdownStyleSet;} interface MarkdownStyleSet {  readonly theme: PartialMarkdownTheme;  readonly styles: NodeStyleOverrides;  readonly renderers: CustomRenderers;} interface ReviewCommentColors {  readonly background: ColorValue;  readonly border: ColorValue;  readonly mutedBackground: ColorValue;  readonly text: ColorValue;  readonly mutedText: ColorValue;  readonly codeBackground: ColorValue;} function useReviewCommentColors(): ReviewCommentColors {  const colorScheme = useColorScheme();  const isDark = colorScheme === "dark";  const background = isDark ? "#151515" : "#ffffff";  const border = isDark ? "#2a2a2a" : "#d7d7d7";  const mutedBackground = isDark ? "#242424" : "#f2f2f2";  const text = isDark ? "#f3f3f3" : "#111111";  const mutedText = isDark ? "#8f8f8f" : "#666666";  const codeBackground = isDark ? "#0f0f0f" : "#ffffff";   return useMemo(    () => ({      background,      border,      mutedBackground,      text,      mutedText,      codeBackground,    }),    [background, border, codeBackground, mutedBackground, mutedText, text],  );} function useMarkdownStyles(): MarkdownStyleSets {  const bodyColor = useThemeColor("--color-md-body");  const strongColor = useThemeColor("--color-md-strong");  const linkColor = useThemeColor("--color-md-link");  const blockquoteBg = useThemeColor("--color-md-blockquote-bg");  const blockquoteBorder = useThemeColor("--color-md-blockquote-border");  const codeBg = useThemeColor("--color-md-code-bg");  const codeText = useThemeColor("--color-md-code-text");  const hrColor = useThemeColor("--color-md-hr");  const userBodyColor = useThemeColor("--color-user-bubble-foreground");  const userCodeBg = useThemeColor("--color-md-user-code-bg");  const userCodeText = useThemeColor("--color-md-user-code-text");  const userFenceBg = useThemeColor("--color-md-user-fence-bg");  const userFenceText = useThemeColor("--color-md-user-fence-text");   return useMemo(() => {    const markdownBodyColor = toMarkdownThemeColor(bodyColor);    const markdownStrongColor = toMarkdownThemeColor(strongColor);    const markdownLinkColor = toMarkdownThemeColor(linkColor);    const markdownBlockquoteBg = toMarkdownThemeColor(blockquoteBg);    const markdownBlockquoteBorder = toMarkdownThemeColor(blockquoteBorder);    const markdownCodeBg = toMarkdownThemeColor(codeBg);    const markdownCodeText = toMarkdownThemeColor(codeText);    const markdownHrColor = toMarkdownThemeColor(hrColor);    const markdownUserBodyColor = toMarkdownThemeColor(userBodyColor);    const markdownUserCodeBg = toMarkdownThemeColor(userCodeBg);    const markdownUserCodeText = toMarkdownThemeColor(userCodeText);    const markdownUserFenceBg = toMarkdownThemeColor(userFenceBg);    const markdownUserFenceText = toMarkdownThemeColor(userFenceText);     const baseTheme: PartialMarkdownTheme = {      colors: {        text: markdownBodyColor,        heading: markdownStrongColor,        link: markdownLinkColor,        blockquote: markdownBlockquoteBorder,        border: markdownHrColor,        surfaceLight: markdownBlockquoteBg,        accent: markdownLinkColor,        tableBorder: markdownHrColor,        tableHeader: markdownBlockquoteBg,        tableHeaderText: markdownStrongColor,        tableRowOdd: "transparent",        tableRowEven: "transparent",      },      spacing: {        xs: 4,        s: 4,        m: 8,        l: 8,        xl: 16,      },      fontSizes: {        s: 13,        m: 15,        h1: 22,        h2: 19,        h3: 17,        h4: 15,        h5: 15,        h6: 15,      },      fontFamilies: {        regular: "DMSans_400Regular",        heading: "DMSans_700Bold",        mono: "ui-monospace",      },      headingWeight: "700",      borderRadius: {        s: 4,        m: 8,        l: 12,      },      showCodeLanguage: false,    };     const baseStyles: NodeStyleOverrides = {      document: { flexShrink: 1 },      paragraph: { marginTop: 0, marginBottom: 8 },      list: { marginTop: 4, marginBottom: 4 },      list_item: { marginTop: 0, marginBottom: 4 },      task_list_item: { marginTop: 0, marginBottom: 4 },      text: { lineHeight: 22 },      bold: {        fontWeight: "700",        color: markdownStrongColor,        fontFamily: "DMSans_700Bold",      },      italic: { fontStyle: "italic" },      link: {        color: markdownLinkColor,        textDecorationLine: "underline" as const,      },      blockquote: {        borderLeftWidth: 3,        borderLeftColor: markdownBlockquoteBorder,        backgroundColor: markdownBlockquoteBg,        paddingLeft: 12,        paddingVertical: 6,        marginLeft: 0,        marginVertical: 4,        borderRadius: 4,      },      heading: {        fontFamily: "DMSans_700Bold",        color: markdownStrongColor,        marginTop: 12,        marginBottom: 6,      },      horizontal_rule: {        backgroundColor: markdownHrColor,        height: 1,        marginVertical: 12,      },    };     const createCodeRenderers = (      inlineBackgroundColor: string,      inlineTextColor: string,      blockBackgroundColor: string,      blockTextColor: string,    ): CustomRenderers => ({      code_inline: ({ content }) => (        <NativeText          style={{            backgroundColor: inlineBackgroundColor,            color: inlineTextColor,            borderRadius: 5,            paddingHorizontal: 5,            paddingVertical: 1,            fontFamily: "ui-monospace",            fontSize: 13,          }}        >          {content}        </NativeText>      ),      code_block: ({ content }) => (        <View          style={{            backgroundColor: blockBackgroundColor,            borderRadius: 12,            padding: 12,            marginVertical: 8,          }}        >          <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}>            <NativeText              selectable              style={{                color: blockTextColor,                fontFamily: "ui-monospace",                fontSize: 13,                lineHeight: 19,              }}            >              {content}            </NativeText>          </ScrollView>        </View>      ),    });     const userTheme: PartialMarkdownTheme = {      ...baseTheme,      colors: {        ...baseTheme.colors,        text: markdownUserBodyColor,        heading: markdownUserBodyColor,        link: markdownUserBodyColor,        code: markdownUserCodeText,        codeBackground: markdownUserCodeBg,        border: markdownUserFenceBg,      },    };    const userStyles: NodeStyleOverrides = {      ...baseStyles,      paragraph: { marginTop: 0, marginBottom: 0 },      bold: {        fontWeight: "700",        color: markdownUserBodyColor,        fontFamily: "DMSans_700Bold",      },      heading: {        ...baseStyles.heading,        color: markdownUserBodyColor,      },      link: {        color: markdownUserBodyColor,        textDecorationLine: "underline" as const,      },    };     const assistantTheme: PartialMarkdownTheme = {      ...baseTheme,      colors: {        ...baseTheme.colors,        code: markdownCodeText,        codeBackground: markdownCodeBg,        border: markdownCodeBg,      },    };    const assistantStyles: NodeStyleOverrides = {      ...baseStyles,    };     return {      user: {        theme: userTheme,        styles: userStyles,        renderers: createCodeRenderers(          markdownUserCodeBg,          markdownUserCodeText,          markdownUserFenceBg,          markdownUserFenceText,        ),      },      assistant: {        theme: assistantTheme,        styles: assistantStyles,        renderers: createCodeRenderers(          markdownCodeBg,          markdownCodeText,          markdownCodeBg,          markdownCodeText,        ),      },    };  }, [    blockquoteBg,    blockquoteBorder,    bodyColor,    codeBg,    codeText,    hrColor,    linkColor,    strongColor,    userBodyColor,    userCodeBg,    userCodeText,    userFenceBg,    userFenceText,  ]);} function renderFeedEntry(  info: { item: ThreadFeedEntry; index: number },  props: Pick<ThreadFeedProps, "bearerToken" | "httpBaseUrl"> & {    readonly copiedRowId: string | null;    readonly expandedWorkGroups: Record<string, boolean>;    readonly onCopyWorkRow: (rowId: string, value: string) => void;    readonly onToggleWorkGroup: (groupId: string) => void;    readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;    readonly iconSubtleColor: string | import("react-native").ColorValue;    readonly userBubbleColor: string | import("react-native").ColorValue;    readonly markdownStyles: MarkdownStyleSets;    readonly reviewCommentColors: ReviewCommentColors;    readonly reviewCommentBubbleWidth: number;  },) {  const entry = info.item;  const { markdownStyles, iconSubtleColor, userBubbleColor } = props;   if (entry.type === "message") {    const { message } = entry;    const isUser = message.role === "user";    const styles = isUser ? markdownStyles.user : markdownStyles.assistant;    const timestampLabel = `${relativeTime(message.createdAt)}${message.streaming ? " • live" : ""}`;    const attachments = message.attachments ?? [];    const hasReviewCommentContext = message.text.includes("<review_comment");     if (isUser) {      return (        <View className="mb-5 items-end">          <View            className="max-w-[85%] gap-2 rounded-[22px] rounded-br-[6px] px-3.5 py-2.5"            style={{              backgroundColor: userBubbleColor,              ...(hasReviewCommentContext ? { width: props.reviewCommentBubbleWidth } : null),            }}          >            {message.text.trim().length > 0 ? (              <UserMessageContent                text={message.text}                markdownStyles={styles}                reviewCommentColors={props.reviewCommentColors}              />            ) : null}            {attachments.map((attachment) => {              const uri = messageImageUrl(props.httpBaseUrl, attachment.id);              if (!uri) {                return null;              }              const headers = props.bearerToken                ? { Authorization: `Bearer ${props.bearerToken}` }                : undefined;               return (                <TouchableOpacity                  key={attachment.id}                  activeOpacity={0.7}                  onPress={() => props.onPressImage(uri, headers)}                >                  <Image                    source={{ uri, ...(headers ? { headers } : {}) }}                    className="aspect-[1.3] w-full rounded-[14px] bg-white/15"                    resizeMode="cover"                  />                </TouchableOpacity>              );            })}          </View>          <Text className="mt-1.5 px-1 text-right font-t3-medium text-xs text-neutral-600 dark:text-neutral-400">            {timestampLabel}          </Text>        </View>      );    }     // Skip empty assistant messages (no text, no attachments) — they would    // render as an orphaned timestamp and break adjacent activity-group merging.    if (message.text.trim().length === 0 && attachments.length === 0) {      return null;    }     return (      <View className="mb-5 px-1">        {message.text.trim().length > 0 ? (          <Markdown            options={{ gfm: true }}            renderers={styles.renderers}            styles={styles.styles}            theme={styles.theme}          >            {message.text}          </Markdown>        ) : null}        {attachments.map((attachment) => {          const uri = messageImageUrl(props.httpBaseUrl, attachment.id);          if (!uri) {            return null;          }          const headers = props.bearerToken            ? { Authorization: `Bearer ${props.bearerToken}` }            : undefined;           return (            <TouchableOpacity              key={attachment.id}              activeOpacity={0.7}              className="mt-1.5"

Lines 1–500 of 985.