1import { useOpenPullRequest } from "../../native/useOpenPullRequest";
2import { ThreadMarkdownVideo, ThreadMediaVisibleContext } from "./ThreadMarkdownVideo";
3import { useMarkdownImageSource } from "../../native/useMarkdownImageSource";
4import { enrichedContextLinkPresentation } from "../../lib/enrichedLinkPresentation";
5import { ThreadContextDivider } from "./thread-context-divider";
6import { ThreadHandoffDivider, ThreadHandoffRow } from "./thread-handoff-row";
7import { SecretRequestCard } from "./SecretRequestCard";
8import { AppActivityIndicator } from "../../components/AppActivityIndicator";
9import {
10 WorktreeWorkingHeader,
11 WorktreeSetupCard,
12 type WorktreeSetupCardProps,
13} from "./worktree-setup-card";
14import * as Haptics from "expo-haptics";
15import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
16import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native";
17import { scopeThreadRef } from "@t3tools/client-runtime/environment";
18import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff";
19import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
20import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
21import {
22 type OrchestrationMessageContext,
23 ThreadId,
24 type ChatAttachment,
25 type ChatFileAttachment,
26 type ChatImageAttachment,
27 type EnvironmentId,
28 type MessageId,
29 type OrchestrationV2ProjectedTurnItem,
30 type RunId,
31} from "@t3tools/contracts";
32import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations";
33import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
34import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";
35import { PROMPT_LINK_MAX_PROMPT_LENGTH } from "@t3tools/shared/promptLinks";
36import {
37 parseComposerContextHref,
38 collectComposerContextReferences,
39 replaceComposerContextReferences,
40 formatComposerContextReference,
41} from "@t3tools/shared/composerContextReferences";
42import { ComposerContextSheet } from "../../components/ComposerContextSheet";
43import { writeComposerContextClipboard } from "../../lib/composerContextClipboard";
44import {
45 codexArtifactTemplatePresentationLabel,
46 type CodexArtifactTemplate,
47} from "@t3tools/shared/codexArtifactTemplates";
48import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
49import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks";
50import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
51import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
52import {
53 classifyMarkdownImageSource,
54 markdownImageSourceFragment,
55} from "@t3tools/client-runtime/markdown-images";
56import { resolveViewedImageAsset } from "@t3tools/client-runtime/work-log/presentation";
57import {
58 renderCodexInlineDirectivesAsMarkdown,
59 splitCodexArtifactTemplateMarkdown,
60} from "@t3tools/shared/codexMarkdownDirectives";
61import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
62import { imageMimeType } from "@t3tools/shared/image";
63import { videoMimeType } from "@t3tools/shared/video";
64import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
65import { HeaderHeightContext } from "@react-navigation/elements";
66import { useFocusEffect, useNavigation } from "@react-navigation/native";
67import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns";
68import {
69 createContext,
70 memo,
71 useCallback,
72 useContext,
73 useEffect,
74 useLayoutEffect,
75 useMemo,
76 useRef,
77 useState,
78 useId,
79 type ReactNode,
80 type RefObject,
81} from "react";
82import {
83 ActivityIndicator,
84 Alert,
85 Image,
86 Platform,
87 type LayoutChangeEvent,
88 type NativeScrollEvent,
89 type NativeSyntheticEvent,
90 Pressable,
91 ScrollView,
92 StyleSheet,
93 Text as NativeText,
94 type ColorValue,
95 useWindowDimensions,
96 View,
97} from "react-native";
98import { Image as ExpoImage } from "expo-image";
99import { useResolveClassNames } from "uniwind";
100import { useFilePreviewGallery, FilePreviewGallery } from "../../components/FilePreviewGallery";
101import { assetImageSource } from "../../lib/assetImageSource";
102import { composerImageGallery } from "../../lib/composerImageGallery";
103import { messageImageGallery } from "./messageImageGallery";
104import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
105import { isPdfFile } from "../../lib/filePreview";
106import { flattenThemeColor } from "../../lib/mobileTheme";
107import { PresentationSource } from "../../components/NativePresentation";
108import { useSafeAreaInsets } from "react-native-safe-area-context";
109import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated";
110import { useUniwindTheme } from "../../lib/useUniwindTheme";
111import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
112import { useFontFamily } from "../../lib/useFontFamily";
113import { scopedThreadKey } from "../../lib/scopedEntities";
114import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
115import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
116import { downloadAndShareAttachment } from "../../lib/attachmentDownload";
117import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";
118import { faviconUrlForOrigin } from "@t3tools/shared/favicon";
119import {
120 SelectableMarkdownText,
121 type MarkdownFileContextMenu,
122 type MarkdownImageRenderer,
123 type MarkdownImageSourceResolver,
124 type MarkdownLinkCustomization,
125 type NativeMarkdownTextStyle,
126 type SelectableMarkdownSkill,
127} from "../../native/SelectableMarkdownText";
128
129import { AppText as Text } from "../../components/AppText";
130import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
131import { VideoAttachmentTile } from "../../components/VideoAttachmentTile";
132import { MediaVideoPlayer } from "../../components/MediaVideoPlayer";
133import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia";
134import {
135 attachmentVideoPreviewSource,
136 mediaVideoPreviewUri,
137 mediaVideoThumbnailKey,
138 type MediaVideoPreviewSource,
139} from "../../lib/videoPreviewSource";
140import { CopyTextButton } from "../../components/CopyTextButton";
141import { ControlPillMenu } from "../../components/ControlPill";
142import { copyPromptLink } from "../prompt-links/prompt-link-actions";
143import { parseReviewCommentMessageSegments } from "../review/reviewCommentSelection";
144import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter";
145import {
146 ReviewCommentCard,
147 useReviewCommentColors,
148 type ReviewCommentColors,
149} from "../review/ReviewCommentCard";
150import { cn } from "../../lib/cn";
151import {
152 deriveCenteredContentHorizontalPadding,
153 deriveThreadFeedInitialContentInset,
154 deriveThreadWorkLogSizing,
155 type LayoutVariant,
156} from "../../lib/layout";
157import { uuidv4 } from "../../lib/uuid";
158import {
159 resolveMarkdownFontSizes,
160 resolveNativeMarkdownTypography,
161} from "../../lib/appearancePreferences";
162import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
163import { markdownFileIconSource } from "../../lib/markdownFileIcons";
164import { PierreEntryIcon } from "../../components/PierreEntryIcon";
165import { markdownLinkIconSource } from "../../lib/markdownLinkIcons";
166import {
167 normalizeNativeMarkdownUrl,
168 resolveMarkdownInlineCodePresentation,
169 resolveMarkdownLinkIcon,
170 resolveMarkdownLinkPresentation,
171} from "../../lib/markdownLinks";
172import {
173 failedFeedRunIds,
174 deriveThreadFeedPresentation,
175 threadFeedRunIsUnsettled,
176 isContextCompactionActivityGroup,
177 isContextHandoffActivityGroup,
178 isSecretRequestActivityGroup,
179 type ThreadFeedEntry,
180 type ThreadFeedLatestRun,
181} from "../../lib/threadActivity";
182import type { ThreadContentPresentation } from "./threadContentPresentation";
183import {
184 resolveThreadFeedLiveFollow,
185 type ThreadFeedLiveFollowEvent,
186 type ThreadWorkGroupScrollPosition,
187} from "./thread-feed-live-follow";
188import {
189 collapsedWorkLogHeight,
190 ThreadDisclosureChevron,
191 THREAD_DISCLOSURE_TRANSITION_MS,
192 ThreadWorkGroupToggle,
193 ThreadThinkingRow,
194 ThreadWorkLog,
195 WORK_GROUP_TOGGLE_HEIGHT,
196} from "./thread-work-log";
197import { appendPendingThreadMessages, type PendingThreadFeedEntry } from "./pending-thread-feed";
198import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
199import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size";
200import { htmlRenderFrameHeight } from "@t3tools/shared/htmlRender";
201import { htmlRenderRowHeight, ThreadHtmlRender } from "./HtmlRenderWebView";
202import { ThreadMcpApp } from "./McpAppWebView";
203import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
204import {
205 assetEnvironment,
206 useAssetUrl,
207 useAssetUrlState,
208 useRefreshAssetUrl,
209} from "../../state/assets";
210import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
211import { usePreparedConnection } from "../../state/session";
212import { useLiveThreadLinkLabels } from "../../state/entities";
213import { useThreadSelection } from "../../state/use-thread-selection";
214import { composerDocumentAttachmentRecord } from "../../lib/composerContext";
215import * as Option from "effect/Option";
216import { appAtomRegistry } from "../../state/atom-registry";
217import { environmentThreadShells, threadEnvironment } from "../../state/threads";
218import { useAtomCommand } from "../../state/use-atom-command";
219import { useV2ItemSupport } from "../../state/v2-item-support";
220import {
221 fileRoutePathSegments,
222 isAbsolutePath,
223 resolveWorkspaceRelativeFilePath,
224} from "../files/filePath";
225import { waitForThreadShellReady } from "./threadForkNavigation";
226import { resolveUserMessageIntentBadge } from "./userMessageIntentBadge";
227import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu";
228import { useFileChipShare } from "./useFileChipShare";
229import {
230 MarkdownImageAvailableWidthContext,
231 ThreadMarkdownImage,
232 ThreadMarkdownImageUnavailable,
233 ThreadMarkdownImageView,
234} from "./ThreadMarkdownImage";
235
236const WIDE_MARKDOWN_BLOCK_OPTIONS = {
237 // Native iOS blockquotes and adjacent selectable text are separate layout
238 // chunks. Giving their shrink-to-fit bubble a definite width keeps both
239 // chunks measured against the width at which UIKit draws them.
240 includeBlockquotes: Platform.OS === "ios",
241 includeOrderedLists: Platform.OS === "android",
242} as const;
243
244const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {
245 hour: "numeric",
246 minute: "2-digit",
247});
248function formatMessageTime(input: string): string {
249 const timestamp = Date.parse(input);
250 if (Number.isNaN(timestamp)) {
251 return "";
252 }
253 return MESSAGE_TIME_FORMATTER.format(timestamp);
254}
255
256// Fixed heights mirror renderFeedEntry's classNames and are only used while
257// text fits at the current font settings. Larger accessibility text is measured.
258const TURN_FOLD_HEIGHT = 42; // min-h-11 (38.5) + mb-1 (3.5), with the mobile 14px rem
259// Tailwind spacing on the mobile 14px rem: px-3.5 on the user bubble, px-1 on
260// assistant rows. Images size their frame from these before their own layout.
261const USER_BUBBLE_HORIZONTAL_PADDING = 3.5 * 3.5;
262const ASSISTANT_ROW_HORIZONTAL_PADDING = 3.5;
263// Let neighboring rows move out of the new rows' space before showing their text.
264const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay(
265 THREAD_DISCLOSURE_TRANSITION_MS,
266).duration(140);
267
268// Entering animations must only play for rows born just now — LegendList
269// remounts rows when they scroll back into view, and replaying an entrance for
270// old content would be its own kind of jank.
271const FRESH_ENTRY_WINDOW_MS = 3_000;
272function isFreshTimestamp(input: string): boolean {
273 const timestamp = Date.parse(input);
274 return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ENTRY_WINDOW_MS;
275}
276
277export interface ThreadFeedHistoryControls {
278 readonly hasMoreHistory: boolean;
279 readonly loading: boolean;
280 readonly error: string | null;
281 readonly onLoadEarlier: () => void;
282}
283
284export interface ThreadFeedProps {
285 readonly worktreeSetup?: WorktreeSetupCardProps | null;
286 readonly setupWorkingStartedAt?: string | null;
287 readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>;
288 readonly dispatchingMessageId: MessageId | null;
289 /** Null where a pending message cannot be edited (no composer to edit it in). */
290 readonly onEditPendingMessage: ((message: QueuedThreadMessage) => void) | null;
291 readonly environmentId: EnvironmentId;
292 readonly threadId: ThreadId;
293 readonly threadTitle: string;
294 readonly workspaceRoot?: string | null;
295 readonly feed: ReadonlyArray<ThreadFeedEntry>;
296 readonly pendingHandoff?: HandoffEndpoints | null;
297 readonly contentPresentation: ThreadContentPresentation;
298 readonly agentLabel: string;
299 readonly latestRun: ThreadFeedLatestRun | null;
300 readonly activeWorkStartedAt: string | null;
301 readonly runlessWorkActive?: boolean;
302 readonly listRef: RefObject<LegendListRef | null>;
303 readonly freeze: SharedValue<boolean>;
304 readonly anchorMessageId: MessageId | null;
305 readonly submittedMessageId: MessageId | null;
306 readonly contentInsetEndAdjustment: SharedValue<number>;
307 readonly contentTopInset?: number;
308 readonly contentBottomInset?: number;
309 readonly historyControls?: ThreadFeedHistoryControls;
310 readonly contentMaxWidth?: number;
311 readonly contentSideInsets?: { readonly left: number; readonly right: number };
312 readonly layoutVariant?: LayoutVariant;
313 readonly usesAutomaticContentInsets?: boolean;
314 readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
315 readonly onEndFollowEnabledChange?: (enabled: boolean) => void;
316 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
317 readonly onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;
318}
319
320async function waitForThreadShell(
321 environmentId: EnvironmentId,
322 threadId: ThreadId,
323): Promise<boolean> {
324 const atom = environmentThreadShells.threadShellAtom(scopeThreadRef(environmentId, threadId));
325 return waitForThreadShellReady({
326 read: () => appAtomRegistry.get(atom) !== null,
327 });
328}
329
330const USER_MESSAGE_COPY_MENU_ACTIONS = [
331 { id: "copy-prompt-link", title: "Copy prompt link", image: "link" },
332];
333
334/**
335 * The copy button under a sent user message. Long-press copies a link that opens a new task
336 * with this prompt, unless the prompt is longer than a link can carry.
337 */
338function UserMessageCopyButton(props: {
339 readonly text: string;
340 readonly onCopy: (() => Promise<void>) | undefined;
341 readonly tintColor: ColorValue;
342}) {
343 const button = (
344 <CopyTextButton
345 accessibilityLabel="Copy message"
346 text={props.text}
347 onCopy={props.onCopy}
348 tintColor={props.tintColor}
349 buttonSize={28}
350 iconSize={13}
351 />
352 );
353 // A link carries plain text, so context chips become their labels.
354 const linkText = replaceComposerContextReferences(props.text, (reference) => reference.label);
355 if (linkText.length > PROMPT_LINK_MAX_PROMPT_LENGTH) return button;
356 return (
357 <ControlPillMenu
358 actions={USER_MESSAGE_COPY_MENU_ACTIONS}
359 onPressAction={({ nativeEvent }) => {
360 if (nativeEvent.event === "copy-prompt-link") void copyPromptLink(linkText);
361 }}
362 shouldOpenOnLongPress
363 >
364 {button}
365 </ControlPillMenu>
366 );
367}
368
369function AssistantForkButton(props: {
370 readonly environmentId: EnvironmentId;
371 readonly iconColor: ColorValue;
372 readonly projectedItem: OrchestrationV2ProjectedTurnItem;
373 readonly sourceTitle: string;
374}) {
375 const support = useV2ItemSupport({
376 environmentId: props.environmentId,
377 sourceThreadId: props.projectedItem.sourceThreadId,
378 sourceItemId: props.projectedItem.sourceItemId,
379 });
380 const forkFromRun = useAtomCommand(threadEnvironment.forkFromRun, "fork from response");
381 const navigation = useNavigation();
382 const [busy, setBusy] = useState(false);
383 const canFork = canForkProjectedAssistantItem({
384 projectedItem: props.projectedItem,
385 capabilities: support.providerSession?.capabilities,
386 });
387 const runId = props.projectedItem.item.runId;
388
389 if (!canFork || runId === null) return null;
390
391 return (
392 <Pressable
393 accessibilityRole="button"
394 accessibilityLabel="Fork from this response"
395 disabled={busy}
396 onPress={() => {
397 const targetThreadId = ThreadId.make(uuidv4());
398 setBusy(true);
399 void Haptics.selectionAsync();
400 void forkFromRun({
401 environmentId: props.environmentId,
402 input: {
403 sourceThreadId: props.projectedItem.sourceThreadId,
404 targetThreadId,
405 runId,
406 title: `${props.sourceTitle} fork`,
407 creationSource: "mobile",
408 },
409 })
410 .then(async (result) => {
411 if (result._tag !== "Success") return;
412 const targetThreadReady = await waitForThreadShell(props.environmentId, targetThreadId);
413 if (!targetThreadReady) {
414 Alert.alert(
415 "Fork created",
416 "Its thread data did not reach this client. Reconnect and try opening it from the thread list.",
417 );
418 return;
419 }
420 navigation.navigate("Thread", {
421 environmentId: props.environmentId,
422 threadId: targetThreadId,
423 });
424 })
425 .finally(() => setBusy(false));
426 }}
427 className="h-7 w-7 items-center justify-center disabled:opacity-40"
428 >
429 {busy ? (
430 <AppActivityIndicator size="small" />
431 ) : (
432 <SymbolView
433 name="arrow.triangle.branch"
434 size={13}
435 tintColor={props.iconColor}
436 type="monochrome"
437 />
438 )}
439 </Pressable>
440 );
441}
442
443function MessageAttachmentImage(props: {
444 readonly environmentId: EnvironmentId;
445 readonly attachmentId: string;
446 readonly name: string;
447 readonly mimeType: string;
448 readonly className: string;
449 readonly onPressPreview: (source: FilePreviewSource) => void;
450}) {
451 const withGallery = useFilePreviewGallery();
452 const sourceIdentifier = useId();
453 const resource = useMemo(
454 () => ({
455 _tag: "attachment" as const,
456 attachmentId: props.attachmentId,
457 fileName: props.name,
458 mimeType: props.mimeType,
459 }),
460 [props.attachmentId, props.name, props.mimeType],
461 );
462 const uri = useAssetUrl(props.environmentId, resource);
463 const refreshAssetUrl = useRefreshAssetUrl(props.environmentId, resource);
464 const retriedImage = useRef(false);
465 const imageStyle = useResolveClassNames(props.className);
466
467 if (uri === null) {
468 return (
469 <View className={`${props.className} items-center justify-center`}>
470 <AppActivityIndicator />
471 </View>
472 );
473 }
474
475 return (
476 <PresentationSource identifier={sourceIdentifier}>
477 <Pressable
478 accessibilityRole="imagebutton"
479 accessibilityLabel={`Open ${props.name}`}
480 onPress={() =>
481 // The viewer mints its own URL from the resource so the image survives a refresh.
482 props.onPressPreview(
483 withGallery({
484 kind: "image",
485 environmentId: props.environmentId,
486 resource,
487 name: props.name,
488 sourceIdentifier,
489 actionsSource: {
490 name: props.name,
491 mimeType: props.mimeType,
492 environmentId: props.environmentId,
493 resource,
494 },
495 }),
496 )
497 }
498 >
499 <ExpoImage
500 source={assetImageSource(props.environmentId, resource, uri)}
501 cachePolicy="memory-disk"
502 style={imageStyle}
503 contentFit="cover"
504 onLoad={() => {
505 retriedImage.current = false;
506 }}
507 onError={() => {
508 if (retriedImage.current) return;
509 retriedImage.current = true;
510 void refreshAssetUrl();
511 }}
512 />
513 </Pressable>
514 </PresentationSource>
515 );
516}
517
518// The attachment union has an open member (`type: string` for attachment
519// types from newer servers), so literal comparisons do not narrow it. Split
520// with guards and render unknown types as inert rows, never crash.
521function isImageAttachment(attachment: ChatAttachment): attachment is ChatImageAttachment {
522 // Messages sent before pictures were typed by content carry `file`; they are still
523 // pictures, and reading them as such is what lets them keep their thumbnail.
524 return attachment.type === "image" || imageMimeType(attachment) !== null;
525}
526
527function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAttachment {
528 return attachment.type === "file";
529}
530
531function MessageAttachmentFile(props: {
532 readonly environmentId: EnvironmentId;
533 readonly attachment: ChatFileAttachment;
534 readonly onPressPreview: (source: FilePreviewSource) => void;
535 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
536}) {
537 const sourceIdentifier = useId();
538 const navigation = useNavigation();
539 const { selectedThread } = useThreadSelection();
540 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
541 refresh: true,
542 reportFailure: false,
543 });
544 const preparedConnection = usePreparedConnection(props.environmentId);
545 const { attachment } = props;
546 const videoType = videoMimeType(attachment);
547 const isPdf = isPdfFile(attachment);
548 const fileTypeLabel = isPdf
549 ? "PDF"
550 : (attachment.name.match(/\.([a-z0-9]{1,8})$/i)?.[1]?.toUpperCase() ?? "File");
551 const sizeLabel = formatAttachmentSize(attachment.sizeBytes);
552 const thumbnailUrl = useAssetUrl(
553 props.environmentId,
554 videoType === null
555 ? null
556 : {
557 _tag: "attachment",
558 attachmentId: attachment.id,
559 fileName: attachment.name,
560 mimeType: videoType,
561 },
562 );
563 const httpBaseUrl = Option.isSome(preparedConnection)
564 ? preparedConnection.value.httpBaseUrl
565 : null;
566 const openingRef = useRef<AbortController | null>(null);
567 const [opening, setOpening] = useState(false);
568
569 useFocusEffect(
570 useCallback(() => {
571 setOpening(false);
572 return () => {
573 openingRef.current?.abort();
574 openingRef.current = null;
575 };
576 }, [props.environmentId, attachment.id, httpBaseUrl]),
577 );
578
579 const shareFile = (sourceIdentifier?: string) => {
580 if (httpBaseUrl === null || openingRef.current) return;
581 const controller = new AbortController();
582 openingRef.current = controller;
583 setOpening(true);
584 void (async () => {
585 try {
586 const result = await createAssetUrl({
587 environmentId: props.environmentId,
588 input: {
589 resource: {
590 _tag: "attachment",
591 attachmentId: attachment.id,
592 fileName: attachment.name,
593 mimeType: attachment.mimeType,
594 },
595 },
596 });
597 if (controller.signal.aborted) return;
598 if (result._tag === "Failure") {
599 throw squashAtomCommandFailure(result);
600 }
601 const url = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl);
602 if (url === null) {
603 throw new Error("The attachment could not be opened.");
604 }
605 await downloadAndShareAttachment({
606 url,
607 attachment,
608 signal: controller.signal,
609 sourceIdentifier,
610 });
611 } catch (error) {
612 if (!controller.signal.aborted) {
613 Alert.alert(
614 "Could not open attachment",
615 error instanceof Error ? error.message : "The attachment is unavailable.",
616 );
617 }
618 } finally {
619 if (openingRef.current === controller) {
620 openingRef.current = null;
621 setOpening(false);
622 }
623 }
624 })();
625 };
626
627 if (videoType !== null) {
628 const sourceIdentifier = `attachment:${props.environmentId}:${attachment.id}`;
629 return (
630 <VideoAttachmentTile
631 name={attachment.name}
632 sourceIdentifier={sourceIdentifier}
633 thumbnailSource={thumbnailUrl}
634 actionsSource={
635 attachmentVideoPreviewSource(props.environmentId, attachment, sourceIdentifier)
636 .actionsSource
637 }
638 disabled={opening || httpBaseUrl === null}
639 onPress={(sourceIdentifier) => props.onPressVideo(attachment, sourceIdentifier)}
640 className="my-1 rounded-2xl"
641 style={{ width: 224, maxWidth: "100%", aspectRatio: 16 / 9 }}
642 />
643 );
644 }
645
646 return (
647 <>
648 <PresentationSource
649 identifier={sourceIdentifier}
650 className="my-1"
651 style={{ width: 280, maxWidth: "100%" }}
652 >
653 <Pressable
654 accessibilityRole="button"
655 accessibilityLabel={`Open ${attachment.name}`}
656 accessibilityValue={{ text: `${fileTypeLabel}, ${sizeLabel}` }}
657 accessibilityState={{ disabled: opening || httpBaseUrl === null, busy: opening }}
658 disabled={opening || httpBaseUrl === null}
659 className="min-w-0 flex-row items-center gap-3 rounded-xl border border-border bg-card p-3 active:bg-subtle"
660 onLongPress={() => shareFile(sourceIdentifier)}
661 onPress={() =>
662 isPdf
663 ? props.onPressPreview({
664 kind: "pdf",
665 name: attachment.name,
666 environmentId: props.environmentId,
667 resource: {
668 _tag: "attachment",
669 attachmentId: attachment.id,
670 fileName: attachment.name,
671 mimeType: "application/pdf",
672 },
673 sourceIdentifier,
674 })
675 : navigation.navigate("ThreadAttachment", {
676 environmentId: String(props.environmentId),
677 ...(selectedThread ? { threadId: String(selectedThread.id) } : {}),
678 attachmentId: attachment.id,
679 name: attachment.name,
680 mimeType: attachment.mimeType,
681 sizeBytes: String(attachment.sizeBytes),
682 })
683 }
684 >
685 <View className="h-12 w-10 shrink-0 items-center justify-center rounded-lg bg-subtle">
686 {opening ? (
687 <AppActivityIndicator size="small" />
688 ) : (
689 <PierreEntryIcon path={attachment.name} kind="file" size={26} />
690 )}
691 </View>
692 <View className="min-w-0 flex-1 gap-1">
693 <Text className="font-t3-medium text-sm text-foreground" numberOfLines={2}>
694 {attachment.name}
695 </Text>
696 <Text className="text-xs text-foreground-muted" numberOfLines={1}>
697 {fileTypeLabel} · {sizeLabel}
698 </Text>
699 </View>
700 <SymbolView
701 name="chevron.right"
702 size={12}
703 tintColorClassName="accent-foreground-muted"
704 type="monochrome"
705 />
706 </Pressable>
707 </PresentationSource>
708 </>
709 );
710}
711
712/**
713 * An attachment type this build does not know (newer server). Rendered as an
714 * inert row: the name is still useful, but there is nothing to open.
715 */
716function MessageAttachmentUnknown(props: { readonly name: string }) {
717 return (
718 <View className="flex-row items-center gap-2 py-1">
719 <PierreEntryIcon path={props.name} kind="file" size={16} />
720 <Text className="min-w-0 flex-1 text-sm text-foreground" numberOfLines={1}>
721 {props.name}
722 </Text>
723 </View>
724 );
725}
726
727// LegendList only computes hook visibility when the list has a viewability config.
728const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 };
729
730function ThreadMediaVisibility(props: { readonly children: ReactNode }) {
731 const [visible, setVisible] = useState(false);
732 useViewabilityAmount<ThreadFeedEntry>(
733 useCallback((token) => setVisible(token.sizeVisible > 0), []),
734 );
735 return <ThreadMediaVisibleContext value={visible}>{props.children}</ThreadMediaVisibleContext>;
736}
737
738interface MarkdownLinkHandlers {
739 readonly onLinkPress: (href: string) => void;
740 readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined;
741 readonly onFileContextMenuAction: (href: string, actionId: string) => void;
742 readonly onImagePress?: (href: string) => void;
743 readonly resolveImageSource?: MarkdownImageSourceResolver;
744 readonly linkCustomization?: (href: string) => MarkdownLinkCustomization | undefined;
745}
746
747interface MarkdownStyleSets {
748 readonly user: MarkdownStyleSet;
749 readonly assistant: MarkdownStyleSet;
750}
751
752interface MarkdownStyleSet {
753 readonly nativeTextStyle: NativeMarkdownTextStyle;
754}
755
756const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record<
757 CodexArtifactTemplate["artifactKind"],
758 AppSymbolName
759> = {
760 document: "doc.text",
761 presentation: "chart.bar.xaxis",
762 spreadsheet: "chart.bar.xaxis",
763 site: "safari",
764 "google-docs": "doc.text",
765 "google-slides": "chart.bar.xaxis",
766 "google-sheets": "chart.bar.xaxis",
767 image: "camera",
768 email: "text.bubble",
769 slack: "text.bubble",
770};
771
772function ArtifactTemplateCard(props: {
773 readonly template: CodexArtifactTemplate;
774 readonly onUse?: ((template: CodexArtifactTemplate) => void) | undefined;
775}) {
776 return (
777 <View className="my-2 min-w-0 flex-row items-center gap-3 rounded-2xl border border-border bg-card px-3 py-3">
778 <View className="relative h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-border bg-subtle">
779 <SymbolView
780 name={ARTIFACT_TEMPLATE_SYMBOL_BY_KIND[props.template.artifactKind]}
781 size={20}
782 tintColorClassName="accent-foreground-muted"
783 type="monochrome"
784 />
785 <View className="absolute -right-1 -bottom-1 h-4 w-4 items-center justify-center rounded-full bg-fuchsia-500">
786 <SymbolView
787 name={{ ios: "sparkles", android: "auto_awesome" }}
788 size={9}
789 tintColor="white"
790 type="monochrome"
791 />
792 </View>
793 </View>
794 <View className="min-w-0 flex-1">
795 <Text className="font-t3-bold text-sm text-foreground" numberOfLines={1}>
796 {props.template.displayName}
797 </Text>
798 <Text className="text-xs text-foreground-muted">
799 {codexArtifactTemplatePresentationLabel(props.template.artifactKind)}
800 </Text>
801 </View>
802 {props.onUse ? (
803 <Pressable
804 accessibilityRole="button"
805 accessibilityLabel={`Use ${props.template.displayName} template`}
806 className="min-h-9 justify-center rounded-lg border border-border bg-subtle px-3 active:opacity-65"
807 onPress={() => props.onUse?.(props.template)}
808 >
809 <Text className="font-t3-bold text-xs text-foreground">Use template</Text>
810 </Pressable>
811 ) : null}
812 </View>
813 );
814}
815
816const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
817 readonly markdown: string;
818 readonly environmentId: EnvironmentId;
819 readonly markdownStyles: MarkdownStyleSet;
820 readonly linkHandlers: MarkdownLinkHandlers;
821 readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;
822 readonly renderImage: MarkdownImageRenderer;
823 readonly skills?: ReadonlyArray<SelectableMarkdownSkill> | undefined;
824}) {
825 const liveMarkdown = useLiveThreadLinkLabels(props.markdown, props.environmentId);
826 const segments = useMemo(() => splitCodexArtifactTemplateMarkdown(liveMarkdown), [liveMarkdown]);
827
828 return segments.map((segment) => {
829 if (segment.kind === "artifact-template") {
830 return (
831 <ArtifactTemplateCard
832 key={`artifact-template:${segment.sourceOffset}`}
833 template={segment.template}
834 onUse={props.onUseArtifactTemplate}
835 />
836 );
837 }
838 if (segment.markdown.trim().length === 0) return null;
839
840 const markdown = renderCodexInlineDirectivesAsMarkdown(segment.markdown);
841 return (
842 <SelectableMarkdownText
843 key={`markdown:${segment.sourceOffset}`}
844 markdown={markdown}
845 skills={props.skills}
846 textStyle={props.markdownStyles.nativeTextStyle}
847 {...props.linkHandlers}
848 renderImage={props.renderImage}
849 />
850 );
851 });
852});
853
854function useMarkdownStyles(): MarkdownStyleSets {
855 const { appearance } = useAppearancePreferences();
856 const nativeMarkdownTypography = useMemo(
857 () => resolveNativeMarkdownTypography(appearance.baseFontSize),
858 [appearance.baseFontSize],
859 );
860 const theme = useUniwindTheme();
861 const markdownBodyColor = theme["--color-md-body"];
862 const markdownStrongColor = theme["--color-md-strong"];
863 const markdownLinkColor = theme["--color-md-link"];
864 const markdownBlockquoteBorder = theme["--color-md-blockquote-border"];
865 const markdownCodeBg = theme["--color-md-code-bg"];
866 const markdownCodeText = theme["--color-md-code-text"];
867 const markdownInlineCodeText = theme["--color-foreground-secondary"];
868 const markdownHrColor = theme["--color-md-hr"];
869 // Native chip drawing parses opaque hex only, and this role is translucent.
870 const contextChipBorderColor = flattenThemeColor(
871 theme["--color-border"],
872 theme["--color-user-bubble"],
873 );
874 const markdownUserBodyColor = theme["--color-user-bubble-foreground"];
875 const markdownUserCodeBg = theme["--color-md-user-code-bg"];
876 const markdownUserCodeText = theme["--color-md-user-code-text"];
877 const markdownUserInlineCodeText = theme["--color-user-bubble-foreground-muted"];
878 const markdownUserFenceBg = theme["--color-md-user-fence-bg"];
879 const inlineSkillForeground = theme["--color-inline-skill-foreground"];
880 const userBubbleSkillForeground = theme["--color-user-bubble-skill-foreground"];
881 const regularFontFamily = useFontFamily("regular");
882 const boldFontFamily = useFontFamily("bold");
883
884 return useMemo(() => {
885 return {
886 user: {
887 nativeTextStyle: {
888 color: markdownUserBodyColor,
889 strongColor: markdownUserBodyColor,
890 mutedColor: markdownUserBodyColor,
891 linkColor: markdownUserBodyColor,
892 inlineCodeColor: markdownUserInlineCodeText,
893 codeColor: markdownUserCodeText,
894 codeBackgroundColor: markdownUserCodeBg,
895 codeBlockBackgroundColor: markdownUserFenceBg,
896 fileTextColor: markdownUserBodyColor,
897 skillTextColor: userBubbleSkillForeground,
898 quoteMarkerColor: markdownUserBodyColor,
899 dividerColor: markdownUserBodyColor,
900 contextChipBorderColor,
901 fontSize: nativeMarkdownTypography.fontSize,
902 lineHeight: nativeMarkdownTypography.lineHeight,
903 headingFontSizes: nativeMarkdownTypography.headingFontSizes,
904 fontFamily: regularFontFamily,
905 headingFontFamily: boldFontFamily,
906 boldFontFamily,
907 },
908 },
909 assistant: {
910 nativeTextStyle: {
911 color: markdownBodyColor,
912 strongColor: markdownStrongColor,
913 mutedColor: markdownBodyColor,
914 linkColor: markdownLinkColor,
915 inlineCodeColor: markdownInlineCodeText,
916 codeColor: markdownCodeText,
917 codeBackgroundColor: markdownCodeBg,
918 codeBlockBackgroundColor: markdownCodeBg,
919 fileTextColor: markdownCodeText,
920 skillTextColor: inlineSkillForeground,
921 quoteMarkerColor: markdownBlockquoteBorder,
922 dividerColor: markdownHrColor,
923 contextChipBorderColor,
924 fontSize: nativeMarkdownTypography.fontSize,
925 lineHeight: nativeMarkdownTypography.lineHeight,
926 headingFontSizes: nativeMarkdownTypography.headingFontSizes,
927 fontFamily: regularFontFamily,
928 headingFontFamily: boldFontFamily,
929 boldFontFamily,
930 },
931 },
932 };
933 }, [
934 boldFontFamily,
935 contextChipBorderColor,
936 inlineSkillForeground,
937 markdownBlockquoteBorder,
938 markdownBodyColor,
939 markdownCodeBg,
940 markdownCodeText,
941 markdownHrColor,
942 markdownInlineCodeText,
943 markdownLinkColor,
944 markdownStrongColor,
945 markdownUserBodyColor,
946 markdownUserCodeBg,
947 markdownUserCodeText,
948 markdownUserFenceBg,
949 markdownUserInlineCodeText,
950 nativeMarkdownTypography,
951 regularFontFamily,
952 userBubbleSkillForeground,
953 ]);
954}
955
956function AgentMessageAttribution(props: {
957 readonly environmentId: EnvironmentId;
958 readonly senderThreadId?: ThreadId;
959}) {
960 const navigation = useNavigation();
961 const senderThreadId = props.senderThreadId;
962 const label = (
963 <Text className="mb-1 pr-1 font-t3-medium text-2xs text-foreground-muted opacity-60">
964 Sent by another agent
965 </Text>
966 );
967 return senderThreadId ? (
968 <Pressable
969 accessibilityRole="button"
970 accessibilityLabel="Open sending thread"
971 hitSlop={4}
972 onPress={() =>
973 navigation.navigate("Thread", {
974 environmentId: String(props.environmentId),
975 threadId: String(senderThreadId),
976 })
977 }
978 >
979 {label}
980 </Pressable>
981 ) : (
982 label
983 );
984}
985
986function renderFeedEntry(
987 info: { item: PendingThreadFeedEntry; index: number },
988 props: Pick<
989 ThreadFeedProps,
990 | "environmentId"
991 | "onUseArtifactTemplate"
992 | "skills"
993 | "dispatchingMessageId"
994 | "onEditPendingMessage"
995 | "threadId"
996 | "workspaceRoot"
997 > & {
998 readonly copiedRowId: string | null;
999 readonly expandedWorkRows: Record<string, boolean>;
1000 readonly workRowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
1001 readonly workGroupScrollPositions: Map<string, ThreadWorkGroupScrollPosition>;
1002 readonly terminalAssistantMessageIds: ReadonlySet<string>;
1003 readonly unsettledTurnId: RunId | null;
1004 readonly failedRunIds: ReadonlySet<RunId>;
1005 readonly onCopyWorkRow: (rowId: string, value: string) => void;
1006 readonly onToggleWorkGroup: (groupId: string, anchorKey?: string) => void;
1007 readonly onToggleWorkRow: (rowId: string, anchorKey?: string) => void;
1008 readonly onToggleTurnFold: (runId: RunId) => void;
1009 readonly onPressPreview: (source: FilePreviewSource) => void;
1010 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
1011 readonly markdownLinkHandlers: MarkdownLinkHandlers;
1012 readonly renderMarkdownImage: MarkdownImageRenderer;
1013 readonly renderViewedImage: MarkdownImageRenderer;
1014 readonly renderMarkdown: (text: string) => ReactNode;
1015 readonly iconSubtleColor: string | import("react-native").ColorValue;
1016 readonly screenColor: string;
1017 readonly userBubbleColor: string | import("react-native").ColorValue;
1018 readonly markdownStyles: MarkdownStyleSets;
1019 readonly reviewCommentColors: ReviewCommentColors;
1020 readonly reviewCommentBubbleWidth: number;
1021 readonly themeAppearance: "light" | "dark";
1022 readonly usesNativeWorkspaceColumns: boolean;
1023 readonly userBubbleMaxWidth: number;
1024 /** Width assistant markdown lays out in, so images can size their frame before layout. */
1025 readonly markdownContentWidth: number;
1026 /** Width full-bleed rows (HTML renders) lay out in. */
1027 readonly contentWidth: number;
1028 readonly threadTitle: string;
1029 },
1030) {
1031 const entry = info.item;
1032 const { markdownStyles, iconSubtleColor, userBubbleColor } = props;
1033
1034 if (entry.type === "run-fold") {
1035 return (
1036 <Pressable
1037 accessibilityRole="button"
1038 accessibilityState={{ expanded: entry.expanded }}
1039 onPress={() => props.onToggleTurnFold(entry.runId)}
1040 hitSlop={4}
1041 className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-border-subtle px-2"
1042 style={{
1043 minHeight: Math.max(TURN_FOLD_HEIGHT - 3.5, props.workRowSizing.estimatedRowHeight),
1044 }}
1045 >
1046 <Text
1047 key={props.workRowSizing.textSizeKey}
1048 className="font-t3-medium text-sm tabular-nums text-foreground-muted"
1049 >
1050 {entry.label}
1051 </Text>
1052 <ThreadDisclosureChevron
1053 expanded={entry.expanded}
1054 collapsedDirection="right"
1055 size={15}
1056 tintColor={iconSubtleColor}
1057 />
1058 </Pressable>
1059 );
1060 }
1061
1062 if (entry.type === "thinking") {
1063 return <ThreadThinkingRow rowSizing={props.workRowSizing} iconSubtleColor={iconSubtleColor} />;
1064 }
1065
1066 if (entry.type === "html-render") {
1067 return (
1068 <ThreadHtmlRender
1069 environmentId={props.environmentId}
1070 threadId={props.threadId}
1071 render={entry.render}
1072 frameWidth={props.contentWidth}
1073 iconColor={iconSubtleColor}
1074 />
1075 );
1076 }
1077
1078 if (entry.type === "mcp-app") {
1079 return (
1080 <ThreadMcpApp
1081 key={entry.app.attachmentId}
1082 environmentId={props.environmentId}
1083 threadId={entry.sourceThreadId}
1084 conversationThreadId={props.threadId}
1085 itemId={entry.itemId}
1086 revision={entry.revision}
1087 app={entry.app}
1088 width={props.contentWidth}
1089 />
1090 );
1091 }
1092
1093 if (entry.type === "work-toggle") {
1094 return (
1095 <ThreadWorkGroupToggle
1096 environmentId={props.environmentId}
1097 rowSizing={props.workRowSizing}
1098 expanded={entry.expanded}
1099 hiddenCount={entry.hiddenCount}
1100 iconSubtleColor={iconSubtleColor}
1101 summary={entry.summary}
1102 summaryKind={entry.summaryKind}
1103 themeAppearance={props.themeAppearance}
1104 toolSurface={entry.toolSurface}
1105 toolIcon={entry.toolIcon}
1106 summaryToolIcon={entry.summaryToolIcon}
1107 hasFailure={entry.hasFailure}
1108 shimmer={entry.shimmer}
1109 thought={entry.thought}
1110 onToggle={() => props.onToggleWorkGroup(entry.groupId, entry.id)}
1111 />
1112 );
1113 }
1114
1115 if (entry.type === "activity-group" && isContextHandoffActivityGroup(entry)) {
1116 return (
1117 <ThreadHandoffRow
1118 environmentId={props.environmentId}
1119 projectedItem={entry.activities[0]!.projectedItem}
1120 iconColor={iconSubtleColor}
1121 />
1122 );
1123 }
1124
1125 if (entry.type === "activity-group" && isSecretRequestActivityGroup(entry)) {
1126 return (
1127 <SecretRequestCard
1128 environmentId={props.environmentId}
1129 projectedItem={entry.activities[0]!.projectedItem}
1130 iconColor={iconSubtleColor}
1131 />
1132 );
1133 }
1134
1135 if (entry.type === "activity-group" && isContextCompactionActivityGroup(entry)) {
1136 const label = entry.activities[0]!.summary;
1137 const active =
1138 props.unsettledTurnId !== null &&
1139 entry.runId === props.unsettledTurnId &&
1140 entry.activities[0]!.projectedItem.item.status === "running";
1141 return (
1142 <ThreadContextDivider
1143 label={label}
1144 icon="arrow.down.right.and.arrow.up.left"
1145 iconColor={iconSubtleColor}
1146 active={active}
1147 />
1148 );
1149 }
1150
1151 if (entry.type === "message") {
1152 const { message } = entry;
1153 const isUser = message.role === "user";
1154 const presentation = resolveUserMessagePresentation(message);
1155 const renderedText = renderAssistantCitationsAsText(presentation.text);
1156 const styles = isUser ? markdownStyles.user : markdownStyles.assistant;
1157 const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt);
1158 const attachments = message.attachments ?? [];
1159 const hasReviewCommentContext = presentation.text.includes("<review_comment");
1160 // A bubble that sizes itself from its content cannot lay out a block whose
1161 // intrinsic width overflows `maxWidth`: Android positions the bubble's
1162 // children during the unclamped pass and never moves them once the width
1163 // is clamped, so the paragraphs around the block end up drawn on top of
1164 // each other. Pinning the width removes that pass.
1165 const hasWideBlock = hasWideMarkdownBlock(renderedText, WIDE_MARKDOWN_BLOCK_OPTIONS);
1166 const assistantTurnStillInProgress =
1167 message.role === "assistant" &&
1168 props.unsettledTurnId !== null &&
1169 message.runId === props.unsettledTurnId;
1170 const showAssistantMeta =
1171 message.role === "assistant" &&
1172 props.terminalAssistantMessageIds.has(message.id) &&
1173 !assistantTurnStillInProgress &&
1174 !message.streaming;
1175
1176 if (isUser) {
1177 const enterAnimated = isFreshTimestamp(message.createdAt);
1178 const intentBadge = resolveUserMessageIntentBadge(message.inputIntent);
1179 const referenceIds = new Set(
1180 collectComposerContextReferences(message.text).map((reference) => reference.contextId),
1181 );
1182 const inlineAttachmentIds = new Set(
1183 message.context?.records.flatMap((record) =>
1184 "attachmentId" in record && referenceIds.has(record.contextId)
1185 ? [record.attachmentId]
1186 : [],
1187 ),
1188 );
1189 const visibleAttachments = attachments.filter(
1190 (attachment) => isImageAttachment(attachment) || !inlineAttachmentIds.has(attachment.id),
1191 );
1192 return (
1193 <Animated.View
1194 className="mb-5 items-end"
1195 {...(enterAnimated ? { entering: FadeInUp.duration(220) } : {})}
1196 >
1197 {presentation.attribution === "automation" ? (
1198 <Text className="mb-1 pr-1 font-t3-medium text-2xs text-foreground-muted opacity-60">
1199 Sent by automation
1200 </Text>
1201 ) : presentation.attribution === "t3code" ? (
1202 <Text className="mb-1 pr-1 font-t3-medium text-2xs text-foreground-muted opacity-60">
1203 Sent by The Knife
1204 </Text>
1205 ) : presentation.attribution === "agent" ? (
1206 <AgentMessageAttribution
1207 environmentId={props.environmentId}
1208 senderThreadId={message.senderThreadId}
1209 />
1210 ) : null}
1211 <View
1212 className="min-w-0 gap-2 rounded-[20px] px-3.5 py-2.5"
1213 style={{
1214 backgroundColor: userBubbleColor,
1215 maxWidth:
1216 props.usesNativeWorkspaceColumns && Platform.OS === "ios" && !Platform.isPad
1217 ? "85%"
1218 : props.userBubbleMaxWidth,
1219 ...(hasReviewCommentContext
1220 ? { width: props.reviewCommentBubbleWidth }
1221 : hasWideBlock
1222 ? {
1223 width:
1224 props.usesNativeWorkspaceColumns && Platform.OS === "ios" && !Platform.isPad
1225 ? "85%"
1226 : props.userBubbleMaxWidth,
1227 }
1228 : null),
1229 }}
1230 >
1231 {entry.pendingMessage?.attachments.map((attachment) =>
1232 attachment.type === "image" && attachment.uploadedAttachmentId ? (
1233 <MessageAttachmentImage
1234 key={attachment.id}
1235 environmentId={props.environmentId}
1236 attachmentId={attachment.uploadedAttachmentId}
1237 name={attachment.name}
1238 mimeType={attachment.mimeType}
1239 className="h-[140px] w-[180px] rounded-[14px]"
1240 onPressPreview={props.onPressPreview}
1241 />
1242 ) : attachment.type === "image" ? (
1243 <Pressable
1244 key={attachment.id}
1245 accessibilityRole="imagebutton"
1246 accessibilityLabel={`Open ${attachment.name}`}
1247 onPress={() =>
1248 props.onPressPreview({
1249 kind: "image",
1250 name: attachment.name,
1251 uri: attachment.dataUrl ?? attachment.previewUri,
1252 gallery: composerImageGallery(entry.pendingMessage?.attachments ?? []),
1253 })
1254 }
1255 >
1256 <Image
1257 source={{ uri: attachment.previewUri }}
1258 style={{ width: 180, height: 140, borderRadius: 14 }}
1259 />
1260 </Pressable>
1261 ) : (
1262 <MessageAttachmentUnknown key={attachment.id} name={attachment.name} />
1263 ),
1264 )}
1265 {/* An empty container still takes a gap, which pads every attachment-free bubble. */}
1266 {visibleAttachments.length > 0 ? (
1267 <View className={inlineAttachmentIds.size ? "flex-row flex-wrap gap-2" : "gap-2"}>
1268 {visibleAttachments.map((attachment) => {
1269 return isImageAttachment(attachment) ? (
1270 <MessageAttachmentImage
1271 key={attachment.id}
1272 environmentId={props.environmentId}
1273 attachmentId={attachment.id}
1274 name={attachment.name}
1275 mimeType={attachment.mimeType}
1276 className={
1277 inlineAttachmentIds.size
1278 ? "h-24 w-24 rounded-[14px] bg-user-bubble-foreground/15"
1279 : "aspect-[1.3] w-full rounded-[14px] bg-user-bubble-foreground/15"
1280 }
1281 onPressPreview={props.onPressPreview}
1282 />
1283 ) : isFileAttachment(attachment) ? (
1284 <MessageAttachmentFile
1285 key={attachment.id}
1286 environmentId={props.environmentId}
1287 attachment={attachment}
1288 onPressPreview={props.onPressPreview}
1289 onPressVideo={props.onPressVideo}
1290 />
1291 ) : (
1292 <MessageAttachmentUnknown key={attachment.id} name={attachment.name} />
1293 );
1294 })}
1295 </View>
1296 ) : null}
1297 {message.text.trim().length > 0 ? (
1298 <MarkdownImageAvailableWidthContext
1299 value={props.userBubbleMaxWidth - USER_BUBBLE_HORIZONTAL_PADDING * 2}
1300 >
1301 <UserMessageContent
1302 text={renderedText}
1303 environmentId={props.environmentId}
1304 context={message.context}
1305 markdownStyles={styles}
1306 reviewCommentColors={props.reviewCommentColors}
1307 skills={props.skills}
1308 linkHandlers={props.markdownLinkHandlers}
1309 renderImage={props.renderMarkdownImage}
1310 />
1311 </MarkdownImageAvailableWidthContext>
1312 ) : null}
1313 </View>
1314 <View className="mt-1 flex-row items-center justify-end gap-1 pr-0.5">
1315 {intentBadge ? (
1316 <View
1317 accessible
1318 accessibilityRole="text"
1319 accessibilityLabel={intentBadge.accessibilityLabel}
1320 className={cn(
1321 "rounded-full border px-1.5 py-0.5",
1322 intentBadge.tone === "queued"
1323 ? "border-adaptive-amber-500-a25-400-a25 bg-adaptive-amber-500-a10-400-a10"
1324 : "border-adaptive-sky-500-a25-400-a25 bg-adaptive-sky-500-a10-400-a10",
1325 )}
1326 >
1327 <Text
1328 className={cn(
1329 "font-t3-medium text-2xs tracking-wide",
1330 intentBadge.tone === "queued"
1331 ? "text-adaptive-amber-700-300"
1332 : "text-adaptive-sky-700-300",
1333 )}
1334 >
1335 {intentBadge.label}
1336 </Text>
1337 </View>
1338 ) : null}
1339 <Text className="font-t3-medium text-xs tabular-nums text-foreground-secondary">
1340 {entry.pendingMessage && !entry.acknowledged ? "Pending" : timestampLabel}
1341 </Text>
1342 {props.onEditPendingMessage !== null &&
1343 entry.pendingMessage &&
1344 !entry.acknowledged &&
1345 !entry.pendingMessage.creation &&
1346 entry.pendingMessage.messageId !== props.dispatchingMessageId ? (
1347 <Pressable
1348 accessibilityRole="button"
1349 accessibilityLabel="Edit pending message"
1350 hitSlop={8}
1351 className="size-7 items-center justify-center"
1352 onPress={() => {
1353 if (entry.pendingMessage && props.onEditPendingMessage) {
1354 props.onEditPendingMessage(entry.pendingMessage);
1355 }
1356 }}
1357 >
1358 <SymbolView name="pencil" size={14} tintColor={iconSubtleColor} />
1359 </Pressable>
1360 ) : null}
1361 {presentation.text.trim().length > 0 ? (
1362 <UserMessageCopyButton
1363 text={presentation.text}
1364 onCopy={
1365 message.context
1366 ? () =>
1367 writeComposerContextClipboard(message.text, {
1368 version: 1,
1369 source: { environmentId: props.environmentId, messageId: message.id },
1370 records: message.context!.records,
1371 })
1372 : undefined
1373 }
1374 tintColor={iconSubtleColor}
1375 />
1376 ) : null}
1377 </View>
1378 </Animated.View>
1379 );
1380 }
1381
1382 // Skip empty assistant messages (no text, no attachments) — they would
1383 // render as an orphaned timestamp and break adjacent activity-group merging.
1384 if (renderedText.trim().length === 0 && attachments.length === 0) {
1385 return null;
1386 }
1387
1388 // Assistant messages hit the same Android unclamped-pass bug as user
1389 // bubbles: wide markdown blocks cause children to be positioned at
1390 // intrinsic width before the container is clamped, overlapping the
1391 // timestamp/copy button row. Pinning the width removes that pass.
1392 const enterAnimated = isFreshTimestamp(message.createdAt);
1393 return (
1394 <Animated.View
1395 className={cn(
1396 showAssistantMeta && !(message.runId && props.failedRunIds.has(message.runId))
1397 ? "mb-5 px-1"
1398 : "mb-1 px-1",
1399 hasWideBlock && "w-full",
1400 )}
1401 {...(enterAnimated ? { entering: FadeIn.duration(220) } : {})}
1402 >
1403 {renderedText.trim().length > 0 ? (
1404 <MarkdownImageAvailableWidthContext value={props.markdownContentWidth}>
1405 <AssistantMarkdownContent
1406 markdown={renderedText}
1407 environmentId={props.environmentId}
1408 markdownStyles={styles}
1409 linkHandlers={props.markdownLinkHandlers}
1410 onUseArtifactTemplate={props.onUseArtifactTemplate}
1411 renderImage={props.renderMarkdownImage}
1412 skills={props.skills}
1413 />
1414 </MarkdownImageAvailableWidthContext>
1415 ) : null}
1416 {attachments.map((attachment) => {
1417 return isImageAttachment(attachment) ? (
1418 <MessageAttachmentImage
1419 key={attachment.id}
1420 environmentId={props.environmentId}
1421 attachmentId={attachment.id}
1422 name={attachment.name}
1423 mimeType={attachment.mimeType}
1424 className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-subtle-strong"
1425 onPressPreview={props.onPressPreview}
1426 />
1427 ) : isFileAttachment(attachment) ? (
1428 <MessageAttachmentFile
1429 key={attachment.id}
1430 environmentId={props.environmentId}
1431 attachment={attachment}
1432 onPressPreview={props.onPressPreview}
1433 onPressVideo={props.onPressVideo}
1434 />
1435 ) : (
1436 <MessageAttachmentUnknown key={attachment.id} name={attachment.name} />
1437 );
1438 })}
1439 {showAssistantMeta ? (
1440 <View className="mt-1 flex-row items-center gap-1">
1441 <CopyTextButton
1442 accessibilityLabel="Copy message"
1443 text={renderedText}
1444 tintColor={iconSubtleColor}
1445 buttonSize={28}
1446 iconSize={13}
1447 />
1448 {message.projectedItem ? (
1449 <AssistantForkButton
1450 environmentId={props.environmentId}
1451 iconColor={iconSubtleColor}
1452 projectedItem={message.projectedItem}
1453 sourceTitle={props.threadTitle}
1454 />
1455 ) : null}
1456 <Text className="font-t3-medium text-xs tabular-nums text-foreground-secondary">
1457 {timestampLabel}
1458 </Text>
1459 </View>
1460 ) : null}
1461 </Animated.View>
1462 );
1463 }
1464
1465 return (
1466 <ThreadWorkLog
1467 // Fixed native rows need fresh measurement after a text-size change.
1468 // Anchors/details live in ThreadFeed and survive this group-only remount.
1469 key={`${entry.id}:${props.workRowSizing.textSizeKey}`}
1470 activities={entry.activities}
1471 continuesWorkLog={entry.continuesWorkLog}
1472 environmentId={props.environmentId}
1473 anchorKey={entry.id}
1474 copiedRowId={props.copiedRowId}
1475 expandedRows={props.expandedWorkRows}
1476 rowSizing={props.workRowSizing}
1477 scrollPositions={props.workGroupScrollPositions}
1478 iconSubtleColor={iconSubtleColor}
1479 edgeFadeColor={props.screenColor}
1480 themeAppearance={props.themeAppearance}
1481 onCopyRow={props.onCopyWorkRow}
1482 onToggleRow={props.onToggleWorkRow}
1483 renderImage={props.renderViewedImage}
1484 renderMarkdown={props.renderMarkdown}
1485 onPressPreview={props.onPressPreview}
1486 />
1487 );
1488}
1489
1490type UserMessageContentProps = {
1491 readonly text: string;
1492 readonly environmentId: EnvironmentId;
1493 readonly context?: OrchestrationMessageContext;
1494 readonly markdownStyles: MarkdownStyleSet;
1495 readonly reviewCommentColors: ReviewCommentColors;
1496 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
1497 readonly linkHandlers: MarkdownLinkHandlers;
1498 readonly renderImage: MarkdownImageRenderer;
1499};
1500
1501function UserMessageContent(props: UserMessageContentProps) {
1502 const [selected, setSelected] = useState<{ contextId: string; label: string } | null>(null);
1503 const navigation = useNavigation();
1504 const { selectedThread } = useThreadSelection();
1505 const liveText = useLiveThreadLinkLabels(props.text, props.environmentId);
1506 const text = replaceComposerContextReferences(liveText, (ref) => {
1507 const available = props.context?.records.some((record) => record.contextId === ref.contextId);
1508 return `[${ref.label}${available ? "" : " (unavailable)"}](t3-context://v1/${ref.kind}/${ref.contextId})`;
1509 });
1510 const onLinkPress = (href: string) => {
1511 const reference = parseComposerContextHref(href);
1512 if (!reference) return props.linkHandlers.onLinkPress?.(href);
1513 const record = props.context?.records.find(
1514 (record) => record.contextId === reference.contextId,
1515 );
1516 if (record?.kind === "mention" && "path" in record) {
1517 props.linkHandlers.onLinkPress?.(record.path);
1518 return;
1519 }
1520 if (record?.kind === "thread" && "threadId" in record) {
1521 navigation.navigate("Thread", {
1522 environmentId: String(record.environmentId),
1523 threadId: String(record.threadId),
1524 });
1525 return;
1526 }
1527 // Documents open in the file screen; pictures, video and PDF keep their native viewers.
1528 const document = composerDocumentAttachmentRecord(record);
1529 if (document) {
1530 navigation.navigate("ThreadAttachment", {
1531 environmentId: String(props.environmentId),
1532 ...(selectedThread ? { threadId: String(selectedThread.id) } : {}),
1533 attachmentId: document.attachmentId,
1534 name: document.name,
1535 mimeType: document.mimeType,
1536 sizeBytes: String(document.sizeBytes),
1537 });
1538 return;
1539 }
1540 setSelected({ contextId: reference.contextId, label: record?.label ?? "Context unavailable" });
1541 };
1542 return (
1543 <>
1544 <UserMessageMarkdownContent
1545 {...props}
1546 text={text}
1547 linkHandlers={{
1548 ...props.linkHandlers,
1549 onLinkPress,
1550 fileContextMenu: (href) => {
1551 const reference = parseComposerContextHref(href);
1552 if (!reference) return props.linkHandlers.fileContextMenu(href);
1553 const record = props.context?.records.find(
1554 (item) => item.contextId === reference.contextId,
1555 );
1556 return {
1557 title: record?.label ?? "Context unavailable",
1558 actions: [
1559 { id: "open-context", title: "Open context" },
1560 { id: "copy-context", title: "Copy context", disabled: !record },
1561 ],
1562 };
1563 },
1564 onFileContextMenuAction: (href, actionId) => {
1565 const reference = parseComposerContextHref(href);
1566 if (!reference) return props.linkHandlers.onFileContextMenuAction(href, actionId);
1567 if (actionId === "open-context") return onLinkPress(href);
1568 const record = props.context?.records.find(
1569 (item) => item.contextId === reference.contextId,
1570 );
1571 if (actionId === "copy-context" && record) {
1572 void writeComposerContextClipboard(
1573 formatComposerContextReference({
1574 ...reference,
1575 label: record.label,
1576 }),
1577 {
1578 version: 1,
1579 source: { environmentId: props.environmentId },
1580 records: [record],
1581 },
1582 )
1583 .then(() => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success))
1584 .catch(() => {
1585 Alert.alert("Could not copy context", "Try copying this context again.");
1586 });
1587 }
1588 },
1589 linkCustomization: (href) => {
1590 const presentation = enrichedContextLinkPresentation(href, props.context?.records);
1591 return presentation
1592 ? { color: presentation.color, icon: markdownFileIconSource(presentation.icon) }
1593 : props.linkHandlers.linkCustomization?.(href);
1594 },
1595 }}
1596 />
1597 {selected ? (
1598 <ComposerContextSheet
1599 label={selected.label}
1600 environmentId={props.environmentId}
1601 records={props.context?.records}
1602 record={props.context?.records.find((record) => record.contextId === selected.contextId)}
1603 onClose={() => setSelected(null)}
1604 />
1605 ) : null}
1606 </>
1607 );
1608}
1609
1610function UserMessageMarkdownContent(props: UserMessageContentProps) {
1611 const text = props.text;
1612 const segments = parseReviewCommentMessageSegments(text);
1613 const hasReviewComment = segments.some((segment) => segment.kind === "review-comment");
1614 const contextClipboardFragment = props.context
1615 ? (encodeComposerContextFragment({
1616 version: 1,
1617 source: { environmentId: props.environmentId },
1618 records: props.context.records,
1619 }) ?? undefined)
1620 : undefined;
1621 if (!hasReviewComment) {
1622 return (
1623 <SelectableMarkdownText
1624 markdown={text}
1625 contextClipboardFragment={contextClipboardFragment}
1626 skills={props.skills}
1627 textStyle={props.markdownStyles.nativeTextStyle}
1628 preserveSoftBreaks
1629 {...props.linkHandlers}
1630 renderImage={props.renderImage}
1631 />
1632 );
1633 }
1634
1635 return (
1636 <View className="w-full gap-2">
1637 {segments.map((segment) => {
1638 if (segment.kind === "review-comment") {
1639 return (
1640 <ReviewCommentCard
1641 key={segment.comment.id}
1642 comment={segment.comment}
1643 colors={props.reviewCommentColors}
1644 />
1645 );
1646 }
1647
1648 const text = segment.text.trim();
1649 if (text.length === 0) {
1650 return null;
1651 }
1652
1653 return (
1654 <SelectableMarkdownText
1655 key={segment.id}
1656 markdown={text}
1657 contextClipboardFragment={contextClipboardFragment}
1658 skills={props.skills}
1659 textStyle={props.markdownStyles.nativeTextStyle}
1660 preserveSoftBreaks
1661 {...props.linkHandlers}
1662 renderImage={props.renderImage}
1663 />
1664 );
1665 })}
1666 </View>
1667 );
1668}
1669
1670function ThreadFeedPlaceholder(props: {
1671 readonly bottomInset: number;
1672 readonly detail: string;
1673 readonly horizontalPadding: number;
1674 readonly title: string;
1675 readonly topInset: number;
1676}) {
1677 return (
1678 <View
1679 style={{
1680 flex: 1,
1681 flexGrow: 1,
1682 alignItems: "center",
1683 justifyContent: "center",
1684 paddingTop: props.topInset,
1685 paddingBottom: props.bottomInset,
1686 paddingHorizontal: props.horizontalPadding + 24,
1687 }}
1688 >
1689 <View className="max-w-[320px] items-center gap-2">
1690 <Text className="text-center font-t3-bold text-lg text-foreground">{props.title}</Text>
1691 <Text className="text-center text-sm leading-normal text-foreground-secondary">
1692 {props.detail}
1693 </Text>
1694 </View>
1695 </View>
1696 );
1697}
1698
1699export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1700 const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
1701 const openPullRequest = useOpenPullRequest();
1702 const navigation = useNavigation();
1703 const { themeAppearance } = useAppearancePreferences();
1704 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1705 const disclosureSettleFrameRef = useRef<number | null>(null);
1706 const disclosureSettleSecondFrameRef = useRef<number | null>(null);
1707 const disclosureAnchorKeyRef = useRef<string | null>(null);
1708 const previousLatestTurnRef = useRef(props.latestRun);
1709 const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1710 const headerMaterialVisibleRef = useRef(false);
1711 const { width: windowWidth, fontScale } = useWindowDimensions();
1712 const { appearance } = useAppearancePreferences();
1713 const workRowSizing = useMemo(
1714 () => deriveThreadWorkLogSizing({ baseFontSize: appearance.baseFontSize, fontScale }),
1715 [appearance.baseFontSize, fontScale],
1716 );
1717 const previousTextSize = useRef(workRowSizing.textSizeKey);
1718 useLayoutEffect(() => {
1719 if (previousTextSize.current === workRowSizing.textSizeKey) {
1720 return;
1721 }
1722 previousTextSize.current = workRowSizing.textSizeKey;
1723 // Text-size changes invalidate the outer list's fixed-height cache too.
1724 // This never runs for scrolling, streamed output, or disclosure toggles.
1725 props.listRef.current?.clearCaches({ mode: "sizes" });
1726 }, [workRowSizing.textSizeKey, props.listRef]);
1727 const [viewportWidth, setViewportWidth] = useState(() =>
1728 props.layoutVariant === "split" ? 0 : windowWidth,
1729 );
1730 const [viewportHeight, setViewportHeight] = useState(0);
1731 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
1732 // Live-follow latch (#5566). LegendList's maintainScrollAtEnd alone re-pins
1733 // the feed whenever the viewport drifts back inside its geometric threshold,
1734 // which yanked users off history they were reading every time a stream chunk
1735 // grew a row. Follow breaks when the user scrolls up and away, and re-arms
1736 // only when the list actually returns to the end (or on send / thread switch).
1737 const [endFollowEnabled, setEndFollowEnabled] = useState(true);
1738 const endFollowEnabledRef = useRef(true);
1739 // A "user scroll session" spans from drag start through the end of its
1740 // momentum; scroll events only break follow inside that session, so MVCP
1741 // compensations and programmatic scrolls never strand a follower.
1742 const userScrollSessionRef = useRef(false);
1743 const setEndFollow = useCallback(
1744 (enabled: boolean) => {
1745 if (endFollowEnabledRef.current === enabled) {
1746 return;
1747 }
1748 endFollowEnabledRef.current = enabled;
1749 setEndFollowEnabled(enabled);
1750 props.onEndFollowEnabledChange?.(enabled);
1751 },
1752 [props.onEndFollowEnabledChange],
1753 );
1754 const transitionEndFollow = useCallback(
1755 (event: ThreadFeedLiveFollowEvent) => {
1756 setEndFollow(resolveThreadFeedLiveFollow(endFollowEnabledRef.current, event));
1757 },
1758 [setEndFollow],
1759 );
1760 const [interactionState, setInteractionState] = useState<{
1761 readonly copiedRowId: string | null;
1762 readonly expandedWorkGroups: Record<string, boolean>;
1763 readonly expandedWorkRows: Record<string, boolean>;
1764 readonly expandedTurnIds: ReadonlySet<RunId>;
1765 }>({
1766 copiedRowId: null,
1767 expandedWorkGroups: {},
1768 expandedWorkRows: {},
1769 expandedTurnIds: new Set(),
1770 });
1771 const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState;
1772 const [expandedFile, setExpandedFile] = useState<FilePreviewSource | null>(null);
1773 const [expandedVideo, setExpandedVideo] = useState<VideoPreviewSource | null>(null);
1774 const fileShareSourceIdentifier = useId();
1775 const shareFileChip = useFileChipShare(
1776 props.environmentId,
1777 props.threadId,
1778 fileShareSourceIdentifier,
1779 );
1780 useEffect(() => {
1781 setExpandedVideo(null);
1782 setExpandedFile(null);
1783 }, [props.environmentId, props.threadId, props.contentPresentation.kind]);
1784 const horizontalPadding = props.layoutVariant === "split" ? 20 : 16;
1785 const contentLeftInset = props.contentSideInsets?.left ?? 0;
1786 const contentRightInset = props.contentSideInsets?.right ?? 0;
1787 const usableViewportWidth = Math.max(0, viewportWidth - contentLeftInset - contentRightInset);
1788 const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({
1789 viewportWidth: usableViewportWidth,
1790 maxContentWidth: props.contentMaxWidth ?? null,
1791 minimumPadding: horizontalPadding,
1792 });
1793 const contentWidth = Math.max(0, usableViewportWidth - contentHorizontalPadding * 2);
1794 const userBubbleMaxWidth = contentWidth * 0.85;
1795 const markdownContentWidth = Math.max(0, contentWidth - ASSISTANT_ROW_HORIZONTAL_PADDING * 2);
1796 const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth);
1797 const insets = useSafeAreaInsets();
1798 const topContentInset = props.contentTopInset ?? insets.top + IOS_NAV_BAR_HEIGHT;
1799 const bottomContentInset = props.contentBottomInset ?? 18;
1800 const usesNativeAutomaticInsets =
1801 props.usesAutomaticContentInsets === true && Platform.OS === "ios";
1802 const initialContentInset = deriveThreadFeedInitialContentInset({
1803 platform: Platform.OS,
1804 usesNativeAutomaticInsets,
1805 bottomContentInset,
1806 });
1807 // With automatic insets the header inset lives in UIKit's adjustedContentInset,
1808 // which LegendList's JS anchoring math cannot see — it measures the anchored
1809 // end space from the scroll view's frame top. Fold the header height back into
1810 // the anchor offset or a just-sent message anchors underneath the header and
1811 // the oversized end space keeps maintainScrollAtEnd snapping away from earlier
1812 // messages. Read the context directly (useHeaderHeight throws outside a
1813 // header-providing screen) and fall back to the standard iOS bar height.
1814 const navigationHeaderHeight = useContext(HeaderHeightContext);
1815 const anchorTopInset = usesNativeAutomaticInsets
1816 ? (navigationHeaderHeight ?? insets.top + IOS_NAV_BAR_HEIGHT)
1817 : topContentInset;
1818
1819 const theme = useUniwindTheme();
1820 const iconSubtleColor = theme["--color-icon-subtle"];
1821 const screenColor = theme["--color-screen"];
1822 const userBubbleColor = theme["--color-user-bubble"];
1823 const onMarkdownLinkPress = useCallback(
1824 (href: string, imageEmbed = false) => {
1825 // A thread link names a thread in this feed's environment.
1826 const linkedThreadId = parseThreadLinkHref(href);
1827 if (linkedThreadId) {
1828 navigation.navigate("Thread", {
1829 environmentId: String(props.environmentId),
1830 threadId: String(linkedThreadId),
1831 });
1832 return;
1833 }
1834 const media = resolveMarkdownMediaPreview(href, {
1835 environmentId: props.environmentId,
1836 threadId: props.threadId,
1837 workspaceRoot: props.workspaceRoot,
1838 imageEmbed,
1839 });
1840 if (media) {
1841 void Haptics.selectionAsync();
1842 if (media.kind === "video") {
1843 setExpandedVideo((current) => current ?? media.source);
1844 } else {
1845 setExpandedFile((current) => current ?? media.source);
1846 }
1847 return;
1848 }
1849
1850 const presentation = resolveMarkdownLinkPresentation(href);
1851 if (presentation.kind === "file") {
1852 const relativePath = resolveWorkspaceRelativeFilePath(
1853 props.workspaceRoot,
1854 presentation.path,
1855 );
1856 if (relativePath) {
1857 void Haptics.selectionAsync();
1858 navigation.navigate("ThreadFile", {
1859 environmentId: String(props.environmentId),
1860 threadId: String(props.threadId),
1861 path: fileRoutePathSegments(relativePath),
1862 ...(presentation.line ? { line: String(presentation.line) } : {}),
1863 });
1864 return;
1865 }
1866 }
1867
1868 // A host file outside the workspace, such as a report an agent wrote to
1869 // a temp directory, opens read-only in the file screen.
1870 if (presentation.kind === "file" && isAbsolutePath(presentation.path)) {
1871 void Haptics.selectionAsync();
1872 navigation.navigate("ThreadFile", {
1873 environmentId: String(props.environmentId),
1874 threadId: String(props.threadId),
1875 path: fileRoutePathSegments(presentation.path),
1876 ...(presentation.line ? { line: String(presentation.line) } : {}),
1877 });
1878 return;
1879 }
1880
1881 if (presentation.kind !== "file" && presentation.href) {
1882 if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) {
1883 setExpandedFile(
1884 (current) => current ?? { kind: "pdf", uri: presentation.href!, name: "Document.pdf" },
1885 );
1886 return;
1887 }
1888 void openPullRequest(presentation.href);
1889 }
1890 },
1891 [props.environmentId, props.threadId, props.workspaceRoot, navigation, openPullRequest],
1892 );
1893 const resolveImageSource = useMarkdownImageSource({
1894 environmentId: props.environmentId,
1895 threadId: props.threadId,
1896 workspaceRoot: props.workspaceRoot ?? null,
1897 });
1898 const markdownLinkHandlers = useMemo<MarkdownLinkHandlers>(
1899 () => ({
1900 onLinkPress: onMarkdownLinkPress,
1901 onImagePress: (href) => onMarkdownLinkPress(href, true),
1902 resolveImageSource,
1903 fileContextMenu: (href) => {
1904 const target = resolveFileChipTarget(href, props.workspaceRoot);
1905 return target ? fileChipMenu(target) : undefined;
1906 },
1907 onFileContextMenuAction: (href, actionId) => {
1908 const target = resolveFileChipTarget(href, props.workspaceRoot);
1909 if (!target) return;
1910 switch (actionId as FileChipAction) {
1911 case "copy-full-path":
1912 if (target.fullPath) copyTextWithHaptic(target.fullPath);
1913 return;
1914 case "copy-relative-path":
1915 if (target.relativePath) copyTextWithHaptic(target.relativePath);
1916 return;
1917 case "open-file":
1918 onMarkdownLinkPress(href);
1919 return;
1920 case "save":
1921 shareFileChip(target);
1922 return;
1923 }
1924 },
1925 }),
1926 [onMarkdownLinkPress, props.workspaceRoot, shareFileChip, resolveImageSource],
1927 );
1928 const renderMarkdownImage = useCallback<MarkdownImageRenderer>(
1929 (image) => {
1930 const media = resolveMarkdownMediaPreview(image.href, {
1931 environmentId: props.environmentId,
1932 threadId: props.threadId,
1933 workspaceRoot: props.workspaceRoot,
1934 imageEmbed: true,
1935 });
1936 if (media?.kind === "video") {
1937 return (
1938 <ThreadMarkdownVideo
1939 key={image.href}
1940 source={{ ...media.source, name: image.alt ?? media.source.name }}
1941 />
1942 );
1943 }
1944 const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null);
1945 if (imageSource._tag === "Direct") {
1946 return (
1947 <ThreadMarkdownImageView
1948 uri={normalizeNativeMarkdownUrl(imageSource.uri)}
1949 sourceKey={imageSource.uri}
1950 format={
1951 /\.svg(?:[?#]|$)/i.test(imageSource.uri) ||
1952 imageSource.uri.startsWith("data:image/svg+xml")
1953 ? "svg"
1954 : undefined
1955 }
1956 unavailable={false}
1957 alt={image.alt}
1958 actionsSource={media?.source.actionsSource}
1959 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
1960 />
1961 );
1962 }
1963 if (imageSource._tag === "Blocked") {
1964 return <ThreadMarkdownImageUnavailable alt={image.alt} />;
1965 }
1966 return (
1967 <ThreadMarkdownImage
1968 environmentId={props.environmentId}
1969 resource={{
1970 _tag: "media-file",
1971 threadId: props.threadId,
1972 path: imageSource.path,
1973 }}
1974 alt={image.alt}
1975 srcFragment={markdownImageSourceFragment(image.href)}
1976 actionsSource={media?.source.actionsSource}
1977 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
1978 />
1979 );
1980 },
1981 [props.environmentId, props.threadId, props.workspaceRoot],
1982 );
1983 const renderViewedImage = useCallback<MarkdownImageRenderer>(
1984 (image) => {
1985 const viewedImage = resolveViewedImageAsset(image.href, {
1986 threadId: props.threadId,
1987 workspaceRoot: props.workspaceRoot,
1988 });
1989 const media = viewedImage
1990 ? resolveMarkdownMediaPreview(image.href, {
1991 environmentId: props.environmentId,
1992 threadId: props.threadId,
1993 workspaceRoot: props.workspaceRoot,
1994 imageEmbed: true,
1995 })
1996 : null;
1997 const actionsSource = media?.source.actionsSource;
1998 return viewedImage ? (
1999 <ThreadMarkdownImage
2000 environmentId={props.environmentId}
2001 resource={viewedImage.resource}
2002 alt={viewedImage.alt}
2003 srcFragment={viewedImage.srcFragment}
2004 actionsSource={
2005 actionsSource && "resource" in actionsSource
2006 ? { ...actionsSource, resource: viewedImage.resource }
2007 : undefined
2008 }
2009 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
2010 />
2011 ) : null;
2012 },
2013 [props.environmentId, props.threadId, props.workspaceRoot],
2014 );
2015 const markdownStyles = useMarkdownStyles();
2016 const renderMarkdown = useCallback(
2017 (text: string) => (
2018 <AssistantMarkdownContent
2019 markdown={text}
2020 environmentId={props.environmentId}
2021 markdownStyles={markdownStyles.assistant}
2022 linkHandlers={markdownLinkHandlers}
2023 renderImage={renderMarkdownImage}
2024 skills={props.skills}
2025 />
2026 ),
2027 [
2028 markdownStyles.assistant,
2029 markdownLinkHandlers,
2030 renderMarkdownImage,
2031 props.skills,
2032 props.environmentId,
2033 ],
2034 );
2035 const reviewCommentColors = useReviewCommentColors();
2036 const unsettledTurnId = threadFeedRunIsUnsettled(props.latestRun) ? props.latestRun.runId : null;
2037 // LegendList does not invalidate visible rows when only the renderItem closure changes.
2038 // Include turn completion so unchanged message rows reveal their footer and spacing
2039 // even when the final message update arrives before the turn settles.
2040 const listAppearanceData = useMemo(
2041 () => ({
2042 worktreeSetup: props.worktreeSetup,
2043 setupWorkingStartedAt: props.setupWorkingStartedAt,
2044 dispatchingMessageId: props.dispatchingMessageId,
2045 unsettledTurnId,
2046 copiedRowId,
2047 expandedWorkGroups,
2048 expandedWorkRows,
2049 workRowSizing,
2050 iconSubtleColor,
2051 markdownStyles,
2052 reviewCommentColors,
2053 themeAppearance,
2054 userBubbleColor,
2055 viewportWidth,
2056 }),
2057 [
2058 props.worktreeSetup,
2059 props.setupWorkingStartedAt,
2060 props.dispatchingMessageId,
2061 unsettledTurnId,
2062 copiedRowId,
2063 expandedWorkGroups,
2064 expandedWorkRows,
2065 workRowSizing,
2066 iconSubtleColor,
2067 markdownStyles,
2068 reviewCommentColors,
2069 themeAppearance,
2070 userBubbleColor,
2071 viewportWidth,
2072 ],
2073 );
2074 const reportHeaderMaterialVisibility = useCallback(
2075 (visible: boolean) => {
2076 if (headerMaterialVisibleRef.current === visible) {
2077 return;
2078 }
2079 headerMaterialVisibleRef.current = visible;
2080 props.onHeaderMaterialVisibilityChange?.(visible);
2081 },
2082 [props.onHeaderMaterialVisibilityChange],
2083 );
2084 const handleScroll = useCallback(
2085 (event: NativeSyntheticEvent<NativeScrollEvent>) => {
2086 // anchorTopInset, not topContentInset: under automatic insets the list
2087 // rests at contentOffset.y = -headerHeight (the inset lives only in
2088 // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the
2089 // header height back or the material toggles a full header too late.
2090 reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6);
2091 // LegendList recomputes its inset-aware end distance before invoking
2092 // this handler, so getState() is current. Only the actual end re-arms
2093 // follow: its broader maintain-scroll threshold is large enough for a
2094 // streaming chunk to pull a user back before their upward drag escapes.
2095 // A live user-scroll session still wins even if the first scroll event
2096 // remains inside LegendList's at-end tolerance.
2097 const listState = props.listRef.current?.getState();
2098 if (listState) {
2099 transitionEndFollow({
2100 type: "scroll",
2101 isAtEnd: listState.isAtEnd,
2102 userScrollSessionActive: userScrollSessionRef.current,
2103 });
2104 }
2105 },
2106 [reportHeaderMaterialVisibility, anchorTopInset, props.listRef, transitionEndFollow],
2107 );
2108 const clearUserScrollSettle = useCallback(() => {
2109 if (userScrollSettleTimerRef.current !== null) {
2110 clearTimeout(userScrollSettleTimerRef.current);
2111 userScrollSettleTimerRef.current = null;
2112 }
2113 }, []);
2114 const handleScrollBeginDrag = useCallback(() => {
2115 clearUserScrollSettle();
2116 userScrollSessionRef.current = true;
2117 // Pause before the first scroll event. Otherwise a stream update can run
2118 // maintainScrollAtEnd between touch-down and the drag leaving its threshold.
2119 transitionEndFollow({ type: "user-scroll-begin" });
2120 }, [clearUserScrollSettle, transitionEndFollow]);
2121 const finishUserScroll = useCallback(
2122 (releaseIsAtEnd?: boolean) => {
2123 clearUserScrollSettle();
2124 const userScrollSessionActive = userScrollSessionRef.current;
2125 userScrollSessionRef.current = false;
2126 transitionEndFollow({
2127 type: "user-scroll-end",
2128 // With no momentum, preserve the finger-release position. Streaming
2129 // growth during the native momentum-detection window must not turn a
2130 // release at the live edge into an opt-out from follow.
2131 isAtEnd: releaseIsAtEnd ?? props.listRef.current?.getState().isAtEnd ?? false,
2132 userScrollSessionActive,
2133 });
2134 },
2135 [clearUserScrollSettle, props.listRef, transitionEndFollow],
2136 );
2137 // Finger-lift velocity is not a reliable momentum signal: a gentle fling
2138 // can report zero and still decelerate. Give native momentum a short window
2139 // to announce itself; if it does, onMomentumScrollBegin cancels this fallback
2140 // and the session survives until the settled momentum-end position. This
2141 // mirrors the native-event handoff used by the home thread list's scroll gate.
2142 const handleScrollEndDrag = useCallback(() => {
2143 clearUserScrollSettle();
2144 const releaseIsAtEnd = props.listRef.current?.getState().isAtEnd ?? false;
2145 userScrollSettleTimerRef.current = setTimeout(() => finishUserScroll(releaseIsAtEnd), 160);
2146 }, [clearUserScrollSettle, finishUserScroll, props.listRef]);
2147 const handleMomentumScrollBegin = useCallback(() => {
2148 if (userScrollSessionRef.current) {
2149 clearUserScrollSettle();
2150 }
2151 }, [clearUserScrollSettle]);
2152 const handleMomentumScrollEnd = useCallback(() => {
2153 finishUserScroll();
2154 }, [finishUserScroll]);
2155
2156 useEffect(() => clearUserScrollSettle, [clearUserScrollSettle]);
2157
2158 // Thread identity is env-scoped: two environments can hold the same
2159 // ThreadId, and keying resets (or the list mount) on the bare id would
2160 // carry stale scroll/follow state across an environment switch.
2161 const feedThreadKey = scopedThreadKey(props.environmentId, props.threadId);
2162 // Virtualized groups can unmount without losing the reader's place. This cache
2163 // belongs to this thread view only and never causes per-scroll React updates.
2164 const workGroupScrollPositions = useMemo(
2165 () => new Map<string, ThreadWorkGroupScrollPosition>(),
2166 [feedThreadKey],
2167 );
2168 // A thread switch opens pinned to the end; a send explicitly returns to the
2169 // live edge (ThreadDetailScreen scrolls the new message into place). Both
2170 // re-arm follow regardless of where the user had scrolled before.
2171 useEffect(() => {
2172 clearUserScrollSettle();
2173 userScrollSessionRef.current = false;
2174 transitionEndFollow({ type: "reset" });
2175 }, [clearUserScrollSettle, feedThreadKey, transitionEndFollow]);
2176 useEffect(() => {
2177 if (props.submittedMessageId !== null) {
2178 clearUserScrollSettle();
2179 userScrollSessionRef.current = false;
2180 transitionEndFollow({ type: "reset" });
2181 }
2182 }, [clearUserScrollSettle, props.submittedMessageId, transitionEndFollow]);
2183
2184 const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {
2185 const nextWidth = Math.round(event.nativeEvent.layout.width);
2186 const nextHeight = Math.round(event.nativeEvent.layout.height);
2187 setViewportWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
2188 setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current));
2189 }, []);
2190
2191 useEffect(() => {
2192 reportHeaderMaterialVisibility(false);
2193 }, [feedThreadKey, reportHeaderMaterialVisibility]);
2194
2195 const presentedFeed = useMemo(
2196 () =>
2197 appendPendingThreadMessages(
2198 deriveThreadFeedPresentation(
2199 props.feed,
2200 props.latestRun,
2201 expandedTurnIds,
2202 new Set(
2203 Object.entries(expandedWorkGroups)
2204 .filter(([, expanded]) => expanded)
2205 .map(([groupId]) => groupId),
2206 ),
2207 props.activeWorkStartedAt,
2208 props.runlessWorkActive ?? false,
2209 ),
2210 props.feed,
2211 props.queuedMessages,
2212 ),
2213 [
2214 props.queuedMessages,
2215 expandedTurnIds,
2216 expandedWorkGroups,
2217 props.activeWorkStartedAt,
2218 props.runlessWorkActive,
2219 props.feed,
2220 props.latestRun,
2221 ],
2222 );
2223 const setupAnchorIndex = presentedFeed.findIndex(
2224 (entry) => entry.type === "message" && entry.message.role === "user",
2225 );
2226 // The empty↔filled key below remounts the list and resets its imperative
2227 // content-inset override. Seed the fresh instance synchronously with the
2228 // current overlay height before the scroll integration's next reaction;
2229 // on Android the declarative contentInset floor covers this same window.
2230 // The thinking row a running thread shows while its messages load is not
2231 // content: the list must still remount, and so open at the end, when they
2232 // arrive.
2233 const listMountKey = `${feedThreadKey}:${presentedFeed.some((entry) => entry.type !== "thinking") ? "filled" : "empty"}`;
2234 useLayoutEffect(() => {
2235 const bottom = props.contentInsetEndAdjustment.value;
2236 if (bottom > 0) {
2237 props.listRef.current?.reportContentInset({ bottom });
2238 }
2239 }, [listMountKey, props.contentInsetEndAdjustment, props.listRef]);
2240
2241 const anchoredEndSpace = useMemo(
2242 () =>
2243 resolveChatListAnchoredEndSpace(
2244 presentedFeed,
2245 props.anchorMessageId,
2246 (entry) => (entry.type === "message" ? entry.id : null),
2247 { anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET },
2248 ),
2249 [presentedFeed, props.anchorMessageId, anchorTopInset],
2250 );
2251 const failedRunIds = useMemo(
2252 () => failedFeedRunIds(props.feed, props.latestRun),
2253 [props.feed, props.latestRun],
2254 );
2255 const terminalAssistantMessageIds = useMemo(() => {
2256 const terminalIdsByTurn = new Map<RunId, string>();
2257 for (const entry of props.feed) {
2258 if (entry.type === "message" && entry.message.role === "assistant" && entry.message.runId) {
2259 terminalIdsByTurn.set(entry.message.runId, entry.message.id);
2260 }
2261 }
2262 return new Set(terminalIdsByTurn.values());
2263 }, [props.feed]);
2264 useEffect(() => {
2265 const previous = previousLatestTurnRef.current;
2266 previousLatestTurnRef.current = props.latestRun;
2267 if (!props.latestRun || !previous) {
2268 return;
2269 }
2270 if (props.latestRun.runId === previous.runId) {
2271 if (previous.status === "running" && props.latestRun.status === "interrupted") {
2272 const interruptedTurnId = props.latestRun.runId;
2273 setInteractionState((current) => ({
2274 ...current,
2275 expandedTurnIds: new Set(current.expandedTurnIds).add(interruptedTurnId),
2276 }));
2277 }
2278 return;
2279 }
2280 setInteractionState((current) => {
2281 if (!current.expandedTurnIds.has(previous.runId)) {
2282 return current;
2283 }
2284 const next = new Set(current.expandedTurnIds);
2285 next.delete(previous.runId);
2286 return { ...current, expandedTurnIds: next };
2287 });
2288 }, [props.latestRun]);
2289
2290 useEffect(() => {
2291 return () => {
2292 if (copyFeedbackTimeoutRef.current) {
2293 clearTimeout(copyFeedbackTimeoutRef.current);
2294 }
2295 if (disclosureSettleFrameRef.current !== null) {
2296 cancelAnimationFrame(disclosureSettleFrameRef.current);
2297 }
2298 if (disclosureSettleSecondFrameRef.current !== null) {
2299 cancelAnimationFrame(disclosureSettleSecondFrameRef.current);
2300 }
2301 };
2302 }, []);
2303
2304 const settleDisclosureAfterLayout = useCallback(() => {
2305 if (disclosureSettleFrameRef.current !== null) {
2306 cancelAnimationFrame(disclosureSettleFrameRef.current);
2307 }
2308 if (disclosureSettleSecondFrameRef.current !== null) {
2309 cancelAnimationFrame(disclosureSettleSecondFrameRef.current);
2310 }
2311 disclosureSettleFrameRef.current = requestAnimationFrame(() => {
2312 disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
2313 // A disclosure can leave the reader above the end without a drag.
2314 // Reconcile follow before a later layout or resume can re-pin it.
2315 const listState = props.listRef.current?.getState();
2316 if (listState) {
2317 transitionEndFollow({
2318 type: "disclosure-settled",
2319 isAtEnd: listState.isAtEnd,
2320 userScrollSessionActive: userScrollSessionRef.current,
2321 });
2322 }
2323 disclosureAnchorKeyRef.current = null;
2324 setDisclosureToggleSettling(false);
2325 disclosureSettleFrameRef.current = null;
2326 disclosureSettleSecondFrameRef.current = null;
2327 });
2328 });
2329 }, [props.listRef, transitionEndFollow]);
2330
2331 const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => {
2332 disclosureAnchorKeyRef.current = anchorKey;
2333 setDisclosureToggleSettling(true);
2334 }, []);
2335
2336 // Start the quiet-frame countdown after React has committed the disclosure.
2337 // Every measured item-size change restarts it, so end maintenance cannot
2338 // wake between the data mutation and LegendList's final layout correction.
2339 useLayoutEffect(() => {
2340 if (disclosureAnchorKeyRef.current !== null) {
2341 settleDisclosureAfterLayout();
2342 }
2343 }, [expandedTurnIds, expandedWorkGroups, expandedWorkRows, settleDisclosureAfterLayout]);
2344
2345 const handleItemSizeChanged = useCallback(() => {
2346 if (disclosureAnchorKeyRef.current !== null) {
2347 settleDisclosureAfterLayout();
2348 }
2349 }, [settleDisclosureAfterLayout]);
2350
2351 const shouldRestoreVisibleContentPosition = useCallback((entry: ThreadFeedEntry) => {
2352 const disclosureAnchorKey = disclosureAnchorKeyRef.current;
2353 return disclosureAnchorKey === null || entry.id === disclosureAnchorKey;
2354 }, []);
2355
2356 const maintainVisibleContentPosition = useMemo(
2357 () => ({
2358 data: true,
2359 size: true,
2360 shouldRestorePosition: shouldRestoreVisibleContentPosition,
2361 }),
2362 [shouldRestoreVisibleContentPosition],
2363 );
2364
2365 const onCopyWorkRow = useCallback((rowId: string, value: string) => {
2366 copyTextWithHaptic(value, {
2367 target: "thread-work-row",
2368 feedback: "selection",
2369 });
2370 setInteractionState((current) => ({ ...current, copiedRowId: rowId }));
2371 if (copyFeedbackTimeoutRef.current) {
2372 clearTimeout(copyFeedbackTimeoutRef.current);
2373 }
2374 copyFeedbackTimeoutRef.current = setTimeout(() => {
2375 setInteractionState((current) =>
2376 current.copiedRowId === rowId ? { ...current, copiedRowId: null } : current,
2377 );
2378 copyFeedbackTimeoutRef.current = null;
2379 }, 1200);
2380 }, []);
2381
2382 const onToggleWorkGroup = useCallback(
2383 (groupId: string, anchorKey?: string) => {
2384 suspendEndScrollMaintenanceForDisclosure(anchorKey ?? `work-toggle:${groupId}`);
2385 setInteractionState((current) => ({
2386 ...current,
2387 expandedWorkGroups: {
2388 ...current.expandedWorkGroups,
2389 [groupId]: !(current.expandedWorkGroups[groupId] ?? false),
2390 },
2391 }));
2392 },
2393 [suspendEndScrollMaintenanceForDisclosure],
2394 );
2395
2396 const onToggleWorkRow = useCallback(
2397 (rowId: string, anchorKey?: string) => {
2398 suspendEndScrollMaintenanceForDisclosure(anchorKey ?? null);
2399 setInteractionState((current) => ({
2400 ...current,
2401 expandedWorkRows: {
2402 ...current.expandedWorkRows,
2403 [rowId]: !(current.expandedWorkRows[rowId] ?? false),
2404 },
2405 }));
2406 },
2407 [suspendEndScrollMaintenanceForDisclosure],
2408 );
2409
2410 const onToggleTurnFold = useCallback(
2411 (runId: RunId) => {
2412 suspendEndScrollMaintenanceForDisclosure(`run-fold:${runId}`);
2413 setInteractionState((current) => {
2414 const next = new Set(current.expandedTurnIds);
2415 if (next.has(runId)) {
2416 next.delete(runId);
2417 } else {
2418 next.add(runId);
2419 }
2420 return { ...current, expandedTurnIds: next };
2421 });
2422 },
2423 [suspendEndScrollMaintenanceForDisclosure],
2424 );
2425
2426 const onPressPreview = useCallback((source: FilePreviewSource) => {
2427 setExpandedFile((current) => current ?? source);
2428 }, []);
2429 const onPressVideo = useCallback(
2430 (attachment: ChatFileAttachment, sourceIdentifier: string) => {
2431 setExpandedVideo(
2432 (current) =>
2433 current ??
2434 attachmentVideoPreviewSource(props.environmentId, attachment, sourceIdentifier),
2435 );
2436 },
2437 [props.environmentId],
2438 );
2439
2440 // Rows whose height is known before they ever render. Without this, every
2441 // row above the viewport is assumed to be estimatedItemSize tall, and
2442 // scrolling up through unmeasured content corrects each row's height as it
2443 // mounts — the feed visibly jumps. Fixed sizes make the small chrome rows
2444 // exact; message rows stay undefined and use LegendList's per-type running
2445 // average once one of their type has been measured. Prominent v2 items,
2446 // expanded details, and compaction rows retain native measurement; their
2447 // cards and related-thread links can exceed the compact row height.
2448 const getFixedItemSize = useCallback(
2449 (entry: ThreadFeedEntry) => {
2450 if (entry.type === "html-render") {
2451 return htmlRenderRowHeight(htmlRenderFrameHeight(entry.render, contentWidth));
2452 }
2453 if (workRowSizing.fixedRowHeight === undefined) {
2454 return undefined;
2455 }
2456 switch (entry.type) {
2457 case "run-fold":
2458 return resolveThreadFeedFixedItemSize(entry.type);
2459 case "work-toggle":
2460 // A live thought wraps up to four lines, so that row measures itself.
2461 return entry.thought ? undefined : WORK_GROUP_TOGGLE_HEIGHT;
2462 case "thinking":
2463 return WORK_GROUP_TOGGLE_HEIGHT;
2464 case "activity-group":
2465 if (entry.activities[0]?.projectedItem.item.type === "subagent") {
2466 return undefined;
2467 }
2468 if (isContextCompactionActivityGroup(entry) || isContextHandoffActivityGroup(entry)) {
2469 return undefined;
2470 }
2471 if (
2472 entry.activities[0]?.groupedToolDetail &&
2473 entry.activities.every((activity) => activity.workEntry.itemType === "reasoning")
2474 ) {
2475 return undefined;
2476 }
2477 // Expanded rows append a variable detail block — fall back to
2478 // measurement for those groups.
2479 return entry.activities.some(
2480 (activity) => activity.prominent || expandedWorkRows[activity.id],
2481 )
2482 ? undefined
2483 : collapsedWorkLogHeight(entry.activities, entry.continuesWorkLog);
2484 default:
2485 return undefined;
2486 }
2487 },
2488 [contentWidth, expandedWorkRows, workRowSizing.fixedRowHeight],
2489 );
2490 // HTML render rows' fixed heights follow the width, so a rotation or split
2491 // resize drops the cached sizes, as a text-size change does.
2492 const hasHtmlRenders = useMemo(
2493 () => presentedFeed.some((entry) => entry.type === "html-render"),
2494 [presentedFeed],
2495 );
2496 const previousContentWidth = useRef(contentWidth);
2497 useLayoutEffect(() => {
2498 if (previousContentWidth.current === contentWidth) return;
2499 previousContentWidth.current = contentWidth;
2500 if (hasHtmlRenders) props.listRef.current?.clearCaches({ mode: "sizes" });
2501 }, [contentWidth, hasHtmlRenders, props.listRef]);
2502
2503 // Disclosures can mount existing offscreen rows as well as new work rows.
2504 // Fade those in after movement; never retain removed rows over replacements.
2505 const renderItem = useCallback(
2506 (info: { item: PendingThreadFeedEntry; index: number }) => (
2507 <Animated.View
2508 key={info.item.id}
2509 entering={disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined}
2510 >
2511 <MessageImageGalleryScope
2512 entry={info.item}
2513 environmentId={props.environmentId}
2514 threadId={props.threadId}
2515 workspaceRoot={props.workspaceRoot}
2516 >
2517 <ThreadMediaVisibility>
2518 {renderFeedEntry(info, {
2519 environmentId: props.environmentId,
2520 dispatchingMessageId: props.dispatchingMessageId,
2521 onEditPendingMessage: props.onEditPendingMessage,
2522 onUseArtifactTemplate: props.onUseArtifactTemplate,
2523 threadId: props.threadId,
2524 copiedRowId,
2525 expandedWorkRows,
2526 workRowSizing,
2527 workGroupScrollPositions,
2528 terminalAssistantMessageIds,
2529 unsettledTurnId,
2530 failedRunIds,
2531 onCopyWorkRow,
2532 onToggleWorkGroup,
2533 onToggleWorkRow,
2534 onToggleTurnFold,
2535 onPressPreview,
2536 onPressVideo,
2537 markdownLinkHandlers,
2538 renderMarkdownImage,
2539 renderViewedImage,
2540 renderMarkdown,
2541 iconSubtleColor,
2542 screenColor,
2543 userBubbleColor,
2544 markdownStyles,
2545 reviewCommentColors,
2546 reviewCommentBubbleWidth,
2547 themeAppearance,
2548 usesNativeWorkspaceColumns,
2549 userBubbleMaxWidth,
2550 markdownContentWidth,
2551 contentWidth,
2552 threadTitle: props.threadTitle,
2553 skills: props.skills,
2554 workspaceRoot: props.workspaceRoot,
2555 })}
2556 {props.worktreeSetup && info.index === setupAnchorIndex ? (
2557 <WorktreeSetupCard key={props.threadId} {...props.worktreeSetup} />
2558 ) : props.setupWorkingStartedAt && info.index === setupAnchorIndex ? (
2559 <WorktreeWorkingHeader startedAt={props.setupWorkingStartedAt} />
2560 ) : null}
2561 </ThreadMediaVisibility>
2562 </MessageImageGalleryScope>
2563 </Animated.View>
2564 ),
2565 [
2566 props.worktreeSetup,
2567 props.setupWorkingStartedAt,
2568 props.threadId,
2569 setupAnchorIndex,
2570 props.dispatchingMessageId,
2571 props.onEditPendingMessage,
2572 copiedRowId,
2573 disclosureToggleSettling,
2574 expandedWorkRows,
2575 workRowSizing,
2576 workGroupScrollPositions,
2577 terminalAssistantMessageIds,
2578 unsettledTurnId,
2579 failedRunIds,
2580 iconSubtleColor,
2581 screenColor,
2582 userBubbleColor,
2583 markdownStyles,
2584 reviewCommentColors,
2585 reviewCommentBubbleWidth,
2586 themeAppearance,
2587 usesNativeWorkspaceColumns,
2588 userBubbleMaxWidth,
2589 markdownContentWidth,
2590 contentWidth,
2591 onCopyWorkRow,
2592 markdownLinkHandlers,
2593 onPressPreview,
2594 onPressVideo,
2595 onToggleTurnFold,
2596 onToggleWorkGroup,
2597 onToggleWorkRow,
2598 props.environmentId,
2599 props.onUseArtifactTemplate,
2600 props.threadId,
2601 props.threadTitle,
2602 props.skills,
2603 props.workspaceRoot,
2604 renderMarkdownImage,
2605 renderViewedImage,
2606 renderMarkdown,
2607 ],
2608 );
2609
2610 if (props.contentPresentation.kind === "unavailable" && props.queuedMessages.length === 0) {
2611 return (
2612 <ThreadFeedPlaceholder
2613 title={props.contentPresentation.title}
2614 detail={props.contentPresentation.detail}
2615 topInset={topContentInset}
2616 bottomInset={bottomContentInset}
2617 horizontalPadding={horizontalPadding}
2618 />
2619 );
2620 }
2621
2622 return (
2623 <PresentationSource identifier={fileShareSourceIdentifier} style={{ flex: 1 }}>
2624 <View className="flex-1" onLayout={handleViewportLayout}>
2625 <View className="flex-1">
2626 <KeyboardAwareLegendList
2627 ref={props.listRef}
2628 // The empty↔filled key remounts the list when messages first
2629 // arrive. LegendList's maintainScrollAtEnd calls scrollToEnd(),
2630 // which is blind to UIKit's adjustedContentInset — inserting into
2631 // an already-attached list under a transparent header can pin
2632 // short content at offset 0 (one header-height too high). A fresh
2633 // mount positions during attach, where UIKit applies the inset.
2634 key={listMountKey}
2635 style={{ flex: 1 }}
2636 // RN 0.81+ drops touches inside the contentInset area
2637 // (facebook/react-native#54123); the anchored end space after a send
2638 // is pure inset, so without this the blank region can't be scrolled.
2639 applyWorkaroundForContentInsetHitTestBug
2640 // Horizontal Duo reservations are already included in the row padding.
2641 // Let UIKit adjust the scrolling axis without shifting content sideways.
2642 contentInsetAdjustmentBehavior={
2643 usesNativeAutomaticInsets
2644 ? usesNativeWorkspaceColumns &&
2645 Platform.OS === "ios" &&
2646 !Platform.isPad &&
2647 props.layoutVariant === "split"
2648 ? "scrollableAxes"
2649 : "automatic"
2650 : "never"
2651 }
2652 automaticallyAdjustsScrollIndicatorInsets={usesNativeAutomaticInsets}
2653 {...(usesNativeAutomaticInsets
2654 ? {
2655 // Do NOT pass a manual `contentInset` here. Like the Home
2656 // ScrollView, we rely purely on `contentInsetAdjustmentBehavior:
2657 // "automatic"` so UIKit derives the top inset from the transparent
2658 // header. A manual contentInset (which LegendList consumes into its
2659 // own layout math) collapses the scroll view's adjustedContentInset
2660 // top to 0, leaving the iOS 26/27 scroll-edge effect no region to
2661 // render into — which is why the header blur was missing on threads.
2662 scrollIndicatorInsets: { top: 0, left: 0, right: 0, bottom: 0 },
2663 }
2664 : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}
2665 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
2666 // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch):
2667 // lets its scroll math clamp programmatic scrolls to -headerInset
2668 // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short
2669 // content rest below the transparent header rather than at frame top.
2670 contentInsetStartAdjustment={usesNativeAutomaticInsets ? anchorTopInset : 0}
2671 contentInsetEndAdjustment={props.contentInsetEndAdjustment}
2672 // UIKit's automatic behavior adds the safe-area bottom on top of the
2673 // raw contentInset the keyboard integration writes. The detail screen
2674 // under-reports the composer inset by this amount (see
2675 // ThreadDetailScreen); this tells LegendList's scroll math about the
2676 // extra so programmatic end scrolls land at the true resting offset.
2677 contentInsetEndStaticAdjustment={usesNativeAutomaticInsets ? insets.bottom : 0}
2678 // Android's initial end scroll can run before the keyboard integration
2679 // reports the composer height. Seed that estimate for LegendList's
2680 // scroll math until the first reported inset replaces it.
2681 {...(initialContentInset ? { contentInset: initialContentInset } : {})}
2682 // The keyboard integration's offset math (end pinning, max scroll)
2683 // must add the same UIKit-added extra, or its keyboard-open end
2684 // targets land one safe-area short of the true resting offset.
2685 adjustedInsetCompensation={usesNativeAutomaticInsets ? insets.bottom : 0}
2686 freeze={props.freeze}
2687 // Follow the measured end immediately. Animating toward an estimated
2688 // end races row measurement when a pending message is acknowledged.
2689 maintainScrollAtEnd={
2690 disclosureToggleSettling || !endFollowEnabled
2691 ? false
2692 : {
2693 animated: false,
2694 on: {
2695 dataChange: true,
2696 itemLayout: true,
2697 layout: true,
2698 },
2699 }
2700 }
2701 maintainVisibleContentPosition={
2702 endFollowEnabled && !disclosureToggleSettling ? false : maintainVisibleContentPosition
2703 }
2704 data={presentedFeed}
2705 extraData={listAppearanceData}
2706 renderItem={renderItem}
2707 viewabilityConfig={THREAD_MEDIA_VIEWABILITY_CONFIG}
2708 keyExtractor={(entry) => entry.id}
2709 getItemType={(entry) =>
2710 entry.type === "message" ? `message:${entry.message.role}` : entry.type
2711 }
2712 getFixedItemSize={getFixedItemSize}
2713 // Virtualized rows must move with their measurements. Native layout
2714 // transitions can retain stale positions during sync, even at duration 0.
2715 onItemSizeChanged={handleItemSizeChanged}
2716 // Measure rows well before they scroll into view so estimate→actual
2717 // corrections land offscreen instead of under the user's finger.
2718 drawDistance={500}
2719 keyboardShouldPersistTaps="always"
2720 keyboardDismissMode="none"
2721 keyboardLiftBehavior="whenAtEnd"
2722 // Seed the list's scroll math with the real viewport before its own
2723 // onLayout: the empty→filled remount can then tell at mount that
2724 // short content underflows the viewport and skip programmatic
2725 // positioning entirely (any offset write during screen attach races
2726 // UIKit's adjustedContentInset application and lands high or low).
2727 {...(viewportHeight > 0 && viewportWidth > 0
2728 ? { estimatedListSize: { height: viewportHeight, width: viewportWidth } }
2729 : {})}
2730 // RN's native scrollTo command clamps targets to a floor of
2731 // -contentInset.top using the RAW inset — under automatic insets the
2732 // header inset only exists in adjustedContentInset, so scrolls to
2733 // negative offsets (content top below the transparent header) get
2734 // clamped to 0. This prop disables that clamp; UIKit still bounces
2735 // user overscroll back to the adjusted rest position.
2736 scrollToOverflowEnabled
2737 estimatedItemSize={180}
2738 // Chat-style bottom alignment: when a thread is shorter than the
2739 // viewport, pad above the content so messages rest just above the
2740 // composer instead of under the header. No effect on threads that
2741 // overflow the viewport (the padding clamps to zero).
2742 alignItemsAtEnd
2743 initialScrollAtEnd
2744 onScroll={handleScroll}
2745 onScrollBeginDrag={handleScrollBeginDrag}
2746 onScrollEndDrag={handleScrollEndDrag}
2747 onMomentumScrollBegin={handleMomentumScrollBegin}
2748 onMomentumScrollEnd={handleMomentumScrollEnd}
2749 scrollEventThrottle={16}
2750 ListHeaderComponent={
2751 <>
2752 {usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />}
2753 {setupAnchorIndex < 0 && props.worktreeSetup ? (
2754 <WorktreeSetupCard key={props.threadId} {...props.worktreeSetup} />
2755 ) : null}
2756 {props.historyControls ? (
2757 <ThreadFeedLoadEarlierControl {...props.historyControls} />
2758 ) : null}
2759 </>
2760 }
2761 ListFooterComponent={
2762 props.pendingHandoff ? (
2763 <ThreadHandoffDivider
2764 environmentId={props.environmentId}
2765 endpoints={props.pendingHandoff}
2766 iconColor={iconSubtleColor}
2767 label="Context handoff pending"
2768 />
2769 ) : null
2770 }
2771 contentContainerStyle={{
2772 paddingTop: 12,
2773 paddingLeft: contentHorizontalPadding + contentLeftInset,
2774 paddingRight: contentHorizontalPadding + contentRightInset,
2775 }}
2776 />
2777 </View>
2778 {presentedFeed.length === 0 &&
2779 !props.worktreeSetup &&
2780 props.activeWorkStartedAt === null &&
2781 props.contentPresentation.kind === "ready" ? (
2782 <View
2783 pointerEvents="none"
2784 style={[StyleSheet.absoluteFill, { left: contentLeftInset, right: contentRightInset }]}
2785 >
2786 <ThreadFeedPlaceholder
2787 title="No conversation yet"
2788 detail="Ask the agent to inspect the repo, run a command, or continue the active thread."
2789 topInset={topContentInset}
2790 bottomInset={bottomContentInset}
2791 horizontalPadding={horizontalPadding}
2792 />
2793 </View>
2794 ) : null}
2795 <VideoPreviewModal source={expandedVideo} onRequestClose={() => setExpandedVideo(null)} />
2796 <FilePreviewModal source={expandedFile} onRequestClose={() => setExpandedFile(null)} />
2797 </View>
2798 </PresentationSource>
2799 );
2800});
2801
2802function ThreadFeedLoadEarlierControl(props: ThreadFeedHistoryControls) {
2803 const theme = useUniwindTheme();
2804 const mutedColor = theme["--color-icon-subtle"];
2805 const accentColor = theme["--color-primary"];
2806 if (!props.hasMoreHistory && props.error === null) {
2807 return null;
2808 }
2809 return (
2810 <View className="mb-3 items-center gap-1.5 px-2">
2811 {props.hasMoreHistory ? (
2812 <Pressable
2813 accessibilityRole="button"
2814 accessibilityLabel="Load earlier activity"
2815 disabled={props.loading}
2816 onPress={props.onLoadEarlier}
2817 className="min-h-9 flex-row items-center justify-center gap-2 rounded-full border border-border/60 bg-card/80 px-4 py-2 disabled:opacity-50"
2818 >
2819 {props.loading ? (
2820 <AppActivityIndicator size="small" color={accentColor} />
2821 ) : (
2822 <SymbolView name="chevron.up" size={12} tintColor={accentColor} type="monochrome" />
2823 )}
2824 <Text className="text-sm font-medium text-foreground">
2825 {props.loading ? "Loading earlier activity…" : "Load earlier activity"}
2826 </Text>
2827 </Pressable>
2828 ) : null}
2829 {props.error !== null ? (
2830 <Text className="text-center text-xs" style={{ color: mutedColor }}>
2831 {props.error}
2832 </Text>
2833 ) : null}
2834 </View>
2835 );
2836}
2837
2838function MessageImageGalleryScope(props: {
2839 entry: PendingThreadFeedEntry;
2840 environmentId: EnvironmentId;
2841 threadId: ThreadId;
2842 workspaceRoot: string | null | undefined;
2843 children: ReactNode;
2844}) {
2845 const { entry, environmentId, threadId, workspaceRoot } = props;
2846 const images = useMemo(
2847 () =>
2848 entry.type === "message"
2849 ? [
2850 ...messageImageGallery(entry.message, { environmentId, threadId, workspaceRoot }),
2851 ...composerImageGallery(entry.pendingMessage?.attachments ?? []),
2852 ]
2853 : [],
2854 [entry, environmentId, threadId, workspaceRoot],
2855 );
2856 return <FilePreviewGallery value={images}>{props.children}</FilePreviewGallery>;
2857}