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() {...@@ -41,6 +41,9 @@ class T3ReviewDiffModule : Module() {
41 Prop("contentWidth") { view: T3ReviewDiffView, contentWidth: Double ->41 Prop("contentWidth") { view: T3ReviewDiffView, contentWidth: Double ->
42 view.setContentWidth(contentWidth.toFloat())42 view.setContentWidth(contentWidth.toFloat())
43 }43 }
44 Prop("scrollEnabled") { view: T3ReviewDiffView, enabled: Boolean ->
45 view.setScrollEnabled(enabled)
46 }
44 Prop("initialRowIndex") { view: T3ReviewDiffView, initialRowIndex: Double ->47 Prop("initialRowIndex") { view: T3ReviewDiffView, initialRowIndex: Double ->
45 view.setInitialRowIndex(initialRowIndex)48 view.setInitialRowIndex(initialRowIndex)
46 }49 }
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...@@ -50,12 +50,21 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
50 private val verticalScroller = OverScroller(context)50 private val verticalScroller = OverScroller(context)
51 private val horizontalScroller = OverScroller(context)51 private val horizontalScroller = OverScroller(context)
52 private var dragAxis: DragAxis? = null52 private var dragAxis: DragAxis? = null
53 private var scrollEnabled = true
53 private var horizontalDragTarget: HorizontalPanTarget? = null54 private var horizontalDragTarget: HorizontalPanTarget? = null
54 private var lastTouchX = 0f55 private var lastTouchX = 0f
55 private var lastTouchY = 0f56 private var lastTouchY = 0f
56 private var velocityTracker: VelocityTracker? = null57 private var velocityTracker: VelocityTracker? = null
5758
58 init {59 init {
60 canvasView.onContentHeightChanged = { height ->
61 onDebug(
62 mapOf(
63 "message" to "metrics",
64 "contentHeight" to height / resources.displayMetrics.density
65 )
66 )
67 }
59 canvasView.onRowTap = { row, gesture, target -> handleRowTap(row, gesture, target) }68 canvasView.onRowTap = { row, gesture, target -> handleRowTap(row, gesture, target) }
60 canvasView.onVisibleRowsChanged = { first, last ->69 canvasView.onVisibleRowsChanged = { first, last ->
61 onDebug(70 onDebug(
...@@ -198,6 +207,14 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont...@@ -198,6 +207,14 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
198 scrollToY(canvasView.rowTop(index), animated)207 scrollToY(canvasView.rowTop(index), animated)
199 }208 }
200209
210 fun setScrollEnabled(enabled: Boolean) {
211 scrollEnabled = enabled
212 if (!enabled) {
213 verticalScroller.forceFinished(true)
214 canvasView.setVerticalOffset(0)
215 }
216 }
217
201 fun scrollToTop(animated: Boolean) {218 fun scrollToTop(animated: Boolean) {
202 scrollToY(0, animated)219 scrollToY(0, animated)
203 }220 }
...@@ -212,7 +229,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont...@@ -212,7 +229,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
212 horizontalDragTarget = canvasView.horizontalPanTarget(event.y)229 horizontalDragTarget = canvasView.horizontalPanTarget(event.y)
213 lastTouchX = event.x230 lastTouchX = event.x
214 lastTouchY = event.y231 lastTouchY = event.y
215 parent?.requestDisallowInterceptTouchEvent(true)232 parent?.requestDisallowInterceptTouchEvent(scrollEnabled)
216 return false233 return false
217 }234 }
218 MotionEvent.ACTION_MOVE -> {235 MotionEvent.ACTION_MOVE -> {
...@@ -221,7 +238,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont...@@ -221,7 +238,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
221 val deltaY = event.y - lastTouchY238 val deltaY = event.y - lastTouchY
222 if (max(abs(deltaX), abs(deltaY)) > touchSlop) {239 if (max(abs(deltaX), abs(deltaY)) > touchSlop) {
223 dragAxis = if (abs(deltaY) >= abs(deltaX)) {240 dragAxis = if (abs(deltaY) >= abs(deltaX)) {
224 DragAxis.VERTICAL241 if (scrollEnabled) DragAxis.VERTICAL else null
225 } else {242 } else {
226 horizontalDragTarget243 horizontalDragTarget
227 ?.takeIf { canvasView.maxHorizontalOffset(it) > 0 }244 ?.takeIf { canvasView.maxHorizontalOffset(it) > 0 }
...@@ -229,6 +246,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont...@@ -229,6 +246,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
229 }246 }
230 }247 }
231 }248 }
249 if (dragAxis == DragAxis.HORIZONTAL) parent?.requestDisallowInterceptTouchEvent(true)
232 return dragAxis != null250 return dragAxis != null
233 }251 }
234 MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> return dragAxis != null252 MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> return dragAxis != null
...@@ -751,6 +769,7 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -751,6 +769,7 @@ private class DiffCanvasView(context: Context) : View(context) {
751 }769 }
752 var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null770 var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null
753 var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null771 var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null
772 var onContentHeightChanged: ((Int) -> Unit)? = null
754773
755 fun prepareRows() = drawing.prepareRows(tokensByRowId, style, width)774 fun prepareRows() = drawing.prepareRows(tokensByRowId, style, width)
756 fun useCodeLayouts(layouts: CodeLayoutCache) {775 fun useCodeLayouts(layouts: CodeLayoutCache) {
...@@ -876,6 +895,7 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -876,6 +895,7 @@ private class DiffCanvasView(context: Context) : View(context) {
876 setVerticalOffset(verticalOffset)895 setVerticalOffset(verticalOffset)
877 setHorizontalOffset(horizontalOffset)896 setHorizontalOffset(horizontalOffset)
878 clampHeaderPathOffsets()897 clampHeaderPathOffsets()
898 onContentHeightChanged?.invoke(rowOffsets.last())
879 }899 }
880900
881 private fun rowHeight(row: DiffRow): Int = when (row.kind) {901 private fun rowHeight(row: DiffRow): Int = when (row.kind) {
...@@ -1198,14 +1218,7 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -1198,14 +1218,7 @@ private class DiffCanvasView(context: Context) : View(context) {
1198 }1218 }
1199 val selected = selectedRowIds.contains(row.id)1219 val selected = selectedRowIds.contains(row.id)
1200 if (selected) {1220 if (selected) {
1201 fill(1221 // Keep the diff and syntax colors intact; selection lives in the gutter.
1202 canvas,
1203 withAlpha(theme.hunkText, 56),
1204 0f,
1205 top.toFloat(),
1206 width.toFloat(),
1207 bottom.toFloat(),
1208 )
1209 fill(1222 fill(
1210 canvas,1223 canvas,
1211 withAlpha(theme.hunkText, 242),1224 withAlpha(theme.hunkText, 242),
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift+12
...@@ -61,6 +61,18 @@ public class T3ReviewDiffModule: Module {...@@ -61,6 +61,18 @@ public class T3ReviewDiffModule: Module {
61 view.setInitialRowIndex(initialRowIndex)61 view.setInitialRowIndex(initialRowIndex)
62 }62 }
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
64 Prop("refreshing") { (view: T3ReviewDiffView, refreshing: Bool) in76 Prop("refreshing") { (view: T3ReviewDiffView, refreshing: Bool) in
65 view.setRefreshing(refreshing)77 view.setRefreshing(refreshing)
66 }78 }
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift+17-7
...@@ -365,7 +365,6 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {...@@ -365,7 +365,6 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
365 scrollView.showsHorizontalScrollIndicator = false365 scrollView.showsHorizontalScrollIndicator = false
366 scrollView.backgroundColor = contentView.theme.background366 scrollView.backgroundColor = contentView.theme.background
367 pullRefreshControl.addTarget(self, action: #selector(handlePullToRefresh), for: .valueChanged)367 pullRefreshControl.addTarget(self, action: #selector(handlePullToRefresh), for: .valueChanged)
368 scrollView.refreshControl = pullRefreshControl
369 addSubview(scrollView)368 addSubview(scrollView)
370369
371 contentView.backgroundColor = contentView.theme.background370 contentView.backgroundColor = contentView.theme.background
...@@ -701,14 +700,14 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {...@@ -701,14 +700,14 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
701 applyPendingScrollIfNeeded()700 applyPendingScrollIfNeeded()
702 emitVisibleFileIfNeeded()701 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))"
705 if debugKey != lastMetricsDebugKey {704 if debugKey != lastMetricsDebugKey {
706 lastMetricsDebugKey = debugKey705 lastMetricsDebugKey = debugKey
707 emitDebug("metrics", [706 emitDebug("metrics", [
708 "rows": rows.count,707 "rows": rows.count,
709 "boundsWidth": Double(bounds.width),708 "boundsWidth": Double(bounds.width),
710 "boundsHeight": Double(bounds.height),709 "boundsHeight": Double(bounds.height),
711 "contentHeight": Double(height),710 "contentHeight": Double(contentView.contentHeight),
712 "contentWidth": Double(style.contentWidth),711 "contentWidth": Double(style.contentWidth),
713 "fileHeaderHeight": Double(style.fileHeaderHeight),712 "fileHeaderHeight": Double(style.fileHeaderHeight),
714 "rowHeight": Double(style.rowHeight),713 "rowHeight": Double(style.rowHeight),
...@@ -840,6 +839,20 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {...@@ -840,6 +839,20 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
840 setVerticalContentOffset(0, animated: animated)839 setVerticalContentOffset(0, animated: animated)
841 }840 }
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
843 @objc private func handlePullToRefresh() {856 @objc private func handlePullToRefresh() {
844 onPullToRefresh([:])857 onPullToRefresh([:])
845 }858 }
...@@ -2429,10 +2442,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -2429,10 +2442,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
2429 return2442 return
2430 }2443 }
24312444
2432 let selectionColor = theme.hunkText.withAlphaComponent(0.22)2445 // Keep the diff and syntax colors intact; selection lives in the gutter.
2433 selectionColor.setFill()
2434 context.fill(rect)
2435
2436 theme.hunkText.withAlphaComponent(0.95).setFill()2446 theme.hunkText.withAlphaComponent(0.95).setFill()
2437 context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height))2447 context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height))
2438 }2448 }
apps/mobile/src/Stack.tsx+5
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { PullRequestGuideStopScreen } from "./features/pull-requests/guide/PullRequestGuideScreen";
1import { PullRequestInlineCommentScreen } from "./features/pull-requests/PullRequestInlineCommentScreen";2import { PullRequestInlineCommentScreen } from "./features/pull-requests/PullRequestInlineCommentScreen";
2import { PullRequestDiscussionScreen } from "./features/pull-requests/PullRequestDiscussionScreen";3import { PullRequestDiscussionScreen } from "./features/pull-requests/PullRequestDiscussionScreen";
3import {4import {
...@@ -722,6 +723,10 @@ const RootStackConfig = createWorkspaceStackNavigator({...@@ -722,6 +723,10 @@ const RootStackConfig = createWorkspaceStackNavigator({
722 },723 },
723 options: GLASS_HEADER_OPTIONS,724 options: GLASS_HEADER_OPTIONS,
724 }),725 }),
726 PullRequestGuideStop: createNativeStackScreen({
727 screen: PullRequestGuideStopScreen,
728 options: GLASS_HEADER_OPTIONS,
729 }),
725 PullRequestFile: createNativeStackScreen({730 PullRequestFile: createNativeStackScreen({
726 screen: PullRequestFileRouteScreen,731 screen: PullRequestFileRouteScreen,
727 options: GLASS_HEADER_OPTIONS,732 options: GLASS_HEADER_OPTIONS,
apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts+9-1
...@@ -124,6 +124,9 @@ export interface NativeReviewDiffViewProps extends ViewProps {...@@ -124,6 +124,9 @@ export interface NativeReviewDiffViewProps extends ViewProps {
124 readonly rowHeight: number;124 readonly rowHeight: number;
125 readonly contentWidth: number;125 readonly contentWidth: number;
126 readonly initialRowIndex?: number;126 readonly initialRowIndex?: number;
127 /** Disable the native vertical scroller when an excerpt grows inside a page. */
128 readonly scrollEnabled?: boolean;
129 readonly bounces?: boolean;
127 readonly refreshing?: boolean;130 readonly refreshing?: boolean;
128 readonly nativeViewRef?: Ref<NativeReviewDiffViewHandle>;131 readonly nativeViewRef?: Ref<NativeReviewDiffViewHandle>;
129 readonly onPullToRefresh?: (event: NativeSyntheticEvent<Record<string, never>>) => void;132 readonly onPullToRefresh?: (event: NativeSyntheticEvent<Record<string, never>>) => void;
...@@ -167,6 +170,7 @@ type NativeReviewDiffRawViewProps = Omit<...@@ -167,6 +170,7 @@ type NativeReviewDiffRawViewProps = Omit<
167 NativeReviewDiffViewProps,170 NativeReviewDiffViewProps,
168 "nativeViewRef" | "rowsJson" | "tokensJson" | "tokensPatchJson"171 "nativeViewRef" | "rowsJson" | "tokensJson" | "tokensPatchJson"
169> & {172> & {
173 readonly refreshEnabled: boolean;
170 readonly ref?: Ref<NativeReviewDiffViewRef>;174 readonly ref?: Ref<NativeReviewDiffViewRef>;
171};175};
172176
...@@ -270,7 +274,11 @@ function NativeReviewDiffView(props: NativeReviewDiffViewProps) {...@@ -270,7 +274,11 @@ function NativeReviewDiffView(props: NativeReviewDiffViewProps) {
270 return null;274 return null;
271 }275 }
272276
273 return createElement(RawNativeView, { ...nativeProps, ref: nativeRef });277 return createElement(RawNativeView, {
278 ...nativeProps,
279 refreshEnabled: props.onPullToRefresh !== undefined,
280 ref: nativeRef,
281 });
274}282}
275283
276export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffViewProps> | null {284export 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 @@...@@ -1,3 +1,5 @@
1import Animated from "react-native-reanimated";
2import { PR_ROW_LAYOUT } from "./pullRequestAnimations";
1import { useNavigation } from "@react-navigation/native";3import { useNavigation } from "@react-navigation/native";
2import type { PullRequestDetail } from "@t3tools/contracts";4import type { PullRequestDetail } from "@t3tools/contracts";
3import type { ReactNode } from "react";5import type { ReactNode } from "react";
...@@ -21,9 +23,12 @@ import { usePullRequestAgentActions } from "./usePullRequestAgentActions";...@@ -21,9 +23,12 @@ import { usePullRequestAgentActions } from "./usePullRequestAgentActions";
2123
22export function PullRequestCardGroup(props: { readonly children: ReactNode }) {24export function PullRequestCardGroup(props: { readonly children: ReactNode }) {
23 return (25 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 >
25 {props.children}30 {props.children}
26 </View>31 </Animated.View>
27 );32 );
28}33}
2934
apps/mobile/src/features/pull-requests/PullRequestFiles.tsx+22-9
...@@ -1,4 +1,6 @@...@@ -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";
2import { useNavigation } from "@react-navigation/native";4import { useNavigation } from "@react-navigation/native";
3import { useState } from "react";5import { useState } from "react";
4import { ActivityIndicator, Pressable, View } from "react-native";6import { ActivityIndicator, Pressable, View } from "react-native";
...@@ -192,7 +194,7 @@ export function usePullRequestFilesContent(props: {...@@ -192,7 +194,7 @@ export function usePullRequestFilesContent(props: {
192 </View>194 </View>
193 ) : parsed.kind === "files" ? (195 ) : parsed.kind === "files" ? (
194 <>196 <>
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">
196 <View className="min-w-0 flex-1 gap-1">198 <View className="min-w-0 flex-1 gap-1">
197 <Text className="text-base font-t3-semibold">199 <Text className="text-base font-t3-semibold">
198 {parsed.fileCount} {filter.hiddenCount ? "visible" : "changed"}{" "}200 {parsed.fileCount} {filter.hiddenCount ? "visible" : "changed"}{" "}
...@@ -201,11 +203,11 @@ export function usePullRequestFilesContent(props: {...@@ -201,11 +203,11 @@ export function usePullRequestFilesContent(props: {
201 ? ` · ${parsed.files.filter((file) => viewed.isViewed(file.path)).length} viewed`203 ? ` · ${parsed.files.filter((file) => viewed.isViewed(file.path)).length} viewed`
202 : ""}204 : ""}
203 </Text>205 </Text>
204 {filter.hideTestFiles ? (206 <Text className="text-2xs text-foreground-muted">
205 <Text className="text-2xs text-foreground-muted">207 {filter.hideTestFiles
206 {filter.hiddenCount} test {filter.hiddenCount === 1 ? "file" : "files"} hidden208 ? `${filter.hiddenCount} test ${filter.hiddenCount === 1 ? "file" : "files"} hidden`
207 </Text>209 : "Test files included"}
208 ) : null}210 </Text>
209 </View>211 </View>
210 <PullRequestDiffStats212 <PullRequestDiffStats
211 additions={parsed.additions}213 additions={parsed.additions}
...@@ -285,11 +287,22 @@ export function usePullRequestFilesContent(props: {...@@ -285,11 +287,22 @@ export function usePullRequestFilesContent(props: {
285287
286export function PullRequestFiles(props: Parameters<typeof usePullRequestFilesContent>[0]) {288export function PullRequestFiles(props: Parameters<typeof usePullRequestFilesContent>[0]) {
287 const content = usePullRequestFilesContent(props);289 const content = usePullRequestFilesContent(props);
290 const [animationsReady, setAnimationsReady] = useState(false);
288 return (291 return (
289 <LegendList292 <AnimatedLegendList
290 data={content.rows}293 data={content.rows}
291 keyExtractor={(row) => row.key}294 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 )}
293 recycleItems={false}306 recycleItems={false}
294 estimatedItemSize={64}307 estimatedItemSize={64}
295 ListHeaderComponent={content.header}308 ListHeaderComponent={content.header}
apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx+315-246
...@@ -1,5 +1,22 @@...@@ -1,5 +1,22 @@
1import { PullRequestEntryRow } from "./PullRequestEntryRow";1import { pullRequestTabOffset } from "./pullRequestTabScroll";
2import { LegendList, type LegendListRef } from "@legendapp/list/react-native";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";
3import { openOnProviderLabel } from "./pullRequestActions";20import { openOnProviderLabel } from "./pullRequestActions";
4import * as Clipboard from "expo-clipboard";21import * as Clipboard from "expo-clipboard";
5import { ControlPillMenu } from "../../components/ControlPillMenu";22import { ControlPillMenu } from "../../components/ControlPillMenu";
...@@ -23,7 +40,7 @@ import { pullRequestReviewKey } from "./pullRequestReviewDrafts";...@@ -23,7 +40,7 @@ import { pullRequestReviewKey } from "./pullRequestReviewDrafts";
23import { type PullRequestDetail, type PullRequestListEntry } from "@t3tools/contracts";40import { type PullRequestDetail, type PullRequestListEntry } from "@t3tools/contracts";
24import { PullRequestOverviewLoading } from "./PullRequestOverviewLoading";41import { PullRequestOverviewLoading } from "./PullRequestOverviewLoading";
25import { pullRequestStatus } from "./pullRequestStatus";42import { pullRequestStatus } from "./pullRequestStatus";
26import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react";43import { useCallback, useEffect, useMemo, useRef, useState } from "react";
27import {44import {
28 ActivityIndicator,45 ActivityIndicator,
29 Linking,46 Linking,
...@@ -104,76 +121,39 @@ function PullRequestTabScrollView(props: ScrollViewProps) {...@@ -104,76 +121,39 @@ function PullRequestTabScrollView(props: ScrollViewProps) {
104 );121 );
105}122}
106123
107function usePullRequestActivityRows(params: PullRequestParams, commitsOnly: boolean) {124function usePullRequestActivityRows(params: PullRequestParams, filter: ActivityFilter) {
108 const navigation = useNavigation();125 const navigation = useNavigation();
109 const result = useEnvironmentQuery(composerPullRequests.activity(requestTarget(params)));126 const result = useEnvironmentQuery(composerPullRequests.activity(requestTarget(params)));
127 const filtered = useMemo(
128 () => (result.data ? filterPullRequestActivity(result.data, filter) : null),
129 [result.data, filter],
130 );
110 const rows = useMemo(() => {131 const rows = useMemo(() => {
111 const rows: { key: string; content: ReactElement }[] = [];132 if (!filtered) return [];
112 if (commitsOnly) {133 const rows = pullRequestTimelineRows(filtered.activity, params, navigation);
113 for (const commit of result.data?.commits ?? [])134 if (filter.newestFirst) {
114 rows.push({135 const notice = rows.at(-1)?.key === "truncated" ? rows.pop() : undefined;
115 key: `commit:${commit.oid}`,136 rows.reverse();
116 content: (137 if (notice) rows.push(notice);
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));
164 }138 }
165 if (!rows.length && result.data)139 if (!rows.length)
166 rows.push({140 rows.push({
167 key: "empty",141 key: "empty",
142 date: "",
168 content: (143 content: (
169 <Text className="py-6 text-sm text-foreground-muted">144 <Text className="py-6 text-sm text-foreground-muted">
170 {commitsOnly ? "No commits reported." : "No comments yet."}145 No activity matches these filters.
171 </Text>146 </Text>
172 ),147 ),
173 });148 });
174 return rows;149 return rows;
175 }, [commitsOnly, navigation, params, result.data]);150 }, [filtered, filter.newestFirst, params, navigation]);
176 return { rows, pending: result.isPending, error: result.error };151 return {
152 rows,
153 hiddenCount: filtered?.hiddenCount ?? 0,
154 pending: result.isPending,
155 error: result.error,
156 };
177}157}
178158
179function PullRequestIdentity(props: {159function PullRequestIdentity(props: {
...@@ -257,26 +237,33 @@ function PullRequestOverview(props: {...@@ -257,26 +237,33 @@ function PullRequestOverview(props: {
257 <PullRequestCardGroup>237 <PullRequestCardGroup>
258 <PullRequestChecksRow pr={pr} params={props.params} />238 <PullRequestChecksRow pr={pr} params={props.params} />
259 <PullRequestReviewersRow pr={pr} params={props.params} />239 <PullRequestReviewersRow pr={pr} params={props.params} />
260 <PullRequestBranchRow pr={pr} params={props.params} />240 <Animated.View layout={PR_ROW_LAYOUT}>
261 <PullRequestMergeControls pr={pr} params={props.params} stack={props.stack} />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>
262 </PullRequestCardGroup>246 </PullRequestCardGroup>
263 {pr.labels.length ? (247 {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">
265 {pr.labels.map((label) => (249 {pr.labels.map((label) => (
266 <View key={label.name} className="rounded-full bg-grouped-card px-3 py-1">250 <View key={label.name} className="rounded-full bg-grouped-card px-3 py-1">
267 <Text className="text-2xs text-foreground-muted">{label.name}</Text>251 <Text className="text-2xs text-foreground-muted">{label.name}</Text>
268 </View>252 </View>
269 ))}253 ))}
270 </View>254 </Animated.View>
271 ) : null}255 ) : 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 >
273 <PullRequestDescription260 <PullRequestDescription
274 markdown={pr.body}261 markdown={pr.body}
275 onCollapse={props.onDescriptionCollapse}262 onCollapse={props.onDescriptionCollapse}
276 expanded={props.descriptionExpanded}263 expanded={props.descriptionExpanded}
277 onExpandedChange={props.onDescriptionExpandedChange}264 onExpandedChange={props.onDescriptionExpandedChange}
278 />265 />
279 </View>266 </Animated.View>
280 </View>267 </View>
281 );268 );
282}269}
...@@ -285,43 +272,45 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP...@@ -285,43 +272,45 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
285 const navigation = useNavigation();272 const navigation = useNavigation();
286 const insets = useSafeAreaInsets();273 const insets = useSafeAreaInsets();
287 const params = route.params;274 const params = route.params;
288 const [section, setSection] = useState<"overview" | "activity" | "commits">("overview");275 const [section, setSection] = useState<"overview" | "activity" | "guide">("overview");
289 // LegendList commits replacement rows after the header. Keep selection with the visible content.276 // Keep each tab's native list and measured rows alive when it is hidden.
290 const [displayedSection, setDisplayedSection] = useState(section);
291 const requestedSection = useRef(section);277 const requestedSection = useRef(section);
278 const [listAnimationsReady, setListAnimationsReady] = useState(false);
292 const [descriptionExpanded, setDescriptionExpanded] = useState(false);279 const [descriptionExpanded, setDescriptionExpanded] = useState(false);
293 const filesContent = usePullRequestFilesContent({ params });280 const filesContent = usePullRequestFilesContent({ params });
294 const activityContent = usePullRequestActivityRows(params, false);281 const guidesEnabled = useReviewGuidesEnabled(params);
295 const commitsContent = usePullRequestActivityRows(params, true);282 const [activityFilter, setActivityFilter] = useState<ActivityFilter>(DEFAULT_ACTIVITY_FILTER);
283 const activityContent = usePullRequestActivityRows(params, activityFilter);
296 const scrollOffsets = useRef<Record<string, number>>({});284 const scrollOffsets = useRef<Record<string, number>>({});
297 const currentScrollOffset = useRef(-insets.top);285 const currentScrollOffset = useRef(-insets.top);
298 const bodyY = useRef(0);286 const bodyY = useRef(0);
299 const pendingScrollOffset = useRef<number | null>(null);287 const [headerHeight, setHeaderHeight] = useState(0);
300 const selectSection = (next: "overview" | "activity" | "commits") => {288 const [viewportHeight, setViewportHeight] = useState(0);
289 const scrollViews = useRef<Partial<Record<typeof section, LegendListRef | null>>>({});
290 const selectSection = (next: typeof section) => {
301 if (next === section) return;291 if (next === section) return;
302 requestedSection.current = next;
303 scrollOffsets.current[section] = currentScrollOffset.current;292 scrollOffsets.current[section] = currentScrollOffset.current;
304 if (scrollOffsets.current[next] === undefined) {293 const headerEnd = Math.max(-insets.top, bodyY.current - insets.top);
305 scrollOffsets.current[next] = Math.min(294 // The shared header stays in place. Once collapsed, retain each tab's own reading position.
306 currentScrollOffset.current,295 const offset = pullRequestTabOffset(
307 bodyY.current - insets.top,296 currentScrollOffset.current,
308 );297 scrollOffsets.current[next],
309 }298 headerEnd,
310 pendingScrollOffset.current = scrollOffsets.current[next] ?? -insets.top;299 );
300 requestedSection.current = next;
301 scrollOffsets.current[next] = offset;
302 currentScrollOffset.current = offset;
303 scrollViews.current[next]?.scrollToOffset({ offset, animated: false });
311 setSection(next);304 setSection(next);
312 };305 };
313 const tab = section;306 useEffect(() => {
314 const tabContent = section === "commits" ? commitsContent : activityContent;307 if (!guidesEnabled && section === "guide") {
315 const scrollView = useRef<LegendListRef>(null);308 requestedSection.current = "overview";
316 const commitSectionLayout = () => {309 currentScrollOffset.current = scrollOffsets.current.overview ?? -insets.top;
317 if (requestedSection.current !== section) return;310 setSection("overview");
318 setDisplayedSection(section);
319 const offset = pendingScrollOffset.current;
320 if (offset !== null) {
321 pendingScrollOffset.current = null;
322 scrollView.current?.scrollToOffset({ offset, animated: false });
323 }311 }
324 };312 }, [guidesEnabled, section, insets.top]);
313 const tabContent = activityContent;
325 const filesY = useRef(0);314 const filesY = useRef(0);
326 const descriptionY = useRef(0);315 const descriptionY = useRef(0);
327 const pendingDescriptionJump = useRef(false);316 const pendingDescriptionJump = useRef(false);
...@@ -329,7 +318,6 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP...@@ -329,7 +318,6 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
329 const detailAtom = composerPullRequests.detail(requestTarget(params));318 const detailAtom = composerPullRequests.detail(requestTarget(params));
330 const result = useEnvironmentQuery(detailAtom);319 const result = useEnvironmentQuery(detailAtom);
331 const pullRefresh = usePullToRefresh(detailAtom);320 const pullRefresh = usePullToRefresh(detailAtom);
332 const activity = useEnvironmentQuery(composerPullRequests.activity(requestTarget(params)));
333 const { refresh: refreshDetail } = result;321 const { refresh: refreshDetail } = result;
334 useFocusEffect(useCallback(() => refreshDetail(), [refreshDetail]));322 useFocusEffect(useCallback(() => refreshDetail(), [refreshDetail]));
335 const pr = result.data;323 const pr = result.data;
...@@ -338,24 +326,27 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP...@@ -338,24 +326,27 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
338 const permissions = usePullRequestPermissions(params, pr);326 const permissions = usePullRequestPermissions(params, pr);
339 const stack = usePullRequestStack(params, pr);327 const stack = usePullRequestStack(params, pr);
340 const refreshActivity = useAtomRefresh(composerPullRequests.activity(requestTarget(params)));328 const refreshActivity = useAtomRefresh(composerPullRequests.activity(requestTarget(params)));
329 const refreshGuide = useAtomRefresh(composerPullRequests.reviewGuide(requestTarget(params)));
341 const refresh = () => {330 const refresh = () => {
342 pullRefresh.onRefresh();331 pullRefresh.onRefresh();
332 if (guidesEnabled) refreshGuide();
343 stack.refresh();333 stack.refresh();
344 refreshActivity();334 refreshActivity();
345 filesContent.refresh();335 filesContent.refresh();
346 };336 };
347 const jumpToFiles = useCallback(() => {337 const jumpToFiles = () => {
348 scrollView.current?.scrollToOffset({338 scrollViews.current.overview?.scrollToOffset({
349 offset: bodyY.current + filesY.current - insets.top,339 offset: bodyY.current + filesY.current - insets.top,
350 animated: true,340 animated: true,
351 });341 });
352 pendingFilesJump.current = false;342 pendingFilesJump.current = false;
353 }, [insets.top]);343 };
354 const openFiles = () => {344 const openFiles = () => {
355 if (section === "overview") jumpToFiles();345 if (section === "overview") jumpToFiles();
356 else {346 else {
357 pendingFilesJump.current = true;347 pendingFilesJump.current = true;
358 selectSection("overview");348 selectSection("overview");
349 if (filesY.current > 0) requestAnimationFrame(jumpToFiles);
359 }350 }
360 };351 };
361 return (352 return (
...@@ -443,155 +434,207 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP...@@ -443,155 +434,207 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
443 ) : null}434 ) : null}
444 </NativeHeaderToolbar.Menu>435 </NativeHeaderToolbar.Menu>
445 </NativeHeaderToolbar>436 </NativeHeaderToolbar>
446 <View style={{ flex: 1 }}>437 <View
447 <LegendList438 style={{ flex: 1 }}
448 ref={scrollView}439 onLayout={(event) => setViewportHeight(event.nativeEvent.layout.height)}
449 initialScrollOffset={-insets.top}440 >
450 onScroll={(event) => {441 {(["overview", "guide", "activity"] as const)
451 currentScrollOffset.current = event.nativeEvent.contentOffset.y;442 .filter((tab) => tab !== "guide" || guidesEnabled)
452 }}443 .map((tab) => (
453 className="flex-1"444 <View
454 renderScrollComponent={Platform.OS === "ios" ? PullRequestTabScrollView : undefined}445 key={tab}
455 contentInsetAdjustmentBehavior="never"446 style={[StyleSheet.absoluteFill, { opacity: section === tab ? 1 : 0 }]}
456 contentInset={447 pointerEvents={section === tab ? "auto" : "none"}
457 Platform.OS === "ios" ? { top: insets.top, bottom: insets.bottom } : undefined448 accessibilityElementsHidden={section !== tab}
458 }449 importantForAccessibility={section === tab ? "auto" : "no-hide-descendants"}
459 contentInsetStartAdjustment={Platform.OS === "ios" ? insets.top : 0}450 >
460 refreshControl={451 <AnimatedLegendList
461 <RefreshControl refreshing={pullRefresh.refreshing} onRefresh={refresh} />452 ref={(list) => {
462 }453 scrollViews.current[tab] = list;
463 alwaysBounceVertical454 }}
464 scrollsToTop455 initialScrollOffset={-insets.top}
465 onContentSizeChange={() => {456 onScroll={(event) => {
466 if (tab !== "overview" || !pendingDescriptionJump.current) return;457 const offset = event.nativeEvent.contentOffset.y;
467 pendingDescriptionJump.current = false;458 scrollOffsets.current[tab] = offset;
468 // Collapse changes the scroll range; restore the heading after that layout commits.459 if (requestedSection.current === tab) currentScrollOffset.current = offset;
469 scrollView.current?.scrollToOffset({460 }}
470 offset: bodyY.current + descriptionY.current - insets.top,461 className="flex-1"
471 animated: false,462 renderScrollComponent={
472 });463 Platform.OS === "ios" ? PullRequestTabScrollView : undefined
473 }}464 }
474 contentContainerStyle={{465 contentInsetAdjustmentBehavior="never"
475 paddingHorizontal: 16,466 contentInset={
476 paddingTop: 8,467 Platform.OS === "ios" ? { top: insets.top, bottom: insets.bottom } : undefined
477 paddingBottom: 24,468 }
478 }}469 contentInsetStartAdjustment={Platform.OS === "ios" ? insets.top : 0}
479 data={[470 refreshControl={
480 {471 <RefreshControl
481 key: `${tab}:body`,472 refreshing={section === tab && pullRefresh.refreshing}
482 content: (473 onRefresh={refresh}
483 <View474 />
484 onLayout={475 }
485 tab === "overview" || tabContent.rows.length === 0476 alwaysBounceVertical
486 ? commitSectionLayout477 scrollsToTop={section === tab}
487 : undefined478 onContentSizeChange={() => {
488 }479 if (
489 >480 tab !== "overview" ||
490 {tab === "overview" && identity ? (481 section !== "overview" ||
491 <View>482 !pendingDescriptionJump.current
492 {pr ? (483 )
493 <PullRequestOverview484 return;
494 pr={pr}485 pendingDescriptionJump.current = false;
495 params={params}486 // Collapse changes the scroll range; restore the heading after that layout commits.
496 stack={stack}487 scrollViews.current.overview?.scrollToOffset({
497 descriptionExpanded={descriptionExpanded}488 offset: bodyY.current + descriptionY.current - insets.top,
498 onDescriptionExpandedChange={setDescriptionExpanded}489 animated: false,
499 onDescriptionLayout={(y) => {490 });
500 descriptionY.current = y;491 }}
501 }}492 contentContainerStyle={{
502 onDescriptionCollapse={() => {493 paddingHorizontal: 16,
503 pendingDescriptionJump.current = true;494 paddingTop: 8,
504 }}495 paddingBottom: 24,
505 />496 // Short tabs must still be tall enough to preserve the collapsed header.
506 ) : summary ? (497 minHeight: Math.max(
507 <PullRequestOverviewLoading498 0,
508 summary={summary}499 viewportHeight + headerHeight - insets.top - insets.bottom,
509 params={params}500 ),
510 stack={stack}501 }}
511 />502 data={[
512 ) : null}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 ? (
513 <View608 <View
514 className="mt-8 border-t border-border-subtle pt-3"609 className="gap-5 pb-5"
515 onLayout={(event) => {610 onLayout={(event) => {
516 filesY.current = event.nativeEvent.layout.y;611 bodyY.current = event.nativeEvent.layout.height;
517 if (pendingFilesJump.current) {612 setHeaderHeight(event.nativeEvent.layout.height);
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 }
524 }}613 }}
525 >614 >
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 />
527 </View>630 </View>
528 </View>631 ) : null}
529 ) : null}632 </>
530 {tab !== "overview" ? (633 }
531 <PullRequestReadStatus634 ListFooterComponent={tab === "overview" ? filesContent.footer : null}
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}
563 />635 />
564 {identity ? (636 </View>
565 <View637 ))}
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 />
595 </View>638 </View>
596 <PullRequestActionToolbar pr={pr} params={params} onOpenFiles={openFiles} />639 <PullRequestActionToolbar pr={pr} params={params} onOpenFiles={openFiles} />
597 </View>640 </View>
...@@ -632,13 +675,17 @@ export function PullRequestFileRouteScreen({...@@ -632,13 +675,17 @@ export function PullRequestFileRouteScreen({
632 readonly path: string;675 readonly path: string;
633 readonly fileSelectionId?: string;676 readonly fileSelectionId?: string;
634 readonly replacingDraftId?: string;677 readonly replacingDraftId?: string;
678 readonly focus?: GuideDiffFocus;
635 }679 }
636>) {680>) {
637 const navigation = useNavigation();681 const navigation = useNavigation();
638 const insets = useSafeAreaInsets();682 const insets = useSafeAreaInsets();
639 const commit = route.params.commit;683 const commit = route.params.commit;
640 const result = usePullRequestDiff(route.params);684 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 );
642 const detail = useEnvironmentQuery(composerPullRequests.detail(requestTarget(route.params)));689 const detail = useEnvironmentQuery(composerPullRequests.detail(requestTarget(route.params)));
643 const { refresh: refreshDetail } = detail;690 const { refresh: refreshDetail } = detail;
644 useFocusEffect(useCallback(() => refreshDetail(), [refreshDetail]));691 useFocusEffect(useCallback(() => refreshDetail(), [refreshDetail]));
...@@ -709,6 +756,11 @@ export function PullRequestFileRouteScreen({...@@ -709,6 +756,11 @@ export function PullRequestFileRouteScreen({
709 const targetFile =756 const targetFile =
710 data.files.find((file) => file.path === jumpPath) ??757 data.files.find((file) => file.path === jumpPath) ??
711 (!jumpPath || requestedFile ? data.files[0] : undefined);758 (!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;
712 const [collapsedIds, setCollapsedIds] = useState<readonly string[]>(EMPTY_IDS);764 const [collapsedIds, setCollapsedIds] = useState<readonly string[]>(EMPTY_IDS);
713 const [visibleFileId, setVisibleFileId] = useState<string | null>(null);765 const [visibleFileId, setVisibleFileId] = useState<string | null>(null);
714 const nativeView = useRef<NativeReviewDiffViewHandle>(null);766 const nativeView = useRef<NativeReviewDiffViewHandle>(null);
...@@ -722,6 +774,7 @@ export function PullRequestFileRouteScreen({...@@ -722,6 +774,7 @@ export function PullRequestFileRouteScreen({
722 )774 )
723 return;775 return;
724 lastSelection.current = { path: route.params.path, id: route.params.fileSelectionId };776 lastSelection.current = { path: route.params.path, id: route.params.fileSelectionId };
777 if (route.params.focus) navigation.setParams({ focus: undefined });
725 setJumpPath(route.params.path);778 setJumpPath(route.params.path);
726 setSelectedRowId(null);779 setSelectedRowId(null);
727 jumpStarted.current = false;780 jumpStarted.current = false;
...@@ -732,7 +785,7 @@ export function PullRequestFileRouteScreen({...@@ -732,7 +785,7 @@ export function PullRequestFileRouteScreen({
732 void nativeView.current.scrollToFile(file.id, false).catch(() => {785 void nativeView.current.scrollToFile(file.id, false).catch(() => {
733 jumpStarted.current = false;786 jumpStarted.current = false;
734 });787 });
735 }, [route.params.path, route.params.fileSelectionId, data.files]);788 }, [route.params.path, route.params.fileSelectionId, route.params.focus, data.files, navigation]);
736 const appliedFilter = useRef(filter.hideTestFiles);789 const appliedFilter = useRef(filter.hideTestFiles);
737 useEffect(() => {790 useEffect(() => {
738 if (appliedFilter.current === filter.hideTestFiles) return;791 if (appliedFilter.current === filter.hideTestFiles) return;
...@@ -745,7 +798,7 @@ export function PullRequestFileRouteScreen({...@@ -745,7 +798,7 @@ export function PullRequestFileRouteScreen({
745 }798 }
746 }, [filter.hideTestFiles, result.parsed]);799 }, [filter.hideTestFiles, result.parsed]);
747 const { nativeReviewDiffStyle } = useAppearanceCodeSurface();800 const { nativeReviewDiffStyle } = useAppearanceCodeSurface();
748 const { themeAppearance } = useAppearancePreferences();801 const { themeAppearance, appearance, setCodeWordBreak } = useAppearancePreferences();
749 const bridge = useNativeReviewDiffBridge({802 const bridge = useNativeReviewDiffBridge({
750 threadKey: null,803 threadKey: null,
751 sectionId: `pull-request:${route.params.repository}:${route.params.number}:${commit ?? "all"}`,804 sectionId: `pull-request:${route.params.repository}:${route.params.number}:${commit ?? "all"}`,
...@@ -753,7 +806,7 @@ export function PullRequestFileRouteScreen({...@@ -753,7 +806,7 @@ export function PullRequestFileRouteScreen({
753 data,806 data,
754 collapsedFileIds: collapsedIds,807 collapsedFileIds: collapsedIds,
755 viewedFileIds: viewedIds,808 viewedFileIds: viewedIds,
756 selectedRowIds: selectedRowId ? [selectedRowId] : EMPTY_IDS,809 selectedRowIds: selectedRowId ? [selectedRowId] : focusedRows.map((row) => row.id),
757 canHighlight: true,810 canHighlight: true,
758 });811 });
759 const [NativeDiffView] = useState(() => resolveNativeReviewDiffView());812 const [NativeDiffView] = useState(() => resolveNativeReviewDiffView());
...@@ -783,6 +836,7 @@ export function PullRequestFileRouteScreen({...@@ -783,6 +836,7 @@ export function PullRequestFileRouteScreen({
783 (Platform.OS !== "ios" || decoded)836 (Platform.OS !== "ios" || decoded)
784 ) {837 ) {
785 jumpStarted.current = true;838 jumpStarted.current = true;
839 if (focusIndex >= 0) return;
786 void nativeView.current.scrollToFile(targetFile.id, false).catch(() => {840 void nativeView.current.scrollToFile(targetFile.id, false).catch(() => {
787 jumpStarted.current = false;841 jumpStarted.current = false;
788 });842 });
...@@ -792,7 +846,7 @@ export function PullRequestFileRouteScreen({...@@ -792,7 +846,7 @@ export function PullRequestFileRouteScreen({
792 const lastRow = event.nativeEvent.lastRowIndex;846 const lastRow = event.nativeEvent.lastRowIndex;
793 if (typeof lastRow === "number" && lastRow >= data.rows.length - 40) loadMore();847 if (typeof lastRow === "number" && lastRow >= data.rows.length - 40) loadMore();
794 },848 },
795 [updateVisibleRange, data.rows.length, loadMore, targetFile],849 [updateVisibleRange, data.rows.length, loadMore, targetFile, focusIndex],
796 );850 );
797 const onToggleFile = useCallback<NonNullable<NativeReviewDiffViewProps["onToggleFile"]>>(851 const onToggleFile = useCallback<NonNullable<NativeReviewDiffViewProps["onToggleFile"]>>(
798 (event) => {852 (event) => {
...@@ -829,6 +883,12 @@ export function PullRequestFileRouteScreen({...@@ -829,6 +883,12 @@ export function PullRequestFileRouteScreen({
829 trailing={883 trailing={
830 <ControlPillMenu884 <ControlPillMenu
831 actions={[885 actions={[
886 {
887 id: "word-wrap",
888 title: "Word wrap",
889 image: "text.alignleft",
890 state: appearance.codeWordBreak ? "on" : "off",
891 },
832 {892 {
833 id: "hide-tests",893 id: "hide-tests",
834 title: "Hide test files",894 title: "Hide test files",
...@@ -849,7 +909,8 @@ export function PullRequestFileRouteScreen({...@@ -849,7 +909,8 @@ export function PullRequestFileRouteScreen({
849 : []),909 : []),
850 ]}910 ]}
851 onPressAction={({ nativeEvent }) => {911 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();
853 else if (914 else if (
854 nativeEvent.event === "submit-review" ||915 nativeEvent.event === "submit-review" ||
855 nativeEvent.event === "comment-on-pr"916 nativeEvent.event === "comment-on-pr"
...@@ -895,6 +956,13 @@ export function PullRequestFileRouteScreen({...@@ -895,6 +956,13 @@ export function PullRequestFileRouteScreen({
895 <NativeHeaderToolbar.Label>Add comment</NativeHeaderToolbar.Label>956 <NativeHeaderToolbar.Label>Add comment</NativeHeaderToolbar.Label>
896 </NativeHeaderToolbar.MenuAction>957 </NativeHeaderToolbar.MenuAction>
897 ) : null}958 ) : 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>
898 <NativeHeaderToolbar.MenuAction966 <NativeHeaderToolbar.MenuAction
899 icon="line.3.horizontal.decrease"967 icon="line.3.horizontal.decrease"
900 isOn={filter.hideTestFiles}968 isOn={filter.hideTestFiles}
...@@ -956,6 +1024,7 @@ export function PullRequestFileRouteScreen({...@@ -956,6 +1024,7 @@ export function PullRequestFileRouteScreen({
956 {NativeDiffView && data.rows.length && !seekingFile ? (1024 {NativeDiffView && data.rows.length && !seekingFile ? (
957 <View className="flex-1" collapsable={false}>1025 <View className="flex-1" collapsable={false}>
958 <NativeDiffView1026 <NativeDiffView
1027 initialRowIndex={focusIndex >= 0 ? Math.max(0, focusIndex - 3) : undefined}
959 nativeViewRef={nativeView}1028 nativeViewRef={nativeView}
960 refreshing={result.refreshing}1029 refreshing={result.refreshing}
961 onPullToRefresh={result.onPullRefresh}1030 onPullToRefresh={result.onPullRefresh}
apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx+20-8
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { useNavigation } from "@react-navigation/native";2import { useNavigation } from "@react-navigation/native";
3import type { PullRequestAction, PullRequestDetail } from "@t3tools/contracts";3import type { PullRequestAction, PullRequestDetail } from "@t3tools/contracts";
4import Animated from "react-native-reanimated";
5import { PR_ROW_LAYOUT, PR_DISCLOSURE_ENTER, PR_ROW_EXIT } from "./pullRequestAnimations";
4import { useState } from "react";6import { useState } from "react";
5import { Linking, Pressable, View } from "react-native";7import { Linking, Pressable, View } from "react-native";
6import { AppText as Text } from "../../components/AppText";8import { AppText as Text } from "../../components/AppText";
...@@ -97,7 +99,7 @@ export function PullRequestChecksRow(props: RowProps) {...@@ -97,7 +99,7 @@ export function PullRequestChecksRow(props: RowProps) {
97 const checks = pullRequestChecksSummary(props.pr);99 const checks = pullRequestChecksSummary(props.pr);
98 const action = usePullRequestAction(props.params, props.pr);100 const action = usePullRequestAction(props.params, props.pr);
99 return (101 return (
100 <>102 <Animated.View layout={PR_ROW_LAYOUT}>
101 <PullRequestCardRow103 <PullRequestCardRow
102 title={checks.title}104 title={checks.title}
103 icon={checks.icon}105 icon={checks.icon}
...@@ -109,7 +111,12 @@ export function PullRequestChecksRow(props: RowProps) {...@@ -109,7 +111,12 @@ export function PullRequestChecksRow(props: RowProps) {
109 onPress={() => setExpanded(!expanded)}111 onPress={() => setExpanded(!expanded)}
110 />112 />
111 {expanded ? (113 {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 >
113 {props.pr.state === "open" &&120 {props.pr.state === "open" &&
114 props.pr.checks.some((check) => check.status === "failure") ? (121 props.pr.checks.some((check) => check.status === "failure") ? (
115 <PullRequestAgentRow {...props} task="feedback" title="Fix failing checks" />122 <PullRequestAgentRow {...props} task="feedback" title="Fix failing checks" />
...@@ -157,9 +164,9 @@ export function PullRequestChecksRow(props: RowProps) {...@@ -157,9 +164,9 @@ export function PullRequestChecksRow(props: RowProps) {
157 )164 )
158 }165 }
159 />166 />
160 </View>167 </Animated.View>
161 ) : null}168 ) : null}
162 </>169 </Animated.View>
163 );170 );
164}171}
165172
...@@ -236,7 +243,7 @@ export function PullRequestReviewersRow(props: RowProps) {...@@ -236,7 +243,7 @@ export function PullRequestReviewersRow(props: RowProps) {
236 props.pr.capabilities.reviewers.request &&243 props.pr.capabilities.reviewers.request &&
237 props.pr.viewerPermissions.requestReviewers;244 props.pr.viewerPermissions.requestReviewers;
238 return (245 return (
239 <>246 <Animated.View layout={PR_ROW_LAYOUT}>
240 <PullRequestCardRow247 <PullRequestCardRow
241 title={summary.title}248 title={summary.title}
242 icon={summary.icon}249 icon={summary.icon}
...@@ -247,7 +254,12 @@ export function PullRequestReviewersRow(props: RowProps) {...@@ -247,7 +254,12 @@ export function PullRequestReviewersRow(props: RowProps) {
247 onPress={() => setExpanded(!expanded)}254 onPress={() => setExpanded(!expanded)}
248 />255 />
249 {expanded ? (256 {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 >
251 {summary.reviews.map((review) => (263 {summary.reviews.map((review) => (
252 <View264 <View
253 key={review.id}265 key={review.id}
...@@ -308,8 +320,8 @@ export function PullRequestReviewersRow(props: RowProps) {...@@ -308,8 +320,8 @@ export function PullRequestReviewersRow(props: RowProps) {
308 onPress={() => void Linking.openURL(props.pr.url)}320 onPress={() => void Linking.openURL(props.pr.url)}
309 />321 />
310 )}322 )}
311 </View>323 </Animated.View>
312 ) : null}324 ) : null}
313 </>325 </Animated.View>
314 );326 );
315}327}
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", () => {...@@ -67,6 +67,17 @@ describe("mobile PR test file filter", () => {
67 expect(parsed.files).toHaveLength(4);67 expect(parsed.files).toHaveLength(4);
68 });68 });
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
70 it("uses the current path when a file is renamed out of a test directory", () => {81 it("uses the current path when a file is renamed out of a test directory", () => {
71 const parsed = buildReviewParsedDiff(82 const parsed = buildReviewParsedDiff(
72 "diff --git a/tests/helper.ts b/src/helper.ts\nsimilarity index 100%\nrename from tests/helper.ts\nrename to src/helper.ts\n",83 "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 {...@@ -21,9 +21,12 @@ export function isPullRequestTestFile(path: string): boolean {
21export function filterPullRequestTestFiles(21export function filterPullRequestTestFiles(
22 parsed: ReviewParsedDiff,22 parsed: ReviewParsedDiff,
23 hideTestFiles: boolean,23 hideTestFiles: boolean,
24 includePath?: string,
24): ReviewParsedDiff {25): ReviewParsedDiff {
25 if (!hideTestFiles || parsed.kind !== "files") return parsed;26 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 );
27 if (files.length === parsed.files.length) return parsed;30 if (files.length === parsed.files.length) return parsed;
28 return {31 return {
29 ...parsed,32 ...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";...@@ -5,14 +5,14 @@ import type { ReviewParsedDiff } from "../review/reviewModel";
5import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";5import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
6import { filterPullRequestTestFiles } from "./pullRequestFileFilters";6import { filterPullRequestTestFiles } from "./pullRequestFileFilters";
77
8export function usePullRequestFileFilter(parsed: ReviewParsedDiff) {8export function usePullRequestFileFilter(parsed: ReviewParsedDiff, includePath?: string) {
9 const preferences = useAtomValue(mobilePreferencesAtom);9 const preferences = useAtomValue(mobilePreferencesAtom);
10 const savePreferences = useAtomSet(updateMobilePreferencesAtom);10 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
11 const hideTestFiles =11 const hideTestFiles =
12 AsyncResult.isSuccess(preferences) && preferences.value.pullRequestHideTestFiles === true;12 AsyncResult.isSuccess(preferences) && preferences.value.pullRequestHideTestFiles === true;
13 const visible = useMemo(13 const visible = useMemo(
14 () => filterPullRequestTestFiles(parsed, hideTestFiles),14 () => filterPullRequestTestFiles(parsed, hideTestFiles, includePath),
15 [parsed, hideTestFiles],15 [parsed, hideTestFiles, includePath],
16 );16 );
17 const hiddenCount =17 const hiddenCount =
18 parsed.kind === "files" && visible.kind === "files" ? parsed.fileCount - visible.fileCount : 0;18 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 @@...@@ -1,7 +1,7 @@
1import { useNativeLayoutMetrics } from "../../native/native-layout-metrics";1import { useNativeLayoutMetrics } from "../../native/native-layout-metrics";
2import { useCallback, useMemo, useState, type ReactNode } from "react";2import { useCallback, useMemo, useState, type ReactNode } from "react";
3import { Keyboard, Platform, View, type NativeSyntheticEvent } from "react-native";3import { 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
6import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext";6import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext";
7import type { WorkspaceTab } from "./workspace-tabs-state";7import type { WorkspaceTab } from "./workspace-tabs-state";
...@@ -70,11 +70,16 @@ export function WorkspaceNativeTabs(props: {...@@ -70,11 +70,16 @@ export function WorkspaceNativeTabs(props: {
70 tabBarMinimizeBehavior: "never",70 tabBarMinimizeBehavior: "never",
71 tabBarLeadingWidth: phoneBar ? 220 : 0,71 tabBarLeadingWidth: phoneBar ? 220 : 0,
72 }}72 }}
73 onTabSelectionRejected={({
74 nativeEvent,
75 }: NativeSyntheticEvent<TabSelectionRejectedEvent>) => {
76 setProvenance(nativeEvent.provenance);
77 }}
73 onTabSelected={({ nativeEvent }: NativeSyntheticEvent<TabSelectedEvent>) => {78 onTabSelected={({ nativeEvent }: NativeSyntheticEvent<TabSelectedEvent>) => {
74 const tab = nativeEvent.selectedScreenKey;79 const tab = nativeEvent.selectedScreenKey;
75 if (tab !== "threads" && tab !== "pullRequests") return;80 if (tab !== "threads" && tab !== "pullRequests") return;
76 setProvenance(nativeEvent.provenance);81 setProvenance(nativeEvent.provenance);
77 if (tab === selectedTab) return;82 if (nativeEvent.actionOrigin === "programmatic-js" || tab === selectedTab) return;
78 Keyboard.dismiss();83 Keyboard.dismiss();
79 props.onSelect();84 props.onSelect();
80 workspace.dispatch({ type: "select", tab });85 workspace.dispatch({ type: "select", tab });
apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx+14-5
...@@ -2,7 +2,7 @@ import { useNavigation } from "@react-navigation/native";...@@ -2,7 +2,7 @@ import { useNavigation } from "@react-navigation/native";
2import { use, useCallback, useMemo } from "react";2import { use, useCallback, useMemo } from "react";
3import { Platform, View, useWindowDimensions } from "react-native";3import { Platform, View, useWindowDimensions } from "react-native";
4import { useKeyboardState } from "react-native-keyboard-controller";4import { useKeyboardState } from "react-native-keyboard-controller";
5import { useSafeAreaInsets } from "react-native-safe-area-context";5import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
66
7import { CompactBrandTitle } from "../../components/CompactBrandTitle";7import { CompactBrandTitle } from "../../components/CompactBrandTitle";
8import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";8import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
...@@ -24,7 +24,8 @@ import {...@@ -24,7 +24,8 @@ import {
24} from "./WorkspaceTabsContext";24} from "./WorkspaceTabsContext";
25import { WorkspaceToolbar } from "./WorkspaceToolbar";25import { WorkspaceToolbar } from "./WorkspaceToolbar";
26import { WORKSPACE_DOCK, workspaceToolbarHeight } from "./workspace-tabs-state";26import { WORKSPACE_DOCK, workspaceToolbarHeight } from "./workspace-tabs-state";
27import { useSystemWorkspaceTabBar } from "./useSystemWorkspaceTabBar";27import { WorkspaceSidebarTabs } from "./WorkspaceSidebarTabs";
28import { useSidebarWorkspaceTabs, useSystemWorkspaceTabBar } from "./useSystemWorkspaceTabBar";
2829
29const TAB_TITLES = {30const TAB_TITLES = {
30 threads: "Threads",31 threads: "Threads",
...@@ -58,6 +59,7 @@ function WorkspaceRootContent() {...@@ -58,6 +59,7 @@ function WorkspaceRootContent() {
58 const columnMetrics = useNativeColumnLayoutMetrics();59 const columnMetrics = useNativeColumnLayoutMetrics();
59 const nativeMetrics = useNativeLayoutMetrics();60 const nativeMetrics = useNativeLayoutMetrics();
60 const systemTabBar = useSystemWorkspaceTabBar();61 const systemTabBar = useSystemWorkspaceTabBar();
62 const sidebarTabs = useSidebarWorkspaceTabs();
61 const sidebarNavigation =63 const sidebarNavigation =
62 primaryColumn !== null &&64 primaryColumn !== null &&
63 (layout.usesSplitView ||65 (layout.usesSplitView ||
...@@ -86,7 +88,7 @@ function WorkspaceRootContent() {...@@ -86,7 +88,7 @@ function WorkspaceRootContent() {
86 const bottomClearance =88 const bottomClearance =
87 Platform.OS === "ios"89 Platform.OS === "ios"
88 ? Math.max(insets.bottom, keyboardHeight) +90 ? Math.max(insets.bottom, keyboardHeight) +
89 (systemTabBar || tab.searchMode !== "resting"91 (systemTabBar || sidebarTabs || tab.searchMode !== "resting"
90 ? 1692 ? 16
91 : WORKSPACE_DOCK.height + WORKSPACE_DOCK.bottomGap + 16)93 : WORKSPACE_DOCK.height + WORKSPACE_DOCK.bottomGap + 16)
92 : keyboardHeight > 0 && tab.searchMode !== "resting"94 : keyboardHeight > 0 && tab.searchMode !== "resting"
...@@ -96,7 +98,10 @@ function WorkspaceRootContent() {...@@ -96,7 +98,10 @@ function WorkspaceRootContent() {
9698
97 return (99 return (
98 <WorkspaceTabsContext value={context}>100 <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 >
100 {Platform.OS === "ios" ? (105 {Platform.OS === "ios" ? (
101 <NativeStackScreenOptions106 <NativeStackScreenOptions
102 optionsVersion={[state.selectedTab, state.selectionRevision, width, sidebarNavigation]}107 optionsVersion={[state.selectedTab, state.selectionRevision, width, sidebarNavigation]}
...@@ -132,7 +137,11 @@ function WorkspaceRootContent() {...@@ -132,7 +137,11 @@ function WorkspaceRootContent() {
132 ]}137 ]}
133 />138 />
134 )}139 )}
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>
136 <WorkspaceToolbar145 <WorkspaceToolbar
137 sidebar={sidebarNavigation}146 sidebar={sidebarNavigation}
138 onNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}147 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...@@ -9,20 +9,17 @@ import { NativeWorkspaceTabActivityContext } from "../../native/v5-workspace-con
9import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";9import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
10import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";10import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
11import { useWorkspaceTabs } from "./WorkspaceTabsContext";11import { 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. */
15export function WorkspaceToolbar(props: {14export function WorkspaceToolbar(props: {
16 readonly onNewTask: () => void;15 readonly onNewTask: () => void;
17 readonly sidebar?: boolean;16 readonly sidebar?: boolean;
18}) {17}) {
19 const workspace = useWorkspaceTabs()!;18 const workspace = useWorkspaceTabs()!;
20 const navigation = useNavigation();19 const navigation = useNavigation();
21 const systemTabBar = useSystemWorkspaceTabBar();
22 const metrics = useNativeLayoutMetrics();20 const metrics = useNativeLayoutMetrics();
23 const nativeToolbarCompose =21 const nativeToolbarCompose =
24 systemTabBar &&22 Platform.OS === "ios" && (Platform.isPad || (metrics?.verticalBarEdge ?? "none") !== "none");
25 ((Platform.OS === "ios" && Platform.isPad) || (metrics?.verticalBarEdge ?? "none") !== "none");
26 const { isTabActive } = workspace;23 const { isTabActive } = workspace;
27 const tabActive = use(NativeWorkspaceTabActivityContext)?.active ?? true;24 const tabActive = use(NativeWorkspaceTabActivityContext)?.active ?? true;
28 const { panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();25 const { panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
apps/mobile/src/features/workspace/useSystemWorkspaceTabBar.ts+9-2
...@@ -1,6 +1,13 @@...@@ -1,6 +1,13 @@
1import { Platform } from "react-native";1import { 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. */
4export function useSystemWorkspaceTabBar() {10export function useSystemWorkspaceTabBar() {
5 return Platform.OS === "ios";11 const sidebarTabs = useSidebarWorkspaceTabs();
12 return Platform.OS === "ios" && !sidebarTabs;
6}13}
apps/web/src/components/pullRequest/guide/GuideExcerpt.tsx+1-1
...@@ -36,7 +36,7 @@ import {...@@ -36,7 +36,7 @@ import {
36 excerptRows,36 excerptRows,
37 fileDiffRows,37 fileDiffRows,
38 rowsToPatch,38 rowsToPatch,
39} from "./guideTour.logic";39} from "@t3tools/client-runtime/review-guide-tour";
40import { GuideNoteMarker } from "./GuideNotes";40import { GuideNoteMarker } from "./GuideNotes";
41import { GuideProse } from "./GuideProse";41import { GuideProse } from "./GuideProse";
4242
apps/web/src/components/pullRequest/guide/GuideRail.tsx+1-1
...@@ -4,7 +4,7 @@ import { CheckIcon } from "lucide-react";...@@ -4,7 +4,7 @@ import { CheckIcon } from "lucide-react";
4import { cn } from "~/lib/utils";4import { cn } from "~/lib/utils";
55
6import { Badge } from "../../ui/badge";6import { Badge } from "../../ui/badge";
7import { stopSubline } from "./guideTour.logic";7import { stopSubline } from "@t3tools/client-runtime/review-guide-tour";
88
9/**9/**
10 * The tour's table of contents: where the reader is and which stops they have read past. A stop10 * 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";...@@ -28,7 +28,7 @@ import { GuideExcerpt, type ExcerptCommenting } from "./GuideExcerpt";
28import { GuideFileDiff } from "./GuideFileDiff";28import { GuideFileDiff } from "./GuideFileDiff";
29import { GuideNotes } from "./GuideNotes";29import { GuideNotes } from "./GuideNotes";
30import { GuideProse } from "./GuideProse";30import { GuideProse } from "./GuideProse";
31import { letterStopNotes } from "./guideTour.logic";31import { letterStopNotes } from "@t3tools/client-runtime/review-guide-tour";
3232
33const ATTENTION_LABELS = {33const ATTENTION_LABELS = {
34 tradeoff: "A tradeoff to weigh",34 tradeoff: "A tradeoff to weigh",
apps/web/src/components/pullRequest/guide/GuideTour.tsx+1-1
...@@ -18,7 +18,7 @@ import {...@@ -18,7 +18,7 @@ import {
18 useGuideRunStore,18 useGuideRunStore,
19 type GuideProgress,19 type GuideProgress,
20} from "./guideRunStore";20} from "./guideRunStore";
21import { stepTourPosition, stopFilePaths } from "./guideTour.logic";21import { stepTourPosition, stopFilePaths } from "@t3tools/client-runtime/review-guide-tour";
22import { useGuideDiffFiles } from "./useGuideDiffFiles";22import { useGuideDiffFiles } from "./useGuideDiffFiles";
2323
24/**24/**
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....@@ -193,7 +193,9 @@ declined pull request.
193### Review guides193### Review guides
194194
195Review guides are in beta. Turn on **Review guides** in Settings → Source Control to add the195Review 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
198The **Guide** tab gives you a tour of a pull request without reading it line by line: stops200The **Guide** tab gives you a tour of a pull request without reading it line by line: stops
199through the change in reading order. Each stop points at the lines worth reading, and a stop marked201through 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...@@ -205,11 +207,12 @@ covers that layer only, and may point out what a lower layer introduced or what
205Choose **Write guide** to have the review guide model from Settings → Source Control read the207Choose **Write guide** to have the review guide model from Settings → Source Control read the
206change in a temporary checkout; without one set, the source control writer model is used. Nothing208change in a temporary checkout; without one set, the source control writer model is used. Nothing
207in the checkout is changed, and a large pull request takes a few minutes; you can go elsewhere in209in 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 is210the 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
209kept for everyone using this server. To write it again, for example after a push or after changing212kept for everyone using this server. To write it again, for example after a push or after changing
210the model or instructions, choose **Rewrite guide** from the pull request's **⋯** menu.213the 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 agent215On web, while reading a stop, select lines in an excerpt to add a comment to your review or ask an agent
213about them; comments wait with the ones from the **Code** tab until you send the review. Choose a216about them; comments wait with the ones from the **Code** tab until you send the review. Choose a
214suggested question, or **Ask about this stop**, to open a thread that already knows which stop you217suggested question, or **Ask about this stop**, to open a thread that already knows which stop you
215are on. **Finish review** on the last stop opens the review with everything you collected.218are on. **Finish review** on the last stop opens the review with everything you collected.
packages/client-runtime/package.json+5
...@@ -398,6 +398,10 @@...@@ -398,6 +398,10 @@
398 "./source-control-clients": {398 "./source-control-clients": {
399 "types": "./src/sourceControlClients.ts",399 "types": "./src/sourceControlClients.ts",
400 "default": "./src/sourceControlClients.ts"400 "default": "./src/sourceControlClients.ts"
401 },
402 "./review-guide-tour": {
403 "types": "./src/reviewGuideTour.ts",
404 "default": "./src/reviewGuideTour.ts"
401 }405 }
402 },406 },
403 "scripts": {407 "scripts": {
...@@ -405,6 +409,7 @@...@@ -405,6 +409,7 @@
405 "test": "vp test run"409 "test": "vp test run"
406 },410 },
407 "dependencies": {411 "dependencies": {
412 "@pierre/diffs": "catalog:",
408 "@t3tools/contracts": "workspace:*",413 "@t3tools/contracts": "workspace:*",
409 "@t3tools/shared": "workspace:*",414 "@t3tools/shared": "workspace:*",
410 "@t3tools/source-control-azure-devops": "workspace:*",415 "@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:...@@ -1073,6 +1073,9 @@ importers:
10731073
1074 packages/client-runtime:1074 packages/client-runtime:
1075 dependencies:1075 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)
1076 '@t3tools/contracts':1079 '@t3tools/contracts':
1077 specifier: workspace:*1080 specifier: workspace:*
1078 version: link:../contracts1081 version: link:../contracts
...@@ -15766,6 +15769,21 @@ snapshots:...@@ -15766,6 +15769,21 @@ snapshots:
15766 transitivePeerDependencies:15769 transitivePeerDependencies:
15767 - '@shikijs/themes'15770 - '@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
15769 '@pierre/theme@1.1.0':15787 '@pierre/theme@1.1.0':
15770 optional: true15788 optional: true
1577115789
...@@ -15803,6 +15821,14 @@ snapshots:...@@ -15803,6 +15821,14 @@ snapshots:
15803 react-dom: 19.2.6(react@19.2.6)15821 react-dom: 19.2.6(react@19.2.6)
15804 shiki: 4.2.015822 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
15806 '@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)':15832 '@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)':
15807 dependencies:15833 dependencies:
15808 '@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)15834 '@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)