685 lines · 22.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 { 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,  Text as NativeText,  type ColorValue,  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 { 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: string;  readonly feed: ReadonlyArray<ThreadFeedEntry>;  readonly httpBaseUrl: string | null;  readonly bearerToken: string | null;  readonly agentLabel: string;  readonly contentBottomInset?: number;  readonly refreshing?: boolean;  readonly onRefresh?: () => void;  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;} 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 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 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 },      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,        code: markdownUserCodeText,        codeBackground: markdownUserCodeBg,        border: markdownUserFenceBg,      },    };    const userStyles: NodeStyleOverrides = {      ...baseStyles,      paragraph: { marginTop: 0, marginBottom: 0 },    };     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,    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 markdownStyles: MarkdownStyleSets;  },) {  const entry = info.item;  const { markdownStyles, iconSubtleColor } = 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 ?? [];     if (isUser) {      return (        <View className="mb-3.5 items-end gap-1.5">          <View className="max-w-[85%] gap-2 rounded-[22px] rounded-br-[10px] border border-blue-300/50 bg-blue-50/80 px-4 py-4 dark:border-blue-400/20 dark:bg-blue-500/12">            {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}                  onPress={() => props.onPressImage(uri, headers)}                >                  <Image                    source={{ uri, ...(headers ? { headers } : {}) }}                    className="aspect-[1.3] w-full rounded-[18px] bg-neutral-200 dark:bg-neutral-800"                    resizeMode="cover"                  />                </TouchableOpacity>              );            })}          </View>          <Text className="px-1 text-right font-t3-medium text-xs text-neutral-500 dark:text-neutral-500">            {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-3.5 gap-1.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}              onPress={() => props.onPressImage(uri, headers)}            >              <Image                source={{ uri, ...(headers ? { headers } : {}) }}                className="aspect-[1.3] w-full rounded-[18px] bg-neutral-200 dark:bg-neutral-800"                resizeMode="cover"              />            </TouchableOpacity>          );        })}        <Text className="font-t3-medium text-xs text-neutral-500 dark:text-neutral-500">          {timestampLabel}        </Text>      </View>    );  }   if (entry.type === "queued-message") {    return (      <View className="mb-3.5 gap-1.5 items-end">        <View className="max-w-[85%] gap-2 rounded-[22px] rounded-br-[10px] border border-sky-300/60 bg-sky-100/75 px-4 py-4 dark:border-sky-300/20 dark:bg-sky-400/10">          <Text className="font-sans text-[15px] leading-[22px] text-neutral-950 dark:text-neutral-50">            {entry.queuedMessage.text}          </Text>          {entry.queuedMessage.attachments.length > 0 ? (            <Text className="font-t3-medium text-xs text-neutral-500 dark:text-neutral-500">              {entry.queuedMessage.attachments.length} image              {entry.queuedMessage.attachments.length === 1 ? "" : "s"} attached            </Text>          ) : null}        </View>        <Text className="px-1 text-right font-t3-medium text-xs text-neutral-500 dark:text-neutral-500">          {entry.sending ? "dispatching" : `${relativeTime(entry.createdAt)} • pending`}        </Text>      </View>    );  }   const rows = buildActivityRows(entry.activities);  const isExpanded = props.expandedWorkGroups[entry.id] ?? false;  const hasOverflow = rows.length > MAX_VISIBLE_WORK_LOG_ENTRIES;  const visibleRows = hasOverflow && !isExpanded ? rows.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : rows;  const hiddenCount = rows.length - visibleRows.length;  const showHeader = hasOverflow;   return (    <View className="mb-3 rounded-[20px] border border-neutral-200/80 bg-neutral-50/85 px-3 py-2 dark:border-white/[0.06] dark:bg-white/[0.025]">      {showHeader ? (        <View className="mb-1.5 flex-row items-center justify-between gap-3 px-0.5">          <Text className="font-t3-bold text-[10px] uppercase tracking-[0.8px] text-neutral-500 dark:text-neutral-500">            Tool calls ({rows.length})          </Text>          <Pressable onPress={() => props.onToggleWorkGroup(entry.id)}>            <Text className="font-t3-medium text-[10px] uppercase tracking-[0.8px] text-neutral-500 dark:text-neutral-500">              {isExpanded ? "Show less" : `Show ${hiddenCount} more`}            </Text>          </Pressable>        </View>      ) : null}      {visibleRows.map((row, index) => (        <View          key={row.id}          className={cn(            "flex-row items-center gap-2 rounded-lg px-1 py-1",            index > 0 && "border-t border-neutral-200/80 dark:border-white/[0.06]",          )}        >          <View className="items-center justify-center pt-0.5">            <SymbolView name="terminal" size={13} tintColor={iconSubtleColor} type="monochrome" />          </View>          <ScrollView            horizontal            nestedScrollEnabled            directionalLockEnabled            showsHorizontalScrollIndicator={false}            bounces={false}            className="flex-1"            contentContainerStyle={{ paddingRight: 12 }}            style={{ flex: 1 }}          >            <Text              className="text-[12px] leading-[18px] text-neutral-600 dark:text-neutral-400"

Lines 1–500 of 685.