From b44ed8237a302543e0680cdfea33097d8492edc4 Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:13:36 +0530 Subject: [PATCH 1/3] perf(mobile): speed up large Android threads --- apps/mobile/src/Stack.tsx | 8 +- .../src/components/AndroidScreenHeader.tsx | 10 +- .../src/features/threads/ThreadFeed.tsx | 8 +- .../features/threads/ThreadRouteScreen.tsx | 30 ++--- apps/mobile/src/lib/threadActivity.test.ts | 81 +++++++++++++ apps/mobile/src/lib/threadActivity.ts | 108 +++++++++--------- 6 files changed, 173 insertions(+), 72 deletions(-) diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 1516b7cbc73..600cf1ba4c6 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -407,7 +407,13 @@ export const RootStack = createNativeStackNavigator({ Thread: createNativeStackScreen({ screen: ThreadRouteScreen, linking: THREAD_LINKING_PREFIX, - options: GLASS_HEADER_OPTIONS, + options: { + ...GLASS_HEADER_OPTIONS, + // Android owns an in-flow thread header. Hide the native header before + // the route's loading state mounts so hydration cannot move the whole + // screen by one toolbar height when runtime options arrive. + ...(Platform.OS === "android" ? { headerShown: false } : null), + }, }), ThreadTerminal: createNativeStackScreen({ screen: ThreadTerminalRouteScreen, diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx index 7fe21fb44ff..279e3a47676 100644 --- a/apps/mobile/src/components/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -48,6 +48,7 @@ export function AndroidHeaderIconButton(props: { export function AndroidScreenHeader(props: { readonly title: string; readonly subtitle?: string | null; + readonly reserveSubtitleSpace?: boolean; readonly actions?: ReadonlyArray; readonly trailing?: ReactNode; readonly onBack?: () => void; @@ -85,12 +86,15 @@ export function AndroidScreenHeader(props: { {props.title} - {props.subtitle ? ( + {props.subtitle || props.reserveSubtitleSpace ? ( - {props.subtitle} + {props.subtitle || " "} ) : null} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index db7fecf64ff..8ed65b7d881 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -265,7 +265,10 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { readonly host: string; readonly href: string; }) { - const [failed, setFailed] = useState(() => failedMarkdownFaviconHosts.has(props.host)); + const [failedHost, setFailedHost] = useState(() => + failedMarkdownFaviconHosts.has(props.host) ? props.host : null, + ); + const failed = failedHost === props.host || failedMarkdownFaviconHosts.has(props.host); return ( { failedMarkdownFaviconHosts.add(props.host); - setFailed(true); + setFailedHost(props.host); }} /> ) : ( @@ -1872,6 +1875,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } maintainVisibleContentPosition={maintainVisibleContentPosition} data={presentedFeed} + recycleItems={Platform.OS === "android"} extraData={listAppearanceData} renderItem={renderItem} keyExtractor={(entry) => entry.id} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index d7754b7d78f..18f6276fc48 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -689,30 +689,29 @@ function ThreadRouteContent( onPress: props.onReturnToThread, }); } - if (selectedThreadCwd !== null) { - actions.push({ - accessibilityLabel: "Open files", - icon: "folder", - onPress: handleOpenFilesInspector, - }); - } - if (selectedThreadProject?.workspaceRoot) { - actions.push({ - accessibilityLabel: "Open terminal", - icon: "terminal", - onPress: () => handleOpenTerminal(null), - }); - } + actions.push({ + accessibilityLabel: "Open files", + icon: "folder", + onPress: handleOpenFilesInspector, + disabled: selectedThreadCwd === null, + }); + actions.push({ + accessibilityLabel: "Open terminal", + icon: "terminal", + onPress: () => handleOpenTerminal(null), + disabled: !selectedThreadProject?.workspaceRoot, + }); actions.push({ accessibilityLabel: "Open git controls", icon: "point.topleft.down.curvedto.point.bottomright.up", onPress: handleOpenGitInspector, }); - if (fileInspector.supported && selectedThreadCwd !== null) { + if (fileInspector.supported) { actions.push({ accessibilityLabel: "Toggle inspector", icon: "sidebar.right", onPress: handleToggleInspector, + disabled: selectedThreadCwd === null, }); } return actions; @@ -856,6 +855,7 @@ function ThreadRouteContent( navigation.goBack()} actions={androidHeaderActions} /> diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index ae9a93e9fc3..fe31590d161 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -13,6 +13,7 @@ import { import { buildThreadFeed, + derivePendingApprovals, deriveThreadFeedPresentation, type ThreadFeedActivity, type ThreadFeedEntry, @@ -531,6 +532,86 @@ describe("buildThreadFeed", () => { expanded: true, }); }); + + it("orders feed entries chronologically without relying on timestamp string order", () => { + const thread = makeThread({ + id: ThreadId.make("thread-ordering"), + projectId: ProjectId.make("project-1"), + title: "Ordering", + messages: [ + { + id: MessageId.make("message-late"), + role: "assistant", + text: "Last", + turnId: null, + streaming: false, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + }, + { + id: MessageId.make("message-early"), + role: "user", + text: "First", + turnId: null, + streaming: false, + createdAt: "2026-04-01T01:00:00.000+02:00", + updatedAt: "2026-04-01T01:00:00.000+02:00", + }, + ], + activities: [ + makeActivity({ + id: EventId.make("warning-between"), + kind: "runtime.warning", + summary: "Runtime warning", + createdAt: "2026-03-31T23:30:00.000Z", + payload: { message: "Between the messages" }, + }), + ], + }); + + expect(buildThreadFeed(thread).map((entry) => entry.id)).toEqual([ + "message-early", + "warning-between", + "message-late", + ]); + }); +}); + +describe("derivePendingApprovals", () => { + it("returns open approvals oldest-first regardless of event order", () => { + const approvals = derivePendingApprovals([ + makeActivity({ + id: EventId.make("approval-late"), + kind: "approval.requested", + summary: "Approve command", + createdAt: "2026-04-01T00:00:09.000Z", + payload: { requestId: "request-late", requestKind: "command" }, + }), + makeActivity({ + id: EventId.make("approval-early"), + kind: "approval.requested", + summary: "Approve file change", + createdAt: "2026-04-01T00:00:03.000Z", + payload: { requestId: "request-early", requestKind: "file-change" }, + }), + makeActivity({ + id: EventId.make("approval-resolved"), + kind: "approval.requested", + summary: "Approve command", + createdAt: "2026-04-01T00:00:01.000Z", + payload: { requestId: "request-resolved", requestKind: "command" }, + }), + makeActivity({ + id: EventId.make("approval-resolution"), + kind: "approval.resolved", + summary: "Approved", + createdAt: "2026-04-01T00:00:02.000Z", + payload: { requestId: "request-resolved" }, + }), + ]); + + expect(approvals.map(({ requestId }) => requestId)).toEqual(["request-early", "request-late"]); + }); }); describe("quiet timeline: nested agents", () => { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 886644bf83e..9d20339d0af 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1038,6 +1038,16 @@ const activityOrder = Order.combineAll([ Order.mapInput(Order.String, (activity) => activity.id), ]); +function sortByCreatedAt(items: Iterable): T[] { + const decorated = Array.from(items, (item) => ({ item, timestamp: Date.parse(item.createdAt) })); + decorated.sort((left, right) => { + if (left.timestamp < right.timestamp) return -1; + if (left.timestamp > right.timestamp) return 1; + return 0; + }); + return decorated.map(({ item }) => item); +} + function isEmptyMessage(entry: RawThreadFeedEntry): boolean { if (entry.type !== "message") { return false; @@ -1376,7 +1386,7 @@ export function derivePendingApprovals( } } - return Arr.sortWith([...openByRequestId.values()], (s) => new Date(s.createdAt), Order.Date); + return sortByCreatedAt(openByRequestId.values()); } export function derivePendingUserInputs( @@ -1419,7 +1429,7 @@ export function derivePendingUserInputs( } } - return Arr.sortWith(openByRequestId.values(), (s) => new Date(s.createdAt), Order.Date); + return sortByCreatedAt(openByRequestId.values()); } export function setPendingUserInputCustomAnswer( @@ -1461,58 +1471,54 @@ export function buildThreadFeed( const oldestLoadedMessageCreatedAt = options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; const workLogEntries = deriveWorkLogEntries(thread.activities); - const entries = Arr.sortWith( - [ - ...loadedMessages.map((message) => ({ - type: "message", - id: message.id, - createdAt: message.createdAt, - message, - })), - ...workLogEntries - .filter((entry) => { - if (options?.loadedMessages === undefined) { - return true; - } - return ( - oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt - ); - }) - .map((entry) => { - const summary = workEntryHeading(entry); - const detail = workEntryPreview(entry); - const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry)); - const getCopyText = memoizeValue(() => - [summary, detail, getFullDetail()] - .filter((value, index, values): value is string => { - return Boolean(value) && values.indexOf(value) === index; - }) - .join("\n"), - ); - return { - type: "activity", + const entries = sortByCreatedAt([ + ...loadedMessages.map((message) => ({ + type: "message", + id: message.id, + createdAt: message.createdAt, + message, + })), + ...workLogEntries + .filter((entry) => { + if (options?.loadedMessages === undefined) { + return true; + } + return ( + oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt + ); + }) + .map((entry) => { + const summary = workEntryHeading(entry); + const detail = workEntryPreview(entry); + const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry)); + const getCopyText = memoizeValue(() => + [summary, detail, getFullDetail()] + .filter((value, index, values): value is string => { + return Boolean(value) && values.indexOf(value) === index; + }) + .join("\n"), + ); + return { + type: "activity", + id: entry.id, + createdAt: entry.createdAt, + turnId: entry.turnId, + activity: { id: entry.id, createdAt: entry.createdAt, turnId: entry.turnId, - activity: { - id: entry.id, - createdAt: entry.createdAt, - turnId: entry.turnId, - summary, - detail, - canExpand: workEntryHasExpandedBody(entry), - getFullDetail, - getCopyText, - icon: workEntryIcon(entry), - toolLike: workLogEntryIsToolLike(entry), - status: workEntryStatus(entry), - }, - }; - }), - ], - (s) => new Date(s.createdAt), - Order.Date, - ); + summary, + detail, + canExpand: workEntryHasExpandedBody(entry), + getFullDetail, + getCopyText, + icon: workEntryIcon(entry), + toolLike: workLogEntryIsToolLike(entry), + status: workEntryStatus(entry), + }, + }; + }), + ]); return groupAdjacentActivities(entries); } From 1d9751497939863d70b67e2d88e75a78e1eaf030 Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:22:43 +0530 Subject: [PATCH 2/3] fix: address large-thread review findings --- apps/mobile/src/components/CopyTextButton.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/components/CopyTextButton.tsx b/apps/mobile/src/components/CopyTextButton.tsx index 7f4e060eda0..2fa849de8dd 100644 --- a/apps/mobile/src/components/CopyTextButton.tsx +++ b/apps/mobile/src/components/CopyTextButton.tsx @@ -16,7 +16,8 @@ export const CopyTextButton = memo(function CopyTextButton(props: { readonly iconSize?: number; readonly buttonSize?: number; }) { - const [copied, setCopied] = useState(false); + const [copiedText, setCopiedText] = useState(null); + const copied = copiedText === props.text; const resetTimeoutRef = useRef | null>(null); useEffect( @@ -36,12 +37,12 @@ export const CopyTextButton = memo(function CopyTextButton(props: { hitSlop={8} onPress={() => { copyTextWithHaptic(props.text); - setCopied(true); + setCopiedText(props.text); if (resetTimeoutRef.current) { clearTimeout(resetTimeoutRef.current); } resetTimeoutRef.current = setTimeout(() => { - setCopied(false); + setCopiedText(null); resetTimeoutRef.current = null; }, COPY_FEEDBACK_DURATION_MS); }} From f8971320ef2f651a8a5b87b93ccf228016c04a6f Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:20:25 +0530 Subject: [PATCH 3/3] fix(mobile): bound large thread loading --- .../review/reviewHighlighterEngine.test.ts | 6 +- .../review/reviewHighlighterEngine.ts | 2 +- .../features/review/shikiReviewHighlighter.ts | 3 +- .../features/threads/ThreadRouteScreen.tsx | 106 ++++++++++++++---- 4 files changed, 91 insertions(+), 26 deletions(-) diff --git a/apps/mobile/src/features/review/reviewHighlighterEngine.test.ts b/apps/mobile/src/features/review/reviewHighlighterEngine.test.ts index e3e1ab0eead..203caa448be 100644 --- a/apps/mobile/src/features/review/reviewHighlighterEngine.test.ts +++ b/apps/mobile/src/features/review/reviewHighlighterEngine.test.ts @@ -6,9 +6,9 @@ import { } from "./reviewHighlighterEngine"; describe("resolveReviewHighlighterEnginePreference", () => { - it("defaults invalid values to native", () => { - expect(resolveReviewHighlighterEnginePreference(undefined)).toBe("native"); - expect(resolveReviewHighlighterEnginePreference("bogus")).toBe("native"); + it("defaults invalid values to javascript", () => { + expect(resolveReviewHighlighterEnginePreference(undefined)).toBe("javascript"); + expect(resolveReviewHighlighterEnginePreference("bogus")).toBe("javascript"); }); it("accepts supported values", () => { diff --git a/apps/mobile/src/features/review/reviewHighlighterEngine.ts b/apps/mobile/src/features/review/reviewHighlighterEngine.ts index 4287685376d..5ef9f055a65 100644 --- a/apps/mobile/src/features/review/reviewHighlighterEngine.ts +++ b/apps/mobile/src/features/review/reviewHighlighterEngine.ts @@ -11,7 +11,7 @@ export function resolveReviewHighlighterEnginePreference( case "native": return "native"; default: - return "native"; + return "javascript"; } } diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index 008a0761949..44065875708 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -59,8 +59,7 @@ const SHIKI_THEME_NAME_BY_SCHEME = { dark: "github-dark-default", } as const; const REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE = - process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE ?? - (process.env.NODE_ENV === "test" ? "javascript" : "native"); + process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE ?? "javascript"; const REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE = resolveReviewHighlighterEnginePreference( REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, ); diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 18f6276fc48..329ce67c672 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -100,6 +100,27 @@ function OpeningThreadLoadingScreen() { return ; } +const ANDROID_THREAD_LOADING_ACTIONS: ReadonlyArray = [ + { + accessibilityLabel: "Open files", + icon: "folder", + disabled: true, + onPress: () => undefined, + }, + { + accessibilityLabel: "Open terminal", + icon: "terminal", + disabled: true, + onPress: () => undefined, + }, + { + accessibilityLabel: "Open git controls", + icon: "point.topleft.down.curvedto.point.bottomright.up", + disabled: true, + onPress: () => undefined, + }, +]; + type ThreadRouteScreenRouteProps = StaticScreenProps<{ readonly environmentId: string; readonly threadId: string; @@ -110,27 +131,65 @@ interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps { readonly renderInspector?: (headerInset: number) => ReactNode; } -function ThreadUnavailableScreen() { +function ThreadRouteStateScreen(props: { + readonly children: ReactNode; + readonly onNavigateUp: () => void; + readonly title: string; +}) { return ( - - - + <> + {Platform.OS === "android" ? ( + + ) : null} + {props.children} + + ); +} + +function ThreadUnavailableScreen(props: { readonly onNavigateUp: () => void }) { + return ( + + + + + + ); +} + +function OpeningThreadRouteScreen(props: { readonly onNavigateUp: () => void }) { + return ( + + + ); } export function ThreadRouteScreen(props: ThreadRouteScreenProps) { + const navigation = useNavigation(); + const handleNavigateUp = useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + navigation.dispatch(StackActions.replace("Home")); + }, [navigation]); const { state: workspaceState } = useWorkspaceState(); const { connectionState } = useRemoteConnectionStatus(); const { selectedThread } = useThreadSelection(); @@ -152,7 +211,7 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { const selectedThreadDetailState = useSelectedThreadDetailState(); if (environmentId === null || threadIdRaw === null) { - return ; + return ; } // Render the full thread chrome (header, feed, composer) as soon as the @@ -169,10 +228,10 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { routeConnectionState === "reconnecting"; if (stillHydrating) { - return ; + return ; } - return ; + return ; } function ThreadRouteContent( @@ -730,6 +789,13 @@ function ThreadRouteContent( // native back button does not render. Provide an explicit Home escape for // that case; when history exists the native back button is used instead. const canGoBack = navigation.canGoBack(); + const handleNavigateUp = useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + navigation.dispatch(StackActions.replace("Home")); + }, [navigation]); const compactHomeHeaderItems = useMemo( () => [ withNativeGlassHeaderItem({ @@ -856,7 +922,7 @@ function ThreadRouteContent( title={selectedThread.title} subtitle={headerSubtitle} reserveSubtitleSpace - onBack={layout.usesSplitView ? undefined : () => navigation.goBack()} + onBack={layout.usesSplitView ? undefined : handleNavigateUp} actions={androidHeaderActions} /> ) : null}