import { Button } from "../../components/ui/Button"; import * as Dialog from "../../components/ui/Dialog"; import { useOpenPullRequest } from "../../native/useOpenPullRequest"; import { useWorkspaceSettledContentWidth } from "../layout/workspace-content-width"; import { ThreadMarkdownVideo, ThreadMediaVisibleContext } from "./ThreadMarkdownVideo"; import { useMarkdownImageSource } from "../../native/useMarkdownImageSource"; import { enrichedContextLinkCustomization, enrichedThreadLinkCustomization, withChipIcon, } from "../../native/useEnrichedLinkVariants"; import { enrichedSkillDisplayName, enrichedSkillInstructionsPath, enrichedSkillReference, } from "../../lib/enrichedLinkPresentation"; import { ThreadContextDivider } from "./thread-context-divider"; import { ThreadHandoffDivider, ThreadHandoffRow, ThreadModelSwitchDivider, } from "./thread-handoff-row"; import { GuestBubbleTint, GuestMessageName } from "./guestMessageStyle"; import { ThreadGuestPendingMessage, useThreadGuestShare } from "./ThreadGuestShareBar"; import { LinkRequestCard } from "./LinkRequestCard"; import { SecretRequestCard } from "./SecretRequestCard"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { WorktreeWorkingHeader, WorktreeSetupCard, type WorktreeSetupCardProps, } from "./worktree-setup-card"; import * as Haptics from "expo-haptics"; import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff"; import { IDLE_COMPACTING_LABELS, resolveUserMessageFileCards, resolveUserMessagePresentation, } from "@t3tools/client-runtime/user-message"; import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; import type { WorkflowOpening } from "@t3tools/client-runtime/state/subagentRuntime"; import { AuthFilesystemReadScope, idleCompactionOf, type OrchestrationMessageContext, ThreadId, type ChatAttachment, type ChatFileAttachment, type ChatImageAttachment, type EnvironmentId, type MessageId, type OrchestrationV2ProjectedTurnItem, type RunId, type ScheduledTaskId, type ScopedThreadRef, } from "@t3tools/contracts"; import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; import { PROMPT_LINK_MAX_PROMPT_LENGTH } from "@t3tools/shared/promptLinks"; import { parseComposerContextHref, collectComposerContextReferences, replaceComposerContextReferences, formatComposerContextReference, } from "@t3tools/shared/composerContextReferences"; import { ComposerContextSheet } from "../../components/ComposerContextSheet"; import { writeComposerContextClipboard } from "../../lib/composerContextClipboard"; import { codexArtifactTemplatePresentationLabel, type CodexArtifactTemplate, } from "@t3tools/shared/codexArtifactTemplates"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; import { executeAtomQuery, settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { classifyMarkdownImageSource, markdownImageSourceFragment, } from "@t3tools/client-runtime/markdown-images"; import { resolveViewedImageAsset } from "@t3tools/client-runtime/work-log/presentation"; import { renderCodexInlineDirectivesAsMarkdown, splitCodexArtifactTemplateMarkdown, } from "@t3tools/shared/codexMarkdownDirectives"; import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { imageMimeType } from "@t3tools/shared/image"; import { videoMimeType } from "@t3tools/shared/video"; import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol"; import { HeaderHeightContext } from "@react-navigation/elements"; import { useFocusEffect, useNavigation } from "@react-navigation/native"; import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns"; import { useDeviceType } from "../../native/native-layout-metrics"; import { isDuoDevice } from "../../lib/device-type"; import { memo, useCallback, useContext, useEffect, useEffectEvent, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useId, type ComponentPropsWithRef, type ReactNode, type Ref, type RefObject, } from "react"; import { Image, Platform, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent, Pressable, StyleSheet, type ColorValue, useWindowDimensions, View, } from "react-native"; import { Image as ExpoImage } from "expo-image"; import { useResolveClassNames } from "uniwind"; import { useFilePreviewGallery, FilePreviewGallery } from "../../components/FilePreviewGallery"; import { assetImageSource } from "../../lib/assetImageSource"; import { composerImageGallery } from "../../lib/composerImageGallery"; import { isFileBackedComposerAttachment } from "../../lib/composerImages"; import { useLocalAttachmentPreview } from "../../lib/localAttachmentPreview"; import { messageImageGallery } from "./messageImageGallery"; import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { inlineFilePreviewKind, isPdfFile } from "../../lib/filePreview"; import { ThreadInlineFilePreview } from "./ThreadInlineFilePreview"; import { flattenThemeColor } from "../../lib/mobileTheme"; import { PresentationSource } from "../../components/ui/NativePresentation"; import { NativeScrollViewMarker } from "../../native/NativeScrollViewMarker"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { FadeIn, FadeInDown, type SharedValue } from "react-native-reanimated"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { FONT_FAMILIES } from "../../lib/fontFamilies"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; import { splitMarkdownDetails } from "../../lib/markdownDetails"; import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { SelectableMarkdownText, type MarkdownFileContextMenu, type MarkdownImageRenderer, type MarkdownImageSourceResolver, type MarkdownLinkCustomization, type NativeMarkdownTextStyle, type SelectableMarkdownSkill, } from "../../native/SelectableMarkdownText"; import { AppText as Text } from "../../components/ui/AppText"; import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; import { markdownFileEmbedLabel, resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; import { attachmentVideoPreviewSource } from "../../lib/videoPreviewSource"; import { CopyTextButton } from "./CopyTextButton"; import { getReviewSectionIdForCheckpoint } from "../review/reviewModel"; import { setReviewSelectedSectionId } from "../review/reviewState"; import { Menu } from "../../components/ui/Menu"; import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; import { copyPromptLink } from "../prompt-links/prompt-link-actions"; import { parseReviewCommentMessageSegments } from "../review/reviewCommentSelection"; import { ReviewCommentCard, useReviewCommentColors, type ReviewCommentColors, } from "../review/ReviewCommentCard"; import { cn } from "../../lib/cn"; import { deriveCenteredContentHorizontalPadding, deriveThreadFeedInitialContentInset, deriveThreadWorkLogSizing, type LayoutVariant, } from "../../lib/layout"; import { uuidv4 } from "../../lib/uuid"; import { resolveNativeMarkdownTypography } from "../../lib/appearancePreferences"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; import { normalizeNativeMarkdownUrl, resolveMarkdownLinkPresentation, } from "../../lib/markdownLinks"; import { createThreadFeedCache, threadFeedCompactionLabels, failedFeedRunIds, deriveThreadFeedPresentation, threadFeedRunIsUnsettled, isContextCompactionActivityGroup, isContextHandoffActivityGroup, isLinkRequestActivityGroup, isSecretRequestActivityGroup, type ThreadFeedEntry, type ThreadFeedLatestRun, messageFoldRunId, } from "../../lib/threadActivity"; import type { ThreadContentPresentation } from "./threadContentPresentation"; import { resolveThreadFeedLiveFollow, resolveThreadFeedReflowAnchor, resolveThreadFeedReflowOffset, resolveThreadFeedReflowScroll, type ThreadFeedLiveFollowEvent, type ThreadFeedReflowAnchor, type ThreadWorkGroupScrollPosition, } from "./thread-feed-live-follow"; import { collapsedWorkLogHeight, ThreadDisclosureChevron, THREAD_DISCLOSURE_TRANSITION_MS, ThreadWorkGroupToggle, ThreadThinkingRow, ThreadWorkLog, WORK_GROUP_TOGGLE_HEIGHT, } from "./thread-work-log"; import { appendGuestPendingMessages, appendPendingThreadMessages, createThreadFeedGrouping, isCompactionCommandFeedEntry, type GroupedThreadFeedRow, type ThreadFeedRow, } from "./pending-thread-feed"; import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size"; import { htmlRenderFrameHeight } from "@t3tools/shared/htmlRender"; import { htmlRenderRowHeight, ThreadHtmlRender } from "./HtmlRenderWebView"; import { ThreadMcpApp } from "./McpAppWebView"; import { assetEnvironment, useAssetUrl, useRefreshAssetUrl } from "../../state/assets"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { readEnvironmentScope, usePreparedConnection } from "../../state/session"; import { projectEnvironment } from "../../state/projects"; import { needsWorkspaceBasenameLookup, pickWorkspaceBasenameMatch, WORKSPACE_BASENAME_LOOKUP_LIMIT, } from "@t3tools/client-runtime/workspace-basename-lookup"; import { resolveThreadLink, useLiveThreadLinkLabels, useQuietThreadShell, useThreadChipIcons, } from "../../state/entities"; import { useThreadSelection } from "../../state/use-thread-selection"; import { useEnvironmentQuery } from "../../state/query"; import { serverEnvironment } from "../../state/server"; import { composerDocumentAttachmentRecord } from "../../lib/composerContext"; import * as Option from "effect/Option"; import { appAtomRegistry } from "../../state/atom-registry"; import { environmentThreadShells, threadEnvironment } from "../../state/threads"; import { ThreadWorkflowOpening, useWorkflowOpening } from "./thread-workflow-opening"; import { useAtomCommand } from "../../state/use-atom-command"; import { useV2ItemSupport } from "../../state/v2-item-support"; import { fileRoutePathSegments, resolveFileLinkPath, resolveWorkspaceFilePath, resolveWorkspaceRelativeFilePath, } from "../files/filePath"; import { waitForThreadShellReady } from "./threadForkNavigation"; import { resolveUserMessageIntentBadge } from "./userMessageIntentBadge"; import { fileChipClipboardPath, fileChipMenu, resolveFileChipTarget } from "./fileChipMenu"; import { useFileChipShare } from "./useFileChipShare"; import { useOpenLinkedThread } from "./useOpenLinkedThread"; import { MarkdownImageAvailableWidthContext, ThreadMarkdownImage, ThreadMarkdownImageUnavailable, ThreadMarkdownImageView, } from "./ThreadMarkdownImage"; import { openSettings } from "../settings/settings-navigation"; import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; const WIDE_MARKDOWN_BLOCK_OPTIONS = { // iOS blockquotes and selectable text measure separately; definite bubble width matches UIKit's drawing width. includeBlockquotes: Platform.OS === "ios", includeOrderedLists: Platform.OS === "android", } as const; const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit", }); function formatMessageTime(input: string): string { const timestamp = Date.parse(input); if (Number.isNaN(timestamp)) { return ""; } return MESSAGE_TIME_FORMATTER.format(timestamp); } const TURN_FOLD_HEIGHT = 42; const FEED_PADDING_TOP = 12; // Rows re-measure within a commit or two of a width change. LegendList also defers imperative // scrolls for 100ms after it compensates a size change, and a deferred restore could land // under a drag that starts meanwhile. const FEED_REFLOW_SETTLE_MS = 200; // A fold resizes the feed in steps about 200ms apart. Holding the place taken at the first step // across all of them keeps a passing moment off the end from re-anchoring the rest to a row. const FEED_REFLOW_HOLD_MS = 600; const USER_BUBBLE_HORIZONTAL_PADDING = 3.5 * 3.5; const ASSISTANT_ROW_HORIZONTAL_PADDING = 3.5; const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay( THREAD_DISCLOSURE_TRANSITION_MS, ).duration(140); // Virtualization remounts old rows; only newly created content should replay an entrance. const FRESH_ENTRY_WINDOW_MS = 3_000; function isFreshTimestamp(input: string): boolean { const timestamp = Date.parse(input); return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ENTRY_WINDOW_MS; } export interface ThreadFeedHistoryControls { readonly hasMoreHistory: boolean; readonly loading: boolean; readonly error: string | null; readonly onLoadEarlier: () => void; /** Loads earlier history down to the entry, in one request. */ readonly onLoadThrough: (entryId: string) => void; } export interface ThreadFeedHandle { /** A send re-arms following the end, whatever the reader had scrolled to. */ readonly resetEndFollow: () => void; } export interface ThreadFeedProps { readonly worktreeSetup?: WorktreeSetupCardProps | null; readonly setupWorkingStartedAt?: string | null; readonly queuedMessages: ReadonlyArray; readonly dispatchingMessageId: MessageId | null; readonly onEditPendingMessage: ((message: QueuedThreadMessage) => void) | null; readonly environmentId: EnvironmentId; readonly threadId: ThreadId; /** Null while the thread's creation is queued: reading detail then caches a not-found as deleted. */ readonly detailRef: ScopedThreadRef | null; readonly threadTitle: string; readonly workspaceRoot?: string | null; readonly feed: ReadonlyArray; readonly pendingHandoff?: HandoffEndpoints | null; readonly contentPresentation: ThreadContentPresentation; readonly latestRun: ThreadFeedLatestRun | null; readonly activeWorkStartedAt: string | null; readonly runlessWorkActive?: boolean; readonly listRef: RefObject; readonly freeze: SharedValue; readonly anchorMessageId: MessageId | null; /** A message to scroll to once it is in the feed; see `onJumpedToMessage`. */ readonly jumpToMessageId?: MessageId | null; readonly onJumpedToMessage?: () => void; readonly contentInsetEndAdjustment: SharedValue; readonly keyboardOffset: number; readonly contentTopInset?: number; readonly contentBottomInset?: number; readonly historyControls?: ThreadFeedHistoryControls; readonly contentMaxWidth?: number; readonly contentSideInsets?: { readonly left: number; readonly right: number }; readonly layoutVariant?: LayoutVariant; readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; readonly onEndFollowEnabledChange?: (enabled: boolean) => void; readonly skills?: ReadonlyArray; readonly onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; } async function waitForThreadShell( environmentId: EnvironmentId, threadId: ThreadId, ): Promise { const atom = environmentThreadShells.threadShellAtom(scopeThreadRef(environmentId, threadId)); return waitForThreadShellReady({ read: () => appAtomRegistry.get(atom) !== null, }); } const USER_MESSAGE_COPY_MENU_ACTIONS = [ { id: "copy-prompt-link", title: "Copy prompt link", image: "link" }, ]; function UserMessageCopyButton(props: { readonly text: string; readonly onCopy: (() => Promise) | undefined; readonly tintColor: ColorValue; }) { const button = ( ); const linkText = replaceComposerContextReferences(props.text, (reference) => reference.label); if (linkText.length > PROMPT_LINK_MAX_PROMPT_LENGTH) return button; return ( { if (nativeEvent.event === "copy-prompt-link") void copyPromptLink(linkText); }} shouldOpenOnLongPress > {button} ); } function AssistantForkButton(props: { readonly environmentId: EnvironmentId; readonly iconColor: ColorValue; readonly buttonSize: number; readonly iconSize: number; readonly projectedItem: OrchestrationV2ProjectedTurnItem; readonly sourceTitle: string; }) { const support = useV2ItemSupport({ environmentId: props.environmentId, sourceThreadId: props.projectedItem.sourceThreadId, sourceItemId: props.projectedItem.sourceItemId, }); const forkFromRun = useAtomCommand(threadEnvironment.forkFromRun, "fork from response"); const selectDetail = useSelectWorkspaceDetail(); const [busy, setBusy] = useState(false); const canFork = canForkProjectedAssistantItem({ projectedItem: props.projectedItem, capabilities: support.providerSession?.capabilities, }); const runId = props.projectedItem.item.runId; if (!canFork || runId === null) return null; return ( { const targetThreadId = ThreadId.make(uuidv4()); setBusy(true); void Haptics.selectionAsync(); void forkFromRun({ environmentId: props.environmentId, input: { sourceThreadId: props.projectedItem.sourceThreadId, targetThreadId, runId, title: `${props.sourceTitle} fork`, creationSource: "mobile", }, }) .then(async (result) => { if (result._tag !== "Success") return; const targetThreadReady = await waitForThreadShell(props.environmentId, targetThreadId); if (!targetThreadReady) { Dialog.alert( "Fork created", "Its thread data did not reach this client. Reconnect and try opening it from the thread list.", ); return; } selectDetail({ name: "Thread", params: { environmentId: props.environmentId, threadId: targetThreadId }, }); }) .finally(() => setBusy(false)); }} className="h-7 w-7 items-center justify-center disabled:opacity-40" style={ Platform.OS === "android" ? { width: props.buttonSize, height: props.buttonSize } : undefined } > {busy ? ( ) : ( )} ); } function MessageImage(props: { readonly source: ComponentPropsWithRef["source"]; readonly className: string; readonly maxWidth?: number; readonly height?: number; readonly onLoad?: () => void; readonly onError?: () => void; }) { const imageStyle = useResolveClassNames(props.className); const [size, setSize] = useState<{ width: number; height: number } | null>(null); const displaySize = props.maxWidth !== undefined && size !== null ? resolveMarkdownImageDisplaySize({ sourceWidth: size.width, sourceHeight: size.height, availableWidth: props.maxWidth, }) : null; return ( { if (props.maxWidth !== undefined) setSize(event.source); props.onLoad?.(); }} onError={props.onError} /> ); } function MessageAttachmentImage(props: { readonly environmentId: EnvironmentId; readonly attachmentId: string; readonly name: string; readonly mimeType: string; readonly className: string; readonly maxWidth?: number; readonly height?: number; readonly onPressPreview: (source: FilePreviewSource) => void; }) { const withGallery = useFilePreviewGallery(); const sourceIdentifier = useId(); const resource = { _tag: "attachment" as const, attachmentId: props.attachmentId, fileName: props.name, mimeType: props.mimeType, }; const uri = useAssetUrl(props.environmentId, resource); const refreshAssetUrl = useRefreshAssetUrl(props.environmentId, resource); const retriedImage = useRef(false); if (uri === null) { return ( ); } return ( props.onPressPreview( withGallery({ kind: "image", environmentId: props.environmentId, resource, name: props.name, sourceIdentifier, actionsSource: { name: props.name, mimeType: props.mimeType, environmentId: props.environmentId, resource, }, }), ) } > { retriedImage.current = false; }} onError={() => { if (retriedImage.current) return; retriedImage.current = true; void refreshAssetUrl(); }} /> ); } function isImageAttachment(attachment: ChatAttachment): attachment is ChatImageAttachment { return attachment.type === "image" || imageMimeType(attachment) !== null; } function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAttachment { return attachment.type === "file"; } function isMediaAttachment(attachment: ChatAttachment): boolean { return ( isImageAttachment(attachment) || (isFileAttachment(attachment) && videoMimeType(attachment) !== null) ); } function MessageAttachmentFile(props: { readonly environmentId: EnvironmentId; readonly attachment: ChatFileAttachment; readonly onPressPreview: (source: FilePreviewSource) => void; readonly onPlaybackStart: () => void; readonly standaloneMedia?: boolean; readonly standaloneFile?: boolean; readonly available?: boolean; }) { const sourceIdentifier = useId(); const navigation = useNavigation(); const { selectedThread } = useThreadSelection(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { refresh: true, reportFailure: false, }); const preparedConnection = usePreparedConnection(props.environmentId); const { attachment } = props; const videoType = videoMimeType(attachment); const isPdf = isPdfFile(attachment); const fileTypeLabel = isPdf ? "PDF" : (attachment.name.match(/\.([a-z0-9]{1,8})$/i)?.[1]?.toUpperCase() ?? "File"); const sizeLabel = formatAttachmentSize(attachment.sizeBytes); const httpBaseUrl = Option.isSome(preparedConnection) ? preparedConnection.value.httpBaseUrl : null; const openingRef = useRef(null); const [opening, setOpening] = useState(false); useFocusEffect( useCallback(() => { setOpening(false); return () => { openingRef.current?.abort(); openingRef.current = null; }; // oxlint-disable-next-line react/memo-dependencies -- Changing the attachment or connection cancels the previous file open even while this screen stays focused. }, [props.environmentId, attachment.id, httpBaseUrl]), ); const shareFile = (sourceIdentifier?: string) => { if (props.available === false || httpBaseUrl === null || openingRef.current) return; const controller = new AbortController(); openingRef.current = controller; setOpening(true); const finish = (message: string | null) => { if (message !== null && !controller.signal.aborted) { Dialog.alert("Could not open attachment", message); } if (openingRef.current === controller) { openingRef.current = null; setOpening(false); } }; void (async () => { const result = await createAssetUrl({ environmentId: props.environmentId, input: { resource: { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType: attachment.mimeType, }, }, }); if (controller.signal.aborted) return finish(null); if (result._tag === "Failure") { const error = squashAtomCommandFailure(result); return finish(error instanceof Error ? error.message : "The attachment is unavailable."); } const url = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl); if (url === null) return finish("The attachment could not be opened."); const shared = await settlePromise(() => downloadAndShareAttachment({ url, attachment, signal: controller.signal, sourceIdentifier, }), ); if (shared._tag === "Failure") { const error = squashAtomCommandFailure(shared); return finish(error instanceof Error ? error.message : "The attachment is unavailable."); } finish(null); })(); }; const openAttachment = () => isPdf ? props.onPressPreview({ kind: "pdf", name: attachment.name, environmentId: props.environmentId, resource: { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType: "application/pdf", }, sourceIdentifier, }) : navigation.navigate("ThreadAttachment", { environmentId: String(props.environmentId), ...(selectedThread ? { threadId: String(selectedThread.id) } : {}), attachmentId: attachment.id, name: attachment.name, mimeType: attachment.mimeType, sizeBytes: String(attachment.sizeBytes), }); const inlineKind = inlineFilePreviewKind(attachment); if (inlineKind) { return ( shareFile(sourceIdentifier)} /> ); } if (videoType !== null) { return ( ); } const disabled = props.available === false || opening || httpBaseUrl === null; return ( shareFile(sourceIdentifier)} onPress={openAttachment} > {opening ? ( ) : ( )} {attachment.name} {fileTypeLabel} · {sizeLabel} {props.standaloneFile ? ( shareFile(sourceIdentifier)} > ) : null} ); } function UserMessageMedia(props: { readonly attachments: ReadonlyArray; readonly pendingAttachments: QueuedThreadMessage["attachments"]; readonly environmentId: EnvironmentId; readonly maxWidth: number; readonly onPressPreview: (source: FilePreviewSource) => void; readonly onPlaybackStart: () => void; }) { const attachments = [...props.pendingAttachments, ...props.attachments]; const collage = attachments.length > 1 && attachments.every(isImageAttachment); const imageProps = (index: number) => ({ className: "w-full bg-user-bubble-foreground/15", maxWidth: collage ? undefined : props.maxWidth, height: !collage ? undefined : attachments.length === 2 || (attachments.length === 3 && index === 0) ? 192 : attachments.length === 3 ? (192 - StyleSheet.hairlineWidth) / 2 : 96, }); const media = [ ...props.pendingAttachments.map((attachment, index) => { const image = isImageAttachment(attachment); return { id: attachment.id, image, content: image && attachment.uploadedAttachmentId ? ( ) : !image && attachment.uploadedAttachmentId ? ( ) : ( ), }; }), ...props.attachments.map((attachment, index) => ({ id: attachment.id, image: isImageAttachment(attachment), content: isImageAttachment(attachment) ? ( ) : isFileAttachment(attachment) ? ( ) : null, })), ]; return collage ? ( {media.length === 3 ? ( {media[0]!.content} {media[1]!.content} {media[2]!.content} ) : ( media .filter((_, index) => index % 2 === 0) .map((item, index) => ( {item.content} {media[index * 2 + 1] ? ( {media[index * 2 + 1]!.content} ) : null} )) )} ) : ( {media.map((item) => ( {item.content} ))} ); } function PendingMessageMedia(props: { readonly attachment: QueuedThreadMessage["attachments"][number]; readonly className: string; readonly maxWidth?: number; readonly height?: number; readonly onPressPreview: (source: FilePreviewSource) => void; readonly onPlaybackStart: () => void; }) { const withGallery = useFilePreviewGallery(); const { attachment } = props; const fileBacked = isFileBackedComposerAttachment(attachment); const preview = useLocalAttachmentPreview(fileBacked ? attachment : null); if (isImageAttachment(attachment)) { const uri = fileBacked ? preview.uri : (attachment.dataUrl ?? attachment.previewUri); return ( { const source = composerImageGallery([attachment])[0]; if (source) props.onPressPreview(withGallery(source)); }} > ); } if (preview.uri === null) { return ( {preview.error ? ( {preview.error.message} ) : ( )} ); } return ( ); } /** A message's files as its bubble shows them; a guest's waiting message uses it too. */ function MessageAttachmentList(props: { readonly attachments: ReadonlyArray; readonly environmentId: EnvironmentId; readonly onPressPreview: (source: FilePreviewSource) => void; readonly onPlaybackStart: () => void; }) { return props.attachments.map((attachment) => isImageAttachment(attachment) ? ( ) : isFileAttachment(attachment) ? ( ) : ( ), ); } function MessageAttachmentUnknown(props: { readonly name: string }) { return ( {props.name} ); } // LegendList only computes hook visibility when the list has a viewability config. const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 }; // The composer and overlays beside the feed keep UIKit from finding it on its own, so // register it with the screen controller. Header minimization observes this scroll view. function ThreadFeedScrollView(props: ComponentPropsWithRef) { return ( ); } function ThreadMediaVisibility(props: { readonly children: ReactNode }) { const [visible, setVisible] = useState(false); useViewabilityAmount( useCallback((token) => setVisible(token.sizeVisible > 0), []), ); return {props.children}; } interface MarkdownLinkHandlers { readonly onLinkPress: (href: string) => void; readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; readonly onFileContextMenuAction: (href: string, actionId: string) => void; readonly onImagePress?: (href: string) => void; readonly resolveImageSource?: MarkdownImageSourceResolver; readonly fileEmbedLabel?: (href: string, standalone: boolean) => string | null; readonly linkCustomization?: (href: string) => MarkdownLinkCustomization | undefined; } interface MarkdownStyleSets { readonly user: MarkdownStyleSet; readonly assistant: MarkdownStyleSet; } interface MarkdownStyleSet { readonly nativeTextStyle: NativeMarkdownTextStyle; } const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record< CodexArtifactTemplate["artifactKind"], AppSymbolName > = { document: "doc.text", presentation: "chart.bar.xaxis", spreadsheet: "chart.bar.xaxis", site: "safari", "google-docs": "doc.text", "google-slides": "chart.bar.xaxis", "google-sheets": "chart.bar.xaxis", image: "camera", email: "text.bubble", slack: "text.bubble", }; function ArtifactTemplateCard(props: { readonly template: CodexArtifactTemplate; readonly onUse?: ((template: CodexArtifactTemplate) => void) | undefined; }) { return ( {props.template.displayName} {codexArtifactTemplatePresentationLabel(props.template.artifactKind)} {props.onUse ? (