From 32276978559132e11000022b5b5e4c2e9226cfa5 Mon Sep 17 00:00:00 2001 From: macodev00 <273427913+macodev00@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:01:49 +0000 Subject: [PATCH] fix(mobile): smooth Android scrolling through long settled threads A settled assistant message was one list row, so scrolling it back into view mounted the whole text tree on the UI thread in a single frame. Android now recycles those containers and splits expensive messages into bounded slices. Live-follow still pins the end as the tail grows. Fixes #13925 --- .../threads/AndroidTranscriptCodeSlice.tsx | 159 +++++ .../src/features/threads/ThreadFeed.tsx | 229 ++++++- .../threads/androidTranscriptSlices.test.ts | 216 +++++++ .../threads/androidTranscriptSlices.ts | 591 ++++++++++++++++++ 4 files changed, 1177 insertions(+), 18 deletions(-) create mode 100644 apps/mobile/src/features/threads/AndroidTranscriptCodeSlice.tsx create mode 100644 apps/mobile/src/features/threads/androidTranscriptSlices.test.ts create mode 100644 apps/mobile/src/features/threads/androidTranscriptSlices.ts diff --git a/apps/mobile/src/features/threads/AndroidTranscriptCodeSlice.tsx b/apps/mobile/src/features/threads/AndroidTranscriptCodeSlice.tsx new file mode 100644 index 000000000000..c9918a79fb4e --- /dev/null +++ b/apps/mobile/src/features/threads/AndroidTranscriptCodeSlice.tsx @@ -0,0 +1,159 @@ +import { memo } from "react"; +import { Platform, ScrollView, StyleSheet, Text, View } from "react-native"; + +import type { NativeMarkdownTextStyle } from "@t3tools/mobile-markdown-text/types"; + +import { CopyTextButton } from "../../components/CopyTextButton"; +import type { AndroidTranscriptCodePart } from "./androidTranscriptSlices"; + +const MONO_FONT_FAMILY = Platform.select({ + ios: "ui-monospace", + android: "monospace", + default: "monospace", +}); + +/** + * Code inside a transcript slice scales with the body size, matching the + * highlighted fence (12pt at the default 15pt body). + */ +function codeSliceFontSize(textStyle: NativeMarkdownTextStyle): number { + return Math.max(10, Math.round(textStyle.fontSize * 0.8)); +} + +/** + * Line height for a plain code window. Kept identical to the highlighted fence + * so a windowed block does not jump when the reader stops on it. + */ +function codeSliceLineHeight(textStyle: NativeMarkdownTextStyle): number { + return codeSliceFontSize(textStyle) + 6; +} + +/** + * Corner radii for one window of a fence. Middle and trailing windows stay + * square on the joined edge so the windows read as a single card. + */ +function codeSliceRadius(part: AndroidTranscriptCodePart): { + readonly borderTopLeftRadius: number; + readonly borderTopRightRadius: number; + readonly borderBottomLeftRadius: number; + readonly borderBottomRightRadius: number; +} { + const radius = part === "start" || part === "end" ? 10 : 0; + return { + borderTopLeftRadius: part === "start" ? radius : 0, + borderTopRightRadius: part === "start" ? radius : 0, + borderBottomLeftRadius: part === "end" ? radius : 0, + borderBottomRightRadius: part === "end" ? radius : 0, + }; +} + +/** + * One plain-text window of a long fenced block. + * + * Highlighted fences mount a `Text` per Shiki token. On Android that tree is + * created on the UI thread in the frame the row enters, which is the settled- + * thread hitch. A window is a single non-selectable `Text`; the header copies + * the whole fence, not just the lines in view. + */ +export const AndroidTranscriptCodeSlice = memo(function AndroidTranscriptCodeSlice(props: { + readonly text: string; + readonly language: string | null; + readonly part: Exclude; + readonly fullCode: string; + readonly textStyle: NativeMarkdownTextStyle; +}) { + const fontSize = codeSliceFontSize(props.textStyle); + const lineHeight = codeSliceLineHeight(props.textStyle); + const showHeader = props.part === "start"; + const languageLabel = props.language?.toUpperCase() ?? "CODE"; + return ( + + {showHeader ? ( + + + {languageLabel} + + + + ) : null} + + + {props.text} + + + + ); +}); + +const styles = StyleSheet.create({ + card: { + borderCurve: "continuous", + borderWidth: 1, + overflow: "hidden", + }, + header: { + minHeight: 42, + borderBottomWidth: 1, + paddingLeft: 14, + paddingRight: 6, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + body: { + paddingHorizontal: 14, + paddingVertical: 12, + }, +}); diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 83b0bef9c022..f7a659ad1dac 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -5,7 +5,12 @@ import { } 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 { + useRecyclingEffect, + useViewabilityAmount, + type LegendListRecyclingState, + type LegendListRef, +} from "@legendapp/list/react-native"; import type { ChatAttachment, ChatFileAttachment, @@ -172,6 +177,14 @@ import { WORK_GROUP_TOGGLE_HEIGHT, } from "./thread-work-log"; import { appendPendingThreadMessages, type PendingThreadFeedEntry } from "./pending-thread-feed"; +import { AndroidTranscriptCodeSlice } from "./AndroidTranscriptCodeSlice"; +import { + androidTranscriptItemType, + assistantSliceGap, + assistantSliceMarkdown, + expandAndroidAssistantTranscriptRows, + type AndroidAssistantSliceEntry, +} from "./androidTranscriptSlices"; import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; import { @@ -235,15 +248,47 @@ 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. +// Entering animations must only play for rows born just now. Replaying one when +// a settled row scrolls back into view is its own kind of jank. const FRESH_ENTRY_WINDOW_MS = 3_000; +/** True when a row was created moments ago and may fade in as it mounts. */ function isFreshTimestamp(input: string): boolean { const timestamp = Date.parse(input); return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ENTRY_WINDOW_MS; } +/** + * Row id LegendList last painted in this container, when the item has one. + * Streaming updates replace the object but keep the id; a recycle does not. + */ +function rowRecycleId(item: unknown): string | undefined { + if (typeof item !== "object" || item === null || !("id" in item)) { + return undefined; + } + const id = item.id; + return typeof id === "string" ? id : undefined; +} + +/** + * Runs `reset` when this container is reused for a different feed row. + * Same-id updates, including a streaming append, do not count as a recycle. + * iOS remounts rows, so the effect never observes a previous item there. + */ +function useResetOnRowRecycle(reset: () => void) { + const resetRef = useRef(reset); + resetRef.current = reset; + useRecyclingEffect( + useCallback((info: LegendListRecyclingState) => { + const previousId = rowRecycleId(info.prevItem); + const nextId = rowRecycleId(info.item); + if (previousId !== undefined && previousId === nextId) { + return; + } + resetRef.current(); + }, []), + ); +} + export interface ThreadFeedProps { readonly worktreeSetup?: WorktreeSetupCardProps | null; readonly setupWorkingStartedAt?: string | null; @@ -279,6 +324,7 @@ export interface ThreadFeedProps { } | null; } +/** Image attachment row. A recycled container must retry the new attachment's URL. */ function MessageAttachmentImage(props: { readonly environmentId: EnvironmentId; readonly attachmentId: string; @@ -300,6 +346,9 @@ function MessageAttachmentImage(props: { const uri = useAssetUrl(props.environmentId, resource); const refreshAssetUrl = useRefreshAssetUrl(props.environmentId, resource); const retriedImage = useRef(false); + useResetOnRowRecycle(() => { + retriedImage.current = false; + }); if (uri === null) { return ( @@ -362,6 +411,7 @@ function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAtt return attachment.type === "file"; } +/** File attachment row. Opening state belongs to the attachment in this container. */ function MessageAttachmentFile(props: { readonly environmentId: EnvironmentId; readonly attachment: ChatFileAttachment; @@ -399,6 +449,11 @@ function MessageAttachmentFile(props: { : null; const openingRef = useRef(null); const [opening, setOpening] = useState(false); + useResetOnRowRecycle(() => { + openingRef.current?.abort(); + openingRef.current = null; + setOpening(false); + }); useFocusEffect( useCallback(() => { @@ -562,8 +617,13 @@ const ThreadMediaVisibleContext = createContext(false); // LegendList only computes hook visibility when the list has a viewability config. const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 }; +/** + * Gates video thumbnails on viewability. Visibility is per row, so a recycled + * container starts hidden until the new row reports itself. + */ function ThreadMediaVisibility(props: { readonly children: ReactNode }) { const [visible, setVisible] = useState(false); + useResetOnRowRecycle(() => setVisible(false)); useViewabilityAmount( useCallback((token) => setVisible(token.sizeVisible > 0), []), ); @@ -634,6 +694,7 @@ const markdownLinkStyles = StyleSheet.create({ }, }); +/** External link with a favicon. A failed host must not follow the container to the next link. */ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { readonly children: ReactNode; readonly color: string; @@ -642,6 +703,7 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { readonly onPress: (href: string) => void; }) { const [failedHost, setFailedHost] = useState(null); + useResetOnRowRecycle(() => setFailedHost(null)); const linkIcon = resolveMarkdownLinkIcon(props.host); const faviconUrl = linkIcon ? null : faviconUrlForOrigin(`https://${props.host}`); @@ -1350,8 +1412,117 @@ function useMarkdownStyles( ]); } +type PresentedThreadFeedEntry = + | PendingThreadFeedEntry + | AndroidAssistantSliceEntry>; + +/** + * Draws one window of an assistant message that was split so Android can mount + * it across frames. Chrome (attachments, copy, timestamp) stays on the last + * window; the copy still covers the whole message. + */ +function renderAssistantTranscriptSlice( + entry: AndroidAssistantSliceEntry>, + props: Parameters[1], +) { + const { message } = entry.source; + const { markdownStyles, iconSubtleColor } = props; + const styles = markdownStyles.assistant; + const renderedFullText = renderAssistantCitationsAsText(message.text); + const sliceMarkdown = assistantSliceMarkdown(entry.slice); + const timestampLabel = formatMessageTime(message.updatedAt); + const attachments = message.attachments ?? []; + const assistantTurnStillInProgress = + props.unsettledTurnId !== null && message.turnId === props.unsettledTurnId; + const showAssistantMeta = + entry.isLast && + props.terminalAssistantMessageIds.has(message.id) && + !assistantTurnStillInProgress && + !message.streaming; + const hasWideBlock = + entry.slice.kind === "code" || + hasWideMarkdownBlock(sliceMarkdown ?? "", WIDE_MARKDOWN_BLOCK_OPTIONS); + const enterAnimated = entry.isFirst && isFreshTimestamp(message.createdAt); + const gap = assistantSliceGap(entry.slice, entry.isLast); + + return ( + 0 ? { marginBottom: gap } : undefined} + {...(enterAnimated ? { entering: FadeIn.duration(220) } : {})} + > + {sliceMarkdown && sliceMarkdown.trim().length > 0 ? ( + + + + ) : entry.slice.kind === "code" && entry.slice.codePart !== "only" ? ( + + ) : null} + {entry.isLast + ? attachments.map((attachment) => { + return isImageAttachment(attachment) ? ( + + ) : isFileAttachment(attachment) ? ( + + ) : ( + + ); + }) + : null} + {showAssistantMeta ? ( + + + + {timestampLabel} + + + ) : null} + + ); +} + +/** + * Renders one transcript row. Long assistant messages arrive already sliced on + * Android; every other row is unchanged. + */ function renderFeedEntry( - info: { item: PendingThreadFeedEntry; index: number }, + info: { item: PresentedThreadFeedEntry; index: number }, props: Pick< ThreadFeedProps, | "environmentId" @@ -1391,6 +1562,9 @@ function renderFeedEntry( }, ) { const entry = info.item; + if (entry.type === "assistant-slice") { + return renderAssistantTranscriptSlice(entry, props); + } const { markdownStyles, iconSubtleColor, userBubbleColor } = props; if (entry.type === "turn-fold") { @@ -1781,8 +1955,10 @@ type UserMessageContentProps = { readonly renderImage: MarkdownImageRenderer; }; +/** User message body. The context sheet is closed when the container is reused. */ function UserMessageContent(props: UserMessageContentProps) { const [selected, setSelected] = useState<{ contextId: string; label: string } | null>(null); + useResetOnRowRecycle(() => setSelected(null)); const navigation = useNavigation(); const { selectedThread } = useThreadSelection(); const text = replaceComposerContextReferences(props.text, (ref) => { @@ -1947,6 +2123,11 @@ function ThreadFeedPlaceholder(props: { ); } +/** + * Virtualized transcript. On Android, long assistant messages are sliced and + * their containers are recycled so scrolling a settled thread does not remount + * each message's text tree. Live-follow still pins the end while the tail grows. + */ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const navigation = useNavigation(); const { themeAppearance } = useAppearancePreferences(); @@ -2450,16 +2631,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, [expandedWorkGroups]); const presentedFeed = useMemo( () => - appendPendingThreadMessages( - deriveThreadFeedPresentation( + expandAndroidAssistantTranscriptRows( + appendPendingThreadMessages( + deriveThreadFeedPresentation( + props.feed, + props.latestTurn, + expandedTurnIds, + expandedWorkGroupIds, + props.activeWorkStartedAt, + ), props.feed, - props.latestTurn, - expandedTurnIds, - expandedWorkGroupIds, - props.activeWorkStartedAt, + props.queuedMessages, ), - props.feed, - props.queuedMessages, + Platform.OS, ), [ props.queuedMessages, @@ -2600,7 +2784,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } }, [settleDisclosureAfterLayout]); - const shouldRestoreVisibleContentPosition = useCallback((entry: ThreadFeedEntry) => { + const shouldRestoreVisibleContentPosition = useCallback((entry: PresentedThreadFeedEntry) => { const disclosureAnchorKey = disclosureAnchorKeyRef.current; return disclosureAnchorKey === null || entry.id === disclosureAnchorKey; }, []); @@ -2713,11 +2897,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // exact; message rows stay undefined and use LegendList's per-type running // average once one of their type has been measured. const getFixedItemSize = useCallback( - (entry: ThreadFeedEntry) => { + (entry: PresentedThreadFeedEntry) => { if (workRowSizing.fixedRowHeight === undefined) { return undefined; } switch (entry.type) { + case "assistant-slice": + return undefined; case "message": // A collapsed reasoning row is the same chrome as a work toggle. return entry.message.role === "reasoning" && !expandedReasoningMessageIds.has(entry.id) @@ -2747,9 +2933,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // Disclosures can mount existing offscreen rows as well as new work rows. // Fade those in after movement; never retain removed rows over replacements. const renderItem = useCallback( - (info: { item: PendingThreadFeedEntry; index: number }) => ( + (info: { item: PresentedThreadFeedEntry; index: number }) => ( + // No key here. On Android the list recycles this container; a key would + // destroy the native text tree every time a settled row comes back. @@ -2932,8 +3119,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { viewabilityConfig={THREAD_MEDIA_VIEWABILITY_CONFIG} keyExtractor={(entry) => entry.id} getItemType={(entry) => - entry.type === "message" ? `message:${entry.message.role}` : entry.type + androidTranscriptItemType(entry) ?? + (entry.type === "message" ? `message:${entry.message.role}` : entry.type) } + // Android recycles row containers so a settled message is not + // destroyed and mounted again when it re-enters the draw window. + // iOS keeps remounting; UITextView is cheap, and recycling would + // reuse its selection state. + recycleItems={Platform.OS === "android"} getFixedItemSize={getFixedItemSize} // Virtualized rows must move with their measurements. Native layout // transitions can retain stale positions during sync, even at duration 0. diff --git a/apps/mobile/src/features/threads/androidTranscriptSlices.test.ts b/apps/mobile/src/features/threads/androidTranscriptSlices.test.ts new file mode 100644 index 000000000000..89a7e7408539 --- /dev/null +++ b/apps/mobile/src/features/threads/androidTranscriptSlices.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + ANDROID_TRANSCRIPT_CODE_LINE_BUDGET, + ANDROID_TRANSCRIPT_MARKDOWN_CHAR_BUDGET, + ANDROID_TRANSCRIPT_SLICE_GAP, + androidTranscriptItemType, + assistantSliceGap, + assistantSliceMarkdown, + expandAndroidAssistantTranscriptRows, + fencedCodeMarkdown, + splitAssistantTranscriptSlices, + type AndroidTranscriptSlice, +} from "./androidTranscriptSlices"; + +/** Minimal feed message for slice tests. */ +function message(id: string, role: "assistant" | "user", text: string) { + return { + type: "message" as const, + id, + createdAt: "2026-01-01T00:00:00.000Z", + message: { role, text }, + }; +} + +/** Assistant message fixture. */ +function assistantMessage(id: string, text: string) { + return message(id, "assistant", text); +} + +/** Fenced block with a numbered line per row, so windows are easy to count. */ +function codeFence(language: string, lineCount: number): string { + const body = Array.from({ length: lineCount }, (_, index) => `line ${index + 1}`).join("\n"); + return `\`\`\`${language}\n${body}\n\`\`\``; +} + +/** Compact kind/part/line-count label for slice assertions. */ +function sliceKinds(slices: readonly AndroidTranscriptSlice[]): string[] { + return slices.map((slice) => + slice.kind === "code" ? `code:${slice.codePart}:${slice.text.split("\n").length}` : "markdown", + ); +} + +describe("splitAssistantTranscriptSlices", () => { + it("leaves a short message with one fence as a single row", () => { + const markdown = `See this.\n\n${codeFence("ts", 4)}`; + expect(splitAssistantTranscriptSlices(markdown)).toBeNull(); + }); + + it("splits prose that would mount as one oversized selectable text", () => { + const paragraph = "word ".repeat(200).trim(); + const markdown = `${paragraph}\n\n${paragraph}`; + const slices = splitAssistantTranscriptSlices(markdown); + expect(slices).not.toBeNull(); + expect(slices!.every((slice) => slice.kind === "markdown")).toBe(true); + for (const slice of slices!) { + expect(slice.text.length).toBeLessThanOrEqual(ANDROID_TRANSCRIPT_MARKDOWN_CHAR_BUDGET); + } + const words = (value: string) => value.replaceAll(/\s+/g, " ").trim(); + expect(words(slices!.map((slice) => slice.text).join(" "))).toBe(words(markdown)); + }); + + it("puts each fence on its own row when a message has more than one", () => { + const markdown = `${codeFence("ts", 3)}\n\nBetween.\n\n${codeFence("go", 2)}`; + const slices = splitAssistantTranscriptSlices(markdown); + expect(sliceKinds(slices!)).toEqual(["code:only:3", "markdown", "code:only:2"]); + expect(assistantSliceMarkdown(slices![0]!)).toContain("```ts"); + expect(assistantSliceMarkdown(slices![2]!)).toContain("```go"); + }); + + it("windows a long fence and keeps earlier windows stable as it grows", () => { + const before = codeFence("ts", ANDROID_TRANSCRIPT_CODE_LINE_BUDGET + 1); + const after = codeFence("ts", ANDROID_TRANSCRIPT_CODE_LINE_BUDGET * 2 + 3); + const first = splitAssistantTranscriptSlices(before); + const grown = splitAssistantTranscriptSlices(after); + expect(sliceKinds(first!)).toEqual([ + `code:start:${ANDROID_TRANSCRIPT_CODE_LINE_BUDGET}`, + "code:end:1", + ]); + expect(sliceKinds(grown!)).toEqual([ + `code:start:${ANDROID_TRANSCRIPT_CODE_LINE_BUDGET}`, + `code:middle:${ANDROID_TRANSCRIPT_CODE_LINE_BUDGET}`, + "code:end:3", + ]); + const grownHead = grown![0]!; + const firstHead = first![0]!; + const grownNext = grown![1]!; + const firstNext = first![1]!; + expect(grownHead).toMatchObject({ + key: firstHead.key, + text: firstHead.text, + }); + expect(grownNext.key).toBe(firstNext.key); + expect(grownHead.kind === "code" && grownHead.text.startsWith("line 1")).toBe(true); + expect(grownHead.kind === "code" && grownHead.text.includes("line 17")).toBe(false); + expect(assistantSliceMarkdown(grown![0]!)).toBeNull(); + expect( + grown!.every( + (slice) => + slice.kind !== "code" || + slice.fullCode.split("\n").length === ANDROID_TRANSCRIPT_CODE_LINE_BUDGET * 2 + 3, + ), + ).toBe(true); + }); + + it("treats an unclosed streaming fence as code and does not renumber finished windows", () => { + const opened = `Intro.\n\n\`\`\`ts\n${Array.from({ length: 10 }, (_, index) => `line ${index + 1}`).join("\n")}`; + const longer = `${opened}\n${Array.from({ length: 12 }, (_, index) => `line ${index + 11}`).join("\n")}`; + const before = splitAssistantTranscriptSlices(opened); + const after = splitAssistantTranscriptSlices(longer); + expect(before).toBeNull(); + const intro = after![0]!; + const codeHead = after![1]!; + expect(intro).toMatchObject({ kind: "markdown", text: "Intro." }); + expect(codeHead).toMatchObject({ kind: "code", codePart: "start" }); + expect(codeHead.kind === "code" && codeHead.text.split("\n")).toHaveLength( + ANDROID_TRANSCRIPT_CODE_LINE_BUDGET, + ); + }); + + it("keeps a GFM table intact when the surrounding message is split", () => { + const cell = "c".repeat(80); + const row = `| ${cell} | ${cell} |`; + const table = [row, "| --- | --- |", row, row].join("\n"); + const slices = splitAssistantTranscriptSlices(`${table}\n\n${"word ".repeat(200).trim()}`); + const tableSlices = slices!.filter((slice) => slice.text.includes("| --- |")); + expect(tableSlices).toHaveLength(1); + expect(tableSlices[0]!.text).toBe(table); + }); + + it("ignores a four-space indented fence and still splits long prose", () => { + const indented = ` \`\`\`\n${"x".repeat(ANDROID_TRANSCRIPT_MARKDOWN_CHAR_BUDGET + 40)}`; + const slices = splitAssistantTranscriptSlices(indented); + expect(slices!.every((slice) => slice.kind === "markdown")).toBe(true); + }); +}); + +describe("expandAndroidAssistantTranscriptRows", () => { + it("returns the same array off Android and for rows that are already small", () => { + const feed = [ + message("user-1", "user", "hello"), + { type: "thinking" as const, id: "thinking" }, + assistantMessage("short", `ok\n\n${codeFence("ts", 2)}`), + ]; + expect(expandAndroidAssistantTranscriptRows(feed, "ios")).toBe(feed); + expect(expandAndroidAssistantTranscriptRows(feed, "android")).toBe(feed); + }); + + it("keeps the message id on the first slice and appends the rest", () => { + const fence = codeFence("ts", ANDROID_TRANSCRIPT_CODE_LINE_BUDGET + 2); + const feed = [ + message("user-1", "user", "ship it"), + { type: "work-toggle" as const, id: "work-1" }, + assistantMessage("assistant-1", fence), + ]; + const rows = expandAndroidAssistantTranscriptRows(feed, "android"); + expect(rows.map((row) => row.type)).toEqual([ + "message", + "work-toggle", + "assistant-slice", + "assistant-slice", + ]); + expect(rows[0]).toBe(feed[0]); + expect(rows[1]).toBe(feed[1]); + const head = rows[2]; + const tail = rows[3]; + if (head?.type !== "assistant-slice" || tail?.type !== "assistant-slice") { + throw new Error("expected assistant slices"); + } + expect(head.id).toBe("assistant-1"); + expect(head.isFirst).toBe(true); + expect(head.isLast).toBe(false); + expect(tail.id).toBe(`assistant-1:${tail.slice.key}`); + expect(tail.isLast).toBe(true); + expect(tail.source).toBe(feed[2]); + expect(androidTranscriptItemType(head)).toBe("assistant-code-head"); + expect(androidTranscriptItemType(tail)).toBe("assistant-code-body"); + expect(androidTranscriptItemType(feed[0]!)).toBeNull(); + }); + + it("keeps earlier slice ids when the settled message later grows at the end", () => { + const before = expandAndroidAssistantTranscriptRows( + [assistantMessage("assistant-1", codeFence("ts", ANDROID_TRANSCRIPT_CODE_LINE_BUDGET + 1))], + "android", + ); + const after = expandAndroidAssistantTranscriptRows( + [assistantMessage("assistant-1", codeFence("ts", ANDROID_TRANSCRIPT_CODE_LINE_BUDGET + 4))], + "android", + ); + expect(before.map((row) => row.id)).toEqual([after[0]!.id, after[1]!.id]); + expect(after).toHaveLength(2); + }); +}); + +describe("assistant slice presentation", () => { + it("closes a fence that contains backticks and leaves plain windows without markdown", () => { + const fenced = fencedCodeMarkdown("ts", "const tick = ```;"); + expect(fenced.startsWith("````")).toBe(true); + expect(fenced).toContain("const tick = ```;"); + expect(fenced.trimEnd().endsWith("````")).toBe(true); + }); + + it("does not gap code windows that belong to the same fence", () => { + const slices = splitAssistantTranscriptSlices( + codeFence("ts", ANDROID_TRANSCRIPT_CODE_LINE_BUDGET * 2 + 1), + )!; + expect(assistantSliceGap(slices[0]!, false)).toBe(0); + expect(assistantSliceGap(slices[1]!, false)).toBe(0); + expect(assistantSliceGap(slices[2]!, true)).toBe(0); + const prose = splitAssistantTranscriptSlices( + `${"word ".repeat(200).trim()}\n\n${"word ".repeat(200).trim()}`, + )!; + expect(assistantSliceGap(prose[0]!, false)).toBe(ANDROID_TRANSCRIPT_SLICE_GAP); + expect(assistantSliceGap(prose[0]!, true)).toBe(0); + }); +}); diff --git a/apps/mobile/src/features/threads/androidTranscriptSlices.ts b/apps/mobile/src/features/threads/androidTranscriptSlices.ts new file mode 100644 index 000000000000..85e4e4aa1ac5 --- /dev/null +++ b/apps/mobile/src/features/threads/androidTranscriptSlices.ts @@ -0,0 +1,591 @@ +import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations"; + +/** + * Prose mounted as one Android text view. Larger selectable paragraphs are the + * `ReactTextView.setText` stalls measured on long settled threads. + */ +export const ANDROID_TRANSCRIPT_MARKDOWN_CHAR_BUDGET = 720; + +/** + * Code lines mounted as one Android row. Past this, a fenced block is windowed + * so a fling never builds the whole fence's text tree in a single frame. + */ +export const ANDROID_TRANSCRIPT_CODE_LINE_BUDGET = 16; + +/** Gap between slices that are not a continuation of the same code fence. */ +export const ANDROID_TRANSCRIPT_SLICE_GAP = 14; + +export type AndroidTranscriptCodePart = "only" | "start" | "middle" | "end"; + +export type AndroidTranscriptSlice = + | { + readonly kind: "markdown"; + readonly key: string; + readonly text: string; + } + | { + readonly kind: "code"; + readonly key: string; + readonly text: string; + readonly language: string | null; + readonly codePart: AndroidTranscriptCodePart; + readonly fullCode: string; + }; + +interface TranscriptMessageEntry { + readonly type: "message"; + readonly id: string; + readonly createdAt: string; + readonly message: { + readonly role: string; + readonly text: string; + }; +} + +export interface AndroidAssistantSliceEntry { + readonly type: "assistant-slice"; + readonly id: string; + readonly createdAt: string; + readonly source: TSource; + readonly slice: AndroidTranscriptSlice; + readonly isFirst: boolean; + readonly isLast: boolean; +} + +interface MarkdownRange { + readonly text: string; + readonly start: number; +} + +interface TranscriptBlock { + readonly kind: "markdown" | "code"; + readonly text: string; + readonly start: number; + readonly language: string | null; + readonly lineCount: number; +} + +/** + * Splits an expensive assistant message into bounded Android list rows. + * + * Short messages return null so they stay one row and keep the highlighted + * code path. A streaming append does not renumber earlier slices: keys come + * from source offsets, and each code window is a fixed line range. + */ +export function splitAssistantTranscriptSlices( + markdown: string, +): readonly AndroidTranscriptSlice[] | null { + if ( + markdown.length <= ANDROID_TRANSCRIPT_MARKDOWN_CHAR_BUDGET && + !markdown.includes("```") && + !markdown.includes("~~~") + ) { + return null; + } + const normalized = markdown.replaceAll("\r\n", "\n"); + const blocks = collectTranscriptBlocks(normalized); + if (!shouldSplitAssistantTranscript(normalized, blocks)) { + return null; + } + + const slices: AndroidTranscriptSlice[] = []; + for (const block of blocks) { + if (block.kind === "code") { + slices.push(...sliceCodeBlock(block)); + continue; + } + slices.push(...sliceMarkdownRegion(block.text, block.start)); + } + + return slices.length > 1 ? slices : null; +} + +/** + * Expands assistant messages that would mount an unbounded text tree. + * + * Non-Android feeds are returned unchanged, including the same array, so iOS + * live-follow and scroll-to-end keep today's row identity. The first slice + * reuses the message id; later slices append, which is the direction + * `maintainScrollAtEnd` already follows. + */ +export function expandAndroidAssistantTranscriptRows< + TEntry extends { readonly type: string; readonly id: string }, +>( + entries: readonly TEntry[], + platform: string, +): readonly (TEntry | AndroidAssistantSliceEntry>)[] { + if (platform !== "android") { + return entries; + } + + let changed = false; + const rows: (TEntry | AndroidAssistantSliceEntry>)[] = []; + for (const entry of entries) { + const slices = slicesForEntry(entry); + if (!slices) { + rows.push(entry); + continue; + } + changed = true; + const source = entry as Extract; + for (let index = 0; index < slices.length; index += 1) { + const slice = slices[index]; + if (!slice) continue; + rows.push({ + type: "assistant-slice", + id: index === 0 ? source.id : `${source.id}:${slice.key}`, + createdAt: source.createdAt, + source, + slice, + isFirst: index === 0, + isLast: index === slices.length - 1, + }); + } + } + + return changed ? rows : entries; +} + +/** + * LegendList recycle pool for a slice. Pools stay separate so a plain code + * window is not reconciled into a highlighted fence or a prose tree. + */ +export function androidTranscriptItemType(entry: { + readonly type: string; + readonly slice?: AndroidTranscriptSlice; +}): string | null { + if (entry.type !== "assistant-slice" || !entry.slice) { + return null; + } + if (entry.slice.kind === "markdown") { + return "assistant-markdown-slice"; + } + if (entry.slice.codePart === "only") { + return "assistant-code-block"; + } + if (entry.slice.codePart === "start") { + return "assistant-code-head"; + } + return "assistant-code-body"; +} + +/** + * Markdown for slices the shared renderer can draw. Plain windows of a long + * fence return null; those are one `Text`, not a token per span. + */ +export function assistantSliceMarkdown(slice: AndroidTranscriptSlice): string | null { + if (slice.kind === "markdown") { + return slice.text; + } + if (slice.codePart !== "only") { + return null; + } + return fencedCodeMarkdown(slice.language, slice.text); +} + +/** + * Space after a slice row. Continued code windows share one card, so they do + * not take the gap that separate blocks use. The last slice uses the message + * row's own bottom margin instead. + */ +export function assistantSliceGap(slice: AndroidTranscriptSlice, isLast: boolean): number { + if (isLast) { + return 0; + } + if (slice.kind === "code" && (slice.codePart === "start" || slice.codePart === "middle")) { + return 0; + } + return ANDROID_TRANSCRIPT_SLICE_GAP; +} + +/** + * Wraps a code window in a fence the markdown renderer already knows how to + * draw. The fence is longer than any backtick run in the body so the body + * cannot close it early. + */ +export function fencedCodeMarkdown(language: string | null, code: string): string { + let longestRun = 0; + let run = 0; + for (const character of code) { + if (character === "`") { + run += 1; + longestRun = Math.max(longestRun, run); + } else { + run = 0; + } + } + const fence = "`".repeat(Math.max(3, longestRun + 1)); + const info = language ? language.replace(/[\r\n`]/g, "") : ""; + return `${fence}${info}\n${code}\n${fence}`; +} + +const assistantSliceCache = new Map(); +const ASSISTANT_SLICE_CACHE_LIMIT = 200; + +/** + * Returns slice rows for an assistant message, or null when the entry should + * stay as it is. User, reasoning, and non-message rows are never split. + * Results are cached by the raw message text so a streaming tail does not + * re-scan every earlier message. + */ +function slicesForEntry(entry: { + readonly type: string; + readonly id: string; +}): readonly AndroidTranscriptSlice[] | null { + if (!isAssistantMessageEntry(entry)) { + return null; + } + const raw = entry.message.text; + const cached = assistantSliceCache.get(raw); + if (cached !== undefined) { + return cached; + } + const text = renderAssistantCitationsAsText(raw); + const slices = text.trim().length === 0 ? null : splitAssistantTranscriptSlices(text); + assistantSliceCache.delete(raw); + assistantSliceCache.set(raw, slices); + while (assistantSliceCache.size > ASSISTANT_SLICE_CACHE_LIMIT) { + const oldest = assistantSliceCache.keys().next().value; + if (oldest === undefined) { + break; + } + assistantSliceCache.delete(oldest); + } + return slices; +} + +/** + * Narrows a feed entry to an assistant message with the fields slicing reads. + */ +function isAssistantMessageEntry(entry: { + readonly type: string; + readonly id: string; +}): entry is TranscriptMessageEntry { + if ( + entry.type !== "message" || + !("createdAt" in entry) || + typeof entry.createdAt !== "string" || + !("message" in entry) + ) { + return false; + } + const message = entry.message; + if ( + typeof message !== "object" || + message === null || + !("role" in message) || + !("text" in message) + ) { + return false; + } + return message.role === "assistant" && typeof message.text === "string"; +} + +/** + * True when one list row would mount more text than a 120 Hz frame can afford. + * One short fence stays on the highlighted path; a second fence or a long + * fence is enough to split. + */ +function shouldSplitAssistantTranscript( + markdown: string, + blocks: readonly TranscriptBlock[], +): boolean { + if (markdown.length > ANDROID_TRANSCRIPT_MARKDOWN_CHAR_BUDGET) { + return true; + } + let codeBlocks = 0; + for (const block of blocks) { + if (block.kind !== "code") { + continue; + } + codeBlocks += 1; + if (block.lineCount > ANDROID_TRANSCRIPT_CODE_LINE_BUDGET) { + return true; + } + } + return codeBlocks >= 2; +} + +/** + * Walks top-level fences. Indented code and prose stay in markdown regions so + * a later char budget can split them without breaking a fence in half. + */ +function collectTranscriptBlocks(markdown: string): readonly TranscriptBlock[] { + const lines = markdown.split("\n"); + const blocks: TranscriptBlock[] = []; + let offset = 0; + let lineIndex = 0; + + while (lineIndex < lines.length) { + const line = lines[lineIndex] ?? ""; + const opener = fenceMarker(line); + if (!opener) { + const start = offset; + const region: string[] = []; + while (lineIndex < lines.length && !fenceMarker(lines[lineIndex] ?? "")) { + region.push(lines[lineIndex] ?? ""); + offset += (lines[lineIndex] ?? "").length + 1; + lineIndex += 1; + } + const text = region.join("\n").replace(/\n+$/, ""); + if (text.trim().length > 0) { + blocks.push({ + kind: "markdown", + text, + start, + language: null, + lineCount: 0, + }); + } + continue; + } + + const fenceStart = offset; + const language = fenceLanguage(line, opener); + offset += line.length + 1; + lineIndex += 1; + const body: string[] = []; + while (lineIndex < lines.length && !isClosingFence(lines[lineIndex] ?? "", opener)) { + body.push(lines[lineIndex] ?? ""); + offset += (lines[lineIndex] ?? "").length + 1; + lineIndex += 1; + } + if (lineIndex < lines.length) { + offset += (lines[lineIndex] ?? "").length + 1; + lineIndex += 1; + } + const text = body.join("\n"); + blocks.push({ + kind: "code", + text, + start: fenceStart, + language, + lineCount: text.length === 0 ? 0 : body.length, + }); + } + + return blocks; +} + +/** + * Breaks a prose region on paragraph boundaries, then on lines, so no markdown + * slice carries more than the char budget into one selectable text view. + */ +function sliceMarkdownRegion(text: string, regionStart: number): readonly AndroidTranscriptSlice[] { + const slices: AndroidTranscriptSlice[] = []; + let buffer = ""; + let bufferStart = regionStart; + + const flush = () => { + if (buffer.trim().length === 0) { + buffer = ""; + return; + } + slices.push({ + kind: "markdown", + key: `md:${bufferStart}`, + text: buffer, + }); + buffer = ""; + }; + + for (const paragraph of paragraphRanges(text, regionStart)) { + if (paragraph.text.length > ANDROID_TRANSCRIPT_MARKDOWN_CHAR_BUDGET) { + flush(); + // A table split on a row boundary no longer parses as a table. Keep it + // whole; one wide table is cheaper than a broken one, and code fences + // are what the scroll trace was mounting. + if (isMarkdownTable(paragraph.text)) { + slices.push({ + kind: "markdown", + key: `md:${paragraph.start}`, + text: paragraph.text, + }); + continue; + } + let pieceStart = paragraph.start; + for (const piece of hardSplitText(paragraph.text)) { + slices.push({ + kind: "markdown", + key: `md:${pieceStart}`, + text: piece, + }); + pieceStart += piece.length; + } + continue; + } + + const combined = buffer.length === 0 ? paragraph.text : `${buffer}\n\n${paragraph.text}`; + if (buffer.length > 0 && combined.length > ANDROID_TRANSCRIPT_MARKDOWN_CHAR_BUDGET) { + flush(); + buffer = paragraph.text; + bufferStart = paragraph.start; + continue; + } + if (buffer.length === 0) { + bufferStart = paragraph.start; + } + buffer = combined; + } + flush(); + return slices; +} + +/** + * Windows a fence into fixed line ranges. The first window's text stops + * changing once it fills, so scrolling back reuses that row instead of + * remounting the rest of the file. + */ +function sliceCodeBlock(block: TranscriptBlock): readonly AndroidTranscriptSlice[] { + const lines = block.text.length === 0 ? [] : block.text.split("\n"); + if (lines.length === 0) { + return []; + } + const windows: string[] = []; + for (let index = 0; index < lines.length; index += ANDROID_TRANSCRIPT_CODE_LINE_BUDGET) { + windows.push(lines.slice(index, index + ANDROID_TRANSCRIPT_CODE_LINE_BUDGET).join("\n")); + } + return windows.map((text, index) => ({ + kind: "code" as const, + key: `code:${block.start}:${index}`, + text, + language: block.language, + codePart: codeWindowPart(index, windows.length), + fullCode: block.text, + })); +} + +/** + * Names a code window so the first piece can show the header and the last + * piece can close the card. A fence that fits in one window stays "only". + */ +function codeWindowPart(index: number, count: number): AndroidTranscriptCodePart { + if (count <= 1) { + return "only"; + } + if (index === 0) { + return "start"; + } + if (index === count - 1) { + return "end"; + } + return "middle"; +} + +/** + * Paragraphs with offsets into the original region. Blank lines are the split + * points; they are not themselves slices. + */ +function paragraphRanges(text: string, regionStart: number): readonly MarkdownRange[] { + const ranges: MarkdownRange[] = []; + let cursor = 0; + while (cursor < text.length) { + while (cursor < text.length && text[cursor] === "\n") { + cursor += 1; + } + if (cursor >= text.length) { + break; + } + const start = cursor; + const nextBreak = text.indexOf("\n\n", cursor); + const end = nextBreak === -1 ? text.length : nextBreak; + ranges.push({ text: text.slice(start, end), start: regionStart + start }); + cursor = end + 2; + } + return ranges; +} + +/** + * True for a GFM table. Those stay one slice so a row split cannot drop the + * header rule and turn the rest into plain paragraphs. + */ +function isMarkdownTable(text: string): boolean { + let hasPipe = false; + let hasRule = false; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (trimmed.includes("|")) { + hasPipe = true; + } + if (/^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/.test(trimmed)) { + hasRule = true; + } + } + return hasPipe && hasRule; +} + +/** + * Splits one over-long paragraph on line breaks, then spaces, then a hard cut, + * so a single line cannot exceed the markdown char budget. + */ +function hardSplitText(text: string): readonly string[] { + if (text.length <= ANDROID_TRANSCRIPT_MARKDOWN_CHAR_BUDGET) { + return [text]; + } + const pieces: string[] = []; + let cursor = 0; + while (cursor < text.length) { + const remaining = text.slice(cursor); + if (remaining.length <= ANDROID_TRANSCRIPT_MARKDOWN_CHAR_BUDGET) { + pieces.push(remaining); + break; + } + const window = remaining.slice(0, ANDROID_TRANSCRIPT_MARKDOWN_CHAR_BUDGET); + const lineBreak = window.lastIndexOf("\n"); + const spaceBreak = window.lastIndexOf(" "); + const splitAt = lineBreak > 0 ? lineBreak + 1 : spaceBreak > 0 ? spaceBreak + 1 : window.length; + pieces.push(remaining.slice(0, splitAt)); + cursor += splitAt; + } + return pieces; +} + +/** + * Opening fence marker (`\`\`\`` or `~~~`), including a leading indent of up + * to three spaces. Four-space indented code is left for the prose splitter. + */ +function fenceMarker(line: string): { readonly char: "`" | "~"; readonly length: number } | null { + const match = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(line); + if (!match) { + return null; + } + const marker = match[2] ?? ""; + const first = marker[0]; + if (first !== "`" && first !== "~") { + return null; + } + return { char: first, length: marker.length }; +} + +/** + * Language info word from an opening fence. Empty info stays null so the + * header can fall back to a generic code label. + */ +function fenceLanguage( + line: string, + opener: { readonly char: "`" | "~"; readonly length: number }, +): string | null { + const match = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(line); + const info = (match?.[3] ?? "").trim(); + if (info.length === 0) { + return null; + } + const word = info.split(/\s+/)[0] ?? ""; + if (word.length === 0 || word.includes(opener.char)) { + return null; + } + return word; +} + +/** + * True when `line` closes `opener`. The closing line is only the marker, and + * it must be at least as long as the opener of the same character. + */ +function isClosingFence( + line: string, + opener: { readonly char: "`" | "~"; readonly length: number }, +): boolean { + const match = /^( {0,3})(`{3,}|~{3,})[ \t]*$/.exec(line); + const marker = match?.[2] ?? ""; + return marker.length >= opener.length && marker[0] === opener.char; +}