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 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;} 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}Lines 1–500 of 989. Load more
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 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; } 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}