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; 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 }) => ( {content} ), code_block: ({ content }) => ( {content} ), }); 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 & { readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; readonly onCopyWorkRow: (rowId: string, value: string) => void; readonly onToggleWorkGroup: (groupId: string) => void; readonly onPressImage: (uri: string, headers?: Record) => 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(" {message.text.trim().length > 0 ? ( ) : 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 ( props.onPressImage(uri, headers)} > ); })} {timestampLabel} ); } // 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 ( {message.text.trim().length > 0 ? ( {message.text} ) : 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 ( props.onPressImage(uri, headers)} > ); })} {timestampLabel} ); } if (entry.type === "queued-message") { return ( {entry.queuedMessage.text} {entry.queuedMessage.attachments.length > 0 ? ( {entry.queuedMessage.attachments.length} image {entry.queuedMessage.attachments.length === 1 ? "" : "s"} attached ) : null} {entry.sending ? "dispatching" : `${relativeTime(entry.createdAt)} • pending`} ); } 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 ( {showHeader ? ( Tool calls ({rows.length}) props.onToggleWorkGroup(entry.id)}> {isExpanded ? "Show less" : `Show ${hiddenCount} more`} ) : null} {visibleRows.map((row, index) => ( 0 && "border-t border-neutral-200/80 dark:border-white/[0.06]", )} > { const copyValue = row.detail ?? row.summary; props.onCopyWorkRow(row.id, copyValue); }} style={{ fontFamily: "ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace", }} > {row.detail ? `${row.summary} - ${row.detail}` : row.summary} {props.copiedRowId === row.id ? ( Copied ) : null} ))} ); } function UserMessageContent(props: { readonly text: string; readonly markdownStyles: MarkdownStyleSet; readonly reviewCommentColors: ReviewCommentColors; }) { const segments = parseReviewCommentMessageSegments(props.text); const hasReviewComment = segments.some((segment) => segment.kind === "review-comment"); if (!hasReviewComment) { return ( {props.text} ); } return ( {segments.map((segment) => { if (segment.kind === "review-comment") { return ( ); } const text = segment.text.trim(); if (text.length === 0) { return null; } return ( {text} ); })} ); } const ReviewCommentCard = memo(function ReviewCommentCard(props: { readonly comment: ReviewInlineComment; readonly colors: ReviewCommentColors; }) { const colorScheme = useColorScheme(); const appearanceScheme = colorScheme === "light" ? "light" : "dark"; const NativeReviewDiffView = resolveNativeReviewDiffView(); const patch = useMemo(() => buildReviewCommentPatch(props.comment), [props.comment]); const parsedDiff = useMemo( () => buildReviewParsedDiff(patch, `thread-review-comment:${props.comment.id}`), [patch, props.comment.id], ); const nativeReviewDiffData = useMemo(() => buildNativeReviewDiffData(parsedDiff), [parsedDiff]); const compactNativeRows = useMemo( () => nativeReviewDiffData.rows.filter((row) => row.kind !== "file"), [nativeReviewDiffData.rows], ); const nativeReviewDiffTheme = useMemo( () => createNativeReviewDiffTheme(appearanceScheme), [appearanceScheme], ); const nativeRowsJson = useMemo(() => JSON.stringify(compactNativeRows), [compactNativeRows]); const nativeThemeJson = useMemo( () => JSON.stringify(nativeReviewDiffTheme), [nativeReviewDiffTheme], ); const nativeStyleJson = useMemo(() => JSON.stringify(NATIVE_REVIEW_DIFF_STYLE), []); const nativeDiffHeight = useMemo( () => Math.min( 360, Math.max( 112, compactNativeRows.length * NATIVE_REVIEW_DIFF_ROW_HEIGHT + NATIVE_REVIEW_DIFF_STYLE.fileHeaderVerticalMargin, ), ), [compactNativeRows.length], ); const shouldRenderNativeDiff = NativeReviewDiffView != null && compactNativeRows.length > 0; return ( {compactFileName(props.comment.filePath)} {shouldRenderNativeDiff ? ( ) : props.comment.diff.trim().length > 0 ? ( {props.comment.diff.trim()} ) : null} {props.comment.text.length > 0 ? ( {props.comment.text} ) : null} ); }); function buildReviewCommentPatch(comment: ReviewInlineComment): string { const diff = comment.diff.trim(); if (!diff) { return ""; } if (diff.startsWith("diff --git ")) { return diff; } const normalizedPath = comment.filePath.replaceAll("\\", "/"); return [ `diff --git a/${normalizedPath} b/${normalizedPath}`, `--- a/${normalizedPath}`, `+++ b/${normalizedPath}`, diff, ].join("\n"); } function compactFileName(filePath: string): string { const normalized = filePath.replaceAll("\\", "/"); const lastSlashIndex = normalized.lastIndexOf("/"); return lastSlashIndex >= 0 ? normalized.slice(lastSlashIndex + 1) : normalized; } const IOS_NAV_BAR_HEIGHT = 44; export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const listRef = useRef(null); const copyFeedbackTimeoutRef = useRef | null>(null); const { width: viewportWidth } = useWindowDimensions(); const [copiedRowId, setCopiedRowId] = useState(null); const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [expandedImage, setExpandedImage] = useState<{ uri: string; headers?: Record; } | null>(null); const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; const contentWidth = Math.max(0, viewportWidth - horizontalPadding * 2); const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); const insets = useSafeAreaInsets(); const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT; const bottomContentInset = props.contentBottomInset ?? 18; const iconSubtleColor = useThemeColor("--color-icon-subtle"); const userBubbleColor = useThemeColor("--color-user-bubble"); const markdownStyles = useMarkdownStyles(); const reviewCommentColors = useReviewCommentColors(); useEffect(() => { setCopiedRowId(null); setExpandedWorkGroups({}); }, [props.threadId]); useEffect(() => { return () => { if (copyFeedbackTimeoutRef.current) { clearTimeout(copyFeedbackTimeoutRef.current); } }; }, []); const onCopyWorkRow = useCallback((rowId: string, value: string) => { void Clipboard.setStringAsync(value); void Haptics.selectionAsync(); setCopiedRowId(rowId); if (copyFeedbackTimeoutRef.current) { clearTimeout(copyFeedbackTimeoutRef.current); } copyFeedbackTimeoutRef.current = setTimeout(() => { setCopiedRowId((current) => (current === rowId ? null : current)); copyFeedbackTimeoutRef.current = null; }, 1200); }, []); const onToggleWorkGroup = useCallback((groupId: string) => { setExpandedWorkGroups((current) => ({ ...current, [groupId]: !(current[groupId] ?? false), })); }, []); const onPressImage = useCallback((uri: string, headers?: Record) => { setExpandedImage({ uri, headers }); }, []); const renderItem = useCallback( (info: { item: ThreadFeedEntry; index: number }) => renderFeedEntry(info, { bearerToken: props.bearerToken, copiedRowId, httpBaseUrl: props.httpBaseUrl, expandedWorkGroups, onCopyWorkRow, onToggleWorkGroup, onPressImage, iconSubtleColor, userBubbleColor, markdownStyles, reviewCommentColors, reviewCommentBubbleWidth, }), [ copiedRowId, expandedWorkGroups, iconSubtleColor, userBubbleColor, markdownStyles, reviewCommentColors, reviewCommentBubbleWidth, onCopyWorkRow, onPressImage, onToggleWorkGroup, props.bearerToken, props.httpBaseUrl, ], ); if (props.feed.length === 0) { return ( ); } return ( <> `${entry.type}:${entry.id}`} getItemType={(entry) => entry.type === "message" ? `message:${entry.message.role}` : entry.type } keyboardShouldPersistTaps="handled" estimatedItemSize={180} initialScrollAtEnd maintainScrollAtEnd={{ on: { layout: true, itemLayout: true, dataChange: true }, }} maintainScrollAtEndThreshold={0.1} safeAreaInsetBottom={insets.bottom} contentContainerStyle={{ paddingTop: 12, paddingHorizontal: horizontalPadding, }} /> setExpandedImage(null)} swipeToCloseEnabled doubleTapToZoomEnabled /> ); });