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 { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts"; import { SymbolView } from "expo-symbols"; import { useRouter } from "expo-router"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Markdown, type CustomRenderers, type NodeStyleOverrides, type PartialMarkdownTheme, } from "react-native-nitro-markdown"; import { ActivityIndicator, Image, Linking, type NativeScrollEvent, type NativeSyntheticEvent, 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 { hasNativeSelectableMarkdownText, SelectableMarkdownText, 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 { 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 { LayoutVariant } from "../../lib/layout"; import { buildThreadFilesNavigation } from "../../lib/routes"; 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 { isThreadFeedNearEnd } from "../../lib/threadFeedLayout"; import { relativeTime } from "../../lib/time"; import type { ThreadContentPresentation } from "./threadContentPresentation"; import { ThreadWorkLog } from "./thread-work-log"; import { useAssetUrl } from "../../state/assets"; import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; const THREAD_FEED_END_THRESHOLD = 80; 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); } export interface ThreadFeedProps { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly workspaceRoot?: string | null; readonly feed: ReadonlyArray; readonly contentPresentation: ThreadContentPresentation; readonly agentLabel: string; readonly latestTurn: ThreadFeedLatestTurn | null; readonly contentTopInset?: number; readonly contentBottomInset?: number; readonly layoutVariant?: LayoutVariant; readonly composerExpanded?: boolean; readonly skills?: ReadonlyArray; } function MessageAttachmentImage(props: { readonly environmentId: EnvironmentId; readonly attachmentId: string; readonly className: string; readonly onPressImage: (uri: string, headers?: Record) => void; }) { const uri = useAssetUrl(props.environmentId, { _tag: "attachment", attachmentId: props.attachmentId, }); if (uri === null) { return ( ); } return ( props.onPressImage(uri)}> ); } const MARKDOWN_COLORS = { light: { body: "#111111", strong: "#000000", link: "#2563eb", blockquoteBorder: "rgba(0, 0, 0, 0.08)", blockquoteBackground: "rgba(0, 0, 0, 0.02)", codeBackground: "rgba(0, 0, 0, 0.04)", codeText: "#262626", inlineCodeText: "#5f6368", horizontalRule: "rgba(0, 0, 0, 0.08)", userBody: "#ffffff", userCodeBackground: "rgba(255, 255, 255, 0.22)", userCodeText: "#ffffff", userInlineCodeText: "rgba(255, 255, 255, 0.82)", userFenceBackground: "rgba(0, 0, 0, 0.16)", userFenceText: "#ffffff", }, dark: { body: "#e5e5e5", strong: "#f5f5f5", link: "#60a5fa", blockquoteBorder: "rgba(255, 255, 255, 0.1)", blockquoteBackground: "rgba(255, 255, 255, 0.03)", codeBackground: "rgba(255, 255, 255, 0.06)", codeText: "#e5e5e5", inlineCodeText: "#b8bcc2", horizontalRule: "rgba(255, 255, 255, 0.08)", userBody: "#ffffff", userCodeBackground: "rgba(255, 255, 255, 0.18)", userCodeText: "#ffffff", userInlineCodeText: "rgba(255, 255, 255, 0.82)", userFenceBackground: "rgba(0, 0, 0, 0.28)", userFenceText: "#ffffff", }, } as const; 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(); const markdownLinkStyles = StyleSheet.create({ inlineIcon: { width: 14, height: 14, marginHorizontal: 3, transform: [{ translateY: 2 }], }, favicon: { borderRadius: 3, }, file: { fontFamily: "DMSans_700Bold", fontWeight: "700", }, }); 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 ( { void Linking.openURL(props.href); }} style={{ color: props.color, fontFamily: "DMSans_400Regular", textDecorationLine: "none", }} > {!failed ? ( { failedMarkdownFaviconHosts.add(props.host); setFailed(true); }} /> ) : ( {" ◉ "} )} {props.children} ); }); 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(onLinkPress: (href: string) => void): MarkdownStyleSets { const colorScheme = useColorScheme(); const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"]; const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground")); return useMemo(() => { const markdownBodyColor = colors.body; const markdownStrongColor = colors.strong; const markdownLinkColor = colors.link; const markdownBlockquoteBg = colors.blockquoteBackground; const markdownBlockquoteBorder = colors.blockquoteBorder; const markdownCodeBg = colors.codeBackground; const markdownCodeText = colors.codeText; const markdownInlineCodeText = colors.inlineCodeText; const markdownHrColor = colors.horizontalRule; const markdownUserBodyColor = colors.userBody; const markdownUserCodeBg = colors.userCodeBackground; const markdownUserCodeText = colors.userCodeText; const markdownUserInlineCodeText = colors.userInlineCodeText; const markdownUserFenceBg = colors.userFenceBackground; const markdownUserFenceText = colors.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: 20, h2: 18, h3: 16, h4: 14, h5: 14, h6: 14, }, 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: 10 }, list: { marginTop: 4, marginBottom: 8 }, 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: 2, borderLeftColor: markdownBlockquoteBorder, paddingLeft: 11, paddingVertical: 2, marginLeft: 0, marginVertical: 10, }, heading: { fontFamily: "DMSans_700Bold", color: markdownStrongColor, marginTop: 18, marginBottom: 8, }, horizontal_rule: { backgroundColor: markdownHrColor, height: 1, marginVertical: 12, }, }; const createMarkdownRenderers = ( inlineTextColor: string, inlineCodeTextColor: string, blockBackgroundColor: string, blockTextColor: string, preserveSoftBreaks: boolean, ): CustomRenderers => ({ link: ({ children, href = "" }) => { const presentation = resolveMarkdownLinkPresentation(href); if (presentation.kind === "file") { return ( onLinkPress(href)} style={[markdownLinkStyles.file, { color: inlineTextColor }]} > {presentation.label} ); } if (presentation.kind === "external") { return ( {children} ); } const linkHref = presentation.href; return ( { void Linking.openURL(linkHref); } : undefined } style={{ color: markdownLinkColor, textDecorationLine: "underline", }} > {children} ); }, list: ({ node, Renderer, ordered = false, start = 1 }) => ( {node.children?.map((child, index) => { const childKey = `${child.type}:${child.beg ?? "unknown"}:${child.end ?? "unknown"}`; if (child.type === "task_list_item") { return ( ); } return ( {ordered ? `${start + index}.` : "•"} ); })} ), code_inline: ({ content }) => { const value = content ?? ""; return ( {value} ); }, ...(preserveSoftBreaks ? { soft_break: () => {"\n"}, } : {}), code_block: ({ content, language }) => ( {language ? ( {language} ) : null} {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, marginTop: 8, marginBottom: 4, }, 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: createMarkdownRenderers( markdownUserCodeText, markdownUserInlineCodeText, markdownUserFenceBg, markdownUserFenceText, true, ), nativeTextStyle: { color: markdownUserBodyColor, strongColor: markdownUserBodyColor, mutedColor: markdownUserBodyColor, linkColor: markdownUserBodyColor, inlineCodeColor: markdownUserInlineCodeText, codeColor: markdownUserCodeText, codeBackgroundColor: markdownUserCodeBg, codeBlockBackgroundColor: markdownUserFenceBg, fileTextColor: "#ffffff", skillTextColor: "#f0abfc", quoteMarkerColor: markdownUserBodyColor, dividerColor: markdownUserBodyColor, fontSize: 15, lineHeight: 22, fontFamily: "DMSans_400Regular", headingFontFamily: "DMSans_700Bold", boldFontFamily: "DMSans_700Bold", }, }, assistant: { theme: assistantTheme, styles: assistantStyles, renderers: createMarkdownRenderers( markdownCodeText, markdownInlineCodeText, markdownCodeBg, markdownCodeText, false, ), nativeTextStyle: { color: markdownBodyColor, strongColor: markdownStrongColor, mutedColor: markdownBodyColor, linkColor: markdownLinkColor, inlineCodeColor: markdownInlineCodeText, codeColor: markdownCodeText, codeBackgroundColor: markdownCodeBg, codeBlockBackgroundColor: markdownCodeBg, fileTextColor: markdownCodeText, skillTextColor: inlineSkillForeground, quoteMarkerColor: markdownBlockquoteBorder, dividerColor: markdownHrColor, fontSize: 15, lineHeight: 22, fontFamily: "DMSans_400Regular", headingFontFamily: "DMSans_700Bold", boldFontFamily: "DMSans_700Bold", }, }, }; }, [colors, inlineSkillForeground, onLinkPress]); } function renderFeedEntry( info: { item: ThreadFeedEntry; index: number }, props: Pick & { readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; readonly expandedWorkRows: Record; readonly terminalAssistantMessageIds: ReadonlySet; readonly unsettledTurnId: TurnId | null; readonly onCopyWorkRow: (rowId: string, value: string) => void; readonly onToggleWorkGroup: (groupId: string) => void; readonly onToggleWorkRow: (rowId: string) => void; readonly onToggleTurnFold: (turnId: TurnId) => void; readonly onPressImage: (uri: string, headers?: Record) => void; readonly onMarkdownLinkPress: (href: 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; readonly userBubbleMaxWidth: number; }, ) { const entry = info.item; const { markdownStyles, iconSubtleColor, userBubbleColor } = props; if (entry.type === "turn-fold") { return ( props.onToggleTurnFold(entry.turnId)} hitSlop={4} className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-neutral-200/80 px-2 dark:border-white/[0.08]" > {entry.label} ); } if (entry.type === "message") { const { message } = entry; const isUser = message.role === "user"; const styles = isUser ? markdownStyles.user : markdownStyles.assistant; const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt); const attachments = message.attachments ?? []; const hasReviewCommentContext = message.text.includes(" {message.text.trim().length > 0 ? ( ) : null} {attachments.map((attachment) => { return ( ); })} {timestampLabel} {message.text.trim().length > 0 ? ( ) : null} ); } // 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 ? ( hasNativeSelectableMarkdownText() ? ( ) : ( {message.text} ) ) : null} {attachments.map((attachment) => { return ( ); })} {showAssistantMeta ? ( {timestampLabel} ) : null} ); } 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`} ); } return ( props.onToggleWorkGroup(entry.id)} onToggleRow={props.onToggleWorkRow} /> ); } function UserMessageContent(props: { readonly text: string; readonly markdownStyles: MarkdownStyleSet; readonly reviewCommentColors: ReviewCommentColors; readonly skills?: ReadonlyArray; readonly onLinkPress: (href: string) => void; }) { const segments = parseReviewCommentMessageSegments(props.text); const hasReviewComment = segments.some((segment) => segment.kind === "review-comment"); if (!hasReviewComment) { if (hasNativeSelectableMarkdownText()) { return ( ); } 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 hasNativeSelectableMarkdownText() ? ( ) : ( {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 { if ((comment.fenceLanguage ?? "diff") !== "diff") { return ""; } 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; } function ThreadFeedPlaceholder(props: { readonly bottomInset: number; readonly detail: string; readonly horizontalPadding: number; readonly loading?: boolean; readonly title: string; readonly topInset: number; }) { return ( {props.loading ? : null} {props.title} {props.detail} ); } export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const router = useRouter(); const listRef = useRef(null); const copyFeedbackTimeoutRef = useRef | null>(null); const scrollFrameRef = useRef(null); const foldSettleFrameRef = useRef(null); const foldSettleSecondFrameRef = useRef(null); const suppressAutoFollowRef = useRef(false); const previousLatestTurnRef = useRef(props.latestTurn); const isNearEndRef = useRef(true); const initialScrollReadyRef = useRef(false); const lastContentHeightRef = useRef(0); const { width: viewportWidth } = useWindowDimensions(); const [interactionState, setInteractionState] = useState<{ readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; readonly expandedWorkRows: Record; readonly expandedTurnIds: ReadonlySet; }>({ copiedRowId: null, expandedWorkGroups: {}, expandedWorkRows: {}, expandedTurnIds: new Set(), }); const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; 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 userBubbleMaxWidth = contentWidth * 0.85; const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); const insets = useSafeAreaInsets(); const topContentInset = props.contentTopInset ?? insets.top + 44; const bottomContentInset = props.contentBottomInset ?? 18; const iconSubtleColor = useThemeColor("--color-icon-subtle"); const userBubbleColor = useThemeColor("--color-user-bubble"); const onMarkdownLinkPress = useCallback( (href: string) => { const presentation = resolveMarkdownLinkPresentation(href); if (presentation.kind === "file") { const relativePath = resolveWorkspaceRelativeFilePath( props.workspaceRoot, presentation.path, ); if (relativePath) { void Haptics.selectionAsync(); router.push( buildThreadFilesNavigation( { environmentId: props.environmentId, threadId: props.threadId }, relativePath, presentation.line, ), ); } return; } if (presentation.href) { void Linking.openURL(presentation.href); } }, [props.environmentId, props.threadId, props.workspaceRoot, router], ); const markdownStyles = useMarkdownStyles(onMarkdownLinkPress); const reviewCommentColors = useReviewCommentColors(); // LegendList does not invalidate visible rows when only the renderItem closure changes. // Keep row-local interaction props in extraData so disclosures and copy feedback repaint. const listAppearanceData = useMemo( () => ({ copiedRowId, expandedWorkGroups, expandedWorkRows, iconSubtleColor, markdownStyles, reviewCommentColors, userBubbleColor, }), [ copiedRowId, expandedWorkGroups, expandedWorkRows, iconSubtleColor, markdownStyles, reviewCommentColors, userBubbleColor, ], ); const presentedFeed = useMemo( () => deriveThreadFeedPresentation(props.feed, props.latestTurn, expandedTurnIds), [expandedTurnIds, props.feed, props.latestTurn], ); const terminalAssistantMessageIds = useMemo(() => { const terminalIdsByTurn = new Map(); for (const entry of props.feed) { if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) { terminalIdsByTurn.set(entry.message.turnId, entry.message.id); } } return new Set(terminalIdsByTurn.values()); }, [props.feed]); const unsettledTurnId = props.latestTurn && (props.latestTurn.completedAt === null || props.latestTurn.state === "running") ? props.latestTurn.turnId : null; const scrollToEnd = useCallback(() => { if (scrollFrameRef.current !== null) { return; } scrollFrameRef.current = requestAnimationFrame(() => { scrollFrameRef.current = null; listRef.current?.scrollToEnd({ animated: false }); }); }, []); const onListScroll = useCallback( (event: NativeSyntheticEvent | NativeScrollEvent) => { const scrollEvent = "nativeEvent" in event ? event.nativeEvent : event; const { contentInset, contentOffset, contentSize, layoutMeasurement } = scrollEvent; isNearEndRef.current = isThreadFeedNearEnd( { contentHeight: contentSize.height, viewportHeight: layoutMeasurement.height, offsetY: contentOffset.y, bottomInset: contentInset.bottom, }, THREAD_FEED_END_THRESHOLD, ); }, [], ); const onListContentSizeChange = useCallback( (_width: number, height: number) => { const contentGrew = height > lastContentHeightRef.current + 0.5; lastContentHeightRef.current = height; if ( initialScrollReadyRef.current && contentGrew && isNearEndRef.current && !suppressAutoFollowRef.current ) { scrollToEnd(); } }, [scrollToEnd], ); const onListLoad = useCallback(() => { initialScrollReadyRef.current = true; }, []); useEffect(() => { const previous = previousLatestTurnRef.current; previousLatestTurnRef.current = props.latestTurn; if (!props.latestTurn || !previous) { return; } if (props.latestTurn.turnId === previous.turnId) { if (previous.state === "running" && props.latestTurn.state === "interrupted") { const interruptedTurnId = props.latestTurn.turnId; setInteractionState((current) => ({ ...current, expandedTurnIds: new Set(current.expandedTurnIds).add(interruptedTurnId), })); } return; } setInteractionState((current) => { if (!current.expandedTurnIds.has(previous.turnId)) { return current; } const next = new Set(current.expandedTurnIds); next.delete(previous.turnId); return { ...current, expandedTurnIds: next }; }); }, [props.latestTurn]); useEffect(() => { return () => { if (copyFeedbackTimeoutRef.current) { clearTimeout(copyFeedbackTimeoutRef.current); } if (scrollFrameRef.current !== null) { cancelAnimationFrame(scrollFrameRef.current); } if (foldSettleFrameRef.current !== null) { cancelAnimationFrame(foldSettleFrameRef.current); } if (foldSettleSecondFrameRef.current !== null) { cancelAnimationFrame(foldSettleSecondFrameRef.current); } }; }, []); const onCopyWorkRow = useCallback((rowId: string, value: string) => { void Clipboard.setStringAsync(value); void Haptics.selectionAsync(); setInteractionState((current) => ({ ...current, copiedRowId: rowId })); if (copyFeedbackTimeoutRef.current) { clearTimeout(copyFeedbackTimeoutRef.current); } copyFeedbackTimeoutRef.current = setTimeout(() => { setInteractionState((current) => current.copiedRowId === rowId ? { ...current, copiedRowId: null } : current, ); copyFeedbackTimeoutRef.current = null; }, 1200); }, []); const onToggleWorkGroup = useCallback((groupId: string) => { setInteractionState((current) => ({ ...current, expandedWorkGroups: { ...current.expandedWorkGroups, [groupId]: !(current.expandedWorkGroups[groupId] ?? false), }, })); }, []); const onToggleWorkRow = useCallback((rowId: string) => { setInteractionState((current) => ({ ...current, expandedWorkRows: { ...current.expandedWorkRows, [rowId]: !(current.expandedWorkRows[rowId] ?? false), }, })); }, []); const onToggleTurnFold = useCallback((turnId: TurnId) => { suppressAutoFollowRef.current = true; if (foldSettleFrameRef.current !== null) { cancelAnimationFrame(foldSettleFrameRef.current); } if (foldSettleSecondFrameRef.current !== null) { cancelAnimationFrame(foldSettleSecondFrameRef.current); } setInteractionState((current) => { const next = new Set(current.expandedTurnIds); if (next.has(turnId)) { next.delete(turnId); } else { next.add(turnId); } return { ...current, expandedTurnIds: next }; }); foldSettleFrameRef.current = requestAnimationFrame(() => { foldSettleSecondFrameRef.current = requestAnimationFrame(() => { suppressAutoFollowRef.current = false; foldSettleFrameRef.current = null; foldSettleSecondFrameRef.current = null; }); }); }, []); const onPressImage = useCallback((uri: string, headers?: Record) => { setExpandedImage({ uri, headers }); }, []); const renderItem = useCallback( (info: { item: ThreadFeedEntry; index: number }) => renderFeedEntry(info, { environmentId: props.environmentId, copiedRowId, expandedWorkGroups, expandedWorkRows, terminalAssistantMessageIds, unsettledTurnId, onCopyWorkRow, onToggleWorkGroup, onToggleWorkRow, onToggleTurnFold, onPressImage, onMarkdownLinkPress, iconSubtleColor, userBubbleColor, markdownStyles, reviewCommentColors, reviewCommentBubbleWidth, userBubbleMaxWidth, skills: props.skills, }), [ copiedRowId, expandedWorkGroups, expandedWorkRows, terminalAssistantMessageIds, unsettledTurnId, iconSubtleColor, userBubbleColor, markdownStyles, reviewCommentColors, reviewCommentBubbleWidth, userBubbleMaxWidth, onCopyWorkRow, onMarkdownLinkPress, onPressImage, onToggleTurnFold, onToggleWorkGroup, onToggleWorkRow, props.environmentId, props.skills, ], ); if (props.contentPresentation.kind === "loading") { return ( ); } if (props.contentPresentation.kind === "unavailable") { return ( ); } if (props.feed.length === 0) { return ( ); } return ( <> `${entry.type}:${entry.id}`} getItemType={(entry) => entry.type === "message" ? `message:${entry.message.role}` : entry.type } keyboardShouldPersistTaps="always" keyboardDismissMode="none" estimatedItemSize={180} initialScrollAtEnd onContentSizeChange={onListContentSizeChange} onLoad={onListLoad} onScroll={onListScroll} scrollEventThrottle={16} ListHeaderComponent={} contentContainerStyle={{ paddingTop: 12, paddingBottom: bottomContentInset, paddingHorizontal: horizontalPadding, }} /> setExpandedImage(null)} swipeToCloseEnabled doubleTapToZoomEnabled /> ); });