import * as Haptics from "expo-haptics"; import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; import { ThreadId, type ChatAttachment, type ChatFileAttachment, type ChatImageAttachment, type EnvironmentId, type MessageId, type OrchestrationV2ProjectedTurnItem, type RunId, } from "@t3tools/contracts"; import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { codexArtifactTemplatePresentationLabel, type CodexArtifactTemplate, } from "@t3tools/client-runtime/codex-artifact-templates"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { classifyMarkdownImageSource, markdownImageSourceFragment, } from "@t3tools/client-runtime/markdown-images"; import { resolveViewedImageAsset } from "@t3tools/client-runtime/work-log/presentation"; import { renderCodexFileCitationsAsMarkdown, splitCodexArtifactTemplateMarkdown, } from "@t3tools/client-runtime/codex-markdown-directives"; import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; import { HeaderHeightContext } from "@react-navigation/elements"; import { useFocusEffect, useNavigation } from "@react-navigation/native"; import { createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useId, type ReactNode, type RefObject, } from "react"; import { Markdown, type CustomRenderers, type NodeStyleOverrides, type PartialMarkdownTheme, } from "react-native-nitro-markdown"; import { ActivityIndicator, Alert, Image, Platform, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent, Pressable, ScrollView, StyleSheet, Text as NativeText, type ColorValue, useWindowDimensions, View, } from "react-native"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { isPdfFile } from "../../lib/filePreview"; import { PresentationSource } from "../../components/NativePresentation"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { FadeIn, FadeInUp, LinearTransition, type SharedValue, } from "react-native-reanimated"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { useFontFamily } from "../../lib/useFontFamily"; import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, type MarkdownFileContextMenu, type MarkdownImageRenderer, type NativeMarkdownTextStyle, type SelectableMarkdownSkill, } from "../../native/SelectableMarkdownText"; import { AppText as Text } from "../../components/AppText"; import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; import { VideoAttachmentTile } from "../../components/VideoAttachmentTile"; import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; import { attachmentVideoPreviewSource, mediaVideoPreviewUri, mediaVideoThumbnailKey, type MediaVideoPreviewSource, } from "../../lib/videoPreviewSource"; import { CopyTextButton } from "../../components/CopyTextButton"; import { parseReviewCommentMessageSegments, type ReviewInlineComment, } from "../review/reviewCommentSelection"; import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter"; import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface"; import { buildNativeReviewDiffData, createNativeReviewDiffTheme, NATIVE_REVIEW_DIFF_CONTENT_WIDTH, } from "../review/nativeReviewDiffAdapter"; import { buildReviewParsedDiff } from "../review/reviewModel"; import { cn } from "../../lib/cn"; import { deriveCenteredContentHorizontalPadding, deriveThreadFeedInitialContentInset, deriveThreadWorkLogSizing, type LayoutVariant, } from "../../lib/layout"; import { uuidv4 } from "../../lib/uuid"; import { resolveMarkdownFontSizes, resolveNativeMarkdownTypography, } from "../../lib/appearancePreferences"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; import { normalizeNativeMarkdownUrl, resolveMarkdownInlineCodePresentation, resolveMarkdownLinkPresentation, } from "@t3tools/mobile-markdown-text/links"; import { deriveThreadFeedPresentation, threadFeedRunIsUnsettled, isContextCompactionActivityGroup, type ThreadFeedEntry, type ThreadFeedLatestRun, } from "../../lib/threadActivity"; import type { ThreadContentPresentation } from "./threadContentPresentation"; import { resolveThreadFeedLiveFollow, type ThreadFeedLiveFollowEvent, type ThreadWorkGroupScrollPosition, } from "./thread-feed-live-follow"; import { collapsedWorkLogHeight, ThreadDisclosureChevron, THREAD_DISCLOSURE_TRANSITION_MS, ThreadWorkGroupToggle, ThreadWorkLog, } from "./thread-work-log"; import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; import { assetEnvironment, useAssetUrl, useAssetUrlState, useRefreshAssetUrl, } from "../../state/assets"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { usePreparedConnection } from "../../state/session"; import * as Option from "effect/Option"; import { videoMimeType } from "@t3tools/shared/video"; import { appAtomRegistry } from "../../state/atom-registry"; import { environmentThreadShells, threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import { useV2ItemSupport } from "../../state/v2-item-support"; import { basename, fileRoutePathSegments, isAbsolutePath, resolveWorkspaceRelativeFilePath, } from "../files/filePath"; import { waitForThreadShellReady } from "./threadForkNavigation"; import { resolveUserMessageIntentBadge } from "./userMessageIntentBadge"; import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu"; import { ThreadMarkdownImage, ThreadMarkdownImageUnavailable, ThreadMarkdownImageView, } from "./ThreadMarkdownImage"; const WIDE_MARKDOWN_BLOCK_OPTIONS = { // Native iOS blockquotes and adjacent selectable text are separate layout // chunks. Giving their shrink-to-fit bubble a definite width keeps both // chunks measured against the width at which UIKit draws them. includeBlockquotes: Platform.OS === "ios", includeOrderedLists: Platform.OS === "android", } as const; 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); } const TURN_FOLD_HEIGHT = 42; const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); // Let neighboring rows move out of the new rows' space before showing their text. const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay( THREAD_DISCLOSURE_TRANSITION_MS, ).duration(140); // Entering animations must only play for rows born just now — LegendList // remounts rows when they scroll back into view, and replaying an entrance for // old content would be its own kind of jank. const FRESH_ENTRY_WINDOW_MS = 3_000; function isFreshTimestamp(input: string): boolean { const timestamp = Date.parse(input); return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ENTRY_WINDOW_MS; } export interface ThreadFeedHistoryControls { readonly hasMoreHistory: boolean; readonly loading: boolean; readonly error: string | null; readonly onLoadEarlier: () => void; } export interface ThreadFeedProps { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly threadTitle: string; readonly workspaceRoot?: string | null; readonly feed: ReadonlyArray; readonly contentPresentation: ThreadContentPresentation; readonly agentLabel: string; readonly latestRun: ThreadFeedLatestRun | null; readonly activeWorkStartedAt: string | null; readonly listRef: RefObject; readonly freeze: SharedValue; readonly anchorMessageId: MessageId | null; readonly submittedMessageId: MessageId | null; readonly contentInsetEndAdjustment: SharedValue; readonly contentTopInset?: number; readonly contentBottomInset?: number; readonly historyControls?: ThreadFeedHistoryControls; readonly topAccessory?: ReactNode; readonly contentMaxWidth?: number; readonly layoutVariant?: LayoutVariant; readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; readonly onEndFollowEnabledChange?: (enabled: boolean) => void; readonly skills?: ReadonlyArray; readonly onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; } async function waitForThreadShell( environmentId: EnvironmentId, threadId: ThreadId, ): Promise { const atom = environmentThreadShells.threadShellAtom(scopeThreadRef(environmentId, threadId)); return waitForThreadShellReady({ read: () => appAtomRegistry.get(atom) !== null, }); } function AssistantForkButton(props: { readonly environmentId: EnvironmentId; readonly iconColor: ColorValue; readonly projectedItem: OrchestrationV2ProjectedTurnItem; readonly sourceTitle: string; }) { const support = useV2ItemSupport({ environmentId: props.environmentId, sourceThreadId: props.projectedItem.sourceThreadId, sourceItemId: props.projectedItem.sourceItemId, }); const forkFromRun = useAtomCommand(threadEnvironment.forkFromRun, "fork from response"); const navigation = useNavigation(); const [busy, setBusy] = useState(false); const canFork = canForkProjectedAssistantItem({ projectedItem: props.projectedItem, capabilities: support.providerSession?.capabilities, }); const runId = props.projectedItem.item.runId; if (!canFork || runId === null) return null; return ( { const targetThreadId = ThreadId.make(uuidv4()); setBusy(true); void Haptics.selectionAsync(); void forkFromRun({ environmentId: props.environmentId, input: { sourceThreadId: props.projectedItem.sourceThreadId, targetThreadId, runId, title: `${props.sourceTitle} fork`, creationSource: "mobile", }, }) .then(async (result) => { if (result._tag !== "Success") return; const targetThreadReady = await waitForThreadShell(props.environmentId, targetThreadId); if (!targetThreadReady) { Alert.alert( "Fork created", "Its thread data did not reach this client. Reconnect and try opening it from the thread list.", ); return; } navigation.navigate("Thread", { environmentId: props.environmentId, threadId: targetThreadId, }); }) .finally(() => setBusy(false)); }} className="h-7 w-7 items-center justify-center disabled:opacity-40" > {busy ? ( ) : ( )} ); } function MessageAttachmentImage(props: { readonly environmentId: EnvironmentId; readonly attachmentId: string; readonly name: string; readonly mimeType: string; readonly className: string; readonly onPressPreview: (source: FilePreviewSource) => void; }) { const sourceIdentifier = useId(); const resource = useMemo( () => ({ _tag: "attachment" as const, attachmentId: props.attachmentId, fileName: props.name, mimeType: props.mimeType, }), [props.attachmentId, props.name, props.mimeType], ); const uri = useAssetUrl(props.environmentId, resource); if (uri === null) { return ( ); } return ( // The viewer mints its own URL from the resource so the image survives a refresh. props.onPressPreview({ kind: "image", environmentId: props.environmentId, resource, name: props.name, sourceIdentifier, actionsSource: { name: props.name, mimeType: props.mimeType, environmentId: props.environmentId, resource, }, }) } > ); } // The attachment union has an open member (`type: string` for attachment // types from newer servers), so literal comparisons do not narrow it. Split // with guards and render unknown types as inert rows, never crash. function isImageAttachment(attachment: ChatAttachment): attachment is ChatImageAttachment { return attachment.type === "image"; } function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAttachment { return attachment.type === "file"; } function MessageAttachmentFile(props: { readonly environmentId: EnvironmentId; readonly attachment: ChatFileAttachment; readonly onPressPreview: (source: FilePreviewSource) => void; readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; }) { const sourceIdentifier = useId(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { refresh: true, reportFailure: false, }); const preparedConnection = usePreparedConnection(props.environmentId); const { attachment } = props; const videoType = videoMimeType(attachment); const isPdf = isPdfFile(attachment); const fileTypeLabel = isPdf ? "PDF" : (attachment.name.match(/\.([a-z0-9]{1,8})$/i)?.[1]?.toUpperCase() ?? "File"); const sizeLabel = formatAttachmentSize(attachment.sizeBytes); const thumbnailUrl = useAssetUrl( props.environmentId, videoType === null ? null : { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType: videoType, }, ); const httpBaseUrl = Option.isSome(preparedConnection) ? preparedConnection.value.httpBaseUrl : null; const openingRef = useRef(null); const [opening, setOpening] = useState(false); useFocusEffect( useCallback(() => { setOpening(false); return () => { openingRef.current?.abort(); openingRef.current = null; }; }, [props.environmentId, attachment.id, httpBaseUrl]), ); const shareFile = (sourceIdentifier?: string) => { if (httpBaseUrl === null || openingRef.current) return; const controller = new AbortController(); openingRef.current = controller; setOpening(true); void (async () => { try { const result = await createAssetUrl({ environmentId: props.environmentId, input: { resource: { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType: attachment.mimeType, }, }, }); if (controller.signal.aborted) return; if (result._tag === "Failure") { throw squashAtomCommandFailure(result); } const url = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl); if (url === null) { throw new Error("The attachment could not be opened."); } await downloadAndShareAttachment({ url, attachment, signal: controller.signal, sourceIdentifier, }); } catch (error) { if (!controller.signal.aborted) { Alert.alert( "Could not open attachment", error instanceof Error ? error.message : "The attachment is unavailable.", ); } } finally { if (openingRef.current === controller) { openingRef.current = null; setOpening(false); } } })(); }; if (videoType !== null) { const sourceIdentifier = `attachment:${props.environmentId}:${attachment.id}`; return ( props.onPressVideo(attachment, sourceIdentifier)} className="my-1 rounded-2xl" style={{ width: 224, maxWidth: "100%", aspectRatio: 16 / 9 }} /> ); } return ( isPdf ? props.onPressPreview({ kind: "pdf", name: attachment.name, environmentId: props.environmentId, resource: { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType: "application/pdf", }, sourceIdentifier, }) : shareFile(sourceIdentifier) } > {opening ? ( ) : ( )} {attachment.name} {fileTypeLabel} · {sizeLabel} ); } /** * An attachment type this build does not know (newer server). Rendered as an * inert row: the name is still useful, but there is nothing to open. */ function MessageAttachmentUnknown(props: { readonly name: string }) { return ( {props.name} ); } const ThreadMediaVisibleContext = createContext(false); // LegendList only computes hook visibility when the list has a viewability config. const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 }; function ThreadMediaVisibility(props: { readonly children: ReactNode }) { const [visible, setVisible] = useState(false); useViewabilityAmount( useCallback((token) => setVisible(token.sizeVisible > 0), []), ); return {props.children}; } function ThreadMarkdownVideo(props: { readonly source: MediaVideoPreviewSource }) { const { source } = props; const visible = useContext(ThreadMediaVisibleContext); const thumbnailKey = mediaVideoThumbnailKey(source); const asset = useAssetUrlState( "environmentId" in source ? source.environmentId : null, "resource" in source ? source.resource : null, ); const refreshAssetUrl = useRefreshAssetUrl( "environmentId" in source ? source.environmentId : null, "resource" in source ? source.resource : null, ); const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); return ( mediaVideoPreviewUri(source, await refreshAssetUrl()) : undefined } name={source.name} thumbnailKey={thumbnailKey} thumbnailVisible={visible} unavailable={"resource" in source && asset._tag === "Failure"} actionsSource={source.actionsSource} /> ); } const MARKDOWN_MONO_FONT = Platform.select({ ios: "ui-monospace", android: "monospace", default: "monospace", }); 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 MarkdownLinkLabelContext = createContext(false); const markdownLinkStyles = StyleSheet.create({ inlineIcon: { width: 14, height: 14, marginHorizontal: 3, transform: [{ translateY: 2 }], }, favicon: { borderRadius: 3, }, }); const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { readonly children: ReactNode; readonly color: string; readonly host: string; readonly href: string; readonly onPress: (href: string) => void; }) { const [failedHost, setFailedHost] = useState(null); const faviconUrl = faviconUrlForOrigin(`https://${props.host}`); return ( props.onPress(props.href)} style={{ color: props.color, textDecorationLine: "none", }} > {faviconUrl !== null && failedHost !== props.host && !failedMarkdownFaviconHosts.has(props.host) ? ( { failedMarkdownFaviconHosts.add(props.host); setFailedHost(props.host); }} /> ) : ( {" ◉ "} )} {props.children} ); }); function MarkdownInlineCode(props: { readonly content: string; readonly textColor: string; readonly codeColor: string; readonly fontSize: number; readonly lineHeight: number; readonly onLinkPress: (href: string) => void; }) { const insideLink = useContext(MarkdownLinkLabelContext); const presentation = insideLink ? null : resolveMarkdownInlineCodePresentation(props.content); return ( props.onLinkPress(presentation.href) : undefined} style={{ color: presentation ? props.textColor : props.codeColor, fontSize: props.fontSize, lineHeight: props.lineHeight, }} > {presentation ? ( ) : null} {presentation?.label ?? props.content} ); } const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record< CodexArtifactTemplate["artifactKind"], AppSymbolName > = { document: "doc.text", presentation: "chart.bar.xaxis", spreadsheet: "chart.bar.xaxis", site: "safari", "google-docs": "doc.text", "google-slides": "chart.bar.xaxis", "google-sheets": "chart.bar.xaxis", image: "camera", email: "text.bubble", slack: "text.bubble", }; function ArtifactTemplateCard(props: { readonly template: CodexArtifactTemplate; readonly onUse?: ((template: CodexArtifactTemplate) => void) | undefined; }) { return ( {props.template.displayName} {codexArtifactTemplatePresentationLabel(props.template.artifactKind)} {props.onUse ? ( props.onUse?.(props.template)} > Use template ) : null} ); } /** Tap opens a link; long-press on a native file chip shows its menu. Built once per feed. */ interface MarkdownLinkHandlers { readonly onLinkPress: (href: string) => void; readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; readonly onFileContextMenuAction: (href: string, actionId: string) => void; } const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { readonly markdown: string; readonly markdownStyles: MarkdownStyleSet; readonly linkHandlers: MarkdownLinkHandlers; readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; readonly renderImage: MarkdownImageRenderer; readonly skills?: ReadonlyArray | undefined; }) { const segments = useMemo( () => splitCodexArtifactTemplateMarkdown(props.markdown), [props.markdown], ); return segments.map((segment) => { if (segment.kind === "artifact-template") { return ( ); } if (segment.markdown.trim().length === 0) return null; const markdown = renderCodexFileCitationsAsMarkdown(segment.markdown); return hasNativeSelectableMarkdownText() ? ( ) : ( {markdown} ); }); }); function MarkdownCodeBlock(props: { readonly backgroundColor: string; readonly borderColor: string; readonly content: string; readonly copyTintColor: ColorValue; readonly headerTextColor: string; readonly fontSize: number; readonly highlightCode: boolean; readonly language?: string | null; readonly lineHeight: number; readonly textColor: string; readonly theme: ReviewDiffTheme; }) { const content = props.content.replace(/\n$/, ""); const languageLabel = props.language?.trim() || "text"; const highlighted = useMarkdownCodeHighlight({ code: content, enabled: props.highlightCode && Boolean(props.language?.trim()), language: props.language, theme: props.theme, }); let tokenOffset = 0; return ( {languageLabel} {highlighted ? highlighted.map((line, lineIndex) => { const lineStartOffset = tokenOffset; const lineText = line.map((token) => token.content).join(""); const renderedLine = ( {line.map((token) => { const startOffset = tokenOffset; tokenOffset += token.content.length; const fontStyle = token.fontStyle !== null && (token.fontStyle & 1) === 1 ? ("italic" as const) : ("normal" as const); const fontWeight = token.fontStyle !== null && (token.fontStyle & 2) === 2 ? ("700" as const) : ("400" as const); return ( {token.content} ); })} {lineIndex + 1 < highlighted.length ? "\n" : ""} ); if (lineIndex + 1 < highlighted.length) { tokenOffset += 1; } return renderedLine; }) : content} ); } function useReviewCommentColors(): ReviewCommentColors { const theme = useUniwindTheme(); return useMemo( () => ({ background: theme["--color-card"], border: theme["--color-border"], mutedBackground: theme["--color-subtle"], text: theme["--color-foreground"], mutedText: theme["--color-foreground-muted"], codeBackground: theme["--color-md-code-bg"], }), [theme], ); } function useMarkdownStyles( onLinkPress: (href: string) => void, renderImage: MarkdownImageRenderer, ): MarkdownStyleSets { const { appearance, themeAppearance } = useAppearancePreferences(); const markdownFontSizes = useMemo( () => resolveMarkdownFontSizes(appearance.baseFontSize), [appearance.baseFontSize], ); const nativeMarkdownTypography = useMemo( () => resolveNativeMarkdownTypography(appearance.baseFontSize), [appearance.baseFontSize], ); const themeMode = themeAppearance; const theme = useUniwindTheme(); const markdownBodyColor = theme["--color-md-body"]; const markdownStrongColor = theme["--color-md-strong"]; const markdownLinkColor = theme["--color-md-link"]; const markdownBlockquoteBg = theme["--color-md-blockquote-bg"]; const markdownBlockquoteBorder = theme["--color-md-blockquote-border"]; const markdownCodeBg = theme["--color-md-code-bg"]; const markdownCodeText = theme["--color-md-code-text"]; const markdownInlineCodeText = theme["--color-foreground-secondary"]; const markdownHrColor = theme["--color-md-hr"]; const markdownUserBodyColor = theme["--color-user-bubble-foreground"]; const markdownUserCodeBg = theme["--color-md-user-code-bg"]; const markdownUserCodeText = theme["--color-md-user-code-text"]; const markdownUserInlineCodeText = theme["--color-user-bubble-foreground-muted"]; const markdownUserFenceBg = theme["--color-md-user-fence-bg"]; const markdownUserFenceText = theme["--color-md-user-fence-text"]; const iconSubtleColor = theme["--color-icon-subtle"]; const inlineSkillForeground = theme["--color-inline-skill-foreground"]; const userBubbleSkillForeground = theme["--color-user-bubble-skill-foreground"]; const userBubbleForegroundMuted = theme["--color-user-bubble-foreground-muted"]; const regularFontFamily = useFontFamily("regular"); const boldFontFamily = useFontFamily("bold"); return useMemo(() => { const baseTheme: PartialMarkdownTheme = { colors: { text: markdownBodyColor, heading: markdownStrongColor, link: markdownLinkColor, blockquote: markdownBlockquoteBorder, border: markdownHrColor, surface: "transparent", 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: markdownFontSizes.s, m: markdownFontSizes.m, h1: markdownFontSizes.h1, h2: markdownFontSizes.h2, h3: markdownFontSizes.h3, h4: markdownFontSizes.h4, h5: markdownFontSizes.h5, h6: markdownFontSizes.h6, }, fontFamilies: { regular: regularFontFamily, heading: boldFontFamily, mono: MARKDOWN_MONO_FONT, }, 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: markdownFontSizes.bodyLineHeight }, bold: { fontWeight: "700", color: markdownStrongColor, fontFamily: boldFontFamily, }, 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: boldFontFamily, color: markdownStrongColor, marginTop: 18, marginBottom: 8, }, horizontal_rule: { backgroundColor: markdownHrColor, height: 1, marginVertical: 12, }, }; const createMarkdownRenderers = ( inlineTextColor: string, inlineCodeTextColor: string, blockBackgroundColor: string, blockTextColor: string, copyTintColor: ColorValue, preserveSoftBreaks: boolean, highlightCode: boolean, ): CustomRenderers => ({ link: ({ children, href = "" }) => { const presentation = resolveMarkdownLinkPresentation(href); if (presentation.kind === "file") { return ( onLinkPress(href)} style={{ color: inlineTextColor }} > {presentation.label} ); } if (presentation.kind === "external") { return ( {children} ); } const linkHref = presentation.href; return ( { void tryOpenExternalUrl(linkHref, "markdown-link"); } : undefined } style={{ color: markdownLinkColor }} > {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}.` : "•"} ); })} ), image: ({ node }) => node.href ? (renderImage({ href: node.href, alt: node.alt ?? null, title: node.title ?? null, }) ?? undefined) : undefined, code_inline: ({ content }) => ( ), ...(preserveSoftBreaks ? { soft_break: () => {"\n"}, } : {}), code_block: ({ content = "", language }) => ( ), }); 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: boldFontFamily, }, 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, userBubbleForegroundMuted, true, false, ), nativeTextStyle: { color: markdownUserBodyColor, strongColor: markdownUserBodyColor, mutedColor: markdownUserBodyColor, linkColor: markdownUserBodyColor, inlineCodeColor: markdownUserInlineCodeText, codeColor: markdownUserCodeText, codeBackgroundColor: markdownUserCodeBg, codeBlockBackgroundColor: markdownUserFenceBg, fileTextColor: markdownUserBodyColor, skillTextColor: userBubbleSkillForeground, quoteMarkerColor: markdownUserBodyColor, dividerColor: markdownUserBodyColor, fontSize: nativeMarkdownTypography.fontSize, lineHeight: nativeMarkdownTypography.lineHeight, headingFontSizes: nativeMarkdownTypography.headingFontSizes, fontFamily: regularFontFamily, headingFontFamily: boldFontFamily, boldFontFamily, }, }, assistant: { theme: assistantTheme, styles: assistantStyles, renderers: createMarkdownRenderers( markdownCodeText, markdownInlineCodeText, markdownCodeBg, markdownCodeText, iconSubtleColor, false, true, ), 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: nativeMarkdownTypography.fontSize, lineHeight: nativeMarkdownTypography.lineHeight, headingFontSizes: nativeMarkdownTypography.headingFontSizes, fontFamily: regularFontFamily, headingFontFamily: boldFontFamily, boldFontFamily, }, }, }; }, [ boldFontFamily, iconSubtleColor, inlineSkillForeground, markdownBlockquoteBg, markdownBlockquoteBorder, markdownBodyColor, markdownCodeBg, markdownCodeText, markdownFontSizes, markdownHrColor, markdownInlineCodeText, markdownLinkColor, markdownStrongColor, markdownUserBodyColor, markdownUserCodeBg, markdownUserCodeText, markdownUserFenceBg, markdownUserFenceText, markdownUserInlineCodeText, nativeMarkdownTypography, onLinkPress, renderImage, regularFontFamily, themeMode, userBubbleForegroundMuted, userBubbleSkillForeground, ]); } function renderFeedEntry( info: { item: ThreadFeedEntry; index: number }, props: Pick< ThreadFeedProps, "environmentId" | "skills" | "threadId" | "workspaceRoot" | "onUseArtifactTemplate" > & { readonly copiedRowId: string | null; readonly expandedWorkRows: Record; readonly workRowSizing: ReturnType; readonly workGroupScrollPositions: Map; readonly terminalAssistantMessageIds: ReadonlySet; readonly unsettledTurnId: RunId | null; readonly onCopyWorkRow: (rowId: string, value: string) => void; readonly onToggleWorkGroup: (groupId: string, anchorKey?: string) => void; readonly onToggleWorkRow: (rowId: string, anchorKey?: string) => void; readonly onToggleTurnFold: (runId: RunId) => void; readonly onPressPreview: (source: FilePreviewSource) => void; readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; readonly markdownLinkHandlers: MarkdownLinkHandlers; readonly renderMarkdownImage: MarkdownImageRenderer; readonly renderViewedImage: MarkdownImageRenderer; readonly iconSubtleColor: string | import("react-native").ColorValue; readonly userBubbleColor: string | import("react-native").ColorValue; readonly markdownStyles: MarkdownStyleSets; readonly reviewCommentColors: ReviewCommentColors; readonly reviewCommentBubbleWidth: number; readonly themeAppearance: "light" | "dark"; readonly userBubbleMaxWidth: number; readonly threadTitle: string; }, ) { const entry = info.item; const { markdownStyles, iconSubtleColor, userBubbleColor } = props; if (entry.type === "run-fold") { return ( props.onToggleTurnFold(entry.runId)} hitSlop={4} className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2" style={{ minHeight: Math.max(TURN_FOLD_HEIGHT - 3.5, props.workRowSizing.estimatedRowHeight), }} > {entry.label} ); } if (entry.type === "work-toggle") { return ( props.onToggleWorkGroup(entry.groupId, entry.id)} /> ); } if (entry.type === "activity-group" && isContextCompactionActivityGroup(entry)) { const label = entry.activities[0]!.summary; return ( {label} ); } if (entry.type === "message") { const { message } = entry; const isUser = message.role === "user"; const renderedText = renderAssistantCitationsAsText(message.text); 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.createdBy === "agent" ? ( Sent by another agent ) : null} {message.text.trim().length > 0 ? ( ) : null} {attachments.map((attachment) => { return isImageAttachment(attachment) ? ( ) : isFileAttachment(attachment) ? ( ) : ( ); })} {intentBadge ? ( {intentBadge.label} ) : null} {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 (renderedText.trim().length === 0 && attachments.length === 0) { return null; } const enterAnimated = isFreshTimestamp(message.createdAt); return ( {renderedText.trim().length > 0 ? ( ) : null} {attachments.map((attachment) => { return isImageAttachment(attachment) ? ( ) : isFileAttachment(attachment) ? ( ) : ( ); })} {showAssistantMeta ? ( {message.projectedItem ? ( ) : null} {timestampLabel} ) : null} ); } return ( ); } function UserMessageContent(props: { readonly text: string; readonly markdownStyles: MarkdownStyleSet; readonly reviewCommentColors: ReviewCommentColors; readonly skills?: ReadonlyArray; readonly linkHandlers: MarkdownLinkHandlers; readonly renderImage: MarkdownImageRenderer; }) { 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 { codeSurface, nativeReviewDiffStyle } = useAppearanceCodeSurface(); const { themeAppearance: appearanceScheme, themeId } = useAppearancePreferences(); const appTheme = useUniwindTheme(); 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, themeId, appTheme), [appearanceScheme, appTheme, themeId], ); const nativeRowsJson = useMemo(() => JSON.stringify(compactNativeRows), [compactNativeRows]); const nativeThemeJson = useMemo( () => JSON.stringify(nativeReviewDiffTheme), [nativeReviewDiffTheme], ); const nativeStyleJson = useMemo( () => JSON.stringify(nativeReviewDiffStyle), [nativeReviewDiffStyle], ); const nativeDiffHeight = useMemo( () => Math.min( 360, Math.max( 112, compactNativeRows.length * nativeReviewDiffStyle.rowHeight + nativeReviewDiffStyle.fileHeaderVerticalMargin, ), ), [compactNativeRows.length, nativeReviewDiffStyle], ); 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 ThreadFeedLoadEarlierControl(props: ThreadFeedHistoryControls) { const theme = useUniwindTheme(); const mutedColor = theme["--color-icon-subtle"]; const accentColor = theme["--color-primary"]; if (!props.hasMoreHistory && props.error === null) { return null; } return ( {props.hasMoreHistory ? ( {props.loading ? ( ) : ( )} {props.loading ? "Loading earlier activity…" : "Load earlier activity"} ) : null} {props.error !== null ? ( {props.error} ) : null} ); } function ThreadFeedPlaceholder(props: { readonly bottomInset: number; readonly detail: string; readonly horizontalPadding: number; readonly title: string; readonly topInset: number; }) { return ( {props.title} {props.detail} ); } export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const navigation = useNavigation(); const { themeAppearance } = useAppearancePreferences(); const copyFeedbackTimeoutRef = useRef | null>(null); const disclosureSettleFrameRef = useRef(null); const disclosureSettleSecondFrameRef = useRef(null); const disclosureAnchorKeyRef = useRef(null); const previousLatestTurnRef = useRef(props.latestRun); const userScrollSettleTimerRef = useRef | null>(null); const headerMaterialVisibleRef = useRef(false); const { width: windowWidth, fontScale } = useWindowDimensions(); const { appearance } = useAppearancePreferences(); const workRowSizing = useMemo( () => deriveThreadWorkLogSizing({ baseFontSize: appearance.baseFontSize, fontScale }), [appearance.baseFontSize, fontScale], ); const previousTextSize = useRef(workRowSizing.textSizeKey); useLayoutEffect(() => { if (previousTextSize.current === workRowSizing.textSizeKey) { return; } previousTextSize.current = workRowSizing.textSizeKey; // Text-size changes invalidate the outer list's fixed-height cache too. // This never runs for scrolling, streamed output, or disclosure toggles. props.listRef.current?.clearCaches({ mode: "sizes" }); }, [workRowSizing.textSizeKey, props.listRef]); const [viewportWidth, setViewportWidth] = useState(() => props.layoutVariant === "split" ? 0 : windowWidth, ); const [viewportHeight, setViewportHeight] = useState(0); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); // Live-follow latch (#5566). LegendList's maintainScrollAtEnd alone re-pins // the feed whenever the viewport drifts back inside its geometric threshold, // which yanked users off history they were reading every time a stream chunk // grew a row. Follow breaks when the user scrolls up and away, and re-arms // only when the list actually returns to the end (or on send / thread switch). const [endFollowEnabled, setEndFollowEnabled] = useState(true); const endFollowEnabledRef = useRef(true); // A "user scroll session" spans from drag start through the end of its // momentum; scroll events only break follow inside that session, so MVCP // compensations and programmatic scrolls never strand a follower. const userScrollSessionRef = useRef(false); const setEndFollow = useCallback( (enabled: boolean) => { if (endFollowEnabledRef.current === enabled) { return; } endFollowEnabledRef.current = enabled; setEndFollowEnabled(enabled); props.onEndFollowEnabledChange?.(enabled); }, [props.onEndFollowEnabledChange], ); const transitionEndFollow = useCallback( (event: ThreadFeedLiveFollowEvent) => { setEndFollow(resolveThreadFeedLiveFollow(endFollowEnabledRef.current, event)); }, [setEndFollow], ); 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 [expandedFile, setExpandedFile] = useState(null); const [expandedVideo, setExpandedVideo] = useState(null); useEffect(() => { setExpandedVideo(null); setExpandedFile(null); }, [props.environmentId, props.threadId, props.contentPresentation.kind]); const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({ viewportWidth, maxContentWidth: props.contentMaxWidth ?? null, minimumPadding: horizontalPadding, }); const contentWidth = Math.max(0, viewportWidth - contentHorizontalPadding * 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 + IOS_NAV_BAR_HEIGHT; const bottomContentInset = props.contentBottomInset ?? 18; const usesNativeAutomaticInsets = props.usesAutomaticContentInsets === true && Platform.OS === "ios"; const initialContentInset = deriveThreadFeedInitialContentInset({ platform: Platform.OS, usesNativeAutomaticInsets, bottomContentInset, }); // With automatic insets the header inset lives in UIKit's adjustedContentInset, // which LegendList's JS anchoring math cannot see — it measures the anchored // end space from the scroll view's frame top. Fold the header height back into // the anchor offset or a just-sent message anchors underneath the header and // the oversized end space keeps maintainScrollAtEnd snapping away from earlier // messages. Read the context directly (useHeaderHeight throws outside a // header-providing screen) and fall back to the standard iOS bar height. const navigationHeaderHeight = useContext(HeaderHeightContext); const anchorTopInset = usesNativeAutomaticInsets ? navigationHeaderHeight || insets.top + IOS_NAV_BAR_HEIGHT : topContentInset; const theme = useUniwindTheme(); const iconSubtleColor = theme["--color-icon-subtle"]; const userBubbleColor = theme["--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(); if (isPdfFile({ name: relativePath })) { setExpandedFile( (current) => current ?? { kind: "pdf", name: relativePath.split("/").at(-1), environmentId: props.environmentId, resource: { _tag: "workspace-file", threadId: props.threadId, path: relativePath, }, }, ); return; } navigation.navigate("ThreadFile", { environmentId: String(props.environmentId), threadId: String(props.threadId), path: fileRoutePathSegments(relativePath), ...(presentation.line ? { line: String(presentation.line) } : {}), }); return; } } const media = resolveMarkdownMediaPreview(href, { environmentId: props.environmentId, threadId: props.threadId, workspaceRoot: props.workspaceRoot, }); if (media) { void Haptics.selectionAsync(); if (media.kind === "video") { setExpandedVideo((current) => current ?? media.source); } else { setExpandedFile((current) => current ?? media.source); } return; } // A host file outside the workspace, such as a report an agent wrote to // a temp directory, opens read-only in the file screen. if (presentation.kind === "file" && isAbsolutePath(presentation.path)) { void Haptics.selectionAsync(); if (isPdfFile({ name: presentation.path })) { setExpandedFile( (current) => current ?? { kind: "pdf", name: basename(presentation.path), environmentId: props.environmentId, resource: { _tag: "media-file", threadId: props.threadId, path: presentation.path, }, }, ); return; } navigation.navigate("ThreadFile", { environmentId: String(props.environmentId), threadId: String(props.threadId), path: fileRoutePathSegments(presentation.path), ...(presentation.line ? { line: String(presentation.line) } : {}), }); return; } if (presentation.kind !== "file" && presentation.href) { if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) { setExpandedFile( (current) => current ?? { kind: "pdf", uri: presentation.href!, name: "Document.pdf" }, ); return; } void tryOpenExternalUrl(presentation.href, "markdown-link"); } }, [props.environmentId, props.threadId, props.workspaceRoot, navigation], ); const markdownLinkHandlers = useMemo( () => ({ onLinkPress: onMarkdownLinkPress, fileContextMenu: (href) => { const target = resolveFileChipTarget(href, props.workspaceRoot); return target ? fileChipMenu(target) : undefined; }, onFileContextMenuAction: (href, actionId) => { const target = resolveFileChipTarget(href, props.workspaceRoot); if (!target) return; switch (actionId as FileChipAction) { case "copy-full-path": if (target.fullPath) copyTextWithHaptic(target.fullPath); return; case "copy-relative-path": if (target.relativePath) copyTextWithHaptic(target.relativePath); return; case "open-file": onMarkdownLinkPress(href); return; } }, }), [onMarkdownLinkPress, props.workspaceRoot], ); const renderMarkdownImage = useCallback( (image) => { const media = resolveMarkdownMediaPreview(image.href, { environmentId: props.environmentId, threadId: props.threadId, workspaceRoot: props.workspaceRoot, imageEmbed: true, }); if (media?.kind === "video") { return ( ); } const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null); if (imageSource._tag === "Direct") { return ( setExpandedFile((current) => current ?? source)} /> ); } if (imageSource._tag === "Blocked") { return ; } return ( setExpandedFile((current) => current ?? source)} /> ); }, [props.environmentId, props.threadId, props.workspaceRoot], ); const renderViewedImage = useCallback( (image) => { const viewedImage = resolveViewedImageAsset(image.href, { threadId: props.threadId, workspaceRoot: props.workspaceRoot, }); const media = viewedImage ? resolveMarkdownMediaPreview(image.href, { environmentId: props.environmentId, threadId: props.threadId, workspaceRoot: props.workspaceRoot, imageEmbed: true, }) : null; const actionsSource = media?.source.actionsSource; return viewedImage ? ( setExpandedFile((current) => current ?? source)} /> ) : null; }, [props.environmentId, props.threadId, props.workspaceRoot], ); const markdownStyles = useMarkdownStyles(onMarkdownLinkPress, renderMarkdownImage); 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, workRowSizing, iconSubtleColor, markdownStyles, reviewCommentColors, themeAppearance, userBubbleColor, viewportWidth, }), [ copiedRowId, expandedWorkGroups, expandedWorkRows, workRowSizing, iconSubtleColor, markdownStyles, reviewCommentColors, themeAppearance, userBubbleColor, viewportWidth, ], ); const reportHeaderMaterialVisibility = useCallback( (visible: boolean) => { if (headerMaterialVisibleRef.current === visible) { return; } headerMaterialVisibleRef.current = visible; props.onHeaderMaterialVisibilityChange?.(visible); }, [props.onHeaderMaterialVisibilityChange], ); const handleScroll = useCallback( (event: NativeSyntheticEvent) => { // anchorTopInset, not topContentInset: under automatic insets the list // rests at contentOffset.y = -headerHeight (the inset lives only in // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the // header height back or the material toggles a full header too late. reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6); // LegendList recomputes its inset-aware end distance before invoking // this handler, so getState() is current. Only the actual end re-arms // follow: its broader maintain-scroll threshold is large enough for a // streaming chunk to pull a user back before their upward drag escapes. // A live user-scroll session still wins even if the first scroll event // remains inside LegendList's at-end tolerance. const listState = props.listRef.current?.getState(); if (listState) { transitionEndFollow({ type: "scroll", isAtEnd: listState.isAtEnd, userScrollSessionActive: userScrollSessionRef.current, }); } }, [reportHeaderMaterialVisibility, anchorTopInset, props.listRef, transitionEndFollow], ); const clearUserScrollSettle = useCallback(() => { if (userScrollSettleTimerRef.current !== null) { clearTimeout(userScrollSettleTimerRef.current); userScrollSettleTimerRef.current = null; } }, []); const handleScrollBeginDrag = useCallback(() => { clearUserScrollSettle(); userScrollSessionRef.current = true; // Pause before the first scroll event. Otherwise a stream update can run // maintainScrollAtEnd between touch-down and the drag leaving its threshold. transitionEndFollow({ type: "user-scroll-begin" }); }, [clearUserScrollSettle, transitionEndFollow]); const finishUserScroll = useCallback( (releaseIsAtEnd?: boolean) => { clearUserScrollSettle(); const userScrollSessionActive = userScrollSessionRef.current; userScrollSessionRef.current = false; transitionEndFollow({ type: "user-scroll-end", // With no momentum, preserve the finger-release position. Streaming // growth during the native momentum-detection window must not turn a // release at the live edge into an opt-out from follow. isAtEnd: releaseIsAtEnd ?? props.listRef.current?.getState().isAtEnd ?? false, userScrollSessionActive, }); }, [clearUserScrollSettle, props.listRef, transitionEndFollow], ); // Finger-lift velocity is not a reliable momentum signal: a gentle fling // can report zero and still decelerate. Give native momentum a short window // to announce itself; if it does, onMomentumScrollBegin cancels this fallback // and the session survives until the settled momentum-end position. This // mirrors the native-event handoff used by the home thread list's scroll gate. const handleScrollEndDrag = useCallback(() => { clearUserScrollSettle(); const releaseIsAtEnd = props.listRef.current?.getState().isAtEnd ?? false; userScrollSettleTimerRef.current = setTimeout(() => finishUserScroll(releaseIsAtEnd), 160); }, [clearUserScrollSettle, finishUserScroll, props.listRef]); const handleMomentumScrollBegin = useCallback(() => { if (userScrollSessionRef.current) { clearUserScrollSettle(); } }, [clearUserScrollSettle]); const handleMomentumScrollEnd = useCallback(() => { finishUserScroll(); }, [finishUserScroll]); useEffect(() => clearUserScrollSettle, [clearUserScrollSettle]); // Thread identity is env-scoped: two environments can hold the same // ThreadId, and keying resets (or the list mount) on the bare id would // carry stale scroll/follow state across an environment switch. const feedThreadKey = scopedThreadKey(props.environmentId, props.threadId); // Virtualized groups can unmount without losing the reader's place. This cache // belongs to this thread view only and never causes per-scroll React updates. const workGroupScrollPositions = useMemo( () => new Map(), [feedThreadKey], ); // A thread switch opens pinned to the end; a send explicitly returns to the // live edge (ThreadDetailScreen scrolls the new message into place). Both // re-arm follow regardless of where the user had scrolled before. useEffect(() => { clearUserScrollSettle(); userScrollSessionRef.current = false; transitionEndFollow({ type: "reset" }); }, [clearUserScrollSettle, feedThreadKey, transitionEndFollow]); useEffect(() => { if (props.submittedMessageId !== null) { clearUserScrollSettle(); userScrollSessionRef.current = false; transitionEndFollow({ type: "reset" }); } }, [clearUserScrollSettle, props.submittedMessageId, transitionEndFollow]); const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { const nextWidth = Math.round(event.nativeEvent.layout.width); const nextHeight = Math.round(event.nativeEvent.layout.height); setViewportWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current)); setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current)); }, []); useEffect(() => { reportHeaderMaterialVisibility(false); }, [feedThreadKey, reportHeaderMaterialVisibility]); const presentedFeed = useMemo( () => deriveThreadFeedPresentation( props.feed, props.latestRun, expandedTurnIds, new Set( Object.entries(expandedWorkGroups) .filter(([, expanded]) => expanded) .map(([groupId]) => groupId), ), props.activeWorkStartedAt, ), [expandedTurnIds, expandedWorkGroups, props.activeWorkStartedAt, props.feed, props.latestRun], ); // The empty↔filled key below remounts the list, which resets its imperative // content-inset override — and useKeyboardChatComposerInset (mounted above // the remount boundary) deduplicates by height, so it never re-reports the // composer inset to the fresh instance. Without this, the remounted list's // initial scroll-to-end computes with a zero end inset and rests one // composer-height short of the end. Layout effect: it must land before the // list's first positioning tick or the one-shot initial scroll misses it. const listMountKey = `${feedThreadKey}:${props.feed.length === 0 ? "empty" : "filled"}`; useLayoutEffect(() => { const bottom = props.contentInsetEndAdjustment.value; if (bottom > 0) { props.listRef.current?.reportContentInset({ bottom }); } }, [listMountKey, props.contentInsetEndAdjustment, props.listRef]); const anchoredEndSpace = useMemo( () => resolveChatListAnchoredEndSpace( presentedFeed, props.anchorMessageId, (entry) => (entry.type === "message" ? entry.id : null), { anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET }, ), [presentedFeed, props.anchorMessageId, anchorTopInset], ); const terminalAssistantMessageIds = useMemo(() => { const terminalIdsByTurn = new Map(); for (const entry of props.feed) { if (entry.type === "message" && entry.message.role === "assistant" && entry.message.runId) { terminalIdsByTurn.set(entry.message.runId, entry.message.id); } } return new Set(terminalIdsByTurn.values()); }, [props.feed]); const unsettledTurnId = threadFeedRunIsUnsettled(props.latestRun) ? props.latestRun.runId : null; useEffect(() => { const previous = previousLatestTurnRef.current; previousLatestTurnRef.current = props.latestRun; if (!props.latestRun || !previous) { return; } if (props.latestRun.runId === previous.runId) { if (previous.status === "running" && props.latestRun.status === "interrupted") { const interruptedTurnId = props.latestRun.runId; setInteractionState((current) => ({ ...current, expandedTurnIds: new Set(current.expandedTurnIds).add(interruptedTurnId), })); } return; } setInteractionState((current) => { if (!current.expandedTurnIds.has(previous.runId)) { return current; } const next = new Set(current.expandedTurnIds); next.delete(previous.runId); return { ...current, expandedTurnIds: next }; }); }, [props.latestRun]); useEffect(() => { return () => { if (copyFeedbackTimeoutRef.current) { clearTimeout(copyFeedbackTimeoutRef.current); } if (disclosureSettleFrameRef.current !== null) { cancelAnimationFrame(disclosureSettleFrameRef.current); } if (disclosureSettleSecondFrameRef.current !== null) { cancelAnimationFrame(disclosureSettleSecondFrameRef.current); } }; }, []); const settleDisclosureAfterLayout = useCallback(() => { if (disclosureSettleFrameRef.current !== null) { cancelAnimationFrame(disclosureSettleFrameRef.current); } if (disclosureSettleSecondFrameRef.current !== null) { cancelAnimationFrame(disclosureSettleSecondFrameRef.current); } disclosureSettleFrameRef.current = requestAnimationFrame(() => { disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => { // A disclosure can leave the reader above the end without a drag. // Reconcile follow before a later layout or resume can re-pin it. const listState = props.listRef.current?.getState(); if (listState) { transitionEndFollow({ type: "disclosure-settled", isAtEnd: listState.isAtEnd, userScrollSessionActive: userScrollSessionRef.current, }); } disclosureAnchorKeyRef.current = null; setDisclosureToggleSettling(false); disclosureSettleFrameRef.current = null; disclosureSettleSecondFrameRef.current = null; }); }); }, [props.listRef, transitionEndFollow]); const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => { disclosureAnchorKeyRef.current = anchorKey; setDisclosureToggleSettling(true); }, []); // Start the quiet-frame countdown after React has committed the disclosure. // Every measured item-size change restarts it, so end maintenance cannot // wake between the data mutation and LegendList's final layout correction. useLayoutEffect(() => { if (disclosureAnchorKeyRef.current !== null) { settleDisclosureAfterLayout(); } }, [expandedTurnIds, expandedWorkGroups, expandedWorkRows, settleDisclosureAfterLayout]); const handleItemSizeChanged = useCallback(() => { if (disclosureAnchorKeyRef.current !== null) { settleDisclosureAfterLayout(); } }, [settleDisclosureAfterLayout]); const shouldRestoreVisibleContentPosition = useCallback((entry: ThreadFeedEntry) => { const disclosureAnchorKey = disclosureAnchorKeyRef.current; return disclosureAnchorKey === null || entry.id === disclosureAnchorKey; }, []); const maintainVisibleContentPosition = useMemo( () => ({ data: true, size: true, shouldRestorePosition: shouldRestoreVisibleContentPosition, }), [shouldRestoreVisibleContentPosition], ); const onCopyWorkRow = useCallback((rowId: string, value: string) => { copyTextWithHaptic(value, { target: "thread-work-row", feedback: "selection", }); 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, anchorKey?: string) => { suspendEndScrollMaintenanceForDisclosure(anchorKey ?? `work-toggle:${groupId}`); setInteractionState((current) => ({ ...current, expandedWorkGroups: { ...current.expandedWorkGroups, [groupId]: !(current.expandedWorkGroups[groupId] ?? false), }, })); }, [suspendEndScrollMaintenanceForDisclosure], ); const onToggleWorkRow = useCallback( (rowId: string, anchorKey?: string) => { suspendEndScrollMaintenanceForDisclosure(anchorKey ?? null); setInteractionState((current) => ({ ...current, expandedWorkRows: { ...current.expandedWorkRows, [rowId]: !(current.expandedWorkRows[rowId] ?? false), }, })); }, [suspendEndScrollMaintenanceForDisclosure], ); const onToggleTurnFold = useCallback( (runId: RunId) => { suspendEndScrollMaintenanceForDisclosure(`run-fold:${runId}`); setInteractionState((current) => { const next = new Set(current.expandedTurnIds); if (next.has(runId)) { next.delete(runId); } else { next.add(runId); } return { ...current, expandedTurnIds: next }; }); }, [suspendEndScrollMaintenanceForDisclosure], ); const onPressPreview = useCallback((source: FilePreviewSource) => { setExpandedFile((current) => current ?? source); }, []); const onPressVideo = useCallback( (attachment: ChatFileAttachment, sourceIdentifier: string) => { setExpandedVideo( (current) => current ?? attachmentVideoPreviewSource(props.environmentId, attachment, sourceIdentifier), ); }, [props.environmentId], ); // Rows whose height is known before they ever render. Without this, every // row above the viewport is assumed to be estimatedItemSize tall, and // scrolling up through unmeasured content corrects each row's height as it // mounts — the feed visibly jumps. Fixed sizes make the small chrome rows // exact; message rows stay undefined and use LegendList's per-type running // average once one of their type has been measured. Prominent v2 items, // expanded details, and compaction rows retain native measurement; their // cards and related-thread links can exceed the compact row height. const getFixedItemSize = useCallback( (entry: ThreadFeedEntry) => { if (workRowSizing.fixedRowHeight === undefined) { return undefined; } switch (entry.type) { case "run-fold": return resolveThreadFeedFixedItemSize(entry.type); case "work-toggle": return resolveThreadFeedFixedItemSize(entry.type); case "activity-group": if (isContextCompactionActivityGroup(entry)) { return undefined; } // Expanded rows append a variable detail block — fall back to // measurement for those groups. return entry.activities.some( (activity) => activity.prominent || expandedWorkRows[activity.id], ) ? undefined : collapsedWorkLogHeight(entry.activities); default: return undefined; } }, [expandedWorkRows, workRowSizing.fixedRowHeight], ); // Disclosures can mount existing offscreen rows as well as new work rows. // Fade those in after movement; never retain removed rows over replacements. const renderItem = useCallback( (info: { item: ThreadFeedEntry; index: number }) => ( {renderFeedEntry(info, { environmentId: props.environmentId, onUseArtifactTemplate: props.onUseArtifactTemplate, threadId: props.threadId, copiedRowId, expandedWorkRows, workRowSizing, workGroupScrollPositions, terminalAssistantMessageIds, unsettledTurnId, onCopyWorkRow, onToggleWorkGroup, onToggleWorkRow, onToggleTurnFold, onPressPreview, onPressVideo, markdownLinkHandlers, renderMarkdownImage, renderViewedImage, iconSubtleColor, userBubbleColor, markdownStyles, reviewCommentColors, reviewCommentBubbleWidth, themeAppearance, userBubbleMaxWidth, threadTitle: props.threadTitle, skills: props.skills, workspaceRoot: props.workspaceRoot, })} ), [ copiedRowId, disclosureToggleSettling, expandedWorkRows, workRowSizing, workGroupScrollPositions, terminalAssistantMessageIds, unsettledTurnId, iconSubtleColor, userBubbleColor, markdownStyles, reviewCommentColors, reviewCommentBubbleWidth, themeAppearance, userBubbleMaxWidth, onCopyWorkRow, markdownLinkHandlers, onPressPreview, onPressVideo, onToggleTurnFold, onToggleWorkGroup, onToggleWorkRow, props.environmentId, props.onUseArtifactTemplate, props.threadId, props.threadTitle, props.skills, props.workspaceRoot, renderMarkdownImage, renderViewedImage, ], ); if (props.contentPresentation.kind === "unavailable") { return ( ); } return ( <> entry.id} getItemType={(entry) => entry.type === "message" ? `message:${entry.message.role}` : entry.type } getFixedItemSize={getFixedItemSize} itemLayoutAnimation={THREAD_FEED_LAYOUT_TRANSITION} onItemSizeChanged={handleItemSizeChanged} // Measure rows well before they scroll into view so estimate→actual // corrections land offscreen instead of under the user's finger. drawDistance={500} keyboardShouldPersistTaps="always" keyboardDismissMode="none" keyboardLiftBehavior="whenAtEnd" // Seed the list's scroll math with the real viewport before its own // onLayout: the empty→filled remount can then tell at mount that // short content underflows the viewport and skip programmatic // positioning entirely (any offset write during screen attach races // UIKit's adjustedContentInset application and lands high or low). {...(viewportHeight > 0 && viewportWidth > 0 ? { estimatedListSize: { height: viewportHeight, width: viewportWidth } } : {})} // RN's native scrollTo command clamps targets to a floor of // -contentInset.top using the RAW inset — under automatic insets the // header inset only exists in adjustedContentInset, so scrolls to // negative offsets (content top below the transparent header) get // clamped to 0. This prop disables that clamp; UIKit still bounces // user overscroll back to the adjusted rest position. scrollToOverflowEnabled estimatedItemSize={180} // Chat-style bottom alignment: when a thread is shorter than the // viewport, pad above the content so messages rest just above the // composer instead of under the header. No effect on threads that // overflow the viewport (the padding clamps to zero). alignItemsAtEnd initialScrollAtEnd onScroll={handleScroll} onScrollBeginDrag={handleScrollBeginDrag} onScrollEndDrag={handleScrollEndDrag} onMomentumScrollBegin={handleMomentumScrollBegin} onMomentumScrollEnd={handleMomentumScrollEnd} scrollEventThrottle={16} ListHeaderComponent={ <> {usesNativeAutomaticInsets ? null : } {props.historyControls ? ( ) : null} {props.topAccessory} } contentContainerStyle={{ paddingTop: 12, paddingHorizontal: contentHorizontalPadding, }} /> {props.feed.length === 0 && props.activeWorkStartedAt === null && props.contentPresentation.kind === "ready" ? ( ) : null} setExpandedVideo(null)} /> setExpandedFile(null)} /> ); });