History

Enhance mobile review comments with diff previews

- Add syntax-highlighted diff rendering and cached review state - Support pasted/attached images in review comments - Improve mobile review sheet handling for large or partial diffs

Julius Marminge committed 7 months ago

Commit 9bebd281 parent 493fbfa17 files · +1567 −575Browse files

Unified · Split

17 files
  1. .oxfmtrc.json +1 −1
  2. apps/mobile/package.json +1 −0
  3. apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx +1 −0
  4. apps/mobile/src/components/ComposerAttachmentStrip.tsx +14 −3
  5. apps/mobile/src/features/threads/review/ReviewCommentComposerSheet.tsx +266 −135
  6. apps/mobile/src/features/threads/review/ReviewSheet.tsx +544 −410
  7. apps/mobile/src/features/threads/review/reviewCommentSelection.ts +4 −0
  8. apps/mobile/src/features/threads/review/reviewDiffRendering.tsx +82 −0
  9. apps/mobile/src/features/threads/review/reviewHighlighterEngine.test.ts +43 −0
  10. apps/mobile/src/features/threads/review/reviewHighlighterEngine.ts +36 −0
  11. apps/mobile/src/features/threads/review/reviewModel.test.ts +60 −0
  12. apps/mobile/src/features/threads/review/reviewModel.ts +99 −0
  13. apps/mobile/src/features/threads/review/reviewState.ts +195 −0
  14. apps/mobile/src/features/threads/review/shikiReviewHighlighter.test.ts +44 −0
  15. apps/mobile/src/features/threads/review/shikiReviewHighlighter.ts +138 −21
  16. apps/mobile/src/features/threads/use-thread-composer-state.ts +21 −0
  17. bun.lock +18 −5
