From 41e8d32b06fbd4b75991dbf11b1193c806f78e3f Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:22:24 -0700 Subject: [PATCH] perf(mobile): ThreadFeed compiles with the React Compiler ThreadFeed's render props read `props.listRef`, which the compiler treats as a ref read during render. The component now destructures its ref and callbacks, the attachment opener settles its download and command result instead of throwing inside try/finally, and the manual useMemo/useCallback around its handlers and derived data are gone. use-thread-selection kept the last complete route params in a ref that it read during render. They are now previous-value state, and the pending creation is compared by contents so the compiled hook does not set state on every render. Both files leave KNOWN_BAILOUTS. Co-Authored-By: Claude Opus 5.5 --- .../src/features/threads/ThreadFeed.tsx | 1071 +++++++---------- apps/mobile/src/reactCompilerCoverage.test.ts | 2 - apps/mobile/src/state/use-thread-selection.ts | 125 +- 3 files changed, 524 insertions(+), 674 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 2a43d6794b20..73b8e5d8ae0e 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -50,7 +50,7 @@ import { import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; -import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { classifyMarkdownImageSource, markdownImageSourceFragment, @@ -568,46 +568,47 @@ function MessageAttachmentFile(props: { const controller = new AbortController(); openingRef.current = controller; setOpening(true); + const finish = (message: string | null) => { + if (message !== null && !controller.signal.aborted) { + Dialog.alert("Could not open attachment", message); + } + if (openingRef.current === controller) { + openingRef.current = null; + setOpening(false); + } + }; void (async () => { - try { - const result = await createAssetUrl({ - environmentId: props.environmentId, - input: { - resource: { - _tag: "attachment", - attachmentId: attachment.id, - fileName: attachment.name, - mimeType: attachment.mimeType, - }, + 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({ + }, + }); + if (controller.signal.aborted) return finish(null); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + return finish(error instanceof Error ? error.message : "The attachment is unavailable."); + } + const url = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl); + if (url === null) return finish("The attachment could not be opened."); + const shared = await settlePromise(() => + downloadAndShareAttachment({ url, attachment, signal: controller.signal, sourceIdentifier, - }); - } catch (error) { - if (!controller.signal.aborted) { - Dialog.alert( - "Could not open attachment", - error instanceof Error ? error.message : "The attachment is unavailable.", - ); - } - } finally { - if (openingRef.current === controller) { - openingRef.current = null; - setOpening(false); - } + }), + ); + if (shared._tag === "Failure") { + const error = squashAtomCommandFailure(shared); + return finish(error instanceof Error ? error.message : "The attachment is unavailable."); } + finish(null); })(); }; @@ -1673,6 +1674,7 @@ function ThreadFeedPlaceholder(props: { } export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { + const { listRef, onEndFollowEnabledChange, onHeaderMaterialVisibilityChange } = props; const workflowOpening = useWorkflowOpening(props.environmentId, props.threadId); const deviceType = useDeviceType(); const duoColumns = useNativeWorkspaceColumnsSupported() && isDuoDevice(deviceType); @@ -1688,18 +1690,18 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const headerMaterialVisibleRef = useRef(false); const { width: windowWidth, fontScale } = useWindowDimensions(); const { appearance } = useAppearancePreferences(); - const workRowSizing = useMemo( - () => deriveThreadWorkLogSizing({ baseFontSize: appearance.baseFontSize, fontScale }), - [appearance.baseFontSize, fontScale], - ); + const workRowSizing = deriveThreadWorkLogSizing({ + baseFontSize: appearance.baseFontSize, + fontScale, + }); const previousTextSize = useRef(workRowSizing.textSizeKey); useLayoutEffect(() => { if (previousTextSize.current === workRowSizing.textSizeKey) { return; } previousTextSize.current = workRowSizing.textSizeKey; - props.listRef.current?.clearCaches({ mode: "sizes" }); - }, [workRowSizing.textSizeKey, props.listRef]); + listRef.current?.clearCaches({ mode: "sizes" }); + }, [workRowSizing.textSizeKey, listRef]); const [viewportWidth, setViewportWidth] = useState(() => props.layoutVariant === "split" ? 0 : windowWidth, ); @@ -1714,23 +1716,17 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // momentum; scroll events only break follow inside that session, so MVCP // compensations and programmatic scrolls never strand a follower. const userScrollSessionRef = useRef(false); - const setEndFollow = useCallback( - (enabled: boolean) => { - if (endFollowEnabledRef.current === enabled) { - return; - } - endFollowEnabledRef.current = enabled; - setEndFollowEnabled(enabled); - props.onEndFollowEnabledChange?.(enabled); - }, - [props.onEndFollowEnabledChange], - ); - const transitionEndFollow = useCallback( - (event: ThreadFeedLiveFollowEvent) => { - setEndFollow(resolveThreadFeedLiveFollow(endFollowEnabledRef.current, event)); - }, - [setEndFollow], - ); + const setEndFollow = (enabled: boolean) => { + if (endFollowEnabledRef.current === enabled) { + return; + } + endFollowEnabledRef.current = enabled; + setEndFollowEnabled(enabled); + onEndFollowEnabledChange?.(enabled); + }; + const transitionEndFollow = (event: ThreadFeedLiveFollowEvent) => { + setEndFollow(resolveThreadFeedLiveFollow(endFollowEnabledRef.current, event)); + }; const [interactionState, setInteractionState] = useState<{ readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; @@ -1793,337 +1789,282 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const iconSubtleColor = theme["--color-icon-subtle"]; const screenColor = theme["--color-screen"]; const userBubbleColor = theme["--color-user-bubble"]; - const onMarkdownLinkPress = useCallback( - (href: string, imageEmbed = false) => { - const linkedThreadId = parseThreadLinkHref(href); - if (linkedThreadId) { - navigation.navigate("Thread", { - environmentId: String(props.environmentId), - threadId: String(linkedThreadId), - }); - return; - } - const media = resolveMarkdownMediaPreview(href, { - environmentId: props.environmentId, - threadId: props.threadId, - workspaceRoot: props.workspaceRoot, - imageEmbed, + const onMarkdownLinkPress = (href: string, imageEmbed = false) => { + const linkedThreadId = parseThreadLinkHref(href); + if (linkedThreadId) { + navigation.navigate("Thread", { + environmentId: String(props.environmentId), + threadId: String(linkedThreadId), }); - if (media) { - void Haptics.selectionAsync(); - if (media.kind === "video") { - setExpandedVideo((current) => current ?? media.source); - } else { - setExpandedFile((current) => current ?? media.source); - } - return; - } - - const presentation = resolveMarkdownLinkPresentation(href); - if (presentation.kind === "file") { - const relativePath = resolveWorkspaceRelativeFilePath( - props.workspaceRoot, - presentation.path, - ); - if (relativePath) { - void Haptics.selectionAsync(); - navigation.navigate("ThreadFile", { - environmentId: String(props.environmentId), - threadId: String(props.threadId), - path: fileRoutePathSegments(relativePath), - ...(presentation.line ? { line: String(presentation.line) } : {}), - }); - return; - } + return; + } + const media = resolveMarkdownMediaPreview(href, { + environmentId: props.environmentId, + threadId: props.threadId, + workspaceRoot: props.workspaceRoot, + imageEmbed, + }); + if (media) { + void Haptics.selectionAsync(); + if (media.kind === "video") { + setExpandedVideo((current) => current ?? media.source); + } else { + setExpandedFile((current) => current ?? media.source); } + return; + } - if (presentation.kind === "file" && isAbsolutePath(presentation.path)) { + const presentation = resolveMarkdownLinkPresentation(href); + if (presentation.kind === "file") { + const relativePath = resolveWorkspaceRelativeFilePath(props.workspaceRoot, presentation.path); + if (relativePath) { void Haptics.selectionAsync(); navigation.navigate("ThreadFile", { environmentId: String(props.environmentId), threadId: String(props.threadId), - path: fileRoutePathSegments(presentation.path), + path: fileRoutePathSegments(relativePath), ...(presentation.line ? { line: String(presentation.line) } : {}), }); return; } + } - if (presentation.kind !== "file" && presentation.href) { - if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) { - setExpandedFile( - (current) => current ?? { kind: "pdf", uri: presentation.href!, name: "Document.pdf" }, - ); - return; - } - void openPullRequest(presentation.href); + if (presentation.kind === "file" && isAbsolutePath(presentation.path)) { + void Haptics.selectionAsync(); + navigation.navigate("ThreadFile", { + environmentId: String(props.environmentId), + threadId: String(props.threadId), + path: fileRoutePathSegments(presentation.path), + ...(presentation.line ? { line: String(presentation.line) } : {}), + }); + return; + } + + if (presentation.kind !== "file" && presentation.href) { + if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) { + setExpandedFile( + (current) => current ?? { kind: "pdf", uri: presentation.href!, name: "Document.pdf" }, + ); + return; } - }, - [props.environmentId, props.threadId, props.workspaceRoot, navigation, openPullRequest], - ); + void openPullRequest(presentation.href); + } + }; const resolveImageSource = useMarkdownImageSource({ environmentId: props.environmentId, threadId: props.threadId, workspaceRoot: props.workspaceRoot ?? null, }); - const markdownLinkHandlers = useMemo( - () => ({ - onLinkPress: onMarkdownLinkPress, - onImagePress: (href) => onMarkdownLinkPress(href, true), - resolveImageSource, - fileContextMenu: (href) => { - const target = resolveFileChipTarget(href, props.workspaceRoot); - return target ? fileChipMenu(target) : undefined; - }, - onFileContextMenuAction: (href, actionId) => { - const target = resolveFileChipTarget(href, props.workspaceRoot); - if (!target) return; - switch (actionId as FileChipAction) { - case "copy-full-path": - if (target.fullPath) copyTextWithHaptic(target.fullPath); - return; - case "copy-relative-path": - if (target.relativePath) copyTextWithHaptic(target.relativePath); - return; - case "open-file": - onMarkdownLinkPress(href); - return; - case "save": - shareFileChip(target); - return; - } - }, - }), - [onMarkdownLinkPress, props.workspaceRoot, shareFileChip, resolveImageSource], - ); - const renderMarkdownImage = useCallback( - (image) => { - const media = resolveMarkdownMediaPreview(image.href, { - environmentId: props.environmentId, - threadId: props.threadId, - workspaceRoot: props.workspaceRoot, - imageEmbed: true, - }); - if (media?.kind === "video") { - return ( - - ); - } - const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null); - if (imageSource._tag === "Direct") { - return ( - setExpandedFile((current) => current ?? source)} - /> - ); - } - if (imageSource._tag === "Blocked") { - return ; + const markdownLinkHandlers: MarkdownLinkHandlers = { + onLinkPress: onMarkdownLinkPress, + onImagePress: (href) => onMarkdownLinkPress(href, true), + resolveImageSource, + fileContextMenu: (href) => { + const target = resolveFileChipTarget(href, props.workspaceRoot); + return target ? fileChipMenu(target) : undefined; + }, + onFileContextMenuAction: (href, actionId) => { + const target = resolveFileChipTarget(href, props.workspaceRoot); + if (!target) return; + switch (actionId as FileChipAction) { + case "copy-full-path": + if (target.fullPath) copyTextWithHaptic(target.fullPath); + return; + case "copy-relative-path": + if (target.relativePath) copyTextWithHaptic(target.relativePath); + return; + case "open-file": + onMarkdownLinkPress(href); + return; + case "save": + shareFileChip(target); + return; } + }, + }; + const renderMarkdownImage: MarkdownImageRenderer = (image) => { + const media = resolveMarkdownMediaPreview(image.href, { + environmentId: props.environmentId, + threadId: props.threadId, + workspaceRoot: props.workspaceRoot, + imageEmbed: true, + }); + if (media?.kind === "video") { return ( - setExpandedFile((current) => current ?? source)} + ); - }, - [props.environmentId, props.threadId, props.workspaceRoot], - ); - const renderViewedImage = useCallback( - (image) => { - const viewedImage = resolveViewedImageAsset(image.href, { - threadId: props.threadId, - workspaceRoot: props.workspaceRoot, - }); - const media = viewedImage - ? resolveMarkdownMediaPreview(image.href, { - environmentId: props.environmentId, - threadId: props.threadId, - workspaceRoot: props.workspaceRoot, - imageEmbed: true, - }) - : null; - const actionsSource = media?.source.actionsSource; - return viewedImage ? ( - setExpandedFile((current) => current ?? source)} /> - ) : null; - }, - [props.environmentId, props.threadId, props.workspaceRoot], - ); - const markdownStyles = useMarkdownStyles(); - const renderReasoning = useCallback( - (text: string) => ( - ; + } + return ( + setExpandedFile((current) => current ?? source)} /> - ), - [ - markdownStyles.assistant, - markdownLinkHandlers, - renderMarkdownImage, - props.skills, - props.environmentId, - ], + ); + }; + const renderViewedImage: MarkdownImageRenderer = (image) => { + const viewedImage = resolveViewedImageAsset(image.href, { + threadId: props.threadId, + workspaceRoot: props.workspaceRoot, + }); + const media = viewedImage + ? resolveMarkdownMediaPreview(image.href, { + environmentId: props.environmentId, + threadId: props.threadId, + workspaceRoot: props.workspaceRoot, + imageEmbed: true, + }) + : null; + const actionsSource = media?.source.actionsSource; + return viewedImage ? ( + setExpandedFile((current) => current ?? source)} + /> + ) : null; + }; + const markdownStyles = useMarkdownStyles(); + const renderReasoning = (text: string) => ( + ); const reviewCommentColors = useReviewCommentColors(); const unsettledTurnId = threadFeedRunIsUnsettled(props.latestRun) ? props.latestRun.runId : null; // LegendList does not invalidate visible rows when only the renderItem closure changes. // Include turn completion so unchanged message rows reveal their footer and spacing // even when the final message update arrives before the turn settles. - const listAppearanceData = useMemo( - () => ({ - worktreeSetup: props.worktreeSetup, - setupWorkingStartedAt: props.setupWorkingStartedAt, - dispatchingMessageId: props.dispatchingMessageId, - unsettledTurnId, - copiedRowId, - expandedWorkGroups, - expandedWorkRows, - workRowSizing, - iconSubtleColor, - markdownStyles, - reviewCommentColors, - themeAppearance, - userBubbleColor, - viewportWidth, - workflowOpening, - }), - [ - props.worktreeSetup, - props.setupWorkingStartedAt, - props.dispatchingMessageId, - unsettledTurnId, - copiedRowId, - expandedWorkGroups, - expandedWorkRows, - workRowSizing, - iconSubtleColor, - markdownStyles, - reviewCommentColors, - themeAppearance, - userBubbleColor, - viewportWidth, - workflowOpening, - ], - ); - const reportHeaderMaterialVisibility = useCallback( - (visible: boolean) => { - if (headerMaterialVisibleRef.current === visible) { - return; - } - headerMaterialVisibleRef.current = visible; - props.onHeaderMaterialVisibilityChange?.(visible); - }, - [props.onHeaderMaterialVisibilityChange], - ); - const handleScroll = useCallback( - (event: NativeSyntheticEvent) => { - reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6); - // Only the actual end re-arms follow: its broader maintain-scroll - // threshold is large enough for a streaming chunk to pull a user back - // before their upward drag escapes. - const listState = props.listRef.current?.getState(); - if (listState) { - transitionEndFollow({ - type: "scroll", - isAtEnd: listState.isAtEnd, - userScrollSessionActive: userScrollSessionRef.current, - }); - } - }, - [reportHeaderMaterialVisibility, anchorTopInset, props.listRef, transitionEndFollow], - ); - const clearUserScrollSettle = useCallback(() => { + const listAppearanceData = { + worktreeSetup: props.worktreeSetup, + setupWorkingStartedAt: props.setupWorkingStartedAt, + dispatchingMessageId: props.dispatchingMessageId, + unsettledTurnId, + copiedRowId, + expandedWorkGroups, + expandedWorkRows, + workRowSizing, + iconSubtleColor, + markdownStyles, + reviewCommentColors, + themeAppearance, + userBubbleColor, + viewportWidth, + workflowOpening, + }; + const reportHeaderMaterialVisibility = (visible: boolean) => { + if (headerMaterialVisibleRef.current === visible) { + return; + } + headerMaterialVisibleRef.current = visible; + onHeaderMaterialVisibilityChange?.(visible); + }; + const handleScroll = (event: NativeSyntheticEvent) => { + reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6); + // Only the actual end re-arms follow: its broader maintain-scroll + // threshold is large enough for a streaming chunk to pull a user back + // before their upward drag escapes. + const listState = listRef.current?.getState(); + if (listState) { + transitionEndFollow({ + type: "scroll", + isAtEnd: listState.isAtEnd, + userScrollSessionActive: userScrollSessionRef.current, + }); + } + }; + const clearUserScrollSettle = () => { if (userScrollSettleTimerRef.current !== null) { clearTimeout(userScrollSettleTimerRef.current); userScrollSettleTimerRef.current = null; } - }, []); - const handleScrollBeginDrag = useCallback(() => { + }; + const handleScrollBeginDrag = () => { clearUserScrollSettle(); userScrollSessionRef.current = true; // Pause before the first scroll event. Otherwise a stream update can run // maintainScrollAtEnd between touch-down and the drag leaving its threshold. transitionEndFollow({ type: "user-scroll-begin" }); - }, [clearUserScrollSettle, transitionEndFollow]); - const finishUserScroll = useCallback( - (releaseIsAtEnd?: boolean) => { - clearUserScrollSettle(); - const userScrollSessionActive = userScrollSessionRef.current; - userScrollSessionRef.current = false; - transitionEndFollow({ - type: "user-scroll-end", - // With no momentum, preserve the finger-release position. Streaming - // growth during the native momentum-detection window must not turn a - // release at the live edge into an opt-out from follow. - isAtEnd: releaseIsAtEnd ?? props.listRef.current?.getState().isAtEnd ?? false, - userScrollSessionActive, - }); - }, - [clearUserScrollSettle, props.listRef, transitionEndFollow], - ); + }; + const finishUserScroll = (releaseIsAtEnd?: boolean) => { + clearUserScrollSettle(); + const userScrollSessionActive = userScrollSessionRef.current; + userScrollSessionRef.current = false; + transitionEndFollow({ + type: "user-scroll-end", + // With no momentum, preserve the finger-release position. Streaming + // growth during the native momentum-detection window must not turn a + // release at the live edge into an opt-out from follow. + isAtEnd: releaseIsAtEnd ?? listRef.current?.getState().isAtEnd ?? false, + userScrollSessionActive, + }); + }; // Finger-lift velocity is not a reliable momentum signal: a gentle fling can // report zero and still decelerate. Give native momentum a short window to // announce itself; if it does, onMomentumScrollBegin cancels this fallback // and the session survives until the settled momentum-end position. - const handleScrollEndDrag = useCallback(() => { + const handleScrollEndDrag = () => { clearUserScrollSettle(); - const releaseIsAtEnd = props.listRef.current?.getState().isAtEnd ?? false; + const releaseIsAtEnd = listRef.current?.getState().isAtEnd ?? false; userScrollSettleTimerRef.current = setTimeout(() => finishUserScroll(releaseIsAtEnd), 160); - }, [clearUserScrollSettle, finishUserScroll, props.listRef]); - const handleMomentumScrollBegin = useCallback(() => { + }; + const handleMomentumScrollBegin = () => { if (userScrollSessionRef.current) { clearUserScrollSettle(); } - }, [clearUserScrollSettle]); - const handleMomentumScrollEnd = useCallback(() => { + }; + const handleMomentumScrollEnd = () => { finishUserScroll(); - }, [finishUserScroll]); + }; useEffect(() => clearUserScrollSettle, [clearUserScrollSettle]); const feedThreadKey = scopedThreadKey(props.environmentId, props.threadId); - const workGroupScrollPositions = useMemo( - () => new Map(), - [feedThreadKey], - ); + const workGroupScrollPositions = new Map(); useEffect(() => { clearUserScrollSettle(); userScrollSessionRef.current = false; @@ -2137,44 +2078,32 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } }, [clearUserScrollSettle, props.submittedMessageId, transitionEndFollow]); - const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { + const handleViewportLayout = (event: LayoutChangeEvent) => { const nextWidth = Math.round(event.nativeEvent.layout.width); const nextHeight = Math.round(event.nativeEvent.layout.height); setViewportWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current)); setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current)); - }, []); + }; useEffect(() => { reportHeaderMaterialVisibility(false); }, [feedThreadKey, reportHeaderMaterialVisibility]); - const presentedFeed = useMemo( - () => - appendPendingThreadMessages( - deriveThreadFeedPresentation( - props.feed, - props.latestRun, - expandedTurnIds, - new Set( - Object.entries(expandedWorkGroups) - .filter(([, expanded]) => expanded) - .map(([groupId]) => groupId), - ), - props.activeWorkStartedAt, - props.runlessWorkActive ?? false, - ), - props.feed, - props.queuedMessages, - ), - [ - props.queuedMessages, - expandedTurnIds, - expandedWorkGroups, - props.activeWorkStartedAt, - props.runlessWorkActive, + const presentedFeed = appendPendingThreadMessages( + deriveThreadFeedPresentation( props.feed, props.latestRun, - ], + expandedTurnIds, + new Set( + Object.entries(expandedWorkGroups) + .filter(([, expanded]) => expanded) + .map(([groupId]) => groupId), + ), + props.activeWorkStartedAt, + props.runlessWorkActive ?? false, + ), + props.feed, + props.queuedMessages, ); const setupAnchorIndex = presentedFeed.findIndex( (entry) => entry.type === "message" && entry.message.role === "user", @@ -2187,25 +2116,18 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { useLayoutEffect(() => { const bottom = props.contentInsetEndAdjustment.value; if (bottom > 0) { - props.listRef.current?.reportContentInset({ bottom }); + listRef.current?.reportContentInset({ bottom }); } - }, [listMountKey, props.contentInsetEndAdjustment, props.listRef]); - - const anchoredEndSpace = useMemo( - () => - resolveChatListAnchoredEndSpace( - presentedFeed, - props.anchorMessageId, - (entry) => (entry.type === "message" ? entry.id : null), - { anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET }, - ), - [presentedFeed, props.anchorMessageId, anchorTopInset], - ); - const failedRunIds = useMemo( - () => failedFeedRunIds(props.feed, props.latestRun), - [props.feed, props.latestRun], + }, [listMountKey, props.contentInsetEndAdjustment, listRef]); + + const anchoredEndSpace = resolveChatListAnchoredEndSpace( + presentedFeed, + props.anchorMessageId, + (entry) => (entry.type === "message" ? entry.id : null), + { anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET }, ); - const terminalAssistantMessageIds = useMemo(() => { + const failedRunIds = failedFeedRunIds(props.feed, props.latestRun); + const terminalAssistantMessageIds = (() => { const terminalIdsByTurn = new Map(); for (const entry of props.feed) { if (entry.type === "message" && entry.message.role === "assistant" && entry.message.runId) { @@ -2213,7 +2135,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } } return new Set(terminalIdsByTurn.values()); - }, [props.feed]); + })(); useEffect(() => { const previous = previousLatestTurnRef.current; previousLatestTurnRef.current = props.latestRun; @@ -2254,7 +2176,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }; }, []); - const settleDisclosureAfterLayout = useCallback(() => { + const settleDisclosureAfterLayout = () => { if (disclosureSettleFrameRef.current !== null) { cancelAnimationFrame(disclosureSettleFrameRef.current); } @@ -2263,7 +2185,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } disclosureSettleFrameRef.current = requestAnimationFrame(() => { disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => { - const listState = props.listRef.current?.getState(); + const listState = listRef.current?.getState(); if (listState) { transitionEndFollow({ type: "disclosure-settled", @@ -2277,12 +2199,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { disclosureSettleSecondFrameRef.current = null; }); }); - }, [props.listRef, transitionEndFollow]); + }; - const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => { + const suspendEndScrollMaintenanceForDisclosure = (anchorKey: string | null) => { disclosureAnchorKeyRef.current = anchorKey; setDisclosureToggleSettling(true); - }, []); + }; // Start the quiet-frame countdown after React has committed the disclosure. // Every measured item-size change restarts it, so end maintenance cannot @@ -2293,27 +2215,24 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } }, [expandedTurnIds, expandedWorkGroups, expandedWorkRows, settleDisclosureAfterLayout]); - const handleItemSizeChanged = useCallback(() => { + const handleItemSizeChanged = () => { if (disclosureAnchorKeyRef.current !== null) { settleDisclosureAfterLayout(); } - }, [settleDisclosureAfterLayout]); + }; - const shouldRestoreVisibleContentPosition = useCallback((entry: ThreadFeedEntry) => { + const shouldRestoreVisibleContentPosition = (entry: ThreadFeedEntry) => { const disclosureAnchorKey = disclosureAnchorKeyRef.current; return disclosureAnchorKey === null || entry.id === disclosureAnchorKey; - }, []); + }; - const maintainVisibleContentPosition = useMemo( - () => ({ - data: true, - size: true, - shouldRestorePosition: shouldRestoreVisibleContentPosition, - }), - [shouldRestoreVisibleContentPosition], - ); + const maintainVisibleContentPosition = { + data: true, + size: true, + shouldRestorePosition: shouldRestoreVisibleContentPosition, + }; - const onCopyWorkRow = useCallback((rowId: string, value: string) => { + const onCopyWorkRow = (rowId: string, value: string) => { copyTextWithHaptic(value, { target: "thread-work-row", feedback: "selection", @@ -2328,230 +2247,165 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); copyFeedbackTimeoutRef.current = null; }, 1200); - }, []); + }; - const onToggleWorkGroup = useCallback( - (groupId: string, anchorKey?: string) => { - suspendEndScrollMaintenanceForDisclosure(anchorKey ?? `work-toggle:${groupId}`); - setInteractionState((current) => ({ - ...current, - expandedWorkGroups: { - ...current.expandedWorkGroups, - [groupId]: !(current.expandedWorkGroups[groupId] ?? false), - }, - })); - }, - [suspendEndScrollMaintenanceForDisclosure], - ); + const onToggleWorkGroup = (groupId: string, anchorKey?: string) => { + suspendEndScrollMaintenanceForDisclosure(anchorKey ?? `work-toggle:${groupId}`); + setInteractionState((current) => ({ + ...current, + expandedWorkGroups: { + ...current.expandedWorkGroups, + [groupId]: !(current.expandedWorkGroups[groupId] ?? false), + }, + })); + }; // A lone call's row stands in for its group header, so both disclosures move // together. Details the reader opened stay open when a second call turns the // row into a group. - const onToggleWorkRow = useCallback( - (rowId: string, anchorKey?: string, groupId?: string) => { - suspendEndScrollMaintenanceForDisclosure(anchorKey ?? null); - setInteractionState((current) => { - const expanded = !(current.expandedWorkRows[rowId] ?? false); - return { - ...current, - expandedWorkRows: { ...current.expandedWorkRows, [rowId]: expanded }, - ...(groupId === undefined - ? {} - : { expandedWorkGroups: { ...current.expandedWorkGroups, [groupId]: expanded } }), - }; - }); - }, - [suspendEndScrollMaintenanceForDisclosure], - ); + const onToggleWorkRow = (rowId: string, anchorKey?: string, groupId?: string) => { + suspendEndScrollMaintenanceForDisclosure(anchorKey ?? null); + setInteractionState((current) => { + const expanded = !(current.expandedWorkRows[rowId] ?? false); + return { + ...current, + expandedWorkRows: { ...current.expandedWorkRows, [rowId]: expanded }, + ...(groupId === undefined + ? {} + : { expandedWorkGroups: { ...current.expandedWorkGroups, [groupId]: expanded } }), + }; + }); + }; - const onToggleTurnFold = useCallback( - (runId: RunId) => { - suspendEndScrollMaintenanceForDisclosure(`run-fold:${runId}`); - setInteractionState((current) => { - const next = new Set(current.expandedTurnIds); - if (next.has(runId)) { - next.delete(runId); - } else { - next.add(runId); - } - return { ...current, expandedTurnIds: next }; - }); - }, - [suspendEndScrollMaintenanceForDisclosure], - ); + const onToggleTurnFold = (runId: RunId) => { + suspendEndScrollMaintenanceForDisclosure(`run-fold:${runId}`); + setInteractionState((current) => { + const next = new Set(current.expandedTurnIds); + if (next.has(runId)) { + next.delete(runId); + } else { + next.add(runId); + } + return { ...current, expandedTurnIds: next }; + }); + }; - const onPressPreview = useCallback((source: FilePreviewSource) => { + const onPressPreview = (source: FilePreviewSource) => { setExpandedFile((current) => current ?? source); - }, []); - const onPressVideo = useCallback( - (attachment: ChatFileAttachment, sourceIdentifier: string) => { - setExpandedVideo( - (current) => - current ?? - attachmentVideoPreviewSource(props.environmentId, attachment, sourceIdentifier), - ); - }, - [props.environmentId], - ); + }; + const onPressVideo = (attachment: ChatFileAttachment, sourceIdentifier: string) => { + setExpandedVideo( + (current) => + current ?? attachmentVideoPreviewSource(props.environmentId, attachment, sourceIdentifier), + ); + }; // Without this, every row above the viewport is assumed to be // estimatedItemSize tall, and scrolling up through unmeasured content // corrects each row's height as it mounts — the feed visibly jumps. - const getFixedItemSize = useCallback( - (entry: ThreadFeedEntry) => { - if (entry.type === "html-render") { - return htmlRenderRowHeight(htmlRenderFrameHeight(entry.render, contentWidth)); - } - if (workRowSizing.fixedRowHeight === undefined) { - return undefined; - } - switch (entry.type) { - case "run-fold": - return resolveThreadFeedFixedItemSize(entry.type); - case "work-toggle": - return entry.thought ? undefined : WORK_GROUP_TOGGLE_HEIGHT; - case "thinking": - return WORK_GROUP_TOGGLE_HEIGHT; - case "activity-group": - if (entry.activities[0]?.projectedItem.item.type === "subagent") { - return undefined; - } - if (isContextCompactionActivityGroup(entry) || isContextHandoffActivityGroup(entry)) { - return undefined; - } - if ( - entry.activities[0]?.groupedToolDetail && - entry.activities.every((activity) => activity.workEntry.itemType === "reasoning") - ) { - return undefined; - } - return entry.activities.some( - (activity) => activity.prominent || expandedWorkRows[activity.id], - ) - ? undefined - : collapsedWorkLogHeight(entry.activities, entry.continuesWorkLog); - default: + const getFixedItemSize = (entry: ThreadFeedEntry) => { + if (entry.type === "html-render") { + return htmlRenderRowHeight(htmlRenderFrameHeight(entry.render, contentWidth)); + } + if (workRowSizing.fixedRowHeight === undefined) { + return undefined; + } + switch (entry.type) { + case "run-fold": + return resolveThreadFeedFixedItemSize(entry.type); + case "work-toggle": + return entry.thought ? undefined : WORK_GROUP_TOGGLE_HEIGHT; + case "thinking": + return WORK_GROUP_TOGGLE_HEIGHT; + case "activity-group": + if (entry.activities[0]?.projectedItem.item.type === "subagent") { return undefined; - } - }, - [contentWidth, expandedWorkRows, workRowSizing.fixedRowHeight], - ); - const hasHtmlRenders = useMemo( - () => presentedFeed.some((entry) => entry.type === "html-render"), - [presentedFeed], - ); + } + if (isContextCompactionActivityGroup(entry) || isContextHandoffActivityGroup(entry)) { + return undefined; + } + if ( + entry.activities[0]?.groupedToolDetail && + entry.activities.every((activity) => activity.workEntry.itemType === "reasoning") + ) { + return undefined; + } + return entry.activities.some( + (activity) => activity.prominent || expandedWorkRows[activity.id], + ) + ? undefined + : collapsedWorkLogHeight(entry.activities, entry.continuesWorkLog); + default: + return undefined; + } + }; + const hasHtmlRenders = presentedFeed.some((entry) => entry.type === "html-render"); const previousContentWidth = useRef(contentWidth); useLayoutEffect(() => { if (previousContentWidth.current === contentWidth) return; previousContentWidth.current = contentWidth; - if (hasHtmlRenders) props.listRef.current?.clearCaches({ mode: "sizes" }); - }, [contentWidth, hasHtmlRenders, props.listRef]); + if (hasHtmlRenders) listRef.current?.clearCaches({ mode: "sizes" }); + }, [contentWidth, hasHtmlRenders, listRef]); - const renderItem = useCallback( - (info: { item: PendingThreadFeedEntry; index: number }) => ( - ( + + - - - {renderFeedEntry(info, { - environmentId: props.environmentId, - dispatchingMessageId: props.dispatchingMessageId, - onEditPendingMessage: props.onEditPendingMessage, - onUseArtifactTemplate: props.onUseArtifactTemplate, - threadId: props.threadId, - copiedRowId, - expandedWorkRows, - workRowSizing, - workGroupScrollPositions, - terminalAssistantMessageIds, - unsettledTurnId, - failedRunIds, - onCopyWorkRow, - onToggleWorkGroup, - onToggleWorkRow, - onToggleTurnFold, - onPressPreview, - onPressVideo, - markdownLinkHandlers, - renderMarkdownImage, - renderViewedImage, - renderReasoning, - iconSubtleColor, - screenColor, - userBubbleColor, - markdownStyles, - reviewCommentColors, - reviewCommentBubbleWidth, - themeAppearance, - duoColumns, - userBubbleMaxWidth, - markdownContentWidth, - contentWidth, - threadTitle: props.threadTitle, - skills: props.skills, - workspaceRoot: props.workspaceRoot, - workflowOpening, - })} - {props.worktreeSetup && info.index === setupAnchorIndex ? ( - - ) : props.setupWorkingStartedAt && info.index === setupAnchorIndex ? ( - - ) : null} - - - - ), - [ - props.worktreeSetup, - props.setupWorkingStartedAt, - props.threadId, - setupAnchorIndex, - props.dispatchingMessageId, - props.onEditPendingMessage, - copiedRowId, - disclosureToggleSettling, - expandedWorkRows, - workRowSizing, - workGroupScrollPositions, - terminalAssistantMessageIds, - unsettledTurnId, - failedRunIds, - iconSubtleColor, - screenColor, - userBubbleColor, - markdownStyles, - reviewCommentColors, - reviewCommentBubbleWidth, - themeAppearance, - duoColumns, - userBubbleMaxWidth, - markdownContentWidth, - contentWidth, - onCopyWorkRow, - markdownLinkHandlers, - onPressPreview, - onPressVideo, - onToggleTurnFold, - onToggleWorkGroup, - onToggleWorkRow, - props.environmentId, - props.onUseArtifactTemplate, - props.threadId, - props.threadTitle, - props.skills, - props.workspaceRoot, - workflowOpening, - renderMarkdownImage, - renderViewedImage, - renderReasoning, - ], + + {renderFeedEntry(info, { + environmentId: props.environmentId, + dispatchingMessageId: props.dispatchingMessageId, + onEditPendingMessage: props.onEditPendingMessage, + onUseArtifactTemplate: props.onUseArtifactTemplate, + threadId: props.threadId, + copiedRowId, + expandedWorkRows, + workRowSizing, + workGroupScrollPositions, + terminalAssistantMessageIds, + unsettledTurnId, + failedRunIds, + onCopyWorkRow, + onToggleWorkGroup, + onToggleWorkRow, + onToggleTurnFold, + onPressPreview, + onPressVideo, + markdownLinkHandlers, + renderMarkdownImage, + renderViewedImage, + renderReasoning, + iconSubtleColor, + screenColor, + userBubbleColor, + markdownStyles, + reviewCommentColors, + reviewCommentBubbleWidth, + themeAppearance, + duoColumns, + userBubbleMaxWidth, + markdownContentWidth, + contentWidth, + threadTitle: props.threadTitle, + skills: props.skills, + workspaceRoot: props.workspaceRoot, + workflowOpening, + })} + {props.worktreeSetup && info.index === setupAnchorIndex ? ( + + ) : props.setupWorkingStartedAt && info.index === setupAnchorIndex ? ( + + ) : null} + + + ); if (props.contentPresentation.kind === "unavailable" && props.queuedMessages.length === 0) { @@ -2571,7 +2425,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { - entry.type === "message" - ? [ - ...messageImageGallery(entry.message, { environmentId, threadId, workspaceRoot }), - ...composerImageGallery(entry.pendingMessage?.attachments ?? []), - ] - : [], - [entry, environmentId, threadId, workspaceRoot], - ); + const images = + entry.type === "message" + ? [ + ...messageImageGallery(entry.message, { environmentId, threadId, workspaceRoot }), + ...composerImageGallery(entry.pendingMessage?.attachments ?? []), + ] + : []; return {props.children}; } diff --git a/apps/mobile/src/reactCompilerCoverage.test.ts b/apps/mobile/src/reactCompilerCoverage.test.ts index cfe6008b80db..3415bd353bac 100644 --- a/apps/mobile/src/reactCompilerCoverage.test.ts +++ b/apps/mobile/src/reactCompilerCoverage.test.ts @@ -24,13 +24,11 @@ const KNOWN_BAILOUTS: Record = { "src/features/terminal/ThreadTerminalRouteScreen.tsx": "ref read during render", "src/features/threads/PendingUserInputCard.tsx": "mutates a frozen value", "src/features/threads/ThreadArrangementSheet.tsx": "ref read during render", - "src/features/threads/ThreadFeed.tsx": "ref read during render, throw inside try, try/finally", "src/features/voice-input/VoiceInputProvider.tsx": "ref read during render", "src/features/voice-input/useVoiceInputController.ts": "ref read during render", "src/native/StackHeader.tsx": "ref read during render", "src/native/T3ComposerEditor.ios.tsx": "ref read during render", "src/native/T3ComposerEditor.native.tsx": "ref read during render", - "src/state/use-thread-selection.ts": "ref read during render", }; // Caller flags Metro passes for an iOS release bundle. supportsReactCompiler is diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 9df012f3428f..8f7c1d9e9e94 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -1,6 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { useRoute, type RouteProp } from "@react-navigation/native"; -import { useMemo, useRef, useState } from "react"; +import { useState } from "react"; import { EnvironmentId, ThreadId, @@ -113,23 +113,32 @@ function threadDetailToShell( function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefined) { const routeParams = params ?? {}; - const routeThreadRef = useMemo(() => { - const environmentId = firstRouteParam(routeParams.environmentId); - const threadId = firstRouteParam(routeParams.threadId); - if (!environmentId || !threadId) { - return null; - } - - return { - environmentId: EnvironmentId.make(environmentId), - threadId: ThreadId.make(threadId), - }; - }, [routeParams.environmentId, routeParams.threadId]); - const lastRouteThreadRef = useRef(null); - if (routeThreadRef !== null) { - lastRouteThreadRef.current = routeThreadRef; + const routeEnvironmentId = firstRouteParam(routeParams.environmentId); + const routeThreadId = firstRouteParam(routeParams.threadId); + // A route that drops its params (a closing sheet, for example) keeps showing + // the last thread, so the last complete pair is kept as state. + const [lastRouteThreadRef, setLastRouteThreadRef] = useState(null); + if ( + routeEnvironmentId !== null && + routeThreadId !== null && + (lastRouteThreadRef?.environmentId !== routeEnvironmentId || + lastRouteThreadRef.threadId !== routeThreadId) + ) { + setLastRouteThreadRef({ + environmentId: EnvironmentId.make(routeEnvironmentId), + threadId: ThreadId.make(routeThreadId), + }); } - const selectedThreadRef = routeThreadRef ?? lastRouteThreadRef.current; + const selectedThreadRef = + routeEnvironmentId !== null && routeThreadId !== null + ? lastRouteThreadRef?.environmentId === routeEnvironmentId && + lastRouteThreadRef.threadId === routeThreadId + ? lastRouteThreadRef + : { + environmentId: EnvironmentId.make(routeEnvironmentId), + threadId: ThreadId.make(routeThreadId), + } + : lastRouteThreadRef; const selectedThreadShell = useThreadShell(selectedThreadRef); const selectedThreadKey = selectedThreadRef === null @@ -137,7 +146,7 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin : scopedThreadKey(selectedThreadRef.environmentId, selectedThreadRef.threadId); const queuedMessagesByThreadKey = useThreadOutboxMessages(); const creationOutcome = useAtomValue(pendingThreadCreationOutcomesAtom); - const pendingCreation = useMemo(() => { + const pendingCreation = ((): PendingThreadCreation | null => { if (selectedThreadKey === null) { return null; } @@ -147,7 +156,7 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin const outcome = creationOutcome[selectedThreadKey] ?? null; const message = queued ?? outcome?.message ?? null; return message === null ? null : { message, outcome }; - }, [creationOutcome, queuedMessagesByThreadKey, selectedThreadKey]); + })(); // Until the creation is delivered the server has no thread to subscribe // to. Every detail reader must use this ref, not the route id: an earlier // read gets a definitive not-found, which the thread state keeps as deleted. @@ -165,60 +174,52 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin needsDetail ? (selectedThreadDetailRef?.threadId ?? null) : null, ); const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); - const selectedThread = useMemo( - () => - selectedThreadShell ?? - (selectedThreadRef !== null && selectedThreadDetail !== null - ? threadDetailToShell(selectedThreadRef.environmentId, selectedThreadDetail) - : pendingCreation !== null - ? pendingThreadCreationShell(pendingCreation.message) - : null), - [pendingCreation, selectedThreadDetail, selectedThreadRef, selectedThreadShell], - ); - const selectedThreadCreation = resolvePendingThreadCreation({ + const selectedThread = + selectedThreadShell ?? + (selectedThreadRef !== null && selectedThreadDetail !== null + ? threadDetailToShell(selectedThreadRef.environmentId, selectedThreadDetail) + : pendingCreation !== null + ? pendingThreadCreationShell(pendingCreation.message) + : null); + const resolvedCreation = resolvePendingThreadCreation({ threadKey: selectedThreadKey, pending: pendingCreation, previous: previousCreation, detail: selectedThreadDetail, }); - if (previousCreation !== selectedThreadCreation) { - setPreviousCreation(selectedThreadCreation); + // `pendingCreation` is rebuilt each render, so compare contents: an identity + // check would set state on every render. + const creationUnchanged = + resolvedCreation === previousCreation || + (resolvedCreation !== null && + previousCreation !== null && + resolvedCreation.message === previousCreation.message && + resolvedCreation.outcome === previousCreation.outcome); + if (!creationUnchanged) { + setPreviousCreation(resolvedCreation); } - const selectedProjectRef = useMemo( - () => - selectedThread === null - ? null - : { - environmentId: selectedThread.environmentId, - projectId: selectedThread.projectId, - }, - [selectedThread], - ); + const selectedThreadCreation = creationUnchanged ? previousCreation : resolvedCreation; + const selectedProjectRef: ScopedProjectRef | null = + selectedThread === null + ? null + : { + environmentId: selectedThread.environmentId, + projectId: selectedThread.projectId, + }; const selectedThreadProject = useProject(selectedProjectRef); const selectedEnvironmentId = selectedThread?.environmentId ?? null; const selectedEnvironmentConnection = useSavedRemoteConnection(selectedEnvironmentId); const selectedEnvironmentRuntime = useRemoteEnvironmentRuntime(selectedEnvironmentId); - return useMemo( - () => ({ - selectedThreadRef, - selectedThread, - selectedThreadCreation, - selectedThreadDetailRef, - selectedThreadProject, - selectedEnvironmentConnection, - selectedEnvironmentRuntime, - }), - [ - selectedEnvironmentConnection, - selectedEnvironmentRuntime, - selectedThread, - selectedThreadCreation, - selectedThreadDetailRef, - selectedThreadProject, - selectedThreadRef, - ], - ); + return { + selectedThreadRef, + selectedThread, + selectedThreadCreation, + selectedThreadDetailRef, + selectedThreadProject, + selectedEnvironmentConnection, + selectedEnvironmentRuntime, + }; } type ThreadSelectionState = ReturnType;