| 1 | import { useOpenPullRequest } from "../../native/useOpenPullRequest"; |
| 2 | import { ThreadMarkdownVideo, ThreadMediaVisibleContext } from "./ThreadMarkdownVideo"; |
| 3 | import { useMarkdownImageSource } from "../../native/useMarkdownImageSource"; |
| 4 | import { enrichedContextLinkPresentation } from "../../lib/enrichedLinkPresentation"; |
| 5 | import { ThreadContextDivider } from "./thread-context-divider"; |
| 6 | import { ThreadHandoffDivider, ThreadHandoffRow } from "./thread-handoff-row"; |
| 7 | import { SecretRequestCard } from "./SecretRequestCard"; |
| 8 | import { AppActivityIndicator } from "../../components/AppActivityIndicator"; |
| 9 | import { |
| 10 | WorktreeWorkingHeader, |
| 11 | WorktreeSetupCard, |
| 12 | type WorktreeSetupCardProps, |
| 13 | } from "./worktree-setup-card"; |
| 14 | import * as Haptics from "expo-haptics"; |
| 15 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; |
| 16 | import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; |
| 17 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 18 | import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff"; |
| 19 | import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; |
| 20 | import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; |
| 21 | import { |
| 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"; |
| 32 | import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations"; |
| 33 | import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; |
| 34 | import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; |
| 35 | import { PROMPT_LINK_MAX_PROMPT_LENGTH } from "@t3tools/shared/promptLinks"; |
| 36 | import { |
| 37 | parseComposerContextHref, |
| 38 | collectComposerContextReferences, |
| 39 | replaceComposerContextReferences, |
| 40 | formatComposerContextReference, |
| 41 | } from "@t3tools/shared/composerContextReferences"; |
| 42 | import { ComposerContextSheet } from "../../components/ComposerContextSheet"; |
| 43 | import { writeComposerContextClipboard } from "../../lib/composerContextClipboard"; |
| 44 | import { |
| 45 | codexArtifactTemplatePresentationLabel, |
| 46 | type CodexArtifactTemplate, |
| 47 | } from "@t3tools/shared/codexArtifactTemplates"; |
| 48 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; |
| 49 | import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; |
| 50 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; |
| 51 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 52 | import { |
| 53 | classifyMarkdownImageSource, |
| 54 | markdownImageSourceFragment, |
| 55 | } from "@t3tools/client-runtime/markdown-images"; |
| 56 | import { resolveViewedImageAsset } from "@t3tools/client-runtime/work-log/presentation"; |
| 57 | import { |
| 58 | renderCodexInlineDirectivesAsMarkdown, |
| 59 | splitCodexArtifactTemplateMarkdown, |
| 60 | } from "@t3tools/shared/codexMarkdownDirectives"; |
| 61 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 62 | import { imageMimeType } from "@t3tools/shared/image"; |
| 63 | import { videoMimeType } from "@t3tools/shared/video"; |
| 64 | import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; |
| 65 | import { HeaderHeightContext } from "@react-navigation/elements"; |
| 66 | import { useFocusEffect, useNavigation } from "@react-navigation/native"; |
| 67 | import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns"; |
| 68 | import { |
| 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"; |
| 82 | import { |
| 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"; |
| 98 | import { Image as ExpoImage } from "expo-image"; |
| 99 | import { useResolveClassNames } from "uniwind"; |
| 100 | import { useFilePreviewGallery, FilePreviewGallery } from "../../components/FilePreviewGallery"; |
| 101 | import { assetImageSource } from "../../lib/assetImageSource"; |
| 102 | import { composerImageGallery } from "../../lib/composerImageGallery"; |
| 103 | import { messageImageGallery } from "./messageImageGallery"; |
| 104 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; |
| 105 | import { isPdfFile } from "../../lib/filePreview"; |
| 106 | import { flattenThemeColor } from "../../lib/mobileTheme"; |
| 107 | import { PresentationSource } from "../../components/NativePresentation"; |
| 108 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 109 | import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated"; |
| 110 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 111 | import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; |
| 112 | import { useFontFamily } from "../../lib/useFontFamily"; |
| 113 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 114 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 115 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; |
| 116 | import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; |
| 117 | import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; |
| 118 | import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; |
| 119 | import { |
| 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 | |
| 129 | import { AppText as Text } from "../../components/AppText"; |
| 130 | import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; |
| 131 | import { VideoAttachmentTile } from "../../components/VideoAttachmentTile"; |
| 132 | import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; |
| 133 | import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; |
| 134 | import { |
| 135 | attachmentVideoPreviewSource, |
| 136 | mediaVideoPreviewUri, |
| 137 | mediaVideoThumbnailKey, |
| 138 | type MediaVideoPreviewSource, |
| 139 | } from "../../lib/videoPreviewSource"; |
| 140 | import { CopyTextButton } from "../../components/CopyTextButton"; |
| 141 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 142 | import { copyPromptLink } from "../prompt-links/prompt-link-actions"; |
| 143 | import { parseReviewCommentMessageSegments } from "../review/reviewCommentSelection"; |
| 144 | import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter"; |
| 145 | import { |
| 146 | ReviewCommentCard, |
| 147 | useReviewCommentColors, |
| 148 | type ReviewCommentColors, |
| 149 | } from "../review/ReviewCommentCard"; |
| 150 | import { cn } from "../../lib/cn"; |
| 151 | import { |
| 152 | deriveCenteredContentHorizontalPadding, |
| 153 | deriveThreadFeedInitialContentInset, |
| 154 | deriveThreadWorkLogSizing, |
| 155 | type LayoutVariant, |
| 156 | } from "../../lib/layout"; |
| 157 | import { uuidv4 } from "../../lib/uuid"; |
| 158 | import { |
| 159 | resolveMarkdownFontSizes, |
| 160 | resolveNativeMarkdownTypography, |
| 161 | } from "../../lib/appearancePreferences"; |
| 162 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 163 | import { markdownFileIconSource } from "../../lib/markdownFileIcons"; |
| 164 | import { PierreEntryIcon } from "../../components/PierreEntryIcon"; |
| 165 | import { markdownLinkIconSource } from "../../lib/markdownLinkIcons"; |
| 166 | import { |
| 167 | normalizeNativeMarkdownUrl, |
| 168 | resolveMarkdownInlineCodePresentation, |
| 169 | resolveMarkdownLinkIcon, |
| 170 | resolveMarkdownLinkPresentation, |
| 171 | } from "../../lib/markdownLinks"; |
| 172 | import { |
| 173 | failedFeedRunIds, |
| 174 | deriveThreadFeedPresentation, |
| 175 | threadFeedRunIsUnsettled, |
| 176 | isContextCompactionActivityGroup, |
| 177 | isContextHandoffActivityGroup, |
| 178 | isSecretRequestActivityGroup, |
| 179 | type ThreadFeedEntry, |
| 180 | type ThreadFeedLatestRun, |
| 181 | } from "../../lib/threadActivity"; |
| 182 | import type { ThreadContentPresentation } from "./threadContentPresentation"; |
| 183 | import { |
| 184 | resolveThreadFeedLiveFollow, |
| 185 | type ThreadFeedLiveFollowEvent, |
| 186 | type ThreadWorkGroupScrollPosition, |
| 187 | } from "./thread-feed-live-follow"; |
| 188 | import { |
| 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"; |
| 197 | import { appendPendingThreadMessages, type PendingThreadFeedEntry } from "./pending-thread-feed"; |
| 198 | import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; |
| 199 | import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size"; |
| 200 | import { htmlRenderFrameHeight } from "@t3tools/shared/htmlRender"; |
| 201 | import { htmlRenderRowHeight, ThreadHtmlRender } from "./HtmlRenderWebView"; |
| 202 | import { ThreadMcpApp } from "./McpAppWebView"; |
| 203 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; |
| 204 | import { |
| 205 | assetEnvironment, |
| 206 | useAssetUrl, |
| 207 | useAssetUrlState, |
| 208 | useRefreshAssetUrl, |
| 209 | } from "../../state/assets"; |
| 210 | import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; |
| 211 | import { usePreparedConnection } from "../../state/session"; |
| 212 | import { useLiveThreadLinkLabels } from "../../state/entities"; |
| 213 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 214 | import { composerDocumentAttachmentRecord } from "../../lib/composerContext"; |
| 215 | import * as Option from "effect/Option"; |
| 216 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 217 | import { environmentThreadShells, threadEnvironment } from "../../state/threads"; |
| 218 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 219 | import { useV2ItemSupport } from "../../state/v2-item-support"; |
| 220 | import { |
| 221 | fileRoutePathSegments, |
| 222 | isAbsolutePath, |
| 223 | resolveWorkspaceRelativeFilePath, |
| 224 | } from "../files/filePath"; |
| 225 | import { waitForThreadShellReady } from "./threadForkNavigation"; |
| 226 | import { resolveUserMessageIntentBadge } from "./userMessageIntentBadge"; |
| 227 | import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu"; |
| 228 | import { useFileChipShare } from "./useFileChipShare"; |
| 229 | import { |
| 230 | MarkdownImageAvailableWidthContext, |
| 231 | ThreadMarkdownImage, |
| 232 | ThreadMarkdownImageUnavailable, |
| 233 | ThreadMarkdownImageView, |
| 234 | } from "./ThreadMarkdownImage"; |
| 235 | |
| 236 | const 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 | |
| 244 | const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { |
| 245 | hour: "numeric", |
| 246 | minute: "2-digit", |
| 247 | }); |
| 248 | function 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. |
| 258 | const 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. |
| 261 | const USER_BUBBLE_HORIZONTAL_PADDING = 3.5 * 3.5; |
| 262 | const ASSISTANT_ROW_HORIZONTAL_PADDING = 3.5; |
| 263 | // Let neighboring rows move out of the new rows' space before showing their text. |
| 264 | const 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. |
| 271 | const FRESH_ENTRY_WINDOW_MS = 3_000; |
| 272 | function isFreshTimestamp(input: string): boolean { |
| 273 | const timestamp = Date.parse(input); |
| 274 | return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ENTRY_WINDOW_MS; |
| 275 | } |
| 276 | |
| 277 | export interface ThreadFeedHistoryControls { |
| 278 | readonly hasMoreHistory: boolean; |
| 279 | readonly loading: boolean; |
| 280 | readonly error: string | null; |
| 281 | readonly onLoadEarlier: () => void; |
| 282 | } |
| 283 | |
| 284 | export 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 | |
| 320 | async 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 | |
| 330 | const 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 | */ |
| 338 | function 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 | |
| 369 | function 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 | |
| 443 | function 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. |
| 521 | function 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 | |
| 527 | function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAttachment { |
| 528 | return attachment.type === "file"; |
| 529 | } |
| 530 | |
| 531 | function 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 | */ |
| 716 | function 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. |
| 728 | const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 }; |
| 729 | |
| 730 | function 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 | |
| 738 | interface 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 | |
| 747 | interface MarkdownStyleSets { |
| 748 | readonly user: MarkdownStyleSet; |
| 749 | readonly assistant: MarkdownStyleSet; |
| 750 | } |
| 751 | |
| 752 | interface MarkdownStyleSet { |
| 753 | readonly nativeTextStyle: NativeMarkdownTextStyle; |
| 754 | } |
| 755 | |
| 756 | const 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 | |
| 772 | function 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 | |
| 816 | const 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 | |
| 854 | function 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 | |
| 956 | function 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 | |
| 986 | function 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 | |
| 1490 | type 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 | |
| 1501 | function 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 | |
| 1610 | function 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 | |
| 1670 | function 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 | |
| 1699 | export 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 | |
| 2802 | function 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 | |
| 2838 | function 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 | } |