import * as Haptics from "expo-haptics";import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native";import type { ChatAttachment, ChatFileAttachment, ChatImageAttachment, EnvironmentId, MessageId, ThreadId, TurnId,} from "@t3tools/contracts";import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations";import { codexArtifactTemplatePresentationLabel, type CodexArtifactTemplate,} from "@t3tools/client-runtime/codex-artifact-templates";import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";import { classifyMarkdownImageSource, markdownImageSourceFragment,} from "@t3tools/client-runtime/markdown-images";import { resolveViewedImageAsset } from "@t3tools/client-runtime/work-log/presentation";import { renderCodexFileCitationsAsMarkdown, splitCodexArtifactTemplateMarkdown,} from "@t3tools/client-runtime/codex-markdown-directives";import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";import { videoMimeType } from "@t3tools/shared/video";import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";import { HeaderHeightContext } from "@react-navigation/elements";import { useFocusEffect, useNavigation } from "@react-navigation/native";import { createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useId, type ReactNode, type RefObject,} from "react";import { Markdown, type CustomRenderers, type NodeStyleOverrides, type PartialMarkdownTheme,} from "react-native-nitro-markdown";import { ActivityIndicator, Alert, Image, Platform, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent, Pressable, ScrollView, StyleSheet, Text as NativeText, type ColorValue, useWindowDimensions, View,} from "react-native";import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";import { isPdfFile } from "../../lib/filePreview";import { PresentationSource } from "../../components/NativePresentation";import { useSafeAreaInsets } from "react-native-safe-area-context";import Animated, { FadeIn, LinearTransition, type SharedValue } from "react-native-reanimated";import { useUniwindTheme } from "../../lib/useUniwindTheme";import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";import { useFontFamily } from "../../lib/useFontFamily";import { scopedThreadKey } from "../../lib/scopedEntities";import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";import { tryOpenExternalUrl } from "../../lib/openExternalUrl";import { downloadAndShareAttachment } from "../../lib/attachmentDownload";import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";import { faviconUrlForOrigin } from "@t3tools/shared/favicon";import { hasNativeSelectableMarkdownText, SelectableMarkdownText, type MarkdownFileContextMenu, type MarkdownImageRenderer, type NativeMarkdownTextStyle, type SelectableMarkdownSkill,} from "../../native/SelectableMarkdownText"; import { AppText as Text } from "../../components/AppText";import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";import { VideoAttachmentTile } from "../../components/VideoAttachmentTile";import { MediaVideoPlayer } from "../../components/MediaVideoPlayer";import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia";import { attachmentVideoPreviewSource, mediaVideoPreviewUri, mediaVideoThumbnailKey, type MediaVideoPreviewSource,} from "../../lib/videoPreviewSource";import { CopyTextButton } from "../../components/CopyTextButton";import { parseReviewCommentMessageSegments, type ReviewInlineComment,} from "../review/reviewCommentSelection";import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter";import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface";import { buildNativeReviewDiffData, createNativeReviewDiffTheme, NATIVE_REVIEW_DIFF_CONTENT_WIDTH,} from "../review/nativeReviewDiffAdapter";import { buildReviewParsedDiff } from "../review/reviewModel";import { cn } from "../../lib/cn";import { deriveCenteredContentHorizontalPadding, deriveThreadFeedInitialContentInset, deriveThreadWorkLogSizing, type LayoutVariant,} from "../../lib/layout";import { resolveMarkdownFontSizes, resolveNativeMarkdownTypography,} from "../../lib/appearancePreferences";import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";import { PierreEntryIcon } from "../../components/PierreEntryIcon";import { markdownLinkIconSource } from "@t3tools/mobile-markdown-text/link-icons";import { normalizeNativeMarkdownUrl, resolveMarkdownInlineCodePresentation, resolveMarkdownLinkIcon, resolveMarkdownLinkPresentation,} from "@t3tools/mobile-markdown-text/links";import { deriveThreadFeedPresentation, isContextCompactionActivityGroup, type ThreadFeedEntry, type ThreadFeedLatestTurn,} from "../../lib/threadActivity";import type { ThreadContentPresentation } from "./threadContentPresentation";import { resolveThreadFeedLiveFollow, type ThreadFeedLiveFollowEvent, type ThreadWorkGroupScrollPosition,} from "./thread-feed-live-follow";import { collapsedWorkLogHeight, ThreadAgentSpawnCard, ThreadDisclosureChevron, ThreadWorkGroupToggle, ThreadThinkingRow, ThreadWorkLog, THREAD_DISCLOSURE_TRANSITION_MS, WORK_GROUP_TOGGLE_HEIGHT,} from "./thread-work-log";import { appendPendingThreadMessages, type PendingThreadFeedEntry } from "./pending-thread-feed";import type { QueuedThreadMessage } from "../../state/thread-outbox-model";import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";import { assetEnvironment, useAssetUrl, useAssetUrlState, useRefreshAssetUrl,} from "../../state/assets";import { useAtomQueryRunner } from "../../state/use-atom-query-runner";import { usePreparedConnection } from "../../state/session";import * as Option from "effect/Option";import { basename, fileRoutePathSegments, isAbsolutePath, resolveWorkspaceRelativeFilePath,} from "../files/filePath";import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu";import { useFileChipShare } from "./useFileChipShare";import { MarkdownImageAvailableWidthContext, ThreadMarkdownImage, ThreadMarkdownImageUnavailable, ThreadMarkdownImageView,} from "./ThreadMarkdownImage"; const WIDE_MARKDOWN_BLOCK_OPTIONS = { // Native iOS blockquotes and adjacent selectable text are separate layout // chunks. Giving their shrink-to-fit bubble a definite width keeps both // chunks measured against the width at which UIKit draws them. includeBlockquotes: Platform.OS === "ios", includeOrderedLists: Platform.OS === "android",} as const; const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit",});function formatMessageTime(input: string): string { const timestamp = Date.parse(input); if (Number.isNaN(timestamp)) { return ""; } return MESSAGE_TIME_FORMATTER.format(timestamp);} // Fixed heights mirror renderFeedEntry's classNames and are only used while// text fits at the current font settings. Larger accessibility text is measured.const TURN_FOLD_HEIGHT = 42; // min-h-11 (38.5) + mb-1 (3.5), with the mobile 14px remconst THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);const THREAD_FEED_IMMEDIATE_TRANSITION = LinearTransition.duration(0);// Tailwind spacing on the mobile 14px rem: px-3.5 on the user bubble, px-1 on// assistant rows. Images size their frame from these before their own layout.const USER_BUBBLE_HORIZONTAL_PADDING = 3.5 * 3.5;const ASSISTANT_ROW_HORIZONTAL_PADDING = 3.5;// Let neighboring rows move out of the new rows' space before showing their text.const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay( THREAD_DISCLOSURE_TRANSITION_MS,).duration(140); // Entering animations must only play for rows born just now — LegendList// remounts rows when they scroll back into view, and replaying an entrance for// old content would be its own kind of jank.const FRESH_ENTRY_WINDOW_MS = 3_000;function isFreshTimestamp(input: string): boolean { const timestamp = Date.parse(input); return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ENTRY_WINDOW_MS;} export interface ThreadFeedProps { readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>; readonly dispatchingMessageId: MessageId | null; readonly onEditPendingMessage: (message: QueuedThreadMessage) => void; readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly workspaceRoot?: string | null; readonly feed: ReadonlyArray<ThreadFeedEntry>; readonly contentPresentation: ThreadContentPresentation; readonly agentLabel: string; readonly latestTurn: ThreadFeedLatestTurn | null; readonly activeWorkStartedAt: string | null; readonly listRef: RefObject<LegendListRef | null>; readonly freeze: SharedValue<boolean>; readonly anchorMessageId: MessageId | null; readonly submittedMessageId: MessageId | null; readonly contentInsetEndAdjustment: SharedValue<number>; readonly contentTopInset?: number; readonly contentBottomInset?: number; readonly contentMaxWidth?: number; readonly layoutVariant?: LayoutVariant; readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; readonly onEndFollowEnabledChange?: (enabled: boolean) => void; readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; readonly onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; /** Non-null when older turns exist beyond the loaded window. */ readonly loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void; } | null;} function MessageAttachmentImage(props: { readonly environmentId: EnvironmentId; readonly attachmentId: string; readonly name: string; readonly mimeType: string; readonly className: string; readonly onPressPreview: (source: FilePreviewSource) => void;}) { const sourceIdentifier = useId(); const resource = useMemo( () => ({ _tag: "attachment" as const, attachmentId: props.attachmentId, fileName: props.name, mimeType: props.mimeType, }), [props.attachmentId, props.name, props.mimeType], ); const uri = useAssetUrl(props.environmentId, resource); if (uri === null) { return ( <View className={`${props.className} items-center justify-center`}> <ActivityIndicator /> </View> ); } return ( <PresentationSource identifier={sourceIdentifier}> <Pressable accessibilityRole="imagebutton" accessibilityLabel={`Open ${props.name}`} onPress={() => // The viewer mints its own URL from the resource so the image survives a refresh. props.onPressPreview({ kind: "image", environmentId: props.environmentId, resource, name: props.name, sourceIdentifier, actionsSource: { name: props.name, mimeType: props.mimeType, environmentId: props.environmentId, resource, }, }) } > <Image source={{ uri }} className={props.className} resizeMode="cover" /> </Pressable> </PresentationSource> );} // The attachment union has an open member (`type: string` for attachment// types from newer servers), so literal comparisons do not narrow it. Split// with guards and render unknown types as inert rows, never crash.function isImageAttachment(attachment: ChatAttachment): attachment is ChatImageAttachment { return attachment.type === "image";} function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAttachment { return attachment.type === "file";} function MessageAttachmentFile(props: { readonly environmentId: EnvironmentId; readonly attachment: ChatFileAttachment; readonly onPressPreview: (source: FilePreviewSource) => void; readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;}) { const sourceIdentifier = useId(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { refresh: true, reportFailure: false, }); const preparedConnection = usePreparedConnection(props.environmentId); const { attachment } = props; const videoType = videoMimeType(attachment); const isPdf = isPdfFile(attachment); const fileTypeLabel = isPdf ? "PDF" : (attachment.name.match(/\.([a-z0-9]{1,8})$/i)?.[1]?.toUpperCase() ?? "File"); const sizeLabel = formatAttachmentSize(attachment.sizeBytes); const thumbnailUrl = useAssetUrl( props.environmentId, videoType === null ? null : { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType: videoType, }, ); const httpBaseUrl = Option.isSome(preparedConnection) ? preparedConnection.value.httpBaseUrl : null; const openingRef = useRef<AbortController | null>(null); const [opening, setOpening] = useState(false); useFocusEffect( useCallback(() => { setOpening(false); return () => { openingRef.current?.abort(); openingRef.current = null; }; }, [props.environmentId, attachment.id, httpBaseUrl]), ); const shareFile = (sourceIdentifier?: string) => { if (httpBaseUrl === null || openingRef.current) return; const controller = new AbortController(); openingRef.current = controller; setOpening(true); void (async () => { try { const result = await createAssetUrl({ environmentId: props.environmentId, input: { resource: { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType: attachment.mimeType, }, }, }); if (controller.signal.aborted) return; if (result._tag === "Failure") { throw squashAtomCommandFailure(result); } const url = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl); if (url === null) { throw new Error("The attachment could not be opened."); } await downloadAndShareAttachment({ url, attachment, signal: controller.signal, sourceIdentifier, }); } catch (error) { if (!controller.signal.aborted) { Alert.alert( "Could not open attachment", error instanceof Error ? error.message : "The attachment is unavailable.", ); } } finally { if (openingRef.current === controller) { openingRef.current = null; setOpening(false); } } })(); }; if (videoType !== null) { const sourceIdentifier = `attachment:${props.environmentId}:${attachment.id}`; return ( <VideoAttachmentTile name={attachment.name} sourceIdentifier={sourceIdentifier} thumbnailSource={thumbnailUrl} actionsSource={ attachmentVideoPreviewSource(props.environmentId, attachment, sourceIdentifier) .actionsSource } disabled={opening || httpBaseUrl === null} onPress={(sourceIdentifier) => props.onPressVideo(attachment, sourceIdentifier)} className="my-1 rounded-2xl" style={{ width: 224, maxWidth: "100%", aspectRatio: 16 / 9 }} /> ); } return ( <PresentationSource identifier={sourceIdentifier} className="my-1" style={{ width: 280, maxWidth: "100%" }} > <Pressable accessibilityRole="button" accessibilityLabel={`Open ${attachment.name}`} accessibilityValue={{ text: `${fileTypeLabel}, ${sizeLabel}` }} accessibilityState={{ disabled: opening || httpBaseUrl === null, busy: opening }} disabled={opening || httpBaseUrl === null} className="min-w-0 flex-row items-center gap-3 rounded-xl border border-border bg-card p-3 active:bg-subtle" onPress={() => isPdf ? props.onPressPreview({ kind: "pdf", name: attachment.name, environmentId: props.environmentId, resource: { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType: "application/pdf", }, sourceIdentifier, }) : shareFile(sourceIdentifier) } > <View className="h-12 w-10 shrink-0 items-center justify-center rounded-lg bg-subtle"> {opening ? ( <ActivityIndicator size="small" /> ) : ( <PierreEntryIcon path={attachment.name} kind="file" size={26} /> )} </View> <View className="min-w-0 flex-1 gap-1"> <Text className="font-t3-medium text-sm text-foreground" numberOfLines={2}> {attachment.name} </Text> <Text className="text-xs text-foreground-muted" numberOfLines={1}> {fileTypeLabel} · {sizeLabel} </Text> </View> <SymbolView name="chevron.right" size={12} tintColorClassName="accent-foreground-muted" type="monochrome" /> </Pressable> </PresentationSource> );} Lines 1–500 of 2955. Load more
import * as Haptics from "expo-haptics"; import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; import type { ChatAttachment, ChatFileAttachment, ChatImageAttachment, EnvironmentId, MessageId, ThreadId, TurnId, } from "@t3tools/contracts"; import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations"; import { codexArtifactTemplatePresentationLabel, type CodexArtifactTemplate, } from "@t3tools/client-runtime/codex-artifact-templates"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { classifyMarkdownImageSource, markdownImageSourceFragment, } from "@t3tools/client-runtime/markdown-images"; import { resolveViewedImageAsset } from "@t3tools/client-runtime/work-log/presentation"; import { renderCodexFileCitationsAsMarkdown, splitCodexArtifactTemplateMarkdown, } from "@t3tools/client-runtime/codex-markdown-directives"; import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { videoMimeType } from "@t3tools/shared/video"; import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; import { HeaderHeightContext } from "@react-navigation/elements"; import { useFocusEffect, useNavigation } from "@react-navigation/native"; import { createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useId, type ReactNode, type RefObject, } from "react"; import { Markdown, type CustomRenderers, type NodeStyleOverrides, type PartialMarkdownTheme, } from "react-native-nitro-markdown"; import { ActivityIndicator, Alert, Image, Platform, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent, Pressable, ScrollView, StyleSheet, Text as NativeText, type ColorValue, useWindowDimensions, View, } from "react-native"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { isPdfFile } from "../../lib/filePreview"; import { PresentationSource } from "../../components/NativePresentation"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { FadeIn, LinearTransition, type SharedValue } from "react-native-reanimated"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { useFontFamily } from "../../lib/useFontFamily"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, type MarkdownFileContextMenu, type MarkdownImageRenderer, type NativeMarkdownTextStyle, type SelectableMarkdownSkill, } from "../../native/SelectableMarkdownText"; import { AppText as Text } from "../../components/AppText"; import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; import { VideoAttachmentTile } from "../../components/VideoAttachmentTile"; import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; import { attachmentVideoPreviewSource, mediaVideoPreviewUri, mediaVideoThumbnailKey, type MediaVideoPreviewSource, } from "../../lib/videoPreviewSource"; import { CopyTextButton } from "../../components/CopyTextButton"; import { parseReviewCommentMessageSegments, type ReviewInlineComment, } from "../review/reviewCommentSelection"; import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter"; import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface"; import { buildNativeReviewDiffData, createNativeReviewDiffTheme, NATIVE_REVIEW_DIFF_CONTENT_WIDTH, } from "../review/nativeReviewDiffAdapter"; import { buildReviewParsedDiff } from "../review/reviewModel"; import { cn } from "../../lib/cn"; import { deriveCenteredContentHorizontalPadding, deriveThreadFeedInitialContentInset, deriveThreadWorkLogSizing, type LayoutVariant, } from "../../lib/layout"; import { resolveMarkdownFontSizes, resolveNativeMarkdownTypography, } from "../../lib/appearancePreferences"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; import { markdownLinkIconSource } from "@t3tools/mobile-markdown-text/link-icons"; import { normalizeNativeMarkdownUrl, resolveMarkdownInlineCodePresentation, resolveMarkdownLinkIcon, resolveMarkdownLinkPresentation, } from "@t3tools/mobile-markdown-text/links"; import { deriveThreadFeedPresentation, isContextCompactionActivityGroup, type ThreadFeedEntry, type ThreadFeedLatestTurn, } from "../../lib/threadActivity"; import type { ThreadContentPresentation } from "./threadContentPresentation"; import { resolveThreadFeedLiveFollow, type ThreadFeedLiveFollowEvent, type ThreadWorkGroupScrollPosition, } from "./thread-feed-live-follow"; import { collapsedWorkLogHeight, ThreadAgentSpawnCard, ThreadDisclosureChevron, ThreadWorkGroupToggle, ThreadThinkingRow, ThreadWorkLog, THREAD_DISCLOSURE_TRANSITION_MS, WORK_GROUP_TOGGLE_HEIGHT, } from "./thread-work-log"; import { appendPendingThreadMessages, type PendingThreadFeedEntry } from "./pending-thread-feed"; import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; import { assetEnvironment, useAssetUrl, useAssetUrlState, useRefreshAssetUrl, } from "../../state/assets"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { usePreparedConnection } from "../../state/session"; import * as Option from "effect/Option"; import { basename, fileRoutePathSegments, isAbsolutePath, resolveWorkspaceRelativeFilePath, } from "../files/filePath"; import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu"; import { useFileChipShare } from "./useFileChipShare"; import { MarkdownImageAvailableWidthContext, ThreadMarkdownImage, ThreadMarkdownImageUnavailable, ThreadMarkdownImageView, } from "./ThreadMarkdownImage"; const WIDE_MARKDOWN_BLOCK_OPTIONS = { // Native iOS blockquotes and adjacent selectable text are separate layout // chunks. Giving their shrink-to-fit bubble a definite width keeps both // chunks measured against the width at which UIKit draws them. includeBlockquotes: Platform.OS === "ios", includeOrderedLists: Platform.OS === "android", } as const; const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit", }); function formatMessageTime(input: string): string { const timestamp = Date.parse(input); if (Number.isNaN(timestamp)) { return ""; } return MESSAGE_TIME_FORMATTER.format(timestamp); } // Fixed heights mirror renderFeedEntry's classNames and are only used while // text fits at the current font settings. Larger accessibility text is measured. const TURN_FOLD_HEIGHT = 42; // min-h-11 (38.5) + mb-1 (3.5), with the mobile 14px rem const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); const THREAD_FEED_IMMEDIATE_TRANSITION = LinearTransition.duration(0); // Tailwind spacing on the mobile 14px rem: px-3.5 on the user bubble, px-1 on // assistant rows. Images size their frame from these before their own layout. const USER_BUBBLE_HORIZONTAL_PADDING = 3.5 * 3.5; const ASSISTANT_ROW_HORIZONTAL_PADDING = 3.5; // Let neighboring rows move out of the new rows' space before showing their text. const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay( THREAD_DISCLOSURE_TRANSITION_MS, ).duration(140); // Entering animations must only play for rows born just now — LegendList // remounts rows when they scroll back into view, and replaying an entrance for // old content would be its own kind of jank. const FRESH_ENTRY_WINDOW_MS = 3_000; function isFreshTimestamp(input: string): boolean { const timestamp = Date.parse(input); return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ENTRY_WINDOW_MS; } export interface ThreadFeedProps { readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>; readonly dispatchingMessageId: MessageId | null; readonly onEditPendingMessage: (message: QueuedThreadMessage) => void; readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly workspaceRoot?: string | null; readonly feed: ReadonlyArray<ThreadFeedEntry>; readonly contentPresentation: ThreadContentPresentation; readonly agentLabel: string; readonly latestTurn: ThreadFeedLatestTurn | null; readonly activeWorkStartedAt: string | null; readonly listRef: RefObject<LegendListRef | null>; readonly freeze: SharedValue<boolean>; readonly anchorMessageId: MessageId | null; readonly submittedMessageId: MessageId | null; readonly contentInsetEndAdjustment: SharedValue<number>; readonly contentTopInset?: number; readonly contentBottomInset?: number; readonly contentMaxWidth?: number; readonly layoutVariant?: LayoutVariant; readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; readonly onEndFollowEnabledChange?: (enabled: boolean) => void; readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; readonly onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; /** Non-null when older turns exist beyond the loaded window. */ readonly loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void; } | null; } function MessageAttachmentImage(props: { readonly environmentId: EnvironmentId; readonly attachmentId: string; readonly name: string; readonly mimeType: string; readonly className: string; readonly onPressPreview: (source: FilePreviewSource) => void; }) { const sourceIdentifier = useId(); const resource = useMemo( () => ({ _tag: "attachment" as const, attachmentId: props.attachmentId, fileName: props.name, mimeType: props.mimeType, }), [props.attachmentId, props.name, props.mimeType], ); const uri = useAssetUrl(props.environmentId, resource); if (uri === null) { return ( <View className={`${props.className} items-center justify-center`}> <ActivityIndicator /> </View> ); } return ( <PresentationSource identifier={sourceIdentifier}> <Pressable accessibilityRole="imagebutton" accessibilityLabel={`Open ${props.name}`} onPress={() => // The viewer mints its own URL from the resource so the image survives a refresh. props.onPressPreview({ kind: "image", environmentId: props.environmentId, resource, name: props.name, sourceIdentifier, actionsSource: { name: props.name, mimeType: props.mimeType, environmentId: props.environmentId, resource, }, }) } > <Image source={{ uri }} className={props.className} resizeMode="cover" /> </Pressable> </PresentationSource> ); } // The attachment union has an open member (`type: string` for attachment // types from newer servers), so literal comparisons do not narrow it. Split // with guards and render unknown types as inert rows, never crash. function isImageAttachment(attachment: ChatAttachment): attachment is ChatImageAttachment { return attachment.type === "image"; } function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAttachment { return attachment.type === "file"; } function MessageAttachmentFile(props: { readonly environmentId: EnvironmentId; readonly attachment: ChatFileAttachment; readonly onPressPreview: (source: FilePreviewSource) => void; readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; }) { const sourceIdentifier = useId(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { refresh: true, reportFailure: false, }); const preparedConnection = usePreparedConnection(props.environmentId); const { attachment } = props; const videoType = videoMimeType(attachment); const isPdf = isPdfFile(attachment); const fileTypeLabel = isPdf ? "PDF" : (attachment.name.match(/\.([a-z0-9]{1,8})$/i)?.[1]?.toUpperCase() ?? "File"); const sizeLabel = formatAttachmentSize(attachment.sizeBytes); const thumbnailUrl = useAssetUrl( props.environmentId, videoType === null ? null : { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType: videoType, }, ); const httpBaseUrl = Option.isSome(preparedConnection) ? preparedConnection.value.httpBaseUrl : null; const openingRef = useRef<AbortController | null>(null); const [opening, setOpening] = useState(false); useFocusEffect( useCallback(() => { setOpening(false); return () => { openingRef.current?.abort(); openingRef.current = null; }; }, [props.environmentId, attachment.id, httpBaseUrl]), ); const shareFile = (sourceIdentifier?: string) => { if (httpBaseUrl === null || openingRef.current) return; const controller = new AbortController(); openingRef.current = controller; setOpening(true); void (async () => { try { const result = await createAssetUrl({ environmentId: props.environmentId, input: { resource: { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType: attachment.mimeType, }, }, }); if (controller.signal.aborted) return; if (result._tag === "Failure") { throw squashAtomCommandFailure(result); } const url = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl); if (url === null) { throw new Error("The attachment could not be opened."); } await downloadAndShareAttachment({ url, attachment, signal: controller.signal, sourceIdentifier, }); } catch (error) { if (!controller.signal.aborted) { Alert.alert( "Could not open attachment", error instanceof Error ? error.message : "The attachment is unavailable.", ); } } finally { if (openingRef.current === controller) { openingRef.current = null; setOpening(false); } } })(); }; if (videoType !== null) { const sourceIdentifier = `attachment:${props.environmentId}:${attachment.id}`; return ( <VideoAttachmentTile name={attachment.name} sourceIdentifier={sourceIdentifier} thumbnailSource={thumbnailUrl} actionsSource={ attachmentVideoPreviewSource(props.environmentId, attachment, sourceIdentifier) .actionsSource } disabled={opening || httpBaseUrl === null} onPress={(sourceIdentifier) => props.onPressVideo(attachment, sourceIdentifier)} className="my-1 rounded-2xl" style={{ width: 224, maxWidth: "100%", aspectRatio: 16 / 9 }} /> ); } return ( <PresentationSource identifier={sourceIdentifier} className="my-1" style={{ width: 280, maxWidth: "100%" }} > <Pressable accessibilityRole="button" accessibilityLabel={`Open ${attachment.name}`} accessibilityValue={{ text: `${fileTypeLabel}, ${sizeLabel}` }} accessibilityState={{ disabled: opening || httpBaseUrl === null, busy: opening }} disabled={opening || httpBaseUrl === null} className="min-w-0 flex-row items-center gap-3 rounded-xl border border-border bg-card p-3 active:bg-subtle" onPress={() => isPdf ? props.onPressPreview({ kind: "pdf", name: attachment.name, environmentId: props.environmentId, resource: { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType: "application/pdf", }, sourceIdentifier, }) : shareFile(sourceIdentifier) } > <View className="h-12 w-10 shrink-0 items-center justify-center rounded-lg bg-subtle"> {opening ? ( <ActivityIndicator size="small" /> ) : ( <PierreEntryIcon path={attachment.name} kind="file" size={26} /> )} </View> <View className="min-w-0 flex-1 gap-1"> <Text className="font-t3-medium text-sm text-foreground" numberOfLines={2}> {attachment.name} </Text> <Text className="text-xs text-foreground-muted" numberOfLines={1}> {fileTypeLabel} · {sizeLabel} </Text> </View> <SymbolView name="chevron.right" size={12} tintColorClassName="accent-foreground-muted" type="monochrome" /> </Pressable> </PresentationSource> ); }