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
11 "**/routeTree.gen.ts",11 "**/routeTree.gen.ts",
12 "apps/web/public/mockServiceWorker.js",12 "apps/web/public/mockServiceWorker.js",
13 "apps/web/src/lib/vendor/qrcodegen.ts",13 "apps/web/src/lib/vendor/qrcodegen.ts",
14 "apps/mobile/uniwind-types.d.ts"14 "apps/mobile/uniwind-types.d.ts",
15 "*.icon/**"15 "*.icon/**"
16 ],16 ],
17 "sortPackageJson": {}17 "sortPackageJson": {}
apps/mobile/package.json+1−0
@@ -73,6 +73,7 @@ · 72 unmodified lines
73 "react-native-reanimated": "4.2.1",73 "react-native-reanimated": "4.2.1",
74 "react-native-safe-area-context": "~5.6.2",74 "react-native-safe-area-context": "~5.6.2",
75 "react-native-screens": "~4.23.0",75 "react-native-screens": "~4.23.0",
76 "react-native-shiki-engine": "^0.3.9",
76 "react-native-svg": "15.15.3",77 "react-native-svg": "15.15.3",
77 "react-native-worklets": "0.7.2",78 "react-native-worklets": "0.7.2",
78 "shiki": "^4.0.2",79 "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
46 options={{46 options={{
47 animation: "slide_from_right",47 animation: "slide_from_right",
48 contentStyle: sheetStyle,48 contentStyle: sheetStyle,
49 headerBackButtonDisplayMode: "minimal",
49 headerShown: true,50 headerShown: true,
50 headerTitle: "Files changed",51 headerTitle: "Files changed",
51 headerBackTitle: "",52 headerBackTitle: "",
apps/mobile/src/components/ComposerAttachmentStrip.tsx+14−3
@@ -15,6 +15,8 @@ export interface ComposerAttachmentStripProps { · 14 unmodified lines
15 readonly imageSize?: number;15 readonly imageSize?: number;
16 /** Border radius of each image thumbnail. Defaults to 16. */16 /** Border radius of each image thumbnail. Defaults to 16. */
17 readonly imageBorderRadius?: number;17 readonly imageBorderRadius?: number;
18 /** Whether the remove button should sit in its own gutter instead of overlapping the image. */
19 readonly removeButtonPlacement?: "overlay" | "gutter";
18}20}
19 21
20/**22/**
@@ -25,6 +27,8 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { · 4 unmodified lines
25 const subtleBg = useThemeColor("--color-subtle");27 const subtleBg = useThemeColor("--color-subtle");
26 const size = props.imageSize ?? 72;28 const size = props.imageSize ?? 72;
27 const radius = props.imageBorderRadius ?? 16;29 const radius = props.imageBorderRadius ?? 16;
30 const removeButtonPlacement = props.removeButtonPlacement ?? "overlay";
31 const removeButtonGutter = removeButtonPlacement === "gutter" ? 10 : 0;
28 32
29 if (props.attachments.length === 0) {33 if (props.attachments.length === 0) {
30 return null;34 return null;
@@ -39,7 +43,14 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { · 8 unmodified lines
39 >43 >
40 <View style={{ flexDirection: "row", gap: 10 }}>44 <View style={{ flexDirection: "row", gap: 10 }}>
41 {props.attachments.map((image) => (45 {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 >
43 <Pressable54 <Pressable
44 onPress={props.onPressImage ? () => props.onPressImage!(image.previewUri) : undefined}55 onPress={props.onPressImage ? () => props.onPressImage!(image.previewUri) : undefined}
45 >56 >
@@ -57,8 +68,8 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { · 11 unmodified lines
57 <Pressable68 <Pressable
58 style={{69 style={{
59 position: "absolute",70 position: "absolute",
60 top: 4,71 top: removeButtonPlacement === "gutter" ? 0 : 4,
61 right: 4,72 right: removeButtonPlacement === "gutter" ? 0 : 4,
62 width: 22,73 width: 22,
63 height: 22,74 height: 22,
64 borderRadius: 11,75 borderRadius: 11,
apps/mobile/src/features/threads/review/ReviewCommentComposerSheet.tsx+266−135
@@ -1,12 +1,29 @@
1import { useLocalSearchParams, useRouter } from "expo-router";1import { useLocalSearchParams, useRouter } from "expo-router";
2import { SymbolView } from "expo-symbols";2import { SymbolView } from "expo-symbols";
3import { useMemo, useState } from "react";3import { TextInputWrapper } from "expo-paste-input";
4import { Platform, Pressable, ScrollView, Text as NativeText, View } from "react-native";4import { useEffect, useMemo, useState } from "react";
5import {
6 KeyboardAvoidingView,
7 Platform,
8 Pressable,
9 ScrollView,
10 View,
11 useColorScheme,
12 useWindowDimensions,
13} from "react-native";
5import { useSafeAreaInsets } from "react-native-safe-area-context";14import { useSafeAreaInsets } from "react-native-safe-area-context";
15import ImageViewing from "react-native-image-viewing";
6 16
7import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";17import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";
18import { ComposerAttachmentStrip } from "../../../components/ComposerAttachmentStrip";
19import { ControlPill } from "../../../components/ControlPill";
8import { cn } from "../../../lib/cn";20import { cn } from "../../../lib/cn";
21import type { DraftComposerImageAttachment } from "../../../lib/composerImages";
22import { convertPastedImagesToAttachments, pickComposerImages } from "../../../lib/composerImages";
9import { useThemeColor } from "../../../lib/useThemeColor";23import { useThemeColor } from "../../../lib/useThemeColor";
24import { useNativePaste } from "../../../hooks/useNativePaste";
25import { setPendingConnectionError } from "../../../state/use-remote-environment-registry";
26import { appendReviewCommentToDraft } from "../use-thread-composer-state";
10import {27import {
11 clearReviewCommentTarget,28 clearReviewCommentTarget,
12 formatReviewCommentContext,29 formatReviewCommentContext,
@@ -15,28 +32,21 @@ import { · 2 unmodified lines
15 getSelectedReviewCommentLines,32 getSelectedReviewCommentLines,
16 useReviewCommentTarget,33 useReviewCommentTarget,
17} from "./reviewCommentSelection";34} from "./reviewCommentSelection";
18import { appendReviewCommentToDraft } from "../use-thread-composer-state";35import { changeTone, DiffTokenText } from "./reviewDiffRendering";
19 36import {
20const REVIEW_MONO_FONT_FAMILY = Platform.select({37 highlightReviewSelectedLines,
21 ios: "ui-monospace",38 type ReviewDiffTheme,
22 android: "monospace",39 type ReviewHighlightedToken,
23 default: "monospace",40} from "./shikiReviewHighlighter";
24});
25
26function renderVisibleWhitespace(value: string): string {
27 const expandedTabs = value.replace(/\t/g, " ");
28 return expandedTabs.replace(/^( +)/, (leading) => leading.replaceAll(" ", "\u00A0"));
29}
30 41
31function changeTone(change: "context" | "add" | "delete"): string {42const REVIEW_COMMENT_PREVIEW_MAX_LINES = 5;
32 if (change === "add") return "bg-emerald-500/12";43const REVIEW_COMMENT_LINE_HEIGHT = 26;
33 if (change === "delete") return "bg-rose-500/12";
34 return "bg-card";
35}
36 44
37export function ReviewCommentComposerSheet() {45export function ReviewCommentComposerSheet() {
38 const router = useRouter();46 const router = useRouter();
39 const insets = useSafeAreaInsets();47 const insets = useSafeAreaInsets();
48 const { width } = useWindowDimensions();
49 const colorScheme = useColorScheme();
40 const iconTint = String(useThemeColor("--color-icon"));50 const iconTint = String(useThemeColor("--color-icon"));
41 const target = useReviewCommentTarget();51 const target = useReviewCommentTarget();
42 const { environmentId, threadId } = useLocalSearchParams<{52 const { environmentId, threadId } = useLocalSearchParams<{
@@ -44,6 +54,11 @@ export function ReviewCommentComposerSheet() { · 1 unmodified line
44 threadId: string;54 threadId: string;
45 }>();55 }>();
46 const [commentText, setCommentText] = useState("");56 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);
47 62
48 const selectedLines = useMemo(63 const selectedLines = useMemo(
49 () => (target ? getSelectedReviewCommentLines(target) : []),64 () => (target ? getSelectedReviewCommentLines(target) : []),
@@ -53,136 +68,252 @@ export function ReviewCommentComposerSheet() { · 3 unmodified lines
53 const lastLine = selectedLines[selectedLines.length - 1] ?? null;68 const lastLine = selectedLines[selectedLines.length - 1] ?? null;
54 const firstNumber = firstLine ? getReviewUnifiedLineNumber(firstLine) : null;69 const firstNumber = firstLine ? getReviewUnifiedLineNumber(firstLine) : null;
55 const lastNumber = lastLine ? getReviewUnifiedLineNumber(lastLine) : null;70 const lastNumber = lastLine ? getReviewUnifiedLineNumber(lastLine) : null;
71 const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme;
56 const canSubmit =72 const canSubmit =
57 commentText.trim().length > 0 && target !== null && !!environmentId && !!threadId;73 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 }
58 140
59 return (141 return (
60 <ScrollView142 <KeyboardAvoidingView
61 keyboardShouldPersistTaps="handled"143 behavior={Platform.OS === "ios" ? "padding" : "height"}
62 showsVerticalScrollIndicator={false}144 keyboardVerticalOffset={Platform.OS === "ios" ? 12 : 0}
63 contentContainerStyle={{145 style={{ flex: 1 }}
64 paddingHorizontal: 20,
65 paddingTop: 8,
66 paddingBottom: Math.max(insets.bottom, 18) + 18,
67 gap: 16,
68 }}
69 >146 >
70 <View className="flex-row items-center justify-between py-2">147 <View
71 <Pressable148 style={{
72 className="bg-subtle h-12 w-12 items-center justify-center rounded-full"149 flex: 1,
73 onPress={() => {150 paddingHorizontal: 20,
74 clearReviewCommentTarget();151 paddingTop: 8,
75 router.dismiss();152 paddingBottom: Math.max(insets.bottom, 18) + 18,
76 }}153 }}
77 >154 >
78 <SymbolView name="xmark" size={18} tintColor={iconTint} type="monochrome" />155 <View className="flex-row items-center justify-between py-2">
79 </Pressable>156 <Pressable
80 157 className="bg-subtle h-12 w-12 items-center justify-center rounded-full"
81 <Text className="text-[18px] font-t3-bold text-foreground">Add Comment</Text>158 onPress={() => {
82 159 clearReviewCommentTarget();
83 <Pressable160 router.dismiss();
84 className={cn(161 }}
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 )}
109 >162 >
110 Comment163 <SymbolView name="xmark" size={18} tintColor={iconTint} type="monochrome" />
111 </Text>164 </Pressable>
112 </Pressable>
113 </View>
114 165
115 {!target ? (166 <Text className="text-[18px] font-t3-bold text-foreground">Add Comment</Text>
116 <View className="rounded-[22px] border border-border bg-card px-4 py-5">167
117 <Text className="text-[15px] font-t3-bold text-foreground">No selection</Text>168 <View className="h-12 w-12" />
118 <Text className="mt-1 text-[13px] leading-[19px] text-foreground-muted">
119 Select a diff line or range first.
120 </Text>
121 </View>169 </View>
122 ) : (170
123 <>171 {!target ? (
124 <View className="gap-1 rounded-[22px] border border-border bg-card px-4 py-4">172 <View className="rounded-[22px] border border-border bg-card px-4 py-5">
125 <Text className="font-mono text-[13px] leading-[18px] text-foreground">173 <Text className="text-[15px] font-t3-bold text-foreground">No selection</Text>
126 {target.filePath}174 <Text className="mt-1 text-[13px] leading-[19px] text-foreground-muted">
127 </Text>175 Select a diff line or range first.
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`}
136 </Text>176 </Text>
137 </View>177 </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>
138 192
139 <View className="overflow-hidden rounded-[22px] border border-border bg-card">193 <View className="overflow-hidden rounded-[22px] border border-border bg-card">
140 {selectedLines.map((line) => {194 <ScrollView
141 const lineNumber = getReviewUnifiedLineNumber(line);195 horizontal
142 196 bounces={false}
143 return (197 keyboardShouldPersistTaps="always"
144 <View198 showsHorizontalScrollIndicator={false}
145 key={line.id}199 >
146 className={cn(200 <ScrollView
147 "flex-row items-start border-b border-border/60",201 bounces={false}
148 changeTone(line.change),202 scrollEnabled={selectedLines.length > REVIEW_COMMENT_PREVIEW_MAX_LINES}
149 )}203 nestedScrollEnabled
204 keyboardShouldPersistTaps="always"
205 showsVerticalScrollIndicator={
206 selectedLines.length > REVIEW_COMMENT_PREVIEW_MAX_LINES
207 }
208 style={{ height: previewHeight }}
150 >209 >
151 <Text className="w-9 px-1 py-2 text-right text-[11px] font-t3-medium text-foreground-muted">210 <View style={{ minWidth: previewViewportWidth }}>
152 {lineNumber ?? ""}211 {selectedLines.map((line) => {
153 </Text>212 const lineNumber = getReviewUnifiedLineNumber(line);
154 <Text213
155 className="px-0.5 py-2 text-center font-mono text-[12px] text-foreground-muted"214 return (
156 style={{ width: 18 }}215 <View
157 >216 key={line.id}
158 {getReviewChangeMarker(line.change)}217 className={cn("flex-row items-start", changeTone(line.change))}
159 </Text>218 style={{ height: REVIEW_COMMENT_LINE_HEIGHT }}
160 <NativeText219 >
161 selectable220 <Text className="w-9 px-1 py-1 text-right text-[11px] font-t3-medium text-foreground-muted">
162 className="flex-1 px-1 py-2 text-[12px] leading-[19px] text-foreground"221 {lineNumber ?? ""}
163 style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }}222 </Text>
164 >223 <Text
165 {renderVisibleWhitespace(line.content || " ")}224 className="px-0.5 py-1 text-center font-mono text-[12px] text-foreground-muted"
166 </NativeText>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>
167 </View>261 </View>
168 );262 {attachments.length > 0 ? (
169 })}263 <View className="px-4 pb-3 pt-2">
170 </View>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>
171 280
172 <View className="gap-2">281 <View className="flex-row items-center gap-3 pt-1">
173 <Text className="text-[13px] font-t3-bold text-foreground">Comment</Text>282 <ControlPill icon="plus" onPress={() => void handlePickImages()} />
174 <TextInput283 <View className="flex-1" />
175 autoFocus284 <ControlPill
176 multiline285 icon="arrow.up"
177 placeholder="Leave a comment..."286 label="Comment"
178 textAlignVertical="top"287 variant="primary"
179 value={commentText}288 disabled={!canSubmit}
180 onChangeText={setCommentText}289 onPress={() => {
181 className="min-h-[192px] rounded-[20px] px-4 py-3.5 font-sans text-[15px]"290 if (!target || !environmentId || !threadId || commentText.trim().length === 0) {
182 />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>
183 </View>306 </View>
184 </>307 )}
185 )}308 </View>
186 </ScrollView>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>
187 );318 );
188}319}
apps/mobile/src/features/threads/review/ReviewSheet.tsx+544−410
@@ -1,15 +1,11 @@
1import {1import { ThreadId, type OrchestrationCheckpointSummary } from "@t3tools/contracts";
2 ThreadId,2import { LegendList } from "@legendapp/list/react-native";
3 type GitReviewDiffSection,
4 type OrchestrationCheckpointSummary,
5} from "@t3tools/contracts";
6import { useLocalSearchParams, useRouter } from "expo-router";3import { useLocalSearchParams, useRouter } from "expo-router";
7import Stack from "expo-router/stack";4import Stack from "expo-router/stack";
8import { SymbolView } from "expo-symbols";5import { SymbolView } from "expo-symbols";
9import { type ReactElement, useCallback, useEffect, useMemo, useState } from "react";6import { memo, type ReactElement, useCallback, useEffect, useMemo, useState } from "react";
10import {7import {
11 ActivityIndicator,8 ActivityIndicator,
12 Platform,
13 Pressable,9 Pressable,
14 ScrollView,10 ScrollView,
15 Text as NativeText,11 Text as NativeText,
@@ -25,16 +21,28 @@ import { useThemeColor } from "../../../lib/useThemeColor"; · 9 unmodified lines
25import { getEnvironmentClient } from "../../../state/use-remote-environment-registry";21import { getEnvironmentClient } from "../../../state/use-remote-environment-registry";
26import { useSelectedThreadDetail } from "../../../state/use-thread-detail";22import { useSelectedThreadDetail } from "../../../state/use-thread-detail";
27import { useThreadSelection } from "../../../state/use-thread-selection";23import { useThreadSelection } from "../../../state/use-thread-selection";
24import { useThreadDraftForThread } from "../use-thread-composer-state";
25import {
26 getCachedReviewParsedDiff,
27 setReviewGitSections,
28 setReviewSelectedSectionId,
29 setReviewTurnDiff,
30 updateReviewExpandedFileIds,
31 updateReviewRevealedLargeFileIds,
32 useReviewCacheForThread,
33} from "./reviewState";
28import {34import {
29 buildReviewParsedDiff,
30 getReadyReviewCheckpoints,35 getReadyReviewCheckpoints,
31 buildReviewSectionItems,36 buildReviewSectionItems,
32 getDefaultReviewSectionId,37 getDefaultReviewSectionId,
38 getReviewFilePreviewState,
33 getReviewSectionIdForCheckpoint,39 getReviewSectionIdForCheckpoint,
40 type ReviewParsedDiff,
34 type ReviewRenderableFile,41 type ReviewRenderableFile,
35 type ReviewRenderableLineRow,42 type ReviewRenderableLineRow,
36} from "./reviewModel";43} from "./reviewModel";
37import {44import {
45 getCachedHighlightedReviewFile,
38 highlightReviewFile,46 highlightReviewFile,
39 type ReviewDiffTheme,47 type ReviewDiffTheme,
40 type ReviewHighlightedFile,48 type ReviewHighlightedFile,
@@ -43,6 +51,7 @@ import { · 2 unmodified lines
43import {51import {
44 buildReviewCommentTarget,52 buildReviewCommentTarget,
45 clearReviewCommentTarget,53 clearReviewCommentTarget,
54 countReviewCommentContexts,
46 formatReviewSelectedRangeLabel,55 formatReviewSelectedRangeLabel,
47 getReviewChangeMarker,56 getReviewChangeMarker,
48 getReviewUnifiedLineNumber,57 getReviewUnifiedLineNumber,
@@ -51,6 +60,7 @@ import { · 2 unmodified lines
51 type ReviewCommentTarget,60 type ReviewCommentTarget,
52 useReviewCommentTarget,61 useReviewCommentTarget,
53} from "./reviewCommentSelection";62} from "./reviewCommentSelection";
63import { changeTone, DiffTokenText } from "./reviewDiffRendering";
54 64
55interface PendingCommentSelection {65interface PendingCommentSelection {
56 readonly sectionTitle: string;66 readonly sectionTitle: string;
@@ -59,20 +69,16 @@ interface PendingCommentSelection { · 2 unmodified lines
59 readonly anchorIndex: number;69 readonly anchorIndex: number;
60}70}
61 71
62const REVIEW_MONO_FONT_FAMILY = Platform.select({72interface ReviewLineActionInput {
63 ios: "ui-monospace",73 readonly sectionTitle: string;
64 android: "monospace",74 readonly filePath: string;
65 default: "monospace",75 readonly lines: ReadonlyArray<ReviewRenderableLineRow>;
66});76 readonly lineIndex: number;
77}
78
67const IOS_NAV_BAR_HEIGHT = 44;79const IOS_NAV_BAR_HEIGHT = 44;
68const REVIEW_HEADER_SPACING = 32;80const REVIEW_HEADER_SPACING = 32;
69 81
70function 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
76function changeTypeLabel(type: ReviewRenderableFile["changeType"]): string {82function changeTypeLabel(type: ReviewRenderableFile["changeType"]): string {
77 switch (type) {83 switch (type) {
78 case "new":84 case "new":
@@ -88,7 +94,7 @@ function changeTypeLabel(type: ReviewRenderableFile["changeType"]): string { · 9 unmodified lines
88 }94 }
89}95}
90 96
91function formatHeaderDiffSummary(parsedDiff: ReturnType<typeof buildReviewParsedDiff>): {97function formatHeaderDiffSummary(parsedDiff: ReviewParsedDiff): {
92 readonly additions: string | null;98 readonly additions: string | null;
93 readonly deletions: string | null;99 readonly deletions: string | null;
94} {100} {
@@ -102,84 +108,13 @@ function formatHeaderDiffSummary(parsedDiff: ReturnType<typeof buildReviewParsed · 7 unmodified lines
102 };108 };
103}109}
104 110
105function shouldAutoExpandFile(file: ReviewRenderableFile): boolean {
106 return file.additions + file.deletions <= 20 && file.rows.length <= 40;
107}
108
109function getDefaultExpandedFileIds(111function getDefaultExpandedFileIds(
110 files: ReadonlyArray<ReviewRenderableFile>,112 files: ReadonlyArray<ReviewRenderableFile>,
111): ReadonlyArray<string> {113): ReadonlyArray<string> {
112 const autoExpanded = files.filter(shouldAutoExpandFile).map((file) => file.id);114 return files.map((file) => file.id);
113 if (autoExpanded.length > 0) {
114 return autoExpanded;
115 }
116 return files.length === 1 ? [files[0]!.id] : [];
117}
118
119function renderVisibleWhitespace(value: string): string {
120 const expandedTabs = value.replace(/\t/g, " ");
121 return expandedTabs.replace(/^( +)/, (leading) => leading.replaceAll(" ", "\u00A0"));
122}
123
124function 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 );
180}115}
181 116
182function ReviewLineRow(props: {117const ReviewLineRow = memo(function ReviewLineRow(props: {
183 readonly line: ReviewRenderableLineRow;118 readonly line: ReviewRenderableLineRow;
184 readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null;119 readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null;
185 readonly viewportWidth: number;120 readonly viewportWidth: number;
@@ -192,7 +127,7 @@ function ReviewLineRow(props: { · 6 unmodified lines
192 return (127 return (
193 <Pressable128 <Pressable
194 className={cn(129 className={cn(
195 "flex-row items-start border-b border-border/60",130 "flex-row items-start",
196 changeTone(props.line.change),131 changeTone(props.line.change),
197 props.selectionState === "anchor" && "bg-sky-500/16",132 props.selectionState === "anchor" && "bg-sky-500/16",
198 props.selectionState === "selected" && "bg-amber-300/28",133 props.selectionState === "selected" && "bg-amber-300/28",
@@ -210,30 +145,34 @@ function ReviewLineRow(props: { · 11 unmodified lines
210 onPress={props.onComment}145 onPress={props.onComment}
211 style={{ minWidth: props.viewportWidth }}146 style={{ minWidth: props.viewportWidth }}
212 >147 >
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">
214 {lineNumber ?? ""}149 {lineNumber ?? ""}
215 </Text>150 </Text>
216 <Text151 <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"
218 style={{ width: 18 }}153 style={{ width: 18 }}
219 >154 >
220 {getReviewChangeMarker(props.line.change)}155 {getReviewChangeMarker(props.line.change)}
221 </Text>156 </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">
223 <DiffTokenText tokens={props.tokens} fallback={props.line.content} />158 <DiffTokenText tokens={props.tokens} fallback={props.line.content} />
224 </View>159 </View>
225 </Pressable>160 </Pressable>
226 );161 );
227}162});
228 163
229function ReviewFileCard(props: {164const ReviewFileCard = memo(function ReviewFileCard(props: {
230 readonly file: ReviewRenderableFile;165 readonly file: ReviewRenderableFile;
166 readonly fileId: string;
231 readonly expanded: boolean;167 readonly expanded: boolean;
232 readonly onToggle: () => void;168 readonly onToggleFile: (fileId: string) => void;
233}) {169}) {
234 return (170 return (
235 <View className="border-b border-border bg-card" style={{ zIndex: 1 }}>171 <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 >
237 <View className="pt-0.5">176 <View className="pt-0.5">
238 <SymbolView177 <SymbolView
239 name={props.expanded ? "chevron.down" : "chevron.right"}178 name={props.expanded ? "chevron.down" : "chevron.right"}
@@ -266,41 +205,44 @@ function ReviewFileCard(props: { · 26 unmodified lines
266 </Pressable>205 </Pressable>
267 </View>206 </View>
268 );207 );
269}208});
270 209
271function ReviewFileBody(props: {210const ReviewFileBody = memo(function ReviewFileBody(props: {
272 readonly file: ReviewRenderableFile;211 readonly file: ReviewRenderableFile;
273 readonly sectionTitle: string;212 readonly sectionTitle: string;
274 readonly highlightedFile: ReviewHighlightedFile | null;213 readonly highlightedFile: ReviewHighlightedFile | null;
275 readonly viewportWidth: number;214 readonly viewportWidth: number;
276 readonly pendingSelection: PendingCommentSelection | null;215 readonly pendingSelection: PendingCommentSelection | null;
277 readonly selectedTarget: ReviewCommentTarget | null;216 readonly selectedTarget: ReviewCommentTarget | null;
278 readonly onPressLine: (217 readonly onPressLine: (input: ReviewLineActionInput) => void;
279 line: ReviewRenderableLineRow,218 readonly onStartRangeSelection: (input: ReviewLineActionInput) => void;
280 availableLines: ReadonlyArray<ReviewRenderableLineRow>,
281 lineIndex: number,
282 ) => void;
283 readonly onStartRangeSelection: (
284 line: ReviewRenderableLineRow,
285 availableLines: ReadonlyArray<ReviewRenderableLineRow>,
286 lineIndex: number,
287 ) => void;
288}) {219}) {
289 const commentableLines = props.file.rows.filter(220 const commentableLines = useMemo(
290 (row): row is ReviewRenderableLineRow => row.kind === "line",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],
291 );245 );
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;
304 246
305 return (247 return (
306 <ScrollView248 <ScrollView
@@ -345,16 +287,20 @@ function ReviewFileBody(props: { · 38 unmodified lines
345 : null287 : null
346 }288 }
347 onComment={() => {289 onComment={() => {
348 const lineIndex = commentableLines.findIndex(290 props.onPressLine({
349 (candidate) => candidate.id === row.id,291 sectionTitle: props.sectionTitle,
350 );292 filePath: props.file.path,
351 props.onPressLine(row, commentableLines, lineIndex >= 0 ? lineIndex : 0);293 lines: commentableLines,
294 lineIndex: lineIndexById.get(row.id) ?? 0,
295 });
352 }}296 }}
353 onStartRangeSelection={() => {297 onStartRangeSelection={() => {
354 const lineIndex = commentableLines.findIndex(298 props.onStartRangeSelection({
355 (candidate) => candidate.id === row.id,299 sectionTitle: props.sectionTitle,
356 );300 filePath: props.file.path,
357 props.onStartRangeSelection(row, commentableLines, lineIndex >= 0 ? lineIndex : 0);301 lines: commentableLines,
302 lineIndex: lineIndexById.get(row.id) ?? 0,
303 });
358 }}304 }}
359 />305 />
360 );306 );
@@ -362,7 +308,129 @@ function ReviewFileBody(props: { · 1 unmodified line
362 </View>308 </View>
363 </ScrollView>309 </ScrollView>
364 );310 );
365}311});
312
313const 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
334const 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
422const 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});
366 434
367function ReviewSelectionActionBar(props: {435function ReviewSelectionActionBar(props: {
368 readonly target: ReviewCommentTarget | null;436 readonly target: ReviewCommentTarget | null;
@@ -419,20 +487,13 @@ export function ReviewSheet() { · 50 unmodified lines
419 environmentId: string;487 environmentId: string;
420 threadId: string;488 threadId: string;
421 }>();489 }>();
490 const { draftMessage } = useThreadDraftForThread({ environmentId, threadId });
491 const reviewCache = useReviewCacheForThread({ environmentId, threadId });
422 const { selectedThreadProject } = useThreadSelection();492 const { selectedThreadProject } = useThreadSelection();
423 const selectedThread = useSelectedThreadDetail();493 const selectedThread = useSelectedThreadDetail();
424 const [gitSections, setGitSections] = useState<ReadonlyArray<GitReviewDiffSection>>([]);
425 const [turnDiffById, setTurnDiffById] = useState<Record<string, string>>({});
426 const [loadingTurnIds, setLoadingTurnIds] = useState<Record<string, boolean>>({});494 const [loadingTurnIds, setLoadingTurnIds] = useState<Record<string, boolean>>({});
427 const [selectedSectionId, setSelectedSectionId] = useState<string | null>(null);
428 const [loadingGitDiffs, setLoadingGitDiffs] = useState(false);495 const [loadingGitDiffs, setLoadingGitDiffs] = useState(false);
429 const [error, setError] = useState<string | null>(null);496 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 >({});
436 const [pendingCommentSelection, setPendingCommentSelection] =497 const [pendingCommentSelection, setPendingCommentSelection] =
437 useState<PendingCommentSelection | null>(null);498 useState<PendingCommentSelection | null>(null);
438 const activeCommentTarget = useReviewCommentTarget();499 const activeCommentTarget = useReviewCommentTarget();
@@ -456,39 +517,49 @@ export function ReviewSheet() { · 17 unmodified lines
456 () =>517 () =>
457 buildReviewSectionItems({518 buildReviewSectionItems({
458 checkpoints: readyCheckpoints,519 checkpoints: readyCheckpoints,
459 gitSections,520 gitSections: reviewCache.gitSections,
460 turnDiffById,521 turnDiffById: reviewCache.turnDiffById,
461 loadingTurnIds,522 loadingTurnIds,
462 }),523 }),
463 [gitSections, loadingTurnIds, readyCheckpoints, turnDiffById],524 [loadingTurnIds, readyCheckpoints, reviewCache.gitSections, reviewCache.turnDiffById],
464 );525 );
465 526
466 const selectedSection =527 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;
468 const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT;531 const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT;
469 const parsedDiff = useMemo(532 const parsedDiff = useMemo(
470 () => buildReviewParsedDiff(selectedSection?.diff, selectedSection?.id ?? "mobile-review"),533 () =>
471 [selectedSection?.diff, selectedSection?.id],534 getCachedReviewParsedDiff({
535 threadKey: reviewCache.threadKey,
536 sectionId: selectedSection?.id ?? null,
537 diff: selectedSection?.diff,
538 }),
539 [reviewCache.threadKey, selectedSection?.diff, selectedSection?.id],
472 );540 );
473 const headerDiffSummary = useMemo(() => formatHeaderDiffSummary(parsedDiff), [parsedDiff]);541 const headerDiffSummary = useMemo(() => formatHeaderDiffSummary(parsedDiff), [parsedDiff]);
542 const pendingReviewCommentCount = useMemo(
543 () => countReviewCommentContexts(draftMessage),
544 [draftMessage],
545 );
474 546
475 const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme;547 const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme;
476 const expandedFileIds = useMemo(548 const expandedFileIds = useMemo(
477 () =>549 () =>
478 selectedSection?.id && parsedDiff.kind === "files"550 selectedSection?.id && parsedDiff.kind === "files"
479 ? (expandedFileIdsBySection[selectedSection.id] ??551 ? (reviewCache.expandedFileIdsBySection[selectedSection.id] ??
480 getDefaultExpandedFileIds(parsedDiff.files))552 getDefaultExpandedFileIds(parsedDiff.files))
481 : [],553 : [],
482 [expandedFileIdsBySection, parsedDiff, selectedSection?.id],554 [parsedDiff, reviewCache.expandedFileIdsBySection, selectedSection?.id],
483 );555 );
484 const expandedFiles = useMemo(556 const revealedLargeFileIds = useMemo(
485 () =>557 () =>
486 parsedDiff.kind === "files"558 selectedSection?.id
487 ? parsedDiff.files.filter((file) => expandedFileIds.includes(file.id))559 ? (reviewCache.revealedLargeFileIdsBySection[selectedSection.id] ?? [])
488 : [],560 : [],
489 [expandedFileIds, parsedDiff],561 [reviewCache.revealedLargeFileIdsBySection, selectedSection?.id],
490 );562 );
491
492 const loadGitDiffs = useCallback(async () => {563 const loadGitDiffs = useCallback(async () => {
493 if (!environmentId || !cwd) {564 if (!environmentId || !cwd) {
494 return;565 return;
@@ -504,13 +575,15 @@ export function ReviewSheet() { · 9 unmodified lines
504 setError(null);575 setError(null);
505 try {576 try {
506 const result = await client.git.getReviewDiffs({ cwd });577 const result = await client.git.getReviewDiffs({ cwd });
507 setGitSections(result.sections);578 if (reviewCache.threadKey) {
579 setReviewGitSections(reviewCache.threadKey, result.sections);
580 }
508 } catch (cause) {581 } catch (cause) {
509 setError(cause instanceof Error ? cause.message : "Failed to load review diffs.");582 setError(cause instanceof Error ? cause.message : "Failed to load review diffs.");
510 } finally {583 } finally {
511 setLoadingGitDiffs(false);584 setLoadingGitDiffs(false);
512 }585 }
513 }, [cwd, environmentId]);586 }, [cwd, environmentId, reviewCache.threadKey]);
514 587
515 const loadTurnDiff = useCallback(588 const loadTurnDiff = useCallback(
516 async (checkpoint: OrchestrationCheckpointSummary, force = false) => {589 async (checkpoint: OrchestrationCheckpointSummary, force = false) => {
@@ -519,9 +592,11 @@ export function ReviewSheet() { · 2 unmodified lines
519 }592 }
520 593
521 const sectionId = getReviewSectionIdForCheckpoint(checkpoint);594 const sectionId = getReviewSectionIdForCheckpoint(checkpoint);
522 setSelectedSectionId(sectionId);595 if (reviewCache.threadKey) {
596 setReviewSelectedSectionId(reviewCache.threadKey, sectionId);
597 }
523 598
524 if (!force && turnDiffById[sectionId] !== undefined) {599 if (!force && reviewCache.turnDiffById[sectionId] !== undefined) {
525 return;600 return;
526 }601 }
527 602
@@ -539,7 +614,9 @@ export function ReviewSheet() { · 11 unmodified lines
539 fromTurnCount: Math.max(0, checkpoint.checkpointTurnCount - 1),614 fromTurnCount: Math.max(0, checkpoint.checkpointTurnCount - 1),
540 toTurnCount: checkpoint.checkpointTurnCount,615 toTurnCount: checkpoint.checkpointTurnCount,
541 });616 });
542 setTurnDiffById((current) => ({ ...current, [sectionId]: result.diff }));617 if (reviewCache.threadKey) {
618 setReviewTurnDiff(reviewCache.threadKey, sectionId, result.diff);
619 }
543 } catch (cause) {620 } catch (cause) {
544 setError(cause instanceof Error ? cause.message : "Failed to load turn diff.");621 setError(cause instanceof Error ? cause.message : "Failed to load turn diff.");
545 } finally {622 } finally {
@@ -550,7 +627,7 @@ export function ReviewSheet() { · 4 unmodified lines
550 });627 });
551 }628 }
552 },629 },
553 [environmentId, threadId, turnDiffById],630 [environmentId, reviewCache.threadKey, reviewCache.turnDiffById, threadId],
554 );631 );
555 632
556 useEffect(() => {633 useEffect(() => {
@@ -563,10 +640,14 @@ export function ReviewSheet() { · 6 unmodified lines
563 }640 }
564 641
565 const fallbackId = getDefaultReviewSectionId(reviewSections);642 const fallbackId = getDefaultReviewSectionId(reviewSections);
566 if (!selectedSectionId || !reviewSections.some((section) => section.id === selectedSectionId)) {643 if (
567 setSelectedSectionId(fallbackId);644 reviewCache.threadKey &&
645 (!reviewCache.selectedSectionId ||
646 !reviewSections.some((section) => section.id === reviewCache.selectedSectionId))
647 ) {
648 setReviewSelectedSectionId(reviewCache.threadKey, fallbackId);
568 }649 }
569 }, [reviewSections, selectedSectionId]);650 }, [reviewCache.selectedSectionId, reviewCache.threadKey, reviewSections]);
570 651
571 useEffect(() => {652 useEffect(() => {
572 const latest = readyCheckpoints[0];653 const latest = readyCheckpoints[0];
@@ -575,12 +656,12 @@ export function ReviewSheet() { · 2 unmodified lines
575 }656 }
576 657
577 const latestId = getReviewSectionIdForCheckpoint(latest);658 const latestId = getReviewSectionIdForCheckpoint(latest);
578 if (turnDiffById[latestId] !== undefined || loadingTurnIds[latestId]) {659 if (reviewCache.turnDiffById[latestId] !== undefined || loadingTurnIds[latestId]) {
579 return;660 return;
580 }661 }
581 662
582 void loadTurnDiff(latest);663 void loadTurnDiff(latest);
583 }, [loadTurnDiff, loadingTurnIds, readyCheckpoints, turnDiffById]);664 }, [loadTurnDiff, loadingTurnIds, readyCheckpoints, reviewCache.turnDiffById]);
584 665
585 useEffect(() => {666 useEffect(() => {
586 if (!selectedSection || selectedSection.kind !== "turn" || selectedSection.diff !== null) {667 if (!selectedSection || selectedSection.kind !== "turn" || selectedSection.diff !== null) {
@@ -594,68 +675,41 @@ export function ReviewSheet() { · 7 unmodified lines
594 }, [checkpointBySectionId, loadTurnDiff, loadingTurnIds, selectedSection]);675 }, [checkpointBySectionId, loadTurnDiff, loadingTurnIds, selectedSection]);
595 676
596 useEffect(() => {677 useEffect(() => {
597 if (!selectedSectionId || parsedDiff.kind !== "files") {678 if (!reviewCache.threadKey || !selectedSection?.id || parsedDiff.kind !== "files") {
598 return;679 return;
599 }680 }
600 681
601 setExpandedFileIdsBySection((current) => {682 updateReviewExpandedFileIds(reviewCache.threadKey, selectedSection.id, (existing) => {
602 const existing = current[selectedSectionId];
603 if (existing !== undefined) {683 if (existing !== undefined) {
604 const validIds = existing.filter((id) => parsedDiff.files.some((file) => file.id === id));684 const validIds = existing.filter((id) => parsedDiff.files.some((file) => file.id === id));
605 if (validIds.length === existing.length) {685 if (validIds.length === existing.length) {
606 return current;686 return existing;
607 }687 }
608 return { ...current, [selectedSectionId]: validIds };688 return validIds;
609 }689 }
610 690
611 return {691 return getDefaultExpandedFileIds(parsedDiff.files);
612 ...current,
613 [selectedSectionId]: getDefaultExpandedFileIds(parsedDiff.files),
614 };
615 });692 });
616 }, [parsedDiff, selectedSectionId]);693 }, [parsedDiff, reviewCache.threadKey, selectedSection?.id]);
617 694
618 useEffect(() => {695 useEffect(() => {
619 if (!selectedSection || expandedFiles.length === 0) {696 if (!reviewCache.threadKey || !selectedSection?.id || parsedDiff.kind !== "files") {
620 return;697 return;
621 }698 }
622 699
623 const filesToHighlight = expandedFiles.filter((file) => {700 updateReviewRevealedLargeFileIds(reviewCache.threadKey, selectedSection.id, (existing) => {
624 const key = `${selectedSection.id}:${selectedTheme}:${file.cacheKey}`;701 if (existing === undefined) {
625 return highlightedFileByKey[key] === undefined;702 return undefined;
626 });703 }
627 if (filesToHighlight.length === 0) {
628 return;
629 }
630 704
631 let cancelled = false;705 const validIds = existing.filter((id) => parsedDiff.files.some((file) => file.id === id));
632 void Promise.all(706 if (validIds.length === existing.length) {
633 filesToHighlight.map(707 return existing;
634 async (file) =>708 }
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 });
654 709
655 return () => {710 return validIds;
656 cancelled = true;711 });
657 };712 }, [parsedDiff, reviewCache.threadKey, selectedSection?.id]);
658 }, [expandedFiles, highlightedFileByKey, selectedSection, selectedTheme]);
659 713
660 const refreshSelectedSection = useCallback(async () => {714 const refreshSelectedSection = useCallback(async () => {
661 if (!selectedSection) {715 if (!selectedSection) {
@@ -673,9 +727,100 @@ export function ReviewSheet() { · 11 unmodified lines
673 await loadGitDiffs();727 await loadGitDiffs();
674 }, [checkpointBySectionId, loadGitDiffs, loadTurnDiff, selectedSection]);728 }, [checkpointBySectionId, loadGitDiffs, loadTurnDiff, selectedSection]);
675 729
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(() => {
677 const children: ReactElement[] = [];823 const children: ReactElement[] = [];
678 const stickyHeaderIndices: number[] = [];
679 824
680 if (error) {825 if (error) {
681 children.push(826 children.push(
@@ -686,185 +831,85 @@ export function ReviewSheet() { · 4 unmodified lines
686 );831 );
687 }832 }
688 833
689 if (parsedDiff.kind !== "empty" && parsedDiff.notice) {834 if (parsedDiffNotice) {
690 children.push(835 children.push(<ReviewNotice key="review-notice" notice={parsedDiffNotice} />);
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 };
715 }836 }
716 837
717 if (selectedSection.isLoading && selectedSection.diff === null) {838 if (children.length === 0) {
718 children.push(839 return null;
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 };
728 }840 }
729 841
730 if (parsedDiff.kind === "empty") {842 return <>{children}</>;
731 children.push(843 }, [error, parsedDiffNotice]);
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 }
741 844
742 if (parsedDiff.kind === "raw") {845 const renderFileItem = useCallback(
743 children.push(846 ({ item }: { item: ReviewRenderableFile; index: number }) => {
744 <View key="review-raw" className="gap-3 border-b border-border bg-card px-4 py-4">847 if (!selectedSection) {
745 <Text className="text-[12px] leading-[18px] text-foreground-muted">848 return null;
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 );
851 }849 }
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 );
853 894
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 };
855 }, [905 }, [
856 activeCommentTarget,906 activeCommentTarget,
857 error,
858 expandedFileIds,907 expandedFileIds,
859 highlightedFileByKey,
860 pendingCommentSelection,908 pendingCommentSelection,
861 parsedDiff,909 revealedLargeFileIds,
862 router,
863 selectedSection,910 selectedSection,
864 selectedTheme,911 selectedTheme,
865 threadId,
866 width,912 width,
867 environmentId,
868 ]);913 ]);
869 914
870 return (915 return (
@@ -891,7 +936,15 @@ export function ReviewSheet() { · 20 unmodified lines
891 >936 >
892 Files Changed937 Files Changed
893 </NativeText>938 </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 >
895 {headerDiffSummary.additions && headerDiffSummary.deletions ? (948 {headerDiffSummary.additions && headerDiffSummary.deletions ? (
896 <>949 <>
897 <NativeText950 <NativeText
@@ -914,19 +967,45 @@ export function ReviewSheet() { · 16 unmodified lines
914 >967 >
915 {headerDiffSummary.deletions}968 {headerDiffSummary.deletions}
916 </NativeText>969 </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}
917 </>982 </>
918 ) : (983 ) : (
919 <NativeText984 <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
920 numberOfLines={1}985 <NativeText
921 style={{986 numberOfLines={1}
922 fontFamily: "DMSans_700Bold",987 style={{
923 fontSize: 12,988 fontFamily: "DMSans_700Bold",
924 fontWeight: "700",989 fontSize: 12,
925 color: headerMuted,990 fontWeight: "700",
926 }}991 color: headerMuted,
927 >992 }}
928 {selectedSection?.title ?? "Review changes"}993 >
929 </NativeText>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>
930 )}1009 )}
931 </View>1010 </View>
932 </View>1011 </View>
@@ -940,7 +1019,11 @@ export function ReviewSheet() { · 7 unmodified lines
940 <Stack.Toolbar.MenuAction1019 <Stack.Toolbar.MenuAction
941 key={section.id}1020 key={section.id}
942 icon={section.id === selectedSection?.id ? "checkmark" : "circle"}1021 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 }}
944 subtitle={section.subtitle ?? undefined}1027 subtitle={section.subtitle ?? undefined}
945 >1028 >
946 <Stack.Toolbar.Label>{section.title}</Stack.Toolbar.Label>1029 <Stack.Toolbar.Label>{section.title}</Stack.Toolbar.Label>
@@ -961,22 +1044,73 @@ export function ReviewSheet() { · 14 unmodified lines
961 </Stack.Toolbar>1044 </Stack.Toolbar>
962 1045
963 <View className="flex-1 bg-sheet">1046 <View className="flex-1 bg-sheet">
964 <ScrollView1047 {selectedSection && parsedDiff.kind === "files" ? (
965 contentInsetAdjustmentBehavior="never"1048 <LegendList
966 contentInset={{ top: topContentInset }}1049 style={{ flex: 1 }}
967 contentOffset={{ x: 0, y: -topContentInset }}1050 contentInsetAdjustmentBehavior="never"
968 scrollIndicatorInsets={{ top: topContentInset }}1051 contentInset={{ top: topContentInset }}
969 showsVerticalScrollIndicator={false}1052 scrollIndicatorInsets={{ top: topContentInset }}
970 style={{ flex: 1 }}1053 data={parsedDiff.files as ReviewRenderableFile[]}
971 stickyHeaderIndices={reviewContent.stickyHeaderIndices}1054 renderItem={renderFileItem}
972 stickyHeaderHiddenOnScroll={false}1055 keyExtractor={(file) => file.id}
973 contentContainerStyle={{1056 extraData={visibleListExtraData}
974 paddingTop: REVIEW_HEADER_SPACING,1057 keyboardShouldPersistTaps="handled"
975 paddingBottom: Math.max(insets.bottom, 18) + 18,1058 estimatedItemSize={220}
976 }}1059 drawDistance={900}
977 >1060 recycleItems
978 {reviewContent.children}1061 ListHeaderComponent={listHeader}
979 </ScrollView>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 )}
980 1114
981 <ReviewSelectionActionBar1115 <ReviewSelectionActionBar
982 target={activeCommentTarget}1116 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
152 "</review_comment>",152 "</review_comment>",
153 ].join("\n");153 ].join("\n");
154}154}
155
156export 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 @@
1import { Platform, Text as NativeText } from "react-native";
2
3import { cn } from "../../../lib/cn";
4
5import type { ReviewRenderableLineRow } from "./reviewModel";
6import type { ReviewHighlightedToken } from "./shikiReviewHighlighter";
7
8export const REVIEW_MONO_FONT_FAMILY = Platform.select({
9 ios: "ui-monospace",
10 android: "monospace",
11 default: "monospace",
12});
13
14export function renderVisibleWhitespace(value: string): string {
15 const expandedTabs = value.replace(/\t/g, " ");
16 return expandedTabs.replace(/^( +)/, (leading) => leading.replaceAll(" ", "\u00A0"));
17}
18
19export 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
25export 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 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 hasTurboModuleProxy,
5 resolveReviewHighlighterEngine,
6 resolveReviewHighlighterEnginePreference,
7} from "./reviewHighlighterEngine";
8
9describe("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
22describe("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
39describe("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 @@
1export type ReviewHighlighterEnginePreference = "auto" | "javascript" | "native";
2export type ReviewHighlighterEngine = "javascript" | "native";
3
4export 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
17export 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
32export 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
11 buildReviewParsedDiff,11 buildReviewParsedDiff,
12 buildReviewSectionItems,12 buildReviewSectionItems,
13 getDefaultReviewSectionId,13 getDefaultReviewSectionId,
14 getReviewFilePreviewState,
14 getReviewSectionIdForCheckpoint,15 getReviewSectionIdForCheckpoint,
16 type ReviewRenderableFile,
15} from "./reviewModel";17} from "./reviewModel";
16 18
17function makeCheckpoint(19function makeCheckpoint(
@@ -27,6 +29,24 @@ function makeCheckpoint( · 9 unmodified lines
27 };29 };
28}30}
29 31
32function 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
30describe("buildReviewSectionItems", () => {50describe("buildReviewSectionItems", () => {
31 it("keeps one chip per checkpoint and appends git sources", () => {51 it("keeps one chip per checkpoint and appends git sources", () => {
32 const checkpoints = [52 const checkpoints = [
@@ -171,4 +191,44 @@ describe("buildReviewParsedDiff", () => { · 138 unmodified lines
171 header: "@@ -1,1 +1,2 @@",191 header: "@@ -1,1 +1,2 @@",
172 });192 });
173 });193 });
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 });
174});234});
apps/mobile/src/features/threads/review/reviewModel.ts+99−0
@@ -48,6 +48,18 @@ export interface ReviewRenderableFile { · 47 unmodified lines
48 readonly rows: ReadonlyArray<ReviewRenderableRow>;48 readonly rows: ReadonlyArray<ReviewRenderableRow>;
49}49}
50 50
51export 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
51export type ReviewParsedDiff =63export type ReviewParsedDiff =
52 | {64 | {
53 readonly kind: "empty";65 readonly kind: "empty";
@@ -147,6 +159,50 @@ const FNV_OFFSET_BASIS_32 = 0x811c9dc5; · 93 unmodified lines
147const FNV_PRIME_32 = 0x01000193;159const FNV_PRIME_32 = 0x01000193;
148const SECONDARY_HASH_SEED = 0x9e3779b9;160const SECONDARY_HASH_SEED = 0x9e3779b9;
149const SECONDARY_HASH_MULTIPLIER = 0x85ebca6b;161const SECONDARY_HASH_MULTIPLIER = 0x85ebca6b;
162const LARGE_DIFF_LINE_THRESHOLD = 400;
163const LARGE_DIFF_CHARACTER_THRESHOLD = 24_000;
164const 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]);
150 206
151function fnv1a32(input: string, seed: number, multiplier: number): number {207function fnv1a32(input: string, seed: number, multiplier: number): number {
152 let hash = seed >>> 0;208 let hash = seed >>> 0;
@@ -168,6 +224,49 @@ function buildPatchCacheKey(patch: string, scope: string): string { · 15 unmodified lines
168 return `${scope}:${normalizedPatch.length}:${primary}:${secondary}`;224 return `${scope}:${normalizedPatch.length}:${primary}:${secondary}`;
169}225}
170 226
227function getFileExtension(path: string): string | null {
228 const match = /\.([a-z0-9]+)$/i.exec(path);
229 return match?.[1]?.toLowerCase() ?? null;
230}
231
232function countReviewRenderableLineRows(file: ReviewRenderableFile): number {
233 return file.rows.reduce((total, row) => total + (row.kind === "line" ? 1 : 0), 0);
234}
235
236function 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
243export 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
171function fallbackHunkHeader(hunk: FileDiffMetadata["hunks"][number]): string {270function fallbackHunkHeader(hunk: FileDiffMetadata["hunks"][number]): string {
172 return `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`;271 return `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`;
173}272}
apps/mobile/src/features/threads/review/reviewState.ts+195−0
@@ -0,0 +1,195 @@
1import { useAtomValue } from "@effect/atom-react";
2
3import { EnvironmentId, ThreadId, type GitReviewDiffSection } from "@t3tools/contracts";
4import { Atom } from "effect/unstable/reactivity";
5
6import { scopedThreadKey } from "../../../lib/scopedEntities";
7import { appAtomRegistry } from "../../../state/atom-registry";
8import { buildReviewParsedDiff, type ReviewParsedDiff } from "./reviewModel";
9
10const EMPTY_GIT_REVIEW_SECTIONS = Object.freeze<ReadonlyArray<GitReviewDiffSection>>([]);
11const EMPTY_REVIEW_TURN_DIFFS = Object.freeze<Readonly<Record<string, string>>>({});
12const EMPTY_REVIEW_SECTION_FILE_IDS = Object.freeze<
13 Readonly<Record<string, ReadonlyArray<string> | undefined>>
14>({});
15const EMPTY_REVIEW_GIT_SECTIONS_ATOM = Atom.make(EMPTY_GIT_REVIEW_SECTIONS).pipe(
16 Atom.keepAlive,
17 Atom.withLabel("mobile:review:git-sections:null"),
18);
19const EMPTY_REVIEW_TURN_DIFFS_ATOM = Atom.make(EMPTY_REVIEW_TURN_DIFFS).pipe(
20 Atom.keepAlive,
21 Atom.withLabel("mobile:review:turn-diffs:null"),
22);
23const 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);
27const 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
32const 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
39const 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
46const 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
53const 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
60const 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
67const 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
74function 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
83function buildSectionCacheKey(threadKey: string, sectionId: string): string {
84 return `${threadKey}:${sectionId}`;
85}
86
87export 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
124export function setReviewGitSections(
125 threadKey: string,
126 sections: ReadonlyArray<GitReviewDiffSection>,
127): void {
128 appAtomRegistry.set(reviewGitSectionsByThreadKeyAtom(threadKey), sections);
129}
130
131export 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
140export function setReviewSelectedSectionId(threadKey: string, sectionId: string | null): void {
141 appAtomRegistry.set(reviewSelectedSectionIdByThreadKeyAtom(threadKey), sectionId);
142}
143
144export 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
158export 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
172export 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 @@
1import { describe, expect, it } from "vitest";
2
3import type { ReviewRenderableFile } from "./reviewModel";
4import { highlightReviewFile } from "./shikiReviewHighlighter";
5
6function 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
24describe("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 @@
1import { getFiletypeFromFileName } from "@pierre/diffs/utils";1import { getFiletypeFromFileName } from "@pierre/diffs/utils";
2 2
3import type { ReviewRenderableFile } from "./reviewModel";3import {
4 hasTurboModuleProxy,
5 resolveReviewHighlighterEngine,
6 resolveReviewHighlighterEnginePreference,
7 type ReviewHighlighterEngine,
8} from "./reviewHighlighterEngine";
9import type { ReviewRenderableFile, ReviewRenderableLineRow } from "./reviewModel";
4 10
5export type ReviewDiffTheme = "light" | "dark";11export type ReviewDiffTheme = "light" | "dark";
6 12
@@ -19,8 +25,12 @@ const SHIKI_THEME_BY_SCHEME = { · 12 unmodified lines
19 light: "github-light-default",25 light: "github-light-default",
20 dark: "github-dark-default",26 dark: "github-dark-default",
21} as const;27} as const;
28const REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE = resolveReviewHighlighterEnginePreference(
29 process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE,
30);
22 31
23const highlightCache = new Map<string, Promise<ReviewHighlightedFile>>();32const highlightCache = new Map<string, Promise<ReviewHighlightedFile>>();
33const resolvedHighlightCache = new Map<string, ReviewHighlightedFile>();
24const loadedLanguages = new Set<string>(["text"]);34const loadedLanguages = new Set<string>(["text"]);
25type ShikiHighlighter = {35type ShikiHighlighter = {
26 loadLanguage: (...langs: string[]) => Promise<void>;36 loadLanguage: (...langs: string[]) => Promise<void>;
@@ -30,38 +40,83 @@ type ShikiHighlighter = { · 3 unmodified lines
30 ) => Promise<Array<Array<{ content: string; color?: string; fontStyle?: number }>>>;40 ) => Promise<Array<Array<{ content: string; color?: string; fontStyle?: number }>>>;
31};41};
32let highlighterPromise: Promise<ShikiHighlighter> | null = null;42let highlighterPromise: Promise<ShikiHighlighter> | null = null;
43let activeHighlighterEnginePromise: Promise<ReviewHighlighterEngine> | null = null;
33 44
34function cleanLastNewline(value: string): string {45function stripTrailingNewline(value: string): string {
35 return value.endsWith("\n") ? value.slice(0, -1) : value;46 return value.endsWith("\n") ? value.slice(0, -1) : value;
36}47}
37 48
38function joinPatchLines(lines: ReadonlyArray<string>): string {49function joinPatchLines(lines: ReadonlyArray<string>): string {
39 if (lines.length === 0) {50 return lines.map(stripTrailingNewline).join("\n");
40 return "";
41 }
42
43 return cleanLastNewline(lines.join(""));
44}51}
45 52
46async function getHighlighter() {53async function getHighlighter() {
47 if (!highlighterPromise) {54 if (!highlighterPromise) {
48 highlighterPromise = import("shiki").then(55 const configuredHighlighterPromise = import("shiki").then(async (shikiModule) => {
49 async ({ createHighlighter, createJavaScriptRegexEngine }) => {56 const nativeRuntimeAvailable = hasTurboModuleProxy();
50 const highlighter = await createHighlighter({57 let nativeEngineAvailable = false;
51 themes: [SHIKI_THEME_BY_SCHEME.light, SHIKI_THEME_BY_SCHEME.dark],58
52 langs: [],59 if (nativeRuntimeAvailable && REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE !== "javascript") {
53 engine: createJavaScriptRegexEngine(),60 try {
54 });61 const nativeEngineModule = await import("react-native-shiki-engine");
55 return highlighter as unknown as ShikiHighlighter;62 nativeEngineAvailable = nativeEngineModule.isNativeEngineAvailable();
56 },63 if (nativeEngineAvailable) {
57 );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);
58 }104 }
59 105
60 return highlighterPromise as Promise<ShikiHighlighter>;106 return highlighterPromise as Promise<ShikiHighlighter>;
61}107}
62 108
63async function resolveLanguage(file: ReviewRenderableFile): Promise<string> {109export async function getActiveReviewHighlighterEngine(): Promise<ReviewHighlighterEngine> {
64 const candidate = file.languageHint ?? getFiletypeFromFileName(file.path);110 await getHighlighter();
111 return (activeHighlighterEnginePromise ??
112 Promise.resolve("javascript")) as Promise<ReviewHighlighterEngine>;
113}
114
115async function resolveLanguageFromPath(
116 path: string,
117 languageHint: string | null = null,
118): Promise<string> {
119 const candidate = languageHint ?? getFiletypeFromFileName(path);
65 if (!candidate || candidate === "text" || candidate === "ansi") {120 if (!candidate || candidate === "text" || candidate === "ansi") {
66 return "text";121 return "text";
67 }122 }
@@ -79,6 +134,10 @@ async function resolveLanguage(file: ReviewRenderableFile): Promise<string> { · 11 unmodified lines
79 return candidate;134 return candidate;
80}135}
81 136
137async function resolveLanguage(file: ReviewRenderableFile): Promise<string> {
138 return resolveLanguageFromPath(file.path, file.languageHint);
139}
140
82function normalizeHighlightedLines(141function normalizeHighlightedLines(
83 tokenLines: ReadonlyArray<ReadonlyArray<{ content: string; color?: string; fontStyle?: number }>>,142 tokenLines: ReadonlyArray<ReadonlyArray<{ content: string; color?: string; fontStyle?: number }>>,
84): ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>> {143): ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>> {
@@ -105,12 +164,27 @@ async function highlightLines( · 20 unmodified lines
105 return normalizeHighlightedLines(tokenLines);164 return normalizeHighlightedLines(tokenLines);
106}165}
107 166
167function getHighlightCacheKey(file: ReviewRenderableFile, theme: ReviewDiffTheme): string {
168 return `${SHIKI_THEME_BY_SCHEME[theme]}:${file.cacheKey}`;
169}
170
171export function getCachedHighlightedReviewFile(
172 file: ReviewRenderableFile,
173 theme: ReviewDiffTheme,
174): ReviewHighlightedFile | null {
175 return resolvedHighlightCache.get(getHighlightCacheKey(file, theme)) ?? null;
176}
177
108export async function highlightReviewFile(178export async function highlightReviewFile(
109 file: ReviewRenderableFile,179 file: ReviewRenderableFile,
110 theme: ReviewDiffTheme,180 theme: ReviewDiffTheme,
111): Promise<ReviewHighlightedFile> {181): Promise<ReviewHighlightedFile> {
112 const shikiTheme = SHIKI_THEME_BY_SCHEME[theme];182 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 }
114 const cached = highlightCache.get(cacheKey);188 const cached = highlightCache.get(cacheKey);
115 if (cached) {189 if (cached) {
116 return cached;190 return cached;
@@ -123,9 +197,52 @@ export async function highlightReviewFile( · 6 unmodified lines
123 highlightLines(joinPatchLines(file.deletionLines), language, shikiTheme),197 highlightLines(joinPatchLines(file.deletionLines), language, shikiTheme),
124 ]);198 ]);
125 199
126 return { additionLines, deletionLines };200 const highlighted = { additionLines, deletionLines };
201 resolvedHighlightCache.set(cacheKey, highlighted);
202 return highlighted;
127 })();203 })();
128 204
129 highlightCache.set(cacheKey, promise);205 highlightCache.set(cacheKey, promise);
130 return promise;206 return promise;
131}207}
208
209export 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
93 readonly environmentId: string;93 readonly environmentId: string;
94 readonly threadId: string;94 readonly threadId: string;
95 readonly text: string;95 readonly text: string;
96 readonly attachments?: ReadonlyArray<DraftComposerImageAttachment>;
96}): void {97}): void {
97 const threadKey = scopedThreadKey(98 const threadKey = scopedThreadKey(
98 EnvironmentId.make(input.environmentId),99 EnvironmentId.make(input.environmentId),
@@ -105,6 +106,26 @@ export function appendReviewCommentToDraft(input: { · 6 unmodified lines
105 ...current,106 ...current,
106 [threadKey]: `${existing}${separator}${input.text}`,107 [threadKey]: `${existing}${separator}${input.text}`,
107 });108 });
109 if (input.attachments && input.attachments.length > 0) {
110 appendDraftAttachments(threadKey, input.attachments);
111 }
112}
113
114export 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 };
108}129}
109 130
110function clearDraft(threadKey: string): void {131function clearDraft(threadKey: string): void {
bun.lock+18−5
@@ -85,6 +85,7 @@ · 84 unmodified lines
85 "react-native-reanimated": "4.2.1",85 "react-native-reanimated": "4.2.1",
86 "react-native-safe-area-context": "~5.6.2",86 "react-native-safe-area-context": "~5.6.2",
87 "react-native-screens": "~4.23.0",87 "react-native-screens": "~4.23.0",
88 "react-native-shiki-engine": "^0.3.9",
88 "react-native-svg": "15.15.3",89 "react-native-svg": "15.15.3",
89 "react-native-worklets": "0.7.2",90 "react-native-worklets": "0.7.2",
90 "shiki": "^4.0.2",91 "shiki": "^4.0.2",
@@ -1071,7 +1072,7 @@ · 980 unmodified lines
1071 1072
1072 "@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="],1073 "@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="],
1073 1074
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=="],
1075 1076
1076 "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],1077 "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
1077 1078
@@ -2453,6 +2454,8 @@ · 1375 unmodified lines
2453 2454
2454 "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=="],2455 "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=="],
2455 2456
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
2456 "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=="],2459 "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=="],
2457 2460
2458 "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=="],2461 "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
3023 3026
3024 "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],3027 "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
3025 3028
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=="],
3027 3036
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=="],
3029 3042
3030 "@tailwindcss/node/tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],3043 "@tailwindcss/node/tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
3031 3044
@@ -3205,6 +3218,8 @@ · 173 unmodified lines
3205 3218
3206 "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],3219 "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
3207 3220
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
3208 "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="],3223 "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="],
3209 3224
3210 "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],3225 "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -3275,8 +3290,6 @@ · 64 unmodified lines
3275 3290
3276 "@pierre/diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="],3291 "@pierre/diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="],
3277 3292
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
3280 "@react-native/babel-preset/babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="],3293 "@react-native/babel-preset/babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="],
3281 3294
3282 "@react-native/codegen/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],3295 "@react-native/codegen/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],