authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-11 12:13:41-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 12:13:41-07:00
logbed923986fbcbe8279f23b9367efde1596bde1f9
tree73d3b93376b48a2861ad36bac945dff203f8c2b0
parent5495cc4445915c3bf4aa92bb57213f1c7cc220ef
signature Signed by PGP key B5690EEEBB952194

feat(mobile): guided reviews for pull requests (#18267)


44 files changed, 2097 insertions(+), 710 deletions(-)

apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt+3
......@@ -41,6 +41,9 @@ class T3ReviewDiffModule : Module() {
4141 Prop("contentWidth") { view: T3ReviewDiffView, contentWidth: Double ->
4242 view.setContentWidth(contentWidth.toFloat())
4343 }
44 Prop("scrollEnabled") { view: T3ReviewDiffView, enabled: Boolean ->
45 view.setScrollEnabled(enabled)
46 }
4447 Prop("initialRowIndex") { view: T3ReviewDiffView, initialRowIndex: Double ->
4548 view.setInitialRowIndex(initialRowIndex)
4649 }
apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt+23-10
......@@ -50,12 +50,21 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
5050 private val verticalScroller = OverScroller(context)
5151 private val horizontalScroller = OverScroller(context)
5252 private var dragAxis: DragAxis? = null
53 private var scrollEnabled = true
5354 private var horizontalDragTarget: HorizontalPanTarget? = null
5455 private var lastTouchX = 0f
5556 private var lastTouchY = 0f
5657 private var velocityTracker: VelocityTracker? = null
5758
5859 init {
60 canvasView.onContentHeightChanged = { height ->
61 onDebug(
62 mapOf(
63 "message" to "metrics",
64 "contentHeight" to height / resources.displayMetrics.density
65 )
66 )
67 }
5968 canvasView.onRowTap = { row, gesture, target -> handleRowTap(row, gesture, target) }
6069 canvasView.onVisibleRowsChanged = { first, last ->
6170 onDebug(
......@@ -198,6 +207,14 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
198207 scrollToY(canvasView.rowTop(index), animated)
199208 }
200209
210 fun setScrollEnabled(enabled: Boolean) {
211 scrollEnabled = enabled
212 if (!enabled) {
213 verticalScroller.forceFinished(true)
214 canvasView.setVerticalOffset(0)
215 }
216 }
217
201218 fun scrollToTop(animated: Boolean) {
202219 scrollToY(0, animated)
203220 }
......@@ -212,7 +229,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
212229 horizontalDragTarget = canvasView.horizontalPanTarget(event.y)
213230 lastTouchX = event.x
214231 lastTouchY = event.y
215 parent?.requestDisallowInterceptTouchEvent(true)
232 parent?.requestDisallowInterceptTouchEvent(scrollEnabled)
216233 return false
217234 }
218235 MotionEvent.ACTION_MOVE -> {
......@@ -221,7 +238,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
221238 val deltaY = event.y - lastTouchY
222239 if (max(abs(deltaX), abs(deltaY)) > touchSlop) {
223240 dragAxis = if (abs(deltaY) >= abs(deltaX)) {
224 DragAxis.VERTICAL
241 if (scrollEnabled) DragAxis.VERTICAL else null
225242 } else {
226243 horizontalDragTarget
227244 ?.takeIf { canvasView.maxHorizontalOffset(it) > 0 }
......@@ -229,6 +246,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
229246 }
230247 }
231248 }
249 if (dragAxis == DragAxis.HORIZONTAL) parent?.requestDisallowInterceptTouchEvent(true)
232250 return dragAxis != null
233251 }
234252 MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> return dragAxis != null
......@@ -751,6 +769,7 @@ private class DiffCanvasView(context: Context) : View(context) {
751769 }
752770 var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null
753771 var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null
772 var onContentHeightChanged: ((Int) -> Unit)? = null
754773
755774 fun prepareRows() = drawing.prepareRows(tokensByRowId, style, width)
756775 fun useCodeLayouts(layouts: CodeLayoutCache) {
......@@ -876,6 +895,7 @@ private class DiffCanvasView(context: Context) : View(context) {
876895 setVerticalOffset(verticalOffset)
877896 setHorizontalOffset(horizontalOffset)
878897 clampHeaderPathOffsets()
898 onContentHeightChanged?.invoke(rowOffsets.last())
879899 }
880900
881901 private fun rowHeight(row: DiffRow): Int = when (row.kind) {
......@@ -1198,14 +1218,7 @@ private class DiffCanvasView(context: Context) : View(context) {
11981218 }
11991219 val selected = selectedRowIds.contains(row.id)
12001220 if (selected) {
1201 fill(
1202 canvas,
1203 withAlpha(theme.hunkText, 56),
1204 0f,
1205 top.toFloat(),
1206 width.toFloat(),
1207 bottom.toFloat(),
1208 )
1221 // Keep the diff and syntax colors intact; selection lives in the gutter.
12091222 fill(
12101223 canvas,
12111224 withAlpha(theme.hunkText, 242),
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift+12
......@@ -61,6 +61,18 @@ public class T3ReviewDiffModule: Module {
6161 view.setInitialRowIndex(initialRowIndex)
6262 }
6363
64 Prop("scrollEnabled") { (view: T3ReviewDiffView, enabled: Bool) in
65 view.setScrollEnabled(enabled)
66 }
67
68 Prop("bounces") { (view: T3ReviewDiffView, bounces: Bool) in
69 view.setBounces(bounces)
70 }
71
72 Prop("refreshEnabled") { (view: T3ReviewDiffView, enabled: Bool) in
73 view.setRefreshEnabled(enabled)
74 }
75
6476 Prop("refreshing") { (view: T3ReviewDiffView, refreshing: Bool) in
6577 view.setRefreshing(refreshing)
6678 }
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift+17-7
......@@ -365,7 +365,6 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
365365 scrollView.showsHorizontalScrollIndicator = false
366366 scrollView.backgroundColor = contentView.theme.background
367367 pullRefreshControl.addTarget(self, action: #selector(handlePullToRefresh), for: .valueChanged)
368 scrollView.refreshControl = pullRefreshControl
369368 addSubview(scrollView)
370369
371370 contentView.backgroundColor = contentView.theme.background
......@@ -701,14 +700,14 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
701700 applyPendingScrollIfNeeded()
702701 emitVisibleFileIfNeeded()
703702
704 let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(height))"
703 let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(contentView.contentHeight))"
705704 if debugKey != lastMetricsDebugKey {
706705 lastMetricsDebugKey = debugKey
707706 emitDebug("metrics", [
708707 "rows": rows.count,
709708 "boundsWidth": Double(bounds.width),
710709 "boundsHeight": Double(bounds.height),
711 "contentHeight": Double(height),
710 "contentHeight": Double(contentView.contentHeight),
712711 "contentWidth": Double(style.contentWidth),
713712 "fileHeaderHeight": Double(style.fileHeaderHeight),
714713 "rowHeight": Double(style.rowHeight),
......@@ -840,6 +839,20 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
840839 setVerticalContentOffset(0, animated: animated)
841840 }
842841
842 func setScrollEnabled(_ enabled: Bool) {
843 scrollView.isScrollEnabled = enabled
844 }
845
846 func setBounces(_ bounces: Bool) {
847 scrollView.bounces = bounces
848 scrollView.alwaysBounceVertical = bounces
849 }
850
851 func setRefreshEnabled(_ enabled: Bool) {
852 if !enabled { pullRefreshControl.endRefreshing() }
853 scrollView.refreshControl = enabled ? pullRefreshControl : nil
854 }
855
843856 @objc private func handlePullToRefresh() {
844857 onPullToRefresh([:])
845858 }
......@@ -2429,10 +2442,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
24292442 return
24302443 }
24312444
2432 let selectionColor = theme.hunkText.withAlphaComponent(0.22)
2433 selectionColor.setFill()
2434 context.fill(rect)
2435
2445 // Keep the diff and syntax colors intact; selection lives in the gutter.
24362446 theme.hunkText.withAlphaComponent(0.95).setFill()
24372447 context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height))
24382448 }
apps/mobile/src/Stack.tsx+5
......@@ -1,3 +1,4 @@
1import { PullRequestGuideStopScreen } from "./features/pull-requests/guide/PullRequestGuideScreen";
12import { PullRequestInlineCommentScreen } from "./features/pull-requests/PullRequestInlineCommentScreen";
23import { PullRequestDiscussionScreen } from "./features/pull-requests/PullRequestDiscussionScreen";
34import {
......@@ -722,6 +723,10 @@ const RootStackConfig = createWorkspaceStackNavigator({
722723 },
723724 options: GLASS_HEADER_OPTIONS,
724725 }),
726 PullRequestGuideStop: createNativeStackScreen({
727 screen: PullRequestGuideStopScreen,
728 options: GLASS_HEADER_OPTIONS,
729 }),
725730 PullRequestFile: createNativeStackScreen({
726731 screen: PullRequestFileRouteScreen,
727732 options: GLASS_HEADER_OPTIONS,
apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts+9-1
......@@ -124,6 +124,9 @@ export interface NativeReviewDiffViewProps extends ViewProps {
124124 readonly rowHeight: number;
125125 readonly contentWidth: number;
126126 readonly initialRowIndex?: number;
127 /** Disable the native vertical scroller when an excerpt grows inside a page. */
128 readonly scrollEnabled?: boolean;
129 readonly bounces?: boolean;
127130 readonly refreshing?: boolean;
128131 readonly nativeViewRef?: Ref<NativeReviewDiffViewHandle>;
129132 readonly onPullToRefresh?: (event: NativeSyntheticEvent<Record<string, never>>) => void;
......@@ -167,6 +170,7 @@ type NativeReviewDiffRawViewProps = Omit<
167170 NativeReviewDiffViewProps,
168171 "nativeViewRef" | "rowsJson" | "tokensJson" | "tokensPatchJson"
169172> & {
173 readonly refreshEnabled: boolean;
170174 readonly ref?: Ref<NativeReviewDiffViewRef>;
171175};
172176
......@@ -270,7 +274,11 @@ function NativeReviewDiffView(props: NativeReviewDiffViewProps) {
270274 return null;
271275 }
272276
273 return createElement(RawNativeView, { ...nativeProps, ref: nativeRef });
277 return createElement(RawNativeView, {
278 ...nativeProps,
279 refreshEnabled: props.onPullToRefresh !== undefined,
280 ref: nativeRef,
281 });
274282}
275283
276284export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffViewProps> | null {
apps/mobile/src/features/pull-requests/PullRequestActivityFilters.tsx created+91
......@@ -0,0 +1,91 @@
1import { Pressable, View } from "react-native";
2import { AppText as Text } from "../../components/AppText";
3import { SymbolView } from "../../components/AppSymbol";
4import { ControlPillMenu } from "../../components/ControlPillMenu";
5import type { ActivityFilter } from "./pullRequestActivityFilter";
6
7export function PullRequestActivityFilters({
8 value,
9 onChange,
10 hiddenCount,
11}: {
12 readonly value: ActivityFilter;
13 readonly onChange: (value: ActivityFilter) => void;
14 readonly hiddenCount: number;
15}) {
16 const kinds = [
17 { id: "all", title: "All activity" },
18 { id: "comments", title: "Comments" },
19 { id: "commits", title: "Commits" },
20 ] as const;
21 return (
22 <View className="mb-4 flex-row items-center justify-between gap-3">
23 <ControlPillMenu
24 title="Activity"
25 actions={kinds.map((kind) => ({ ...kind, state: value.kind === kind.id ? "on" : "off" }))}
26 onPressAction={({ nativeEvent }) => {
27 const kind = kinds.find((item) => item.id === nativeEvent.event);
28 if (kind) onChange({ ...value, kind: kind.id });
29 }}
30 >
31 <Pressable
32 accessibilityRole="button"
33 accessibilityLabel={`Activity type, ${kinds.find((kind) => kind.id === value.kind)?.title}`}
34 className="min-h-11 flex-row items-center gap-2 active:opacity-60"
35 >
36 <Text className="text-sm font-t3-semibold">
37 {kinds.find((kind) => kind.id === value.kind)?.title}
38 </Text>
39 <SymbolView name="chevron.down" size={11} tintColorClassName="accent-icon-muted" />
40 </Pressable>
41 </ControlPillMenu>
42 <View className="flex-row items-center gap-3">
43 <ControlPillMenu
44 title="Show in activity"
45 actions={[
46 { id: "bots", title: "Bot comments", state: value.bots ? "on" : "off" },
47 {
48 id: "resolved",
49 title: "Resolved or dismissed",
50 state: value.resolved ? "on" : "off",
51 },
52 ]}
53 onPressAction={({ nativeEvent: { event } }) => {
54 if (event === "bots" || event === "resolved")
55 onChange({ ...value, [event]: !value[event] });
56 }}
57 >
58 <Pressable
59 accessibilityRole="button"
60 accessibilityLabel={`Filter activity, ${hiddenCount} hidden`}
61 className="min-h-11 flex-row items-center gap-1.5 active:opacity-60"
62 >
63 {hiddenCount > 0 ? (
64 <Text className="text-xs text-foreground-muted">{hiddenCount} hidden</Text>
65 ) : null}
66 <SymbolView
67 name="line.3.horizontal.decrease"
68 size={18}
69 tintColorClassName="accent-icon-muted"
70 />
71 </Pressable>
72 </ControlPillMenu>
73 <Pressable
74 accessibilityRole="button"
75 accessibilityLabel={value.newestFirst ? "Newest first" : "Oldest first"}
76 onPress={() => onChange({ ...value, newestFirst: !value.newestFirst })}
77 className="h-11 flex-row items-center gap-1 active:opacity-60"
78 >
79 <SymbolView
80 name={value.newestFirst ? "arrow.down" : "arrow.up"}
81 size={18}
82 tintColorClassName="accent-icon-muted"
83 />
84 <Text className="text-xs text-foreground-muted">
85 {value.newestFirst ? "Newest" : "Oldest"}
86 </Text>
87 </Pressable>
88 </View>
89 </View>
90 );
91}
apps/mobile/src/features/pull-requests/PullRequestControls.tsx+7-2
......@@ -1,3 +1,5 @@
1import Animated from "react-native-reanimated";
2import { PR_ROW_LAYOUT } from "./pullRequestAnimations";
13import { useNavigation } from "@react-navigation/native";
24import type { PullRequestDetail } from "@t3tools/contracts";
35import type { ReactNode } from "react";
......@@ -21,9 +23,12 @@ import { usePullRequestAgentActions } from "./usePullRequestAgentActions";
2123
2224export function PullRequestCardGroup(props: { readonly children: ReactNode }) {
2325 return (
24 <View className="overflow-hidden rounded-[22px] border-continuous bg-grouped-card">
26 <Animated.View
27 layout={PR_ROW_LAYOUT}
28 className="overflow-hidden rounded-[22px] border-continuous bg-grouped-card"
29 >
2530 {props.children}
26 </View>
31 </Animated.View>
2732 );
2833}
2934
apps/mobile/src/features/pull-requests/PullRequestFiles.tsx+22-9
......@@ -1,4 +1,6 @@
1import { LegendList } from "@legendapp/list/react-native";
1import { AnimatedLegendList } from "@legendapp/list/reanimated";
2import Animated from "react-native-reanimated";
3import { PR_ROW_LAYOUT, PR_ROW_ENTER, PR_ROW_EXIT } from "./pullRequestAnimations";
24import { useNavigation } from "@react-navigation/native";
35import { useState } from "react";
46import { ActivityIndicator, Pressable, View } from "react-native";
......@@ -192,7 +194,7 @@ export function usePullRequestFilesContent(props: {
192194 </View>
193195 ) : parsed.kind === "files" ? (
194196 <>
195 <View className="flex-row items-center gap-2 py-3">
197 <View className="min-h-17 flex-row items-center gap-2 py-3">
196198 <View className="min-w-0 flex-1 gap-1">
197199 <Text className="text-base font-t3-semibold">
198200 {parsed.fileCount} {filter.hiddenCount ? "visible" : "changed"}{" "}
......@@ -201,11 +203,11 @@ export function usePullRequestFilesContent(props: {
201203 ? ` · ${parsed.files.filter((file) => viewed.isViewed(file.path)).length} viewed`
202204 : ""}
203205 </Text>
204 {filter.hideTestFiles ? (
205 <Text className="text-2xs text-foreground-muted">
206 {filter.hiddenCount} test {filter.hiddenCount === 1 ? "file" : "files"} hidden
207 </Text>
208 ) : null}
206 <Text className="text-2xs text-foreground-muted">
207 {filter.hideTestFiles
208 ? `${filter.hiddenCount} test ${filter.hiddenCount === 1 ? "file" : "files"} hidden`
209 : "Test files included"}
210 </Text>
209211 </View>
210212 <PullRequestDiffStats
211213 additions={parsed.additions}
......@@ -285,11 +287,22 @@ export function usePullRequestFilesContent(props: {
285287
286288export function PullRequestFiles(props: Parameters<typeof usePullRequestFilesContent>[0]) {
287289 const content = usePullRequestFilesContent(props);
290 const [animationsReady, setAnimationsReady] = useState(false);
288291 return (
289 <LegendList
292 <AnimatedLegendList
290293 data={content.rows}
291294 keyExtractor={(row) => row.key}
292 renderItem={({ item }) => item.content}
295 itemLayoutAnimation={PR_ROW_LAYOUT}
296 onLoad={() => setAnimationsReady(true)}
297 renderItem={({ item }) => (
298 <Animated.View
299 key={item.key}
300 entering={animationsReady ? PR_ROW_ENTER : undefined}
301 exiting={animationsReady ? PR_ROW_EXIT : undefined}
302 >
303 {item.content}
304 </Animated.View>
305 )}
293306 recycleItems={false}
294307 estimatedItemSize={64}
295308 ListHeaderComponent={content.header}
apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx+315-246
......@@ -1,5 +1,22 @@
1import { PullRequestEntryRow } from "./PullRequestEntryRow";
2import { LegendList, type LegendListRef } from "@legendapp/list/react-native";
1import { pullRequestTabOffset } from "./pullRequestTabScroll";
2import { guideFocusedRows, type GuideDiffFocus } from "./guide/guideDiffFocus";
3import { PullRequestGuideContent } from "./guide/PullRequestGuideScreen";
4import { useReviewGuidesEnabled } from "./guide/useReviewGuide";
5import {
6 filterPullRequestActivity,
7 DEFAULT_ACTIVITY_FILTER,
8 type ActivityFilter,
9} from "./pullRequestActivityFilter";
10import { PullRequestActivityFilters } from "./PullRequestActivityFilters";
11import type { LegendListRef } from "@legendapp/list/react-native";
12import { AnimatedLegendList } from "@legendapp/list/reanimated";
13import Animated from "react-native-reanimated";
14import {
15 PR_ROW_LAYOUT,
16 PR_ROW_ENTER,
17 PR_ROW_EXIT,
18 PR_POSITION_LAYOUT,
19} from "./pullRequestAnimations";
320import { openOnProviderLabel } from "./pullRequestActions";
421import * as Clipboard from "expo-clipboard";
522import { ControlPillMenu } from "../../components/ControlPillMenu";
......@@ -23,7 +40,7 @@ import { pullRequestReviewKey } from "./pullRequestReviewDrafts";
2340import { type PullRequestDetail, type PullRequestListEntry } from "@t3tools/contracts";
2441import { PullRequestOverviewLoading } from "./PullRequestOverviewLoading";
2542import { pullRequestStatus } from "./pullRequestStatus";
26import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react";
43import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2744import {
2845 ActivityIndicator,
2946 Linking,
......@@ -104,76 +121,39 @@ function PullRequestTabScrollView(props: ScrollViewProps) {
104121 );
105122}
106123
107function usePullRequestActivityRows(params: PullRequestParams, commitsOnly: boolean) {
124function usePullRequestActivityRows(params: PullRequestParams, filter: ActivityFilter) {
108125 const navigation = useNavigation();
109126 const result = useEnvironmentQuery(composerPullRequests.activity(requestTarget(params)));
127 const filtered = useMemo(
128 () => (result.data ? filterPullRequestActivity(result.data, filter) : null),
129 [result.data, filter],
130 );
110131 const rows = useMemo(() => {
111 const rows: { key: string; content: ReactElement }[] = [];
112 if (commitsOnly) {
113 for (const commit of result.data?.commits ?? [])
114 rows.push({
115 key: `commit:${commit.oid}`,
116 content: (
117 <Pressable
118 key={commit.oid}
119 accessibilityRole="button"
120 accessibilityLabel={`View changes in commit ${commit.oid.slice(0, 7)}, ${commit.messageHeadline}`}
121 onPress={() =>
122 navigation.dispatch(
123 StackActions.push("PullRequestFile", {
124 ...params,
125 commit: commit.oid,
126 path: "",
127 }),
128 )
129 }
130 className={`border-b border-border-subtle pb-3 active:opacity-70 ${rows.length ? "pt-3" : ""}`}
131 >
132 <PullRequestEntryRow
133 leading={
134 <View className="h-8 items-center justify-center">
135 <PullRequestAvatar actor={commit.authors?.[0] ?? null} size={24} />
136 </View>
137 }
138 >
139 <View className="gap-1">
140 <View className="min-h-8 flex-row items-center gap-2">
141 <Text className="min-w-0 flex-1 text-sm" numberOfLines={1}>
142 {commit.messageHeadline}
143 </Text>
144 <Text className="font-mono text-2xs text-foreground-muted">
145 {commit.oid.slice(0, 7)}
146 </Text>
147 <SymbolView
148 name="chevron.right"
149 size={11}
150 tintColorClassName="accent-icon-muted"
151 />
152 </View>
153 <Text className="text-2xs text-foreground-muted">
154 {commit.authors?.map((author) => author.login).join(", ") || "Commit"} ·{" "}
155 {relativeTime(commit.committedDate)}
156 </Text>
157 </View>
158 </PullRequestEntryRow>
159 </Pressable>
160 ),
161 });
162 } else if (result.data) {
163 rows.push(...pullRequestTimelineRows(result.data, params, navigation));
132 if (!filtered) return [];
133 const rows = pullRequestTimelineRows(filtered.activity, params, navigation);
134 if (filter.newestFirst) {
135 const notice = rows.at(-1)?.key === "truncated" ? rows.pop() : undefined;
136 rows.reverse();
137 if (notice) rows.push(notice);
164138 }
165 if (!rows.length && result.data)
139 if (!rows.length)
166140 rows.push({
167141 key: "empty",
142 date: "",
168143 content: (
169144 <Text className="py-6 text-sm text-foreground-muted">
170 {commitsOnly ? "No commits reported." : "No comments yet."}
145 No activity matches these filters.
171146 </Text>
172147 ),
173148 });
174149 return rows;
175 }, [commitsOnly, navigation, params, result.data]);
176 return { rows, pending: result.isPending, error: result.error };
150 }, [filtered, filter.newestFirst, params, navigation]);
151 return {
152 rows,
153 hiddenCount: filtered?.hiddenCount ?? 0,
154 pending: result.isPending,
155 error: result.error,
156 };
177157}
178158
179159function PullRequestIdentity(props: {
......@@ -257,26 +237,33 @@ function PullRequestOverview(props: {
257237 <PullRequestCardGroup>
258238 <PullRequestChecksRow pr={pr} params={props.params} />
259239 <PullRequestReviewersRow pr={pr} params={props.params} />
260 <PullRequestBranchRow pr={pr} params={props.params} />
261 <PullRequestMergeControls pr={pr} params={props.params} stack={props.stack} />
240 <Animated.View layout={PR_ROW_LAYOUT}>
241 <PullRequestBranchRow pr={pr} params={props.params} />
242 </Animated.View>
243 <Animated.View layout={PR_ROW_LAYOUT}>
244 <PullRequestMergeControls pr={pr} params={props.params} stack={props.stack} />
245 </Animated.View>
262246 </PullRequestCardGroup>
263247 {pr.labels.length ? (
264 <View className="flex-row flex-wrap gap-2">
248 <Animated.View layout={PR_ROW_LAYOUT} className="flex-row flex-wrap gap-2">
265249 {pr.labels.map((label) => (
266250 <View key={label.name} className="rounded-full bg-grouped-card px-3 py-1">
267251 <Text className="text-2xs text-foreground-muted">{label.name}</Text>
268252 </View>
269253 ))}
270 </View>
254 </Animated.View>
271255 ) : null}
272 <View onLayout={(event) => props.onDescriptionLayout(event.nativeEvent.layout.y)}>
256 <Animated.View
257 layout={PR_POSITION_LAYOUT}
258 onLayout={(event) => props.onDescriptionLayout(event.nativeEvent.layout.y)}
259 >
273260 <PullRequestDescription
274261 markdown={pr.body}
275262 onCollapse={props.onDescriptionCollapse}
276263 expanded={props.descriptionExpanded}
277264 onExpandedChange={props.onDescriptionExpandedChange}
278265 />
279 </View>
266 </Animated.View>
280267 </View>
281268 );
282269}
......@@ -285,43 +272,45 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
285272 const navigation = useNavigation();
286273 const insets = useSafeAreaInsets();
287274 const params = route.params;
288 const [section, setSection] = useState<"overview" | "activity" | "commits">("overview");
289 // LegendList commits replacement rows after the header. Keep selection with the visible content.
290 const [displayedSection, setDisplayedSection] = useState(section);
275 const [section, setSection] = useState<"overview" | "activity" | "guide">("overview");
276 // Keep each tab's native list and measured rows alive when it is hidden.
291277 const requestedSection = useRef(section);
278 const [listAnimationsReady, setListAnimationsReady] = useState(false);
292279 const [descriptionExpanded, setDescriptionExpanded] = useState(false);
293280 const filesContent = usePullRequestFilesContent({ params });
294 const activityContent = usePullRequestActivityRows(params, false);
295 const commitsContent = usePullRequestActivityRows(params, true);
281 const guidesEnabled = useReviewGuidesEnabled(params);
282 const [activityFilter, setActivityFilter] = useState<ActivityFilter>(DEFAULT_ACTIVITY_FILTER);
283 const activityContent = usePullRequestActivityRows(params, activityFilter);
296284 const scrollOffsets = useRef<Record<string, number>>({});
297285 const currentScrollOffset = useRef(-insets.top);
298286 const bodyY = useRef(0);
299 const pendingScrollOffset = useRef<number | null>(null);
300 const selectSection = (next: "overview" | "activity" | "commits") => {
287 const [headerHeight, setHeaderHeight] = useState(0);
288 const [viewportHeight, setViewportHeight] = useState(0);
289 const scrollViews = useRef<Partial<Record<typeof section, LegendListRef | null>>>({});
290 const selectSection = (next: typeof section) => {
301291 if (next === section) return;
302 requestedSection.current = next;
303292 scrollOffsets.current[section] = currentScrollOffset.current;
304 if (scrollOffsets.current[next] === undefined) {
305 scrollOffsets.current[next] = Math.min(
306 currentScrollOffset.current,
307 bodyY.current - insets.top,
308 );
309 }
310 pendingScrollOffset.current = scrollOffsets.current[next] ?? -insets.top;
293 const headerEnd = Math.max(-insets.top, bodyY.current - insets.top);
294 // The shared header stays in place. Once collapsed, retain each tab's own reading position.
295 const offset = pullRequestTabOffset(
296 currentScrollOffset.current,
297 scrollOffsets.current[next],
298 headerEnd,
299 );
300 requestedSection.current = next;
301 scrollOffsets.current[next] = offset;
302 currentScrollOffset.current = offset;
303 scrollViews.current[next]?.scrollToOffset({ offset, animated: false });
311304 setSection(next);
312305 };
313 const tab = section;
314 const tabContent = section === "commits" ? commitsContent : activityContent;
315 const scrollView = useRef<LegendListRef>(null);
316 const commitSectionLayout = () => {
317 if (requestedSection.current !== section) return;
318 setDisplayedSection(section);
319 const offset = pendingScrollOffset.current;
320 if (offset !== null) {
321 pendingScrollOffset.current = null;
322 scrollView.current?.scrollToOffset({ offset, animated: false });
306 useEffect(() => {
307 if (!guidesEnabled && section === "guide") {
308 requestedSection.current = "overview";
309 currentScrollOffset.current = scrollOffsets.current.overview ?? -insets.top;
310 setSection("overview");
323311 }
324 };
312 }, [guidesEnabled, section, insets.top]);
313 const tabContent = activityContent;
325314 const filesY = useRef(0);
326315 const descriptionY = useRef(0);
327316 const pendingDescriptionJump = useRef(false);
......@@ -329,7 +318,6 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
329318 const detailAtom = composerPullRequests.detail(requestTarget(params));
330319 const result = useEnvironmentQuery(detailAtom);
331320 const pullRefresh = usePullToRefresh(detailAtom);
332 const activity = useEnvironmentQuery(composerPullRequests.activity(requestTarget(params)));
333321 const { refresh: refreshDetail } = result;
334322 useFocusEffect(useCallback(() => refreshDetail(), [refreshDetail]));
335323 const pr = result.data;
......@@ -338,24 +326,27 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
338326 const permissions = usePullRequestPermissions(params, pr);
339327 const stack = usePullRequestStack(params, pr);
340328 const refreshActivity = useAtomRefresh(composerPullRequests.activity(requestTarget(params)));
329 const refreshGuide = useAtomRefresh(composerPullRequests.reviewGuide(requestTarget(params)));
341330 const refresh = () => {
342331 pullRefresh.onRefresh();
332 if (guidesEnabled) refreshGuide();
343333 stack.refresh();
344334 refreshActivity();
345335 filesContent.refresh();
346336 };
347 const jumpToFiles = useCallback(() => {
348 scrollView.current?.scrollToOffset({
337 const jumpToFiles = () => {
338 scrollViews.current.overview?.scrollToOffset({
349339 offset: bodyY.current + filesY.current - insets.top,
350340 animated: true,
351341 });
352342 pendingFilesJump.current = false;
353 }, [insets.top]);
343 };
354344 const openFiles = () => {
355345 if (section === "overview") jumpToFiles();
356346 else {
357347 pendingFilesJump.current = true;
358348 selectSection("overview");
349 if (filesY.current > 0) requestAnimationFrame(jumpToFiles);
359350 }
360351 };
361352 return (
......@@ -443,155 +434,207 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
443434 ) : null}
444435 </NativeHeaderToolbar.Menu>
445436 </NativeHeaderToolbar>
446 <View style={{ flex: 1 }}>
447 <LegendList
448 ref={scrollView}
449 initialScrollOffset={-insets.top}
450 onScroll={(event) => {
451 currentScrollOffset.current = event.nativeEvent.contentOffset.y;
452 }}
453 className="flex-1"
454 renderScrollComponent={Platform.OS === "ios" ? PullRequestTabScrollView : undefined}
455 contentInsetAdjustmentBehavior="never"
456 contentInset={
457 Platform.OS === "ios" ? { top: insets.top, bottom: insets.bottom } : undefined
458 }
459 contentInsetStartAdjustment={Platform.OS === "ios" ? insets.top : 0}
460 refreshControl={
461 <RefreshControl refreshing={pullRefresh.refreshing} onRefresh={refresh} />
462 }
463 alwaysBounceVertical
464 scrollsToTop
465 onContentSizeChange={() => {
466 if (tab !== "overview" || !pendingDescriptionJump.current) return;
467 pendingDescriptionJump.current = false;
468 // Collapse changes the scroll range; restore the heading after that layout commits.
469 scrollView.current?.scrollToOffset({
470 offset: bodyY.current + descriptionY.current - insets.top,
471 animated: false,
472 });
473 }}
474 contentContainerStyle={{
475 paddingHorizontal: 16,
476 paddingTop: 8,
477 paddingBottom: 24,
478 }}
479 data={[
480 {
481 key: `${tab}:body`,
482 content: (
483 <View
484 onLayout={
485 tab === "overview" || tabContent.rows.length === 0
486 ? commitSectionLayout
487 : undefined
488 }
489 >
490 {tab === "overview" && identity ? (
491 <View>
492 {pr ? (
493 <PullRequestOverview
494 pr={pr}
495 params={params}
496 stack={stack}
497 descriptionExpanded={descriptionExpanded}
498 onDescriptionExpandedChange={setDescriptionExpanded}
499 onDescriptionLayout={(y) => {
500 descriptionY.current = y;
501 }}
502 onDescriptionCollapse={() => {
503 pendingDescriptionJump.current = true;
504 }}
505 />
506 ) : summary ? (
507 <PullRequestOverviewLoading
508 summary={summary}
509 params={params}
510 stack={stack}
511 />
512 ) : null}
437 <View
438 style={{ flex: 1 }}
439 onLayout={(event) => setViewportHeight(event.nativeEvent.layout.height)}
440 >
441 {(["overview", "guide", "activity"] as const)
442 .filter((tab) => tab !== "guide" || guidesEnabled)
443 .map((tab) => (
444 <View
445 key={tab}
446 style={[StyleSheet.absoluteFill, { opacity: section === tab ? 1 : 0 }]}
447 pointerEvents={section === tab ? "auto" : "none"}
448 accessibilityElementsHidden={section !== tab}
449 importantForAccessibility={section === tab ? "auto" : "no-hide-descendants"}
450 >
451 <AnimatedLegendList
452 ref={(list) => {
453 scrollViews.current[tab] = list;
454 }}
455 initialScrollOffset={-insets.top}
456 onScroll={(event) => {
457 const offset = event.nativeEvent.contentOffset.y;
458 scrollOffsets.current[tab] = offset;
459 if (requestedSection.current === tab) currentScrollOffset.current = offset;
460 }}
461 className="flex-1"
462 renderScrollComponent={
463 Platform.OS === "ios" ? PullRequestTabScrollView : undefined
464 }
465 contentInsetAdjustmentBehavior="never"
466 contentInset={
467 Platform.OS === "ios" ? { top: insets.top, bottom: insets.bottom } : undefined
468 }
469 contentInsetStartAdjustment={Platform.OS === "ios" ? insets.top : 0}
470 refreshControl={
471 <RefreshControl
472 refreshing={section === tab && pullRefresh.refreshing}
473 onRefresh={refresh}
474 />
475 }
476 alwaysBounceVertical
477 scrollsToTop={section === tab}
478 onContentSizeChange={() => {
479 if (
480 tab !== "overview" ||
481 section !== "overview" ||
482 !pendingDescriptionJump.current
483 )
484 return;
485 pendingDescriptionJump.current = false;
486 // Collapse changes the scroll range; restore the heading after that layout commits.
487 scrollViews.current.overview?.scrollToOffset({
488 offset: bodyY.current + descriptionY.current - insets.top,
489 animated: false,
490 });
491 }}
492 contentContainerStyle={{
493 paddingHorizontal: 16,
494 paddingTop: 8,
495 paddingBottom: 24,
496 // Short tabs must still be tall enough to preserve the collapsed header.
497 minHeight: Math.max(
498 0,
499 viewportHeight + headerHeight - insets.top - insets.bottom,
500 ),
501 }}
502 data={[
503 {
504 key: `${tab}:body`,
505 content: (
506 <View>
507 {tab === "overview" && identity ? (
508 <View>
509 {pr ? (
510 <PullRequestOverview
511 pr={pr}
512 params={params}
513 stack={stack}
514 descriptionExpanded={descriptionExpanded}
515 onDescriptionExpandedChange={setDescriptionExpanded}
516 onDescriptionLayout={(y) => {
517 descriptionY.current = y;
518 }}
519 onDescriptionCollapse={() => {
520 pendingDescriptionJump.current = true;
521 }}
522 />
523 ) : summary ? (
524 <PullRequestOverviewLoading
525 summary={summary}
526 params={params}
527 stack={stack}
528 />
529 ) : null}
530 <View
531 className="mt-8 border-t border-border-subtle pt-3"
532 onLayout={(event) => {
533 filesY.current = event.nativeEvent.layout.y;
534 if (pendingFilesJump.current) {
535 requestAnimationFrame(() => {
536 if (requestedSection.current === "overview") jumpToFiles();
537 });
538 }
539 }}
540 >
541 {filesContent.header}
542 </View>
543 </View>
544 ) : null}
545 {tab === "guide" ? <PullRequestGuideContent params={params} /> : null}
546 {tab === "activity" ? (
547 <PullRequestActivityFilters
548 value={activityFilter}
549 onChange={setActivityFilter}
550 hiddenCount={tabContent.hiddenCount}
551 />
552 ) : null}
553 {tab === "activity" ? (
554 <PullRequestReadStatus
555 pending={tabContent.pending}
556 error={tabContent.error}
557 />
558 ) : null}
559 </View>
560 ),
561 },
562 ...(tab === "overview"
563 ? identity
564 ? filesContent.rows
565 : []
566 : tab === "activity"
567 ? tabContent.rows
568 : []
569 ).map((row) => ({
570 ...row,
571 key: `${tab}:${row.key}`,
572 content: row.content,
573 })),
574 ]}
575 keyExtractor={(row) => row.key}
576 getItemType={(row) =>
577 row.key.endsWith(":body") ? row.key : row.key.split(":")[0]
578 }
579 // The overview body contains the clipped description and owns collapse scroll
580 // correction. Animating that cell fights both; file rows still fade individually.
581 itemLayoutAnimation={tab === "overview" ? undefined : PR_ROW_LAYOUT}
582 onLoad={() => setListAnimationsReady(true)}
583 renderItem={({ item }) => (
584 <Animated.View
585 key={item.key}
586 entering={
587 listAnimationsReady && !item.key.endsWith(":body")
588 ? PR_ROW_ENTER
589 : undefined
590 }
591 exiting={
592 listAnimationsReady && !item.key.endsWith(":body") ? PR_ROW_EXIT : undefined
593 }
594 >
595 {item.content}
596 </Animated.View>
597 )}
598 recycleItems={false}
599 estimatedItemSize={tab === "overview" ? 64 : 220}
600 maintainVisibleContentPosition={false}
601 ListHeaderComponent={
602 <>
603 <PullRequestReadStatus
604 pending={result.isPending && !identity}
605 error={result.error}
606 />
607 {identity ? (
513608 <View
514 className="mt-8 border-t border-border-subtle pt-3"
609 className="gap-5 pb-5"
515610 onLayout={(event) => {
516 filesY.current = event.nativeEvent.layout.y;
517 if (pendingFilesJump.current) {
518 // The explicit file jump supersedes the tab's restored offset.
519 pendingScrollOffset.current = null;
520 requestAnimationFrame(() => {
521 if (requestedSection.current === "overview") jumpToFiles();
522 });
523 }
611 bodyY.current = event.nativeEvent.layout.height;
612 setHeaderHeight(event.nativeEvent.layout.height);
524613 }}
525614 >
526 {filesContent.header}
615 <PullRequestIdentity pr={identity} params={params} />
616 <SegmentedControl
617 className="bg-grouped-card"
618 options={[
619 { value: "overview", label: "Overview" },
620 ...(guidesEnabled
621 ? [{ value: "guide" as const, label: "Guide" }]
622 : []),
623 { value: "activity", label: "Activity" },
624 ]}
625 selected={section}
626 onSelect={selectSection}
627 role="tab"
628 size="compact"
629 />
527630 </View>
528 </View>
529 ) : null}
530 {tab !== "overview" ? (
531 <PullRequestReadStatus
532 pending={tabContent.pending}
533 error={tabContent.error}
534 />
535 ) : null}
536 </View>
537 ),
538 },
539 ...(tab === "overview" ? (identity ? filesContent.rows : []) : tabContent.rows).map(
540 (row) => ({
541 ...row,
542 key: `${tab}:${row.key}`,
543 content:
544 tab !== "overview" ? (
545 <View onLayout={commitSectionLayout}>{row.content}</View>
546 ) : (
547 row.content
548 ),
549 }),
550 ),
551 ]}
552 keyExtractor={(row) => row.key}
553 getItemType={(row) => (row.key.endsWith(":body") ? row.key : row.key.split(":")[0])}
554 renderItem={({ item }) => item.content}
555 recycleItems={false}
556 estimatedItemSize={tab === "overview" ? 64 : tab === "commits" ? 80 : 220}
557 maintainVisibleContentPosition={false}
558 ListHeaderComponent={
559 <>
560 <PullRequestReadStatus
561 pending={result.isPending && !identity}
562 error={result.error}
631 ) : null}
632 </>
633 }
634 ListFooterComponent={tab === "overview" ? filesContent.footer : null}
563635 />
564 {identity ? (
565 <View
566 className="gap-5 pb-5"
567 onLayout={(event) => {
568 bodyY.current = event.nativeEvent.layout.height;
569 }}
570 >
571 <PullRequestIdentity pr={identity} params={params} />
572 <SegmentedControl
573 className="bg-grouped-card"
574 options={[
575 { value: "overview", label: "Overview" },
576 { value: "activity", label: "Activity" },
577 {
578 value: "commits",
579 label: activity.data
580 ? `Commits ${activity.data.commits.length}`
581 : "Commits",
582 },
583 ]}
584 selected={displayedSection}
585 onSelect={selectSection}
586 role="tab"
587 size="compact"
588 />
589 </View>
590 ) : null}
591 </>
592 }
593 ListFooterComponent={tab === "overview" ? filesContent.footer : null}
594 />
636 </View>
637 ))}
595638 </View>
596639 <PullRequestActionToolbar pr={pr} params={params} onOpenFiles={openFiles} />
597640 </View>
......@@ -632,13 +675,17 @@ export function PullRequestFileRouteScreen({
632675 readonly path: string;
633676 readonly fileSelectionId?: string;
634677 readonly replacingDraftId?: string;
678 readonly focus?: GuideDiffFocus;
635679 }
636680>) {
637681 const navigation = useNavigation();
638682 const insets = useSafeAreaInsets();
639683 const commit = route.params.commit;
640684 const result = usePullRequestDiff(route.params);
641 const filter = usePullRequestFileFilter(result.parsed);
685 const filter = usePullRequestFileFilter(
686 result.parsed,
687 route.params.focus ? route.params.path : undefined,
688 );
642689 const detail = useEnvironmentQuery(composerPullRequests.detail(requestTarget(route.params)));
643690 const { refresh: refreshDetail } = detail;
644691 useFocusEffect(useCallback(() => refreshDetail(), [refreshDetail]));
......@@ -709,6 +756,11 @@ export function PullRequestFileRouteScreen({
709756 const targetFile =
710757 data.files.find((file) => file.path === jumpPath) ??
711758 (!jumpPath || requestedFile ? data.files[0] : undefined);
759 const focus = route.params.focus;
760 const focusedRows = guideFocusedRows(data.rows, targetFile?.id, focus);
761 const focusIndex = focusedRows.length
762 ? data.rows.findIndex((row) => row.id === focusedRows[0]?.id)
763 : -1;
712764 const [collapsedIds, setCollapsedIds] = useState<readonly string[]>(EMPTY_IDS);
713765 const [visibleFileId, setVisibleFileId] = useState<string | null>(null);
714766 const nativeView = useRef<NativeReviewDiffViewHandle>(null);
......@@ -722,6 +774,7 @@ export function PullRequestFileRouteScreen({
722774 )
723775 return;
724776 lastSelection.current = { path: route.params.path, id: route.params.fileSelectionId };
777 if (route.params.focus) navigation.setParams({ focus: undefined });
725778 setJumpPath(route.params.path);
726779 setSelectedRowId(null);
727780 jumpStarted.current = false;
......@@ -732,7 +785,7 @@ export function PullRequestFileRouteScreen({
732785 void nativeView.current.scrollToFile(file.id, false).catch(() => {
733786 jumpStarted.current = false;
734787 });
735 }, [route.params.path, route.params.fileSelectionId, data.files]);
788 }, [route.params.path, route.params.fileSelectionId, route.params.focus, data.files, navigation]);
736789 const appliedFilter = useRef(filter.hideTestFiles);
737790 useEffect(() => {
738791 if (appliedFilter.current === filter.hideTestFiles) return;
......@@ -745,7 +798,7 @@ export function PullRequestFileRouteScreen({
745798 }
746799 }, [filter.hideTestFiles, result.parsed]);
747800 const { nativeReviewDiffStyle } = useAppearanceCodeSurface();
748 const { themeAppearance } = useAppearancePreferences();
801 const { themeAppearance, appearance, setCodeWordBreak } = useAppearancePreferences();
749802 const bridge = useNativeReviewDiffBridge({
750803 threadKey: null,
751804 sectionId: `pull-request:${route.params.repository}:${route.params.number}:${commit ?? "all"}`,
......@@ -753,7 +806,7 @@ export function PullRequestFileRouteScreen({
753806 data,
754807 collapsedFileIds: collapsedIds,
755808 viewedFileIds: viewedIds,
756 selectedRowIds: selectedRowId ? [selectedRowId] : EMPTY_IDS,
809 selectedRowIds: selectedRowId ? [selectedRowId] : focusedRows.map((row) => row.id),
757810 canHighlight: true,
758811 });
759812 const [NativeDiffView] = useState(() => resolveNativeReviewDiffView());
......@@ -783,6 +836,7 @@ export function PullRequestFileRouteScreen({
783836 (Platform.OS !== "ios" || decoded)
784837 ) {
785838 jumpStarted.current = true;
839 if (focusIndex >= 0) return;
786840 void nativeView.current.scrollToFile(targetFile.id, false).catch(() => {
787841 jumpStarted.current = false;
788842 });
......@@ -792,7 +846,7 @@ export function PullRequestFileRouteScreen({
792846 const lastRow = event.nativeEvent.lastRowIndex;
793847 if (typeof lastRow === "number" && lastRow >= data.rows.length - 40) loadMore();
794848 },
795 [updateVisibleRange, data.rows.length, loadMore, targetFile],
849 [updateVisibleRange, data.rows.length, loadMore, targetFile, focusIndex],
796850 );
797851 const onToggleFile = useCallback<NonNullable<NativeReviewDiffViewProps["onToggleFile"]>>(
798852 (event) => {
......@@ -829,6 +883,12 @@ export function PullRequestFileRouteScreen({
829883 trailing={
830884 <ControlPillMenu
831885 actions={[
886 {
887 id: "word-wrap",
888 title: "Word wrap",
889 image: "text.alignleft",
890 state: appearance.codeWordBreak ? "on" : "off",
891 },
832892 {
833893 id: "hide-tests",
834894 title: "Hide test files",
......@@ -849,7 +909,8 @@ export function PullRequestFileRouteScreen({
849909 : []),
850910 ]}
851911 onPressAction={({ nativeEvent }) => {
852 if (nativeEvent.event === "hide-tests") toggleTestFiles();
912 if (nativeEvent.event === "word-wrap") setCodeWordBreak(!appearance.codeWordBreak);
913 else if (nativeEvent.event === "hide-tests") toggleTestFiles();
853914 else if (
854915 nativeEvent.event === "submit-review" ||
855916 nativeEvent.event === "comment-on-pr"
......@@ -895,6 +956,13 @@ export function PullRequestFileRouteScreen({
895956 <NativeHeaderToolbar.Label>Add comment</NativeHeaderToolbar.Label>
896957 </NativeHeaderToolbar.MenuAction>
897958 ) : null}
959 <NativeHeaderToolbar.MenuAction
960 icon="text.alignleft"
961 isOn={appearance.codeWordBreak}
962 onPress={() => setCodeWordBreak(!appearance.codeWordBreak)}
963 >
964 <NativeHeaderToolbar.Label>Word wrap</NativeHeaderToolbar.Label>
965 </NativeHeaderToolbar.MenuAction>
898966 <NativeHeaderToolbar.MenuAction
899967 icon="line.3.horizontal.decrease"
900968 isOn={filter.hideTestFiles}
......@@ -956,6 +1024,7 @@ export function PullRequestFileRouteScreen({
9561024 {NativeDiffView && data.rows.length && !seekingFile ? (
9571025 <View className="flex-1" collapsable={false}>
9581026 <NativeDiffView
1027 initialRowIndex={focusIndex >= 0 ? Math.max(0, focusIndex - 3) : undefined}
9591028 nativeViewRef={nativeView}
9601029 refreshing={result.refreshing}
9611030 onPullToRefresh={result.onPullRefresh}
apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx+20-8
......@@ -1,6 +1,8 @@
11import { useAtomValue } from "@effect/atom-react";
22import { useNavigation } from "@react-navigation/native";
33import type { PullRequestAction, PullRequestDetail } from "@t3tools/contracts";
4import Animated from "react-native-reanimated";
5import { PR_ROW_LAYOUT, PR_DISCLOSURE_ENTER, PR_ROW_EXIT } from "./pullRequestAnimations";
46import { useState } from "react";
57import { Linking, Pressable, View } from "react-native";
68import { AppText as Text } from "../../components/AppText";
......@@ -97,7 +99,7 @@ export function PullRequestChecksRow(props: RowProps) {
9799 const checks = pullRequestChecksSummary(props.pr);
98100 const action = usePullRequestAction(props.params, props.pr);
99101 return (
100 <>
102 <Animated.View layout={PR_ROW_LAYOUT}>
101103 <PullRequestCardRow
102104 title={checks.title}
103105 icon={checks.icon}
......@@ -109,7 +111,12 @@ export function PullRequestChecksRow(props: RowProps) {
109111 onPress={() => setExpanded(!expanded)}
110112 />
111113 {expanded ? (
112 <View className="pl-8">
114 <Animated.View
115 layout={PR_ROW_LAYOUT}
116 entering={PR_DISCLOSURE_ENTER}
117 exiting={PR_ROW_EXIT}
118 className="pl-8"
119 >
113120 {props.pr.state === "open" &&
114121 props.pr.checks.some((check) => check.status === "failure") ? (
115122 <PullRequestAgentRow {...props} task="feedback" title="Fix failing checks" />
......@@ -157,9 +164,9 @@ export function PullRequestChecksRow(props: RowProps) {
157164 )
158165 }
159166 />
160 </View>
167 </Animated.View>
161168 ) : null}
162 </>
169 </Animated.View>
163170 );
164171}
165172
......@@ -236,7 +243,7 @@ export function PullRequestReviewersRow(props: RowProps) {
236243 props.pr.capabilities.reviewers.request &&
237244 props.pr.viewerPermissions.requestReviewers;
238245 return (
239 <>
246 <Animated.View layout={PR_ROW_LAYOUT}>
240247 <PullRequestCardRow
241248 title={summary.title}
242249 icon={summary.icon}
......@@ -247,7 +254,12 @@ export function PullRequestReviewersRow(props: RowProps) {
247254 onPress={() => setExpanded(!expanded)}
248255 />
249256 {expanded ? (
250 <View className="pl-8">
257 <Animated.View
258 layout={PR_ROW_LAYOUT}
259 entering={PR_DISCLOSURE_ENTER}
260 exiting={PR_ROW_EXIT}
261 className="pl-8"
262 >
251263 {summary.reviews.map((review) => (
252264 <View
253265 key={review.id}
......@@ -308,8 +320,8 @@ export function PullRequestReviewersRow(props: RowProps) {
308320 onPress={() => void Linking.openURL(props.pr.url)}
309321 />
310322 )}
311 </View>
323 </Animated.View>
312324 ) : null}
313 </>
325 </Animated.View>
314326 );
315327}
apps/mobile/src/features/pull-requests/guide/GuideOutline.tsx created+115
......@@ -0,0 +1,115 @@
1import type { PullRequestReviewGuide } from "@t3tools/contracts";
2import { stopFilePaths } from "@t3tools/client-runtime/review-guide-tour";
3import { Pressable, View } from "react-native";
4import { AppText as Text } from "../../../components/AppText";
5import { SymbolView } from "../../../components/AppSymbol";
6import { PullRequestMarkdown } from "../PullRequestMarkdown";
7import { ControlPillMenu } from "../../../components/ControlPillMenu";
8
9import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider";
10
11/** The stops are a reading sequence, with file context kept beside each idea. */
12export function GuideOutline({
13 guide,
14 onOpen,
15 onRewrite,
16}: {
17 readonly guide: PullRequestReviewGuide;
18 readonly onOpen: (position: number) => void;
19 readonly onRewrite?: () => void;
20}) {
21 const { appearance, setCodeWordBreak } = useAppearancePreferences();
22 const files = new Set(guide.stops.flatMap(stopFilePaths));
23 return (
24 <View>
25 <View className="mb-5 flex-row items-center justify-between">
26 <View className="flex-row items-baseline gap-2">
27 <Text className="text-base font-t3-semibold">{guide.stops.length} stops</Text>
28 <Text className="text-sm text-foreground-muted">
29 through {files.size} {files.size === 1 ? "file" : "files"}
30 </Text>
31 </View>
32 <ControlPillMenu
33 title="Guide options"
34 actions={[
35 {
36 id: "word-wrap",
37 title: "Word wrap",
38 image: "text.alignleft",
39 state: appearance.codeWordBreak ? "on" : "off",
40 },
41 ...(onRewrite
42 ? [{ id: "rewrite", title: "Rewrite guide", image: "arrow.clockwise" }]
43 : []),
44 ]}
45 onPressAction={({ nativeEvent }) =>
46 nativeEvent.event === "word-wrap"
47 ? setCodeWordBreak(!appearance.codeWordBreak)
48 : onRewrite?.()
49 }
50 >
51 <Pressable
52 accessibilityRole="button"
53 accessibilityLabel="Guide options"
54 className="h-11 w-11 items-center justify-center rounded-full active:bg-grouped-card"
55 >
56 <SymbolView name="ellipsis" size={20} tintColorClassName="accent-icon-muted" />
57 </Pressable>
58 </ControlPillMenu>
59 </View>
60 {guide.stops[0]?.summary ? (
61 <View className="mb-6">
62 <PullRequestMarkdown markdown={guide.stops[0].summary} />
63 </View>
64 ) : null}
65 {guide.stops.map((stop, position) => {
66 const paths = stopFilePaths(stop);
67 const firstPath = paths[0];
68 const filename = firstPath?.split("/").at(-1);
69 return (
70 <Pressable
71 key={`${guide.headSha}:${JSON.stringify(stop)}`}
72 accessibilityRole="button"
73 accessibilityLabel={`Stop ${position + 1}: ${stop.title}`}
74 onPress={() => onOpen(position)}
75 className="flex-row gap-4 active:opacity-60"
76 >
77 <View className="w-8 items-center">
78 <View className="h-8 w-8 items-center justify-center rounded-full border border-border bg-grouped-card">
79 <Text className="text-sm font-t3-medium tabular-nums">{position + 1}</Text>
80 </View>
81 {position < guide.stops.length - 1 ? (
82 <View className="min-h-5 w-px flex-1 bg-border-subtle" />
83 ) : null}
84 </View>
85 <View className="min-w-0 flex-1 gap-2 pb-7 pt-0.5">
86 <Text className="text-base leading-6 font-t3-medium">{stop.title}</Text>
87 <View className="flex-row items-center gap-1.5">
88 {stop.attention ? (
89 <SymbolView name="eye" size={14} tintColorClassName="accent-warning-foreground" />
90 ) : null}
91 <Text className="shrink text-xs text-foreground-muted" numberOfLines={1}>
92 {filename ?? "Context"}
93 {paths.length > 1 ? ` +${paths.length - 1}` : ""}
94 </Text>
95 </View>
96 </View>
97 <View className="pt-2">
98 <SymbolView name="chevron.right" size={12} tintColorClassName="accent-icon-muted" />
99 </View>
100 </Pressable>
101 );
102 })}
103 {guide.stops.length ? (
104 <Pressable
105 accessibilityRole="button"
106 onPress={() => onOpen(0)}
107 className="min-h-11 flex-row items-center justify-end gap-2 active:opacity-60"
108 >
109 <Text className="text-sm font-t3-medium">Start tour</Text>
110 <SymbolView name="arrow.right" size={17} tintColorClassName="accent-foreground" />
111 </Pressable>
112 ) : null}
113 </View>
114 );
115}
apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx created+400
......@@ -0,0 +1,400 @@
1import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
2import type { FileDiffMetadata } from "@pierre/diffs";
3import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
4import { useEffect, useMemo } from "react";
5import { Platform, Pressable, View } from "react-native";
6import { AndroidScreenHeader } from "../../../components/AndroidScreenHeader";
7import { useSafeAreaInsets } from "react-native-safe-area-context";
8import {
9 letterStopNotes,
10 stepTourPosition,
11 stopFilePaths,
12} from "@t3tools/client-runtime/review-guide-tour";
13import { SymbolView } from "../../../components/AppSymbol";
14import { GuideOutline } from "./GuideOutline";
15import { AppText as Text } from "../../../components/AppText";
16import { MaterialButton } from "../../../components/MaterialButton";
17import { ScreenScrollView } from "../../../components/ScreenScrollView";
18import { ControlPillMenu } from "../../../components/ControlPillMenu";
19import { MaterialIconButton } from "../../../components/MaterialIconButton";
20import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider";
21import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../../native/StackHeader";
22import { PullRequestCardGroup, PullRequestCardRow } from "../PullRequestControls";
23import { PullRequestMarkdown, PullRequestMarkdownRepository } from "../PullRequestMarkdown";
24import { usePullRequestDiff, type PullRequestParams } from "../usePullRequestDiff";
25import { ReviewGuideExcerpt } from "./ReviewGuideExcerpt";
26import { reviewGuideState } from "./reviewGuideState";
27import { useEnvironmentQuery } from "../../../state/query";
28import { composerPullRequests } from "../../../state/pull-requests";
29import { usePullRequestPermissions } from "../usePullRequestPermissions";
30import { pullRequestTarget } from "../usePullRequestDiff";
31import { useReviewGuide } from "./useReviewGuide";
32
33export type GuideStopParams = PullRequestParams & {
34 readonly position: number;
35 readonly guideHeadSha: string;
36 readonly guideGeneratedAt: string;
37};
38
39const attentionLabels = {
40 tradeoff: "A tradeoff to weigh",
41 contract: "A contract to check",
42 product: "A product call",
43 risk: "A risk to judge",
44 accessibility: "An accessibility call",
45};
46
47function GuideNotice({ state }: { readonly state: ReturnType<typeof useReviewGuide> }) {
48 const { guide, stale, failure } = reviewGuideState(state.data);
49 const error = state.startError ?? failure ?? state.error;
50 const label = state.running
51 ? guide
52 ? "Rewriting review guide"
53 : "Writing review guide"
54 : error
55 ? "Could not write guide"
56 : stale
57 ? "Guide is out of date"
58 : "No review guide yet";
59 if (guide && !stale && !error && !state.running) return null;
60 return (
61 <View
62 className={guide ? "gap-3 border-l-2 border-border pl-4 py-1" : "gap-4 py-4"}
63 accessibilityLiveRegion="polite"
64 >
65 <View className="flex-row items-center gap-2">
66 <SymbolView
67 name={
68 state.running
69 ? "clock"
70 : error
71 ? "exclamationmark.circle"
72 : stale
73 ? "arrow.clockwise"
74 : "list.number"
75 }
76 size={20}
77 tintColorClassName={
78 error
79 ? "accent-danger-foreground"
80 : stale
81 ? "accent-warning-foreground"
82 : "accent-icon-muted"
83 }
84 />
85 <Text className={guide ? "text-base font-t3-semibold" : "text-xl font-t3-semibold"}>
86 {label}
87 </Text>
88 </View>
89 <Text className="text-sm leading-6 text-foreground-muted">
90 {error ??
91 (state.running
92 ? "An agent is reading the change. You can leave this screen and return when it’s ready."
93 : stale
94 ? "There are new changes since this guide was written. Rewrite it to see the latest code."
95 : "Walk through the important code in order, with notes on what changed and where to look closely.")}
96 </Text>
97 {state.running ? (
98 <Text className="text-xs text-foreground-muted">
99 {state.data?.run?._tag === "Running"
100 ? `Started at ${new Date(state.data.run.startedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`
101 : "Starting the guide…"}
102 </Text>
103 ) : state.canWrite ? (
104 <MaterialButton
105 label={error ? "Try again" : guide ? "Rewrite guide" : "Write guide"}
106 tone={guide ? "secondary" : "contrast"}
107 onPress={() => void state.write()}
108 />
109 ) : null}
110 </View>
111 );
112}
113
114export function PullRequestGuideContent({ params }: { readonly params: PullRequestParams }) {
115 const navigation = useNavigation();
116 const state = useReviewGuide(params);
117 const { guide, stale } = reviewGuideState(state.data);
118 return (
119 <View className="w-full max-w-[760px] self-center gap-5">
120 {!state.enabled ? (
121 <Text>Review guides are disabled for this server.</Text>
122 ) : (
123 <>
124 {state.isPending && !state.data ? (
125 <Text className="text-foreground-muted">Loading review guide…</Text>
126 ) : (
127 <GuideNotice state={state} />
128 )}
129 {guide ? (
130 <GuideOutline
131 guide={guide}
132 onOpen={(position) =>
133 navigation.dispatch(
134 StackActions.push("PullRequestGuideStop", {
135 ...params,
136 position,
137 guideHeadSha: guide.headSha,
138 guideGeneratedAt: guide.generatedAt,
139 }),
140 )
141 }
142 onRewrite={
143 state.canWrite && !state.running && !stale && !state.startError && !state.error
144 ? () => void state.write()
145 : undefined
146 }
147 />
148 ) : null}
149 </>
150 )}
151 </View>
152 );
153}
154
155export function PullRequestGuideStopScreen({ route }: StaticScreenProps<GuideStopParams>) {
156 const navigation = useNavigation();
157 const state = useReviewGuide(route.params);
158 const detail = useEnvironmentQuery(composerPullRequests.detail(pullRequestTarget(route.params)));
159 const permissions = usePullRequestPermissions(route.params, detail.data);
160 const canReview =
161 permissions.verdicts.length > 0 && detail.data?.headSha === route.params.guideHeadSha;
162 const { guide, stale } = reviewGuideState(state.data);
163 const position = Math.max(0, Math.min(route.params.position, (guide?.stops.length ?? 1) - 1));
164 const stop = guide?.stops[Math.max(0, position)];
165 const diff = usePullRequestDiff(route.params);
166 const excerptFiles = useMemo(() => {
167 const files = new Map<string, FileDiffMetadata>();
168 try {
169 for (const patch of parsePatchFiles(diff.patch)) {
170 for (const file of patch.files) {
171 files.set(file.name, file);
172 if (file.prevName) files.set(file.prevName, file);
173 }
174 }
175 } catch {
176 // An omitted or unparseable patch is shown as unavailable by the excerpt.
177 }
178 return files;
179 }, [diff.patch]);
180 const wanted = useMemo(() => (stop ? stopFilePaths(stop) : []), [stop]);
181 const loadedPaths = new Set(
182 diff.parsed.kind === "files" ? diff.parsed.files.map((file) => file.path) : [],
183 );
184 const missing = wanted.some((path) => !loadedPaths.has(path));
185 const { error: diffError, loadMore } = diff;
186 useEffect(() => {
187 if (state.enabled && missing && !diffError) loadMore();
188 }, [state.enabled, missing, diffError, loadMore]);
189 const insets = useSafeAreaInsets();
190 const changedGuide =
191 guide !== null &&
192 (guide.headSha !== route.params.guideHeadSha ||
193 guide.generatedAt !== route.params.guideGeneratedAt);
194 const { appearance, setCodeWordBreak } = useAppearancePreferences();
195 const notes = stop ? letterStopNotes(stop).lettered : [];
196 return (
197 <View className="flex-1 bg-screen">
198 <NativeStackScreenOptions
199 options={{
200 title: `Stop ${route.params.position + 1}`,
201 headerTitle: `Stop ${route.params.position + 1} of ${guide?.stops.length ?? "…"}`,
202 }}
203 />
204 {Platform.OS === "android" ? (
205 <AndroidScreenHeader
206 title={`Stop ${route.params.position + 1}`}
207 onBack={() => navigation.goBack()}
208 trailing={
209 <ControlPillMenu
210 actions={[
211 {
212 id: "word-wrap",
213 title: "Word wrap",
214 image: "text.alignleft",
215 state: appearance.codeWordBreak ? "on" : "off",
216 },
217 ]}
218 onPressAction={() => setCodeWordBreak(!appearance.codeWordBreak)}
219 >
220 <View pointerEvents="none">
221 <MaterialIconButton icon="ellipsis" accessibilityLabel="Guide options" />
222 </View>
223 </ControlPillMenu>
224 }
225 />
226 ) : null}
227 <NativeHeaderToolbar placement="right">
228 <NativeHeaderToolbar.Menu
229 title="Guide options"
230 accessibilityLabel="Guide options"
231 icon="ellipsis"
232 >
233 <NativeHeaderToolbar.MenuAction
234 icon="text.alignleft"
235 isOn={appearance.codeWordBreak}
236 onPress={() => setCodeWordBreak(!appearance.codeWordBreak)}
237 >
238 <NativeHeaderToolbar.Label>Word wrap</NativeHeaderToolbar.Label>
239 </NativeHeaderToolbar.MenuAction>
240 </NativeHeaderToolbar.Menu>
241 </NativeHeaderToolbar>
242 <PullRequestMarkdownRepository
243 value={`https://${route.params.host ?? "github.com"}/${route.params.repository}`}
244 >
245 <ScreenScrollView
246 contentInsetAdjustmentBehavior="automatic"
247 contentContainerStyle={{ padding: 16, paddingBottom: insets.bottom + 24, gap: 20 }}
248 >
249 <View className="w-full max-w-[760px] self-center gap-5">
250 {!state.enabled ? (
251 <Text>Review guides are disabled for this server.</Text>
252 ) : changedGuide ? (
253 <Text>This guide was rewritten. Go back to start the new tour.</Text>
254 ) : !stop ? (
255 <Text>{state.error ?? "Loading stop…"}</Text>
256 ) : (
257 <>
258 <GuideNotice state={state} />
259 <Text className="text-2xl leading-8 font-t3-semibold">{stop.title}</Text>
260 <PullRequestMarkdown markdown={stop.summary} />
261 {stop.attention ? (
262 <View className="gap-3 rounded-xl border border-border bg-grouped-card p-4">
263 <View className="flex-row items-center gap-2">
264 <SymbolView name="eye" size={17} tintColorClassName="accent-icon-muted" />
265 <Text className="text-sm font-t3-semibold">
266 {attentionLabels[stop.attention.kind]}
267 </Text>
268 </View>
269 <PullRequestMarkdown markdown={stop.attention.note} />
270 </View>
271 ) : null}
272 {stale ? (
273 <Text className="text-sm text-foreground-muted">
274 Excerpts are hidden until the guide is rewritten for the current head.
275 </Text>
276 ) : (
277 stop.excerpts.map((excerpt) => (
278 <ReviewGuideExcerpt
279 key={`${guide?.headSha}:${JSON.stringify(excerpt)}`}
280 onOpen={() =>
281 navigation.dispatch(
282 StackActions.push("PullRequestFile", {
283 ...route.params,
284 path: excerpt.path,
285 focus: { side: excerpt.side, start: excerpt.start, end: excerpt.end },
286 fileSelectionId: `guide:${position}:${excerpt.path}:${excerpt.start}`,
287 }),
288 )
289 }
290 excerpt={excerpt}
291 file={excerptFiles.get(excerpt.path)}
292 loading={diff.isPending || (missing && diff.nextCursor !== null)}
293 />
294 ))
295 )}
296 {diff.error ? <Text className="text-danger-foreground">{diff.error}</Text> : null}
297 {notes.length ? (
298 <View className="gap-4">
299 <Text className="font-t3-semibold">Notes on this stop</Text>
300 {notes.map(({ marker, note }) => (
301 <View key={marker} className="flex-row gap-3">
302 <View className="h-7 w-7 items-center justify-center rounded-lg bg-grouped-card">
303 <Text className="text-xs font-t3-semibold">{marker}</Text>
304 </View>
305 <View className="min-w-0 flex-1 gap-2">
306 <Text className="font-t3-semibold">{note.title}</Text>
307 <Text className="text-xs text-foreground-muted">
308 {note.path.split("/").at(-1)}:{note.line}
309 </Text>
310 <PullRequestMarkdown markdown={note.body} />
311 </View>
312 </View>
313 ))}
314 </View>
315 ) : null}
316 {stop.files.length ? (
317 <PullRequestCardGroup>
318 {stop.files.map((file, index) => (
319 <PullRequestCardRow
320 key={file.path}
321 title={file.path}
322 detail={file.note}
323 icon="doc"
324 last={index === stop.files.length - 1}
325 onPress={() =>
326 navigation.navigate("PullRequestFile", {
327 ...route.params,
328 path: file.path,
329 })
330 }
331 />
332 ))}
333 </PullRequestCardGroup>
334 ) : null}
335 {stop.questions.length ? (
336 <View className="gap-3">
337 <Text className="font-t3-semibold">Questions to consider</Text>
338 {stop.questions.map((question) => (
339 <PullRequestMarkdown key={question} markdown={question} />
340 ))}
341 </View>
342 ) : null}
343 <Pressable
344 accessibilityRole="button"
345 accessibilityLabel={
346 guide && position < guide.stops.length - 1
347 ? "Next stop"
348 : canReview
349 ? "Finish review"
350 : "Finish tour"
351 }
352 className="mt-2 flex-row items-center gap-4 border-t border-border-subtle pt-5 pb-2 active:opacity-60"
353 onPress={() =>
354 guide && position < guide.stops.length - 1
355 ? navigation.dispatch(
356 StackActions.push("PullRequestGuideStop", {
357 ...route.params,
358 position: stepTourPosition(position, 1, guide.stops.length),
359 }),
360 )
361 : canReview
362 ? navigation.navigate("PullRequestReview", {
363 ...route.params,
364 mode: "review",
365 })
366 : navigation.dispatch(StackActions.popTo("PullRequest", route.params))
367 }
368 >
369 <View className="min-w-0 flex-1 gap-1">
370 <Text className="text-xs text-foreground-muted">
371 {guide && position < guide.stops.length - 1
372 ? `Next · ${position + 2} of ${guide.stops.length}`
373 : "End of guide"}
374 </Text>
375 <Text className="text-base font-t3-semibold">
376 {guide && position < guide.stops.length - 1
377 ? guide.stops[position + 1]?.title
378 : canReview
379 ? "Finish review"
380 : "Finish tour"}
381 </Text>
382 </View>
383 <View className="h-11 w-11 items-center justify-center rounded-full bg-foreground">
384 <SymbolView
385 name={
386 guide && position < guide.stops.length - 1 ? "arrow.right" : "checkmark"
387 }
388 size={20}
389 tintColorClassName="accent-screen"
390 />
391 </View>
392 </Pressable>
393 </>
394 )}
395 </View>
396 </ScreenScrollView>
397 </PullRequestMarkdownRepository>
398 </View>
399 );
400}
apps/mobile/src/features/pull-requests/guide/ReviewGuideExcerpt.tsx created+135
......@@ -0,0 +1,135 @@
1import { useMemo, useState } from "react";
2import { Pressable, View } from "react-native";
3import type { FileDiffMetadata } from "@pierre/diffs";
4import type { PullRequestReviewGuideExcerpt } from "@t3tools/contracts";
5import { excerptRows, fileDiffRows, rowsToPatch } from "@t3tools/client-runtime/review-guide-tour";
6import { AppText as Text } from "../../../components/AppText";
7import { SymbolView } from "../../../components/AppSymbol";
8import { resolveNativeReviewDiffView } from "../../diffs/nativeReviewDiffSurface";
9import { buildNativeReviewDiffData } from "../../review/nativeReviewDiffAdapter";
10import { buildReviewParsedDiff } from "../../review/reviewModel";
11import { useNativeReviewDiffBridge } from "../../review/useNativeReviewDiffBridge";
12import { useAppearanceCodeSurface } from "../../settings/appearance/useAppearanceCodeSurface";
13import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider";
14import { PullRequestMarkdown } from "../PullRequestMarkdown";
15
16const EMPTY_IDS: readonly string[] = [];
17
18export function ReviewGuideExcerpt({
19 excerpt,
20 file,
21 loading,
22 onOpen,
23}: {
24 readonly excerpt: PullRequestReviewGuideExcerpt;
25 readonly file: FileDiffMetadata | undefined;
26 readonly loading: boolean;
27 readonly onOpen: () => void;
28}) {
29 const { nativeReviewDiffStyle, codeWordBreak } = useAppearanceCodeSurface();
30 const { themeAppearance } = useAppearancePreferences();
31 const slice = useMemo(() => {
32 try {
33 if (!file) return "";
34 return rowsToPatch(
35 excerpt.path,
36 file.prevName ?? excerpt.path,
37 excerptRows(fileDiffRows(file), excerpt),
38 );
39 } catch {
40 return "";
41 }
42 }, [file, excerpt]);
43 const data = useMemo(() => {
44 const parsed = buildNativeReviewDiffData(
45 buildReviewParsedDiff(slice, `guide:${excerpt.path}:${excerpt.start}`),
46 );
47 // The compact header and real line numbers provide context without file/hunk chrome.
48 return {
49 ...parsed,
50 rows: parsed.rows.filter((row) => row.kind !== "file" && row.kind !== "hunk"),
51 };
52 }, [slice, excerpt.path, excerpt.start]);
53 const bridge = useNativeReviewDiffBridge({
54 threadKey: null,
55 sectionId: `guide:${excerpt.path}:${excerpt.start}`,
56 diff: slice,
57 data,
58 collapsedFileIds: EMPTY_IDS,
59 viewedFileIds: EMPTY_IDS,
60 selectedRowIds: EMPTY_IDS,
61 canHighlight: true,
62 });
63 const [measuredHeight, setMeasuredHeight] = useState<{ key: string; height: number } | null>(
64 null,
65 );
66 const measurementKey = `${slice}:${codeWordBreak}:${nativeReviewDiffStyle.rowHeight}`;
67 const [NativeDiff] = useState(() => resolveNativeReviewDiffView());
68 return (
69 <View className="gap-3">
70 {excerpt.caption.trim() ? <PullRequestMarkdown markdown={excerpt.caption} /> : null}
71 <View className="overflow-hidden rounded-lg border border-border-subtle">
72 <Pressable
73 accessibilityRole="button"
74 accessibilityLabel={`Open ${excerpt.path} at line ${excerpt.start} in full diff`}
75 onPress={onOpen}
76 className="min-h-11 flex-row items-center gap-3 border-b border-border-subtle px-3 py-2 active:opacity-60"
77 >
78 <Text
79 className="min-w-0 flex-1 text-xs text-foreground-muted"
80 numberOfLines={1}
81 ellipsizeMode="middle"
82 >
83 {excerpt.path}
84 </Text>
85 <Text className="text-2xs text-foreground-muted">
86 {excerpt.start}–{excerpt.end}
87 </Text>
88 <SymbolView name="arrow.up.right" size={13} tintColorClassName="accent-icon-muted" />
89 </Pressable>
90 {NativeDiff && data.rows.length ? (
91 <View
92 style={{
93 height:
94 measuredHeight?.key === measurementKey
95 ? measuredHeight.height
96 : data.rows.length * nativeReviewDiffStyle.rowHeight,
97 }}
98 >
99 <NativeDiff
100 scrollEnabled={false}
101 bounces={false}
102 appearanceScheme={themeAppearance}
103 rowHeight={nativeReviewDiffStyle.rowHeight}
104 contentWidth={2800}
105 style={{ flex: 1 }}
106 rowsJson={bridge.rowsJson}
107 themeJson={bridge.themeJson}
108 styleJson={bridge.styleJson}
109 tokensPatchJson={bridge.tokensPatchJson}
110 tokensResetKey={bridge.tokensResetKey}
111 onDebug={(event) => {
112 bridge.onDebug(event);
113 const { message, contentHeight } = event.nativeEvent;
114 if (message === "metrics" && typeof contentHeight === "number") {
115 setMeasuredHeight((current) =>
116 current?.key === measurementKey && current.height === contentHeight
117 ? current
118 : { key: measurementKey, height: contentHeight },
119 );
120 }
121 }}
122 onPressLine={onOpen}
123 />
124 </View>
125 ) : (
126 <Text className="p-3 text-xs text-foreground-muted">
127 {loading
128 ? `Loading ${excerpt.path}…`
129 : `${excerpt.path}:${excerpt.start}–${excerpt.end} is not in the loaded diff.`}
130 </Text>
131 )}
132 </View>
133 </View>
134 );
135}
apps/mobile/src/features/pull-requests/guide/guideDiffFocus.test.ts created+23
......@@ -0,0 +1,23 @@
1import { describe, expect, it } from "vite-plus/test";
2import { guideFocusedRows } from "./guideDiffFocus";
3
4describe("guide diff focus", () => {
5 const rows = [
6 { id: "old", kind: "line" as const, fileId: "a", oldLineNumber: 10, change: "delete" as const },
7 { id: "new", kind: "line" as const, fileId: "a", newLineNumber: 20, change: "add" as const },
8 { id: "context", kind: "line" as const, fileId: "a", oldLineNumber: 11, newLineNumber: 21 },
9 { id: "other", kind: "line" as const, fileId: "b", newLineNumber: 20 },
10 ];
11 it("uses the requested side and includes both range boundaries only in the target file", () => {
12 expect(
13 guideFocusedRows(rows, "a", { side: "old", start: 10, end: 11 }).map((r) => r.id),
14 ).toEqual(["old", "context"]);
15 expect(
16 guideFocusedRows(rows, "a", { side: "new", start: 20, end: 21 }).map((r) => r.id),
17 ).toEqual(["new", "context"]);
18 });
19 it("does not select unrelated rows while a later file is loading", () => {
20 expect(guideFocusedRows(rows, undefined, { side: "new", start: 20, end: 21 })).toEqual([]);
21 expect(guideFocusedRows(rows, "a", undefined)).toEqual([]);
22 });
23});
apps/mobile/src/features/pull-requests/guide/guideDiffFocus.ts created+26
......@@ -0,0 +1,26 @@
1import type { NativeReviewDiffRow } from "../../diffs/nativeReviewDiffSurface";
2
3export type GuideDiffFocus = {
4 readonly side: "old" | "new";
5 readonly start: number;
6 readonly end: number;
7};
8
9/** Match original diff coordinates, including deletions and renamed files. */
10export function guideFocusedRows(
11 rows: readonly NativeReviewDiffRow[],
12 fileId: string | undefined,
13 focus: GuideDiffFocus | undefined,
14) {
15 if (!fileId || !focus) return [];
16 return rows.filter((row) => {
17 const line = focus.side === "old" ? row.oldLineNumber : row.newLineNumber;
18 return (
19 row.fileId === fileId &&
20 row.kind === "line" &&
21 line != null &&
22 line >= focus.start &&
23 line <= focus.end
24 );
25 });
26}
apps/mobile/src/features/pull-requests/guide/reviewGuideState.test.ts created+48
......@@ -0,0 +1,48 @@
1import { describe, expect, it } from "vite-plus/test";
2import {
3 IsoDateTime,
4 ProviderInstanceId,
5 type PullRequestReviewGuideResult,
6} from "@t3tools/contracts";
7import { reviewGuideState } from "./reviewGuideState";
8
9const guide: NonNullable<PullRequestReviewGuideResult["guide"]> = {
10 headSha: "old-head",
11 generatedAt: IsoDateTime.make("2026-10-11T00:00:00.000Z"),
12 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
13 stops: [],
14};
15
16describe("mobile review guide state", () => {
17 it("keeps a previous guide readable during a rewrite", () => {
18 const state = reviewGuideState({
19 guide,
20 currentHeadSha: "new-head",
21 run: { _tag: "Running", headSha: "new-head", startedAt: guide.generatedAt },
22 });
23 expect(state).toMatchObject({ guide, stale: true, running: true, failure: null });
24 });
25 it("does not label an unknown head stale", () => {
26 expect(reviewGuideState({ guide, currentHeadSha: null, run: null }).stale).toBe(false);
27 });
28 it("shows only failures for the current head", () => {
29 const run = {
30 _tag: "Failed" as const,
31 headSha: "old-head",
32 failedAt: guide.generatedAt,
33 detail: "The writer disconnected.",
34 };
35 expect(reviewGuideState({ guide: null, currentHeadSha: "old-head", run }).failure).toBe(
36 run.detail,
37 );
38 expect(reviewGuideState({ guide: null, currentHeadSha: "new-head", run }).failure).toBeNull();
39 });
40 it("starts empty without claiming a job is running", () => {
41 expect(reviewGuideState(null)).toEqual({
42 guide: null,
43 stale: false,
44 running: false,
45 failure: null,
46 });
47 });
48});
apps/mobile/src/features/pull-requests/guide/reviewGuideState.ts created+15
......@@ -0,0 +1,15 @@
1import type { PullRequestReviewGuideResult } from "@t3tools/contracts";
2
3/** Old failures must not follow a PR onto a different head. */
4export function reviewGuideState(data: PullRequestReviewGuideResult | null) {
5 const guide = data?.guide ?? null;
6 return {
7 guide,
8 running: data?.run?._tag === "Running",
9 stale: guide !== null && data?.currentHeadSha != null && guide.headSha !== data.currentHeadSha,
10 failure:
11 data?.run?._tag === "Failed" && data.run.headSha === data.currentHeadSha
12 ? data.run.detail
13 : null,
14 };
15}
apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts created+61
......@@ -0,0 +1,61 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useFocusEffect } from "@react-navigation/native";
3import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
4import { useCallback, useRef, useState } from "react";
5import { composerPullRequests } from "../../../state/pull-requests";
6import { useEnvironmentQuery } from "../../../state/query";
7import { serverEnvironment } from "../../../state/server";
8import { useAtomCommand } from "../../../state/use-atom-command";
9import { pullRequestTarget, type PullRequestParams } from "../usePullRequestDiff";
10
11export function useReviewGuidesEnabled(params: PullRequestParams) {
12 const settings = useAtomValue(
13 serverEnvironment.settingsValueAtom(pullRequestTarget(params).environmentId),
14 );
15 return settings?.reviewGuidesEnabled === true;
16}
17
18export function useReviewGuide(params: PullRequestParams) {
19 const target = pullRequestTarget(params);
20 const enabled = useReviewGuidesEnabled(params);
21 const query = useEnvironmentQuery(enabled ? composerPullRequests.reviewGuide(target) : null);
22 const canWrite = useAtomValue(
23 composerPullRequests.generateReviewGuide.permissionAtom(target.environmentId),
24 );
25 const generate = useAtomCommand(composerPullRequests.generateReviewGuide, {
26 reportFailure: false,
27 });
28 const [starting, setStarting] = useState(false);
29 const [error, setError] = useState<string | null>(null);
30 const pending = useRef(false);
31 const running = starting || query.data?.run?._tag === "Running";
32 const refresh = query.refresh;
33 useFocusEffect(
34 useCallback(() => {
35 if (!enabled) return;
36 refresh();
37 if (!running) return;
38 const timer = setInterval(refresh, 5_000);
39 return () => clearInterval(timer);
40 }, [enabled, refresh, running]),
41 );
42 const write = async () => {
43 if (!enabled || !canWrite || running || pending.current) return;
44 pending.current = true;
45 setStarting(true);
46 setError(null);
47 try {
48 const result = await generate(target);
49 if (result._tag === "Failure") {
50 const cause = squashAtomCommandFailure(result);
51 setError(cause instanceof Error ? cause.message : "Could not start the review guide.");
52 } else {
53 refresh();
54 }
55 } finally {
56 pending.current = false;
57 setStarting(false);
58 }
59 };
60 return { ...query, enabled, canWrite, running, write, startError: error };
61}
apps/mobile/src/features/pull-requests/pullRequestActivityFilter.test.ts created+90
......@@ -0,0 +1,90 @@
1import { describe, expect, it } from "vite-plus/test";
2import { IsoDateTime, type PullRequestActivity, type PullRequestActor } from "@t3tools/contracts";
3import { DEFAULT_ACTIVITY_FILTER, filterPullRequestActivity } from "./pullRequestActivityFilter";
4
5const date = IsoDateTime.make("2026-10-11T00:00:00.000Z");
6const human: PullRequestActor = { login: "reader", name: null, avatarUrl: null };
7const bot: PullRequestActor = { ...human, login: "reviewer[bot]" };
8const comment = (id: string, author = human, reviewState: string | null = null) => ({
9 id,
10 author,
11 reviewState,
12 kind: "issue-comment" as const,
13 body: "A comment",
14 createdAt: date,
15 url: null,
16 path: null,
17});
18const activity: PullRequestActivity = {
19 comments: [
20 comment("human"),
21 comment("bot", bot),
22 comment("dismissed", human, "DISMISSED"),
23 comment("reply"),
24 ],
25 commentCount: 4,
26 commentsTruncated: false,
27 commits: [{ oid: "abcdef", messageHeadline: "A change", committedDate: date }],
28 reviewThreads: [
29 {
30 id: "thread",
31 path: "file.ts",
32 line: 1,
33 side: "right",
34 isResolved: true,
35 isOutdated: false,
36 comments: [comment("reply")],
37 },
38 ],
39};
40
41describe("mobile activity filters", () => {
42 it("hides bots and finished conversations by default, counting each thread once", () => {
43 const result = filterPullRequestActivity(activity, DEFAULT_ACTIVITY_FILTER);
44 expect(result.activity.comments.map((item) => item.id)).toEqual(["human"]);
45 expect(result.activity.reviewThreads).toEqual([]);
46 expect(result.activity.commits).toHaveLength(1);
47 expect(result.hiddenCount).toBe(3);
48 });
49 it("restores bot comments and finished conversations without duplicate thread replies", () => {
50 const result = filterPullRequestActivity(activity, {
51 ...DEFAULT_ACTIVITY_FILTER,
52 bots: true,
53 resolved: true,
54 });
55 expect(result.activity.comments.map((item) => item.id)).toEqual(["human", "bot", "dismissed"]);
56 expect(result.activity.reviewThreads).toHaveLength(1);
57 expect(result.hiddenCount).toBe(0);
58 });
59 it("keeps partially loaded bot discussions visible", () => {
60 const thread = {
61 ...activity.reviewThreads[0]!,
62 isResolved: false,
63 comments: [comment("bot-reply", bot)],
64 };
65 for (const incomplete of [{ commentCount: 2 }, { nextCommentsCursor: "next" }]) {
66 const result = filterPullRequestActivity(
67 { ...activity, reviewThreads: [{ ...thread, ...incomplete }] },
68 DEFAULT_ACTIVITY_FILTER,
69 );
70 expect(result.activity.reviewThreads).toHaveLength(1);
71 }
72 expect(
73 filterPullRequestActivity({ ...activity, reviewThreads: [thread] }, DEFAULT_ACTIVITY_FILTER)
74 .activity.reviewThreads,
75 ).toEqual([]);
76 });
77 it("provides a commits-only view and a comments-only view", () => {
78 const commits = filterPullRequestActivity(activity, {
79 ...DEFAULT_ACTIVITY_FILTER,
80 kind: "commits",
81 }).activity;
82 expect(commits.comments).toEqual([]);
83 expect(commits.reviewThreads).toEqual([]);
84 expect(commits.commits).toHaveLength(1);
85 expect(
86 filterPullRequestActivity(activity, { ...DEFAULT_ACTIVITY_FILTER, kind: "comments" }).activity
87 .commits,
88 ).toEqual([]);
89 });
90});
apps/mobile/src/features/pull-requests/pullRequestActivityFilter.ts created+66
......@@ -0,0 +1,66 @@
1import type { PullRequestActivity, PullRequestActor } from "@t3tools/contracts";
2
3export interface ActivityFilter {
4 readonly kind: "all" | "comments" | "commits";
5 readonly bots: boolean;
6 readonly resolved: boolean;
7 readonly newestFirst: boolean;
8}
9
10export const DEFAULT_ACTIVITY_FILTER: ActivityFilter = {
11 kind: "all",
12 bots: false,
13 resolved: false,
14 newestFirst: true,
15};
16
17function isBot(actor: PullRequestActor | null) {
18 return actor?.isBot === true || actor?.login.endsWith("[bot]") === true;
19}
20
21function eventCount(activity: PullRequestActivity) {
22 const discussionIds = new Set(
23 activity.reviewThreads.flatMap((thread) => thread.comments.map((comment) => comment.id)),
24 );
25 return (
26 activity.comments.filter((comment) => !discussionIds.has(comment.id)).length +
27 activity.reviewThreads.length +
28 activity.commits.length
29 );
30}
31
32export function filterPullRequestActivity(activity: PullRequestActivity, filter: ActivityFilter) {
33 // A discussion's replies stay together, even when a bot replies to a human.
34 const reviewThreads =
35 filter.kind === "commits"
36 ? []
37 : activity.reviewThreads.filter(
38 (thread) =>
39 (filter.resolved || !thread.isResolved) &&
40 (filter.bots ||
41 (thread.commentCount ?? 0) > thread.comments.length ||
42 thread.nextCommentsCursor != null ||
43 thread.comments.some((comment) => !isBot(comment.author))),
44 );
45 const allDiscussionIds = new Set(
46 activity.reviewThreads.flatMap((thread) => thread.comments.map((comment) => comment.id)),
47 );
48 const comments =
49 filter.kind === "commits"
50 ? []
51 : activity.comments.filter(
52 (comment) =>
53 !allDiscussionIds.has(comment.id) &&
54 (filter.bots || !isBot(comment.author)) &&
55 (filter.resolved || comment.reviewState?.trim().toLowerCase() !== "dismissed"),
56 );
57 const filtered = {
58 ...activity,
59 comments,
60 reviewThreads,
61 commits: filter.kind === "comments" ? [] : activity.commits,
62 commentsTruncated: filter.kind !== "commits" && activity.commentsTruncated,
63 reviewThreadsTruncated: filter.kind !== "commits" && activity.reviewThreadsTruncated,
64 };
65 return { activity: filtered, hiddenCount: eventCount(activity) - eventCount(filtered) };
66}
apps/mobile/src/features/pull-requests/pullRequestAnimations.ts created+37
......@@ -0,0 +1,37 @@
1import {
2 FadeIn,
3 FadeOut,
4 LinearTransition,
5 ReduceMotion,
6 withTiming,
7 type LayoutAnimationFunction,
8} from "react-native-reanimated";
9
10// Match the model picker: move existing rows, briefly fade additions and removals.
11export const PR_ROW_LAYOUT = LinearTransition.duration(180).reduceMotion(ReduceMotion.System);
12export const PR_ROW_ENTER = FadeIn.duration(140).reduceMotion(ReduceMotion.System);
13export const PR_ROW_EXIT = FadeOut.duration(100).reduceMotion(ReduceMotion.System);
14
15export const PR_DISCLOSURE_ENTER = FadeIn.delay(80).duration(120).reduceMotion(ReduceMotion.System);
16
17// Follow expanding status rows without interpolating the clipped markdown's height.
18export const PR_POSITION_LAYOUT: LayoutAnimationFunction = (values) => {
19 "worklet";
20 return {
21 initialValues: {
22 originX: values.targetOriginX,
23 originY: values.currentOriginY,
24 width: values.targetWidth,
25 height: values.targetHeight,
26 },
27 animations: {
28 originX: values.targetOriginX,
29 originY: withTiming(values.targetOriginY, {
30 duration: 180,
31 reduceMotion: ReduceMotion.System,
32 }),
33 width: values.targetWidth,
34 height: values.targetHeight,
35 },
36 };
37};
apps/mobile/src/features/pull-requests/pullRequestFileFilters.test.ts+11
......@@ -67,6 +67,17 @@ describe("mobile PR test file filter", () => {
6767 expect(parsed.files).toHaveLength(4);
6868 });
6969
70 it("keeps a guide's target test file visible without exposing other hidden tests", () => {
71 const parsed = buildReviewParsedDiff(
72 ["src/first.test.ts", "src/app.ts", "src/second.test.ts"].map(patch).join("\n"),
73 "pr",
74 );
75 const visible = filterPullRequestTestFiles(parsed, true, "src/second.test.ts");
76 if (parsed.kind !== "files" || visible.kind !== "files") throw new Error("Expected files");
77 expect(visible.files.map((file) => file.path)).toEqual(["src/app.ts", "src/second.test.ts"]);
78 expect(visible.files[1]).toBe(parsed.files[2]);
79 });
80
7081 it("uses the current path when a file is renamed out of a test directory", () => {
7182 const parsed = buildReviewParsedDiff(
7283 "diff --git a/tests/helper.ts b/src/helper.ts\nsimilarity index 100%\nrename from tests/helper.ts\nrename to src/helper.ts\n",
apps/mobile/src/features/pull-requests/pullRequestFileFilters.ts+4-1
......@@ -21,9 +21,12 @@ export function isPullRequestTestFile(path: string): boolean {
2121export function filterPullRequestTestFiles(
2222 parsed: ReviewParsedDiff,
2323 hideTestFiles: boolean,
24 includePath?: string,
2425): ReviewParsedDiff {
2526 if (!hideTestFiles || parsed.kind !== "files") return parsed;
26 const files = parsed.files.filter((file) => !isPullRequestTestFile(file.path));
27 const files = parsed.files.filter(
28 (file) => file.path === includePath || !isPullRequestTestFile(file.path),
29 );
2730 if (files.length === parsed.files.length) return parsed;
2831 return {
2932 ...parsed,
apps/mobile/src/features/pull-requests/pullRequestTabScroll.test.ts created+14
......@@ -0,0 +1,14 @@
1import { describe, expect, it } from "vite-plus/test";
2import { pullRequestTabOffset } from "./pullRequestTabScroll";
3
4describe("pull request tab scroll", () => {
5 it("keeps the header fixed while switching, even if the destination was scrolled deeply", () => {
6 expect(pullRequestTabOffset(-100, 900, 200)).toBe(-100);
7 expect(pullRequestTabOffset(75, 900, 200)).toBe(75);
8 });
9 it("resumes the destination body only when the shared header is already collapsed", () => {
10 expect(pullRequestTabOffset(600, 900, 200)).toBe(900);
11 expect(pullRequestTabOffset(600, 50, 200)).toBe(200);
12 expect(pullRequestTabOffset(600, undefined, 200)).toBe(200);
13 });
14});
apps/mobile/src/features/pull-requests/pullRequestTabScroll.ts created+8
......@@ -0,0 +1,8 @@
1/** Preserve the shared header's position, then each tab's own reading position. */
2export function pullRequestTabOffset(
3 current: number,
4 saved: number | undefined,
5 headerEnd: number,
6) {
7 return current < headerEnd ? current : Math.max(headerEnd, saved ?? headerEnd);
8}
apps/mobile/src/features/pull-requests/usePullRequestFileFilter.ts+3-3
......@@ -5,14 +5,14 @@ import type { ReviewParsedDiff } from "../review/reviewModel";
55import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
66import { filterPullRequestTestFiles } from "./pullRequestFileFilters";
77
8export function usePullRequestFileFilter(parsed: ReviewParsedDiff) {
8export function usePullRequestFileFilter(parsed: ReviewParsedDiff, includePath?: string) {
99 const preferences = useAtomValue(mobilePreferencesAtom);
1010 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
1111 const hideTestFiles =
1212 AsyncResult.isSuccess(preferences) && preferences.value.pullRequestHideTestFiles === true;
1313 const visible = useMemo(
14 () => filterPullRequestTestFiles(parsed, hideTestFiles),
15 [parsed, hideTestFiles],
14 () => filterPullRequestTestFiles(parsed, hideTestFiles, includePath),
15 [parsed, hideTestFiles, includePath],
1616 );
1717 const hiddenCount =
1818 parsed.kind === "files" && visible.kind === "files" ? parsed.fileCount - visible.fileCount : 0;
apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx+7-2
......@@ -1,7 +1,7 @@
11import { useNativeLayoutMetrics } from "../../native/native-layout-metrics";
22import { useCallback, useMemo, useState, type ReactNode } from "react";
33import { Keyboard, Platform, View, type NativeSyntheticEvent } from "react-native";
4import { Tabs, type TabSelectedEvent } from "react-native-screens";
4import { Tabs, type TabSelectedEvent, type TabSelectionRejectedEvent } from "react-native-screens";
55
66import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext";
77import type { WorkspaceTab } from "./workspace-tabs-state";
......@@ -70,11 +70,16 @@ export function WorkspaceNativeTabs(props: {
7070 tabBarMinimizeBehavior: "never",
7171 tabBarLeadingWidth: phoneBar ? 220 : 0,
7272 }}
73 onTabSelectionRejected={({
74 nativeEvent,
75 }: NativeSyntheticEvent<TabSelectionRejectedEvent>) => {
76 setProvenance(nativeEvent.provenance);
77 }}
7378 onTabSelected={({ nativeEvent }: NativeSyntheticEvent<TabSelectedEvent>) => {
7479 const tab = nativeEvent.selectedScreenKey;
7580 if (tab !== "threads" && tab !== "pullRequests") return;
7681 setProvenance(nativeEvent.provenance);
77 if (tab === selectedTab) return;
82 if (nativeEvent.actionOrigin === "programmatic-js" || tab === selectedTab) return;
7883 Keyboard.dismiss();
7984 props.onSelect();
8085 workspace.dispatch({ type: "select", tab });
apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx+14-5
......@@ -2,7 +2,7 @@ import { useNavigation } from "@react-navigation/native";
22import { use, useCallback, useMemo } from "react";
33import { Platform, View, useWindowDimensions } from "react-native";
44import { useKeyboardState } from "react-native-keyboard-controller";
5import { useSafeAreaInsets } from "react-native-safe-area-context";
5import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
66
77import { CompactBrandTitle } from "../../components/CompactBrandTitle";
88import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
......@@ -24,7 +24,8 @@ import {
2424} from "./WorkspaceTabsContext";
2525import { WorkspaceToolbar } from "./WorkspaceToolbar";
2626import { WORKSPACE_DOCK, workspaceToolbarHeight } from "./workspace-tabs-state";
27import { useSystemWorkspaceTabBar } from "./useSystemWorkspaceTabBar";
27import { WorkspaceSidebarTabs } from "./WorkspaceSidebarTabs";
28import { useSidebarWorkspaceTabs, useSystemWorkspaceTabBar } from "./useSystemWorkspaceTabBar";
2829
2930const TAB_TITLES = {
3031 threads: "Threads",
......@@ -58,6 +59,7 @@ function WorkspaceRootContent() {
5859 const columnMetrics = useNativeColumnLayoutMetrics();
5960 const nativeMetrics = useNativeLayoutMetrics();
6061 const systemTabBar = useSystemWorkspaceTabBar();
62 const sidebarTabs = useSidebarWorkspaceTabs();
6163 const sidebarNavigation =
6264 primaryColumn !== null &&
6365 (layout.usesSplitView ||
......@@ -86,7 +88,7 @@ function WorkspaceRootContent() {
8688 const bottomClearance =
8789 Platform.OS === "ios"
8890 ? Math.max(insets.bottom, keyboardHeight) +
89 (systemTabBar || tab.searchMode !== "resting"
91 (systemTabBar || sidebarTabs || tab.searchMode !== "resting"
9092 ? 16
9193 : WORKSPACE_DOCK.height + WORKSPACE_DOCK.bottomGap + 16)
9294 : keyboardHeight > 0 && tab.searchMode !== "resting"
......@@ -96,7 +98,10 @@ function WorkspaceRootContent() {
9698
9799 return (
98100 <WorkspaceTabsContext value={context}>
99 <View className="flex-1 bg-screen">
101 <View
102 className="flex-1 bg-screen"
103 style={sidebarTabs ? { paddingTop: insets.top } : undefined}
104 >
100105 {Platform.OS === "ios" ? (
101106 <NativeStackScreenOptions
102107 optionsVersion={[state.selectedTab, state.selectionRevision, width, sidebarNavigation]}
......@@ -132,7 +137,11 @@ function WorkspaceRootContent() {
132137 ]}
133138 />
134139 )}
135 {state.selectedTab === "threads" ? <HomeRouteScreen /> : <PullRequestsScreen />}
140 {sidebarTabs ? <WorkspaceSidebarTabs /> : null}
141 {/* The pinned picker already clears the navigation bar; lists must not inset it again. */}
142 <SafeAreaInsetsContext value={sidebarTabs ? { ...insets, top: 0 } : insets}>
143 {state.selectedTab === "threads" ? <HomeRouteScreen /> : <PullRequestsScreen />}
144 </SafeAreaInsetsContext>
136145 <WorkspaceToolbar
137146 sidebar={sidebarNavigation}
138147 onNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
apps/mobile/src/features/workspace/WorkspaceSidebarTabs.ios.tsx created+38
......@@ -0,0 +1,38 @@
1import { Host, Picker, Text } from "@expo/ui/swift-ui";
2import { pickerStyle, tag } from "@expo/ui/swift-ui/modifiers";
3import { StackActions, useNavigation } from "@react-navigation/native";
4import { Keyboard, View } from "react-native";
5import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
6import { useWorkspaceTabs } from "./WorkspaceTabsContext";
7import type { WorkspaceTab } from "./workspace-tabs-state";
8
9/** Wide iPad navigation belongs to the sidebar, leaving the detail's header unobstructed. */
10export function WorkspaceSidebarTabs() {
11 const workspace = useWorkspaceTabs()!;
12 const navigation = useNavigation();
13 const { themeAppearance } = useAppearancePreferences();
14 return (
15 <View className="px-4 pb-3 pt-2">
16 <Host
17 matchContents={{ vertical: true }}
18 colorScheme={themeAppearance}
19 style={{ width: "100%" }}
20 >
21 <Picker
22 label="Workspace"
23 selection={workspace.state.selectedTab}
24 onSelectionChange={(tab: WorkspaceTab) => {
25 if (tab === workspace.state.selectedTab) return;
26 Keyboard.dismiss();
27 if (navigation.getState()?.index) navigation.dispatch(StackActions.popToTop());
28 workspace.dispatch({ type: "select", tab });
29 }}
30 modifiers={[pickerStyle("segmented")]}
31 >
32 <Text modifiers={[tag("threads")]}>Threads</Text>
33 <Text modifiers={[tag("pullRequests")]}>Pull requests</Text>
34 </Picker>
35 </Host>
36 </View>
37 );
38}
apps/mobile/src/features/workspace/WorkspaceSidebarTabs.tsx created+4
......@@ -0,0 +1,4 @@
1/** The sidebar workspace picker is native to wide iPad layouts. */
2export function WorkspaceSidebarTabs() {
3 return null;
4}
apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx+2-5
......@@ -9,20 +9,17 @@ import { NativeWorkspaceTabActivityContext } from "../../native/v5-workspace-con
99import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
1010import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
1111import { useWorkspaceTabs } from "./WorkspaceTabsContext";
12import { useSystemWorkspaceTabBar } from "./useSystemWorkspaceTabBar";
1312
14/** Root actions stay in the header; UIKit owns the tab bar and bottom compose button. */
13/** Root actions stay in the header; UIKit owns the bottom compose button. */
1514export function WorkspaceToolbar(props: {
1615 readonly onNewTask: () => void;
1716 readonly sidebar?: boolean;
1817}) {
1918 const workspace = useWorkspaceTabs()!;
2019 const navigation = useNavigation();
21 const systemTabBar = useSystemWorkspaceTabBar();
2220 const metrics = useNativeLayoutMetrics();
2321 const nativeToolbarCompose =
24 systemTabBar &&
25 ((Platform.OS === "ios" && Platform.isPad) || (metrics?.verticalBarEdge ?? "none") !== "none");
22 Platform.OS === "ios" && (Platform.isPad || (metrics?.verticalBarEdge ?? "none") !== "none");
2623 const { isTabActive } = workspace;
2724 const tabActive = use(NativeWorkspaceTabActivityContext)?.active ?? true;
2825 const { panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
apps/mobile/src/features/workspace/useSystemWorkspaceTabBar.ts+9-2
......@@ -1,6 +1,13 @@
11import { Platform } from "react-native";
2import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
23
3/** UIKit owns workspace tab navigation on every iOS size class. */
4export function useSidebarWorkspaceTabs() {
5 const { layout } = useAdaptiveWorkspaceLayout();
6 return Platform.OS === "ios" && Platform.isPad && layout.usesSplitView;
7}
8
9/** Compact iOS windows use UIKit tabs; wide iPad windows use the sidebar picker. */
410export function useSystemWorkspaceTabBar() {
5 return Platform.OS === "ios";
11 const sidebarTabs = useSidebarWorkspaceTabs();
12 return Platform.OS === "ios" && !sidebarTabs;
613}
apps/web/src/components/pullRequest/guide/GuideExcerpt.tsx+1-1
......@@ -36,7 +36,7 @@ import {
3636 excerptRows,
3737 fileDiffRows,
3838 rowsToPatch,
39} from "./guideTour.logic";
39} from "@t3tools/client-runtime/review-guide-tour";
4040import { GuideNoteMarker } from "./GuideNotes";
4141import { GuideProse } from "./GuideProse";
4242
apps/web/src/components/pullRequest/guide/GuideRail.tsx+1-1
......@@ -4,7 +4,7 @@ import { CheckIcon } from "lucide-react";
44import { cn } from "~/lib/utils";
55
66import { Badge } from "../../ui/badge";
7import { stopSubline } from "./guideTour.logic";
7import { stopSubline } from "@t3tools/client-runtime/review-guide-tour";
88
99/**
1010 * The tour's table of contents: where the reader is and which stops they have read past. A stop
apps/web/src/components/pullRequest/guide/GuideStop.tsx+1-1
......@@ -28,7 +28,7 @@ import { GuideExcerpt, type ExcerptCommenting } from "./GuideExcerpt";
2828import { GuideFileDiff } from "./GuideFileDiff";
2929import { GuideNotes } from "./GuideNotes";
3030import { GuideProse } from "./GuideProse";
31import { letterStopNotes } from "./guideTour.logic";
31import { letterStopNotes } from "@t3tools/client-runtime/review-guide-tour";
3232
3333const ATTENTION_LABELS = {
3434 tradeoff: "A tradeoff to weigh",
apps/web/src/components/pullRequest/guide/GuideTour.tsx+1-1
......@@ -18,7 +18,7 @@ import {
1818 useGuideRunStore,
1919 type GuideProgress,
2020} from "./guideRunStore";
21import { stepTourPosition, stopFilePaths } from "./guideTour.logic";
21import { stepTourPosition, stopFilePaths } from "@t3tools/client-runtime/review-guide-tour";
2222import { useGuideDiffFiles } from "./useGuideDiffFiles";
2323
2424/**
apps/web/src/components/pullRequest/guide/guideTour.logic.test.ts deleted-178
......@@ -1,178 +0,0 @@
1import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
2import type { PullRequestReviewGuideStop } from "@t3tools/contracts";
3import { describe, expect, it } from "vite-plus/test";
4
5import {
6 excerptExpansionOldContents,
7 excerptRows,
8 fileDiffRows,
9 letterStopNotes,
10 rowsToPatch,
11 stepTourPosition,
12} from "./guideTour.logic";
13
14const PATCH = `diff --git a/src/tray.ts b/src/tray.ts
15--- a/src/tray.ts
16+++ b/src/tray.ts
17@@ -10,5 +10,6 @@ function tray()
18 const a = 1;
19-const size = pick();
20+const size = "xs";
21+const variant = "tray";
22 const b = 2;
23 const c = 3;
24 const d = 4;
25@@ -40,3 +41,2 @@ function footer()
26 keep();
27-dropped();
28 tail();
29`;
30
31function parse(patch: string) {
32 const file = parsePatchFiles(patch, `test:${patch.length}`)[0]?.files[0];
33 if (file === undefined) throw new Error("patch did not parse");
34 return file;
35}
36
37const rows = fileDiffRows(parse(PATCH));
38
39function stop(overrides: Partial<PullRequestReviewGuideStop>): PullRequestReviewGuideStop {
40 return {
41 title: "Stop",
42 summary: "",
43 attention: null,
44 excerpts: [],
45 files: [],
46 notes: [],
47 questions: [],
48 ...overrides,
49 };
50}
51
52describe("stepTourPosition", () => {
53 it("holds the tour to its first and last stops", () => {
54 expect(stepTourPosition(0, -1, 3)).toBe(0);
55 expect(stepTourPosition(0, 1, 3)).toBe(1);
56 expect(stepTourPosition(2, 1, 3)).toBe(2);
57 });
58});
59
60describe("fileDiffRows", () => {
61 it("numbers both sides through context, deletions and additions", () => {
62 expect(rows.map((row) => [row.kind, row.oldLine, row.newLine])).toEqual([
63 ["context", 10, 10],
64 ["deletion", 11, null],
65 ["addition", null, 11],
66 ["addition", null, 12],
67 ["context", 12, 13],
68 ["context", 13, 14],
69 ["context", 14, 15],
70 ["context", 40, 41],
71 ["deletion", 41, null],
72 ["context", 42, 42],
73 ]);
74 });
75});
76
77describe("excerptRows", () => {
78 it("brings the replaced lines along with an excerpt that starts on an addition", () => {
79 const shown = excerptRows(rows, { side: "new", start: 11, end: 12 });
80 expect(shown.map((row) => row.kind)).toEqual(["deletion", "addition", "addition"]);
81 });
82
83 it("selects old-side lines for a removal and stops at the hunk boundary", () => {
84 const shown = excerptRows(rows, { side: "old", start: 41, end: 41 });
85 expect(shown.map((row) => [row.kind, row.oldLine])).toEqual([["deletion", 41]]);
86 });
87
88 it("returns nothing for a range outside the diff", () => {
89 expect(excerptRows(rows, { side: "new", start: 100, end: 120 })).toEqual([]);
90 });
91});
92
93describe("rowsToPatch", () => {
94 it("cuts a slice that parses back to the same lines and numbers", () => {
95 const shown = excerptRows(rows, { side: "new", start: 10, end: 13 });
96 const slice = parse(rowsToPatch("src/tray.ts", "src/tray.ts", shown));
97 expect(fileDiffRows(slice).map((row) => [row.kind, row.oldLine, row.newLine])).toEqual(
98 shown.map((row) => [row.kind, row.oldLine, row.newLine]),
99 );
100 });
101
102 it("starts a hunk per source hunk so separate hunks stay separate", () => {
103 const shown = excerptRows(rows, { side: "old", start: 14, end: 42 });
104 const patch = rowsToPatch("src/tray.ts", "src/tray.ts", shown);
105 expect(patch.match(/^@@ /gm)).toHaveLength(2);
106 expect(patch).toContain("@@ -14,1 +15,1 @@");
107 expect(patch).toContain("@@ -40,3 +41,2 @@");
108 });
109});
110
111describe("excerptExpansionOldContents", () => {
112 // The new file behind PATCH: lines 1-9, the hunk at 10-15, then lines up to 41-42 around the
113 // second hunk, which removed a line.
114 const newFile = Array.from({ length: 50 }, (_, index) => `line ${index + 1}\n`);
115 newFile.splice(
116 9,
117 6,
118 "const a = 1;\n",
119 'const size = "xs";\n',
120 'const variant = "tray";\n',
121 "const b = 2;\n",
122 "const c = 3;\n",
123 "const d = 4;\n",
124 );
125 newFile.splice(40, 2, "keep();\n", "tail();\n");
126 const newContents = newFile.join("");
127
128 it("puts back only the excerpt's lines and keeps both sides numbered as in the file", () => {
129 const shown = excerptRows(rows, { side: "new", start: 11, end: 12 });
130 const old = excerptExpansionOldContents(newContents, shown);
131 if (old === null) throw new Error("expected contents");
132 const oldLines = old.split("\n");
133 // The excerpt's old side sits at its real old line.
134 expect(oldLines[10]).toBe("const size = pick();");
135 // Around it, the new file's lines, so the two sides agree outside the excerpt.
136 expect(oldLines[9]).toBe("const a = 1;");
137 expect(oldLines[11]).toBe("const b = 2;");
138 // One line replaced by two: the old side is a line shorter, everything after lines up.
139 expect(oldLines.length).toBe(newContents.split("\n").length - 1);
140 });
141
142 it("does not try to expand an excerpt that spans hunks", () => {
143 const shown = excerptRows(rows, { side: "old", start: 14, end: 42 });
144 expect(excerptExpansionOldContents(newContents, shown)).toBeNull();
145 });
146});
147
148describe("letterStopNotes", () => {
149 it("letters notes in guide order and pins each to the excerpt that holds its line", () => {
150 const { lettered, byExcerpt } = letterStopNotes(
151 stop({
152 excerpts: [
153 { path: "a.ts", side: "new", start: 1, end: 5, caption: "" },
154 { path: "b.ts", side: "new", start: 10, end: 12, caption: "" },
155 ],
156 notes: [
157 { path: "b.ts", line: 11, title: "First", body: "" },
158 { path: "a.ts", line: 2, title: "Second", body: "" },
159 { path: "a.ts", line: 2, title: "Third", body: "" },
160 { path: "c.ts", line: 1, title: "Unanchored", body: "" },
161 ],
162 }),
163 );
164 expect(lettered.map((entry) => entry.marker)).toEqual(["A", "B", "C", "D"]);
165 expect(byExcerpt).toEqual([
166 [
167 {
168 line: 2,
169 markers: [
170 { marker: "B", title: "Second" },
171 { marker: "C", title: "Third" },
172 ],
173 },
174 ],
175 [{ line: 11, markers: [{ marker: "A", title: "First" }] }],
176 ]);
177 });
178});
apps/web/src/components/pullRequest/guide/guideTour.logic.ts deleted-224
......@@ -1,224 +0,0 @@
1import type { FileDiffMetadata } from "@pierre/diffs";
2import type { PullRequestReviewGuideExcerpt, PullRequestReviewGuideStop } from "@t3tools/contracts";
3
4/** One step forward or back, held to the tour's first and last stops. */
5export function stepTourPosition(current: number, delta: 1 | -1, stopCount: number): number {
6 return Math.max(Math.min(current + delta, stopCount - 1), 0);
7}
8
9/** The stop's files in reading order: the excerpted ones first, then the rest it owns. */
10export function stopFilePaths(stop: PullRequestReviewGuideStop): ReadonlyArray<string> {
11 return [
12 ...new Set([
13 ...stop.excerpts.map((excerpt) => excerpt.path),
14 ...stop.files.map((file) => file.path),
15 ]),
16 ];
17}
18
19/** The rail's second line for a stop. */
20export function stopSubline(stop: PullRequestReviewGuideStop): string {
21 const count = stopFilePaths(stop).length;
22 if (count === 0) return "Context, no code";
23 return count === 1 ? "1 file" : `${count} files`;
24}
25
26/** A note's marker: A, B, C… in the order the guide lists them, numbers past Z. */
27function noteMarker(index: number): string {
28 return index < 26 ? String.fromCharCode(65 + index) : String(index + 1);
29}
30
31/** One rendered line of a file's diff, with where it sits on each side. */
32export interface DiffRow {
33 readonly kind: "context" | "addition" | "deletion";
34 /** Line number on the old side, null for an added line. */
35 readonly oldLine: number | null;
36 /** Line number on the new side, null for a removed line. */
37 readonly newLine: number | null;
38 /** The next line number on each side when this row is reached, for hunk headers. */
39 readonly oldAt: number;
40 readonly newAt: number;
41 readonly text: string;
42 /** Which hunk of the file the row came from; rows from two hunks are not adjacent. */
43 readonly hunk: number;
44}
45
46/** Every line of a parsed file diff as the unified view lists it. */
47export function fileDiffRows(file: FileDiffMetadata): ReadonlyArray<DiffRow> {
48 const rows: DiffRow[] = [];
49 file.hunks.forEach((hunk, hunkIndex) => {
50 let oldAt = hunk.deletionStart;
51 let newAt = hunk.additionStart;
52 for (const content of hunk.hunkContent) {
53 if (content.type === "context") {
54 for (let offset = 0; offset < content.lines; offset += 1) {
55 rows.push({
56 kind: "context",
57 oldLine: oldAt,
58 newLine: newAt,
59 oldAt,
60 newAt,
61 text: file.additionLines[content.additionLineIndex + offset] ?? "",
62 hunk: hunkIndex,
63 });
64 oldAt += 1;
65 newAt += 1;
66 }
67 continue;
68 }
69 for (let offset = 0; offset < content.deletions; offset += 1) {
70 rows.push({
71 kind: "deletion",
72 oldLine: oldAt,
73 newLine: null,
74 oldAt,
75 newAt,
76 text: file.deletionLines[content.deletionLineIndex + offset] ?? "",
77 hunk: hunkIndex,
78 });
79 oldAt += 1;
80 }
81 for (let offset = 0; offset < content.additions; offset += 1) {
82 rows.push({
83 kind: "addition",
84 oldLine: null,
85 newLine: newAt,
86 oldAt,
87 newAt,
88 text: file.additionLines[content.additionLineIndex + offset] ?? "",
89 hunk: hunkIndex,
90 });
91 newAt += 1;
92 }
93 }
94 });
95 return rows;
96}
97
98/**
99 * The rows an excerpt covers. Its range is in one side's line numbers, so the other side's half
100 * of a replacement at either edge comes along: an excerpt starting on an added line also shows
101 * the lines it replaced, which is what makes it read as a change rather than as new code.
102 */
103export function excerptRows(
104 rows: ReadonlyArray<DiffRow>,
105 excerpt: Pick<PullRequestReviewGuideExcerpt, "side" | "start" | "end">,
106): ReadonlyArray<DiffRow> {
107 const lineOf = (row: DiffRow) => (excerpt.side === "new" ? row.newLine : row.oldLine);
108 const inRange = (row: DiffRow) => {
109 const line = lineOf(row);
110 return line !== null && line >= excerpt.start && line <= excerpt.end;
111 };
112 let first = rows.findIndex(inRange);
113 if (first === -1) return [];
114 let last = rows.findLastIndex(inRange);
115 const sameHunk = (index: number, anchor: number) => rows[index]?.hunk === rows[anchor]?.hunk;
116 if (excerpt.side === "new" && rows[first]?.kind === "addition") {
117 while (first > 0 && rows[first - 1]?.kind === "deletion" && sameHunk(first - 1, first)) {
118 first -= 1;
119 }
120 }
121 if (excerpt.side === "old" && rows[last]?.kind === "deletion") {
122 while (
123 last < rows.length - 1 &&
124 rows[last + 1]?.kind === "addition" &&
125 sameHunk(last + 1, last)
126 ) {
127 last += 1;
128 }
129 }
130 return rows.slice(first, last + 1);
131}
132
133const ROW_PREFIX = { context: " ", addition: "+", deletion: "-" } as const;
134
135/**
136 * A unified patch of just these rows, so the shared diff renderer can draw a slice of a file
137 * with its real line numbers instead of a second diff view being written for excerpts.
138 */
139export function rowsToPatch(
140 path: string,
141 previousPath: string,
142 rows: ReadonlyArray<DiffRow>,
143): string {
144 if (rows.length === 0) return "";
145 const lines = [
146 `diff --git a/${previousPath} b/${path}`,
147 `--- a/${previousPath}`,
148 `+++ b/${path}`,
149 ];
150 let start = 0;
151 while (start < rows.length) {
152 let end = start;
153 while (end + 1 < rows.length && rows[end + 1]?.hunk === rows[start]?.hunk) end += 1;
154 const group = rows.slice(start, end + 1);
155 const head = group[0]!;
156 const oldCount = group.filter((row) => row.oldLine !== null).length;
157 const newCount = group.filter((row) => row.newLine !== null).length;
158 // Git names the line before an empty side, so a pure insertion after line 9 is `-9,0`.
159 const oldStart = oldCount === 0 ? Math.max(head.oldAt - 1, 0) : head.oldAt;
160 const newStart = newCount === 0 ? Math.max(head.newAt - 1, 0) : head.newAt;
161 lines.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`);
162 for (const row of group) lines.push(`${ROW_PREFIX[row.kind]}${row.text.replace(/\r?\n$/, "")}`);
163 start = end + 1;
164 }
165 return `${lines.join("\n")}\n`;
166}
167
168/**
169 * The old side an excerpt expands against: the new file with only the excerpt's lines put back
170 * as they were. The renderer fills the code around a hunk from both files and needs them to
171 * agree outside it, which the real old file does not wherever another hunk changed lines. Only
172 * the lines the reader can expand into are read, and those are the new file's, so the code
173 * around the excerpt shows as it reads after the change. Null when the excerpt spans hunks.
174 */
175export function excerptExpansionOldContents(
176 newContents: string,
177 rows: ReadonlyArray<DiffRow>,
178): string | null {
179 const head = rows[0];
180 if (head === undefined || rows.some((row) => row.hunk !== head.hunk)) return null;
181 const newLines = newContents.match(/[^\n]*\n|[^\n]+$/g) ?? [];
182 const before = head.newAt - 1;
183 const newCount = rows.filter((row) => row.newLine !== null).length;
184 // The old side keeps its own numbering, so its prefix is as long as the old file's: trimmed
185 // from the front, or padded there with lines no expansion reaches.
186 const oldBefore = head.oldAt - 1;
187 const prefix = newLines.slice(0, before);
188 const oldPrefix =
189 oldBefore >= prefix.length
190 ? [...Array.from({ length: oldBefore - prefix.length }, () => "\n"), ...prefix]
191 : prefix.slice(prefix.length - oldBefore);
192 const oldRows = rows
193 .filter((row) => row.oldLine !== null)
194 .map((row) => (row.text.endsWith("\n") ? row.text : `${row.text}\n`));
195 return [...oldPrefix, ...oldRows, ...newLines.slice(before + newCount)].join("");
196}
197
198/** A stop's notes lettered in guide order, with the excerpt lines each one marks. */
199export interface LetteredNote<Note> {
200 readonly marker: string;
201 readonly note: Note;
202}
203
204/**
205 * Letters a stop's notes and groups them by the excerpt line they sit on. A note outside every
206 * excerpt keeps its letter in the list; it just has no line to mark.
207 */
208export function letterStopNotes(stop: PullRequestReviewGuideStop) {
209 const lettered = stop.notes.map((note, index) => ({ marker: noteMarker(index), note }));
210 const byExcerpt = stop.excerpts.map((excerpt) => {
211 const lines = new Map<number, Array<{ marker: string; title: string }>>();
212 for (const { marker, note } of lettered) {
213 if (note.path !== excerpt.path || note.line < excerpt.start || note.line > excerpt.end) {
214 continue;
215 }
216 const entry = { marker, title: note.title };
217 const list = lines.get(note.line);
218 if (list === undefined) lines.set(note.line, [entry]);
219 else list.push(entry);
220 }
221 return [...lines].map(([line, markers]) => ({ line, markers }));
222 });
223 return { lettered, byExcerpt };
224}
docs/user/source-control.md+6-3
......@@ -193,7 +193,9 @@ declined pull request.
193193### Review guides
194194
195195Review guides are in beta. Turn on **Review guides** in Settings → Source Control to add the
196**Guide** tab to pull requests on that server.
196**Guide** tab to pull requests on that server. On mobile, move between stops with **Next**
197and the back button; a stale guide offers a rewrite and hides excerpts that no longer match
198the current change.
197199
198200The **Guide** tab gives you a tour of a pull request without reading it line by line: stops
199201through the change in reading order. Each stop points at the lines worth reading, and a stop marked
......@@ -205,11 +207,12 @@ covers that layer only, and may point out what a lower layer introduced or what
205207Choose **Write guide** to have the review guide model from Settings → Source Control read the
206208change in a temporary checkout; without one set, the source control writer model is used. Nothing
207209in the checkout is changed, and a large pull request takes a few minutes; you can go elsewhere in
208the app and you are told when the guide is ready. Claude and Codex can write guides. The guide is
210the app. On web, you are told when the guide is ready; on mobile, return to the guide to check
211its progress. Claude and Codex can write guides. The guide is
209212kept for everyone using this server. To write it again, for example after a push or after changing
210213the model or instructions, choose **Rewrite guide** from the pull request's **⋯** menu.
211214
212While reading a stop, select lines in an excerpt to add a comment to your review or ask an agent
215On web, while reading a stop, select lines in an excerpt to add a comment to your review or ask an agent
213216about them; comments wait with the ones from the **Code** tab until you send the review. Choose a
214217suggested question, or **Ask about this stop**, to open a thread that already knows which stop you
215218are on. **Finish review** on the last stop opens the review with everything you collected.
packages/client-runtime/package.json+5
......@@ -398,6 +398,10 @@
398398 "./source-control-clients": {
399399 "types": "./src/sourceControlClients.ts",
400400 "default": "./src/sourceControlClients.ts"
401 },
402 "./review-guide-tour": {
403 "types": "./src/reviewGuideTour.ts",
404 "default": "./src/reviewGuideTour.ts"
401405 }
402406 },
403407 "scripts": {
......@@ -405,6 +409,7 @@
405409 "test": "vp test run"
406410 },
407411 "dependencies": {
412 "@pierre/diffs": "catalog:",
408413 "@t3tools/contracts": "workspace:*",
409414 "@t3tools/shared": "workspace:*",
410415 "@t3tools/source-control-azure-devops": "workspace:*",
packages/client-runtime/src/reviewGuideTour.test.ts created+178
......@@ -0,0 +1,178 @@
1import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
2import type { PullRequestReviewGuideStop } from "@t3tools/contracts";
3import { describe, expect, it } from "vite-plus/test";
4
5import {
6 excerptExpansionOldContents,
7 excerptRows,
8 fileDiffRows,
9 letterStopNotes,
10 rowsToPatch,
11 stepTourPosition,
12} from "./reviewGuideTour.js";
13
14const PATCH = `diff --git a/src/tray.ts b/src/tray.ts
15--- a/src/tray.ts
16+++ b/src/tray.ts
17@@ -10,5 +10,6 @@ function tray()
18 const a = 1;
19-const size = pick();
20+const size = "xs";
21+const variant = "tray";
22 const b = 2;
23 const c = 3;
24 const d = 4;
25@@ -40,3 +41,2 @@ function footer()
26 keep();
27-dropped();
28 tail();
29`;
30
31function parse(patch: string) {
32 const file = parsePatchFiles(patch, `test:${patch.length}`)[0]?.files[0];
33 if (file === undefined) throw new Error("patch did not parse");
34 return file;
35}
36
37const rows = fileDiffRows(parse(PATCH));
38
39function stop(overrides: Partial<PullRequestReviewGuideStop>): PullRequestReviewGuideStop {
40 return {
41 title: "Stop",
42 summary: "",
43 attention: null,
44 excerpts: [],
45 files: [],
46 notes: [],
47 questions: [],
48 ...overrides,
49 };
50}
51
52describe("stepTourPosition", () => {
53 it("holds the tour to its first and last stops", () => {
54 expect(stepTourPosition(0, -1, 3)).toBe(0);
55 expect(stepTourPosition(0, 1, 3)).toBe(1);
56 expect(stepTourPosition(2, 1, 3)).toBe(2);
57 });
58});
59
60describe("fileDiffRows", () => {
61 it("numbers both sides through context, deletions and additions", () => {
62 expect(rows.map((row) => [row.kind, row.oldLine, row.newLine])).toEqual([
63 ["context", 10, 10],
64 ["deletion", 11, null],
65 ["addition", null, 11],
66 ["addition", null, 12],
67 ["context", 12, 13],
68 ["context", 13, 14],
69 ["context", 14, 15],
70 ["context", 40, 41],
71 ["deletion", 41, null],
72 ["context", 42, 42],
73 ]);
74 });
75});
76
77describe("excerptRows", () => {
78 it("brings the replaced lines along with an excerpt that starts on an addition", () => {
79 const shown = excerptRows(rows, { side: "new", start: 11, end: 12 });
80 expect(shown.map((row) => row.kind)).toEqual(["deletion", "addition", "addition"]);
81 });
82
83 it("selects old-side lines for a removal and stops at the hunk boundary", () => {
84 const shown = excerptRows(rows, { side: "old", start: 41, end: 41 });
85 expect(shown.map((row) => [row.kind, row.oldLine])).toEqual([["deletion", 41]]);
86 });
87
88 it("returns nothing for a range outside the diff", () => {
89 expect(excerptRows(rows, { side: "new", start: 100, end: 120 })).toEqual([]);
90 });
91});
92
93describe("rowsToPatch", () => {
94 it("cuts a slice that parses back to the same lines and numbers", () => {
95 const shown = excerptRows(rows, { side: "new", start: 10, end: 13 });
96 const slice = parse(rowsToPatch("src/tray.ts", "src/tray.ts", shown));
97 expect(fileDiffRows(slice).map((row) => [row.kind, row.oldLine, row.newLine])).toEqual(
98 shown.map((row) => [row.kind, row.oldLine, row.newLine]),
99 );
100 });
101
102 it("starts a hunk per source hunk so separate hunks stay separate", () => {
103 const shown = excerptRows(rows, { side: "old", start: 14, end: 42 });
104 const patch = rowsToPatch("src/tray.ts", "src/tray.ts", shown);
105 expect(patch.match(/^@@ /gm)).toHaveLength(2);
106 expect(patch).toContain("@@ -14,1 +15,1 @@");
107 expect(patch).toContain("@@ -40,3 +41,2 @@");
108 });
109});
110
111describe("excerptExpansionOldContents", () => {
112 // The new file behind PATCH: lines 1-9, the hunk at 10-15, then lines up to 41-42 around the
113 // second hunk, which removed a line.
114 const newFile = Array.from({ length: 50 }, (_, index) => `line ${index + 1}\n`);
115 newFile.splice(
116 9,
117 6,
118 "const a = 1;\n",
119 'const size = "xs";\n',
120 'const variant = "tray";\n',
121 "const b = 2;\n",
122 "const c = 3;\n",
123 "const d = 4;\n",
124 );
125 newFile.splice(40, 2, "keep();\n", "tail();\n");
126 const newContents = newFile.join("");
127
128 it("puts back only the excerpt's lines and keeps both sides numbered as in the file", () => {
129 const shown = excerptRows(rows, { side: "new", start: 11, end: 12 });
130 const old = excerptExpansionOldContents(newContents, shown);
131 if (old === null) throw new Error("expected contents");
132 const oldLines = old.split("\n");
133 // The excerpt's old side sits at its real old line.
134 expect(oldLines[10]).toBe("const size = pick();");
135 // Around it, the new file's lines, so the two sides agree outside the excerpt.
136 expect(oldLines[9]).toBe("const a = 1;");
137 expect(oldLines[11]).toBe("const b = 2;");
138 // One line replaced by two: the old side is a line shorter, everything after lines up.
139 expect(oldLines.length).toBe(newContents.split("\n").length - 1);
140 });
141
142 it("does not try to expand an excerpt that spans hunks", () => {
143 const shown = excerptRows(rows, { side: "old", start: 14, end: 42 });
144 expect(excerptExpansionOldContents(newContents, shown)).toBeNull();
145 });
146});
147
148describe("letterStopNotes", () => {
149 it("letters notes in guide order and pins each to the excerpt that holds its line", () => {
150 const { lettered, byExcerpt } = letterStopNotes(
151 stop({
152 excerpts: [
153 { path: "a.ts", side: "new", start: 1, end: 5, caption: "" },
154 { path: "b.ts", side: "new", start: 10, end: 12, caption: "" },
155 ],
156 notes: [
157 { path: "b.ts", line: 11, title: "First", body: "" },
158 { path: "a.ts", line: 2, title: "Second", body: "" },
159 { path: "a.ts", line: 2, title: "Third", body: "" },
160 { path: "c.ts", line: 1, title: "Unanchored", body: "" },
161 ],
162 }),
163 );
164 expect(lettered.map((entry) => entry.marker)).toEqual(["A", "B", "C", "D"]);
165 expect(byExcerpt).toEqual([
166 [
167 {
168 line: 2,
169 markers: [
170 { marker: "B", title: "Second" },
171 { marker: "C", title: "Third" },
172 ],
173 },
174 ],
175 [{ line: 11, markers: [{ marker: "A", title: "First" }] }],
176 ]);
177 });
178});
packages/client-runtime/src/reviewGuideTour.ts created+224
......@@ -0,0 +1,224 @@
1import type { FileDiffMetadata } from "@pierre/diffs";
2import type { PullRequestReviewGuideExcerpt, PullRequestReviewGuideStop } from "@t3tools/contracts";
3
4/** One step forward or back, held to the tour's first and last stops. */
5export function stepTourPosition(current: number, delta: 1 | -1, stopCount: number): number {
6 return Math.max(Math.min(current + delta, stopCount - 1), 0);
7}
8
9/** The stop's files in reading order: the excerpted ones first, then the rest it owns. */
10export function stopFilePaths(stop: PullRequestReviewGuideStop): ReadonlyArray<string> {
11 return [
12 ...new Set([
13 ...stop.excerpts.map((excerpt) => excerpt.path),
14 ...stop.files.map((file) => file.path),
15 ]),
16 ];
17}
18
19/** The rail's second line for a stop. */
20export function stopSubline(stop: PullRequestReviewGuideStop): string {
21 const count = stopFilePaths(stop).length;
22 if (count === 0) return "Context, no code";
23 return count === 1 ? "1 file" : `${count} files`;
24}
25
26/** A note's marker: A, B, C… in the order the guide lists them, numbers past Z. */
27function noteMarker(index: number): string {
28 return index < 26 ? String.fromCharCode(65 + index) : String(index + 1);
29}
30
31/** One rendered line of a file's diff, with where it sits on each side. */
32export interface DiffRow {
33 readonly kind: "context" | "addition" | "deletion";
34 /** Line number on the old side, null for an added line. */
35 readonly oldLine: number | null;
36 /** Line number on the new side, null for a removed line. */
37 readonly newLine: number | null;
38 /** The next line number on each side when this row is reached, for hunk headers. */
39 readonly oldAt: number;
40 readonly newAt: number;
41 readonly text: string;
42 /** Which hunk of the file the row came from; rows from two hunks are not adjacent. */
43 readonly hunk: number;
44}
45
46/** Every line of a parsed file diff as the unified view lists it. */
47export function fileDiffRows(file: FileDiffMetadata): ReadonlyArray<DiffRow> {
48 const rows: DiffRow[] = [];
49 file.hunks.forEach((hunk, hunkIndex) => {
50 let oldAt = hunk.deletionStart;
51 let newAt = hunk.additionStart;
52 for (const content of hunk.hunkContent) {
53 if (content.type === "context") {
54 for (let offset = 0; offset < content.lines; offset += 1) {
55 rows.push({
56 kind: "context",
57 oldLine: oldAt,
58 newLine: newAt,
59 oldAt,
60 newAt,
61 text: file.additionLines[content.additionLineIndex + offset] ?? "",
62 hunk: hunkIndex,
63 });
64 oldAt += 1;
65 newAt += 1;
66 }
67 continue;
68 }
69 for (let offset = 0; offset < content.deletions; offset += 1) {
70 rows.push({
71 kind: "deletion",
72 oldLine: oldAt,
73 newLine: null,
74 oldAt,
75 newAt,
76 text: file.deletionLines[content.deletionLineIndex + offset] ?? "",
77 hunk: hunkIndex,
78 });
79 oldAt += 1;
80 }
81 for (let offset = 0; offset < content.additions; offset += 1) {
82 rows.push({
83 kind: "addition",
84 oldLine: null,
85 newLine: newAt,
86 oldAt,
87 newAt,
88 text: file.additionLines[content.additionLineIndex + offset] ?? "",
89 hunk: hunkIndex,
90 });
91 newAt += 1;
92 }
93 }
94 });
95 return rows;
96}
97
98/**
99 * The rows an excerpt covers. Its range is in one side's line numbers, so the other side's half
100 * of a replacement at either edge comes along: an excerpt starting on an added line also shows
101 * the lines it replaced, which is what makes it read as a change rather than as new code.
102 */
103export function excerptRows(
104 rows: ReadonlyArray<DiffRow>,
105 excerpt: Pick<PullRequestReviewGuideExcerpt, "side" | "start" | "end">,
106): ReadonlyArray<DiffRow> {
107 const lineOf = (row: DiffRow) => (excerpt.side === "new" ? row.newLine : row.oldLine);
108 const inRange = (row: DiffRow) => {
109 const line = lineOf(row);
110 return line !== null && line >= excerpt.start && line <= excerpt.end;
111 };
112 let first = rows.findIndex(inRange);
113 if (first === -1) return [];
114 let last = rows.findLastIndex(inRange);
115 const sameHunk = (index: number, anchor: number) => rows[index]?.hunk === rows[anchor]?.hunk;
116 if (excerpt.side === "new" && rows[first]?.kind === "addition") {
117 while (first > 0 && rows[first - 1]?.kind === "deletion" && sameHunk(first - 1, first)) {
118 first -= 1;
119 }
120 }
121 if (excerpt.side === "old" && rows[last]?.kind === "deletion") {
122 while (
123 last < rows.length - 1 &&
124 rows[last + 1]?.kind === "addition" &&
125 sameHunk(last + 1, last)
126 ) {
127 last += 1;
128 }
129 }
130 return rows.slice(first, last + 1);
131}
132
133const ROW_PREFIX = { context: " ", addition: "+", deletion: "-" } as const;
134
135/**
136 * A unified patch of just these rows, so the shared diff renderer can draw a slice of a file
137 * with its real line numbers instead of a second diff view being written for excerpts.
138 */
139export function rowsToPatch(
140 path: string,
141 previousPath: string,
142 rows: ReadonlyArray<DiffRow>,
143): string {
144 if (rows.length === 0) return "";
145 const lines = [
146 `diff --git a/${previousPath} b/${path}`,
147 `--- a/${previousPath}`,
148 `+++ b/${path}`,
149 ];
150 let start = 0;
151 while (start < rows.length) {
152 let end = start;
153 while (end + 1 < rows.length && rows[end + 1]?.hunk === rows[start]?.hunk) end += 1;
154 const group = rows.slice(start, end + 1);
155 const head = group[0]!;
156 const oldCount = group.filter((row) => row.oldLine !== null).length;
157 const newCount = group.filter((row) => row.newLine !== null).length;
158 // Git names the line before an empty side, so a pure insertion after line 9 is `-9,0`.
159 const oldStart = oldCount === 0 ? Math.max(head.oldAt - 1, 0) : head.oldAt;
160 const newStart = newCount === 0 ? Math.max(head.newAt - 1, 0) : head.newAt;
161 lines.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`);
162 for (const row of group) lines.push(`${ROW_PREFIX[row.kind]}${row.text.replace(/\r?\n$/, "")}`);
163 start = end + 1;
164 }
165 return `${lines.join("\n")}\n`;
166}
167
168/**
169 * The old side an excerpt expands against: the new file with only the excerpt's lines put back
170 * as they were. The renderer fills the code around a hunk from both files and needs them to
171 * agree outside it, which the real old file does not wherever another hunk changed lines. Only
172 * the lines the reader can expand into are read, and those are the new file's, so the code
173 * around the excerpt shows as it reads after the change. Null when the excerpt spans hunks.
174 */
175export function excerptExpansionOldContents(
176 newContents: string,
177 rows: ReadonlyArray<DiffRow>,
178): string | null {
179 const head = rows[0];
180 if (head === undefined || rows.some((row) => row.hunk !== head.hunk)) return null;
181 const newLines = newContents.match(/[^\n]*\n|[^\n]+$/g) ?? [];
182 const before = head.newAt - 1;
183 const newCount = rows.filter((row) => row.newLine !== null).length;
184 // The old side keeps its own numbering, so its prefix is as long as the old file's: trimmed
185 // from the front, or padded there with lines no expansion reaches.
186 const oldBefore = head.oldAt - 1;
187 const prefix = newLines.slice(0, before);
188 const oldPrefix =
189 oldBefore >= prefix.length
190 ? [...Array.from({ length: oldBefore - prefix.length }, () => "\n"), ...prefix]
191 : prefix.slice(prefix.length - oldBefore);
192 const oldRows = rows
193 .filter((row) => row.oldLine !== null)
194 .map((row) => (row.text.endsWith("\n") ? row.text : `${row.text}\n`));
195 return [...oldPrefix, ...oldRows, ...newLines.slice(before + newCount)].join("");
196}
197
198/** A stop's notes lettered in guide order, with the excerpt lines each one marks. */
199export interface LetteredNote<Note> {
200 readonly marker: string;
201 readonly note: Note;
202}
203
204/**
205 * Letters a stop's notes and groups them by the excerpt line they sit on. A note outside every
206 * excerpt keeps its letter in the list; it just has no line to mark.
207 */
208export function letterStopNotes(stop: PullRequestReviewGuideStop) {
209 const lettered = stop.notes.map((note, index) => ({ marker: noteMarker(index), note }));
210 const byExcerpt = stop.excerpts.map((excerpt) => {
211 const lines = new Map<number, Array<{ marker: string; title: string }>>();
212 for (const { marker, note } of lettered) {
213 if (note.path !== excerpt.path || note.line < excerpt.start || note.line > excerpt.end) {
214 continue;
215 }
216 const entry = { marker, title: note.title };
217 const list = lines.get(note.line);
218 if (list === undefined) lines.set(note.line, [entry]);
219 else list.push(entry);
220 }
221 return [...lines].map(([line, markers]) => ({ line, markers }));
222 });
223 return { lettered, byExcerpt };
224}
pnpm-lock.yaml+26
......@@ -1073,6 +1073,9 @@ importers:
10731073
10741074 packages/client-runtime:
10751075 dependencies:
1076 '@pierre/diffs':
1077 specifier: 'catalog:'
1078 version: 1.5.2(patch_hash=298e94372dbd51846a4e26ece2ecc3e22eb1f94b0450b1f1a9c6fec127809be7)(@shikijs/themes@4.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
10761079 '@t3tools/contracts':
10771080 specifier: workspace:*
10781081 version: link:../contracts
......@@ -15766,6 +15769,21 @@ snapshots:
1576615769 transitivePeerDependencies:
1576715770 - '@shikijs/themes'
1576815771
15772 '@pierre/diffs@1.5.2(patch_hash=298e94372dbd51846a4e26ece2ecc3e22eb1f94b0450b1f1a9c6fec127809be7)(@shikijs/themes@4.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)':
15773 dependencies:
15774 '@pierre/theme': 2.0.0
15775 '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0)
15776 '@shikijs/transformers': 4.2.0
15777 diff: 9.0.0
15778 hast-util-to-html: 9.0.5
15779 lru_map: 0.4.1
15780 shiki: 4.2.0
15781 optionalDependencies:
15782 react: 19.3.0
15783 react-dom: 19.3.0(react@19.3.0)
15784 transitivePeerDependencies:
15785 - '@shikijs/themes'
15786
1576915787 '@pierre/theme@1.1.0':
1577015788 optional: true
1577115789
......@@ -15803,6 +15821,14 @@ snapshots:
1580315821 react-dom: 19.2.6(react@19.2.6)
1580415822 shiki: 4.2.0
1580515823
15824 '@pierre/theming@1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0)':
15825 optionalDependencies:
15826 '@pierre/theme': 2.0.0
15827 '@shikijs/themes': 4.3.0
15828 react: 19.3.0
15829 react-dom: 19.3.0(react@19.3.0)
15830 shiki: 4.2.0
15831
1580615832 '@pierre/trees@1.0.0-beta.6(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)':
1580715833 dependencies:
1580815834 '@pierre/theming': 1.0.0(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)