.oxfmtrc.json+1−1
@@ -11,7 +11,7 @@ · 10 unmodified lines
1111 "**/routeTree.gen.ts",
1212 "apps/web/public/mockServiceWorker.js",
1313 "apps/web/src/lib/vendor/qrcodegen.ts",
14− "apps/mobile/uniwind-types.d.ts"
14+ "apps/mobile/uniwind-types.d.ts",
1515 "*.icon/**"
1616 ],
1717 "sortPackageJson": {}
apps/mobile/package.json+1−0
@@ -73,6 +73,7 @@ · 72 unmodified lines
7373 "react-native-reanimated": "4.2.1",
7474 "react-native-safe-area-context": "~5.6.2",
7575 "react-native-screens": "~4.23.0",
76+ "react-native-shiki-engine": "^0.3.9",
7677 "react-native-svg": "15.15.3",
7778 "react-native-worklets": "0.7.2",
7879 "shiki": "^4.0.2",
apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx+1−0
@@ -46,6 +46,7 @@ export default function ThreadLayout() { · 45 unmodified lines
4646 options={{
4747 animation: "slide_from_right",
4848 contentStyle: sheetStyle,
49+ headerBackButtonDisplayMode: "minimal",
4950 headerShown: true,
5051 headerTitle: "Files changed",
5152 headerBackTitle: "",
apps/mobile/src/components/ComposerAttachmentStrip.tsx+14−3
@@ -15,6 +15,8 @@ export interface ComposerAttachmentStripProps { · 14 unmodified lines
1515 readonly imageSize?: number;
1616 /** Border radius of each image thumbnail. Defaults to 16. */
1717 readonly imageBorderRadius?: number;
18+ /** Whether the remove button should sit in its own gutter instead of overlapping the image. */
19+ readonly removeButtonPlacement?: "overlay" | "gutter";
1820 }
1921
2022 /**
@@ -25,6 +27,8 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { · 4 unmodified lines
2527 const subtleBg = useThemeColor("--color-subtle");
2628 const size = props.imageSize ?? 72;
2729 const radius = props.imageBorderRadius ?? 16;
30+ const removeButtonPlacement = props.removeButtonPlacement ?? "overlay";
31+ const removeButtonGutter = removeButtonPlacement === "gutter" ? 10 : 0;
2832
2933 if (props.attachments.length === 0) {
3034 return null;
@@ -39,7 +43,14 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { · 8 unmodified lines
3943 >
4044 <View style={{ flexDirection: "row", gap: 10 }}>
4145 {props.attachments.map((image) => (
42− <View key={image.id} style={{ position: "relative" }}>
46+ <View
47+ key={image.id}
48+ style={{
49+ position: "relative",
50+ paddingTop: removeButtonGutter,
51+ paddingRight: removeButtonGutter,
52+ }}
53+ >
4354 <Pressable
4455 onPress={props.onPressImage ? () => props.onPressImage!(image.previewUri) : undefined}
4556 >
@@ -57,8 +68,8 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { · 11 unmodified lines
5768 <Pressable
5869 style={{
5970 position: "absolute",
60− top: 4,
61− right: 4,
71+ top: removeButtonPlacement === "gutter" ? 0 : 4,
72+ right: removeButtonPlacement === "gutter" ? 0 : 4,
6273 width: 22,
6374 height: 22,
6475 borderRadius: 11,
apps/mobile/src/features/threads/review/ReviewCommentComposerSheet.tsx+266−135
@@ -1,12 +1,29 @@
11 import { useLocalSearchParams, useRouter } from "expo-router";
22 import { SymbolView } from "expo-symbols";
3−import { useMemo, useState } from "react";
4−import { Platform, Pressable, ScrollView, Text as NativeText, View } from "react-native";
3+import { TextInputWrapper } from "expo-paste-input";
4+import { useEffect, useMemo, useState } from "react";
5+import {
6+ KeyboardAvoidingView,
7+ Platform,
8+ Pressable,
9+ ScrollView,
10+ View,
11+ useColorScheme,
12+ useWindowDimensions,
13+} from "react-native";
514 import { useSafeAreaInsets } from "react-native-safe-area-context";
15+import ImageViewing from "react-native-image-viewing";
616
717 import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";
18+import { ComposerAttachmentStrip } from "../../../components/ComposerAttachmentStrip";
19+import { ControlPill } from "../../../components/ControlPill";
820 import { cn } from "../../../lib/cn";
21+import type { DraftComposerImageAttachment } from "../../../lib/composerImages";
22+import { convertPastedImagesToAttachments, pickComposerImages } from "../../../lib/composerImages";
923 import { useThemeColor } from "../../../lib/useThemeColor";
24+import { useNativePaste } from "../../../hooks/useNativePaste";
25+import { setPendingConnectionError } from "../../../state/use-remote-environment-registry";
26+import { appendReviewCommentToDraft } from "../use-thread-composer-state";
1027 import {
1128 clearReviewCommentTarget,
1229 formatReviewCommentContext,
@@ -15,28 +32,21 @@ import { · 2 unmodified lines
1532 getSelectedReviewCommentLines,
1633 useReviewCommentTarget,
1734 } from "./reviewCommentSelection";
18−import { appendReviewCommentToDraft } from "../use-thread-composer-state";
19−
20−const REVIEW_MONO_FONT_FAMILY = Platform.select({
21− ios: "ui-monospace",
22− android: "monospace",
23− default: "monospace",
24−});
25−
26−function renderVisibleWhitespace(value: string): string {
27− const expandedTabs = value.replace(/\t/g, " ");
28− return expandedTabs.replace(/^( +)/, (leading) => leading.replaceAll(" ", "\u00A0"));
29−}
35+import { changeTone, DiffTokenText } from "./reviewDiffRendering";
36+import {
37+ highlightReviewSelectedLines,
38+ type ReviewDiffTheme,
39+ type ReviewHighlightedToken,
40+} from "./shikiReviewHighlighter";
3041
31−function changeTone(change: "context" | "add" | "delete"): string {
32− if (change === "add") return "bg-emerald-500/12";
33− if (change === "delete") return "bg-rose-500/12";
34− return "bg-card";
35−}
42+const REVIEW_COMMENT_PREVIEW_MAX_LINES = 5;
43+const REVIEW_COMMENT_LINE_HEIGHT = 26;
3644
3745 export function ReviewCommentComposerSheet() {
3846 const router = useRouter();
3947 const insets = useSafeAreaInsets();
48+ const { width } = useWindowDimensions();
49+ const colorScheme = useColorScheme();
4050 const iconTint = String(useThemeColor("--color-icon"));
4151 const target = useReviewCommentTarget();
4252 const { environmentId, threadId } = useLocalSearchParams<{
@@ -44,6 +54,11 @@ export function ReviewCommentComposerSheet() { · 1 unmodified line
4454 threadId: string;
4555 }>();
4656 const [commentText, setCommentText] = useState("");
57+ const [highlightedLinesById, setHighlightedLinesById] = useState<
58+ Record<string, ReadonlyArray<ReviewHighlightedToken>>
59+ >({});
60+ const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]);
61+ const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
4762
4863 const selectedLines = useMemo(
4964 () => (target ? getSelectedReviewCommentLines(target) : []),
@@ -53,136 +68,252 @@ export function ReviewCommentComposerSheet() { · 3 unmodified lines
5368 const lastLine = selectedLines[selectedLines.length - 1] ?? null;
5469 const firstNumber = firstLine ? getReviewUnifiedLineNumber(firstLine) : null;
5570 const lastNumber = lastLine ? getReviewUnifiedLineNumber(lastLine) : null;
71+ const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme;
5672 const canSubmit =
5773 commentText.trim().length > 0 && target !== null && !!environmentId && !!threadId;
74+ const selectionLabel =
75+ selectedLines.length === 1
76+ ? firstNumber !== null
77+ ? `Line ${firstNumber}`
78+ : "File comment"
79+ : firstNumber !== null && lastNumber !== null
80+ ? `Lines ${firstNumber}-${lastNumber}`
81+ : `${selectedLines.length} lines selected`;
82+ const previewHeight = Math.max(
83+ Math.min(selectedLines.length, REVIEW_COMMENT_PREVIEW_MAX_LINES) * REVIEW_COMMENT_LINE_HEIGHT,
84+ REVIEW_COMMENT_LINE_HEIGHT,
85+ );
86+ const previewViewportWidth = Math.max(width - 40, 280);
87+ const handleNativePaste = useNativePaste((uris) => {
88+ void (async () => {
89+ try {
90+ const images = await convertPastedImagesToAttachments({
91+ uris,
92+ existingCount: attachments.length,
93+ });
94+ if (images.length > 0) {
95+ setAttachments((current) => [...current, ...images]);
96+ }
97+ } catch (error) {
98+ console.error("[review comment] error converting pasted images", error);
99+ }
100+ })();
101+ });
102+
103+ useEffect(() => {
104+ if (!target || selectedLines.length === 0) {
105+ setHighlightedLinesById({});
106+ return;
107+ }
108+
109+ let cancelled = false;
110+ void highlightReviewSelectedLines({
111+ filePath: target.filePath,
112+ lines: selectedLines,
113+ theme: selectedTheme,
114+ })
115+ .then((next) => {
116+ if (!cancelled) {
117+ setHighlightedLinesById(next);
118+ }
119+ })
120+ .catch(() => {
121+ if (!cancelled) {
122+ setHighlightedLinesById({});
123+ }
124+ });
125+
126+ return () => {
127+ cancelled = true;
128+ };
129+ }, [selectedLines, selectedTheme, target]);
130+
131+ async function handlePickImages(): Promise<void> {
132+ const result = await pickComposerImages({ existingCount: attachments.length });
133+ if (result.images.length > 0) {
134+ setAttachments((current) => [...current, ...result.images]);
135+ }
136+ if (result.error) {
137+ setPendingConnectionError(result.error);
138+ }
139+ }
58140
59141 return (
60− <ScrollView
61− keyboardShouldPersistTaps="handled"
62− showsVerticalScrollIndicator={false}
63− contentContainerStyle={{
64− paddingHorizontal: 20,
65− paddingTop: 8,
66− paddingBottom: Math.max(insets.bottom, 18) + 18,
67− gap: 16,
68− }}
142+ <KeyboardAvoidingView
143+ behavior={Platform.OS === "ios" ? "padding" : "height"}
144+ keyboardVerticalOffset={Platform.OS === "ios" ? 12 : 0}
145+ style={{ flex: 1 }}
69146 >
70− <View className="flex-row items-center justify-between py-2">
71− <Pressable
72− className="bg-subtle h-12 w-12 items-center justify-center rounded-full"
73− onPress={() => {
74− clearReviewCommentTarget();
75− router.dismiss();
76− }}
77− >
78− <SymbolView name="xmark" size={18} tintColor={iconTint} type="monochrome" />
79− </Pressable>
80−
81− <Text className="text-[18px] font-t3-bold text-foreground">Add Comment</Text>
82−
83− <Pressable
84− className={cn(
85− "min-h-[44px] min-w-[124px] items-center justify-center rounded-full px-5",
86− canSubmit ? "bg-foreground" : "border border-border bg-card",
87− )}
88− disabled={!canSubmit}
89− onPress={() => {
90− if (!target || !environmentId || !threadId || commentText.trim().length === 0) {
91− return;
92− }
93−
94− appendReviewCommentToDraft({
95− environmentId,
96− threadId,
97− text: formatReviewCommentContext(target, commentText),
98− });
99− clearReviewCommentTarget();
100− router.dismiss();
101− }}
102− style={{ opacity: canSubmit ? 1 : 0.55 }}
103− >
104− <Text
105− className={cn(
106− "text-[14px] font-t3-bold",
107− canSubmit ? "text-background" : "text-foreground-muted",
108− )}
147+ <View
148+ style={{
149+ flex: 1,
150+ paddingHorizontal: 20,
151+ paddingTop: 8,
152+ paddingBottom: Math.max(insets.bottom, 18) + 18,
153+ }}
154+ >
155+ <View className="flex-row items-center justify-between py-2">
156+ <Pressable
157+ className="bg-subtle h-12 w-12 items-center justify-center rounded-full"
158+ onPress={() => {
159+ clearReviewCommentTarget();
160+ router.dismiss();
161+ }}
109162 >
110− Comment
111− </Text>
112− </Pressable>
113− </View>
163+ <SymbolView name="xmark" size={18} tintColor={iconTint} type="monochrome" />
164+ </Pressable>
114165
115− {!target ? (
116− <View className="rounded-[22px] border border-border bg-card px-4 py-5">
117− <Text className="text-[15px] font-t3-bold text-foreground">No selection</Text>
118− <Text className="mt-1 text-[13px] leading-[19px] text-foreground-muted">
119− Select a diff line or range first.
120− </Text>
166+ <Text className="text-[18px] font-t3-bold text-foreground">Add Comment</Text>
167+
168+ <View className="h-12 w-12" />
121169 </View>
122− ) : (
123− <>
124− <View className="gap-1 rounded-[22px] border border-border bg-card px-4 py-4">
125− <Text className="font-mono text-[13px] leading-[18px] text-foreground">
126− {target.filePath}
127− </Text>
128− <Text className="text-[12px] font-t3-medium text-foreground-muted">
129− {selectedLines.length === 1
130− ? firstNumber !== null
131− ? `Line ${firstNumber}`
132− : "File comment"
133− : firstNumber !== null && lastNumber !== null
134− ? `Lines ${firstNumber}-${lastNumber}`
135− : `${selectedLines.length} lines selected`}
170+
171+ {!target ? (
172+ <View className="rounded-[22px] border border-border bg-card px-4 py-5">
173+ <Text className="text-[15px] font-t3-bold text-foreground">No selection</Text>
174+ <Text className="mt-1 text-[13px] leading-[19px] text-foreground-muted">
175+ Select a diff line or range first.
136176 </Text>
137177 </View>
178+ ) : (
179+ <View className="flex-1 gap-4">
180+ <View className="gap-1 px-1">
181+ <Text className="text-[11px] font-t3-bold uppercase text-foreground-muted">
182+ {selectionLabel}
183+ </Text>
184+ <Text
185+ className="font-mono text-[12px] leading-[17px] text-foreground-muted"
186+ ellipsizeMode="middle"
187+ numberOfLines={2}
188+ >
189+ {target.filePath}
190+ </Text>
191+ </View>
138192
139− <View className="overflow-hidden rounded-[22px] border border-border bg-card">
140− {selectedLines.map((line) => {
141− const lineNumber = getReviewUnifiedLineNumber(line);
142−
143− return (
144− <View
145− key={line.id}
146− className={cn(
147− "flex-row items-start border-b border-border/60",
148− changeTone(line.change),
149− )}
193+ <View className="overflow-hidden rounded-[22px] border border-border bg-card">
194+ <ScrollView
195+ horizontal
196+ bounces={false}
197+ keyboardShouldPersistTaps="always"
198+ showsHorizontalScrollIndicator={false}
199+ >
200+ <ScrollView
201+ bounces={false}
202+ scrollEnabled={selectedLines.length > REVIEW_COMMENT_PREVIEW_MAX_LINES}
203+ nestedScrollEnabled
204+ keyboardShouldPersistTaps="always"
205+ showsVerticalScrollIndicator={
206+ selectedLines.length > REVIEW_COMMENT_PREVIEW_MAX_LINES
207+ }
208+ style={{ height: previewHeight }}
150209 >
151− <Text className="w-9 px-1 py-2 text-right text-[11px] font-t3-medium text-foreground-muted">
152− {lineNumber ?? ""}
153− </Text>
154− <Text
155− className="px-0.5 py-2 text-center font-mono text-[12px] text-foreground-muted"
156− style={{ width: 18 }}
157− >
158− {getReviewChangeMarker(line.change)}
159− </Text>
160− <NativeText
161− selectable
162− className="flex-1 px-1 py-2 text-[12px] leading-[19px] text-foreground"
163− style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }}
164− >
165− {renderVisibleWhitespace(line.content || " ")}
166− </NativeText>
210+ <View style={{ minWidth: previewViewportWidth }}>
211+ {selectedLines.map((line) => {
212+ const lineNumber = getReviewUnifiedLineNumber(line);
213+
214+ return (
215+ <View
216+ key={line.id}
217+ className={cn("flex-row items-start", changeTone(line.change))}
218+ style={{ height: REVIEW_COMMENT_LINE_HEIGHT }}
219+ >
220+ <Text className="w-9 px-1 py-1 text-right text-[11px] font-t3-medium text-foreground-muted">
221+ {lineNumber ?? ""}
222+ </Text>
223+ <Text
224+ className="px-0.5 py-1 text-center font-mono text-[12px] text-foreground-muted"
225+ style={{ width: 18 }}
226+ >
227+ {getReviewChangeMarker(line.change)}
228+ </Text>
229+ <View className="min-w-0 flex-1 flex-shrink-0 px-1 py-1">
230+ <DiffTokenText
231+ fallback={line.content}
232+ tokens={highlightedLinesById[line.id] ?? null}
233+ />
234+ </View>
235+ </View>
236+ );
237+ })}
238+ </View>
239+ </ScrollView>
240+ </ScrollView>
241+ </View>
242+
243+ <View className="gap-2">
244+ <Text className="text-[13px] font-t3-bold text-foreground">Comment</Text>
245+ <View
246+ className="overflow-hidden rounded-[20px] border border-border bg-card"
247+ style={{ height: 172 }}
248+ >
249+ <View className="flex-1 px-4 pt-3.5">
250+ <TextInputWrapper onPaste={handleNativePaste}>
251+ <TextInput
252+ autoFocus
253+ multiline
254+ placeholder="Leave a comment..."
255+ textAlignVertical="top"
256+ value={commentText}
257+ onChangeText={setCommentText}
258+ className="flex-1 border-0 bg-transparent px-0 py-0 font-sans text-[15px]"
259+ />
260+ </TextInputWrapper>
167261 </View>
168− );
169− })}
170− </View>
262+ {attachments.length > 0 ? (
263+ <View className="px-4 pb-3 pt-2">
264+ <ComposerAttachmentStrip
265+ attachments={attachments}
266+ imageBorderRadius={16}
267+ imageSize={60}
268+ onPressImage={setPreviewImageUri}
269+ removeButtonPlacement="gutter"
270+ onRemove={(imageId) => {
271+ setAttachments((current) =>
272+ current.filter((image) => image.id !== imageId),
273+ );
274+ }}
275+ />
276+ </View>
277+ ) : null}
278+ </View>
279+ </View>
171280
172− <View className="gap-2">
173− <Text className="text-[13px] font-t3-bold text-foreground">Comment</Text>
174− <TextInput
175− autoFocus
176− multiline
177− placeholder="Leave a comment..."
178− textAlignVertical="top"
179− value={commentText}
180− onChangeText={setCommentText}
181− className="min-h-[192px] rounded-[20px] px-4 py-3.5 font-sans text-[15px]"
182− />
281+ <View className="flex-row items-center gap-3 pt-1">
282+ <ControlPill icon="plus" onPress={() => void handlePickImages()} />
283+ <View className="flex-1" />
284+ <ControlPill
285+ icon="arrow.up"
286+ label="Comment"
287+ variant="primary"
288+ disabled={!canSubmit}
289+ onPress={() => {
290+ if (!target || !environmentId || !threadId || commentText.trim().length === 0) {
291+ return;
292+ }
293+
294+ appendReviewCommentToDraft({
295+ environmentId,
296+ threadId,
297+ text: formatReviewCommentContext(target, commentText),
298+ attachments,
299+ });
300+ setAttachments([]);
301+ clearReviewCommentTarget();
302+ router.dismiss();
303+ }}
304+ />
305+ </View>
183306 </View>
184− </>
185− )}
186− </ScrollView>
307+ )}
308+ </View>
309+ <ImageViewing
310+ images={previewImageUri ? [{ uri: previewImageUri }] : []}
311+ imageIndex={0}
312+ visible={previewImageUri !== null}
313+ onRequestClose={() => setPreviewImageUri(null)}
314+ swipeToCloseEnabled
315+ doubleTapToZoomEnabled
316+ />
317+ </KeyboardAvoidingView>
187318 );
188319 }
apps/mobile/src/features/threads/review/ReviewSheet.tsx+544−410
@@ -1,15 +1,11 @@
1−import {
2− ThreadId,
3− type GitReviewDiffSection,
4− type OrchestrationCheckpointSummary,
5−} from "@t3tools/contracts";
1+import { ThreadId, type OrchestrationCheckpointSummary } from "@t3tools/contracts";
2+import { LegendList } from "@legendapp/list/react-native";
63 import { useLocalSearchParams, useRouter } from "expo-router";
74 import Stack from "expo-router/stack";
85 import { SymbolView } from "expo-symbols";
9−import { type ReactElement, useCallback, useEffect, useMemo, useState } from "react";
6+import { memo, type ReactElement, useCallback, useEffect, useMemo, useState } from "react";
107 import {
118 ActivityIndicator,
12− Platform,
139 Pressable,
1410 ScrollView,
1511 Text as NativeText,
@@ -25,16 +21,28 @@ import { useThemeColor } from "../../../lib/useThemeColor"; · 9 unmodified lines
2521 import { getEnvironmentClient } from "../../../state/use-remote-environment-registry";
2622 import { useSelectedThreadDetail } from "../../../state/use-thread-detail";
2723 import { useThreadSelection } from "../../../state/use-thread-selection";
24+import { useThreadDraftForThread } from "../use-thread-composer-state";
25+import {
26+ getCachedReviewParsedDiff,
27+ setReviewGitSections,
28+ setReviewSelectedSectionId,
29+ setReviewTurnDiff,
30+ updateReviewExpandedFileIds,
31+ updateReviewRevealedLargeFileIds,
32+ useReviewCacheForThread,
33+} from "./reviewState";
2834 import {
29− buildReviewParsedDiff,
3035 getReadyReviewCheckpoints,
3136 buildReviewSectionItems,
3237 getDefaultReviewSectionId,
38+ getReviewFilePreviewState,
3339 getReviewSectionIdForCheckpoint,
40+ type ReviewParsedDiff,
3441 type ReviewRenderableFile,
3542 type ReviewRenderableLineRow,
3643 } from "./reviewModel";
3744 import {
45+ getCachedHighlightedReviewFile,
3846 highlightReviewFile,
3947 type ReviewDiffTheme,
4048 type ReviewHighlightedFile,
@@ -43,6 +51,7 @@ import { · 2 unmodified lines
4351 import {
4452 buildReviewCommentTarget,
4553 clearReviewCommentTarget,
54+ countReviewCommentContexts,
4655 formatReviewSelectedRangeLabel,
4756 getReviewChangeMarker,
4857 getReviewUnifiedLineNumber,
@@ -51,6 +60,7 @@ import { · 2 unmodified lines
5160 type ReviewCommentTarget,
5261 useReviewCommentTarget,
5362 } from "./reviewCommentSelection";
63+import { changeTone, DiffTokenText } from "./reviewDiffRendering";
5464
5565 interface PendingCommentSelection {
5666 readonly sectionTitle: string;
@@ -59,20 +69,16 @@ interface PendingCommentSelection { · 2 unmodified lines
5969 readonly anchorIndex: number;
6070 }
6171
62−const REVIEW_MONO_FONT_FAMILY = Platform.select({
63− ios: "ui-monospace",
64− android: "monospace",
65− default: "monospace",
66−});
72+interface ReviewLineActionInput {
73+ readonly sectionTitle: string;
74+ readonly filePath: string;
75+ readonly lines: ReadonlyArray<ReviewRenderableLineRow>;
76+ readonly lineIndex: number;
77+}
78+
6779 const IOS_NAV_BAR_HEIGHT = 44;
6880 const REVIEW_HEADER_SPACING = 32;
6981
70−function changeTone(change: ReviewRenderableLineRow["change"]): string {
71− if (change === "add") return "bg-emerald-500/12";
72− if (change === "delete") return "bg-rose-500/12";
73− return "bg-card";
74−}
75−
7682 function changeTypeLabel(type: ReviewRenderableFile["changeType"]): string {
7783 switch (type) {
7884 case "new":
@@ -88,7 +94,7 @@ function changeTypeLabel(type: ReviewRenderableFile["changeType"]): string { · 9 unmodified lines
8894 }
8995 }
9096
91−function formatHeaderDiffSummary(parsedDiff: ReturnType<typeof buildReviewParsedDiff>): {
97+function formatHeaderDiffSummary(parsedDiff: ReviewParsedDiff): {
9298 readonly additions: string | null;
9399 readonly deletions: string | null;
94100 } {
@@ -102,84 +108,13 @@ function formatHeaderDiffSummary(parsedDiff: ReturnType<typeof buildReviewParsed · 7 unmodified lines
102108 };
103109 }
104110
105−function shouldAutoExpandFile(file: ReviewRenderableFile): boolean {
106− return file.additions + file.deletions <= 20 && file.rows.length <= 40;
107−}
108−
109111 function getDefaultExpandedFileIds(
110112 files: ReadonlyArray<ReviewRenderableFile>,
111113 ): ReadonlyArray<string> {
112− const autoExpanded = files.filter(shouldAutoExpandFile).map((file) => file.id);
113− if (autoExpanded.length > 0) {
114− return autoExpanded;
115− }
116− return files.length === 1 ? [files[0]!.id] : [];
117−}
118−
119−function renderVisibleWhitespace(value: string): string {
120− const expandedTabs = value.replace(/\t/g, " ");
121− return expandedTabs.replace(/^( +)/, (leading) => leading.replaceAll(" ", "\u00A0"));
122−}
123−
124−function DiffTokenText(props: {
125− readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null;
126− readonly fallback: string;
127−}) {
128− if (!props.tokens || props.tokens.length === 0) {
129− return (
130− <NativeText
131− selectable
132− className="text-[12px] leading-[19px] text-foreground"
133− style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }}
134− >
135− {renderVisibleWhitespace(props.fallback || " ")}
136− </NativeText>
137− );
138− }
139−
140− return (
141− <NativeText
142− selectable
143− className="text-[12px] leading-[19px] text-foreground"
144− style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }}
145− >
146− {(() => {
147− let offset = 0;
148−
149− return props.tokens.map((token) => {
150− const start = offset;
151− offset += token.content.length;
152−
153− const fontWeight =
154− token.fontStyle !== null && (token.fontStyle & 2) === 2
155− ? ("700" as const)
156− : ("400" as const);
157− const fontStyle =
158− token.fontStyle !== null && (token.fontStyle & 1) === 1
159− ? ("italic" as const)
160− : ("normal" as const);
161−
162− return (
163− <NativeText
164− key={`${start}:${token.content.length}:${token.color ?? ""}:${token.fontStyle ?? ""}`}
165− selectable
166− style={{
167− color: token.color ?? undefined,
168− fontFamily: REVIEW_MONO_FONT_FAMILY,
169− fontWeight,
170− fontStyle,
171− }}
172− >
173− {token.content.length > 0 ? renderVisibleWhitespace(token.content) : " "}
174− </NativeText>
175− );
176− });
177− })()}
178− </NativeText>
179− );
114+ return files.map((file) => file.id);
180115 }
181116
182−function ReviewLineRow(props: {
117+const ReviewLineRow = memo(function ReviewLineRow(props: {
183118 readonly line: ReviewRenderableLineRow;
184119 readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null;
185120 readonly viewportWidth: number;
@@ -192,7 +127,7 @@ function ReviewLineRow(props: { · 6 unmodified lines
192127 return (
193128 <Pressable
194129 className={cn(
195− "flex-row items-start border-b border-border/60",
130+ "flex-row items-start",
196131 changeTone(props.line.change),
197132 props.selectionState === "anchor" && "bg-sky-500/16",
198133 props.selectionState === "selected" && "bg-amber-300/28",
@@ -210,30 +145,34 @@ function ReviewLineRow(props: { · 11 unmodified lines
210145 onPress={props.onComment}
211146 style={{ minWidth: props.viewportWidth }}
212147 >
213− <Text className="w-9 px-1 py-2 text-right text-[11px] font-t3-medium text-foreground-muted">
148+ <Text className="w-9 px-1 py-1 text-right text-[11px] font-t3-medium text-foreground-muted">
214149 {lineNumber ?? ""}
215150 </Text>
216151 <Text
217− className="px-0.5 py-2 text-center font-mono text-[12px] text-foreground-muted"
152+ className="px-0.5 py-1 text-center font-mono text-[12px] text-foreground-muted"
218153 style={{ width: 18 }}
219154 >
220155 {getReviewChangeMarker(props.line.change)}
221156 </Text>
222− <View className="min-w-0 flex-1 flex-shrink-0 px-1 py-2">
157+ <View className="min-w-0 flex-1 flex-shrink-0 px-1 py-1">
223158 <DiffTokenText tokens={props.tokens} fallback={props.line.content} />
224159 </View>
225160 </Pressable>
226161 );
227−}
162+});
228163
229−function ReviewFileCard(props: {
164+const ReviewFileCard = memo(function ReviewFileCard(props: {
230165 readonly file: ReviewRenderableFile;
166+ readonly fileId: string;
231167 readonly expanded: boolean;
232− readonly onToggle: () => void;
168+ readonly onToggleFile: (fileId: string) => void;
233169 }) {
234170 return (
235171 <View className="border-b border-border bg-card" style={{ zIndex: 1 }}>
236− <Pressable className="flex-row items-start gap-2 px-3 py-3" onPress={props.onToggle}>
172+ <Pressable
173+ className="flex-row items-start gap-2 px-3 py-3"
174+ onPress={() => props.onToggleFile(props.fileId)}
175+ >
237176 <View className="pt-0.5">
238177 <SymbolView
239178 name={props.expanded ? "chevron.down" : "chevron.right"}
@@ -266,41 +205,44 @@ function ReviewFileCard(props: { · 26 unmodified lines
266205 </Pressable>
267206 </View>
268207 );
269−}
208+});
270209
271−function ReviewFileBody(props: {
210+const ReviewFileBody = memo(function ReviewFileBody(props: {
272211 readonly file: ReviewRenderableFile;
273212 readonly sectionTitle: string;
274213 readonly highlightedFile: ReviewHighlightedFile | null;
275214 readonly viewportWidth: number;
276215 readonly pendingSelection: PendingCommentSelection | null;
277216 readonly selectedTarget: ReviewCommentTarget | null;
278− readonly onPressLine: (
279− line: ReviewRenderableLineRow,
280− availableLines: ReadonlyArray<ReviewRenderableLineRow>,
281− lineIndex: number,
282− ) => void;
283− readonly onStartRangeSelection: (
284− line: ReviewRenderableLineRow,
285− availableLines: ReadonlyArray<ReviewRenderableLineRow>,
286− lineIndex: number,
287− ) => void;
217+ readonly onPressLine: (input: ReviewLineActionInput) => void;
218+ readonly onStartRangeSelection: (input: ReviewLineActionInput) => void;
288219 }) {
289− const commentableLines = props.file.rows.filter(
290− (row): row is ReviewRenderableLineRow => row.kind === "line",
220+ const commentableLines = useMemo(
221+ () => props.file.rows.filter((row): row is ReviewRenderableLineRow => row.kind === "line"),
222+ [props.file.rows],
223+ );
224+ const lineIndexById = useMemo(
225+ () => new Map(commentableLines.map((line, index) => [line.id, index])),
226+ [commentableLines],
227+ );
228+ const anchorLineId = useMemo(
229+ () =>
230+ props.pendingSelection &&
231+ props.pendingSelection.sectionTitle === props.sectionTitle &&
232+ props.pendingSelection.filePath === props.file.path
233+ ? (props.pendingSelection.lines[props.pendingSelection.anchorIndex]?.id ?? null)
234+ : null,
235+ [props.file.path, props.pendingSelection, props.sectionTitle],
236+ );
237+ const selectedLineIds = useMemo(
238+ () =>
239+ props.selectedTarget &&
240+ props.selectedTarget.sectionTitle === props.sectionTitle &&
241+ props.selectedTarget.filePath === props.file.path
242+ ? new Set(getSelectedReviewCommentLines(props.selectedTarget).map((line) => line.id))
243+ : null,
244+ [props.file.path, props.sectionTitle, props.selectedTarget],
291245 );
292− const anchorLineId =
293− props.pendingSelection &&
294− props.pendingSelection.sectionTitle === props.sectionTitle &&
295− props.pendingSelection.filePath === props.file.path
296− ? (props.pendingSelection.lines[props.pendingSelection.anchorIndex]?.id ?? null)
297− : null;
298− const selectedLineIds =
299− props.selectedTarget &&
300− props.selectedTarget.sectionTitle === props.sectionTitle &&
301− props.selectedTarget.filePath === props.file.path
302− ? new Set(getSelectedReviewCommentLines(props.selectedTarget).map((line) => line.id))
303− : null;
304246
305247 return (
306248 <ScrollView
@@ -345,16 +287,20 @@ function ReviewFileBody(props: { · 38 unmodified lines
345287 : null
346288 }
347289 onComment={() => {
348− const lineIndex = commentableLines.findIndex(
349− (candidate) => candidate.id === row.id,
350− );
351− props.onPressLine(row, commentableLines, lineIndex >= 0 ? lineIndex : 0);
290+ props.onPressLine({
291+ sectionTitle: props.sectionTitle,
292+ filePath: props.file.path,
293+ lines: commentableLines,
294+ lineIndex: lineIndexById.get(row.id) ?? 0,
295+ });
352296 }}
353297 onStartRangeSelection={() => {
354− const lineIndex = commentableLines.findIndex(
355− (candidate) => candidate.id === row.id,
356− );
357− props.onStartRangeSelection(row, commentableLines, lineIndex >= 0 ? lineIndex : 0);
298+ props.onStartRangeSelection({
299+ sectionTitle: props.sectionTitle,
300+ filePath: props.file.path,
301+ lines: commentableLines,
302+ lineIndex: lineIndexById.get(row.id) ?? 0,
303+ });
358304 }}
359305 />
360306 );
@@ -362,7 +308,129 @@ function ReviewFileBody(props: { · 1 unmodified line
362308 </View>
363309 </ScrollView>
364310 );
365−}
311+});
312+
313+const ReviewFileSuppressedBody = memo(function ReviewFileSuppressedBody(props: {
314+ readonly message: string;
315+ readonly actionLabel?: string | null;
316+ readonly fileId: string;
317+ readonly onLoadDiffFile?: (fileId: string) => void;
318+}) {
319+ return (
320+ <View className="gap-2 border-b border-border bg-card px-4 py-3">
321+ <Text className="text-[12px] leading-[18px] text-foreground-muted">{props.message}</Text>
322+ {props.actionLabel && props.onLoadDiffFile ? (
323+ <Pressable
324+ className="self-start rounded-full bg-subtle px-3 py-2"
325+ onPress={() => props.onLoadDiffFile?.(props.fileId)}
326+ >
327+ <Text className="text-[12px] font-t3-bold text-foreground">{props.actionLabel}</Text>
328+ </Pressable>
329+ ) : null}
330+ </View>
331+ );
332+});
333+
334+const ReviewFileItem = memo(function ReviewFileItem(props: {
335+ readonly file: ReviewRenderableFile;
336+ readonly expanded: boolean;
337+ readonly sectionTitle: string;
338+ readonly selectedTheme: ReviewDiffTheme;
339+ readonly pendingSelection: PendingCommentSelection | null;
340+ readonly selectedTarget: ReviewCommentTarget | null;
341+ readonly viewportWidth: number;
342+ readonly revealedLarge: boolean;
343+ readonly onToggleFile: (fileId: string) => void;
344+ readonly onLoadDiffFile: (fileId: string) => void;
345+ readonly onPressLine: (input: ReviewLineActionInput) => void;
346+ readonly onStartRangeSelection: (input: ReviewLineActionInput) => void;
347+}) {
348+ const previewState = useMemo(() => getReviewFilePreviewState(props.file), [props.file]);
349+ const shouldRenderBody =
350+ previewState.kind === "render" || (previewState.reason === "large" && props.revealedLarge);
351+ const highlightCacheKey = `${props.selectedTheme}:${props.file.cacheKey}`;
352+ const [highlightedFile, setHighlightedFile] = useState<ReviewHighlightedFile | null>(() =>
353+ getCachedHighlightedReviewFile(props.file, props.selectedTheme),
354+ );
355+
356+ useEffect(() => {
357+ setHighlightedFile(getCachedHighlightedReviewFile(props.file, props.selectedTheme));
358+ }, [highlightCacheKey, props.file, props.selectedTheme]);
359+
360+ useEffect(() => {
361+ if (!props.expanded || !shouldRenderBody) {
362+ return;
363+ }
364+
365+ const cached = getCachedHighlightedReviewFile(props.file, props.selectedTheme);
366+ if (cached) {
367+ setHighlightedFile(cached);
368+ return;
369+ }
370+
371+ let cancelled = false;
372+ void highlightReviewFile(props.file, props.selectedTheme)
373+ .then((result) => {
374+ if (!cancelled) {
375+ setHighlightedFile(result);
376+ }
377+ })
378+ .catch(() => {
379+ if (cancelled) {
380+ return;
381+ }
382+ });
383+
384+ return () => {
385+ cancelled = true;
386+ };
387+ }, [highlightCacheKey, props.expanded, props.file, props.selectedTheme, shouldRenderBody]);
388+
389+ return (
390+ <>
391+ <ReviewFileCard
392+ file={props.file}
393+ fileId={props.file.id}
394+ expanded={props.expanded}
395+ onToggleFile={props.onToggleFile}
396+ />
397+ {props.expanded ? (
398+ shouldRenderBody ? (
399+ <ReviewFileBody
400+ file={props.file}
401+ sectionTitle={props.sectionTitle}
402+ highlightedFile={highlightedFile}
403+ pendingSelection={props.pendingSelection}
404+ selectedTarget={props.selectedTarget}
405+ viewportWidth={props.viewportWidth}
406+ onPressLine={props.onPressLine}
407+ onStartRangeSelection={props.onStartRangeSelection}
408+ />
409+ ) : (
410+ <ReviewFileSuppressedBody
411+ message={previewState.message}
412+ actionLabel={previewState.actionLabel}
413+ fileId={props.file.id}
414+ onLoadDiffFile={previewState.actionLabel ? props.onLoadDiffFile : undefined}
415+ />
416+ )
417+ ) : null}
418+ </>
419+ );
420+});
421+
422+const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) {
423+ return (
424+ <View className="border-b border-amber-200 bg-amber-50 px-4 py-3 dark:border-amber-900/60 dark:bg-amber-950/40">
425+ <Text className="text-[12px] font-t3-bold uppercase text-amber-700 dark:text-amber-300">
426+ Partial diff
427+ </Text>
428+ <Text className="text-[12px] leading-[18px] text-amber-800 dark:text-amber-200">
429+ {props.notice}
430+ </Text>
431+ </View>
432+ );
433+});
366434
367435 function ReviewSelectionActionBar(props: {
368436 readonly target: ReviewCommentTarget | null;
@@ -419,20 +487,13 @@ export function ReviewSheet() { · 50 unmodified lines
419487 environmentId: string;
420488 threadId: string;
421489 }>();
490+ const { draftMessage } = useThreadDraftForThread({ environmentId, threadId });
491+ const reviewCache = useReviewCacheForThread({ environmentId, threadId });
422492 const { selectedThreadProject } = useThreadSelection();
423493 const selectedThread = useSelectedThreadDetail();
424− const [gitSections, setGitSections] = useState<ReadonlyArray<GitReviewDiffSection>>([]);
425− const [turnDiffById, setTurnDiffById] = useState<Record<string, string>>({});
426494 const [loadingTurnIds, setLoadingTurnIds] = useState<Record<string, boolean>>({});
427− const [selectedSectionId, setSelectedSectionId] = useState<string | null>(null);
428495 const [loadingGitDiffs, setLoadingGitDiffs] = useState(false);
429496 const [error, setError] = useState<string | null>(null);
430− const [expandedFileIdsBySection, setExpandedFileIdsBySection] = useState<
431− Record<string, ReadonlyArray<string> | undefined>
432− >({});
433− const [highlightedFileByKey, setHighlightedFileByKey] = useState<
434− Record<string, ReviewHighlightedFile>
435− >({});
436497 const [pendingCommentSelection, setPendingCommentSelection] =
437498 useState<PendingCommentSelection | null>(null);
438499 const activeCommentTarget = useReviewCommentTarget();
@@ -456,39 +517,49 @@ export function ReviewSheet() { · 17 unmodified lines
456517 () =>
457518 buildReviewSectionItems({
458519 checkpoints: readyCheckpoints,
459− gitSections,
460− turnDiffById,
520+ gitSections: reviewCache.gitSections,
521+ turnDiffById: reviewCache.turnDiffById,
461522 loadingTurnIds,
462523 }),
463− [gitSections, loadingTurnIds, readyCheckpoints, turnDiffById],
524+ [loadingTurnIds, readyCheckpoints, reviewCache.gitSections, reviewCache.turnDiffById],
464525 );
465526
466527 const selectedSection =
467− reviewSections.find((section) => section.id === selectedSectionId) ?? reviewSections[0] ?? null;
528+ reviewSections.find((section) => section.id === reviewCache.selectedSectionId) ??
529+ reviewSections[0] ??
530+ null;
468531 const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT;
469532 const parsedDiff = useMemo(
470− () => buildReviewParsedDiff(selectedSection?.diff, selectedSection?.id ?? "mobile-review"),
471− [selectedSection?.diff, selectedSection?.id],
533+ () =>
534+ getCachedReviewParsedDiff({
535+ threadKey: reviewCache.threadKey,
536+ sectionId: selectedSection?.id ?? null,
537+ diff: selectedSection?.diff,
538+ }),
539+ [reviewCache.threadKey, selectedSection?.diff, selectedSection?.id],
472540 );
473541 const headerDiffSummary = useMemo(() => formatHeaderDiffSummary(parsedDiff), [parsedDiff]);
542+ const pendingReviewCommentCount = useMemo(
543+ () => countReviewCommentContexts(draftMessage),
544+ [draftMessage],
545+ );
474546
475547 const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme;
476548 const expandedFileIds = useMemo(
477549 () =>
478550 selectedSection?.id && parsedDiff.kind === "files"
479− ? (expandedFileIdsBySection[selectedSection.id] ??
551+ ? (reviewCache.expandedFileIdsBySection[selectedSection.id] ??
480552 getDefaultExpandedFileIds(parsedDiff.files))
481553 : [],
482− [expandedFileIdsBySection, parsedDiff, selectedSection?.id],
554+ [parsedDiff, reviewCache.expandedFileIdsBySection, selectedSection?.id],
483555 );
484− const expandedFiles = useMemo(
556+ const revealedLargeFileIds = useMemo(
485557 () =>
486− parsedDiff.kind === "files"
487− ? parsedDiff.files.filter((file) => expandedFileIds.includes(file.id))
558+ selectedSection?.id
559+ ? (reviewCache.revealedLargeFileIdsBySection[selectedSection.id] ?? [])
488560 : [],
489− [expandedFileIds, parsedDiff],
561+ [reviewCache.revealedLargeFileIdsBySection, selectedSection?.id],
490562 );
491−
492563 const loadGitDiffs = useCallback(async () => {
493564 if (!environmentId || !cwd) {
494565 return;
@@ -504,13 +575,15 @@ export function ReviewSheet() { · 9 unmodified lines
504575 setError(null);
505576 try {
506577 const result = await client.git.getReviewDiffs({ cwd });
507− setGitSections(result.sections);
578+ if (reviewCache.threadKey) {
579+ setReviewGitSections(reviewCache.threadKey, result.sections);
580+ }
508581 } catch (cause) {
509582 setError(cause instanceof Error ? cause.message : "Failed to load review diffs.");
510583 } finally {
511584 setLoadingGitDiffs(false);
512585 }
513− }, [cwd, environmentId]);
586+ }, [cwd, environmentId, reviewCache.threadKey]);
514587
515588 const loadTurnDiff = useCallback(
516589 async (checkpoint: OrchestrationCheckpointSummary, force = false) => {
@@ -519,9 +592,11 @@ export function ReviewSheet() { · 2 unmodified lines
519592 }
520593
521594 const sectionId = getReviewSectionIdForCheckpoint(checkpoint);
522− setSelectedSectionId(sectionId);
595+ if (reviewCache.threadKey) {
596+ setReviewSelectedSectionId(reviewCache.threadKey, sectionId);
597+ }
523598
524− if (!force && turnDiffById[sectionId] !== undefined) {
599+ if (!force && reviewCache.turnDiffById[sectionId] !== undefined) {
525600 return;
526601 }
527602
@@ -539,7 +614,9 @@ export function ReviewSheet() { · 11 unmodified lines
539614 fromTurnCount: Math.max(0, checkpoint.checkpointTurnCount - 1),
540615 toTurnCount: checkpoint.checkpointTurnCount,
541616 });
542− setTurnDiffById((current) => ({ ...current, [sectionId]: result.diff }));
617+ if (reviewCache.threadKey) {
618+ setReviewTurnDiff(reviewCache.threadKey, sectionId, result.diff);
619+ }
543620 } catch (cause) {
544621 setError(cause instanceof Error ? cause.message : "Failed to load turn diff.");
545622 } finally {
@@ -550,7 +627,7 @@ export function ReviewSheet() { · 4 unmodified lines
550627 });
551628 }
552629 },
553− [environmentId, threadId, turnDiffById],
630+ [environmentId, reviewCache.threadKey, reviewCache.turnDiffById, threadId],
554631 );
555632
556633 useEffect(() => {
@@ -563,10 +640,14 @@ export function ReviewSheet() { · 6 unmodified lines
563640 }
564641
565642 const fallbackId = getDefaultReviewSectionId(reviewSections);
566− if (!selectedSectionId || !reviewSections.some((section) => section.id === selectedSectionId)) {
567− setSelectedSectionId(fallbackId);
643+ if (
644+ reviewCache.threadKey &&
645+ (!reviewCache.selectedSectionId ||
646+ !reviewSections.some((section) => section.id === reviewCache.selectedSectionId))
647+ ) {
648+ setReviewSelectedSectionId(reviewCache.threadKey, fallbackId);
568649 }
569− }, [reviewSections, selectedSectionId]);
650+ }, [reviewCache.selectedSectionId, reviewCache.threadKey, reviewSections]);
570651
571652 useEffect(() => {
572653 const latest = readyCheckpoints[0];
@@ -575,12 +656,12 @@ export function ReviewSheet() { · 2 unmodified lines
575656 }
576657
577658 const latestId = getReviewSectionIdForCheckpoint(latest);
578− if (turnDiffById[latestId] !== undefined || loadingTurnIds[latestId]) {
659+ if (reviewCache.turnDiffById[latestId] !== undefined || loadingTurnIds[latestId]) {
579660 return;
580661 }
581662
582663 void loadTurnDiff(latest);
583− }, [loadTurnDiff, loadingTurnIds, readyCheckpoints, turnDiffById]);
664+ }, [loadTurnDiff, loadingTurnIds, readyCheckpoints, reviewCache.turnDiffById]);
584665
585666 useEffect(() => {
586667 if (!selectedSection || selectedSection.kind !== "turn" || selectedSection.diff !== null) {
@@ -594,68 +675,41 @@ export function ReviewSheet() { · 7 unmodified lines
594675 }, [checkpointBySectionId, loadTurnDiff, loadingTurnIds, selectedSection]);
595676
596677 useEffect(() => {
597− if (!selectedSectionId || parsedDiff.kind !== "files") {
678+ if (!reviewCache.threadKey || !selectedSection?.id || parsedDiff.kind !== "files") {
598679 return;
599680 }
600681
601− setExpandedFileIdsBySection((current) => {
602− const existing = current[selectedSectionId];
682+ updateReviewExpandedFileIds(reviewCache.threadKey, selectedSection.id, (existing) => {
603683 if (existing !== undefined) {
604684 const validIds = existing.filter((id) => parsedDiff.files.some((file) => file.id === id));
605685 if (validIds.length === existing.length) {
606− return current;
686+ return existing;
607687 }
608− return { ...current, [selectedSectionId]: validIds };
688+ return validIds;
609689 }
610690
611− return {
612− ...current,
613− [selectedSectionId]: getDefaultExpandedFileIds(parsedDiff.files),
614− };
691+ return getDefaultExpandedFileIds(parsedDiff.files);
615692 });
616− }, [parsedDiff, selectedSectionId]);
693+ }, [parsedDiff, reviewCache.threadKey, selectedSection?.id]);
617694
618695 useEffect(() => {
619− if (!selectedSection || expandedFiles.length === 0) {
696+ if (!reviewCache.threadKey || !selectedSection?.id || parsedDiff.kind !== "files") {
620697 return;
621698 }
622699
623− const filesToHighlight = expandedFiles.filter((file) => {
624− const key = `${selectedSection.id}:${selectedTheme}:${file.cacheKey}`;
625− return highlightedFileByKey[key] === undefined;
626− });
627− if (filesToHighlight.length === 0) {
628− return;
629− }
700+ updateReviewRevealedLargeFileIds(reviewCache.threadKey, selectedSection.id, (existing) => {
701+ if (existing === undefined) {
702+ return undefined;
703+ }
630704
631− let cancelled = false;
632− void Promise.all(
633− filesToHighlight.map(
634− async (file) =>
635− [
636− `${selectedSection.id}:${selectedTheme}:${file.cacheKey}`,
637− await highlightReviewFile(file, selectedTheme),
638− ] as const,
639− ),
640− )
641− .then((entries) => {
642− if (!cancelled) {
643− setHighlightedFileByKey((current) => ({
644− ...current,
645− ...Object.fromEntries(entries),
646− }));
647− }
648− })
649− .catch(() => {
650− if (cancelled) {
651− return;
652− }
653− });
705+ const validIds = existing.filter((id) => parsedDiff.files.some((file) => file.id === id));
706+ if (validIds.length === existing.length) {
707+ return existing;
708+ }
654709
655− return () => {
656− cancelled = true;
657− };
658− }, [expandedFiles, highlightedFileByKey, selectedSection, selectedTheme]);
710+ return validIds;
711+ });
712+ }, [parsedDiff, reviewCache.threadKey, selectedSection?.id]);
659713
660714 const refreshSelectedSection = useCallback(async () => {
661715 if (!selectedSection) {
@@ -673,9 +727,100 @@ export function ReviewSheet() { · 11 unmodified lines
673727 await loadGitDiffs();
674728 }, [checkpointBySectionId, loadGitDiffs, loadTurnDiff, selectedSection]);
675729
676− const reviewContent = useMemo(() => {
730+ const handleToggleExpandedFile = useCallback(
731+ (fileId: string) => {
732+ if (!reviewCache.threadKey || !selectedSection?.id || parsedDiff.kind !== "files") {
733+ return;
734+ }
735+
736+ updateReviewExpandedFileIds(reviewCache.threadKey, selectedSection.id, (existing) => {
737+ const currentIds = existing ?? getDefaultExpandedFileIds(parsedDiff.files);
738+ return currentIds.includes(fileId)
739+ ? currentIds.filter((id) => id !== fileId)
740+ : [...currentIds, fileId];
741+ });
742+ },
743+ [parsedDiff, reviewCache.threadKey, selectedSection?.id],
744+ );
745+
746+ const handleRevealLargeDiff = useCallback(
747+ (fileId: string) => {
748+ if (!reviewCache.threadKey || !selectedSection?.id) {
749+ return;
750+ }
751+
752+ updateReviewRevealedLargeFileIds(reviewCache.threadKey, selectedSection.id, (existing) => {
753+ const currentIds = existing ?? [];
754+ return currentIds.includes(fileId) ? currentIds : [...currentIds, fileId];
755+ });
756+ },
757+ [reviewCache.threadKey, selectedSection?.id],
758+ );
759+
760+ const handlePressLine = useCallback(
761+ (input: ReviewLineActionInput) => {
762+ if (pendingCommentSelection) {
763+ if (
764+ pendingCommentSelection.sectionTitle === input.sectionTitle &&
765+ pendingCommentSelection.filePath === input.filePath
766+ ) {
767+ setReviewCommentTarget(
768+ buildReviewCommentTarget(
769+ {
770+ sectionTitle: pendingCommentSelection.sectionTitle,
771+ filePath: pendingCommentSelection.filePath,
772+ lines: pendingCommentSelection.lines,
773+ },
774+ pendingCommentSelection.anchorIndex,
775+ input.lineIndex,
776+ ),
777+ );
778+ setPendingCommentSelection(null);
779+ return;
780+ }
781+
782+ clearReviewCommentTarget();
783+ setPendingCommentSelection({
784+ sectionTitle: input.sectionTitle,
785+ filePath: input.filePath,
786+ lines: input.lines,
787+ anchorIndex: input.lineIndex,
788+ });
789+ return;
790+ }
791+
792+ setReviewCommentTarget({
793+ sectionTitle: input.sectionTitle,
794+ filePath: input.filePath,
795+ lines: input.lines,
796+ startIndex: input.lineIndex,
797+ endIndex: input.lineIndex,
798+ });
799+ if (environmentId && threadId) {
800+ router.push({
801+ pathname: "/threads/[environmentId]/[threadId]/review-comment",
802+ params: { environmentId, threadId },
803+ });
804+ }
805+ },
806+ [environmentId, pendingCommentSelection, router, threadId],
807+ );
808+
809+ const handleStartRangeSelection = useCallback((input: ReviewLineActionInput) => {
810+ clearReviewCommentTarget();
811+ setPendingCommentSelection({
812+ sectionTitle: input.sectionTitle,
813+ filePath: input.filePath,
814+ lines: input.lines,
815+ anchorIndex: input.lineIndex,
816+ });
817+ }, []);
818+
819+ const parsedDiffNotice =
820+ parsedDiff.kind === "files" || parsedDiff.kind === "raw" ? parsedDiff.notice : null;
821+
822+ const listHeader = useMemo(() => {
677823 const children: ReactElement[] = [];
678− const stickyHeaderIndices: number[] = [];
679824
680825 if (error) {
681826 children.push(
@@ -686,185 +831,85 @@ export function ReviewSheet() { · 4 unmodified lines
686831 );
687832 }
688833
689− if (parsedDiff.kind !== "empty" && parsedDiff.notice) {
690− children.push(
691− <View
692− key="review-notice"
693− className="border-b border-amber-200 bg-amber-50 px-4 py-3 dark:border-amber-900/60 dark:bg-amber-950/40"
694− >
695− <Text className="text-[12px] font-t3-bold uppercase text-amber-700 dark:text-amber-300">
696− Partial diff
697− </Text>
698− <Text className="text-[12px] leading-[18px] text-amber-800 dark:text-amber-200">
699− {parsedDiff.notice}
700− </Text>
701− </View>,
702− );
703− }
704−
705− if (!selectedSection) {
706− children.push(
707− <View key="review-empty-state" className="border-b border-border bg-card px-4 py-5">
708− <Text className="text-[14px] font-t3-bold text-foreground">No review diffs</Text>
709− <Text className="text-[12px] leading-[18px] text-foreground-muted">
710− This thread has no ready turn diffs and the worktree diff is empty.
711− </Text>
712− </View>,
713− );
714− return { children, stickyHeaderIndices };
834+ if (parsedDiffNotice) {
835+ children.push(<ReviewNotice key="review-notice" notice={parsedDiffNotice} />);
715836 }
716837
717− if (selectedSection.isLoading && selectedSection.diff === null) {
718− children.push(
719− <View
720− key="review-loading"
721− className="items-center gap-3 border-b border-border bg-card px-4 py-6"
722− >
723− <ActivityIndicator size="small" />
724− <Text className="text-[12px] text-foreground-muted">Loading diff…</Text>
725− </View>,
726− );
727− return { children, stickyHeaderIndices };
838+ if (children.length === 0) {
839+ return null;
728840 }
729841
730− if (parsedDiff.kind === "empty") {
731− children.push(
732− <View key="review-no-changes" className="border-b border-border bg-card px-4 py-5">
733− <Text className="text-[14px] font-t3-bold text-foreground">No changes</Text>
734− <Text className="text-[12px] leading-[18px] text-foreground-muted">
735− {selectedSection.subtitle ?? "This diff is empty."}
736− </Text>
737− </View>,
738− );
739− return { children, stickyHeaderIndices };
740− }
842+ return <>{children}</>;
843+ }, [error, parsedDiffNotice]);
741844
742− if (parsedDiff.kind === "raw") {
743− children.push(
744− <View key="review-raw" className="gap-3 border-b border-border bg-card px-4 py-4">
745− <Text className="text-[12px] leading-[18px] text-foreground-muted">
746− {parsedDiff.reason}
747− </Text>
748− <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}>
749− <Text selectable className="font-mono text-[12px] leading-[19px] text-foreground">
750− {parsedDiff.text}
751− </Text>
752− </ScrollView>
753− </View>,
754− );
755− return { children, stickyHeaderIndices };
756− }
757−
758− parsedDiff.files.forEach((file) => {
759− const isExpanded = expandedFileIds.includes(file.id);
760− const fileHighlightKey = `${selectedSection.id}:${selectedTheme}:${file.cacheKey}`;
761− const fileHighlights = highlightedFileByKey[fileHighlightKey] ?? null;
762−
763− stickyHeaderIndices.push(children.length);
764− children.push(
765− <ReviewFileCard
766− key={`review-file-header:${file.id}`}
767− file={file}
768− expanded={isExpanded}
769− onToggle={() =>
770− setExpandedFileIdsBySection((current) => {
771− const existing =
772− current[selectedSection.id] ?? getDefaultExpandedFileIds(parsedDiff.files);
773− const next = existing.includes(file.id)
774− ? existing.filter((id) => id !== file.id)
775− : [...existing, file.id];
776− return {
777− ...current,
778− [selectedSection.id]: next,
779− };
780− })
781− }
782− />,
783− );
784−
785− if (isExpanded) {
786− children.push(
787− <ReviewFileBody
788− key={`review-file-body:${file.id}`}
789− file={file}
790− sectionTitle={selectedSection.title}
791− highlightedFile={fileHighlights}
792− pendingSelection={pendingCommentSelection}
793− selectedTarget={activeCommentTarget}
794− viewportWidth={Math.max(width, 280)}
795− onPressLine={(_line, availableLines, lineIndex) => {
796− if (pendingCommentSelection) {
797− if (
798− pendingCommentSelection.sectionTitle === selectedSection.title &&
799− pendingCommentSelection.filePath === file.path
800− ) {
801− setReviewCommentTarget(
802− buildReviewCommentTarget(
803− {
804− sectionTitle: pendingCommentSelection.sectionTitle,
805− filePath: pendingCommentSelection.filePath,
806− lines: pendingCommentSelection.lines,
807− },
808− pendingCommentSelection.anchorIndex,
809− lineIndex,
810− ),
811− );
812− setPendingCommentSelection(null);
813− return;
814− }
815−
816− clearReviewCommentTarget();
817− setPendingCommentSelection({
818− sectionTitle: selectedSection.title,
819− filePath: file.path,
820− lines: availableLines,
821− anchorIndex: lineIndex,
822− });
823− return;
824− }
825−
826− setReviewCommentTarget({
827− sectionTitle: selectedSection.title,
828− filePath: file.path,
829− lines: availableLines,
830− startIndex: lineIndex,
831− endIndex: lineIndex,
832− });
833− if (environmentId && threadId) {
834− router.push({
835− pathname: "/threads/[environmentId]/[threadId]/review-comment",
836− params: { environmentId, threadId },
837− });
838− }
839− }}
840− onStartRangeSelection={(_line, availableLines, lineIndex) => {
841− clearReviewCommentTarget();
842− setPendingCommentSelection({
843− sectionTitle: selectedSection.title,
844− filePath: file.path,
845− lines: availableLines,
846− anchorIndex: lineIndex,
847− });
848− }}
849− />,
850− );
845+ const renderFileItem = useCallback(
846+ ({ item }: { item: ReviewRenderableFile; index: number }) => {
847+ if (!selectedSection) {
848+ return null;
851849 }
852− });
850+ const pendingSelectionForFile =
851+ pendingCommentSelection &&
852+ pendingCommentSelection.sectionTitle === selectedSection.title &&
853+ pendingCommentSelection.filePath === item.path
854+ ? pendingCommentSelection
855+ : null;
856+ const selectedTargetForFile =
857+ activeCommentTarget &&
858+ activeCommentTarget.sectionTitle === selectedSection.title &&
859+ activeCommentTarget.filePath === item.path
860+ ? activeCommentTarget
861+ : null;
862+
863+ return (
864+ <ReviewFileItem
865+ file={item}
866+ expanded={expandedFileIds.includes(item.id)}
867+ sectionTitle={selectedSection.title}
868+ selectedTheme={selectedTheme}
869+ pendingSelection={pendingSelectionForFile}
870+ selectedTarget={selectedTargetForFile}
871+ viewportWidth={Math.max(width, 280)}
872+ revealedLarge={revealedLargeFileIds.includes(item.id)}
873+ onToggleFile={handleToggleExpandedFile}
874+ onLoadDiffFile={handleRevealLargeDiff}
875+ onPressLine={handlePressLine}
876+ onStartRangeSelection={handleStartRangeSelection}
877+ />
878+ );
879+ },
880+ [
881+ activeCommentTarget,
882+ expandedFileIds,
883+ handlePressLine,
884+ handleRevealLargeDiff,
885+ handleStartRangeSelection,
886+ handleToggleExpandedFile,
887+ pendingCommentSelection,
888+ revealedLargeFileIds,
889+ selectedSection,
890+ selectedTheme,
891+ width,
892+ ],
893+ );
853894
854− return { children, stickyHeaderIndices };
895+ const visibleListExtraData = useMemo(() => {
896+ return {
897+ selectedSectionId: selectedSection?.id ?? null,
898+ selectedTheme,
899+ expandedFileIds,
900+ revealedLargeFileIds,
901+ pendingSelection: pendingCommentSelection,
902+ selectedTarget: activeCommentTarget,
903+ viewportWidth: Math.max(width, 280),
904+ };
855905 }, [
856906 activeCommentTarget,
857− error,
858907 expandedFileIds,
859− highlightedFileByKey,
860908 pendingCommentSelection,
861− parsedDiff,
862− router,
909+ revealedLargeFileIds,
863910 selectedSection,
864911 selectedTheme,
865− threadId,
866912 width,
867− environmentId,
868913 ]);
869914
870915 return (
@@ -891,7 +936,15 @@ export function ReviewSheet() { · 20 unmodified lines
891936 >
892937 Files Changed
893938 </NativeText>
894− <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
939+ <View
940+ style={{
941+ flexDirection: "row",
942+ alignItems: "center",
943+ justifyContent: "center",
944+ gap: 6,
945+ flexWrap: "wrap",
946+ }}
947+ >
895948 {headerDiffSummary.additions && headerDiffSummary.deletions ? (
896949 <>
897950 <NativeText
@@ -914,19 +967,45 @@ export function ReviewSheet() { · 16 unmodified lines
914967 >
915968 {headerDiffSummary.deletions}
916969 </NativeText>
970+ {pendingReviewCommentCount > 0 ? (
971+ <NativeText
972+ style={{
973+ fontFamily: "DMSans_700Bold",
974+ fontSize: 12,
975+ fontWeight: "700",
976+ color: "#b45309",
977+ }}
978+ >
979+ {pendingReviewCommentCount} pending
980+ </NativeText>
981+ ) : null}
917982 </>
918983 ) : (
919− <NativeText
920− numberOfLines={1}
921− style={{
922− fontFamily: "DMSans_700Bold",
923− fontSize: 12,
924− fontWeight: "700",
925− color: headerMuted,
926− }}
927− >
928− {selectedSection?.title ?? "Review changes"}
929− </NativeText>
984+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
985+ <NativeText
986+ numberOfLines={1}
987+ style={{
988+ fontFamily: "DMSans_700Bold",
989+ fontSize: 12,
990+ fontWeight: "700",
991+ color: headerMuted,
992+ }}
993+ >
994+ {selectedSection?.title ?? "Review changes"}
995+ </NativeText>
996+ {pendingReviewCommentCount > 0 ? (
997+ <NativeText
998+ style={{
999+ fontFamily: "DMSans_700Bold",
1000+ fontSize: 12,
1001+ fontWeight: "700",
1002+ color: "#b45309",
1003+ }}
1004+ >
1005+ {pendingReviewCommentCount} pending
1006+ </NativeText>
1007+ ) : null}
1008+ </View>
9301009 )}
9311010 </View>
9321011 </View>
@@ -940,7 +1019,11 @@ export function ReviewSheet() { · 7 unmodified lines
9401019 <Stack.Toolbar.MenuAction
9411020 key={section.id}
9421021 icon={section.id === selectedSection?.id ? "checkmark" : "circle"}
943− onPress={() => setSelectedSectionId(section.id)}
1022+ onPress={() => {
1023+ if (reviewCache.threadKey) {
1024+ setReviewSelectedSectionId(reviewCache.threadKey, section.id);
1025+ }
1026+ }}
9441027 subtitle={section.subtitle ?? undefined}
9451028 >
9461029 <Stack.Toolbar.Label>{section.title}</Stack.Toolbar.Label>
@@ -961,22 +1044,73 @@ export function ReviewSheet() { · 14 unmodified lines
9611044 </Stack.Toolbar>
9621045
9631046 <View className="flex-1 bg-sheet">
964− <ScrollView
965− contentInsetAdjustmentBehavior="never"
966− contentInset={{ top: topContentInset }}
967− contentOffset={{ x: 0, y: -topContentInset }}
968− scrollIndicatorInsets={{ top: topContentInset }}
969− showsVerticalScrollIndicator={false}
970− style={{ flex: 1 }}
971− stickyHeaderIndices={reviewContent.stickyHeaderIndices}
972− stickyHeaderHiddenOnScroll={false}
973− contentContainerStyle={{
974− paddingTop: REVIEW_HEADER_SPACING,
975− paddingBottom: Math.max(insets.bottom, 18) + 18,
976− }}
977− >
978− {reviewContent.children}
979− </ScrollView>
1047+ {selectedSection && parsedDiff.kind === "files" ? (
1048+ <LegendList
1049+ style={{ flex: 1 }}
1050+ contentInsetAdjustmentBehavior="never"
1051+ contentInset={{ top: topContentInset }}
1052+ scrollIndicatorInsets={{ top: topContentInset }}
1053+ data={parsedDiff.files as ReviewRenderableFile[]}
1054+ renderItem={renderFileItem}
1055+ keyExtractor={(file) => file.id}
1056+ extraData={visibleListExtraData}
1057+ keyboardShouldPersistTaps="handled"
1058+ estimatedItemSize={220}
1059+ drawDistance={900}
1060+ recycleItems
1061+ ListHeaderComponent={listHeader}
1062+ contentContainerStyle={{
1063+ paddingTop: REVIEW_HEADER_SPACING,
1064+ paddingBottom: Math.max(insets.bottom, 18) + 18,
1065+ }}
1066+ />
1067+ ) : (
1068+ <ScrollView
1069+ contentInsetAdjustmentBehavior="never"
1070+ contentInset={{ top: topContentInset }}
1071+ contentOffset={{ x: 0, y: -topContentInset }}
1072+ scrollIndicatorInsets={{ top: topContentInset }}
1073+ showsVerticalScrollIndicator={false}
1074+ style={{ flex: 1 }}
1075+ contentContainerStyle={{
1076+ paddingTop: REVIEW_HEADER_SPACING,
1077+ paddingBottom: Math.max(insets.bottom, 18) + 18,
1078+ }}
1079+ >
1080+ {listHeader}
1081+ {!selectedSection ? (
1082+ <View className="border-b border-border bg-card px-4 py-5">
1083+ <Text className="text-[14px] font-t3-bold text-foreground">No review diffs</Text>
1084+ <Text className="text-[12px] leading-[18px] text-foreground-muted">
1085+ This thread has no ready turn diffs and the worktree diff is empty.
1086+ </Text>
1087+ </View>
1088+ ) : selectedSection.isLoading && selectedSection.diff === null ? (
1089+ <View className="items-center gap-3 border-b border-border bg-card px-4 py-6">
1090+ <ActivityIndicator size="small" />
1091+ <Text className="text-[12px] text-foreground-muted">Loading diff…</Text>
1092+ </View>
1093+ ) : parsedDiff.kind === "empty" ? (
1094+ <View className="border-b border-border bg-card px-4 py-5">
1095+ <Text className="text-[14px] font-t3-bold text-foreground">No changes</Text>
1096+ <Text className="text-[12px] leading-[18px] text-foreground-muted">
1097+ {selectedSection.subtitle ?? "This diff is empty."}
1098+ </Text>
1099+ </View>
1100+ ) : parsedDiff.kind === "raw" ? (
1101+ <View className="gap-3 border-b border-border bg-card px-4 py-4">
1102+ <Text className="text-[12px] leading-[18px] text-foreground-muted">
1103+ {parsedDiff.reason}
1104+ </Text>
1105+ <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}>
1106+ <Text selectable className="font-mono text-[12px] leading-[19px] text-foreground">
1107+ {parsedDiff.text}
1108+ </Text>
1109+ </ScrollView>
1110+ </View>
1111+ ) : null}
1112+ </ScrollView>
1113+ )}
9801114
9811115 <ReviewSelectionActionBar
9821116 target={activeCommentTarget}
apps/mobile/src/features/threads/review/reviewCommentSelection.ts+4−0
@@ -152,3 +152,7 @@ export function formatReviewCommentContext(target: ReviewCommentTarget, comment: · 151 unmodified lines
152152 "</review_comment>",
153153 ].join("\n");
154154 }
155+
156+export function countReviewCommentContexts(value: string): number {
157+ return Array.from(value.matchAll(/<review_comment>/g)).length;
158+}
apps/mobile/src/features/threads/review/reviewDiffRendering.tsx+82−0
@@ -0,0 +1,82 @@
1+import { Platform, Text as NativeText } from "react-native";
2+
3+import { cn } from "../../../lib/cn";
4+
5+import type { ReviewRenderableLineRow } from "./reviewModel";
6+import type { ReviewHighlightedToken } from "./shikiReviewHighlighter";
7+
8+export const REVIEW_MONO_FONT_FAMILY = Platform.select({
9+ ios: "ui-monospace",
10+ android: "monospace",
11+ default: "monospace",
12+});
13+
14+export function renderVisibleWhitespace(value: string): string {
15+ const expandedTabs = value.replace(/\t/g, " ");
16+ return expandedTabs.replace(/^( +)/, (leading) => leading.replaceAll(" ", "\u00A0"));
17+}
18+
19+export function changeTone(change: ReviewRenderableLineRow["change"]): string {
20+ if (change === "add") return "bg-emerald-500/12";
21+ if (change === "delete") return "bg-rose-500/12";
22+ return "bg-card";
23+}
24+
25+export function DiffTokenText(props: {
26+ readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null;
27+ readonly fallback: string;
28+ readonly className?: string;
29+}) {
30+ if (!props.tokens || props.tokens.length === 0) {
31+ return (
32+ <NativeText
33+ selectable
34+ className={cn("text-[13px] leading-[17px] font-medium text-foreground", props.className)}
35+ style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }}
36+ >
37+ {renderVisibleWhitespace(props.fallback || " ")}
38+ </NativeText>
39+ );
40+ }
41+
42+ return (
43+ <NativeText
44+ selectable
45+ className={cn("text-[13px] leading-[17px] font-medium text-foreground", props.className)}
46+ style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }}
47+ >
48+ {(() => {
49+ let offset = 0;
50+
51+ return props.tokens.map((token) => {
52+ const start = offset;
53+ offset += token.content.length;
54+
55+ const fontWeight =
56+ token.fontStyle !== null && (token.fontStyle & 2) === 2
57+ ? ("700" as const)
58+ : ("500" as const);
59+ const fontStyle =
60+ token.fontStyle !== null && (token.fontStyle & 1) === 1
61+ ? ("italic" as const)
62+ : ("normal" as const);
63+
64+ return (
65+ <NativeText
66+ key={`${start}:${token.content.length}:${token.color ?? ""}:${token.fontStyle ?? ""}`}
67+ selectable
68+ style={{
69+ color: token.color ?? undefined,
70+ fontFamily: REVIEW_MONO_FONT_FAMILY,
71+ fontWeight,
72+ fontStyle,
73+ }}
74+ >
75+ {token.content.length > 0 ? renderVisibleWhitespace(token.content) : " "}
76+ </NativeText>
77+ );
78+ });
79+ })()}
80+ </NativeText>
81+ );
82+}
apps/mobile/src/features/threads/review/reviewHighlighterEngine.test.ts+43−0
@@ -0,0 +1,43 @@
1+import { describe, expect, it } from "vitest";
2+
3+import {
4+ hasTurboModuleProxy,
5+ resolveReviewHighlighterEngine,
6+ resolveReviewHighlighterEnginePreference,
7+} from "./reviewHighlighterEngine";
8+
9+describe("resolveReviewHighlighterEnginePreference", () => {
10+ it("defaults invalid values to auto", () => {
11+ expect(resolveReviewHighlighterEnginePreference(undefined)).toBe("auto");
12+ expect(resolveReviewHighlighterEnginePreference("bogus")).toBe("auto");
13+ });
14+
15+ it("accepts supported values", () => {
16+ expect(resolveReviewHighlighterEnginePreference("auto")).toBe("auto");
17+ expect(resolveReviewHighlighterEnginePreference("javascript")).toBe("javascript");
18+ expect(resolveReviewHighlighterEnginePreference("native")).toBe("native");
19+ });
20+});
21+
22+describe("resolveReviewHighlighterEngine", () => {
23+ it("uses javascript when explicitly requested", () => {
24+ expect(resolveReviewHighlighterEngine("javascript", true)).toBe("javascript");
25+ expect(resolveReviewHighlighterEngine("javascript", false)).toBe("javascript");
26+ });
27+
28+ it("uses native when available for auto or native preference", () => {
29+ expect(resolveReviewHighlighterEngine("auto", true)).toBe("native");
30+ expect(resolveReviewHighlighterEngine("native", true)).toBe("native");
31+ });
32+
33+ it("falls back to javascript when native is unavailable", () => {
34+ expect(resolveReviewHighlighterEngine("auto", false)).toBe("javascript");
35+ expect(resolveReviewHighlighterEngine("native", false)).toBe("javascript");
36+ });
37+});
38+
39+describe("hasTurboModuleProxy", () => {
40+ it("returns false outside a native runtime", () => {
41+ expect(hasTurboModuleProxy()).toBe(false);
42+ });
43+});
apps/mobile/src/features/threads/review/reviewHighlighterEngine.ts+36−0
@@ -0,0 +1,36 @@
1+export type ReviewHighlighterEnginePreference = "auto" | "javascript" | "native";
2+export type ReviewHighlighterEngine = "javascript" | "native";
3+
4+export function resolveReviewHighlighterEnginePreference(
5+ value: string | undefined,
6+): ReviewHighlighterEnginePreference {
7+ switch (value) {
8+ case "javascript":
9+ case "native":
10+ case "auto":
11+ return value;
12+ default:
13+ return "auto";
14+ }
15+}
16+
17+export function resolveReviewHighlighterEngine(
18+ preference: ReviewHighlighterEnginePreference,
19+ nativeAvailable: boolean,
20+): ReviewHighlighterEngine {
21+ if (preference === "javascript") {
22+ return "javascript";
23+ }
24+
25+ if (nativeAvailable) {
26+ return "native";
27+ }
28+
29+ return "javascript";
30+}
31+
32+export function hasTurboModuleProxy(): boolean {
33+ const turboModuleProxy = (globalThis as { readonly __turboModuleProxy?: unknown })
34+ .__turboModuleProxy;
35+ return typeof turboModuleProxy === "function";
36+}
apps/mobile/src/features/threads/review/reviewModel.test.ts+60−0
@@ -11,7 +11,9 @@ import { · 10 unmodified lines
1111 buildReviewParsedDiff,
1212 buildReviewSectionItems,
1313 getDefaultReviewSectionId,
14+ getReviewFilePreviewState,
1415 getReviewSectionIdForCheckpoint,
16+ type ReviewRenderableFile,
1517 } from "./reviewModel";
1618
1719 function makeCheckpoint(
@@ -27,6 +29,24 @@ function makeCheckpoint( · 9 unmodified lines
2729 };
2830 }
2931
32+function makeRenderableFile(
33+ input: Partial<ReviewRenderableFile> & Pick<ReviewRenderableFile, "path">,
34+): ReviewRenderableFile {
35+ return {
36+ id: input.path,
37+ cacheKey: input.path,
38+ previousPath: null,
39+ changeType: "new",
40+ additions: 0,
41+ deletions: 0,
42+ languageHint: null,
43+ additionLines: [],
44+ deletionLines: [],
45+ rows: [],
46+ ...input,
47+ };
48+}
49+
3050 describe("buildReviewSectionItems", () => {
3151 it("keeps one chip per checkpoint and appends git sources", () => {
3252 const checkpoints = [
@@ -171,4 +191,44 @@ describe("buildReviewParsedDiff", () => { · 138 unmodified lines
171191 header: "@@ -1,1 +1,2 @@",
172192 });
173193 });
194+
195+ it("suppresses preview for non-text file formats", () => {
196+ const preview = getReviewFilePreviewState(
197+ makeRenderableFile({
198+ path: "apps/mobile/assets/icon.png",
199+ }),
200+ );
201+
202+ expect(preview).toMatchObject({
203+ kind: "suppressed",
204+ reason: "non-text",
205+ title: "Non-text file",
206+ actionLabel: null,
207+ });
208+ });
209+
210+ it("suppresses large diffs until explicitly requested", () => {
211+ const preview = getReviewFilePreviewState(
212+ makeRenderableFile({
213+ path: "apps/mobile/src/big.ts",
214+ rows: Array.from({ length: 401 }, (_, index) => ({
215+ kind: "line" as const,
216+ id: `line-${index}`,
217+ change: "add" as const,
218+ oldLineNumber: null,
219+ newLineNumber: index + 1,
220+ content: `const line${index} = ${index};`,
221+ additionTokenIndex: index,
222+ deletionTokenIndex: null,
223+ })),
224+ }),
225+ );
226+
227+ expect(preview).toMatchObject({
228+ kind: "suppressed",
229+ reason: "large",
230+ title: "Large diff",
231+ actionLabel: "Load diff",
232+ });
233+ });
174234 });
apps/mobile/src/features/threads/review/reviewModel.ts+99−0
@@ -48,6 +48,18 @@ export interface ReviewRenderableFile { · 47 unmodified lines
4848 readonly rows: ReadonlyArray<ReviewRenderableRow>;
4949 }
5050
51+export type ReviewFilePreviewState =
52+ | {
53+ readonly kind: "render";
54+ }
55+ | {
56+ readonly kind: "suppressed";
57+ readonly reason: "non-text" | "large";
58+ readonly title: string;
59+ readonly message: string;
60+ readonly actionLabel: string | null;
61+ };
62+
5163 export type ReviewParsedDiff =
5264 | {
5365 readonly kind: "empty";
@@ -147,6 +159,50 @@ const FNV_OFFSET_BASIS_32 = 0x811c9dc5; · 93 unmodified lines
147159 const FNV_PRIME_32 = 0x01000193;
148160 const SECONDARY_HASH_SEED = 0x9e3779b9;
149161 const SECONDARY_HASH_MULTIPLIER = 0x85ebca6b;
162+const LARGE_DIFF_LINE_THRESHOLD = 400;
163+const LARGE_DIFF_CHARACTER_THRESHOLD = 24_000;
164+const NON_TEXT_FILE_EXTENSIONS = new Set([
165+ "png",
166+ "jpg",
167+ "jpeg",
168+ "gif",
169+ "webp",
170+ "bmp",
171+ "ico",
172+ "icns",
173+ "avif",
174+ "heic",
175+ "tif",
176+ "tiff",
177+ "mp3",
178+ "wav",
179+ "flac",
180+ "ogg",
181+ "m4a",
182+ "aac",
183+ "mp4",
184+ "mov",
185+ "avi",
186+ "mkv",
187+ "webm",
188+ "pdf",
189+ "zip",
190+ "gz",
191+ "tgz",
192+ "bz2",
193+ "7z",
194+ "rar",
195+ "woff",
196+ "woff2",
197+ "ttf",
198+ "otf",
199+ "eot",
200+ "wasm",
201+ "exe",
202+ "dll",
203+ "so",
204+ "dylib",
205+]);
150206
151207 function fnv1a32(input: string, seed: number, multiplier: number): number {
152208 let hash = seed >>> 0;
@@ -168,6 +224,49 @@ function buildPatchCacheKey(patch: string, scope: string): string { · 15 unmodified lines
168224 return `${scope}:${normalizedPatch.length}:${primary}:${secondary}`;
169225 }
170226
227+function getFileExtension(path: string): string | null {
228+ const match = /\.([a-z0-9]+)$/i.exec(path);
229+ return match?.[1]?.toLowerCase() ?? null;
230+}
231+
232+function countReviewRenderableLineRows(file: ReviewRenderableFile): number {
233+ return file.rows.reduce((total, row) => total + (row.kind === "line" ? 1 : 0), 0);
234+}
235+
236+function countReviewRenderableCharacters(file: ReviewRenderableFile): number {
237+ return file.rows.reduce(
238+ (total, row) => total + (row.kind === "line" ? row.content.length : row.header.length),
239+ 0,
240+ );
241+}
242+
243+export function getReviewFilePreviewState(file: ReviewRenderableFile): ReviewFilePreviewState {
244+ const extension = getFileExtension(file.path);
245+ if (extension && NON_TEXT_FILE_EXTENSIONS.has(extension)) {
246+ return {
247+ kind: "suppressed",
248+ reason: "non-text",
249+ title: "Non-text file",
250+ message: "Diff preview is not available for this file format.",
251+ actionLabel: null,
252+ };
253+ }
254+
255+ const lineCount = countReviewRenderableLineRows(file);
256+ const characterCount = countReviewRenderableCharacters(file);
257+ if (lineCount > LARGE_DIFF_LINE_THRESHOLD || characterCount > LARGE_DIFF_CHARACTER_THRESHOLD) {
258+ return {
259+ kind: "suppressed",
260+ reason: "large",
261+ title: "Large diff",
262+ message: "Large diffs are not rendered by default.",
263+ actionLabel: "Load diff",
264+ };
265+ }
266+
267+ return { kind: "render" };
268+}
269+
171270 function fallbackHunkHeader(hunk: FileDiffMetadata["hunks"][number]): string {
172271 return `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`;
173272 }
apps/mobile/src/features/threads/review/reviewState.ts+195−0
@@ -0,0 +1,195 @@
1+import { useAtomValue } from "@effect/atom-react";
2+
3+import { EnvironmentId, ThreadId, type GitReviewDiffSection } from "@t3tools/contracts";
4+import { Atom } from "effect/unstable/reactivity";
5+
6+import { scopedThreadKey } from "../../../lib/scopedEntities";
7+import { appAtomRegistry } from "../../../state/atom-registry";
8+import { buildReviewParsedDiff, type ReviewParsedDiff } from "./reviewModel";
9+
10+const EMPTY_GIT_REVIEW_SECTIONS = Object.freeze<ReadonlyArray<GitReviewDiffSection>>([]);
11+const EMPTY_REVIEW_TURN_DIFFS = Object.freeze<Readonly<Record<string, string>>>({});
12+const EMPTY_REVIEW_SECTION_FILE_IDS = Object.freeze<
13+ Readonly<Record<string, ReadonlyArray<string> | undefined>>
14+>({});
15+const EMPTY_REVIEW_GIT_SECTIONS_ATOM = Atom.make(EMPTY_GIT_REVIEW_SECTIONS).pipe(
16+ Atom.keepAlive,
17+ Atom.withLabel("mobile:review:git-sections:null"),
18+);
19+const EMPTY_REVIEW_TURN_DIFFS_ATOM = Atom.make(EMPTY_REVIEW_TURN_DIFFS).pipe(
20+ Atom.keepAlive,
21+ Atom.withLabel("mobile:review:turn-diffs:null"),
22+);
23+const EMPTY_REVIEW_SELECTED_SECTION_ID_ATOM = Atom.make<string | null>(null).pipe(
24+ Atom.keepAlive,
25+ Atom.withLabel("mobile:review:selected-section-id:null"),
26+);
27+const EMPTY_REVIEW_SECTION_FILE_IDS_ATOM = Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe(
28+ Atom.keepAlive,
29+ Atom.withLabel("mobile:review:section-file-ids:null"),
30+);
31+
32+const reviewGitSectionsByThreadKeyAtom = Atom.family((threadKey: string) =>
33+ Atom.make(EMPTY_GIT_REVIEW_SECTIONS).pipe(
34+ Atom.keepAlive,
35+ Atom.withLabel(`mobile:review:git-sections:${threadKey}`),
36+ ),
37+);
38+
39+const reviewTurnDiffByThreadKeyAtom = Atom.family((threadKey: string) =>
40+ Atom.make(EMPTY_REVIEW_TURN_DIFFS).pipe(
41+ Atom.keepAlive,
42+ Atom.withLabel(`mobile:review:turn-diffs:${threadKey}`),
43+ ),
44+);
45+
46+const reviewSelectedSectionIdByThreadKeyAtom = Atom.family((threadKey: string) =>
47+ Atom.make<string | null>(null).pipe(
48+ Atom.keepAlive,
49+ Atom.withLabel(`mobile:review:selected-section-id:${threadKey}`),
50+ ),
51+);
52+
53+const reviewExpandedFileIdsByThreadKeyAtom = Atom.family((threadKey: string) =>
54+ Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe(
55+ Atom.keepAlive,
56+ Atom.withLabel(`mobile:review:expanded-file-ids:${threadKey}`),
57+ ),
58+);
59+
60+const reviewRevealedLargeFileIdsByThreadKeyAtom = Atom.family((threadKey: string) =>
61+ Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe(
62+ Atom.keepAlive,
63+ Atom.withLabel(`mobile:review:revealed-large-file-ids:${threadKey}`),
64+ ),
65+);
66+
67+const reviewParsedDiffBySectionCacheKeyAtom = Atom.family((cacheKey: string) =>
68+ Atom.make<{ readonly diff: string | null; readonly parsed: ReviewParsedDiff } | null>(null).pipe(
69+ Atom.keepAlive,
70+ Atom.withLabel(`mobile:review:parsed-diffs:${cacheKey}`),
71+ ),
72+);
73+
74+function buildThreadKey(input: {
75+ readonly environmentId?: string;
76+ readonly threadId?: string;
77+}): string | null {
78+ return input.environmentId && input.threadId
79+ ? scopedThreadKey(EnvironmentId.make(input.environmentId), ThreadId.make(input.threadId))
80+ : null;
81+}
82+
83+function buildSectionCacheKey(threadKey: string, sectionId: string): string {
84+ return `${threadKey}:${sectionId}`;
85+}
86+
87+export function useReviewCacheForThread(input: {
88+ readonly environmentId?: string;
89+ readonly threadId?: string;
90+}) {
91+ const threadKey = buildThreadKey(input);
92+ const gitSections = useAtomValue(
93+ threadKey ? reviewGitSectionsByThreadKeyAtom(threadKey) : EMPTY_REVIEW_GIT_SECTIONS_ATOM,
94+ );
95+ const turnDiffById = useAtomValue(
96+ threadKey ? reviewTurnDiffByThreadKeyAtom(threadKey) : EMPTY_REVIEW_TURN_DIFFS_ATOM,
97+ );
98+ const selectedSectionId = useAtomValue(
99+ threadKey
100+ ? reviewSelectedSectionIdByThreadKeyAtom(threadKey)
101+ : EMPTY_REVIEW_SELECTED_SECTION_ID_ATOM,
102+ );
103+ const expandedFileIdsBySection = useAtomValue(
104+ threadKey
105+ ? reviewExpandedFileIdsByThreadKeyAtom(threadKey)
106+ : EMPTY_REVIEW_SECTION_FILE_IDS_ATOM,
107+ );
108+ const revealedLargeFileIdsBySection = useAtomValue(
109+ threadKey
110+ ? reviewRevealedLargeFileIdsByThreadKeyAtom(threadKey)
111+ : EMPTY_REVIEW_SECTION_FILE_IDS_ATOM,
112+ );
113+
114+ return {
115+ threadKey,
116+ gitSections,
117+ turnDiffById,
118+ selectedSectionId,
119+ expandedFileIdsBySection,
120+ revealedLargeFileIdsBySection,
121+ };
122+}
123+
124+export function setReviewGitSections(
125+ threadKey: string,
126+ sections: ReadonlyArray<GitReviewDiffSection>,
127+): void {
128+ appAtomRegistry.set(reviewGitSectionsByThreadKeyAtom(threadKey), sections);
129+}
130+
131+export function setReviewTurnDiff(threadKey: string, sectionId: string, diff: string): void {
132+ const atom = reviewTurnDiffByThreadKeyAtom(threadKey);
133+ const current = appAtomRegistry.get(atom);
134+ appAtomRegistry.set(atom, {
135+ ...current,
136+ [sectionId]: diff,
137+ });
138+}
139+
140+export function setReviewSelectedSectionId(threadKey: string, sectionId: string | null): void {
141+ appAtomRegistry.set(reviewSelectedSectionIdByThreadKeyAtom(threadKey), sectionId);
142+}
143+
144+export function updateReviewExpandedFileIds(
145+ threadKey: string,
146+ sectionId: string,
147+ update: (current: ReadonlyArray<string> | undefined) => ReadonlyArray<string> | undefined,
148+): void {
149+ const atom = reviewExpandedFileIdsByThreadKeyAtom(threadKey);
150+ const current = appAtomRegistry.get(atom);
151+ const nextValue = update(current[sectionId]);
152+ appAtomRegistry.set(atom, {
153+ ...current,
154+ [sectionId]: nextValue,
155+ });
156+}
157+
158+export function updateReviewRevealedLargeFileIds(
159+ threadKey: string,
160+ sectionId: string,
161+ update: (current: ReadonlyArray<string> | undefined) => ReadonlyArray<string> | undefined,
162+): void {
163+ const atom = reviewRevealedLargeFileIdsByThreadKeyAtom(threadKey);
164+ const current = appAtomRegistry.get(atom);
165+ const nextValue = update(current[sectionId]);
166+ appAtomRegistry.set(atom, {
167+ ...current,
168+ [sectionId]: nextValue,
169+ });
170+}
171+
172+export function getCachedReviewParsedDiff(input: {
173+ readonly threadKey: string | null;
174+ readonly sectionId: string | null;
175+ readonly diff: string | null | undefined;
176+}): ReviewParsedDiff {
177+ if (!input.threadKey || !input.sectionId) {
178+ return buildReviewParsedDiff(input.diff, input.sectionId ?? "mobile-review");
179+ }
180+
181+ const cacheKey = buildSectionCacheKey(input.threadKey, input.sectionId);
182+ const normalizedDiff = input.diff?.trim() ?? null;
183+ const atom = reviewParsedDiffBySectionCacheKeyAtom(cacheKey);
184+ const cached = appAtomRegistry.get(atom);
185+ if (cached && cached.diff === normalizedDiff) {
186+ return cached.parsed;
187+ }
188+
189+ const parsed = buildReviewParsedDiff(input.diff, input.sectionId);
190+ appAtomRegistry.set(atom, {
191+ diff: normalizedDiff,
192+ parsed,
193+ });
194+ return parsed;
195+}
apps/mobile/src/features/threads/review/shikiReviewHighlighter.test.ts+44−0
@@ -0,0 +1,44 @@
1+import { describe, expect, it } from "vitest";
2+
3+import type { ReviewRenderableFile } from "./reviewModel";
4+import { highlightReviewFile } from "./shikiReviewHighlighter";
5+
6+function makeRenderableFile(
7+ input: Partial<ReviewRenderableFile> & Pick<ReviewRenderableFile, "path">,
8+): ReviewRenderableFile {
9+ return {
10+ id: input.path,
11+ cacheKey: input.path,
12+ previousPath: null,
13+ changeType: "new",
14+ additions: 0,
15+ deletions: 0,
16+ languageHint: null,
17+ additionLines: [],
18+ deletionLines: [],
19+ rows: [],
20+ ...input,
21+ };
22+}
23+
24+describe("highlightReviewFile", () => {
25+ it("preserves one highlighted token row per diff line even without trailing newlines", async () => {
26+ const file = makeRenderableFile({
27+ path: "apps/mobile/src/example.test.ts",
28+ additionLines: [
29+ 'const items = ["a"];',
30+ 'expect(items).toEqual(["a"]);',
31+ "const next = items.map((item) => item.toUpperCase());",
32+ 'expect(next).toContain("A");',
33+ ],
34+ });
35+
36+ const highlighted = await highlightReviewFile(file, "light");
37+
38+ expect(highlighted.additionLines).toHaveLength(file.additionLines.length);
39+ expect(highlighted.additionLines[0]?.some((token) => token.content === "const")).toBe(true);
40+ expect(highlighted.additionLines[1]?.some((token) => token.content === "expect")).toBe(true);
41+ expect(highlighted.additionLines[2]?.some((token) => token.content === "const")).toBe(true);
42+ expect(highlighted.additionLines[3]?.some((token) => token.content === "expect")).toBe(true);
43+ });
44+});
apps/mobile/src/features/threads/review/shikiReviewHighlighter.ts+138−21
@@ -1,6 +1,12 @@
11 import { getFiletypeFromFileName } from "@pierre/diffs/utils";
22
3−import type { ReviewRenderableFile } from "./reviewModel";
3+import {
4+ hasTurboModuleProxy,
5+ resolveReviewHighlighterEngine,
6+ resolveReviewHighlighterEnginePreference,
7+ type ReviewHighlighterEngine,
8+} from "./reviewHighlighterEngine";
9+import type { ReviewRenderableFile, ReviewRenderableLineRow } from "./reviewModel";
410
511 export type ReviewDiffTheme = "light" | "dark";
612
@@ -19,8 +25,12 @@ const SHIKI_THEME_BY_SCHEME = { · 12 unmodified lines
1925 light: "github-light-default",
2026 dark: "github-dark-default",
2127 } as const;
28+const REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE = resolveReviewHighlighterEnginePreference(
29+ process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE,
30+);
2231
2332 const highlightCache = new Map<string, Promise<ReviewHighlightedFile>>();
33+const resolvedHighlightCache = new Map<string, ReviewHighlightedFile>();
2434 const loadedLanguages = new Set<string>(["text"]);
2535 type ShikiHighlighter = {
2636 loadLanguage: (...langs: string[]) => Promise<void>;
@@ -30,38 +40,83 @@ type ShikiHighlighter = { · 3 unmodified lines
3040 ) => Promise<Array<Array<{ content: string; color?: string; fontStyle?: number }>>>;
3141 };
3242 let highlighterPromise: Promise<ShikiHighlighter> | null = null;
43+let activeHighlighterEnginePromise: Promise<ReviewHighlighterEngine> | null = null;
3344
34−function cleanLastNewline(value: string): string {
45+function stripTrailingNewline(value: string): string {
3546 return value.endsWith("\n") ? value.slice(0, -1) : value;
3647 }
3748
3849 function joinPatchLines(lines: ReadonlyArray<string>): string {
39− if (lines.length === 0) {
40− return "";
41− }
42−
43− return cleanLastNewline(lines.join(""));
50+ return lines.map(stripTrailingNewline).join("\n");
4451 }
4552
4653 async function getHighlighter() {
4754 if (!highlighterPromise) {
48− highlighterPromise = import("shiki").then(
49− async ({ createHighlighter, createJavaScriptRegexEngine }) => {
50− const highlighter = await createHighlighter({
51− themes: [SHIKI_THEME_BY_SCHEME.light, SHIKI_THEME_BY_SCHEME.dark],
52− langs: [],
53− engine: createJavaScriptRegexEngine(),
54− });
55− return highlighter as unknown as ShikiHighlighter;
56− },
57− );
55+ const configuredHighlighterPromise = import("shiki").then(async (shikiModule) => {
56+ const nativeRuntimeAvailable = hasTurboModuleProxy();
57+ let nativeEngineAvailable = false;
58+
59+ if (nativeRuntimeAvailable && REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE !== "javascript") {
60+ try {
61+ const nativeEngineModule = await import("react-native-shiki-engine");
62+ nativeEngineAvailable = nativeEngineModule.isNativeEngineAvailable();
63+ if (nativeEngineAvailable) {
64+ const createHighlighter = shikiModule.createBundledHighlighter({
65+ langs: shikiModule.bundledLanguages,
66+ themes: shikiModule.bundledThemes,
67+ engine: () => nativeEngineModule.createNativeEngine(),
68+ });
69+ const highlighter = await createHighlighter({
70+ themes: [SHIKI_THEME_BY_SCHEME.light, SHIKI_THEME_BY_SCHEME.dark],
71+ langs: [],
72+ });
73+ return {
74+ highlighter: highlighter as unknown as ShikiHighlighter,
75+ engine: "native" as const,
76+ };
77+ }
78+ } catch {
79+ nativeEngineAvailable = false;
80+ }
81+ }
82+
83+ const engine = resolveReviewHighlighterEngine(
84+ REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE,
85+ nativeEngineAvailable,
86+ );
87+ const createHighlighter = shikiModule.createBundledHighlighter({
88+ langs: shikiModule.bundledLanguages,
89+ themes: shikiModule.bundledThemes,
90+ engine: () => shikiModule.createJavaScriptRegexEngine(),
91+ });
92+ const highlighter = await createHighlighter({
93+ themes: [SHIKI_THEME_BY_SCHEME.light, SHIKI_THEME_BY_SCHEME.dark],
94+ langs: [],
95+ });
96+ return {
97+ highlighter: highlighter as unknown as ShikiHighlighter,
98+ engine,
99+ };
100+ });
101+
102+ highlighterPromise = configuredHighlighterPromise.then((result) => result.highlighter);
103+ activeHighlighterEnginePromise = configuredHighlighterPromise.then((result) => result.engine);
58104 }
59105
60106 return highlighterPromise as Promise<ShikiHighlighter>;
61107 }
62108
63−async function resolveLanguage(file: ReviewRenderableFile): Promise<string> {
64− const candidate = file.languageHint ?? getFiletypeFromFileName(file.path);
109+export async function getActiveReviewHighlighterEngine(): Promise<ReviewHighlighterEngine> {
110+ await getHighlighter();
111+ return (activeHighlighterEnginePromise ??
112+ Promise.resolve("javascript")) as Promise<ReviewHighlighterEngine>;
113+}
114+
115+async function resolveLanguageFromPath(
116+ path: string,
117+ languageHint: string | null = null,
118+): Promise<string> {
119+ const candidate = languageHint ?? getFiletypeFromFileName(path);
65120 if (!candidate || candidate === "text" || candidate === "ansi") {
66121 return "text";
67122 }
@@ -79,6 +134,10 @@ async function resolveLanguage(file: ReviewRenderableFile): Promise<string> { · 11 unmodified lines
79134 return candidate;
80135 }
81136
137+async function resolveLanguage(file: ReviewRenderableFile): Promise<string> {
138+ return resolveLanguageFromPath(file.path, file.languageHint);
139+}
140+
82141 function normalizeHighlightedLines(
83142 tokenLines: ReadonlyArray<ReadonlyArray<{ content: string; color?: string; fontStyle?: number }>>,
84143 ): ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>> {
@@ -105,12 +164,27 @@ async function highlightLines( · 20 unmodified lines
105164 return normalizeHighlightedLines(tokenLines);
106165 }
107166
167+function getHighlightCacheKey(file: ReviewRenderableFile, theme: ReviewDiffTheme): string {
168+ return `${SHIKI_THEME_BY_SCHEME[theme]}:${file.cacheKey}`;
169+}
170+
171+export function getCachedHighlightedReviewFile(
172+ file: ReviewRenderableFile,
173+ theme: ReviewDiffTheme,
174+): ReviewHighlightedFile | null {
175+ return resolvedHighlightCache.get(getHighlightCacheKey(file, theme)) ?? null;
176+}
177+
108178 export async function highlightReviewFile(
109179 file: ReviewRenderableFile,
110180 theme: ReviewDiffTheme,
111181 ): Promise<ReviewHighlightedFile> {
112182 const shikiTheme = SHIKI_THEME_BY_SCHEME[theme];
113− const cacheKey = `${shikiTheme}:${file.cacheKey}`;
183+ const cacheKey = getHighlightCacheKey(file, theme);
184+ const resolved = resolvedHighlightCache.get(cacheKey);
185+ if (resolved) {
186+ return resolved;
187+ }
114188 const cached = highlightCache.get(cacheKey);
115189 if (cached) {
116190 return cached;
@@ -123,9 +197,52 @@ export async function highlightReviewFile( · 6 unmodified lines
123197 highlightLines(joinPatchLines(file.deletionLines), language, shikiTheme),
124198 ]);
125199
126− return { additionLines, deletionLines };
200+ const highlighted = { additionLines, deletionLines };
201+ resolvedHighlightCache.set(cacheKey, highlighted);
202+ return highlighted;
127203 })();
128204
129205 highlightCache.set(cacheKey, promise);
130206 return promise;
131207 }
208+
209+export async function highlightReviewSelectedLines(input: {
210+ readonly filePath: string;
211+ readonly lines: ReadonlyArray<ReviewRenderableLineRow>;
212+ readonly theme: ReviewDiffTheme;
213+ readonly languageHint?: string | null;
214+}): Promise<Record<string, ReadonlyArray<ReviewHighlightedToken>>> {
215+ if (input.lines.length === 0) {
216+ return {};
217+ }
218+
219+ const language = await resolveLanguageFromPath(input.filePath, input.languageHint ?? null);
220+ const shikiTheme = SHIKI_THEME_BY_SCHEME[input.theme];
221+ const additionLikeLines = input.lines
222+ .filter((line) => line.change !== "delete")
223+ .map((line) => `${line.content}\n`);
224+ const deletionLines = input.lines
225+ .filter((line) => line.change === "delete")
226+ .map((line) => `${line.content}\n`);
227+ const [additionTokens, deletionTokens] = await Promise.all([
228+ highlightLines(joinPatchLines(additionLikeLines), language, shikiTheme),
229+ highlightLines(joinPatchLines(deletionLines), language, shikiTheme),
230+ ]);
231+
232+ const tokenMap: Record<string, ReadonlyArray<ReviewHighlightedToken>> = {};
233+ let additionIndex = 0;
234+ let deletionIndex = 0;
235+
236+ input.lines.forEach((line) => {
237+ if (line.change === "delete") {
238+ tokenMap[line.id] = deletionTokens[deletionIndex] ?? [];
239+ deletionIndex += 1;
240+ return;
241+ }
242+
243+ tokenMap[line.id] = additionTokens[additionIndex] ?? [];
244+ additionIndex += 1;
245+ });
246+
247+ return tokenMap;
248+}
apps/mobile/src/features/threads/use-thread-composer-state.ts+21−0
@@ -93,6 +93,7 @@ export function appendReviewCommentToDraft(input: { · 92 unmodified lines
9393 readonly environmentId: string;
9494 readonly threadId: string;
9595 readonly text: string;
96+ readonly attachments?: ReadonlyArray<DraftComposerImageAttachment>;
9697 }): void {
9798 const threadKey = scopedThreadKey(
9899 EnvironmentId.make(input.environmentId),
@@ -105,6 +106,26 @@ export function appendReviewCommentToDraft(input: { · 6 unmodified lines
105106 ...current,
106107 [threadKey]: `${existing}${separator}${input.text}`,
107108 });
109+ if (input.attachments && input.attachments.length > 0) {
110+ appendDraftAttachments(threadKey, input.attachments);
111+ }
112+}
113+
114+export function useThreadDraftForThread(input: {
115+ readonly environmentId?: string;
116+ readonly threadId?: string;
117+}) {
118+ const draftMessageByThreadKey = useAtomValue(draftMessageByThreadKeyAtom);
119+ const draftAttachmentsByThreadKey = useAtomValue(draftAttachmentsByThreadKeyAtom);
120+ const threadKey =
121+ input.environmentId && input.threadId
122+ ? scopedThreadKey(EnvironmentId.make(input.environmentId), ThreadId.make(input.threadId))
123+ : null;
124+
125+ return {
126+ draftMessage: threadKey ? (draftMessageByThreadKey[threadKey] ?? "") : "",
127+ draftAttachments: threadKey ? (draftAttachmentsByThreadKey[threadKey] ?? []) : [],
128+ };
108129 }
109130
110131 function clearDraft(threadKey: string): void {
bun.lock+18−5
@@ -85,6 +85,7 @@ · 84 unmodified lines
8585 "react-native-reanimated": "4.2.1",
8686 "react-native-safe-area-context": "~5.6.2",
8787 "react-native-screens": "~4.23.0",
88+ "react-native-shiki-engine": "^0.3.9",
8889 "react-native-svg": "15.15.3",
8990 "react-native-worklets": "0.7.2",
9091 "shiki": "^4.0.2",
@@ -1071,7 +1072,7 @@ · 980 unmodified lines
10711072
10721073 "@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="],
10731074
1074− "@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
1075+ "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],
10751076
10761077 "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
10771078
@@ -2453,6 +2454,8 @@ · 1375 unmodified lines
24532454
24542455 "react-native-screens": ["react-native-screens@4.23.0", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-XhO3aK0UeLpBn4kLecd+J+EDeRRJlI/Ro9Fze06vo1q163VeYtzfU9QS09/VyDFMWR1qxDC1iazCArTPSFFiPw=="],
24552456
2457+ "react-native-shiki-engine": ["react-native-shiki-engine@0.3.9", "", { "dependencies": { "@shikijs/types": "^3.19.0", "@shikijs/vscode-textmate": "^10.0.2" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FoCzxk6ODGqbYFXRAgnrZ+ZTt5gbCfvwxxobuvGtvjIAK3GtjV6gOImldgI2hB/oelwqw1gksrFjpN3BqE7jKA=="],
2458+
24562459 "react-native-svg": ["react-native-svg@15.15.3", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/k4KYwPBLGcx2f5d4FjE+vCScK7QOX14cl2lIASJ28u4slHHtIhL0SZKU7u9qmRBHxTCKPoPBtN6haT1NENJNA=="],
24572460
24582461 "react-native-worklets": ["react-native-worklets@0.7.2", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "7.27.1", "@babel/plugin-transform-class-properties": "7.27.1", "@babel/plugin-transform-classes": "7.28.4", "@babel/plugin-transform-nullish-coalescing-operator": "7.27.1", "@babel/plugin-transform-optional-chaining": "7.27.1", "@babel/plugin-transform-shorthand-properties": "7.27.1", "@babel/plugin-transform-template-literals": "7.27.1", "@babel/plugin-transform-unicode-regex": "7.27.1", "@babel/preset-typescript": "7.27.1", "convert-source-map": "2.0.0", "semver": "7.7.3" }, "peerDependencies": { "@babel/core": "*", "react": "*", "react-native": "*" } }, "sha512-DuLu1kMV/Uyl9pQHp3hehAlThoLw7Yk2FwRTpzASOmI+cd4845FWn3m2bk9MnjUw8FBRIyhwLqYm2AJaXDXsog=="],
@@ -3023,9 +3026,19 @@ · 564 unmodified lines
30233026
30243027 "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
30253028
3026− "@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="],
3029+ "@shikijs/core/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
3030+
3031+ "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
3032+
3033+ "@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
3034+
3035+ "@shikijs/langs/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
30273036
3028− "@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],
3037+ "@shikijs/primitive/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
3038+
3039+ "@shikijs/themes/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
3040+
3041+ "@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="],
30293042
30303043 "@tailwindcss/node/tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
30313044
@@ -3205,6 +3218,8 @@ · 173 unmodified lines
32053218
32063219 "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
32073220
3221+ "shiki/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
3222+
32083223 "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="],
32093224
32103225 "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -3275,8 +3290,6 @@ · 64 unmodified lines
32753290
32763291 "@pierre/diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="],
32773292
3278− "@pierre/diffs/shiki/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],
3279−
32803293 "@react-native/babel-preset/babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="],
32813294
32823295 "@react-native/codegen/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],