import * as Haptics from "expo-haptics";import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native";import { scopeThreadRef } from "@t3tools/client-runtime/environment";import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";import { ThreadId, type ChatAttachment, type ChatFileAttachment, type ChatImageAttachment, type EnvironmentId, type MessageId, type OrchestrationV2ProjectedTurnItem, type RunId,} from "@t3tools/contracts";import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";import { codexArtifactTemplatePresentationLabel, type CodexArtifactTemplate,} from "@t3tools/client-runtime/codex-artifact-templates";import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";import { classifyMarkdownImageSource, markdownImageSourceFragment,} from "@t3tools/client-runtime/markdown-images";import { resolveViewedImageAsset } from "@t3tools/client-runtime/work-log/presentation";import { renderCodexFileCitationsAsMarkdown, splitCodexArtifactTemplateMarkdown,} from "@t3tools/client-runtime/codex-markdown-directives";import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";import { HeaderHeightContext } from "@react-navigation/elements";import { useFocusEffect, useNavigation } from "@react-navigation/native";import { createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useId, type ReactNode, type RefObject,} from "react";import { Markdown, type CustomRenderers, type NodeStyleOverrides, type PartialMarkdownTheme,} from "react-native-nitro-markdown";import { ActivityIndicator, Alert, Image, Platform, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent, Pressable, ScrollView, StyleSheet, Text as NativeText, type ColorValue, useWindowDimensions, View,} from "react-native";import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";import { isPdfFile } from "../../lib/filePreview";import { PresentationSource } from "../../components/NativePresentation";import { useSafeAreaInsets } from "react-native-safe-area-context";import Animated, { FadeIn, FadeInUp, LinearTransition, type SharedValue,} from "react-native-reanimated";import { useUniwindTheme } from "../../lib/useUniwindTheme";import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";import { useFontFamily } from "../../lib/useFontFamily";import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations";import { scopedThreadKey } from "../../lib/scopedEntities";import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";import { tryOpenExternalUrl } from "../../lib/openExternalUrl";import { downloadAndShareAttachment } from "../../lib/attachmentDownload";import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";import { faviconUrlForOrigin } from "@t3tools/shared/favicon";import { hasNativeSelectableMarkdownText, SelectableMarkdownText, type MarkdownFileContextMenu, type MarkdownImageRenderer, type NativeMarkdownTextStyle, type SelectableMarkdownSkill,} from "../../native/SelectableMarkdownText"; import { AppText as Text } from "../../components/AppText";import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";import { VideoAttachmentTile } from "../../components/VideoAttachmentTile";import { MediaVideoPlayer } from "../../components/MediaVideoPlayer";import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia";import { attachmentVideoPreviewSource, mediaVideoPreviewUri, mediaVideoThumbnailKey, type MediaVideoPreviewSource,} from "../../lib/videoPreviewSource";import { CopyTextButton } from "../../components/CopyTextButton";import { parseReviewCommentMessageSegments, type ReviewInlineComment,} from "../review/reviewCommentSelection";import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter";import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface";import { buildNativeReviewDiffData, createNativeReviewDiffTheme, NATIVE_REVIEW_DIFF_CONTENT_WIDTH,} from "../review/nativeReviewDiffAdapter";import { buildReviewParsedDiff } from "../review/reviewModel";import { cn } from "../../lib/cn";import { deriveCenteredContentHorizontalPadding, deriveThreadFeedInitialContentInset, deriveThreadWorkLogSizing, type LayoutVariant,} from "../../lib/layout";import { uuidv4 } from "../../lib/uuid";import { resolveMarkdownFontSizes, resolveNativeMarkdownTypography,} from "../../lib/appearancePreferences";import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";import { normalizeNativeMarkdownUrl, resolveMarkdownInlineCodePresentation, resolveMarkdownLinkPresentation,} from "@t3tools/mobile-markdown-text/links";import { deriveThreadFeedPresentation, threadFeedRunIsUnsettled, isContextCompactionActivityGroup, type ThreadFeedEntry, type ThreadFeedLatestRun,} from "../../lib/threadActivity";import type { ThreadContentPresentation } from "./threadContentPresentation";import { resolveThreadFeedLiveFollow, type ThreadFeedLiveFollowEvent, type ThreadWorkGroupScrollPosition,} from "./thread-feed-live-follow";import { collapsedWorkLogHeight, ThreadDisclosureChevron, THREAD_DISCLOSURE_TRANSITION_MS, ThreadWorkGroupToggle, ThreadWorkLog,} from "./thread-work-log";import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size";import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";import { assetEnvironment, useAssetUrl, useAssetUrlState, useRefreshAssetUrl,} from "../../state/assets";import { useAtomQueryRunner } from "../../state/use-atom-query-runner";import { usePreparedConnection } from "../../state/session";import * as Option from "effect/Option";import { videoMimeType } from "@t3tools/shared/video";import { appAtomRegistry } from "../../state/atom-registry";import { environmentThreadShells, threadEnvironment } from "../../state/threads";import { useAtomCommand } from "../../state/use-atom-command";import { useV2ItemSupport } from "../../state/v2-item-support";import { basename, fileRoutePathSegments, isAbsolutePath, resolveWorkspaceRelativeFilePath,} from "../files/filePath";import { waitForThreadShellReady } from "./threadForkNavigation";import { resolveUserMessageIntentBadge } from "./userMessageIntentBadge";import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu";import { ThreadMarkdownImage, ThreadMarkdownImageUnavailable, ThreadMarkdownImageView,} from "./ThreadMarkdownImage"; const WIDE_MARKDOWN_BLOCK_OPTIONS = { // Native iOS blockquotes and adjacent selectable text are separate layout // chunks. Giving their shrink-to-fit bubble a definite width keeps both // chunks measured against the width at which UIKit draws them. includeBlockquotes: Platform.OS === "ios", includeOrderedLists: Platform.OS === "android",} as const; const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit",});function formatMessageTime(input: string): string { const timestamp = Date.parse(input); if (Number.isNaN(timestamp)) { return ""; } return MESSAGE_TIME_FORMATTER.format(timestamp);} const TURN_FOLD_HEIGHT = 42; const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);// Let neighboring rows move out of the new rows' space before showing their text.const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay( THREAD_DISCLOSURE_TRANSITION_MS,).duration(140); // Entering animations must only play for rows born just now — LegendList// remounts rows when they scroll back into view, and replaying an entrance for// old content would be its own kind of jank.const FRESH_ENTRY_WINDOW_MS = 3_000;function isFreshTimestamp(input: string): boolean { const timestamp = Date.parse(input); return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ENTRY_WINDOW_MS;} export interface ThreadFeedHistoryControls { readonly hasMoreHistory: boolean; readonly loading: boolean; readonly error: string | null; readonly onLoadEarlier: () => void;} export interface ThreadFeedProps { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly threadTitle: string; readonly workspaceRoot?: string | null; readonly feed: ReadonlyArray<ThreadFeedEntry>; readonly contentPresentation: ThreadContentPresentation; readonly agentLabel: string; readonly latestRun: ThreadFeedLatestRun | 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 historyControls?: ThreadFeedHistoryControls; readonly topAccessory?: ReactNode; readonly contentMaxWidth?: number; readonly layoutVariant?: LayoutVariant; readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; readonly onEndFollowEnabledChange?: (enabled: boolean) => void; readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; readonly onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;} async function waitForThreadShell( environmentId: EnvironmentId, threadId: ThreadId,): Promise<boolean> { const atom = environmentThreadShells.threadShellAtom(scopeThreadRef(environmentId, threadId)); return waitForThreadShellReady({ read: () => appAtomRegistry.get(atom) !== null, });} function AssistantForkButton(props: { readonly environmentId: EnvironmentId; readonly iconColor: ColorValue; readonly projectedItem: OrchestrationV2ProjectedTurnItem; readonly sourceTitle: string;}) { const support = useV2ItemSupport({ environmentId: props.environmentId, sourceThreadId: props.projectedItem.sourceThreadId, sourceItemId: props.projectedItem.sourceItemId, }); const forkFromRun = useAtomCommand(threadEnvironment.forkFromRun, "fork from response"); const navigation = useNavigation(); const [busy, setBusy] = useState(false); const canFork = canForkProjectedAssistantItem({ projectedItem: props.projectedItem, capabilities: support.providerSession?.capabilities, }); const runId = props.projectedItem.item.runId; if (!canFork || runId === null) return null; return ( <Pressable accessibilityRole="button" accessibilityLabel="Fork from this response" disabled={busy} onPress={() => { const targetThreadId = ThreadId.make(uuidv4()); setBusy(true); void Haptics.selectionAsync(); void forkFromRun({ environmentId: props.environmentId, input: { sourceThreadId: props.projectedItem.sourceThreadId, targetThreadId, runId, title: `${props.sourceTitle} fork`, creationSource: "mobile", }, }) .then(async (result) => { if (result._tag !== "Success") return; const targetThreadReady = await waitForThreadShell(props.environmentId, targetThreadId); if (!targetThreadReady) { Alert.alert( "Fork created", "Its thread data did not reach this client. Reconnect and try opening it from the thread list.", ); return; } navigation.navigate("Thread", { environmentId: props.environmentId, threadId: targetThreadId, }); }) .finally(() => setBusy(false)); }} className="h-7 w-7 items-center justify-center disabled:opacity-40" > {busy ? ( <ActivityIndicator size="small" /> ) : ( <SymbolView name="arrow.triangle.branch" size={13} tintColor={props.iconColor} type="monochrome" /> )} </Pressable> );} 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",Lines 1–500 of 2999. Load more
import * as Haptics from "expo-haptics"; import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; import { ThreadId, type ChatAttachment, type ChatFileAttachment, type ChatImageAttachment, type EnvironmentId, type MessageId, type OrchestrationV2ProjectedTurnItem, type RunId, } from "@t3tools/contracts"; import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { codexArtifactTemplatePresentationLabel, type CodexArtifactTemplate, } from "@t3tools/client-runtime/codex-artifact-templates"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { classifyMarkdownImageSource, markdownImageSourceFragment, } from "@t3tools/client-runtime/markdown-images"; import { resolveViewedImageAsset } from "@t3tools/client-runtime/work-log/presentation"; import { renderCodexFileCitationsAsMarkdown, splitCodexArtifactTemplateMarkdown, } from "@t3tools/client-runtime/codex-markdown-directives"; import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; import { HeaderHeightContext } from "@react-navigation/elements"; import { useFocusEffect, useNavigation } from "@react-navigation/native"; import { createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useId, type ReactNode, type RefObject, } from "react"; import { Markdown, type CustomRenderers, type NodeStyleOverrides, type PartialMarkdownTheme, } from "react-native-nitro-markdown"; import { ActivityIndicator, Alert, Image, Platform, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent, Pressable, ScrollView, StyleSheet, Text as NativeText, type ColorValue, useWindowDimensions, View, } from "react-native"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { isPdfFile } from "../../lib/filePreview"; import { PresentationSource } from "../../components/NativePresentation"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { FadeIn, FadeInUp, LinearTransition, type SharedValue, } from "react-native-reanimated"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { useFontFamily } from "../../lib/useFontFamily"; import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, type MarkdownFileContextMenu, type MarkdownImageRenderer, type NativeMarkdownTextStyle, type SelectableMarkdownSkill, } from "../../native/SelectableMarkdownText"; import { AppText as Text } from "../../components/AppText"; import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; import { VideoAttachmentTile } from "../../components/VideoAttachmentTile"; import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; import { attachmentVideoPreviewSource, mediaVideoPreviewUri, mediaVideoThumbnailKey, type MediaVideoPreviewSource, } from "../../lib/videoPreviewSource"; import { CopyTextButton } from "../../components/CopyTextButton"; import { parseReviewCommentMessageSegments, type ReviewInlineComment, } from "../review/reviewCommentSelection"; import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter"; import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface"; import { buildNativeReviewDiffData, createNativeReviewDiffTheme, NATIVE_REVIEW_DIFF_CONTENT_WIDTH, } from "../review/nativeReviewDiffAdapter"; import { buildReviewParsedDiff } from "../review/reviewModel"; import { cn } from "../../lib/cn"; import { deriveCenteredContentHorizontalPadding, deriveThreadFeedInitialContentInset, deriveThreadWorkLogSizing, type LayoutVariant, } from "../../lib/layout"; import { uuidv4 } from "../../lib/uuid"; import { resolveMarkdownFontSizes, resolveNativeMarkdownTypography, } from "../../lib/appearancePreferences"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; import { normalizeNativeMarkdownUrl, resolveMarkdownInlineCodePresentation, resolveMarkdownLinkPresentation, } from "@t3tools/mobile-markdown-text/links"; import { deriveThreadFeedPresentation, threadFeedRunIsUnsettled, isContextCompactionActivityGroup, type ThreadFeedEntry, type ThreadFeedLatestRun, } from "../../lib/threadActivity"; import type { ThreadContentPresentation } from "./threadContentPresentation"; import { resolveThreadFeedLiveFollow, type ThreadFeedLiveFollowEvent, type ThreadWorkGroupScrollPosition, } from "./thread-feed-live-follow"; import { collapsedWorkLogHeight, ThreadDisclosureChevron, THREAD_DISCLOSURE_TRANSITION_MS, ThreadWorkGroupToggle, ThreadWorkLog, } from "./thread-work-log"; import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; import { assetEnvironment, useAssetUrl, useAssetUrlState, useRefreshAssetUrl, } from "../../state/assets"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { usePreparedConnection } from "../../state/session"; import * as Option from "effect/Option"; import { videoMimeType } from "@t3tools/shared/video"; import { appAtomRegistry } from "../../state/atom-registry"; import { environmentThreadShells, threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import { useV2ItemSupport } from "../../state/v2-item-support"; import { basename, fileRoutePathSegments, isAbsolutePath, resolveWorkspaceRelativeFilePath, } from "../files/filePath"; import { waitForThreadShellReady } from "./threadForkNavigation"; import { resolveUserMessageIntentBadge } from "./userMessageIntentBadge"; import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu"; import { ThreadMarkdownImage, ThreadMarkdownImageUnavailable, ThreadMarkdownImageView, } from "./ThreadMarkdownImage"; const WIDE_MARKDOWN_BLOCK_OPTIONS = { // Native iOS blockquotes and adjacent selectable text are separate layout // chunks. Giving their shrink-to-fit bubble a definite width keeps both // chunks measured against the width at which UIKit draws them. includeBlockquotes: Platform.OS === "ios", includeOrderedLists: Platform.OS === "android", } as const; const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit", }); function formatMessageTime(input: string): string { const timestamp = Date.parse(input); if (Number.isNaN(timestamp)) { return ""; } return MESSAGE_TIME_FORMATTER.format(timestamp); } const TURN_FOLD_HEIGHT = 42; const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); // Let neighboring rows move out of the new rows' space before showing their text. const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay( THREAD_DISCLOSURE_TRANSITION_MS, ).duration(140); // Entering animations must only play for rows born just now — LegendList // remounts rows when they scroll back into view, and replaying an entrance for // old content would be its own kind of jank. const FRESH_ENTRY_WINDOW_MS = 3_000; function isFreshTimestamp(input: string): boolean { const timestamp = Date.parse(input); return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ENTRY_WINDOW_MS; } export interface ThreadFeedHistoryControls { readonly hasMoreHistory: boolean; readonly loading: boolean; readonly error: string | null; readonly onLoadEarlier: () => void; } export interface ThreadFeedProps { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly threadTitle: string; readonly workspaceRoot?: string | null; readonly feed: ReadonlyArray<ThreadFeedEntry>; readonly contentPresentation: ThreadContentPresentation; readonly agentLabel: string; readonly latestRun: ThreadFeedLatestRun | 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 historyControls?: ThreadFeedHistoryControls; readonly topAccessory?: ReactNode; readonly contentMaxWidth?: number; readonly layoutVariant?: LayoutVariant; readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; readonly onEndFollowEnabledChange?: (enabled: boolean) => void; readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; readonly onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; } async function waitForThreadShell( environmentId: EnvironmentId, threadId: ThreadId, ): Promise<boolean> { const atom = environmentThreadShells.threadShellAtom(scopeThreadRef(environmentId, threadId)); return waitForThreadShellReady({ read: () => appAtomRegistry.get(atom) !== null, }); } function AssistantForkButton(props: { readonly environmentId: EnvironmentId; readonly iconColor: ColorValue; readonly projectedItem: OrchestrationV2ProjectedTurnItem; readonly sourceTitle: string; }) { const support = useV2ItemSupport({ environmentId: props.environmentId, sourceThreadId: props.projectedItem.sourceThreadId, sourceItemId: props.projectedItem.sourceItemId, }); const forkFromRun = useAtomCommand(threadEnvironment.forkFromRun, "fork from response"); const navigation = useNavigation(); const [busy, setBusy] = useState(false); const canFork = canForkProjectedAssistantItem({ projectedItem: props.projectedItem, capabilities: support.providerSession?.capabilities, }); const runId = props.projectedItem.item.runId; if (!canFork || runId === null) return null; return ( <Pressable accessibilityRole="button" accessibilityLabel="Fork from this response" disabled={busy} onPress={() => { const targetThreadId = ThreadId.make(uuidv4()); setBusy(true); void Haptics.selectionAsync(); void forkFromRun({ environmentId: props.environmentId, input: { sourceThreadId: props.projectedItem.sourceThreadId, targetThreadId, runId, title: `${props.sourceTitle} fork`, creationSource: "mobile", }, }) .then(async (result) => { if (result._tag !== "Success") return; const targetThreadReady = await waitForThreadShell(props.environmentId, targetThreadId); if (!targetThreadReady) { Alert.alert( "Fork created", "Its thread data did not reach this client. Reconnect and try opening it from the thread list.", ); return; } navigation.navigate("Thread", { environmentId: props.environmentId, threadId: targetThreadId, }); }) .finally(() => setBusy(false)); }} className="h-7 w-7 items-center justify-center disabled:opacity-40" > {busy ? ( <ActivityIndicator size="small" /> ) : ( <SymbolView name="arrow.triangle.branch" size={13} tintColor={props.iconColor} type="monochrome" /> )} </Pressable> ); } 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",