2999 lines · 108.2 KB · TypeScript
RawDownload
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.