From 6548c006d429e7242ebc10c34946420418a276ec Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 24 Oct 2026 18:19:01 -0700 Subject: [PATCH 001/592] fix(web): signed-out T3 Connect shows a Sign in button (#24636) Co-authored-by: Claude Opus 5.5 --- .../settings/SavedEnvironmentRows.tsx | 120 ++++++++++-------- 1 file changed, 70 insertions(+), 50 deletions(-) diff --git a/apps/web/src/components/settings/SavedEnvironmentRows.tsx b/apps/web/src/components/settings/SavedEnvironmentRows.tsx index dbb7c3b94827e..5a331193bcae0 100644 --- a/apps/web/src/components/settings/SavedEnvironmentRows.tsx +++ b/apps/web/src/components/settings/SavedEnvironmentRows.tsx @@ -48,6 +48,7 @@ import { supportsServerUpdateThreadContinuation, } from "~/versionSkew"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; +import { isElectron } from "~/env"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; import { environmentCatalog } from "~/connection/catalog"; import { readEnvironmentScope } from "~/state/session"; @@ -65,6 +66,7 @@ import { ServerUpdateProgress, } from "../ServerUpdateAction"; import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; +import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt"; type SavedBackendListRowProps = { environment: EnvironmentPresentation; @@ -476,15 +478,29 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b operationError, reconcileCloudState, } = useCloudLinkController(); + const { openAuthPrompt } = useT3ConnectAuthPrompt(); const [isUpdating, setIsUpdating] = useState(false); const [isUpdatingPreference, setIsUpdatingPreference] = useState(false); - const disabledReason = !isSignedIn - ? "Sign in to T3 Connect to manage this environment." - : !canManageRelay - ? "Your session does not have permission to manage T3 Connect access." - : null; + const disabledReason = canManageRelay + ? null + : "Your session does not have permission to manage T3 Connect access."; const isBusy = isUpdating || isUpdatingPreference; + const switchDisabled = + !canManageRelay || + !isSignedIn || + primaryCloudLinkState.data === null || + primaryCloudLinkState.isPending || + isBusy; + // `isSignedIn` is undefined until Clerk loads. Signed out, one row shows a + // sign-in button instead of its switch; the onboarding dialog that opens after + // a fresh sign-in offers both linking and publishing. + const signedOut = isSignedIn === false; + const signInButton = ( + + ); const refreshLinkState = useEffectEvent(primaryCloudLinkState.refresh); // The server rechecks reachability when the tunnel's 30 s readiness poll changes. // Poll while mounted, so a failed read does not stop later ones. @@ -579,36 +595,36 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b return ( <> - {window.desktopBridge ? ( + {isElectron ? ( void updateManagedTunnel(enabled)} - /> + signedOut ? ( + signInButton + ) : ( + void updateManagedTunnel(enabled)} + /> + ) } /> ) : linkRevoked ? ( @@ -617,26 +633,30 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b description="This environment was removed from T3 Connect. Run `t3 connect link` on the host to link it again." /> ) : null} - void updatePublishAgentActivity(enabled)} - /> - } - /> - {managedTunnelActive ? ( + {signedOut && isElectron ? null : ( + void updatePublishAgentActivity(enabled)} + /> + ) + } + /> + )} + {managedTunnelActive && !signedOut ? ( void updateHoldWebhooks(enabled)} /> From 62eb35b163d636d3c87181367976eac5622e9933 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 18:32:19 -0700 Subject: [PATCH 002/592] perf(mobile): stop menu fault logging and whole-list re-renders on Home (#24646) Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/features/home/HomeScreen.tsx | 128 ++++------------ .../threads/ThreadNavigationSidebar.tsx | 144 +++++------------- .../src/features/threads/useThreadListV2.ts | 139 +++++++++++++++++ .../src/state/threadInbox.test.ts | 15 +- .../client-runtime/src/state/threadInbox.ts | 13 +- patches/@react-native-menu__menu@2.0.0.patch | 15 +- pnpm-lock.yaml | 6 +- 7 files changed, 246 insertions(+), 214 deletions(-) create mode 100644 apps/mobile/src/features/threads/useThreadListV2.ts diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index f20a8fee636ad..6e83e124de48c 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -2,7 +2,6 @@ import { useActiveNewTask } from "../../state/active-new-task"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; import type { ThreadMoveDestination } from "../threads/threadOrder"; -import { computeThreadMoveAvailability } from "../threads/threadOrder"; import type { LegendListRef } from "@legendapp/list/react-native"; import { AnimatedLegendList } from "@legendapp/list/reanimated"; import { THREAD_SIDEBAR_ROW_LAYOUT } from "../threads/threadListAnimations"; @@ -54,6 +53,7 @@ import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspace import { useThreadSearch } from "../../state/queries"; import { requestThreadMessageJump } from "../threads/threadMessageJump"; import { useThreadListSearchMatches } from "../threads/useThreadListSearchMatches"; +import { useThreadListV2 } from "../threads/useThreadListV2"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; import { usePendingThreadOrder } from "../../state/thread-order"; import { threadListEnvironmentsAtom } from "../../state/server"; @@ -71,11 +71,7 @@ import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider import { useOpenThreadPullRequests } from "../threads/use-open-thread-pull-requests"; import type { ThreadPrPresentation } from "../../state/thread-pr-presentation"; import { - buildThreadListV2Items, - getThreadListV2OrderedSection, - buildThreadListV2ListItems, threadListV2ListItemsAreEqual, - threadListInboxReturns, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, type ThreadListV2ListItem, @@ -300,7 +296,6 @@ export function HomeScreen(props: HomeScreenProps) { : []; const threadSearch = useThreadSearch(searchEnvironmentIds, props.searchQuery); const threadSearchMatchByKey = useThreadListSearchMatches(props.threads, threadSearch); - const matchedThreadKeys = new Set(threadSearchMatchByKey.keys()); const handleSwipeableWillOpen = (methods: SwipeableMethods) => { if (openSwipeableRef.current !== methods) { openSwipeableRef.current?.close(); @@ -397,14 +392,6 @@ export function HomeScreen(props: HomeScreenProps) { ), ), ); - const v2ScopedProjectKeys = - v2ScopedProjectGroup === null - ? null - : new Set( - v2ScopedProjectGroup.projectRefs.map((projectRef) => - scopedProjectKey(projectRef.environmentId, projectRef.projectId), - ), - ); const handleSettleThread = props.onSettleThread; const handleSnoozeThread = props.onSnoozeThread; const handleUnsnoozeThread = props.onUnsnoozeThread; @@ -462,54 +449,32 @@ export function HomeScreen(props: HomeScreenProps) { } = listEnvironments; const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId); const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); - // Up/down menu availability for every card, computed once per section per - // rebuild (see computeThreadMoveAvailability): per-thread planner calls made - // list construction quadratic, and this list rebuilds on every minute tick. - const threadMoveAvailability = (() => { - const sectionAvailability = (section: "pinned" | "active") => - computeThreadMoveAvailability({ - allThreads: props.threads, - section, - pendingOrder, - reorderableEnvironmentIds: - section === "pinned" ? pinReorderEnvironmentIds : activeReorderEnvironmentIds, - ordered: getThreadListV2OrderedSection({ - threads: props.threads, - section, - pendingOrder, - now, - settlementEnvironmentIds, - snoozeEnvironmentIds, - queuedThreadKeys, - }), - }); - return new Map([ - ...sectionAvailability("pinned"), - ...(workingShelfEnabled ? [] : sectionAvailability("active")), - ]); - })(); - const threadListV2Layout = (() => { - threadListInboxReturns.observe(workingShelfEnabled ? props.threads : null); - return buildThreadListV2Items({ - pendingOrder, - threads: props.threads.filter((thread) => thread.archivedAt === null), - environmentId: props.selectedEnvironmentId, - projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs, - searchQuery: props.searchQuery, - matchedThreadKeys, - settlementEnvironmentIds, - snoozeEnvironmentIds, - queuedThreadKeys, - settledLimit: settledVisibleCount, - now, - workingShelfEnabled, - workingShelfExpanded, - inboxReturnAt: threadListInboxReturns.returnedAt, - snoozedShelfExpanded, - settledShelfExpanded, - selectedThreadKey, - }); - })(); + const { + layout: threadListV2Layout, + items: threadListV2Items, + contentOnlyThreadKeys, + } = useThreadListV2({ + threads: props.threads, + pendingTasks: props.pendingTasks, + environmentId: props.selectedEnvironmentId, + projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs, + searchQuery: props.searchQuery, + searchMatches: threadSearchMatchByKey, + settlementEnvironmentIds, + snoozeEnvironmentIds, + pinReorderEnvironmentIds, + activeReorderEnvironmentIds, + queuedThreadKeys, + settledLimit: settledVisibleCount, + now, + pendingOrder, + shelfPreferencesLoaded, + workingShelfEnabled, + workingShelfExpanded, + snoozedShelfExpanded, + settledShelfExpanded, + selectedThreadKey, + }); const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt; useEffect(() => { if (nextSnoozeWakeAt === null) return; @@ -525,47 +490,20 @@ export function HomeScreen(props: HomeScreenProps) { // unchanged: after a clamped fire (wake beyond the 32-bit setTimeout // range) the boundary string is identical and the chain would die. }, [nextSnoozeWakeAt, snoozeWakeTick]); - const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); - const v2PendingTasks = props.pendingTasks.filter( - (pendingTask) => - (props.selectedEnvironmentId === null || - pendingTask.environmentId === props.selectedEnvironmentId) && - (v2ScopedProjectKeys === null || - v2ScopedProjectKeys.has( - scopedProjectKey(pendingTask.environmentId, pendingTask.projectId), - )) && - (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), - ); - const threadListV2Items = buildThreadListV2ListItems({ - items: threadListV2Layout.items, - pendingTasks: v2PendingTasks, - workingCount: threadListV2Layout.workingCount, - workingShelfExpanded, - workingShelfHeaderIndex: threadListV2Layout.workingShelfHeaderIndex, - snoozedCount: threadListV2Layout.snoozedCount, - snoozedShelfExpanded, - snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, - settledCount: threadListV2Layout.settledCount, - settledShelfExpanded, - settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex, - snoozeLabelNow: `${nowMinute}:00.000Z`, - snoozeEnvironmentIds, - queuedThreadKeys, - moveAvailability: threadMoveAvailability, - shelfPreferencesLoading: !shelfPreferencesLoaded, - }); - + // Called as `props.onSelectThread(...)`, the compiler would cache this handler on the whole + // props object, which is new on every render of Home's parent. + const { onSelectThread } = props; // A row listed for a message match opens the thread at that message, by // tap or by shortcut. const selectThread = (thread: EnvironmentThreadShell) => { const key = threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }); - const messageId = threadListV2Layout.contentOnlyThreadKeys.has(key) + const messageId = contentOnlyThreadKeys.has(key) ? threadSearchMatchByKey.get(key)?.messageId : undefined; if (messageId) { requestThreadMessageJump(scopedThreadKey(thread.environmentId, thread.id), messageId); } - props.onSelectThread(thread); + onSelectThread(thread); }; useThreadJumpShortcuts(threadListV2Items, selectThread); useEffect(() => { @@ -670,7 +608,7 @@ export function HomeScreen(props: HomeScreenProps) { environmentMachine={machineByEnvironmentId.get(thread.environmentId)} searchMatch={ // Only rows listed for their messages show one and open at it. - threadListV2Layout.contentOnlyThreadKeys.has( + contentOnlyThreadKeys.has( threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }), ) ? threadSearchMatchByKey.get( diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 13ce478929d25..3462c8ae8850c 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -3,7 +3,6 @@ import { ThreadUndoNotice } from "../home/ThreadUndoNotice"; import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; import { resolveScratchProjectScopeKey } from "@t3tools/client-runtime/state/project-grouping"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; -import { computeThreadMoveAvailability } from "./threadOrder"; import type { EnvironmentProject, EnvironmentThreadShell, @@ -41,6 +40,7 @@ import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; +import { useThreadListV2 } from "./useThreadListV2"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; import { requestThreadMessageJump } from "./threadMessageJump"; import { useThreadListSearchMatches } from "./useThreadListSearchMatches"; @@ -72,12 +72,8 @@ import { import { useThreadRowProviderInstanceResolver } from "./thread-provider-instance"; import type { ThreadPrPresentation } from "../../state/thread-pr-presentation"; import { - buildThreadListV2Items, - getThreadListV2OrderedSection, - buildThreadListV2ListItems, isThreadListV2ListItem, threadListV2ListItemsAreEqual, - threadListInboxReturns, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, type ThreadListV2ListItem, @@ -190,7 +186,6 @@ function ThreadNavigationSidebarPane( // This map reaches LegendList's extraData, which re-renders every row whenever it changes // identity; the hook keeps it stable. const threadSearchMatchByKey = useThreadListSearchMatches(threads, threadSearch); - const matchedThreadKeys = new Set(threadSearchMatchByKey.keys()); const projectScopeKey = options.selectedProjectKey; const resolvedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, { sidebarProjectGroupingMode: options.projectGroupingMode, @@ -232,14 +227,6 @@ function ThreadNavigationSidebarPane( const selectedProjectScope = projectScopes.find((scope) => scope.key === resolvedProjectKey) ?? null; const selectedProjectKey = selectedProjectScope?.key ?? null; - const selectedProjectRefs = - selectedProjectScope === null - ? null - : new Set( - selectedProjectScope.projectRefs.map((projectRef) => - scopedProjectKey(projectRef.environmentId, projectRef.projectId), - ), - ); const projectByKey = useMemo(() => { const map = new Map(); for (const project of projects) { @@ -294,54 +281,32 @@ function ThreadNavigationSidebarPane( } = listEnvironments; const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId); const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); - // Up/down menu availability for every card, computed once per section per - // rebuild (see computeThreadMoveAvailability): per-thread planner calls made - // list construction quadratic, and this list rebuilds on every minute tick. - const threadMoveAvailability = (() => { - const sectionAvailability = (section: "pinned" | "active") => - computeThreadMoveAvailability({ - allThreads: threads, - section, - pendingOrder, - reorderableEnvironmentIds: - section === "pinned" ? pinReorderEnvironmentIds : activeReorderEnvironmentIds, - ordered: getThreadListV2OrderedSection({ - threads, - section, - pendingOrder, - now, - settlementEnvironmentIds, - snoozeEnvironmentIds, - queuedThreadKeys, - }), - }); - return new Map([ - ...sectionAvailability("pinned"), - ...(workingShelfEnabled ? [] : sectionAvailability("active")), - ]); - })(); - const threadListV2Layout = (() => { - threadListInboxReturns.observe(workingShelfEnabled ? threads : null); - return buildThreadListV2Items({ - pendingOrder, - threads: threads.filter((thread) => thread.archivedAt === null), - environmentId: options.selectedEnvironmentId, - projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, - searchQuery: props.searchQuery, - matchedThreadKeys, - settlementEnvironmentIds, - snoozeEnvironmentIds, - queuedThreadKeys, - settledLimit: settledVisibleCount, - now, - workingShelfEnabled, - workingShelfExpanded, - inboxReturnAt: threadListInboxReturns.returnedAt, - snoozedShelfExpanded, - settledShelfExpanded, - selectedThreadKey: props.selectedThreadKey ?? null, - }); - })(); + const { + layout: threadListV2Layout, + items: threadListV2Items, + contentOnlyThreadKeys, + } = useThreadListV2({ + threads, + pendingTasks, + environmentId: options.selectedEnvironmentId, + projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, + searchQuery: props.searchQuery, + searchMatches: threadSearchMatchByKey, + settlementEnvironmentIds, + snoozeEnvironmentIds, + pinReorderEnvironmentIds, + activeReorderEnvironmentIds, + queuedThreadKeys, + settledLimit: settledVisibleCount, + now, + pendingOrder, + shelfPreferencesLoaded, + workingShelfEnabled, + workingShelfExpanded, + snoozedShelfExpanded, + settledShelfExpanded, + selectedThreadKey: props.selectedThreadKey ?? null, + }); const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt; useEffect(() => { if (nextSnoozeWakeAt === null) return; @@ -357,46 +322,17 @@ function ThreadNavigationSidebarPane( // unchanged: after a clamped fire (wake beyond the 32-bit setTimeout // range) the boundary string is identical and the chain would die. }, [nextSnoozeWakeAt, snoozeWakeTick]); - const listItems = ((): readonly SidebarListItem[] => { - const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); - const v2PendingTasks = pendingTasks.filter( - (pendingTask) => - (options.selectedEnvironmentId === null || - pendingTask.environmentId === options.selectedEnvironmentId) && - (selectedProjectRefs === null || - selectedProjectRefs.has( - scopedProjectKey(pendingTask.environmentId, pendingTask.projectId), - )) && - (v2SearchQuery.length === 0 || - pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), - ); - const items: SidebarListItem[] = buildThreadListV2ListItems({ - items: threadListV2Layout.items, - pendingTasks: v2PendingTasks, - workingCount: threadListV2Layout.workingCount, - workingShelfExpanded, - workingShelfHeaderIndex: threadListV2Layout.workingShelfHeaderIndex, - snoozedCount: threadListV2Layout.snoozedCount, - snoozedShelfExpanded, - snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, - settledCount: threadListV2Layout.settledCount, - settledShelfExpanded, - settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex, - snoozeLabelNow: `${nowMinute}:00.000Z`, - snoozeEnvironmentIds, - queuedThreadKeys, - moveAvailability: threadMoveAvailability, - shelfPreferencesLoading: !shelfPreferencesLoaded, - }); - if (settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0) { - items.push({ - type: "v2-show-more", - key: "v2-show-more", - hiddenCount: threadListV2Layout.hiddenSettledCount, - }); - } - return items; - })(); + const listItems: readonly SidebarListItem[] = + settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 + ? [ + ...threadListV2Items, + { + type: "v2-show-more", + key: "v2-show-more", + hiddenCount: threadListV2Layout.hiddenSettledCount, + }, + ] + : threadListV2Items; const listMenuActions = [ { id: "environment", @@ -503,7 +439,7 @@ function ThreadNavigationSidebarPane( // tap or by shortcut. const handleSelectThread = (thread: EnvironmentThreadShell) => { const key = threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }); - const messageId = threadListV2Layout.contentOnlyThreadKeys.has(key) + const messageId = contentOnlyThreadKeys.has(key) ? threadSearchMatchByKey.get(key)?.messageId : undefined; if (messageId) { @@ -634,7 +570,7 @@ function ThreadNavigationSidebarPane( environmentMachine={machineByEnvironmentId.get(thread.environmentId)} searchMatch={ // Only rows listed for their messages show one and open at it. - threadListV2Layout.contentOnlyThreadKeys.has( + contentOnlyThreadKeys.has( threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }), ) ? threadSearchMatchByKey.get( diff --git a/apps/mobile/src/features/threads/useThreadListV2.ts b/apps/mobile/src/features/threads/useThreadListV2.ts new file mode 100644 index 0000000000000..12d861d193a7d --- /dev/null +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -0,0 +1,139 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { useMemo } from "react"; + +import { scopedProjectKey } from "../../lib/scopedEntities"; +import type { PendingNewTask } from "../../state/use-pending-new-tasks"; +import { + buildThreadListV2Items, + buildThreadListV2ListItems, + getThreadListV2OrderedSection, + threadListInboxReturns, +} from "./threadListV2"; +import { computeThreadMoveAvailability } from "./threadOrder"; + +/** + * Screens' select handlers read the content-only key set; a fresh one per list rebuild gave + * every row a new handler whenever any thread shell changed. Keyed by a string, the set keeps + * its identity until its keys change. + */ +function useKeySet(newlineSeparatedKeys: string): ReadonlySet { + return useMemo( + () => new Set(newlineSeparatedKeys.length === 0 ? [] : newlineSeparatedKeys.split("\n")), + [newlineSeparatedKeys], + ); +} + +type LayoutInput = Parameters[0]; + +/** + * Home's and the thread sidebar's thread list. Its result is one value a screen only reads: + * built inline, the list's inputs stayed open to mutation across the screen's other hooks, so + * the React Compiler cached none of it and every render of the screen re-rendered every row. + */ +export function useThreadListV2(input: { + readonly threads: LayoutInput["threads"]; + readonly pendingTasks: ReadonlyArray; + readonly environmentId: EnvironmentId | null; + readonly projectRefs: LayoutInput["projectRefs"]; + readonly searchQuery: string; + /** Threads that match the search by content, keyed like `threadSearchMatchKey`. */ + readonly searchMatches: ReadonlyMap; + readonly settlementEnvironmentIds: ReadonlySet; + readonly snoozeEnvironmentIds: ReadonlySet; + readonly pinReorderEnvironmentIds: ReadonlySet; + readonly activeReorderEnvironmentIds: ReadonlySet; + readonly queuedThreadKeys: ReadonlySet; + readonly settledLimit: number; + readonly now: string; + readonly pendingOrder: LayoutInput["pendingOrder"]; + readonly shelfPreferencesLoaded: boolean; + readonly workingShelfEnabled: boolean; + readonly workingShelfExpanded: boolean; + readonly snoozedShelfExpanded: boolean; + readonly settledShelfExpanded: boolean; + readonly selectedThreadKey: string | null; +}) { + const { threads, workingShelfEnabled } = input; + // Observed once per change to the threads: that is when a thread can stop working. + const inboxReturnAt = useMemo( + () => threadListInboxReturns.observe(workingShelfEnabled ? threads : null), + [threads, workingShelfEnabled], + ); + // Up/down menu availability for every card, computed once per section per + // rebuild (see computeThreadMoveAvailability): per-thread planner calls made + // list construction quadratic, and this list rebuilds on every minute tick. + const sectionAvailability = (section: "pinned" | "active") => + computeThreadMoveAvailability({ + allThreads: threads, + section, + pendingOrder: input.pendingOrder, + reorderableEnvironmentIds: + section === "pinned" ? input.pinReorderEnvironmentIds : input.activeReorderEnvironmentIds, + ordered: getThreadListV2OrderedSection({ + threads, + section, + pendingOrder: input.pendingOrder, + now: input.now, + settlementEnvironmentIds: input.settlementEnvironmentIds, + snoozeEnvironmentIds: input.snoozeEnvironmentIds, + queuedThreadKeys: input.queuedThreadKeys, + }), + }); + const moveAvailability = new Map([ + ...sectionAvailability("pinned"), + ...(workingShelfEnabled ? [] : sectionAvailability("active")), + ]); + const layout = buildThreadListV2Items({ + pendingOrder: input.pendingOrder, + threads: threads.filter((thread) => thread.archivedAt === null), + environmentId: input.environmentId, + projectRefs: input.projectRefs, + searchQuery: input.searchQuery, + matchedThreadKeys: new Set(input.searchMatches.keys()), + settlementEnvironmentIds: input.settlementEnvironmentIds, + snoozeEnvironmentIds: input.snoozeEnvironmentIds, + queuedThreadKeys: input.queuedThreadKeys, + settledLimit: input.settledLimit, + now: input.now, + workingShelfEnabled, + workingShelfExpanded: input.workingShelfExpanded, + inboxReturnAt, + snoozedShelfExpanded: input.snoozedShelfExpanded, + settledShelfExpanded: input.settledShelfExpanded, + selectedThreadKey: input.selectedThreadKey, + }); + const contentOnlyThreadKeys = useKeySet([...layout.contentOnlyThreadKeys].sort().join("\n")); + const scopedProjectKeys = + input.projectRefs == null + ? null + : new Set(input.projectRefs.map((ref) => scopedProjectKey(ref.environmentId, ref.projectId))); + const searchQuery = input.searchQuery.trim().toLocaleLowerCase(); + const pendingTasks = input.pendingTasks.filter( + (pendingTask) => + (input.environmentId === null || pendingTask.environmentId === input.environmentId) && + (scopedProjectKeys === null || + scopedProjectKeys.has( + scopedProjectKey(pendingTask.environmentId, pendingTask.projectId), + )) && + (searchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(searchQuery)), + ); + const items = buildThreadListV2ListItems({ + items: layout.items, + pendingTasks, + workingCount: layout.workingCount, + workingShelfExpanded: input.workingShelfExpanded, + workingShelfHeaderIndex: layout.workingShelfHeaderIndex, + snoozedCount: layout.snoozedCount, + snoozedShelfExpanded: input.snoozedShelfExpanded, + snoozedShelfHeaderIndex: layout.snoozedShelfHeaderIndex, + settledCount: layout.settledCount, + settledShelfExpanded: input.settledShelfExpanded, + settledShelfHeaderIndex: layout.settledShelfHeaderIndex, + snoozeLabelNow: `${input.now.slice(0, 16)}:00.000Z`, + snoozeEnvironmentIds: input.snoozeEnvironmentIds, + queuedThreadKeys: input.queuedThreadKeys, + moveAvailability, + shelfPreferencesLoading: !input.shelfPreferencesLoaded, + }); + return { layout, items, contentOnlyThreadKeys }; +} diff --git a/packages/client-runtime/src/state/threadInbox.test.ts b/packages/client-runtime/src/state/threadInbox.test.ts index 86516983a2fe4..f5675d3a72eef 100644 --- a/packages/client-runtime/src/state/threadInbox.test.ts +++ b/packages/client-runtime/src/state/threadInbox.test.ts @@ -32,13 +32,16 @@ function thread(id: string, working: boolean) { describe("createInboxReturnTracker", () => { it("stamps a thread when it stops working, but never on the first observation", () => { const tracker = createInboxReturnTracker(); - tracker.observe([thread("a", true), thread("b", false)]); - expect(tracker.returnedAt(thread("a", true))).toBeUndefined(); - expect(tracker.returnedAt(thread("b", false))).toBeUndefined(); + const first = tracker.observe([thread("a", true), thread("b", false)]); + expect(first(thread("a", true))).toBeUndefined(); + expect(first(thread("b", false))).toBeUndefined(); - tracker.observe([thread("a", false), thread("b", false)]); - expect(tracker.returnedAt(thread("a", false))).toBeDefined(); - expect(tracker.returnedAt(thread("b", false))).toBeUndefined(); + const second = tracker.observe([thread("a", false), thread("b", false)]); + expect(second(thread("a", false))).toBeDefined(); + expect(second(thread("b", false))).toBeUndefined(); + expect(tracker.returnedAt(thread("a", false))).toBe(second(thread("a", false))); + // An earlier observation keeps its answer, so lists built from it stay valid. + expect(first(thread("a", false))).toBeUndefined(); }); }); diff --git a/packages/client-runtime/src/state/threadInbox.ts b/packages/client-runtime/src/state/threadInbox.ts index 880e6ebf31a08..8083dd045385b 100644 --- a/packages/client-runtime/src/state/threadInbox.ts +++ b/packages/client-runtime/src/state/threadInbox.ts @@ -88,8 +88,9 @@ function sortNewestFirst) => @@ -97,11 +98,13 @@ export function createInboxReturnTracker() { let lastWorkingKeys: ReadonlySet | null = null; const returns = new Map(); return { - observe(threads: ReadonlyArray | null): void { + observe( + threads: ReadonlyArray | null, + ): (thread: Pick) => number | undefined { if (threads === null) { lastWorkingKeys = null; returns.clear(); - return; + return () => undefined; } const working = new Set(); const present = new Set(); @@ -119,6 +122,8 @@ export function createInboxReturnTracker() { if (present.has(key) && !working.has(key)) returns.set(key, at); } lastWorkingKeys = working; + const observed = new Map(returns); + return (thread) => observed.get(keyOf(thread)); }, returnedAt: (thread: Pick) => returns.get(keyOf(thread)), diff --git a/patches/@react-native-menu__menu@2.0.0.patch b/patches/@react-native-menu__menu@2.0.0.patch index 5e2071dca0b62..a96e172c09886 100644 --- a/patches/@react-native-menu__menu@2.0.0.patch +++ b/patches/@react-native-menu__menu@2.0.0.patch @@ -343,10 +343,21 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..475a99b2a3b1800c9d3e3e5f77a64f55 + } } diff --git a/ios/Shared/RCTMenuItem.swift b/ios/Shared/RCTMenuItem.swift -index bb6bb2b7ad56135089f267587c974b166760539d..949b3f7ec49af7ba26d966a8923a51f619443d5a 100644 +index bb6bb2b7ad56135089f267587c974b166760539d..5675067ecb2db0239b74c66ff2e095471a721699 100644 --- a/ios/Shared/RCTMenuItem.swift +++ b/ios/Shared/RCTMenuItem.swift -@@ -103,10 +103,18 @@ class RCTMenuAction { +@@ -27,7 +27,9 @@ class RCTMenuAction { + self.identifier = UIAction.Identifier(rawValue: identifier as String); + } + +- if let image = details["image"] as? NSString { ++ // Fabric sends an absent image as "", and every asset lookup for "" logs a ++ // CoreUI fault with a backtrace. ++ if let image = details["image"] as? NSString, image.length > 0 { + self.image = UIImage(systemName: image as String); + if self.image === nil { + self.image = UIImage(named: image as String) +@@ -103,10 +105,18 @@ class RCTMenuAction { subMenuActions.append(subaction.createUIMenuElement(handler)) } var menu: UIMenu; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10d7746c90eb4..75a04ae377ee7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -356,7 +356,7 @@ patchedDependencies: '@muse-code/sdk@1.4.6': 7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101 '@pierre/diffs@1.5.2': 2ec81ba1e8ce4681095a3473c2bf78e630417740c622720ef624febb467c25fc '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d - '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba + '@react-native-menu/menu@2.0.0': 910ec1bf22aa85a96fcf12299cd823bc30f702dd9b2d77b564d10b9ed709f7f6 '@react-navigation/native-stack@7.21.0': 990d575444c4af2ce8218b3b7a8280fe244c03abbdb32b822a2e862f791dbf42 alchemy@2.0.0-beta.80: bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd app-builder-lib@26.16.1: a28c2dfcfc2b90d6c6247e199e6537391db9ecd941854c650910d607442512d3 @@ -550,7 +550,7 @@ importers: version: 3.1.1(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) '@react-native-menu/menu': specifier: 2.0.0 - version: 2.0.0(patch_hash=a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + version: 2.0.0(patch_hash=910ec1bf22aa85a96fcf12299cd823bc30f702dd9b2d77b564d10b9ed709f7f6)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) '@react-navigation/elements': specifier: 2.9.45 version: 2.9.45(d8845bac868f4d8a58c8a8f12ad0d4a5) @@ -15465,7 +15465,7 @@ snapshots: react-native: 0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) optional: true - '@react-native-menu/menu@2.0.0(patch_hash=a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)': + '@react-native-menu/menu@2.0.0(patch_hash=910ec1bf22aa85a96fcf12299cd823bc30f702dd9b2d77b564d10b9ed709f7f6)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)': dependencies: react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) From 074d9e4d48832d9aee15f9ad85de483023f4cd6b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 18:40:31 -0700 Subject: [PATCH 003/592] fix(mobile): preserve syntax and navigation in large diffs (#24276) --- .../t3reviewdiff/ReviewDiffCanvasDrawing.kt | 86 ++- .../t3reviewdiff/ReviewDiffCodeLayout.kt | 43 +- .../t3reviewdiff/T3ReviewDiffModule.kt | 12 +- .../modules/t3reviewdiff/T3ReviewDiffView.kt | 259 +++++--- .../t3reviewdiff/ReviewDiffCodeLayoutTest.kt | 40 +- .../ios/ReviewDiffCodeLayout.swift | 26 +- .../ios/T3ReviewDiffModule.swift | 10 +- .../t3-review-diff/ios/T3ReviewDiffView.swift | 368 ++++++++--- .../t3-review-diff/tests/android/.gitignore | 2 + .../t3-review-diff/tests/android/README.md | 33 + .../t3-review-diff/tests/android/build.gradle | 77 +++ .../tests/android/settings.gradle | 1 + .../t3reviewdiff/ReviewDiffGeometryTest.kt | 244 +++++++ .../t3reviewdiff/ReviewDiffViewportTest.kt | 156 +++++ .../stubs/expo/modules/kotlin/AppContext.kt | 6 + .../kotlin/viewevent/EventDispatcher.kt | 15 + .../expo/modules/kotlin/views/ExpoView.kt | 7 + .../t3-review-diff/tests/ios/anchor.swift | 53 ++ .../t3-review-diff/tests/ios/geometry.swift | 137 ++++ .../t3-review-diff/tests/ios/main.swift | 2 +- .../t3-review-diff/tests/run-android.sh | 4 + .../modules/t3-review-diff/tests/run-ios.sh | 26 +- .../diffs/nativeReviewDiffHighlighter.test.ts | 600 +++++++++++++++++- .../diffs/nativeReviewDiffHighlighter.ts | 451 +++++++++++-- .../diffs/nativeReviewDiffSource.test.ts | 151 +++++ .../features/diffs/nativeReviewDiffSource.ts | 188 ++++++ .../diffs/nativeReviewDiffSurface.test.ts | 33 - .../diffs/nativeReviewDiffSurface.test.tsx | 405 ++++++++++++ .../features/diffs/nativeReviewDiffSurface.ts | 159 ++++- .../diffs/nativeReviewDiffTrace.test.ts | 98 +++ .../features/diffs/nativeReviewDiffTrace.ts | 70 ++ .../features/diffs/nativeReviewDiffTypes.ts | 6 + .../pull-requests/PullRequestRouteScreen.tsx | 81 ++- .../pullRequestDiffComments.test.ts | 11 + .../pull-requests/pullRequestDiffComments.ts | 1 + .../pullRequestDiffDebug.test.ts | 82 +++ .../pull-requests/pullRequestDiffDebug.ts | 63 ++ .../pullRequestDiffHighlightFiles.test.ts | 440 +++++++++++++ .../pullRequestDiffHighlightFiles.ts | 113 ++++ .../pull-requests/pullRequestDiffPages.ts | 3 +- .../pull-requests/usePullRequestDiff.test.tsx | 118 ++++ .../pull-requests/usePullRequestDiff.ts | 7 +- .../usePullRequestDiffHighlightFiles.ts | 78 +++ .../review/nativeReviewDiffAdapter.test.ts | 273 +++++++- .../review/nativeReviewDiffAdapter.ts | 99 ++- .../review/reviewDiffHighlightScheduler.ts | 16 +- .../src/features/review/reviewModel.test.ts | 117 ++++ .../mobile/src/features/review/reviewModel.ts | 157 ++++- .../review/useNativeReviewDiffBridge.ts | 34 +- .../review/useNativeReviewDiffHighlighting.ts | 203 +++++- apps/mobile/src/lib/highlights.test.ts | 15 +- apps/mobile/src/lib/highlights.ts | 91 ++- apps/mobile/vite.config.ts | 2 + .../server/GitCafePullRequestProvider.test.ts | 166 +++++ .../src/server/GitCafePullRequestProvider.ts | 123 ++++ .../src/server/gitCafePullRequestJson.test.ts | 109 ++++ .../src/server/gitCafePullRequestJson.ts | 30 +- 57 files changed, 5626 insertions(+), 574 deletions(-) create mode 100644 apps/mobile/modules/t3-review-diff/tests/android/.gitignore create mode 100644 apps/mobile/modules/t3-review-diff/tests/android/README.md create mode 100644 apps/mobile/modules/t3-review-diff/tests/android/build.gradle create mode 100644 apps/mobile/modules/t3-review-diff/tests/android/settings.gradle create mode 100644 apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffGeometryTest.kt create mode 100644 apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffViewportTest.kt create mode 100644 apps/mobile/modules/t3-review-diff/tests/android/stubs/expo/modules/kotlin/AppContext.kt create mode 100644 apps/mobile/modules/t3-review-diff/tests/android/stubs/expo/modules/kotlin/viewevent/EventDispatcher.kt create mode 100644 apps/mobile/modules/t3-review-diff/tests/android/stubs/expo/modules/kotlin/views/ExpoView.kt create mode 100644 apps/mobile/modules/t3-review-diff/tests/ios/anchor.swift create mode 100644 apps/mobile/modules/t3-review-diff/tests/ios/geometry.swift create mode 100755 apps/mobile/modules/t3-review-diff/tests/run-android.sh create mode 100644 apps/mobile/src/features/diffs/nativeReviewDiffSource.test.ts create mode 100644 apps/mobile/src/features/diffs/nativeReviewDiffSource.ts delete mode 100644 apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts create mode 100644 apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.tsx create mode 100644 apps/mobile/src/features/diffs/nativeReviewDiffTrace.test.ts create mode 100644 apps/mobile/src/features/diffs/nativeReviewDiffTrace.ts create mode 100644 apps/mobile/src/features/pull-requests/pullRequestDiffDebug.test.ts create mode 100644 apps/mobile/src/features/pull-requests/pullRequestDiffDebug.ts create mode 100644 apps/mobile/src/features/pull-requests/pullRequestDiffHighlightFiles.test.ts create mode 100644 apps/mobile/src/features/pull-requests/pullRequestDiffHighlightFiles.ts create mode 100644 apps/mobile/src/features/pull-requests/usePullRequestDiff.test.tsx create mode 100644 apps/mobile/src/features/pull-requests/usePullRequestDiffHighlightFiles.ts diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt index 67579a1cad889..8339b79592b1d 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt @@ -185,7 +185,8 @@ internal class ReviewDiffCanvasDrawing(context: Context) { fun prepareRows( tokens: Map>, style: DiffStyle, - width: Int + width: Int, + isCancelled: () -> Boolean = { false } ): (List) -> CodeLayoutCache { configureCodePaint(theme.text, 0, style) val paint = TextPaint(textPaint) @@ -196,7 +197,7 @@ internal class ReviewDiffCanvasDrawing(context: Context) { style.codePaddingPx * 2f ).toInt() return { rows -> - cache.apply { layout(rows, tokens, paint, style, colors, availableWidth) } + cache.apply { layout(rows, tokens, paint, style, colors, availableWidth, isCancelled) } } } @@ -218,6 +219,39 @@ internal class ReviewDiffCanvasDrawing(context: Context) { private var widthTokens: Map>? = null private var widthStyle: DiffStyle? = null private var measuredContentWidth = 0 + private var asciiCharacterWidth = 0f + private var asciiFixedPitch = false + + fun tokensChangeWidth( + previous: Map>, + next: Map>, + style: DiffStyle + ): Boolean { + if (widthStyle != style) return true + fun changed(id: String): Boolean { + val before = previous[id] + val after = next[id] + if (before == after) return false + return if (widthEntries.containsKey(id) || + before.orEmpty().any { it.fontStyle and 3 != 0 } || + after.orEmpty().any { it.fontStyle and 3 != 0 } + ) { + // Native run boundaries and fallback fonts can change advances, including ASCII italics. + before?.size != after?.size || before.orEmpty().indices.any { index -> + val old = before.orEmpty()[index] + val new = after.orEmpty()[index] + old.content != new.content || old.fontStyle and 3 != new.fontStyle and 3 + } + } else { + before != null && after != null && + before.sumOf { it.content.length } != after.sumOf { it.content.length } + } + } + val changed = next.keys.any(::changed) || previous.keys.any { it !in next && changed(it) } + // Paint-only patches must not make the next comment/viewport update remeasure rows. + if (!changed) widthTokens = next + return changed + } fun contentWidth( rows: List, @@ -233,20 +267,30 @@ internal class ReviewDiffCanvasDrawing(context: Context) { } // Comment heights and viewport resizing do not change unwrapped text widths. if (widthRows !== rows || widthTokens !== tokens || widthStyle != style) { - if (widthStyle != style) widthEntries = emptyMap() + if (widthStyle != style) { + widthEntries = emptyMap() + configureCodePaint(theme.text, 0, style) + asciiCharacterWidth = textPaint.measureText("M") + // Some system font weights resolve to proportional fallbacks even for "monospace". + val glyphs = CharArray(95) { (' '.code + it).toChar() }.concatToString() + val advances = FloatArray(glyphs.length) + textPaint.getTextWidths(glyphs, advances) + asciiFixedPitch = advances.all { it == asciiCharacterWidth } + } val next = HashMap() var widest = 0f for (row in rows) { if (row.kind != "line" && row.kind != "hunk") continue val rowTokens = if (row.kind == "line") tokens[row.id] else null - val cached = widthEntries[row.id] - val entry = if (cached?.row == row && cached.tokens == rowTokens) { - cached + val asciiWidth = asciiRowWidth(row, rowTokens) + val measured = if (asciiWidth >= 0f) { + asciiWidth } else { - WidthEntry(row, rowTokens, measureRowWidth(row, rowTokens, style)) + val entry = widthEntry(row, rowTokens, style) + next[row.id] = entry + entry.width } - next[row.id] = entry - widest = max(widest, entry.width) + widest = max(widest, measured) } widthEntries = next widthRows = rows @@ -259,6 +303,30 @@ internal class ReviewDiffCanvasDrawing(context: Context) { return measuredContentWidth } + private fun widthEntry(row: DiffRow, tokens: List?, style: DiffStyle): WidthEntry { + val cached = widthEntries[row.id] + return if (cached?.row == row && cached.tokens == tokens) { + cached + } else { + WidthEntry(row, tokens, measureRowWidth(row, tokens, style)) + } + } + + private fun asciiRowWidth(row: DiffRow, tokens: List?): Float { + val fixedPitch = asciiFixedPitch && row.kind == "line" && row.isPlainAscii + if (!fixedPitch || tokens.orEmpty().any { it.fontStyle and 3 != 0 }) { + return -1f + } + val length = if (tokens.isNullOrEmpty()) { + row.content.length + } else { + tokens.sumOf { + it.content.length + } + } + return length * asciiCharacterWidth + } + private fun measureRowWidth(row: DiffRow, tokens: List?, style: DiffStyle): Float { if (row.kind == "hunk") { configureMonospacePaint(theme.hunkText, style.hunkFontSizePx, style.hunkFontWeight) diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayout.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayout.kt index dc77a84670332..b63baa569beaa 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayout.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayout.kt @@ -35,15 +35,31 @@ internal class CodeLines( internal class CodeWrapLayout( val enabled: Boolean, - private val linesByRowId: Map + private val linesByRowId: Map, + private val asciiColumns: Int = Int.MAX_VALUE, + private val asciiLineHeight: Int = 0 ) { - fun lines(rowId: String): CodeLines = linesByRowId[rowId] ?: SINGLE_LINE - fun extraHeight(rowId: String): Int = lines(rowId).extraHeight - fun rowHeight(rowId: String, base: Int): Int = lines(rowId).let { - it.firstHeight(base) + - it.extraHeight + fun needsTokenLayout( + previous: Map>, + next: Map> + ): Boolean = linesByRowId.keys.any { previous[it] != next[it] } + + // Only visible ASCII rows need line starts; offscreen heights use the same arithmetic. + fun lines(row: DiffRow): CodeLines = linesByRowId[row.id] ?: if (enabled) { + CodeLines(IntArray(asciiLineCount(row)) { it * asciiColumns }, asciiLineHeight) + } else { + SINGLE_LINE } + private fun asciiLineCount(row: DiffRow): Int = max( + 1, + (row.content.length - 1) / asciiColumns + 1 + ) + + fun rowHeight(row: DiffRow, base: Int): Int = linesByRowId[row.id]?.let { + it.firstHeight(base) + it.extraHeight + } ?: (base + if (enabled) (asciiLineCount(row) - 1) * asciiLineHeight else 0) + companion object { private val SINGLE_LINE = CodeLines(intArrayOf(0), 0) val NONE = CodeWrapLayout(false, emptyMap()) @@ -87,14 +103,15 @@ internal class CodeLayoutCache { it.previousWidth = previousWidth } - @Suppress("LongParameterList") + @Suppress("LongParameterList", "ReturnCount") fun layout( rows: List, tokens: Map>, paint: Paint, style: DiffStyle, theme: DiffTheme, - width: Int + width: Int, + isCancelled: () -> Boolean = { false } ): CodeWrapLayout { if (!style.wordWrap || width < paint.measureText("M")) { entries = emptyMap() @@ -106,10 +123,14 @@ internal class CodeLayoutCache { previousTheme = theme previousWidth = width } + val characterWidth = paint.measureText("M") + val columns = max(1, (width / characterWidth).toInt()) + val lineHeight = ceil(paint.fontMetrics.run { descent - ascent }).toInt() val next = HashMap() val layouts = HashMap() for (row in rows) { - if (row.kind != "line") continue + if (isCancelled()) return CodeWrapLayout.NONE + if (row.kind != "line" || row.isPlainAscii) continue val rowTokens = tokens[row.id] val cached = entries[row.id] val entry = if (cached?.row == row && cached.tokens == rowTokens) { @@ -122,12 +143,10 @@ internal class CodeLayoutCache { layouts[row.id] = entry.lines } entries = next - return CodeWrapLayout(true, layouts) + return CodeWrapLayout(true, layouts, columns, lineHeight) } private fun styledCode(row: DiffRow, tokens: List?, theme: DiffTheme): CharSequence { - // The ASCII path uses the existing token drawing and rounded highlight rectangles. - if (row.content.all { it in ' '..'~' }) return row.content val text = SpannableString(row.content) var offset = 0 for (token in tokens.orEmpty()) { diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt index ee6740cf34c3d..1291826f1c1bf 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt @@ -1,5 +1,6 @@ package expo.modules.t3reviewdiff +import expo.modules.kotlin.Promise import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition @@ -49,6 +50,9 @@ class T3ReviewDiffModule : Module() { Prop("initialRowIndex") { view: T3ReviewDiffView, initialRowIndex: Double -> view.setInitialRowIndex(initialRowIndex) } + Prop("initialFileId") { view: T3ReviewDiffView, fileId: String -> + view.setInitialFileId(fileId) + } Events( "onDebug", @@ -71,8 +75,12 @@ class T3ReviewDiffModule : Module() { AsyncFunction("setTokensJson") { view: T3ReviewDiffView, tokensJson: String -> view.setTokensJson(tokensJson) } - AsyncFunction("setTokensPatchJson") { view: T3ReviewDiffView, tokensPatchJson: String -> - view.setTokensPatchJson(tokensPatchJson) + AsyncFunction("setTokensPatchJson") { + view: T3ReviewDiffView, + tokensPatchJson: String, + promise: Promise + -> + view.setTokensPatchJson(tokensPatchJson) { accepted -> promise.resolve(accepted) } } OnViewDestroys { view: T3ReviewDiffView -> diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt index e39fdeb963409..5accfa4b96353 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt @@ -39,10 +39,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont private var collapsedCommentIds: Set = emptySet() private var initialRowIndex = 0 private var pendingInitialScroll = false + private var initialFileId: String? = null + private var pendingScrollFileId: String? = null private var lastVisibleFileId: String? = null private var tokensResetKey = "" private var contentResetKey = "" - private var rowsDecodeGeneration = 0 + + @Volatile private var rowsDecodeGeneration = 0 private var tokensDecodeGeneration = 0 private val payloadDecodeExecutor = Executors.newSingleThreadExecutor() private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop @@ -65,13 +68,16 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont ) ) } + canvasView.onVerticalAnchorRestored = { verticalScroller.forceFinished(true) } canvasView.onRowTap = { row, gesture, target -> handleRowTap(row, gesture, target) } + canvasView.onViewportSizeChanged = { applyPendingInitialScroll() } canvasView.onVisibleRowsChanged = { first, last -> onDebug( mapOf( "message" to "visible-range", "firstRowIndex" to first, "lastRowIndex" to last, + "totalRows" to rows.size, ), ) emitVisibleFile(first) @@ -92,10 +98,18 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont fun setContentResetKey(value: String) { if (contentResetKey == value) return contentResetKey = value + rowsDecodeGeneration += 1 tokensDecodeGeneration += 1 + canvasView.setViewportEventsPaused(true) + verticalScroller.forceFinished(true) + horizontalScroller.forceFinished(true) + rows = emptyList() + visibleRows = emptyList() + canvasView.rows = emptyList() canvasView.tokensByRowId = emptyMap() lastVisibleFileId = null pendingInitialScroll = true + pendingScrollFileId = initialFileId canvasView.setVerticalOffset(0) canvasView.resetHorizontalOffsets() applyPendingInitialScroll() @@ -146,18 +160,33 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont } fun setInitialRowIndex(value: Double) { - initialRowIndex = value.toInt().coerceAtLeast(0) - pendingInitialScroll = true + initialRowIndex = value.toInt() + pendingInitialScroll = value.isFinite() && value >= 0 + canvasView.setViewportEventsPaused(true) + applyPendingInitialScroll() + } + + fun setInitialFileId(value: String) { + val nextFileId = value.takeIf { it.isNotEmpty() } + if (nextFileId == initialFileId) return + initialFileId = nextFileId + pendingScrollFileId = nextFileId + canvasView.setViewportEventsPaused(true) applyPendingInitialScroll() } fun setRowsJson(value: String) { rowsDecodeGeneration += 1 + canvasView.setViewportEventsPaused(true) val generation = rowsDecodeGeneration - val prepareLayout = canvasView.prepareRows() + val isCancelled = { generation != rowsDecodeGeneration || Thread.currentThread().isInterrupted } + val prepareLayout = canvasView.prepareRows(isCancelled) payloadDecodeExecutor.execute { - val decodedRows = parseRows(value) + if (isCancelled()) return@execute + val decodedRows = parseRows(value, isCancelled) + if (isCancelled()) return@execute val codeLayouts = prepareLayout(decodedRows) + if (isCancelled()) return@execute post { if (generation != rowsDecodeGeneration) return@post canvasView.useCodeLayouts(codeLayouts) @@ -180,7 +209,8 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont } } - fun setTokensPatchJson(value: String) { + fun setTokensPatchJson(value: String, completion: (Boolean) -> Unit) { + val contentKey = contentResetKey payloadDecodeExecutor.execute { try { val payload = JSONObject(value) @@ -189,12 +219,19 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont payload.optJSONObject("tokensByRowId") ?: JSONObject(), ) post { - if (resetKey.isNotEmpty() && resetKey != tokensResetKey) return@post + if (contentKey != contentResetKey || + (resetKey.isNotEmpty() && resetKey != tokensResetKey) + ) { + completion(false) + return@post + } if (decodedTokens.isNotEmpty()) { canvasView.tokensByRowId = canvasView.tokensByRowId + decodedTokens } + completion(true) } } catch (_: Exception) { + post { completion(false) } } } } @@ -354,6 +391,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont } private fun rebuildVisibleRows() { + canvasView.setViewportEventsPaused(true) val filtered = ArrayList(rows.size) var currentFileCollapsed = false rows.forEach { row -> @@ -411,10 +449,20 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont } private fun applyPendingInitialScroll() { - if (!pendingInitialScroll || visibleRows.isEmpty()) return - pendingInitialScroll = false - val index = initialRowIndex.coerceIn(0, visibleRows.lastIndex) - post { canvasView.setVerticalOffset(canvasView.rowTop(index)) } + if (visibleRows.isEmpty() || canvasView.width <= 0 || canvasView.height <= 0) return + val fileId = pendingScrollFileId + if (fileId != null) { + val index = visibleRows.indexOfFirst { it.kind == "file" && it.resolvedFileId == fileId } + if (index < 0) return + pendingScrollFileId = null + pendingInitialScroll = false + canvasView.setVerticalOffset(canvasView.rowTop(index)) + } else if (pendingInitialScroll) { + pendingInitialScroll = false + val index = initialRowIndex.coerceIn(0, visibleRows.lastIndex) + canvasView.setVerticalOffset(canvasView.rowTop(index)) + } + canvasView.setViewportEventsPaused(false) } private fun scrollToY(y: Int, animated: Boolean) { @@ -476,6 +524,7 @@ internal data class DiffRow( val commentSectionTitle: String ) { val resolvedFileId: String get() = fileId.ifEmpty { id } + val isPlainAscii: Boolean = content.all { it in ' '..'~' } } internal data class DiffWordDiffRange( @@ -710,6 +759,7 @@ private class DiffCanvasView(context: Context) : View(context) { }, ) private var rowOffsets = intArrayOf(0) + private var fileHeaderIndices = intArrayOf() private var codeWrap = CodeWrapLayout.NONE private var verticalOffset = 0 @@ -718,20 +768,26 @@ private class DiffCanvasView(context: Context) : View(context) { private var contentWidthPx = 0 private val headerPathOffsetsByFileId = mutableMapOf() private var lastVisibleRange: Pair? = null + private var viewportEventsPaused = true var rows: List = emptyList() set(value) { field = value + fileHeaderIndices = indexFileHeaders(value) headerPathOffsetsByFileId.keys.retainAll( - value.asSequence().filter { it.kind == "file" }.map { it.resolvedFileId }.toSet(), + fileHeaderIndices.map { value[it].resolvedFileId }.toSet(), ) - rebuildOffsets() + rebuildOffsets(preserveAnchor = false) } var tokensByRowId: Map> = emptyMap() set(value) { + val previous = field field = value if (style.wordWrap) { - rebuildOffsets() - } else { + // ASCII uses fixed-pitch geometry. Native layouts also carry their painted spans. + if (codeWrap.needsTokenLayout(previous, value)) { + rebuildOffsets() + } + } else if (drawing.tokensChangeWidth(previous, value, style)) { contentWidthPx = drawing.contentWidth(rows, value, style) setHorizontalOffset(horizontalOffset) } @@ -772,8 +828,11 @@ private class DiffCanvasView(context: Context) : View(context) { var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null var onContentHeightChanged: ((Int) -> Unit)? = null + var onVerticalAnchorRestored: (() -> Unit)? = null + var onViewportSizeChanged: (() -> Unit)? = null - fun prepareRows() = drawing.prepareRows(tokensByRowId, style, width) + fun prepareRows(isCancelled: () -> Boolean = { false }) = + drawing.prepareRows(tokensByRowId, style, width, isCancelled) fun useCodeLayouts(layouts: CodeLayoutCache) { drawing.codeLayouts = layouts } @@ -789,6 +848,7 @@ private class DiffCanvasView(context: Context) : View(context) { super.onSizeChanged(width, height, oldWidth, oldHeight) // Wrapped rows take their height from the width, so a new width is a new layout. if (style.wordWrap && width != oldWidth) layoutRows() + onViewportSizeChanged?.invoke() setVerticalOffset(verticalOffset) setHorizontalOffset(horizontalOffset) clampHeaderPathOffsets() @@ -883,20 +943,27 @@ private class DiffCanvasView(context: Context) : View(context) { } } - private fun rebuildOffsets() { - layoutRows() + private fun rebuildOffsets(preserveAnchor: Boolean = true) { + layoutRows(preserveAnchor) requestLayout() invalidate() } - private fun layoutRows() { + private fun layoutRows(preserveAnchor: Boolean = true) { + // Rows are unchanged here; their old offsets still identify the viewport's code row. + val anchorIndex = if (preserveAnchor && rows.isNotEmpty()) rowIndexAt(verticalOffset) else null + val intraRowOffset = anchorIndex?.let { verticalOffset - rowOffsets[it] } ?: 0 contentWidthPx = drawing.contentWidth(rows, tokensByRowId, style) codeWrap = drawing.codeWrapLayout(rows, tokensByRowId, style, width) rowOffsets = IntArray(rows.size + 1) rows.forEachIndexed { index, row -> rowOffsets[index + 1] = rowOffsets[index] + rowHeight(row) } - setVerticalOffset(verticalOffset) + val targetOffset = anchorIndex?.let { + rowOffsets[it] + intraRowOffset.coerceIn(0, max(0, rowOffsets[it + 1] - rowOffsets[it] - 1)) + } ?: verticalOffset + if (targetOffset != verticalOffset) onVerticalAnchorRestored?.invoke() + setVerticalOffset(targetOffset) setHorizontalOffset(horizontalOffset) clampHeaderPathOffsets() onContentHeightChanged?.invoke(rowOffsets.last()) @@ -910,25 +977,11 @@ private class DiffCanvasView(context: Context) : View(context) { } else { (124 * density).toInt() } - "line" -> codeWrap.rowHeight(row.id, style.rowHeightPx.toInt()) + "line" -> codeWrap.rowHeight(row, style.rowHeightPx.toInt()) else -> style.rowHeightPx.toInt() }.coerceAtLeast(1) - @Suppress("ReturnCount") - private fun rowIndexAt(y: Int): Int { - if (rows.isEmpty()) return 0 - var low = 0 - var high = rows.lastIndex - while (low <= high) { - val middle = (low + high) ushr 1 - when { - y < rowOffsets[middle] -> high = middle - 1 - y >= rowOffsets[middle + 1] -> low = middle + 1 - else -> return middle - } - } - return low.coerceIn(0, rows.lastIndex) - } + private fun rowIndexAt(y: Int): Int = rowIndexAtOffset(y, rowOffsets, rows.size) private fun rowHitAt(y: Float): RowHit? { stickyFileHeader(firstVisibleRow())?.let { sticky -> @@ -949,14 +1002,21 @@ private class DiffCanvasView(context: Context) : View(context) { private fun firstVisibleRow(): Int = rowIndexAt(verticalOffset) + fun setViewportEventsPaused(paused: Boolean) { + viewportEventsPaused = paused + if (paused) lastVisibleRange = null else emitVisibleRange() + } + private fun emitVisibleRange() { - if (rows.isEmpty()) return + if (viewportEventsPaused || rows.isEmpty()) return + if (width <= 0 || height <= 0) return val first = rowIndexAt(verticalOffset) val last = rowIndexAt(verticalOffset + max(1, height)) val range = first to last - if (range == lastVisibleRange) return - lastVisibleRange = range - onVisibleRowsChanged?.invoke(first, last) + if (range != lastVisibleRange) { + lastVisibleRange = range + onVisibleRowsChanged?.invoke(first, last) + } } private fun drawRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { @@ -1235,7 +1295,7 @@ private class DiffCanvasView(context: Context) : View(context) { } // Wrapped rows keep the line number and first code line in the first row-height band. - val lines = codeWrap.lines(row.id) + val lines = codeWrap.lines(row) val firstLineBottom = top + lines.firstHeight(style.rowHeightPx.toInt()) drawScrollableCode(canvas, top, bottom) { codeX -> drawing.configureCodePaint(theme.text, 0, style) @@ -1287,11 +1347,13 @@ private class DiffCanvasView(context: Context) : View(context) { @Suppress("ReturnCount") private fun stickyFileHeader(firstVisibleIndex: Int): StickyFileHeader? { if (rows.isEmpty()) return null - val fileIndex = (firstVisibleIndex.coerceIn(0, rows.lastIndex) downTo 0) - .firstOrNull { rows[it].kind == "file" } - ?: return null + val rowIndex = firstVisibleIndex.coerceIn(0, rows.lastIndex) + val found = fileHeaderIndices.binarySearch(rowIndex) + val slot = if (found >= 0) found else -found - 2 + if (slot < 0) return null + val fileIndex = fileHeaderIndices[slot] val headerHeight = rowHeight(rows[fileIndex]) - val nextFileIndex = ((fileIndex + 1)..rows.lastIndex).firstOrNull { rows[it].kind == "file" } + val nextFileIndex = fileHeaderIndices.getOrNull(slot + 1) val top = nextFileIndex ?.let { min(0, rowOffsets[it] - verticalOffset - headerHeight) } ?: 0 @@ -1334,20 +1396,6 @@ private class DiffCanvasView(context: Context) : View(context) { canvas.drawLine(0f, bottom - density / 2f, width.toFloat(), bottom - density / 2f, borderPaint) } - private fun centeredBaseline(top: Int, bottom: Int, paint: Paint): Float { - val metrics = paint.fontMetrics - return (top + bottom) / 2f - (metrics.ascent + metrics.descent) / 2f - } - - private fun ellipsize(value: String, paint: Paint, width: Float): String { - if (paint.measureText(value) <= width) return value - val suffix = "..." - val available = max(0f, width - paint.measureText(suffix)) - var end = value.length - while (end > 0 && paint.measureText(value, 0, end) > available) end -= 1 - return value.substring(0, end) + suffix - } - private data class StickyFileHeader( val index: Int, val top: Int, @@ -1366,35 +1414,62 @@ private class DiffCanvasView(context: Context) : View(context) { ) } +private fun centeredBaseline(top: Int, bottom: Int, paint: Paint): Float { + val metrics = paint.fontMetrics + return (top + bottom) / 2f - (metrics.ascent + metrics.descent) / 2f +} + +private fun ellipsize(value: String, paint: Paint, width: Float): String { + if (paint.measureText(value) <= width) return value + val suffix = "..." + val available = max(0f, width - paint.measureText(suffix)) + var end = value.length + while (end > 0 && paint.measureText(value, 0, end) > available) end -= 1 + return value.substring(0, end) + suffix +} + +private fun indexFileHeaders(rows: List): IntArray { + val headers = ArrayList() + rows.forEachIndexed { index, row -> + if (row.kind == "file") headers.add(index) + } + return headers.toIntArray() +} + private fun withAlpha(color: Int, alpha: Int): Int = Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)) -private fun parseRows(value: String): List = try { - val array = JSONArray(value) - List(array.length()) { index -> - val row = array.getJSONObject(index) - DiffRow( - kind = row.optString("kind"), - id = row.optString("id"), - fileId = row.optString("fileId"), - filePath = row.optString("filePath"), - previousPath = row.optNullableString("previousPath"), - changeType = row.optString("changeType"), - additions = row.optInt("additions"), - deletions = row.optInt("deletions"), - text = row.optString("text"), - content = row.optString("content"), - change = row.optString("change", "context"), - oldLineNumber = row.optNullableInt("oldLineNumber"), - newLineNumber = row.optNullableInt("newLineNumber"), - wordDiffRanges = row.optJSONArray("wordDiffRanges")?.let(::parseWordDiffRanges).orEmpty(), - commentText = row.optString("commentText"), - commentRangeLabel = row.optString("commentRangeLabel"), - commentSectionTitle = row.optString("commentSectionTitle"), - ) +private fun parseRows(value: String): List = parseRows(value) { false } + +private fun parseRows(value: String, isCancelled: () -> Boolean): List { + return try { + val array = JSONArray(value) + List(array.length()) { index -> + if (isCancelled()) return emptyList() + val row = array.getJSONObject(index) + DiffRow( + kind = row.optString("kind"), + id = row.optString("id"), + fileId = row.optString("fileId"), + filePath = row.optString("filePath"), + previousPath = row.optNullableString("previousPath"), + changeType = row.optString("changeType"), + additions = row.optInt("additions"), + deletions = row.optInt("deletions"), + text = row.optString("text"), + content = row.optString("content"), + change = row.optString("change", "context"), + oldLineNumber = row.optNullableInt("oldLineNumber"), + newLineNumber = row.optNullableInt("newLineNumber"), + wordDiffRanges = row.optJSONArray("wordDiffRanges")?.let(::parseWordDiffRanges).orEmpty(), + commentText = row.optString("commentText"), + commentRangeLabel = row.optString("commentRangeLabel"), + commentSectionTitle = row.optString("commentSectionTitle"), + ) + } + } catch (_: Exception) { + emptyList() } -} catch (_: Exception) { - emptyList() } private fun parseWordDiffRanges(value: JSONArray): List = buildList { @@ -1464,3 +1539,19 @@ private fun JSONObject.floatDp(key: String, fallbackPx: Float, density: Float): private fun JSONObject.floatSp(key: String, fallbackPx: Float, density: Float): Float = if (has(key)) optDouble(key).toFloat() * density else fallbackPx + +@Suppress("ReturnCount") +private fun rowIndexAtOffset(y: Int, rowOffsets: IntArray, rowCount: Int): Int { + if (rowCount == 0) return 0 + var low = 0 + var high = rowCount - 1 + while (low <= high) { + val middle = (low + high) ushr 1 + when { + y < rowOffsets[middle] -> high = middle - 1 + y >= rowOffsets[middle + 1] -> low = middle + 1 + else -> return middle + } + } + return low.coerceIn(0, rowCount - 1) +} diff --git a/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt b/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt index 7c4f531fbecbb..0446997423141 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt @@ -91,7 +91,7 @@ class ReviewDiffCodeLayoutTest { style, theme, 280 - ).lines(row.id) + ).lines(row) val second = cache.layout( listOf(row, comment.copy(commentText = "After")), emptyMap(), @@ -99,9 +99,9 @@ class ReviewDiffCodeLayoutTest { style, theme, 280, - ).lines(row.id) + ).lines(row) assertSame(first, second) - val narrow = cache.layout(listOf(row), emptyMap(), paint, style, theme, 180).lines(row.id) + val narrow = cache.layout(listOf(row), emptyMap(), paint, style, theme, 180).lines(row) assertNotSame(first, narrow) assertTrue(narrow.extraHeight > first.extraHeight) val edited = cache.layout( @@ -111,7 +111,7 @@ class ReviewDiffCodeLayoutTest { style, theme, 180 - ).lines(row.id) + ).lines(row) assertTrue(edited.extraHeight < narrow.extraHeight) assertEquals( 0, @@ -122,7 +122,7 @@ class ReviewDiffCodeLayoutTest { style.copy(wordWrap = false), theme, 180 - ).extraHeight(row.id) + ).rowHeight(row, 0) ) } @@ -132,9 +132,9 @@ class ReviewDiffCodeLayoutTest { val row = row("ๆผขๅญ—".repeat(30)).copy(wordDiffRanges = listOf(DiffWordDiffRange(3, 21))) val style = DiffStyle.defaults(1f).copy(wordWrap = true) val theme = DiffTheme.fallback("light") - val initial = cache.layout(listOf(row), emptyMap(), paint, style, theme, 180).lines(row.id) + val initial = cache.layout(listOf(row), emptyMap(), paint, style, theme, 180).lines(row) val tokens = mapOf(row.id to listOf(DiffToken(row.content, 0xff008800.toInt(), 2))) - val highlighted = cache.layout(listOf(row), tokens, paint, style, theme, 180).lines(row.id) + val highlighted = cache.layout(listOf(row), tokens, paint, style, theme, 180).lines(row) assertNotSame(initial, highlighted) val text = requireNotNull(highlighted.nativeLayout).text as Spanned val span = text.getSpans(0, text.length, BackgroundColorSpan::class.java).single() @@ -193,6 +193,32 @@ class ReviewDiffCodeLayoutTest { assertFresh(rows) } + @Test + fun obsoletePreparationStopsBeforeWalkingTheRemainingRows() { + val cache = CodeLayoutCache() + val row = row("ๆผขๅญ—".repeat(100)) + val style = DiffStyle.defaults(1f).copy(wordWrap = true) + val theme = DiffTheme.fallback("light") + val displayed = cache.layout(listOf(row), emptyMap(), paint, style, theme, 280).lines(row) + val preparation = cache.copyForPreparation() + var checked = 0 + val cancelled = preparation.layout( + List(10000) { row.copy(id = "row:$it") }, + emptyMap(), + paint, + style, + theme, + 280, + isCancelled = { ++checked == 2 }, + ) + assertEquals(2, checked) + assertEquals(false, cancelled.enabled) + assertSame( + displayed, + cache.layout(listOf(row), emptyMap(), paint, style, theme, 280).lines(row) + ) + } + private fun row(content: String) = DiffRow( kind = "line", id = "line", fileId = "file", filePath = "test.ts", previousPath = null, changeType = "modified", additions = 1, deletions = 0, text = "", content = content, diff --git a/apps/mobile/modules/t3-review-diff/ios/ReviewDiffCodeLayout.swift b/apps/mobile/modules/t3-review-diff/ios/ReviewDiffCodeLayout.swift index 20aa20c2c730d..604e90658b7ce 100644 --- a/apps/mobile/modules/t3-review-diff/ios/ReviewDiffCodeLayout.swift +++ b/apps/mobile/modules/t3-review-diff/ios/ReviewDiffCodeLayout.swift @@ -1,5 +1,19 @@ import UIKit +/// Fixed-pitch line boundaries need no storage proportional to the text length. +struct ReviewDiffCodeLineStarts: RandomAccessCollection { + let startIndex = 0 + let endIndex: Int + let columns: Int + + init(length: Int, columns: Int?) { + self.columns = Swift.max(1, columns ?? length) + endIndex = (Swift.max(1, length) - 1) / self.columns + 1 + } + + subscript(index: Int) -> Int { index * columns } +} + /// ASCII uses fixed-pitch columns. TextKit handles shaping, tabs, and Unicode highlights. /// Pass `unboundedWidth` to lay a row out on one line for horizontal scrolling. final class ReviewDiffCodeLayout: NSObject { @@ -19,7 +33,7 @@ final class ReviewDiffCodeLayout: NSObject { return cache }() let text: String - let starts: [Int] + let starts: ReviewDiffCodeLineStarts let lineHeight: CGFloat let firstLineHeight: CGFloat let extraHeight: CGFloat @@ -36,10 +50,10 @@ final class ReviewDiffCodeLayout: NSObject { self.width = width self.characterWidth = characterWidth lineHeight = ceil(font.lineHeight) - if text.utf8.allSatisfy({ $0 >= 32 && $0 <= 126 }) { + if Self.isPrintableASCII(text) { let length = max(1, text.utf8.count) let columns = max(1, Int(min(width / characterWidth, CGFloat(length)))) - starts = Array(stride(from: 0, to: length, by: columns)) + starts = ReviewDiffCodeLineStarts(length: length, columns: columns) firstLineHeight = font.lineHeight extraHeight = CGFloat(starts.count - 1) * lineHeight usedWidth = CGFloat(min(text.utf8.count, columns)) * characterWidth @@ -50,7 +64,7 @@ final class ReviewDiffCodeLayout: NSObject { let manager = layout.manager let container = layout.container usesNativeLayout = true - starts = [0] + starts = ReviewDiffCodeLineStarts(length: 0, columns: nil) firstLineHeight = manager.numberOfGlyphs > 0 ? manager.lineFragmentRect(forGlyphAt: 0, effectiveRange: nil).height : font.lineHeight let used = manager.usedRect(for: container) @@ -59,6 +73,10 @@ final class ReviewDiffCodeLayout: NSObject { } } + static func isPrintableASCII(_ text: String) -> Bool { + text.utf8.allSatisfy { $0 >= 32 && $0 <= 126 } + } + private func nativeLayout() -> ReviewDiffTextLayout { if let cached = Self.drawnLayouts.object(forKey: self) { return cached } let layout = ReviewDiffTextLayout() diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift index 0b8bd70633b5a..caab10799c09f 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift @@ -61,6 +61,10 @@ public class T3ReviewDiffModule: Module { view.setInitialRowIndex(initialRowIndex) } + Prop("initialFileId") { (view: T3ReviewDiffView, fileId: String) in + view.setInitialFileId(fileId) + } + Prop("scrollEnabled") { (view: T3ReviewDiffView, enabled: Bool) in view.setScrollEnabled(enabled) } @@ -106,8 +110,10 @@ public class T3ReviewDiffModule: Module { view.setTokensJson(tokensJson) } - AsyncFunction("setTokensPatchJson") { (view: T3ReviewDiffView, tokensPatchJson: String) in - view.setTokensPatchJson(tokensPatchJson) + AsyncFunction("setTokensPatchJson") { (view: T3ReviewDiffView, tokensPatchJson: String, promise: Promise) in + view.setTokensPatchJson(tokensPatchJson) { accepted in + promise.resolve(accepted) + } } } } diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift index 4e41fa643832f..41edbe3e09547 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift @@ -170,7 +170,7 @@ private struct ReviewDiffNativeStylePayload: Decodable { let emptyStateFontWeight: String? } -private struct ReviewDiffNativeStyle { +private struct ReviewDiffNativeStyle: Equatable { let wordWrap: Bool let rowHeight: CGFloat let contentWidth: CGFloat @@ -333,6 +333,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { private var tokensResetKey = "" private var contentResetKey = "" private var initialRowIndex: Int? + private var initialFileId: String? private var hasAppliedInitialRowIndex = false private var lastVisibleFileId: String? private var pendingScrollFileId: String? @@ -388,8 +389,9 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { public override func layoutSubviews() { super.layoutSubviews() + let anchor = contentView.viewportWidth != bounds.width ? contentView.viewportRowAnchor() : nil scrollView.frame = bounds - updateContentMetrics() + updateContentMetrics(rowAnchor: anchor) } public func scrollViewDidScroll(_ scrollView: UIScrollView) { @@ -423,6 +425,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { } func setRowsJson(_ rowsJson: String) { + contentView.cancelRowPreparation() rowsDecodeGeneration += 1 let generation = rowsDecodeGeneration @@ -504,11 +507,12 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { } } - func setTokensPatchJson(_ tokensPatchJson: String) { + func setTokensPatchJson(_ tokensPatchJson: String, completion: @escaping (Bool) -> Void) { let contentResetKey = self.contentResetKey payloadDecodeQueue.async { [weak self] in guard let data = tokensPatchJson.data(using: .utf8) else { + DispatchQueue.main.async { completion(false) } return } @@ -516,27 +520,34 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { let patch = try JSONDecoder().decode(ReviewDiffNativeTokenPatch.self, from: data) DispatchQueue.main.async { [weak self] in guard let self else { + completion(false) return } guard contentResetKey == self.contentResetKey else { + completion(false) return } // A highlighter request from the previous file can finish after the view has // already reset. Never let that stale patch roll the native token state back. if let resetKey = patch.resetKey, resetKey != self.tokensResetKey { + completion(false) return } let tokensByRowId = patch.tokensByRowId ?? [:] if tokensByRowId.isEmpty { + completion(true) return } self.contentView.mergeTokensByRowId(tokensByRowId) - if let chunkIndex = patch.chunkIndex, chunkIndex < 5 || chunkIndex.isMultiple(of: 10) { + completion(true) + if let chunkIndex = patch.chunkIndex { self.emitDebug("tokens-patch-decoded", [ + "resetKey": patch.resetKey ?? "", "chunkIndex": chunkIndex, "rows": tokensByRowId.count, + "tokens": tokensByRowId.values.reduce(0) { $0 + $1.count }, "totalRows": self.contentView.tokensByRowId.count, ]) } @@ -545,6 +556,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { let message = error.localizedDescription DispatchQueue.main.async { [weak self] in self?.emitDebug("tokens-patch-decode-failed", ["error": message]) + completion(false) } } } @@ -587,13 +599,15 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { } self.contentResetKey = contentResetKey + lastVisibleRangeDebugKey = "" + contentView.cancelRowPreparation() rowsDecodeGeneration += 1 tokensDecodeGeneration += 1 contentView.tokensByRowId = [:] rows = [] contentView.rows = [] hasAppliedInitialRowIndex = false - pendingScrollFileId = nil + pendingScrollFileId = initialFileId isProgrammaticScrollActive = false scrollView.setContentOffset(CGPoint(x: 0, y: -scrollView.contentInset.top), animated: false) updateContentMetrics() @@ -681,12 +695,23 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { applyTheme() } - private func updateContentMetrics() { + private func updateContentMetrics(rowAnchor: ReviewDiffViewportRowAnchor? = nil) { + let anchor = rowAnchor ?? (contentView.viewportWidth != bounds.width + ? contentView.viewportRowAnchor() : nil) let style = contentView.style contentView.viewportWidth = bounds.width let height = max(bounds.height, contentView.contentHeight) let width = bounds.width scrollView.contentSize = CGSize(width: bounds.width, height: height) + if let anchor, let offset = contentView.offset(for: anchor) { + let minOffset = -scrollView.contentInset.top + let maxOffset = max(height - scrollView.bounds.height + scrollView.contentInset.bottom, minOffset) + let target = min(max(offset - scrollView.contentInset.top, minOffset), maxOffset) + if abs(target - scrollView.contentOffset.y) > 0.01 { + scrollView.setContentOffset(CGPoint(x: 0, y: target), animated: false) + if isProgrammaticScrollActive { finishVerticalScroll() } + } + } contentView.frame = CGRect( x: 0, y: scrollView.contentOffset.y, @@ -699,6 +724,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { applyInitialRowIndexIfNeeded() applyPendingScrollIfNeeded() emitVisibleFileIfNeeded() + emitVisibleRange(reason: "layout") let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(contentView.contentHeight))" if debugKey != lastMetricsDebugKey { @@ -759,7 +785,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { } private func emitVisibleRange(reason: String) { - guard let range = contentView.currentVisibleRowRange() else { + guard bounds.width > 0, bounds.height > 0, + let range = contentView.currentVisibleRowRange() else { return } @@ -833,6 +860,16 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { applyPendingScrollIfNeeded() } + func setInitialFileId(_ fileId: String) { + let nextFileId = fileId.isEmpty ? nil : fileId + guard nextFileId != initialFileId else { return } + initialFileId = nextFileId + pendingScrollFileId = nextFileId + pendingScrollAnimated = false + // Apply after the prop batch and content metrics, before the first range event. + setNeedsLayout() + } + func scrollToTop(animated: Bool) { pendingScrollFileId = nil pendingScrollAnimated = false @@ -869,10 +906,11 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { } private func applyStyle() { + let anchor = contentView.viewportRowAnchor() contentView.style = ReviewDiffNativeStyle .resolve(stylePayload) .applyingOverrides(rowHeight: rowHeightOverride, contentWidth: contentWidthOverride) - updateContentMetrics() + updateContentMetrics(rowAnchor: anchor) } private func updateViewportFrame() { @@ -918,7 +956,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { contentView.isVerticalScrollActive = shouldAnimate scrollView.setContentOffset(CGPoint(x: 0, y: clampedOffset), animated: shouldAnimate) if !shouldAnimate { - updateViewportFrame() + finishVerticalScroll() } } @@ -964,11 +1002,77 @@ private struct ReviewDiffStickyFileHeaderTarget { let rect: CGRect } +private struct ReviewDiffViewportRowAnchor { + let rowIndex: Int + let intraRowOffset: CGFloat +} + private struct ReviewDiffScrollAnchor { let fileId: String let screenY: CGFloat } +private final class ReviewDiffRowPreparation { + private let lock = NSLock() + private var cancelled = false + + func cancel() { + lock.lock() + cancelled = true + lock.unlock() + } + + var isCancelled: Bool { + lock.lock() + defer { lock.unlock() } + return cancelled + } +} + +private struct ReviewDiffGeometryConfiguration: Equatable { + let style: ReviewDiffNativeStyle + let viewportWidth: CGFloat + let collapsedFileIds: Set + let collapsedCommentIds: Set + + func height(for row: ReviewDiffNativeRow) -> CGFloat { + if row.kind == "file" { return style.fileHeaderHeight } + if collapsedFileIds.contains(row.resolvedFileId) { return 0 } + if row.kind == "notice" { return max(style.rowHeight * 2, 44) } + if row.kind == "comment" { return collapsedCommentIds.contains(row.id) ? 44 : 124 } + return style.rowHeight + } +} + +private struct ReviewDiffRowGeometryInput { + let configuration: ReviewDiffGeometryConfiguration + let font: UIFont + let hunkFont: UIFont + let characterWidth: CGFloat + let layoutWidth: CGFloat? + let cached: [String: ReviewDiffCodeLayout] +} + +private struct ReviewDiffRowGeometry { + let offsets: [CGFloat] + let fileHeaderIndices: [Int] + let contentWidths: [String: CGFloat] + let nativeLayouts: [String: ReviewDiffCodeLayout] + let height: CGFloat + let characterWidth: CGFloat + let wrapColumns: Int? +} + +private extension ReviewDiffNativeRow { + var resolvedFileId: String { + if let fileId { return fileId } + if let range = id.range(of: ":header") ?? id.range(of: ":hunk:") ?? id.range(of: ":line:") { + return String(id[.. = [] { @@ -1065,9 +1171,18 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { private var codeWrapColumns: Int? /// Text geometry survives comment height changes; width, font, and content invalidate it. private var codeLayoutsByRowId: [String: ReviewDiffCodeLayout] = [:] - private var codeLayoutWidth: CGFloat = 0 + private var codeLayoutWidth: CGFloat? private var codeLayoutFont: UIFont? private var codeDecorationVersion = 0 + private var rowPreparation: ReviewDiffRowPreparation? + private var preparedRowGeometry: ReviewDiffRowGeometry? + + private var geometryConfiguration: ReviewDiffGeometryConfiguration { + ReviewDiffGeometryConfiguration( + style: style, viewportWidth: viewportWidth, + collapsedFileIds: collapsedFileIds, collapsedCommentIds: collapsedCommentIds + ) + } private var panStartHorizontalOffset: CGFloat = 0 private var activePanFileId: String? private var activePanKind: ReviewDiffHorizontalPanKind? @@ -1095,19 +1210,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { /// Height before word wrap. Laid-out rows use height(at:), which includes wrapped lines. private func height(for row: ReviewDiffNativeRow) -> CGFloat { - if row.kind == "file" { - return style.fileHeaderHeight - } - if collapsedFileIds.contains(resolvedFileId(for: row)) { - return 0 - } - if row.kind == "notice" { - return max(style.rowHeight * 2, 44) - } - if row.kind == "comment" { - return collapsedCommentIds.contains(row.id) ? 44 : 124 - } - return style.rowHeight + geometryConfiguration.height(for: row) } /// Laid-out height, including wrapped lines. Requires a layout built from the current rows. @@ -1141,111 +1244,174 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { ) } - /// Shape new content on the existing decode worker before publishing rows to the UI. + // Pixel offsets cannot identify the same code row after wrapping or font changes. + func viewportRowAnchor() -> ReviewDiffViewportRowAnchor? { + let top = max(0, verticalOffset + topContentInset) + guard let index = rowIndex(at: top) else { return nil } + return ReviewDiffViewportRowAnchor(rowIndex: index, intraRowOffset: top - rowOffsets[index]) + } + + func offset(for anchor: ReviewDiffViewportRowAnchor) -> CGFloat? { + guard let frame = frameForRow(at: anchor.rowIndex), frame.height > 0 else { return nil } + let intraRowOffset = anchor.intraRowOffset < frame.height + ? anchor.intraRowOffset : max(0, frame.height - 1) + return frame.minY + intraRowOffset + } + + func cancelRowPreparation() { + rowPreparation?.cancel() + rowPreparation = nil + preparedRowGeometry = nil + } + + /// Prepare immutable geometry on the decode worker; publication only swaps its buffers. func prepareRows( _ rows: [ReviewDiffNativeRow], on queue: DispatchQueue, isCurrent: @escaping () -> Bool, completion: @escaping () -> Void ) { + cancelRowPreparation() + let preparation = ReviewDiffRowPreparation() + rowPreparation = preparation + let configuration = geometryConfiguration let font = codeFont let characterWidth = monospaceCharacterWidth(font: font) - guard let width = codeLayoutWidthLimit(font: font) else { - completion() - return - } + let width = codeLayoutWidthLimit(font: font) + let hunkFont = self.hunkFont let cached = codeLayoutWidth == width && codeLayoutFont == font ? codeLayoutsByRowId : [:] + let input = ReviewDiffRowGeometryInput( + configuration: configuration, font: font, hunkFont: hunkFont, + characterWidth: characterWidth, layoutWidth: width, cached: cached + ) queue.async { [weak self] in - var layouts: [String: ReviewDiffCodeLayout] = [:] - for row in rows where row.kind == "line" { - guard let text = row.content else { continue } - if let previous = cached[row.id], previous.text == text { - layouts[row.id] = previous - } else { - layouts[row.id] = ReviewDiffCodeLayout(text: text, font: font, width: width, characterWidth: characterWidth) - } - } + guard let geometry = Self.buildRowGeometry( + rows, input: input, isCancelled: { preparation.isCancelled } + ) else { return } DispatchQueue.main.async { [weak self] in - guard let self, isCurrent() else { return } - if self.codeFont != font || self.codeLayoutWidthLimit(font: font) != width { + guard let self, !preparation.isCancelled, isCurrent() else { return } + if self.geometryConfiguration != configuration { self.prepareRows(rows, on: queue, isCurrent: isCurrent, completion: completion) return } + self.rowPreparation = nil self.codeLayoutWidth = width self.codeLayoutFont = font - self.codeLayoutsByRowId = layouts + self.preparedRowGeometry = geometry completion() } } } private func rebuildRowLayout() { + let font = codeFont + let characterWidth = monospaceCharacterWidth(font: font) + let layoutWidth = codeLayoutWidthLimit(font: font) + let cached = codeLayoutWidth == layoutWidth && codeLayoutFont == font ? codeLayoutsByRowId : [:] + let input = ReviewDiffRowGeometryInput( + configuration: geometryConfiguration, font: font, hunkFont: hunkFont, + characterWidth: characterWidth, layoutWidth: layoutWidth, cached: cached + ) + guard let geometry = Self.buildRowGeometry(rows, input: input) else { return } + codeLayoutWidth = layoutWidth + codeLayoutFont = font + applyRowGeometry(geometry) + } + + private static func buildRowGeometry( + _ rows: [ReviewDiffNativeRow], + input: ReviewDiffRowGeometryInput, + isCancelled: () -> Bool = { false } + ) -> ReviewDiffRowGeometry? { var nextOffsets: [CGFloat] = [] var nextFileHeaderRowIndices: [Int] = [] nextOffsets.reserveCapacity(rows.count) var maxWidthsByFileId: [String: CGFloat] = [:] var nextCodeLayouts: [String: ReviewDiffCodeLayout] = [:] var offset: CGFloat = 0 - let font = codeFont - let characterWidth = monospaceCharacterWidth(font: font) - let layoutWidth = codeLayoutWidthLimit(font: font) - let wrapColumns = style.wordWrap ? layoutWidth.map { Int($0 / characterWidth) } : nil - if let layoutWidth, codeLayoutWidth != layoutWidth || codeLayoutFont != font { - codeLayoutsByRowId.removeAll() - codeLayoutWidth = layoutWidth - codeLayoutFont = font - } + let style = input.configuration.style + let characterWidth = input.characterWidth + let wrapColumns = style.wordWrap ? input.layoutWidth.map { Int($0 / characterWidth) } : nil for (index, row) in rows.enumerated() { + // Bound obsolete work without locking once per row; never read UIView state here. + if index.isMultiple(of: 256), isCancelled() { return nil } nextOffsets.append(offset) if row.kind == "file" { nextFileHeaderRowIndices.append(index) } - var rowHeight = height(for: row) - - let fileId = resolvedFileId(for: row) + var rowHeight = input.configuration.height(for: row) + let fileId = row.resolvedFileId switch row.kind { case "line": - // UTF-16 columns match the word diff ranges and the segments drawCodeLines draws. - var width = CGFloat(row.content?.utf16.count ?? 0) * characterWidth - if let layoutWidth, let content = row.content { - let cached = codeLayoutsByRowId[row.id] - let layout: ReviewDiffCodeLayout - if let cached, cached.text == content { - layout = cached - } else { - layout = ReviewDiffCodeLayout( - text: content, font: font, width: layoutWidth, characterWidth: characterWidth - ) - } - nextCodeLayouts[row.id] = layout - // Tabs and fallback glyphs are wider than one column. - width = layout.usedWidth - if rowHeight > 0 { - rowHeight = max(rowHeight, layout.firstLineHeight) + layout.extraHeight - } - } + let width = lineWidth( + for: row, input: input, wrapColumns: wrapColumns, + height: &rowHeight, layouts: &nextCodeLayouts + ) maxWidthsByFileId[fileId] = max(maxWidthsByFileId[fileId] ?? 0, width) case "hunk": - // Hunk context can hold tabs and draws in its own font, so measure what drawHunkRow draws. - maxWidthsByFileId[fileId] = max( - maxWidthsByFileId[fileId] ?? 0, - textWidth(row.text ?? "", font: hunkFont) - ) + // Hunk context can hold tabs and draws in its own font. + let width = ceil(((row.text ?? "") as NSString).size(withAttributes: [.font: input.hunkFont, .ligature: 0]).width) + maxWidthsByFileId[fileId] = max(maxWidthsByFileId[fileId] ?? 0, width) default: break } offset += rowHeight } + guard !isCancelled() else { return nil } + return ReviewDiffRowGeometry( + offsets: nextOffsets, fileHeaderIndices: nextFileHeaderRowIndices, + contentWidths: maxWidthsByFileId.mapValues { ceil($0) + style.codePadding * 2 }, + nativeLayouts: nextCodeLayouts, height: offset, + characterWidth: characterWidth, wrapColumns: wrapColumns + ) + } - codeCharacterWidth = characterWidth - codeWrapColumns = wrapColumns - codeLayoutsByRowId = nextCodeLayouts - // Measured widths are not capped, so the end of every line stays reachable by scrolling. - contentWidthsByFileId = maxWidthsByFileId.mapValues { ceil($0) + style.codePadding * 2 } - rowOffsets = nextOffsets - fileHeaderRowIndices = nextFileHeaderRowIndices - contentHeight = offset + private static func lineWidth( + for row: ReviewDiffNativeRow, + input: ReviewDiffRowGeometryInput, + wrapColumns: Int?, + height: inout CGFloat, + layouts: inout [String: ReviewDiffCodeLayout] + ) -> CGFloat { + let characterWidth = input.characterWidth + let font = input.font + var width = CGFloat(row.content?.utf16.count ?? 0) * characterWidth + if let layoutWidth = input.layoutWidth, let content = row.content { + if ReviewDiffCodeLayout.isPrintableASCII(content) { + let length = content.utf8.count + let starts = ReviewDiffCodeLineStarts(length: length, columns: wrapColumns) + width = CGFloat(min(length, starts.columns)) * characterWidth + if height > 0 { + height = max(height, font.lineHeight) + CGFloat(starts.count - 1) * ceil(font.lineHeight) + } + } else { + let layout: ReviewDiffCodeLayout + if let previous = input.cached[row.id], previous.text == content { + layout = previous + } else { + layout = ReviewDiffCodeLayout( + text: content, font: font, width: layoutWidth, characterWidth: characterWidth + ) + } + layouts[row.id] = layout + width = layout.usedWidth + if height > 0 { + height = max(height, layout.firstLineHeight) + layout.extraHeight + } + } + } + return width + } + + private func applyRowGeometry(_ geometry: ReviewDiffRowGeometry) { + codeCharacterWidth = geometry.characterWidth + codeWrapColumns = geometry.wrapColumns + codeLayoutsByRowId = geometry.nativeLayouts + contentWidthsByFileId = geometry.contentWidths + rowOffsets = geometry.offsets + fileHeaderRowIndices = geometry.fileHeaderIndices + contentHeight = geometry.height } private var codeFont: UIFont { @@ -1704,13 +1870,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { } private func resolvedFileId(for row: ReviewDiffNativeRow) -> String { - if let fileId = row.fileId { - return fileId - } - if let range = row.id.range(of: ":header") ?? row.id.range(of: ":hunk:") ?? row.id.range(of: ":line:") { - return String(row.id[.. Void ) { @@ -2455,7 +2619,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { private func drawWordDiffRanges( _ row: ReviewDiffNativeRow, - lineStarts: [Int], + lineStarts: ReviewDiffCodeLineStarts, firstLineRect: CGRect, context: CGContext, horizontalOffset: CGFloat diff --git a/apps/mobile/modules/t3-review-diff/tests/android/.gitignore b/apps/mobile/modules/t3-review-diff/tests/android/.gitignore new file mode 100644 index 0000000000000..67bcc2f72725f --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/tests/android/.gitignore @@ -0,0 +1,2 @@ +.gradle/ +build/ diff --git a/apps/mobile/modules/t3-review-diff/tests/android/README.md b/apps/mobile/modules/t3-review-diff/tests/android/README.md new file mode 100644 index 0000000000000..4211691134d69 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/tests/android/README.md @@ -0,0 +1,33 @@ +# Android native regressions + +From the repository root: + +```sh +apps/mobile/modules/t3-review-diff/tests/run-android.sh +``` + +Requires JDK 17+ (`JAVA_HOME`) and Gradle on `PATH`. Tested with JDK 17 and +Gradle 9.4.1. To use another installed Gradle or a wrapper, set +`GRADLE=/absolute/path/to/gradle` before the command. The runner resolves its own +location, so it also works from other directories. Gradle test options are +forwarded, for example `--tests '*ReviewDiffViewportTest' --rerun-tasks`. + +This standalone JVM build registers the viewport and geometry regressions and the +module's existing `android/src/test` layout suite with Gradle's `test` task. It +compiles the module's actual Android view, drawing, and layout sources. Pinned Maven dependencies supply Kotlin, Robolectric, Android API 35, +and AndroidX test classes. The first run requires access to Maven Central and +Google Maven, including Robolectric's runtime SDK download. No local Android +SDK, Expo prebuild, device, temporary checkout, or pre-extracted jars are needed. +Generated files stay in ignored `build/` and `.gradle/` directories; the HTML +report is `build/reports/tests/test/index.html`. + +The three files under `stubs/expo/modules/kotlin/` replace only Expo's context, view base class, and event +dispatcher. Events go to a per-test context instead of a React Native bridge. +This checks reset/decode ordering, stale-row removal, and viewport publication +in the real view, but does not validate Expo registration, JS event delivery, +native libraries, or device rendering. These stubs are exclusive to this +standalone build and must not be added to the production Android source set. +Geometry regressions bound sticky-header row reads, check lazy ASCII layout +retention and syntax invalidation, and compare ASCII widths against Android +paint metrics. Native shaping tests cover tabs, Unicode clusters, and styled +word-diff spans. diff --git a/apps/mobile/modules/t3-review-diff/tests/android/build.gradle b/apps/mobile/modules/t3-review-diff/tests/android/build.gradle new file mode 100644 index 0000000000000..fa067950ed044 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/tests/android/build.gradle @@ -0,0 +1,77 @@ +plugins { id 'java' } + +repositories { + mavenCentral() + google() +} + +configurations { + compiler + androidTestAars +} + +dependencies { + compiler 'org.jetbrains.kotlin:kotlin-compiler-embeddable:2.2.21' + testImplementation 'org.jetbrains.kotlin:kotlin-stdlib:2.2.21' + testImplementation 'junit:junit:4.13.2' + testImplementation('org.robolectric:robolectric:4.16.1') { + exclude group: 'androidx.test', module: 'monitor' + exclude group: 'androidx.test.espresso', module: 'espresso-idling-resource' + } + // Compile against the same API used by @Config(sdk = [35]), without a local Android SDK. + testImplementation 'org.robolectric:android-all:15-robolectric-12650502' + androidTestAars 'androidx.test:monitor:1.8.0@aar' + androidTestAars 'androidx.test.espresso:espresso-idling-resource:3.7.0@aar' + testImplementation files(layout.buildDirectory.dir('android-test-jars').map { + fileTree(it) { include '**/*.jar' } + }) +} + +// The JVM plugin cannot load classes.jar inside AndroidX AAR dependencies. +tasks.register('extractAndroidTestJars') { + inputs.files configurations.androidTestAars + outputs.dir layout.buildDirectory.dir('android-test-jars') + doLast { + configurations.androidTestAars.each { aar -> + copy { + from zipTree(aar) + include 'classes.jar' + filePermissions { unix('644') } + into layout.buildDirectory.dir("android-test-jars/${aar.name}") + } + } + } +} + +def moduleRoot = file('../..') +def sources = files( + fileTree('stubs') { include '**/*.kt' }, + fileTree('src/test/kotlin') { include '**/*.kt' }, + fileTree("${moduleRoot}/android/src/test/java") { include '**/*.kt' }, + fileTree("${moduleRoot}/android/src/main/java/expo/modules/t3reviewdiff") { + include '**/*.kt' + exclude '**/T3ReviewDiffModule.kt' + }, +) + +tasks.register('compileRegression', JavaExec) { + dependsOn 'extractAndroidTestJars' + classpath = configurations.compiler + mainClass = 'org.jetbrains.kotlin.cli.jvm.K2JVMCompiler' + inputs.files sources, configurations.testCompileClasspath + outputs.dir layout.buildDirectory.dir('regression') + doFirst { + args = [ + '-no-stdlib', '-no-reflect', '-jvm-target', '17', + '-classpath', configurations.testCompileClasspath.asPath, + '-d', layout.buildDirectory.dir('regression').get().asFile.absolutePath, + ] + sources.files.collect { it.absolutePath } + } +} + +test { + dependsOn 'compileRegression' + testClassesDirs = files(layout.buildDirectory.dir('regression')) + classpath = files(testClassesDirs, configurations.testRuntimeClasspath) + testLogging { events 'passed', 'failed'; exceptionFormat 'full' } +} diff --git a/apps/mobile/modules/t3-review-diff/tests/android/settings.gradle b/apps/mobile/modules/t3-review-diff/tests/android/settings.gradle new file mode 100644 index 0000000000000..8fc37a9c434b9 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/tests/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 't3-review-diff-viewport-tests' diff --git a/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffGeometryTest.kt b/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffGeometryTest.kt new file mode 100644 index 0000000000000..d6b4c7456392e --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffGeometryTest.kt @@ -0,0 +1,244 @@ +package expo.modules.t3reviewdiff + +import android.graphics.Color +import android.view.View +import kotlin.math.ceil +import expo.modules.kotlin.AppContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], manifest = Config.NONE) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ReviewDiffGeometryTest { + @Test + fun geometryChangesKeepTheVisibleRowAndClampWithinShortenedRows() { + val parent = T3ReviewDiffView(RuntimeEnvironment.getApplication(), AppContext()) + val canvas = parent.getChildAt(0) + val spec = View.MeasureSpec.makeMeasureSpec(390, View.MeasureSpec.EXACTLY) + canvas.measure(spec, spec) + canvas.layout(0, 0, 390, 600) + try { + val rows = listOf("highlights.c", "simd128.h").flatMap { file -> + listOf(row("$file:header", "").copy(kind = "file", fileId = file)) + + List(100) { line -> + row("$file:line:$line", "long native code ".repeat(line % 9 + 1)).copy(fileId = file) + } + } + call(canvas, "setRows", rows) + val index = 130 + fun position(intra: Int) { + call(canvas, "setVerticalOffset", (call(canvas, "rowTop", index) as Int) + intra) + } + fun assertPosition(intra: Int, reason: String) { + val top = call(canvas, "verticalOffset") as Int + assertEquals("$reason: visible row/file changed", index, call(canvas, "rowIndexAt", top)) + assertEquals( + "$reason: intra-row position changed", + intra, + top - (call(canvas, "rowTop", index) as Int) + ) + } + position(7) + call(parent, "scrollToY", 0, true) + val scroller = field(parent, "verticalScroller") as android.widget.OverScroller + assertTrue(!scroller.isFinished) + parent.setStyleJson("{\"wordWrap\":true}") + assertPosition(7, "wrap on") + assertTrue( + "Geometry correction must stop the old absolute scroll trajectory", + scroller.isFinished + ) + parent.computeScroll() + assertPosition(7, "after interrupted animation") + parent.setStyleJson("{\"wordWrap\":true,\"codeFontSize\":22}") + assertPosition(7, "font change") + canvas.layout(0, 0, 280, 600) + assertPosition(7, "wrapped width change") + val height = + (call(canvas, "rowTop", index + 1) as Int) - (call(canvas, "rowTop", index) as Int) + position(height - 2) + parent.setStyleJson("{\"wordWrap\":false,\"rowHeight\":24}") + val shorterHeight = + (call(canvas, "rowTop", index + 1) as Int) - (call(canvas, "rowTop", index) as Int) + assertPosition(shorterHeight - 1, "unwrap clamps inside shortened row") + parent.setContentResetKey("new-content") + parent.setStyleJson("{\"wordWrap\":true}") + assertEquals(0, call(canvas, "verticalOffset")) + } finally { + parent.cleanup() + } + } + + @Test + fun stickyHeadersUseTheHeaderIndexAndKeepPushOffAndHitTesting() { + val parent = T3ReviewDiffView(RuntimeEnvironment.getApplication(), AppContext()) + val canvas = parent.getChildAt(0) + val spec = View.MeasureSpec.makeMeasureSpec(320, View.MeasureSpec.EXACTLY) + canvas.measure(spec, spec) + canvas.layout(0, 0, 320, 320) + try { + val rows = CountingRows( + List(10000) { index -> + row("row:$index", "const value = $index").copy( + kind = if (index == 100 || index == 9000) "file" else "line", + fileId = if (index < 9000) "first" else "second", + ) + } + ) + call(canvas, "setRows", rows) + assertEquals(null, call(canvas, "stickyFileHeader", 0)) + val nextTop = call(canvas, "rowTop", 9000) as Int + call(canvas, "setVerticalOffset", nextTop - 10) + rows.reads = 0 + val sticky = requireNotNull(call(canvas, "stickyFileHeader", 8999)) + assertEquals(100, field(sticky, "index")) + assertEquals(10, field(sticky, "bottom")) + assertTrue("sticky lookup must not scan code rows: ${rows.reads}", rows.reads < 10) + val hit = requireNotNull(call(canvas, "rowHitAt", 5f)) + assertEquals("row:100", (field(hit, "row") as DiffRow).id) + val second = requireNotNull(call(canvas, "stickyFileHeader", 9000)) + assertEquals(9000, field(second, "index")) + call(canvas, "setRows", listOf(row("only", "value"))) + assertEquals(null, call(canvas, "stickyFileHeader", 0)) + } finally { + parent.cleanup() + } + } + + @Test + fun asciiSyntaxArrivalDoesNotRebuildWrappedOffsetsOrRetainPerRowLayouts() { + val parent = T3ReviewDiffView(RuntimeEnvironment.getApplication(), AppContext()) + val canvas = parent.getChildAt(0) + val spec = View.MeasureSpec.makeMeasureSpec(320, View.MeasureSpec.EXACTLY) + canvas.measure(spec, spec) + canvas.layout(0, 0, 320, 320) + try { + call(canvas, "setStyle", DiffStyle.defaults(1f).copy(wordWrap = true)) + val rows = List(10000) { row("row:$it", "const value = 123; ".repeat(10)) } + call(canvas, "setRows", rows) + val offsets = field(canvas, "rowOffsets") + call( + canvas, + "setTokensByRowId", + mapOf(rows[0].id to listOf(DiffToken(rows[0].content, 0xff008800.toInt(), 2))) + ) + assertSame("ASCII syntax must only repaint", offsets, field(canvas, "rowOffsets")) + val drawing = requireNotNull(field(canvas, "drawing")) + val cache = requireNotNull(field(drawing, "codeLayouts")) + assertEquals( + "offscreen ASCII must not retain layout objects", + 0, + (field(cache, "entries") as Map<*, *>).size + ) + } finally { + parent.cleanup() + } + } + + @Test + fun asciiWidthCacheDoesNotRetainPerRowMeasurementObjects() { + val drawing = ReviewDiffCanvasDrawing(RuntimeEnvironment.getApplication()) + val rows = List(10000) { row("row:$it", "const value = $it") } + drawing.contentWidth(rows, emptyMap(), DiffStyle.defaults(1f)) + assertEquals(0, (field(drawing, "widthEntries") as Map<*, *>).size) + } + + @Test + fun asciiWidthMatchesPaintForEverySyntaxFontAndWeight() { + val drawing = ReviewDiffCanvasDrawing(RuntimeEnvironment.getApplication()) + val content = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + "[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" + val row = row("ascii", content) + for (weight in listOf("regular", "medium", "bold")) { + val style = DiffStyle.defaults(1f).copy(codeFontWeight = weight, codeFontSizePx = 17f) + for (fontStyle in 0..7) { + drawing.configureCodePaint(Color.RED, fontStyle, style) + val expected = ceil( + style.changeBarWidthPx + style.gutterWidthPx + style.codePaddingPx * 2f + + drawing.textPaint.measureText(content), + ).toInt() + val tokens = mapOf(row.id to listOf(DiffToken(content, Color.RED, fontStyle))) + assertEquals( + "weight=$weight style=$fontStyle", + expected, + drawing.contentWidth(listOf(row), tokens, style) + ) + val recolored = mapOf(row.id to listOf(DiffToken(content, Color.BLUE, fontStyle xor 4))) + assertEquals(false, drawing.tokensChangeWidth(tokens, recolored, style)) + } + } + } + + @Test + fun lazyAsciiHeightAndLineStartsMatchTheExistingLayoutAtEveryBoundary() { + val drawing = ReviewDiffCanvasDrawing(RuntimeEnvironment.getApplication()) + val style = DiffStyle.defaults(1f).copy(wordWrap = true, codeFontSizePx = 17f) + drawing.configureCodePaint(Color.BLACK, 0, style) + val paint = android.text.TextPaint(drawing.textPaint) + val cache = CodeLayoutCache() + for (width in listOf(120, 180, 280, 420)) { + for (length in listOf(0, 1, 13, 14, 15, 1000)) { + val row = row("ascii", "a".repeat(length)) + val expected = createCodeLines(row.content, paint, width) + val wrapped = cache.layout( + listOf(row), + emptyMap(), + paint, + style, + DiffTheme.fallback("light"), + width + ) + assertTrue(expected.starts.contentEquals(wrapped.lines(row).starts)) + assertEquals(expected.firstHeight(24) + expected.extraHeight, wrapped.rowHeight(row, 24)) + } + } + } + + @Test + fun unwrappedColorAndUnderlinePatchesDoNotReadOffscreenRows() { + val parent = T3ReviewDiffView(RuntimeEnvironment.getApplication(), AppContext()) + val canvas = parent.getChildAt(0) + try { + val rows = CountingRows(List(10000) { row("row:$it", "const value = $it") }) + call(canvas, "setRows", rows) + val content = rows[0].content + rows.reads = 0 + call(canvas, "setTokensByRowId", mapOf("row:0" to listOf(DiffToken(content, Color.RED, 0)))) + call(canvas, "setTokensByRowId", mapOf("row:0" to listOf(DiffToken(content, Color.BLUE, 4)))) + assertEquals(0, rows.reads) + } finally { + parent.cleanup() + } + } + + private fun call(target: Any, name: String, vararg args: Any): Any? = target.javaClass + .declaredMethods.single { it.name == name && it.parameterCount == args.size } + .apply { isAccessible = true }.invoke(target, *args) + + private fun field(target: Any, name: String): Any? = target.javaClass.getDeclaredField(name) + .apply { isAccessible = true }.get(target) + + private fun row(id: String, content: String) = DiffRow( + kind = "line", id = id, fileId = "file", filePath = "test.ts", previousPath = null, + changeType = "modified", additions = 1, deletions = 0, text = "", content = content, + change = "add", oldLineNumber = null, newLineNumber = 1, wordDiffRanges = emptyList(), + commentText = "", commentRangeLabel = "", commentSectionTitle = "", + ) + + private class CountingRows(private val rows: List) : AbstractList() { + var reads = 0 + override val size: Int get() = rows.size + override fun get(index: Int): DiffRow { + reads += 1 + return rows[index] + } + } +} diff --git a/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffViewportTest.kt b/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffViewportTest.kt new file mode 100644 index 0000000000000..28935a11db8d4 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffViewportTest.kt @@ -0,0 +1,156 @@ +package expo.modules.t3reviewdiff + +import android.app.Activity +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Looper +import android.view.View +import expo.modules.kotlin.AppContext +import java.util.concurrent.AbstractExecutorService +import java.util.concurrent.TimeUnit +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], manifest = Config.NONE) +class ReviewDiffViewportTest { + @Test + fun resetDiscardsOldRowsAndPendingDecodeBeforePublishingTheNewDestination() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val appContext = AppContext() + val view = T3ReviewDiffView(activity, appContext) + val decoder = ManualDecoder() + view.javaClass.getDeclaredField("payloadDecodeExecutor").apply { isAccessible = true } + .set(view, decoder) + activity.setContentView(view) + val canvas = view.getChildAt(0) + val measure = View.MeasureSpec.makeMeasureSpec(320, View.MeasureSpec.EXACTLY) + canvas.measure(measure, measure) + canvas.layout(0, 0, 320, 320) + val bitmap = Bitmap.createBitmap(320, 320, Bitmap.Config.ARGB_8888) + try { + view.setInitialFileId("old-selected") + view.setRowsJson(rows("old-selected")) + decoder.completeNext() + canvas.draw(Canvas(bitmap)) + assertEquals(900, ranges(appContext).first()["firstRowIndex"]) + appContext.events.clear() + + // This decode belongs to the old content, but completes after the reset. + view.setRowsJson(rows("old-selected")) + view.setContentResetKey("new-content") + view.setInitialFileId("new-selected") + canvas.draw(Canvas(bitmap)) + assertTrue( + "reset must not publish the previous payload's top range", + ranges(appContext).isEmpty() + ) + assertEquals(0, rowTop(canvas, 1)) + decoder.completeNext() + canvas.draw(Canvas(bitmap)) + assertTrue( + "a stale decode must not restore old rows or publish a range", + ranges(appContext).isEmpty() + ) + assertEquals(0, rowTop(canvas, 1)) + + view.setRowsJson(rows("new-selected")) + decoder.completeNext() + canvas.draw(Canvas(bitmap)) + assertEquals(1, ranges(appContext).size) + assertEquals(900, ranges(appContext).single()["firstRowIndex"]) + assertEquals(1000, ranges(appContext).single()["totalRows"]) + } finally { + view.cleanup() + bitmap.recycle() + activity.finish() + appContext.events.clear() + } + } + + @Test + fun decodedRowsWaitForTheReplacementFilePropBeforePublishingAViewport() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val appContext = AppContext() + val view = T3ReviewDiffView(activity, appContext) + val decoder = ManualDecoder() + view.javaClass.getDeclaredField("payloadDecodeExecutor").apply { isAccessible = true } + .set(view, decoder) + activity.setContentView(view) + val canvas = view.getChildAt(0) + val measure = View.MeasureSpec.makeMeasureSpec(320, View.MeasureSpec.EXACTLY) + canvas.measure(measure, measure) + canvas.layout(0, 0, 320, 320) + try { + view.setInitialFileId("old-selected") + view.setRowsJson(rows("old-selected")) + decoder.completeNext() + appContext.events.clear() + + view.setContentResetKey("new-content") + view.setRowsJson(rows("new-selected")) + decoder.completeNext() + // Other props can rebuild layout while the destination still names the old payload. + view.setCollapsedFileIdsJson("[]") + view.setStyleJson("{}") + view.setInitialRowIndex(-1.0) + assertTrue( + "decoded rows must wait for a resolvable destination", + ranges(appContext).isEmpty() + ) + + view.setInitialFileId("new-selected") + assertEquals(900, ranges(appContext).single()["firstRowIndex"]) + assertEquals(1000, ranges(appContext).single()["totalRows"]) + } finally { + view.cleanup() + activity.finish() + appContext.events.clear() + } + } + + private fun rowTop(canvas: View, index: Int): Int = canvas.javaClass + .getDeclaredMethod("rowTop", Int::class.javaPrimitiveType).apply { isAccessible = true } + .invoke(canvas, index) as Int + + private fun ranges(appContext: AppContext) = appContext.events + .filter { (name, payload) -> name == "onDebug" && payload["message"] == "visible-range" } + .map { it.second } + + private fun rows(selectedFileId: String): String = JSONArray().apply { + for (index in 0 until 1000) { + put( + JSONObject().apply { + put("id", "$selectedFileId:$index") + put("kind", if (index == 0 || index == 900) "file" else "line") + put("fileId", if (index < 900) "first-file" else selectedFileId) + put("content", "const value = $index") + } + ) + } + }.toString() + + private class ManualDecoder : AbstractExecutorService() { + private val work = ArrayDeque() + override fun execute(command: Runnable) { + work.addLast(command) + } + fun completeNext() { + work.removeFirst().run() + shadowOf(Looper.getMainLooper()).idle() + } + override fun shutdown() = Unit + override fun shutdownNow(): MutableList = mutableListOf() + override fun isShutdown() = false + override fun isTerminated() = false + override fun awaitTermination(timeout: Long, unit: TimeUnit) = true + } +} diff --git a/apps/mobile/modules/t3-review-diff/tests/android/stubs/expo/modules/kotlin/AppContext.kt b/apps/mobile/modules/t3-review-diff/tests/android/stubs/expo/modules/kotlin/AppContext.kt new file mode 100644 index 0000000000000..88a47d2d2ed3d --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/tests/android/stubs/expo/modules/kotlin/AppContext.kt @@ -0,0 +1,6 @@ +package expo.modules.kotlin + +// Per-view event sink for the isolated JVM harness; no React Native bridge is loaded. +class AppContext { + val events = mutableListOf>>() +} diff --git a/apps/mobile/modules/t3-review-diff/tests/android/stubs/expo/modules/kotlin/viewevent/EventDispatcher.kt b/apps/mobile/modules/t3-review-diff/tests/android/stubs/expo/modules/kotlin/viewevent/EventDispatcher.kt new file mode 100644 index 0000000000000..fff408e4c5e6e --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/tests/android/stubs/expo/modules/kotlin/viewevent/EventDispatcher.kt @@ -0,0 +1,15 @@ +package expo.modules.kotlin.viewevent + +import expo.modules.kotlin.views.ExpoView +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +@Suppress("FunctionName") +fun ExpoView.EventDispatcher() = object : ReadOnlyProperty) -> Unit> { + override fun getValue( + thisRef: ExpoView, + property: KProperty<*> + ): (Map) -> Unit = { payload -> + thisRef.appContext.events.add(property.name to payload) + } +} diff --git a/apps/mobile/modules/t3-review-diff/tests/android/stubs/expo/modules/kotlin/views/ExpoView.kt b/apps/mobile/modules/t3-review-diff/tests/android/stubs/expo/modules/kotlin/views/ExpoView.kt new file mode 100644 index 0000000000000..d65b9f412abcb --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/tests/android/stubs/expo/modules/kotlin/views/ExpoView.kt @@ -0,0 +1,7 @@ +package expo.modules.kotlin.views + +import android.content.Context +import android.widget.FrameLayout +import expo.modules.kotlin.AppContext + +open class ExpoView(context: Context, val appContext: AppContext) : FrameLayout(context) diff --git a/apps/mobile/modules/t3-review-diff/tests/ios/anchor.swift b/apps/mobile/modules/t3-review-diff/tests/ios/anchor.swift new file mode 100644 index 0000000000000..40bde07fc5b03 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/tests/ios/anchor.swift @@ -0,0 +1,53 @@ +// Exercise the real UIScrollView/geometry update path with a preceding large file. +private extension T3ReviewDiffView { + func checkRowAnchoring() throws { + frame = CGRect(x: 0, y: 0, width: 390, height: 600) + layoutSubviews() + setContentInsetTop(80) + let fixture = ["highlights.c", "simd128.h"].flatMap { file in + [["kind": "file", "id": "\(file):header", "fileId": file]] + (0..<100).map { line in + ["kind": "line", "id": "\(file):line:\(line)", "fileId": file, + "content": String(repeating: "long native code ", count: line % 9 + 1)] + } + } + rows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: JSONSerialization.data(withJSONObject: fixture)) + contentView.rows = rows + updateContentMetrics() + let index = 130 + func position(_ intra: CGFloat) { + setVerticalContentOffset(contentView.frameForRow(at: index)!.minY + intra, animated: false) + } + func assertPosition(_ intra: CGFloat, _ reason: String) { + let frame = contentView.frameForRow(at: index)! + let top = scrollView.contentOffset.y + scrollView.contentInset.top + check(top >= frame.minY && top < frame.maxY, "\(reason): visible row/file changed across geometry update") + check(abs(top - frame.minY - intra) < 0.01, "\(reason): intra-row position changed") + check(contentView.verticalOffset == scrollView.contentOffset.y, "Viewport and UIScrollView must agree") + } + position(7) + isProgrammaticScrollActive = true + contentView.isVerticalScrollActive = true + setStyleJson("{\"wordWrap\":true}") + assertPosition(7, "wrap on") + check(!isProgrammaticScrollActive && !contentView.isVerticalScrollActive, + "Anchor restoration must finish an interrupted programmatic scroll") + setStyleJson("{\"wordWrap\":true,\"codeFontSize\":22}") + assertPosition(7, "font change") + frame.size.width = 280 + layoutSubviews() + assertPosition(7, "wrapped width change") + position(contentView.frameForRow(at: index)!.height - 2) + setStyleJson("{\"wordWrap\":false,\"rowHeight\":24}") + let height = contentView.frameForRow(at: index)!.height + assertPosition(height - 1, "unwrap clamps inside shortened row") + setInitialFileId("simd128.h") + layoutSubviews() + check(abs(scrollView.contentOffset.y + scrollView.contentInset.top - contentView.fileHeaderOffset(forFileId: "simd128.h")!) < 0.01, + "Explicit initial-file navigation must win over geometry anchoring") + setContentResetKey("new-content") + setStyleJson("{\"wordWrap\":true}") + check(rows.isEmpty && scrollView.contentOffset.y == -80, "Reset must discard the old anchor") + print("Passed viewport row anchors for wrap, font, width, intra-row clamp, initial file, and reset") + } +} +try T3ReviewDiffView().checkRowAnchoring() diff --git a/apps/mobile/modules/t3-review-diff/tests/ios/geometry.swift b/apps/mobile/modules/t3-review-diff/tests/ios/geometry.swift new file mode 100644 index 0000000000000..bd213f694f0c7 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/tests/ios/geometry.swift @@ -0,0 +1,137 @@ +// Geometry tests exercise the production content view without an Expo app or device. +private extension ReviewDiffContentView { + func checkSparseGeometry(expectedNativeRows: Int) { + check(codeLayoutsByRowId.count == expectedNativeRows, + "Offscreen ASCII must not retain per-row layout objects or wrap-start arrays (retained \(codeLayoutsByRowId.count))") + } + + func checkReferenceGeometry() { + let font = codeFont + let characterWidth = monospaceCharacterWidth(font: font) + let width = codeLayoutWidthLimit(font: font) + var offset: CGFloat = 0 + var widths: [String: CGFloat] = [:] + for (index, row) in rows.enumerated() { + check(rowOffsets[index] == offset, "Wrapped offsets changed for \(row.id)") + var rowHeight = height(for: row) + if row.kind == "line", let width, let content = row.content { + let reference = ReviewDiffCodeLayout(text: content, font: font, width: width, characterWidth: characterWidth) + if rowHeight > 0 { + rowHeight = max(rowHeight, reference.firstLineHeight) + reference.extraHeight + } + widths[row.resolvedFileId] = max(widths[row.resolvedFileId] ?? 0, reference.usedWidth) + } else if row.kind == "hunk" { + widths[row.resolvedFileId] = max(widths[row.resolvedFileId] ?? 0, textWidth(row.text ?? "", font: hunkFont)) + } + check(height(at: index) == rowHeight, "Row height changed for \(row.id)") + if rowHeight > 0 { + check(rowIndex(at: offset + rowHeight / 2) == index, "Hit testing must use the published wrapped offsets") + } + offset += rowHeight + } + check(contentHeight == offset, "Content height must include every row") + check(contentWidthsByFileId == widths.mapValues { ceil($0) + style.codePadding * 2 }, + "The end of every unwrapped line must remain reachable") + } + + func preparedOffsetAddress() -> UInt { + guard let geometry = preparedRowGeometry else { fatalError("Geometry was not prepared") } + return geometry.offsets.withUnsafeBufferPointer { UInt(bitPattern: $0.baseAddress) } + } + + func checkPublishedOffsetAddress(_ address: UInt) { + check(rowOffsets.withUnsafeBufferPointer { UInt(bitPattern: $0.baseAddress) } == address, + "Publication must reuse the prepared offsets without rebuilding on the UI thread") + } + + func checkCancellationInsideLoop() { + var checks = 0 + let input = ReviewDiffRowGeometryInput( + configuration: geometryConfiguration, font: codeFont, hunkFont: hunkFont, + characterWidth: codeCharacterWidth, layoutWidth: codeLayoutWidthLimit(font: codeFont), cached: [:] + ) + let geometry = Self.buildRowGeometry( + rows, input: input, isCancelled: { + checks += 1 + return checks == 2 + } + ) + check(geometry == nil && checks == 2, "Obsolete work must abort at the next batch, before visiting every row") + } +} + +private let geometryRows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: JSONSerialization.data(withJSONObject: + [["kind": "file", "id": "a:header", "fileId": "a"], + ["kind": "hunk", "id": "a:hunk:0", "fileId": "a", "text": "@@ context\twith tabs @@"]] + (0..<4096).map { index in + ["kind": "line", "id": "a:line:\(index)", "fileId": "a", + "content": String(repeating: "printable ASCII ", count: index % 30), "newLineNumber": index + 1] + } + [["kind": "line", "id": "a:unicode", "fileId": "a", "content": "ๆผขๅญ—\t๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"], + ["kind": "comment", "id": "a:comment", "fileId": "a"], + ["kind": "file", "id": "b:header", "fileId": "b"], + ["kind": "line", "id": "b:line:0", "fileId": "b", "content": "e\u{301}\tู…ุฑุญุจุง"]] +)) +private let geometryView = ReviewDiffContentView(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) +private func geometryStyle(wrap: Bool, fontSize: Int = 12, weight: String = "regular", rowHeight: Int = 24) throws -> ReviewDiffNativeStyle { + let payload = try JSONDecoder().decode(ReviewDiffNativeStylePayload.self, from: Data( + "{\"wordWrap\":\(wrap),\"codeFontSize\":\(fontSize),\"codeFontWeight\":\"\(weight)\",\"rowHeight\":\(rowHeight)}".utf8 + )) + return ReviewDiffNativeStyle.resolve(payload) +} +geometryView.style = try geometryStyle(wrap: true) +geometryView.viewportWidth = 390 +geometryView.rows = geometryRows +geometryView.checkSparseGeometry(expectedNativeRows: 2) +geometryView.checkReferenceGeometry() +geometryView.checkCancellationInsideLoop() +for width: CGFloat in [400, 280] { + geometryView.viewportWidth = width + geometryView.checkReferenceGeometry() +} +geometryView.collapsedFileIds = ["b"] +geometryView.collapsedCommentIds = ["a:comment"] +geometryView.checkReferenceGeometry() +geometryView.style = try geometryStyle(wrap: true, fontSize: 28, weight: "bold", rowHeight: 8) +geometryView.checkReferenceGeometry() +geometryView.style = try geometryStyle(wrap: false) +geometryView.checkReferenceGeometry() +geometryView.collapsedFileIds = [] +geometryView.collapsedCommentIds = [] +geometryView.style = try geometryStyle(wrap: true) +geometryView.checkReferenceGeometry() +let originalHeight = geometryView.contentHeight +geometryView.mergeTokensByRowId(["a:line:29": [ReviewDiffNativeToken(content: geometryRows[31].content ?? "", color: "#ff0000", fontStyle: 3)]]) +geometryView.selectedRowIds = ["a:line:29"] +check(geometryView.contentHeight == originalHeight, "Tokens and selection must preserve geometry") + +// Configuration changes before publication must restart preparation from a fresh snapshot. +private let geometryQueue = DispatchQueue(label: "review-diff.geometry-tests") +geometryQueue.suspend() +geometryView.prepareRows(geometryRows, on: geometryQueue, isCurrent: { true }, completion: { + let address = geometryView.preparedOffsetAddress() + geometryView.rows = geometryRows + geometryView.checkPublishedOffsetAddress(address) + geometryView.checkSparseGeometry(expectedNativeRows: 2) + geometryView.checkReferenceGeometry() + check(geometryView.style.codeFontSize == 28 && geometryView.viewportWidth == 400) + + // A reset/new payload can cancel work before its worker is scheduled, without publishing it. + geometryQueue.suspend() + geometryView.prepareRows(geometryRows, on: geometryQueue, isCurrent: { true }, completion: { + check(false, "Cancelled geometry must never publish") + }) + geometryView.rows = [] + geometryQueue.resume() + geometryQueue.async { + DispatchQueue.main.async { + check(geometryView.rows.isEmpty && geometryView.contentHeight == 0, "Reset must remain empty after obsolete work drains") + print("Passed sparse geometry, reference heights/widths, font/wrap/collapse, hit testing, direct publication, and cancellation") + exit(0) + } + } +}) +geometryView.style = try geometryStyle(wrap: true, fontSize: 28, weight: "bold", rowHeight: 8) +geometryView.viewportWidth = 400 +geometryView.collapsedFileIds = ["b"] +geometryView.collapsedCommentIds = ["a:comment"] +geometryQueue.resume() +dispatchMain() diff --git a/apps/mobile/modules/t3-review-diff/tests/ios/main.swift b/apps/mobile/modules/t3-review-diff/tests/ios/main.swift index d8ce5420e0921..974c3d7266549 100644 --- a/apps/mobile/modules/t3-review-diff/tests/ios/main.swift +++ b/apps/mobile/modules/t3-review-diff/tests/ios/main.swift @@ -77,7 +77,7 @@ for text in ["\t\t\"extends\": \"@riptech/tsconfig/tsconfig.test.json\",", "ๆผข let unwrappedAscii = ReviewDiffCodeLayout( text: "const value = 123;", font: font, width: ReviewDiffCodeLayout.unboundedWidth, characterWidth: characterWidth ) -check(unwrappedAscii.starts == [0] && unwrappedAscii.usedWidth == 18 * characterWidth) +check(Array(unwrappedAscii.starts) == [0] && unwrappedAscii.usedWidth == 18 * characterWidth) let ascii = String(repeating: "const value = 123; ", count: 100) let layout = ReviewDiffCodeLayout(text: ascii, font: font, width: 280, characterWidth: characterWidth) diff --git a/apps/mobile/modules/t3-review-diff/tests/run-android.sh b/apps/mobile/modules/t3-review-diff/tests/run-android.sh new file mode 100755 index 0000000000000..f62b0a02d012e --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/tests/run-android.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +tests_dir=$(cd "$(dirname "$0")" && pwd) +"${GRADLE:-gradle}" --project-dir "$tests_dir/android" test "$@" diff --git a/apps/mobile/modules/t3-review-diff/tests/run-ios.sh b/apps/mobile/modules/t3-review-diff/tests/run-ios.sh index 464ef0cf45774..a8ecdd88947d5 100644 --- a/apps/mobile/modules/t3-review-diff/tests/run-ios.sh +++ b/apps/mobile/modules/t3-review-diff/tests/run-ios.sh @@ -5,8 +5,32 @@ sdk=$(xcrun --sdk macosx --show-sdk-path) compiler=$(xcrun --find swiftc) output=$(mktemp -d) trap 'rm -rf "$output"' EXIT +# Keep the real content view and helpers; the Expo bridge is not needed by this harness. +python3 - "$module_dir" "$output/main.swift" <<'PYTHON' +import sys +from pathlib import Path +module = Path(sys.argv[1]) +source = (module / "ios/T3ReviewDiffView.swift").read_text() +source = source.replace("import ExpoModulesCore\n", "") +stubs = """ +public final class AppContext {} +open class ExpoView: UIView { + public required init(appContext: AppContext? = nil) { super.init(frame: .zero) } + @available(*, unavailable) + public required init?(coder: NSCoder) { fatalError("init(coder:) is unused") } +} +final class EventDispatcher { + func callAsFunction(_ payload: [String: Any]) {} +} +""" +# Expo alone is stubbed; keep the real scroll container as well as the content view. +Path(sys.argv[2]).write_text(source + "\n" + stubs + "\n" + + (module / "tests/ios/main.swift").read_text() + "\n" + + (module / "tests/ios/anchor.swift").read_text() + "\n" + + (module / "tests/ios/geometry.swift").read_text()) +PYTHON "$compiler" -O -target "$(uname -m)-apple-ios16.1-macabi" -sdk "$sdk" \ -F "$sdk/System/iOSSupport/System/Library/Frameworks" \ - "$module_dir/ios/ReviewDiffCodeLayout.swift" "$module_dir/tests/ios/main.swift" \ + "$module_dir/ios/ReviewDiffCodeLayout.swift" "$output/main.swift" \ -o "$output/layout-tests" "$output/layout-tests" diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts index aa9275745ce14..c418d528129a6 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts @@ -1,13 +1,36 @@ -import { describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { highlightCode } from "../../lib/highlights"; import type { NativeReviewDiffRow } from "./nativeReviewDiffSurface"; import type { NativeReviewDiffFile } from "./nativeReviewDiffTypes"; import { highlightNativeReviewDiffVisibleRows } from "./nativeReviewDiffHighlighter"; +import { buildNativeReviewDiffData } from "../review/nativeReviewDiffAdapter"; +import { buildReviewParsedDiff } from "../review/reviewModel"; + +const tokenization = vi.hoisted(() => ({ + calls: [] as string[], + afterCall: undefined as (() => void) | undefined, +})); vi.mock("expo", async () => { const { createWasmHighlightsModule } = await import("../../lib/highlights.testing"); - const highlights = createWasmHighlightsModule(); - return { requireOptionalNativeModule: () => highlights }; + const module = createWasmHighlightsModule(); + return { + requireOptionalNativeModule: () => ({ + tokenize(code: string, language: number) { + tokenization.calls.push(code); + const records = module.tokenize(code, language); + tokenization.afterCall?.(); + return records; + }, + }), + }; +}); + +afterEach(() => { + tokenization.calls = []; + tokenization.afterCall = undefined; }); const TYPESCRIPT_FILE: NativeReviewDiffFile = { @@ -54,7 +77,128 @@ function highlight( } describe("highlightNativeReviewDiffVisibleRows", () => { - it("does not carry grammar state across hunk boundaries", () => { + it("keeps overscan within the visible files, including a file header at the viewport edge", async () => { + const loadedFiles: string[] = []; + const files = Array.from({ length: 14 }, (_, index) => { + const id = `file-${index}`; + const source = Array.from({ length: 3 }, (_, line) => `const value${line} = ${line};`); + return { + ...TYPESCRIPT_FILE, + id, + loadContents: async () => { + loadedFiles.push(id); + return { oldContents: "", newContents: source.join("\n") }; + }, + rows: [ + { kind: "file", id: `${id}-header`, fileId: id }, + ...source.map((content, line) => ({ + ...makeLine({ + id: `${id}-line-${line + 1}`, + content, + change: "add", + oldLineNumber: null, + newLineNumber: line + 1, + }), + fileId: id, + })), + ] satisfies NativeReviewDiffRow[], + }; + }); + const input = { + rows: files.flatMap((file) => file.rows), + files, + scheme: "dark" as const, + firstRowIndex: 30, + lastRowIndex: 32, + }; + const viewport = await highlightNativeReviewDiffVisibleRows({ ...input, overscanRows: 0 }); + const visibleIds = ["file-7-line-2", "file-7-line-3"]; + expect(Object.keys(viewport.tokensByRowId)).toEqual(visibleIds); + const overscan = await highlightNativeReviewDiffVisibleRows({ + ...input, + overscanRows: 160, + alreadyHighlightedRowIds: new Set(visibleIds), + }); + expect(Object.keys(overscan.tokensByRowId).sort()).toEqual([ + "file-7-line-1", + "file-8-line-1", + "file-8-line-2", + "file-8-line-3", + ]); + for (const tokens of Object.values({ ...viewport.tokensByRowId, ...overscan.tokensByRowId })) { + expect(tokens.some((token) => token.color !== null)).toBe(true); + } + expect([...new Set(loadedFiles)].sort()).toEqual(["file-7", "file-8"]); + }); + + it("delivers the dominant viewport file before fetching a smaller blocked neighbor", async () => { + const neighborStarted = Promise.withResolvers(); + const neighborResponse = Promise.withResolvers<{ oldContents: string; newContents: string }>(); + const source = Array.from({ length: 27 }, (_, index) => `const value${index} = true;`).join( + "\n", + ); + const chunks = vi.fn(); + const rows = [ + ...Array.from({ length: 5 }, (_, index) => ({ + ...makeLine({ + id: `neighbor-${index}`, + content: `const value${index} = true;`, + change: "add", + oldLineNumber: null, + newLineNumber: index + 1, + }), + fileId: "neighbor", + })), + ...Array.from({ length: 27 }, (_, index) => + makeLine({ + id: `selected-${index}`, + content: `const value${index} = true;`, + change: "delete", + oldLineNumber: index + 1, + newLineNumber: null, + }), + ), + ]; + const highlighting = highlightNativeReviewDiffVisibleRows({ + rows, + files: [ + { + ...TYPESCRIPT_FILE, + id: "neighbor", + loadContents: () => { + neighborStarted.resolve(); + return neighborResponse.promise; + }, + }, + { + ...TYPESCRIPT_FILE, + loadContents: async () => ({ oldContents: source, newContents: "" }), + }, + ], + scheme: "dark", + firstRowIndex: 0, + lastRowIndex: rows.length - 1, + overscanRows: 0, + onChunk: chunks, + }); + try { + await neighborStarted.promise; + expect(chunks).toHaveBeenCalledTimes(1); + expect(Object.keys(chunks.mock.calls[0]![0].tokensByRowId)).toEqual( + Array.from({ length: 27 }, (_, index) => `selected-${index}`), + ); + expect( + chunks.mock.calls[0]![0].tokensByRowId["selected-0"].some( + (token: { color: string | null }) => token.color !== null, + ), + ).toBe(true); + } finally { + neighborResponse.resolve({ oldContents: "", newContents: source }); + await highlighting; + } + }); + + it("does not carry grammar state across hunk boundaries", async () => { const exportRow = makeLine({ id: "export-row", content: "export async function run() {}", @@ -65,23 +209,32 @@ describe("highlightNativeReviewDiffVisibleRows", () => { const rows = [ makeHunk("hunk-1"), makeLine({ - id: "template-open", - content: "const message = `open", + id: "import-open", + content: "import {", change: "context", oldLineNumber: 1, newLineNumber: 1, }), + makeLine({ + id: "import-entry", + content: " Model,", + change: "context", + oldLineNumber: 2, + newLineNumber: 2, + }), makeHunk("hunk-2"), exportRow, ]; - const highlighted = highlight(rows); - const standalone = highlight([makeHunk("standalone-hunk"), exportRow]); + const [highlighted, standalone] = await Promise.all([ + highlight(rows), + highlight([makeHunk("standalone-hunk"), exportRow]), + ]); expect(highlighted.tokensByRowId[exportRow.id]).toEqual(standalone.tokensByRowId[exportRow.id]); }); - it("keeps grammar state across inline comment rows", () => { + it("keeps grammar state across inline comment rows", async () => { const openingRow = makeLine({ id: "template-open", content: "const message = `open", @@ -110,16 +263,15 @@ describe("highlightNativeReviewDiffVisibleRows", () => { commentText: "Review note", }; - const withComment = highlight([openingRow, commentRow, closingRow, trailingRow]); - const contiguous = highlight([openingRow, closingRow, trailingRow]); + const [withComment, contiguous] = await Promise.all([ + highlight([openingRow, commentRow, closingRow, trailingRow]), + highlight([openingRow, closingRow, trailingRow]), + ]); expect(withComment.tokensByRowId).toEqual(contiguous.tokensByRowId); - expect(withComment.tokensByRowId[closingRow.id]?.[0]?.color).toBe( - withComment.tokensByRowId[openingRow.id]?.at(-1)?.color, - ); }); - it("does not join unhighlighted rows across cached gaps", () => { + it("does not join unhighlighted rows across cached gaps", async () => { const trailingRow = makeLine({ id: "trailing-row", content: "export const answer = 42;", @@ -145,11 +297,425 @@ describe("highlightNativeReviewDiffVisibleRows", () => { trailingRow, ]; - const highlighted = highlight(rows, new Set(["template-close"])); - const standalone = highlight([trailingRow]); + const [highlighted, standalone] = await Promise.all([ + highlight(rows, new Set(["template-close"])), + highlight([trailingRow]), + ]); expect(highlighted.tokensByRowId[trailingRow.id]).toEqual( standalone.tokensByRowId[trailingRow.id], ); }); }); + +// These complete sources are independent of the diff row selection below. +const OLD_SOURCE = [ + "export function render(name: string) {", + " const heading = `Hello ${name}", + "Welcome to the archive.", + "Your saved reports follow.", + " `;", + " /* Historical formatting rules:", + " * Keep the title and summary together.", + " * Preserve links in the footer.", + " */", + " return heading.trim();", + "}", +]; +const NEW_SOURCE = [ + "export function render(name: string) {", + " const heading = `Greetings ${name}", + "Welcome to the workspace.", + "Your saved reports follow.", + " `;", + " /* Current formatting rules:", + " * Keep the title and summary together.", + " * Highlight links in the footer.", + " */", + " return heading.trim();", + "}", +]; + +function authoritativeTokens(source: ReadonlyArray) { + return highlightCode(source.join("\n"), "typescript", "dark"); +} + +function sourceRow(change: "context" | "add" | "delete", lineNumber: number) { + return makeLine({ + id: `${change}-${lineNumber}`, + content: (change === "delete" ? OLD_SOURCE : NEW_SOURCE)[lineNumber - 1]!, + change, + oldLineNumber: change === "add" ? null : lineNumber, + newLineNumber: change === "delete" ? null : lineNumber, + }); +} + +function completeDiffRows() { + return [ + sourceRow("context", 1), + sourceRow("delete", 2), + sourceRow("delete", 3), + sourceRow("add", 2), + sourceRow("add", 3), + sourceRow("context", 4), + sourceRow("context", 5), + sourceRow("delete", 6), + sourceRow("add", 6), + sourceRow("context", 7), + sourceRow("delete", 8), + sourceRow("add", 8), + sourceRow("context", 9), + sourceRow("context", 10), + sourceRow("context", 11), + ]; +} + +describe("source context", () => { + it("highlights parsed CRLF patch rows using omitted full-source templates and comments", async () => { + const newSource = [...OLD_SOURCE]; + newSource[2] = NEW_SOURCE[2]!; + newSource[7] = NEW_SOURCE[7]!; + const parsed = buildReviewParsedDiff( + [ + "diff --git a/example.ts b/example.ts", + "--- a/example.ts", + "+++ b/example.ts", + "@@ -3,2 +3,2 @@", + `-${OLD_SOURCE[2]}\r`, + `+${newSource[2]}\r`, + ` ${newSource[3]}\r`, + "@@ -7,3 +7,3 @@", + ` ${newSource[6]}\r`, + `-${OLD_SOURCE[7]}\r`, + `+${newSource[7]}\r`, + ` ${newSource[8]}\r`, + "", + ].join("\n"), + "crlf-context", + ); + expect(parsed.kind).toBe("files"); + const { rows, files } = buildNativeReviewDiffData(parsed); + const lineRows = rows.filter((row) => row.kind === "line"); + expect(lineRows).toHaveLength(7); + const originalContents = lineRows.map((row) => row.content); + expect(originalContents.every((content) => content?.endsWith("\r"))).toBe(true); + const oldTokens = await authoritativeTokens(OLD_SOURCE); + const newTokens = await authoritativeTokens(newSource); + const result = await highlightNativeReviewDiffVisibleRows({ + rows, + files: files.map((file) => ({ + ...file, + loadContents: async () => ({ + oldContents: `${OLD_SOURCE.join("\r\n")}\r\n`, + newContents: `${newSource.join("\r\n")}\r\n`, + }), + })), + scheme: "dark", + firstRowIndex: 0, + lastRowIndex: rows.length - 1, + overscanRows: 0, + }); + for (const row of lineRows) { + const expected = + row.change === "delete" + ? oldTokens[row.oldLineNumber! - 1] + : newTokens[row.newLineNumber! - 1]; + const tokens = result.tokensByRowId[row.id]; + expect(tokens, row.id).toEqual(expected); + expect( + tokens?.every((token) => token.color !== null), + row.id, + ).toBe(true); + expect(tokens?.[0]?.color, row.id).toBe( + (row.newLineNumber ?? row.oldLineNumber)! <= 4 ? "#5ecc71" : "#737373", + ); + } + expect(lineRows.map((row) => row.content)).toEqual(originalContents); + expect(result.retryableRowIds).toEqual([]); + }); + + it("matches full OLD/NEW source across replacement runs and shared multiline context", async () => { + const rows = completeDiffRows(); + const oldTokens = await authoritativeTokens(OLD_SOURCE); + const newTokens = await authoritativeTokens(NEW_SOURCE); + const result = await highlightNativeReviewDiffVisibleRows({ + rows, + files: [TYPESCRIPT_FILE], + scheme: "dark", + firstRowIndex: 0, + lastRowIndex: rows.length - 1, + overscanRows: 0, + }); + for (const row of rows) { + const expected = + row.change === "delete" + ? oldTokens[row.oldLineNumber! - 1] + : newTokens[row.newLineNumber! - 1]; + expect(result.tokensByRowId[row.id], row.id).toEqual(expected); + } + }); + + it("uses NEW grammar for shared rows when OLD remains inside a comment", async () => { + const oldSource = ["/* Retired renderer", "const value = 3;", "finish();"]; + const newSource = ["// Active renderer", "const value = 3;", "finish();"]; + const rows = [ + makeLine({ + id: "old-open", + content: oldSource[0], + change: "delete", + oldLineNumber: 1, + newLineNumber: null, + }), + makeLine({ + id: "new-open", + content: newSource[0], + change: "add", + oldLineNumber: null, + newLineNumber: 1, + }), + makeLine({ + id: "shared", + content: newSource[1], + change: "context", + oldLineNumber: 2, + newLineNumber: 2, + }), + makeLine({ + id: "old-tail", + content: oldSource[2], + change: "delete", + oldLineNumber: 3, + newLineNumber: null, + }), + makeLine({ + id: "new-tail", + content: newSource[2], + change: "add", + oldLineNumber: null, + newLineNumber: 3, + }), + ]; + const oldTokens = await authoritativeTokens(oldSource); + const newTokens = await authoritativeTokens(newSource); + const result = await highlightNativeReviewDiffVisibleRows({ + rows, + files: [TYPESCRIPT_FILE], + scheme: "dark", + firstRowIndex: 0, + lastRowIndex: rows.length - 1, + overscanRows: 0, + }); + expect(oldTokens[1]).not.toEqual(newTokens[1]); + expect(result.tokensByRowId.shared).toEqual(newTokens[1]); + expect(result.tokensByRowId["old-tail"]).toEqual(oldTokens[2]); + expect(result.tokensByRowId["new-tail"]).toEqual(newTokens[2]); + }); + + it("leaves mismatched and unavailable source revisions plain", async () => { + const rows = [sourceRow("add", 3)]; + for (const source of [null, { oldContents: "", newContents: "different revision" }]) { + const result = await highlightNativeReviewDiffVisibleRows({ + rows, + files: [{ ...TYPESCRIPT_FILE, loadContents: async () => source }], + scheme: "dark", + firstRowIndex: 0, + lastRowIndex: 0, + overscanRows: 0, + }); + expect(result.tokensByRowId[rows[0]!.id]).toEqual([ + { content: NEW_SOURCE[2], color: null, fontStyle: null }, + ]); + } + }); +}); + +it("reuses full native records across viewport and theme changes and evicts old entries", async () => { + const sources = Array.from({ length: 9 }, (_, i) => ({ + oldContents: "", + newContents: `/* ${i}\ninside\n*/\nconst tail = 2;`, + })); + const rows = [ + makeLine({ + id: "body", + content: "inside", + change: "add", + oldLineNumber: null, + newLineNumber: 2, + }), + makeLine({ + id: "tail", + content: "const tail = 2;", + change: "add", + oldLineNumber: null, + newLineNumber: 4, + }), + ]; + const input = { + rows, + scheme: "dark" as const, + firstRowIndex: 0, + lastRowIndex: 0, + overscanRows: 0, + }; + const files = [{ ...TYPESCRIPT_FILE, loadContents: async () => sources[0]! }]; + const initial = await highlightNativeReviewDiffVisibleRows({ ...input, files }); + const moved = await highlightNativeReviewDiffVisibleRows({ + ...input, + files, + firstRowIndex: 1, + lastRowIndex: 1, + }); + await highlightNativeReviewDiffVisibleRows({ ...input, files, scheme: "light" }); + expect(tokenization.calls).toEqual([sources[0]!.newContents]); + expect(initial.tokensByRowId.body).toEqual( + highlightCode(sources[0]!.newContents, "typescript", "dark")[1], + ); + expect(moved.tokensByRowId.tail).toEqual( + highlightCode(sources[0]!.newContents, "typescript", "dark")[3], + ); + tokenization.calls = []; + for (const source of sources.slice(1)) { + await highlightNativeReviewDiffVisibleRows({ + ...input, + files: [{ ...TYPESCRIPT_FILE, loadContents: async () => source }], + }); + } + await highlightNativeReviewDiffVisibleRows({ ...input, files }); + expect(tokenization.calls).toHaveLength(9); + expect(tokenization.calls.at(-1)).toBe(sources[0]!.newContents); +}); + +it("reuses segment records across themes and replaces records when the language changes", async () => { + const rows = [ + makeLine({ + id: "line", + content: "const value = 1;", + change: "add", + oldLineNumber: null, + newLineNumber: 1, + }), + ]; + const input = { + rows, + files: [TYPESCRIPT_FILE], + scheme: "dark" as const, + firstRowIndex: 0, + lastRowIndex: 0, + overscanRows: 0, + }; + const first = await highlightNativeReviewDiffVisibleRows(input); + await highlightNativeReviewDiffVisibleRows({ ...input, scheme: "light" }); + expect(tokenization.calls).toEqual([rows[0]!.content]); + await highlightNativeReviewDiffVisibleRows({ + ...input, + files: [{ ...TYPESCRIPT_FILE, language: "c" }], + }); + const restored = await highlightNativeReviewDiffVisibleRows(input); + expect(tokenization.calls).toHaveLength(3); + expect(restored.tokensByRowId).toEqual(first.tokensByRowId); +}); + +it("uses full grammar after an oversized line and expands only selected rows in a 100k-line C source", async () => { + const contents = [ + " ".repeat(1_001) + "/*", + ...Array.from({ length: 99_997 }, (_, i) => `comment ${i}: int value = 1;`), + "*/", + "int done = 2;", + ].join("\n"); + expect(contents.length).toBeGreaterThan(500_000); + const source = { oldContents: "", newContents: contents }; + const rows = [ + makeLine({ + id: "body", + content: "comment 99996: int value = 1;", + change: "add", + oldLineNumber: null, + newLineNumber: 99_998, + }), + makeLine({ + id: "end", + content: "int done = 2;", + change: "add", + oldLineNumber: null, + newLineNumber: 100_000, + }), + ]; + const input = { + rows, + files: [{ ...TYPESCRIPT_FILE, language: "c", loadContents: async () => source }], + scheme: "dark" as const, + firstRowIndex: 0, + lastRowIndex: 0, + overscanRows: 0, + }; + const result = await highlightNativeReviewDiffVisibleRows(input); + expect(Object.keys(result.tokensByRowId)).toEqual(["body"]); + expect(result.tokensByRowId.body).toEqual([ + { content: rows[0]!.content, color: "#737373", fontStyle: 0 }, + ]); + const moved = await highlightNativeReviewDiffVisibleRows({ + ...input, + firstRowIndex: 1, + lastRowIndex: 1, + }); + expect(moved.tokensByRowId.end?.some((token) => token.color !== "#737373")).toBe(true); + expect(tokenization.calls).toEqual([contents]); +}); + +it("publishes no cancelled file chunk and never tokenizes its next side", async () => { + const controller = new AbortController(); + tokenization.afterCall = () => controller.abort(); + const chunks = vi.fn(); + const result = await highlightNativeReviewDiffVisibleRows({ + rows: [sourceRow("delete", 3), sourceRow("add", 3)], + files: [ + { + ...TYPESCRIPT_FILE, + loadContents: async () => ({ + oldContents: OLD_SOURCE.join("\n"), + newContents: NEW_SOURCE.join("\n"), + }), + }, + ], + scheme: "dark", + firstRowIndex: 0, + lastRowIndex: 1, + overscanRows: 0, + signal: controller.signal, + onChunk: chunks, + }); + expect(tokenization.calls).toHaveLength(1); + expect(chunks).not.toHaveBeenCalled(); + expect(result.tokensByRowId).toEqual({}); + expect(result.rowCount).toBe(0); +}); + +it("does not tokenize or publish a source that arrives after cancellation", async () => { + const controller = new AbortController(); + const started = Promise.withResolvers(); + const response = Promise.withResolvers<{ oldContents: string; newContents: string }>(); + const chunks = vi.fn(); + const work = highlightNativeReviewDiffVisibleRows({ + rows: [sourceRow("add", 3)], + files: [ + { + ...TYPESCRIPT_FILE, + loadContents: () => { + started.resolve(); + return response.promise; + }, + }, + ], + scheme: "dark", + firstRowIndex: 0, + lastRowIndex: 0, + signal: controller.signal, + onChunk: chunks, + }); + await started.promise; + controller.abort(); + response.resolve({ oldContents: "", newContents: NEW_SOURCE.join("\n") }); + expect((await work).rowCount).toBe(0); + expect(tokenization.calls).toEqual([]); + expect(chunks).not.toHaveBeenCalled(); +}); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts index dd94f6fb608b0..a4084d2ccaa14 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts @@ -1,6 +1,7 @@ -import { highlightCode, type HighlightScheme } from "../../lib/highlights"; -import type { NativeReviewDiffFile } from "./nativeReviewDiffTypes"; +import { tokenizeCode, type HighlightScheme, type TokenizedCode } from "../../lib/highlights"; +import type { NativeReviewDiffFile, NativeReviewDiffSource } from "./nativeReviewDiffTypes"; import type { NativeReviewDiffRow, NativeReviewDiffToken } from "./nativeReviewDiffSurface"; +import { diffHighlightTraceId, traceDiffHighlight } from "./nativeReviewDiffTrace"; interface NativeReviewDiffLineRow extends NativeReviewDiffRow { readonly kind: "line"; @@ -8,11 +9,6 @@ interface NativeReviewDiffLineRow extends NativeReviewDiffRow { readonly content: string; } -interface IndexedNativeReviewDiffLineRow { - readonly row: NativeReviewDiffLineRow; - readonly rowIndex: number; -} - export interface HighlightNativeReviewDiffVisibleRowsInput { readonly rows: ReadonlyArray; readonly files: ReadonlyArray; @@ -22,12 +18,19 @@ export interface HighlightNativeReviewDiffVisibleRowsInput { readonly overscanRows?: number; readonly maxRows?: number; readonly alreadyHighlightedRowIds?: ReadonlySet; + readonly signal?: AbortSignal; + readonly onChunk?: (chunk: NativeReviewDiffHighlightChunk) => void; +} + +export interface NativeReviewDiffHighlightChunk { + readonly tokensByRowId: Record>; + readonly retryableRowIds: ReadonlyArray; + readonly rowCount: number; + readonly durationMs: number; } -// Bounds each token patch the native view parses, not the highlighting itself. const NATIVE_REVIEW_DIFF_VISIBLE_OVERSCAN_ROWS = 160; const NATIVE_REVIEW_DIFF_VISIBLE_MAX_ROWS = 360; - function isHighlightableLineRow(row: NativeReviewDiffRow): row is NativeReviewDiffLineRow { return row.kind === "line" && typeof row.fileId === "string" && typeof row.content === "string"; } @@ -39,49 +42,186 @@ function hasConsecutiveLineNumbers( return typeof previous === "number" && typeof next === "number" && next === previous + 1; } -function hasOnlyCommentRowsBetween( - rows: ReadonlyArray, - previousRowIndex: number, - nextRowIndex: number, -): boolean { - for (let rowIndex = previousRowIndex + 1; rowIndex < nextRowIndex; rowIndex += 1) { - if (rows[rowIndex]?.kind !== "comment") { - return false; +// Native lexing is non-incremental. Cache theme-independent raw records, never +// expanded token objects for an entire large file. Bound both entry count and bytes. +const MAX_TOKEN_CACHE_BYTES = 64 * 1024 * 1024; +const MAX_TOKEN_CACHE_ENTRIES = 8; +// LRU entries own only token data; source contexts and diff rows can collect independently. +const tokenCacheKeys = new WeakMap(); +const tokenCache = new Map(); +let tokenCacheBytes = 0; + +function getTokenCacheKey(context: object): object { + let key = tokenCacheKeys.get(context); + if (!key) { + key = {}; + tokenCacheKeys.set(context, key); + } + return key; +} + +function getCachedTokens( + cacheKey: object, + contents: string | (() => string), + language: string, +): TokenizedCode { + const cached = tokenCache.get(cacheKey); + if (cached) { + tokenCache.delete(cacheKey); + tokenCacheBytes -= cached.tokens.byteLength; + if (cached.language === language) { + tokenCache.set(cacheKey, cached); + tokenCacheBytes += cached.tokens.byteLength; + return cached.tokens; } } - return true; + const tokens = tokenizeCode(typeof contents === "string" ? contents : contents(), language); + if (tokens.byteLength <= MAX_TOKEN_CACHE_BYTES) { + tokenCache.set(cacheKey, { language, tokens }); + tokenCacheBytes += tokens.byteLength; + } + while (tokenCacheBytes > MAX_TOKEN_CACHE_BYTES || tokenCache.size > MAX_TOKEN_CACHE_ENTRIES) { + const oldest = tokenCache.keys().next().value!; + tokenCacheBytes -= tokenCache.get(oldest)!.tokens.byteLength; + tokenCache.delete(oldest); + } + return tokens; } -function canShareGrammarContext( - previous: IndexedNativeReviewDiffLineRow, - next: IndexedNativeReviewDiffLineRow, - rows: ReadonlyArray, -): boolean { - if ( - next.row.fileId !== previous.row.fileId || - !hasOnlyCommentRowsBetween(rows, previous.rowIndex, next.rowIndex) - ) { - return false; +const fullSourceContexts = new WeakMap< + NativeReviewDiffSource, + { old: FullSourceContext; new: FullSourceContext } +>(); + +interface FullSourceContext { + readonly contents: string; + readonly comparisonLines: ReadonlyArray; + readonly validation: WeakMap, boolean>; +} + +const sourceRowsByFileCache = new WeakMap< + ReadonlyArray, + Map +>(); + +function getSourceRowsByFile(rows: ReadonlyArray) { + let byFile = sourceRowsByFileCache.get(rows); + if (!byFile) { + byFile = new Map(); + for (const row of rows) { + if (!isHighlightableLineRow(row)) continue; + const fileRows = byFile.get(row.fileId); + if (fileRows) fileRows.push(row); + else byFile.set(row.fileId, [row]); + } + sourceRowsByFileCache.set(rows, byFile); } + return byFile; +} - if (previous.row.change === "delete" || next.row.change === "delete") { +function matchesSourceSide( + context: FullSourceContext, + rows: ReadonlyArray, + side: DiffSide, +) { + const cached = context.validation.get(rows); + if (cached !== undefined) return cached; + // Context participates on both sides, even though it displays NEW tokens. + // A known mismatch anywhere invalidates grammar from the entire source side. + const matches = rows.every((row) => { + if (row.change === (side === "old" ? "add" : "delete")) return true; + const lineNumber = side === "old" ? row.oldLineNumber : row.newLineNumber; return ( - previous.row.change !== "add" && - next.row.change !== "add" && - hasConsecutiveLineNumbers(previous.row.oldLineNumber, next.row.oldLineNumber) + typeof lineNumber === "number" && context.comparisonLines[lineNumber - 1] === row.content ); + }); + context.validation.set(rows, matches); + return matches; +} + +function createFullSourceContext(contents: string): FullSourceContext { + return { + contents, + // The diff parser retains CR on patch rows, unlike the token output. + comparisonLines: contents.split("\n"), + validation: new WeakMap(), + }; +} + +function getFullSourceContexts(source: NativeReviewDiffSource) { + let contexts = fullSourceContexts.get(source); + if (!contexts) { + contexts = { + old: createFullSourceContext(source.oldContents), + new: createFullSourceContext(source.newContents), + }; + fullSourceContexts.set(source, contexts); } + return contexts; +} + +type DiffSide = "old" | "new"; - if (previous.row.change === "add" || next.row.change === "add") { - return hasConsecutiveLineNumbers(previous.row.newLineNumber, next.row.newLineNumber); +interface SourceSegment { + readonly fileId: string; + readonly side: DiffSide; + readonly rows: NativeReviewDiffLineRow[]; +} + +// Row arrays are immutable snapshots; comments and hidden hunks retain source context. +const sourceSegmentsCache = new WeakMap< + ReadonlyArray, + Map +>(); + +function getSourceSegments(rows: ReadonlyArray) { + const cached = sourceSegmentsCache.get(rows); + if (cached) return cached; + + const segmentsByRowId = new Map< + string, + { readonly segment: SourceSegment; readonly index: number } + >(); + for (const side of ["old", "new"] as const) { + let segment: SourceSegment | undefined; + for (const row of rows) { + if (row.kind === "comment" || row.kind === "hunk") continue; + if (!isHighlightableLineRow(row)) { + segment = undefined; + continue; + } + if (row.change === (side === "old" ? "add" : "delete")) continue; + + const previous = segment?.rows.at(-1); + const lineNumber = side === "old" ? row.oldLineNumber : row.newLineNumber; + const previousLineNumber = side === "old" ? previous?.oldLineNumber : previous?.newLineNumber; + if ( + !segment || + segment.fileId !== row.fileId || + !hasConsecutiveLineNumbers(previousLineNumber, lineNumber) + ) { + // A gap has no known incoming grammar state. Highlight the available + // fragment independently; never join it to an earlier, incomplete hunk. + segment = { fileId: row.fileId, side, rows: [] }; + } + if (displaySide(row) === side) { + segmentsByRowId.set(row.id, { segment, index: segment.rows.length }); + } + segment.rows.push(row); + } } + sourceSegmentsCache.set(rows, segmentsByRowId); + return segmentsByRowId; +} - return ( - previous.row.change === "context" && - next.row.change === "context" && - hasConsecutiveLineNumbers(previous.row.oldLineNumber, next.row.oldLineNumber) && - hasConsecutiveLineNumbers(previous.row.newLineNumber, next.row.newLineNumber) - ); +function displaySide(row: NativeReviewDiffLineRow): DiffSide { + // Unified context rows have one token array. Match the NEW side, while OLD + // context still advances deletion grammar independently. + return row.change === "delete" ? "old" : "new"; +} + +function createFileMap(files: ReadonlyArray) { + return new Map(files.map((file) => [file.id, file])); } function clampRowIndex(index: number, rows: ReadonlyArray) { @@ -92,18 +232,53 @@ function clampRowIndex(index: number, rows: ReadonlyArray) return Math.min(rows.length - 1, Math.max(0, Math.floor(index))); } -export function highlightNativeReviewDiffVisibleRows( +function makePlainTokenFallback( + row: NativeReviewDiffLineRow, +): ReadonlyArray { + return [{ content: row.content || " ", color: null, fontStyle: null }]; +} + +export async function highlightNativeReviewDiffVisibleRows( input: HighlightNativeReviewDiffVisibleRowsInput, -): { +): Promise<{ readonly tokensByRowId: Record>; + // Deliver plain tokens for these rows without marking them permanently highlighted. + readonly retryableRowIds: ReadonlyArray; readonly rowCount: number; -} { - const fileMap = new Map(input.files.map((file) => [file.id, file])); + readonly durationMs: number; +}> { + const request = diffHighlightTraceId(input); + traceDiffHighlight("highlight-start", { + request, + first: input.firstRowIndex, + last: input.lastRowIndex, + overscan: input.overscanRows ?? NATIVE_REVIEW_DIFF_VISIBLE_OVERSCAN_ROWS, + }); + if (input.signal?.aborted) { + return { + tokensByRowId: {}, + retryableRowIds: [], + rowCount: 0, + durationMs: 0, + }; + } + + const startedAt = performance.now(); + const fileMap = createFileMap(input.files); const overscanRows = input.overscanRows ?? NATIVE_REVIEW_DIFF_VISIBLE_OVERSCAN_ROWS; const maxRows = input.maxRows ?? NATIVE_REVIEW_DIFF_VISIBLE_MAX_ROWS; const startIndex = clampRowIndex(input.firstRowIndex - overscanRows, input.rows); const endIndex = clampRowIndex(input.lastRowIndex + overscanRows, input.rows); - const selectedRows: IndexedNativeReviewDiffLineRow[] = []; + const visibleStartIndex = clampRowIndex(input.firstRowIndex, input.rows); + const visibleEndIndex = clampRowIndex(input.lastRowIndex, input.rows); + const visibleFileIds = new Set(); + // Overscan warms visible files without loading unrelated neighboring sources. + // Headers and already-highlighted rows still identify files in the viewport. + for (let rowIndex = visibleStartIndex; rowIndex <= visibleEndIndex; rowIndex += 1) { + const fileId = input.rows[rowIndex]?.fileId; + if (typeof fileId === "string") visibleFileIds.add(fileId); + } + const selectedRows: NativeReviewDiffLineRow[] = []; for ( let rowIndex = startIndex; @@ -114,40 +289,180 @@ export function highlightNativeReviewDiffVisibleRows( if ( row && isHighlightableLineRow(row) && + visibleFileIds.has(row.fileId) && !input.alreadyHighlightedRowIds?.has(row.id) && fileMap.has(row.fileId) ) { - selectedRows.push({ row, rowIndex }); + selectedRows.push(row); } } - // Rows that continue each other's source are highlighted together so - // multi-line constructs keep their colors. const tokensByRowId: Record> = {}; - let segment: IndexedNativeReviewDiffLineRow[] = []; - const flushSegment = () => { - const file = segment[0] ? fileMap.get(segment[0].row.fileId) : undefined; - if (file) { - const tokenLines = highlightCode( - segment.map(({ row }) => row.content).join("\n"), - file.language, - input.scheme, - ); - segment.forEach(({ row }, index) => { - tokensByRowId[row.id] = tokenLines[index] ?? []; + const retryableRowIds: string[] = []; + const selectedRowIds = new Set(selectedRows.map((row) => row.id)); + const tokenize = (context: object, contents: string | (() => string), language: string) => { + const cacheKey = getTokenCacheKey(context); + const cacheHit = tokenCache.get(cacheKey)?.language === language; + const cached = getCachedTokens(cacheKey, contents, language); + traceDiffHighlight("tokenized", { + request, + characters: typeof contents === "string" ? contents.length : undefined, + cacheHit, + retainedBytes: cached.byteLength, + lines: cached.lineCount, + aborted: input.signal?.aborted ?? false, + }); + return cached; + }; + + const sourceFiles = new Map(); + for (const row of selectedRows) { + if (!fileMap.get(row.fileId)?.loadContents) continue; + const fileRows = sourceFiles.get(row.fileId); + if (fileRows) fileRows.push(row); + else sourceFiles.set(row.fileId, [row]); + } + const emitFileChunk = (fileRows: ReadonlyArray) => { + if (!input.onChunk || input.signal?.aborted) return; + const rowIds = new Set(fileRows.map((row) => row.id)); + input.onChunk({ + tokensByRowId: Object.fromEntries(fileRows.map((row) => [row.id, tokensByRowId[row.id]!])), + retryableRowIds: retryableRowIds.filter((id) => rowIds.has(id)), + rowCount: fileRows.length, + durationMs: Math.round(performance.now() - startedAt), + }); + }; + // The file filling the viewport should not wait for a sliver of its neighbor. + for (const [fileId, fileRows] of [...sourceFiles].sort((a, b) => b[1].length - a[1].length)) { + if (input.signal?.aborted) break; + const file = fileMap.get(fileId)!; + const fileTraceId = diffHighlightTraceId(fileId); + traceDiffHighlight("highlight-source", { request, file: fileTraceId, rows: fileRows.length }); + let source: NativeReviewDiffSource | null; + try { + source = await file.loadContents!(input.signal); + } catch { + traceDiffHighlight("highlight-source-error", { request, file: fileTraceId }); + for (const row of fileRows) { + selectedRowIds.delete(row.id); + tokensByRowId[row.id] = makePlainTokenFallback(row); + retryableRowIds.push(row.id); + } + emitFileChunk(fileRows); + continue; + } + if (input.signal?.aborted) break; + const contexts = source ? getFullSourceContexts(source) : null; + for (const side of ["old", "new"] as const) { + if (input.signal?.aborted) break; + const candidate = contexts?.[side]; + const context = + candidate && + matchesSourceSide(candidate, getSourceRowsByFile(input.rows).get(fileId)!, side) + ? candidate + : null; + traceDiffHighlight("source-validation", { + request, + file: fileTraceId, + side, + present: !!candidate, + matches: !!context, + knownLines: context?.comparisonLines.length, }); + const matchingRows: { row: NativeReviewDiffLineRow; lineIndex: number }[] = []; + for (const row of fileRows) { + if (displaySide(row) !== side) continue; + selectedRowIds.delete(row.id); + const lineNumber = side === "old" ? row.oldLineNumber : row.newLineNumber; + const lineIndex = typeof lineNumber === "number" ? lineNumber - 1 : -1; + if ( + context && + lineIndex >= 0 && + lineIndex < context.comparisonLines.length && + context.comparisonLines[lineIndex] === row.content + ) { + matchingRows.push({ row, lineIndex }); + } else { + // A changed revision or missing source cannot + // supply trustworthy grammar. Do not cache guessed syntax colors. + tokensByRowId[row.id] = makePlainTokenFallback(row); + } + } + if (!context || matchingRows.length === 0) continue; + const tokens = tokenize(context, context.contents, file.language); + for (const { row, lineIndex } of matchingRows) { + tokensByRowId[row.id] = + tokens.highlightLine(lineIndex, input.scheme) ?? makePlainTokenFallback(row); + } } - segment = []; - }; + emitFileChunk(fileRows); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + const selectedSegments = new Map< + SourceSegment, + { firstSelected: number; lastSelected: number } + >(); + if (selectedRowIds.size > 0 && !input.signal?.aborted) { + const sourceSegments = getSourceSegments(input.rows); + for (const row of selectedRows) { + if (!selectedRowIds.has(row.id)) continue; + const source = sourceSegments.get(row.id); + if (!source) continue; + const selection = selectedSegments.get(source.segment); + if (selection) { + selection.lastSelected = source.index; + } else { + selectedSegments.set(source.segment, { + firstSelected: source.index, + lastSelected: source.index, + }); + } + } + } - for (const selectedRow of selectedRows) { - const previousRow = segment.at(-1); - if (previousRow && !canShareGrammarContext(previousRow, selectedRow, input.rows)) { - flushSegment(); + for (const [segment, { firstSelected, lastSelected }] of selectedSegments) { + if (input.signal?.aborted) break; + const file = fileMap.get(segment.fileId); + if (!file) continue; + + const tokens = tokenize( + segment, + () => segment.rows.map((row) => row.content).join("\n"), + file.language, + ); + for (let index = firstSelected; index <= lastSelected; index += 1) { + const row = segment.rows[index]!; + if (selectedRowIds.has(row.id) && displaySide(row) === segment.side) { + tokensByRowId[row.id] = + tokens.highlightLine(index, input.scheme) ?? makePlainTokenFallback(row); + } } - segment.push(selectedRow); } - flushSegment(); - return { tokensByRowId, rowCount: selectedRows.length }; + if (input.signal?.aborted) { + traceDiffHighlight("highlight-aborted", { request }); + return { + tokensByRowId: {}, + retryableRowIds: [], + rowCount: 0, + durationMs: 0, + }; + } + + traceDiffHighlight("highlight-result", { + request, + selected: selectedRows.length, + delivered: Object.keys(tokensByRowId).length, + retryable: retryableRowIds.length, + colored: Object.values(tokensByRowId).filter((tokens) => + tokens.some((token) => token.color !== null), + ).length, + }); + return { + tokensByRowId, + retryableRowIds, + rowCount: selectedRows.length, + durationMs: Math.round(performance.now() - startedAt), + }; } diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSource.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSource.test.ts new file mode 100644 index 0000000000000..3dcc3c7597f07 --- /dev/null +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSource.test.ts @@ -0,0 +1,151 @@ +import { expect, it, vi } from "vite-plus/test"; + +import { createNativeReviewDiffSourceLoader } from "./nativeReviewDiffSource"; +import type { NativeReviewDiffSource } from "./nativeReviewDiffTypes"; + +const SOURCE = { oldContents: "const before = true;", newContents: "const after = false;" }; + +vi.mock("expo-file-system", () => ({ + Paths: { document: "test-documents" }, + File: class { + create() {} + async write() {} + }, +})); + +it("retains fetched stress sources above 500k characters within the aggregate budget", async () => { + const source = { oldContents: "", newContents: "x".repeat(3_270_000) }; + const fetch = vi.fn(async () => source); + const load = createNativeReviewDiffSourceLoader(fetch); + expect(await load("generated.c")).toBe(source); + expect(await load("generated.c")).toBe(source); + expect(fetch).toHaveBeenCalledTimes(1); +}); + +it("evicts source text by aggregate size and returns an over-budget response uncached", async () => { + const source = { oldContents: "", newContents: "x".repeat(4_500_000) }; + const huge = { oldContents: "", newContents: "x".repeat(9_000_000) }; + const fetch = vi.fn(async (id: string) => (id === "huge" ? huge : source)); + const load = createNativeReviewDiffSourceLoader(fetch); + expect(await load("first")).toBe(source); + expect(await load("second")).toBe(source); + expect(await load("first")).toBe(source); + expect(fetch).toHaveBeenCalledTimes(3); + expect(await load("huge")).toBe(huge); + expect(await load("huge")).toBe(huge); + expect(fetch).toHaveBeenCalledTimes(5); + expect(await load("first")).toBe(source); + expect(fetch).toHaveBeenCalledTimes(5); +}); + +it("lets later viewport requests retry a failed source fetch", async () => { + const fetch = vi.fn().mockRejectedValueOnce(new Error("Offline")).mockResolvedValueOnce(SOURCE); + const load = createNativeReviewDiffSourceLoader(fetch); + await expect(load("retry.ts")).rejects.toThrow("Offline"); + expect(await load("retry.ts")).toBe(SOURCE); +}); + +const failureKeys = [ + ["string", () => "retry.ts"], + ["number", () => 1], + ["boolean", () => false], + ["bigint", () => 1n], + ["symbol", () => Symbol("retry.ts")], + ["null", () => null], + ["undefined", () => undefined], + ["object", () => ({ id: "retry.ts" })], + ["function", () => () => SOURCE], +] as const; + +it.each(failureKeys)("bounds transient retries for a %s key", async (_name, createKey) => { + const key = createKey(); + const fetch = vi.fn(async () => { + throw new Error("Offline"); + }); + const load = createNativeReviewDiffSourceLoader(fetch); + await expect(load(key)).rejects.toThrow("Offline"); + expect(await load(key)).toBeNull(); + expect(await load(key)).toBeNull(); + expect(fetch).toHaveBeenCalledTimes(2); +}); + +it.each(failureKeys)( + "remembers permanent unavailability for a %s key", + async (_name, createKey) => { + const key = createKey(); + const fetch = vi.fn(async () => { + throw { reason: "unsupported" }; + }); + const load = createNativeReviewDiffSourceLoader(fetch); + expect(await load(key)).toBeNull(); + expect(await load(key)).toBeNull(); + expect(fetch).toHaveBeenCalledOnce(); + }, +); + +it.each(failureKeys)( + "resets retries after success and eviction for a %s key", + async (_name, createKey) => { + const key = createKey(); + const fetch = vi + .fn() + .mockRejectedValueOnce(new Error("Offline")) + .mockResolvedValueOnce(SOURCE) + .mockResolvedValueOnce({ oldContents: "", newContents: "x".repeat(8_000_000) }) + .mockRejectedValueOnce(new Error("Offline again")) + .mockResolvedValueOnce(SOURCE); + const load = createNativeReviewDiffSourceLoader(fetch); + await expect(load(key)).rejects.toThrow("Offline"); + expect(await load(key)).toBe(SOURCE); + await load({ id: "evict" }); + await expect(load(key)).rejects.toThrow("Offline again"); + expect(await load(key)).toBe(SOURCE); + expect(fetch).toHaveBeenCalledTimes(5); + }, +); + +it("shares an in-flight function request while one subscriber cancels", async () => { + const started = Promise.withResolvers(); + const response = Promise.withResolvers(); + let calls = 0; + const request = () => { + calls++; + started.resolve(); + return response.promise; + }; + const load = createNativeReviewDiffSourceLoader((sourceRequest: typeof request) => + sourceRequest(), + ); + const controller = new AbortController(); + const first = load(request, controller.signal); + await started.promise; + const second = load(request); + controller.abort(); + expect(await first).toBeNull(); + response.resolve(SOURCE); + expect(await second).toBe(SOURCE); + expect(await load(request)).toBe(SOURCE); + expect(calls).toBe(1); +}); + +it("serializes RPCs and drops cancelled queued files", async () => { + const started = Promise.withResolvers(); + const response = Promise.withResolvers(); + const fetch = vi.fn((file: string) => { + if (file !== "active.ts") return Promise.resolve(SOURCE); + started.resolve(); + return response.promise; + }); + const load = createNativeReviewDiffSourceLoader(fetch); + const active = load("active.ts"); + await started.promise; + const controller = new AbortController(); + const queued = load("cancelled.ts", controller.signal); + controller.abort(); + expect(await queued).toBeNull(); + expect(fetch.mock.calls).toEqual([["active.ts"]]); + response.resolve(SOURCE); + await active; + await load("next.ts"); + expect(fetch.mock.calls).toEqual([["active.ts"], ["next.ts"]]); +}); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSource.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSource.ts new file mode 100644 index 0000000000000..52fae25d35b97 --- /dev/null +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSource.ts @@ -0,0 +1,188 @@ +import type { NativeReviewDiffSource } from "./nativeReviewDiffTypes"; +import { diffHighlightTraceId, traceDiffHighlight } from "./nativeReviewDiffTrace"; + +const MAX_SOURCE_FILES = 4; +// Bound retained UTF-16 text across files without discarding an already-fetched +// authoritative source just because it is large. Provider response limits still apply. +const MAX_CACHED_SOURCE_CHARACTERS = 8_000_000; +const MAX_SOURCE_ATTEMPTS = 2; + +function isWeakKey(value: unknown): value is object { + return (typeof value === "object" && value !== null) || typeof value === "function"; +} + +function permanentSourceFailureReason(error: unknown): string | undefined { + for (let depth = 0; depth < 8 && typeof error === "object" && error !== null; depth++) { + const reason = "reason" in error ? error.reason : undefined; + const name = "name" in error ? error.name : undefined; + // Defect serialization preserves error names and causes, but drops custom fields. + if (name === "GitCafeDiffSourceUnavailableError") return "gitcafe-source-unavailable"; + if (name === "GitHubDiffFileContentsUnavailableError") return "github-source-unavailable"; + for (const permanent of [ + "provider-unsupported", + "unsupported", + "oversized", + "binary", + "not-found", + ]) { + if (permanent === reason) return permanent; + } + if ("status" in error && error.status === 413) return "http-413"; + if ("status" in error && error.status === 501) return "http-501"; + // Older servers have no structured reason for an unsupported contents RPC. + if ( + "detail" in error && + error.detail === "This host cannot expand unchanged pull request lines." + ) + return "legacy-host-unsupported"; + error = "cause" in error ? error.cause : undefined; + } + return undefined; +} + +function waitForSource( + promise: Promise, + signal?: AbortSignal, +): Promise { + if (!signal) return promise; + if (signal.aborted) return Promise.resolve(null); + return new Promise((resolve, reject) => { + const abort = () => { + signal.removeEventListener("abort", abort); + resolve(null); + }; + signal.addEventListener("abort", abort, { once: true }); + void promise.then( + (source) => { + signal.removeEventListener("abort", abort); + resolve(source); + }, + (error: unknown) => { + signal.removeEventListener("abort", abort); + reject(error); + }, + ); + }); +} + +export function createNativeReviewDiffSourceLoader( + fetchSource: (fileId: FileId) => Promise, +) { + const cached = new Map(); + let cachedCharacters = 0; + type Failure = { attempts: number; unavailable: boolean }; + // Request closures can own parsed rows; failed versions must not retain them. + const objectFailures = new WeakMap(); + const primitiveFailures = new Map(); + const pending = new Map< + FileId, + { + readonly promise: Promise; + readonly subscribers: Set; + } + >(); + // RPC commands cannot abort a request already sent. Serialize source fetches + // and drop queued requests whose viewports have all been cancelled. + let tail = Promise.resolve(); + const loader = diffHighlightTraceId(cached); + + return async (fileId: FileId, signal?: AbortSignal) => { + const file = diffHighlightTraceId(fileId); + const failure = (isWeakKey(fileId) + ? objectFailures.get(fileId) + : primitiveFailures.get(fileId)) ?? { attempts: 0, unavailable: false }; + if (signal?.aborted || failure.unavailable) { + traceDiffHighlight("source-skip", { + loader, + file, + aborted: signal?.aborted ?? false, + unavailable: failure.unavailable, + }); + return null; + } + if (cached.has(fileId)) { + traceDiffHighlight("source-cache", { loader, file }); + const source = cached.get(fileId)!; + cached.delete(fileId); + cached.set(fileId, source); + return source; + } + let request = pending.get(fileId); + if (!request) { + traceDiffHighlight("source-queued", { loader, file, pending: pending.size }); + const subscribers = new Set(); + const promise = tail + .then(async () => { + if ([...subscribers].every((subscriber) => subscriber?.aborted)) { + traceDiffHighlight("source-dropped", { loader, file }); + return null; + } + const attempt = ++failure.attempts; + if (isWeakKey(fileId)) objectFailures.set(fileId, failure); + else primitiveFailures.set(fileId, failure); + traceDiffHighlight("source-start", { loader, file, attempt }); + try { + const contents = await fetchSource(fileId); + traceDiffHighlight("source-result", { + loader, + file, + oldCharacters: contents.oldContents.length, + newCharacters: contents.newContents.length, + }); + const characters = contents.oldContents.length + contents.newContents.length; + if (isWeakKey(fileId)) objectFailures.delete(fileId); + else primitiveFailures.delete(fileId); + if (characters <= MAX_CACHED_SOURCE_CHARACTERS) { + cached.set(fileId, contents); + cachedCharacters += characters; + while ( + cached.size > MAX_SOURCE_FILES || + cachedCharacters > MAX_CACHED_SOURCE_CHARACTERS + ) { + const oldest = cached.keys().next().value!; + const source = cached.get(oldest)!; + cachedCharacters -= source.oldContents.length + source.newContents.length; + cached.delete(oldest); + } + } + return contents; + } catch (error) { + const reason = permanentSourceFailureReason(error); + const permanent = reason !== undefined; + traceDiffHighlight("source-error", { + loader, + file, + attempt, + permanent, + reason: reason ?? "transient", + }); + if (permanent || attempt >= MAX_SOURCE_ATTEMPTS) { + failure.unavailable = true; + traceDiffHighlight("source-unavailable", { + loader, + file, + reason: permanent ? "permanent-error" : "attempts", + }); + return null; + } + // Only a later viewport request retries; never loop inside this request. + throw error; + } + }) + .finally(() => pending.delete(fileId)); + tail = promise.then( + () => {}, + () => {}, + ); + request = { promise, subscribers }; + pending.set(fileId, request); + } else traceDiffHighlight("source-shared", { loader, file }); + request.subscribers.add(signal); + try { + return await waitForSource(request.promise, signal); + } finally { + if (signal?.aborted) traceDiffHighlight("source-wait-aborted", { loader, file }); + request.subscribers.delete(signal); + } + }; +} diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts deleted file mode 100644 index 805cc77ae367d..0000000000000 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it, vi } from "vite-plus/test"; - -const expoMocks = vi.hoisted(() => ({ - requireNativeView: vi.fn(), -})); - -vi.mock("expo", () => ({ - requireNativeView: expoMocks.requireNativeView, -})); - -describe("isPendingNativeViewRegistration", () => { - it("recognizes registration races for the installed native view name", async () => { - const { isPendingNativeViewRegistration } = await import("./nativeReviewDiffSurface"); - - expect( - isPendingNativeViewRegistration( - new Error("Unable to find the 'T3ReviewDiffSurface' view for this native tag"), - ), - ).toBe(true); - expect( - isPendingNativeViewRegistration( - new Error("Unable to find the 'T3ReviewDiffView' view for this native tag"), - ), - ).toBe(false); - expect( - isPendingNativeViewRegistration( - new Error( - "Unable to find the class expo.modules.t3reviewdiff.T3ReviewDiffView view with tag 1150", - ), - ), - ).toBe(true); - }); -}); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.tsx b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.tsx new file mode 100644 index 0000000000000..52cc180534272 --- /dev/null +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.tsx @@ -0,0 +1,405 @@ +import { + act, + useLayoutEffect, + useImperativeHandle, + useRef, + useState, + type Ref, + type ReactNode, +} from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; +import { AsyncResult } from "effect/reactivity"; +import * as Cause from "effect/Cause"; +import type { HighlightNativeReviewDiffVisibleRowsInput } from "./nativeReviewDiffHighlighter"; +import type { NativeReviewDiffViewProps } from "./nativeReviewDiffSurface"; +import type { NativeReviewDiffData } from "../review/nativeReviewDiffAdapter"; + +const mocks = vi.hoisted(() => ({ + registrationFailures: 0, + tokenAccepted: true as boolean | undefined, + tokenCompletion: null as Promise | null, + onDebug: undefined as NativeReviewDiffViewProps["onDebug"], + autoViewport: false, + rows: vi.fn(), + tokens: vi.fn(), + highlight: vi.fn(), +})); + +function RawView({ + ref: nativeRef, + onDebug, + initialFileId, + contentResetKey, +}: NativeReviewDiffViewProps & { ref: Ref }) { + const [content, setContent] = useState({ resetKey: contentResetKey, rows: "" }); + const rows = content.resetKey === contentResetKey ? content.rows : ""; + const [tokenRowIds, setTokenRowIds] = useState([]); + useLayoutEffect(() => { + mocks.onDebug = onDebug; + }); + useImperativeHandle(nativeRef, () => ({ + async setRowsJson(value: string) { + mocks.rows(value); + if (mocks.registrationFailures > 0) { + mocks.registrationFailures -= 1; + throw new Error("Unable to find the 'T3ReviewDiffSurface' view for this native tag"); + } + setContent({ resetKey: contentResetKey, rows: value }); + if (mocks.autoViewport) { + const decoded = JSON.parse(value) as NativeReviewDiffData["rows"]; + onDebug?.({ + nativeEvent: { message: "rows-decoded", rows: decoded.length }, + } as unknown as Parameters>[0]); + const index = decoded.findIndex( + (row) => row.kind === "file" && row.fileId === initialFileId, + ); + onDebug?.({ + nativeEvent: { + message: "visible-range", + firstRowIndex: Math.max(0, index), + lastRowIndex: Math.max(0, index) + 40, + totalRows: decoded.length, + }, + } as unknown as Parameters>[0]); + } + }, + async scrollToFile() {}, + async setTokensPatchJson(value: string) { + mocks.tokens(value); + const accepted = mocks.tokenCompletion ? await mocks.tokenCompletion : mocks.tokenAccepted; + if (accepted === true) { + const patch = JSON.parse(value); + setTokenRowIds((current) => [ + ...new Set([...current, ...Object.keys(patch.tokensByRowId)]), + ]); + } + return accepted; + }, + })); + return ( +
+ {rows ? "Rendered diff" : "Blank native surface"} +
+ ); +} + +vi.mock("expo", () => ({ requireNativeView: () => RawView })); +vi.mock("react-native", () => ({ + View: ({ children }: { children?: ReactNode }) =>
{children}
, +})); +vi.mock("../../components/ui/EmptyState", () => ({ + EmptyState: ({ + title, + actionLabel, + onAction, + }: { + title: string; + actionLabel: string; + onAction: () => void; + }) => ( +
+ {title} + +
+ ), +})); +vi.mock("./nativeReviewDiffHighlighter", () => ({ + highlightNativeReviewDiffVisibleRows: mocks.highlight, +})); +vi.mock("@t3tools/client-runtime/state/runtime", () => ({ + settlePromise: async (execute: () => Promise) => { + try { + return AsyncResult.success(await execute()); + } catch (error) { + return AsyncResult.failure(Cause.die(error)); + } + }, + squashAtomCommandFailure: () => "tokenization failed", +})); +vi.mock("../review/nativeReviewDiffAdapter", () => ({ createNativeReviewDiffTheme: () => ({}) })); +vi.mock("../settings/appearance/useAppearanceCodeSurface", () => ({ + useAppearanceCodeSurface: () => ({ nativeReviewDiffStyle: {} }), +})); +vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({ + useAppearancePreferences: () => ({ themeAppearance: "light", themeId: "test" }), +})); +vi.mock("../../lib/useUniwindTheme", () => ({ useUniwindTheme: () => ({}) })); + +import { resolveNativeReviewDiffView } from "./nativeReviewDiffSurface"; +import { useNativeReviewDiffBridge } from "../review/useNativeReviewDiffBridge"; +import { + handlePullRequestDiffDebugEvent, + pullRequestDiffInitialPosition, +} from "../pull-requests/pullRequestDiffDebug"; + +function setup(integrated = false) { + vi.useFakeTimers(); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + mocks.registrationFailures = 0; + mocks.tokenAccepted = true; + mocks.tokenCompletion = null; + mocks.onDebug = undefined; + mocks.autoViewport = false; + mocks.rows.mockClear(); + mocks.tokens.mockClear(); + mocks.highlight + .mockReset() + .mockImplementation(async (input: HighlightNativeReviewDiffVisibleRowsInput) => { + const rowId = `r${input.firstRowIndex}`; + const tokensByRowId = input.alreadyHighlightedRowIds?.has(rowId) + ? {} + : { [rowId]: [{ content: "const", color: "#ff0000", fontStyle: null }] }; + return { + rowCount: Object.keys(tokensByRowId).length, + durationMs: 0, + retryableRowIds: [], + tokensByRowId, + }; + }); + const frames = new Map(); + let nextFrame = 0; + const requestFrame = vi + .spyOn(globalThis, "requestAnimationFrame") + .mockImplementation((callback) => { + frames.set(++nextFrame, callback); + return nextFrame; + }); + const cancelFrame = vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation((id) => { + if (id != null) frames.delete(id); + }); + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + const expo = Object.getOwnPropertyDescriptor(globalThis, "expo"); + Object.defineProperty(globalThis, "expo", { + configurable: true, + value: { getViewConfig: () => ({}) }, + }); + const NativeDiff = resolveNativeReviewDiffView()!; + const container = document.createElement("div"); + const root = createRoot(container); + let data: NativeReviewDiffData = { + rows: Array.from({ length: 1000 }, (_, index) => + index === 999 + ? { kind: "comment", id: `r${index}`, commentText: "Original comment" } + : index === 900 + ? { kind: "file", id: `r${index}`, fileId: "last-file" } + : { kind: "line", id: `r${index}` }, + ), + files: [], + additions: 0, + deletions: 0, + commentTargetsByRowId: new Map(), + rowIdByCommentLineId: new Map(), + }; + const ids: string[] = []; + function PullRequestDiff({ targetFileId }: { targetFileId?: string }) { + const bridge = useNativeReviewDiffBridge({ + threadKey: null, + sectionId: "pull-request:test:1:all", + diff: "test diff", + data, + collapsedFileIds: ids, + viewedFileIds: ids, + selectedRowIds: ids, + canHighlight: true, + }); + const nativeView = useRef(null); + const jumpStarted = useRef(true); + const onDebug: NonNullable = (event) => + handlePullRequestDiffDebugEvent(event, { + rowCount: data.rows.length, + targetFileId, + focusIndex: -1, + isIos: true, + nativeView, + jumpStarted, + loadMore: () => {}, + onDebug: bridge.onDebug, + }); + return ( + + ); + } + if (!integrated) { + act(() => + root.render( + , + ), + ); + } + return { + container, + async mountPullRequest(targetFileId?: string) { + await act(async () => root.render()); + }, + async updateComment() { + data = { + ...data, + rows: data.rows.map((row) => + row.kind === "comment" ? { ...row, commentText: "Updated comment" } : row, + ), + }; + await act(async () => root.render()); + }, + get tokenRows() { + return container.querySelector("[data-token-rows]")?.getAttribute("data-token-rows"); + }, + async jump(firstRowIndex: number, totalRows = 1000) { + await act(async () => { + mocks.onDebug?.({ + nativeEvent: { + message: "visible-range", + firstRowIndex, + lastRowIndex: firstRowIndex + 40, + totalRows, + }, + } as unknown as Parameters>[0]); + await vi.runOnlyPendingTimersAsync(); + }); + }, + async deliverPayloads() { + const callbacks = [...frames.values()]; + frames.clear(); + await act(async () => { + for (const callback of callbacks) callback(0); + await vi.runOnlyPendingTimersAsync(); + }); + }, + retry() { + const button = container.querySelector("button"); + expect(button?.textContent).toBe("Retry diff"); + act(() => button!.click()); + }, + decodeFailure() { + act(() => + mocks.onDebug?.({ + nativeEvent: { message: "rows-decode-failed", error: "Decode failed" }, + } as unknown as Parameters>[0]), + ); + }, + [Symbol.dispose]() { + act(() => root.unmount()); + requestFrame.mockRestore(); + cancelFrame.mockRestore(); + errors.mockRestore(); + vi.useRealTimers(); + if (expo) Object.defineProperty(globalThis, "expo", expo); + else Reflect.deleteProperty(globalThis, "expo"); + }, + }; +} + +it("retries transient native registration without replacing the diff with an error", async () => { + using screen = setup(); + mocks.registrationFailures = 1; + await screen.deliverPayloads(); + expect(screen.container.textContent).toBe("Blank native surface"); + await screen.deliverPayloads(); + expect(screen.container.textContent).toBe("Rendered diff"); +}); + +it.each([false, undefined])( + "keeps colors queued until native accepts them and recovers rejected patches (%s)", + async (rejected) => { + using screen = setup(true); + await screen.mountPullRequest(); + let accept!: (accepted: boolean) => void; + mocks.tokenCompletion = new Promise((resolve) => { + accept = resolve; + }); + mocks.tokens.mockClear(); + await screen.jump(0); + await screen.deliverPayloads(); + await screen.jump(400); + await screen.deliverPayloads(); + expect( + mocks.tokens.mock.calls.map(([json]) => Object.keys(JSON.parse(json).tokensByRowId)), + ).toEqual([["r0"]]); + await act(async () => accept(true)); + + mocks.tokenCompletion = null; + mocks.tokenAccepted = rejected; + await screen.deliverPayloads(); + expect(screen.tokenRows).toBe("r0"); + + mocks.tokenAccepted = true; + await screen.jump(800); + await screen.deliverPayloads(); + expect(screen.tokenRows).toBe("r0,r800"); + await screen.jump(400); + await screen.deliverPayloads(); + expect(screen.tokenRows).toBe("r0,r800,r400"); + }, +); + +it.each(["retry", "comment update"])( + "re-highlights accepted rows after a native row failure, PR %s, and viewport revisit", + async (recovery) => { + using screen = setup(true); + await screen.mountPullRequest(); + mocks.tokens.mockClear(); + await screen.jump(0); + await screen.deliverPayloads(); + await screen.jump(400); + await screen.deliverPayloads(); + expect(screen.tokenRows).toBe("r0,r400"); + + screen.decodeFailure(); + expect(screen.container.textContent).toContain("Could not display diff"); + if (recovery === "retry") await act(async () => screen.retry()); + else await screen.updateComment(); + await screen.deliverPayloads(); + expect(screen.container.textContent).toBe("Rendered diff"); + await screen.jump(800); + await screen.deliverPayloads(); + await screen.jump(400); + await screen.deliverPayloads(); + expect(screen.tokenRows?.split(",")).toContain("r400"); + }, +); + +it("delivers the selected file first with a warm highlighter and no startup command", async () => { + using screen = setup(true); + mocks.autoViewport = true; + await screen.mountPullRequest("last-file"); + await screen.deliverPayloads(); + await screen.deliverPayloads(); + expect(screen.tokenRows).toBe("r900"); +}); + +it("recognizes registration races for the installed native view name", async () => { + const { isPendingNativeViewRegistration } = await import("./nativeReviewDiffSurface"); + + expect( + isPendingNativeViewRegistration( + new Error("Unable to find the 'T3ReviewDiffSurface' view for this native tag"), + ), + ).toBe(true); + expect( + isPendingNativeViewRegistration( + new Error("Unable to find the 'T3ReviewDiffView' view for this native tag"), + ), + ).toBe(false); + expect( + isPendingNativeViewRegistration( + new Error( + "Unable to find the class expo.modules.t3reviewdiff.T3ReviewDiffView view with tag 1150", + ), + ), + ).toBe(true); +}); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts index 9d51c72272b63..bb0d0d170b49b 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts @@ -1,14 +1,17 @@ import { createElement, + useCallback, useEffect, useImperativeHandle, useRef, + useState, type ComponentType, type Ref, } from "react"; -import type { NativeSyntheticEvent, ViewProps } from "react-native"; +import { View, type NativeSyntheticEvent, type ViewProps } from "react-native"; import { requireNativeView } from "expo"; +import { EmptyState } from "../../components/ui/EmptyState"; import { NativeViewResolutionError } from "../../native/nativeViewResolutionError"; const NATIVE_REVIEW_DIFF_MODULE_NAME = "T3ReviewDiffSurface"; @@ -125,6 +128,7 @@ export interface NativeReviewDiffViewProps extends ViewProps { /** Android ignores this and measures the widest row. */ readonly contentWidth: number; readonly initialRowIndex?: number; + readonly initialFileId?: string; readonly scrollEnabled?: boolean; readonly bounces?: boolean; readonly refreshing?: boolean; @@ -161,7 +165,7 @@ export function isNativeReviewDiffDrawEvent(payload: Readonly Promise; readonly setTokensJson: (tokensJson: string) => Promise; - readonly setTokensPatchJson: (tokensPatchJson: string) => Promise; + readonly setTokensPatchJson: (tokensPatchJson: string) => Promise; readonly scrollToFile: (fileId: string, animated: boolean) => Promise; readonly scrollToTop: (animated: boolean) => Promise; } @@ -192,6 +196,9 @@ function useNativeReviewDiffPayload( nativeRef: React.RefObject, method: NativeReviewDiffPayloadMethod, payload: string | undefined, + resetKey?: string, + onFailure?: () => void, + onDelivered?: (payload: string) => void, ) { useEffect(() => { if (payload === undefined) { @@ -208,27 +215,43 @@ function useNativeReviewDiffPayload( } const view = nativeRef.current; - const command = view?.[method]; + const command: ((payload: string) => Promise) | undefined = view?.[method]; if (!view || !command) { if (attempts < NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES) { attempts += 1; frame = requestAnimationFrame(dispatch); + } else { + console.error(`[native-review-diff] ${method} unavailable`); + onFailure?.(); } return; } - void command.call(view, payload).catch((error: unknown) => { - if ( - !cancelled && - attempts < NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES && - isPendingNativeViewRegistration(error) - ) { - attempts += 1; - frame = requestAnimationFrame(dispatch); - return; - } - console.error(`[native-review-diff] ${method} failed`, error); - }); + void Promise.resolve() + .then(() => { + if (!cancelled) return command.call(view, payload); + }) + .then((accepted) => { + if (cancelled) return; + // This promise resolves after native decode, reset validation, and merge. + if (method === "setTokensPatchJson" && accepted !== true) { + throw new Error("Native token patch was not accepted"); + } + onDelivered?.(payload); + }) + .catch((error: unknown) => { + if (cancelled) return; + if ( + attempts < NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES && + isPendingNativeViewRegistration(error) + ) { + attempts += 1; + frame = requestAnimationFrame(dispatch); + return; + } + console.error(`[native-review-diff] ${method} failed`, error); + onFailure?.(); + }); }; // Fabric attaches the React ref before Expo registers the native tag used by @@ -241,7 +264,7 @@ function useNativeReviewDiffPayload( cancelAnimationFrame(frame); } }; - }, [method, nativeRef, payload]); + }, [method, nativeRef, payload, resetKey, onFailure, onDelivered]); } function getExpoViewConfig(moduleName: string) { @@ -251,11 +274,102 @@ function getExpoViewConfig(moduleName: string) { } function NativeReviewDiffView(props: NativeReviewDiffViewProps) { - const { nativeViewRef, rowsJson, tokensJson, tokensPatchJson, ...nativeProps } = props; + const [failedRowsJson, setFailedRowsJson] = useState(null); + const onRowsFailure = useCallback(() => setFailedRowsJson(props.rowsJson), [props.rowsJson]); + if (failedRowsJson !== null && failedRowsJson !== props.rowsJson) setFailedRowsJson(null); + if (failedRowsJson === props.rowsJson) { + return createElement( + View, + { + style: [ + props.style, + { + flex: 1, + justifyContent: "center", + paddingTop: props.contentInsetTop, + paddingBottom: props.contentInsetBottom, + }, + ], + }, + createElement(EmptyState, { + title: "Could not display diff", + detail: "Try loading the diff view again.", + variant: "plain", + actionLabel: "Retry diff", + onAction: () => { + props.onDebug?.({ + nativeEvent: { message: "surface-retry" }, + } as unknown as NativeSyntheticEvent>); + setFailedRowsJson(null); + }, + }), + ); + } + return createElement(NativeReviewDiffContent, { ...props, onRowsFailure }); +} + +function NativeReviewDiffContent( + props: NativeReviewDiffViewProps & { readonly onRowsFailure: () => void }, +) { + const { + nativeViewRef, + rowsJson, + tokensJson, + tokensPatchJson, + onRowsFailure, + onDebug, + ...nativeProps + } = props; const nativeRef = useRef(null); - useNativeReviewDiffPayload(nativeRef, "setRowsJson", rowsJson); + useNativeReviewDiffPayload( + nativeRef, + "setRowsJson", + rowsJson, + props.contentResetKey, + onRowsFailure, + ); useNativeReviewDiffPayload(nativeRef, "setTokensJson", tokensJson); - useNativeReviewDiffPayload(nativeRef, "setTokensPatchJson", tokensPatchJson); + const onDebugRef = useRef(onDebug); + useEffect(() => { + onDebugRef.current = onDebug; + }, [onDebug]); + const reportedMountRef = useRef(false); + useEffect(() => { + // Effect replay keeps the same native content; only a new mount loses tokens. + if (reportedMountRef.current) return; + reportedMountRef.current = true; + onDebugRef.current?.({ + nativeEvent: { message: "surface-content-mounted" }, + } as unknown as NativeSyntheticEvent>); + }, []); + const reportTokenDelivery = useCallback((json: string, message: string) => { + const patch = JSON.parse(json) as { resetKey?: string; chunkIndex?: number }; + if (patch.chunkIndex === undefined) return; + onDebugRef.current?.({ + nativeEvent: { + message, + resetKey: patch.resetKey, + chunkIndex: patch.chunkIndex, + }, + } as unknown as NativeSyntheticEvent>); + }, []); + const onTokensDelivered = useCallback( + (json: string) => { + reportTokenDelivery(json, "tokens-patch-delivered"); + }, + [reportTokenDelivery], + ); + const onTokensFailure = useCallback(() => { + if (tokensPatchJson !== undefined) reportTokenDelivery(tokensPatchJson, "tokens-patch-failed"); + }, [reportTokenDelivery, tokensPatchJson]); + useNativeReviewDiffPayload( + nativeRef, + "setTokensPatchJson", + tokensPatchJson, + undefined, + onTokensFailure, + onTokensDelivered, + ); useImperativeHandle( nativeViewRef, () => ({ @@ -277,6 +391,13 @@ function NativeReviewDiffView(props: NativeReviewDiffViewProps) { return createElement(RawNativeView, { ...nativeProps, refreshEnabled: props.onPullToRefresh !== undefined, + onDebug: (event) => { + if (event.nativeEvent.message === "rows-decode-failed") { + console.error("[native-review-diff] rows decode failed", event.nativeEvent.error); + onRowsFailure(); + } + onDebug?.(event); + }, ref: nativeRef, }); } diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffTrace.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffTrace.test.ts new file mode 100644 index 0000000000000..e43a2d7ec9a09 --- /dev/null +++ b/apps/mobile/src/features/diffs/nativeReviewDiffTrace.test.ts @@ -0,0 +1,98 @@ +import { expect, it, vi } from "vite-plus/test"; + +const sink = vi.hoisted(() => ({ + created: vi.fn(), + written: vi.fn<(line: string, options: unknown) => Promise>(), + opened: vi.fn(), +})); + +vi.mock("expo-file-system", () => ({ + Paths: { document: "documents" }, + File: class { + constructor(...args: unknown[]) { + sink.opened(...args); + } + create(options: unknown) { + sink.created(options); + } + write(line: string, options: unknown) { + return sink.written(line, options); + } + }, +})); + +it("leaves the filesystem untouched when Release tracing is disabled", async () => { + vi.resetModules(); + sink.opened.mockClear(); + vi.stubEnv("EXPO_PUBLIC_T3_DIFF_TRACE", "0"); + try { + const { traceDiffHighlight } = await import("./nativeReviewDiffTrace"); + traceDiffHighlight("viewport-start", { rows: 80 }); + expect(sink.opened).not.toHaveBeenCalled(); + } finally { + vi.unstubAllEnvs(); + } +}); + +it("keeps object, function, and primitive identities stable without collisions", async () => { + vi.resetModules(); + vi.stubEnv("EXPO_PUBLIC_T3_DIFF_TRACE", "1"); + try { + const { diffHighlightTraceId } = await import("./nativeReviewDiffTrace"); + const values = [{}, {}, () => {}, () => {}, null, undefined, "file", 1, "1", false]; + const ids = values.map(diffHighlightTraceId); + expect(ids.every((id) => id > 0)).toBe(true); + expect(new Set(ids).size).toBe(values.length); + expect(values.map(diffHighlightTraceId)).toEqual(ids); + } finally { + vi.unstubAllEnvs(); + } +}); + +it("serializes appends to a retrievable file and bounds a process to 1000 events", async () => { + vi.resetModules(); + sink.opened.mockClear(); + sink.created.mockClear(); + sink.written.mockReset(); + const firstWrite = Promise.withResolvers(); + const releaseFirst = Promise.withResolvers(); + const finished = Promise.withResolvers(); + sink.written.mockImplementation(async (line) => { + const { seq } = JSON.parse(line); + if (seq === 1) { + firstWrite.resolve(); + await releaseFirst.promise; + } + if (seq === 1000) finished.resolve(); + }); + using warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubEnv("EXPO_PUBLIC_T3_DIFF_TRACE", "1"); + try { + const { diffHighlightTraceId, traceDiffHighlight } = await import("./nativeReviewDiffTrace"); + const request = {}; + expect(diffHighlightTraceId(request)).toBeGreaterThan(0); + for (let index = 0; index < 1001; index++) + traceDiffHighlight("viewport-start", { rows: index }); + await firstWrite.promise; + expect(sink.written).toHaveBeenCalledTimes(1); + releaseFirst.resolve(); + await finished.promise; + expect(sink.opened.mock.calls).toEqual([["documents", "t3-diff-highlight-trace.jsonl"]]); + expect(sink.created.mock.calls).toEqual([[{ overwrite: true, intermediates: true }]]); + expect(sink.written).toHaveBeenCalledTimes(1000); + expect(warning).toHaveBeenCalledTimes(1000); + expect(diffHighlightTraceId(request)).toBe(0); + expect(diffHighlightTraceId("file")).toBe(0); + expect(sink.written.mock.calls.map(([line]) => JSON.parse(line).seq)).toEqual( + Array.from({ length: 1000 }, (_, index) => index + 1), + ); + expect( + sink.written.mock.calls.every( + ([line, options]) => line.endsWith("\n") && JSON.stringify(options) === '{"append":true}', + ), + ).toBe(true); + } finally { + releaseFirst.resolve(); + vi.unstubAllEnvs(); + } +}); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffTrace.ts b/apps/mobile/src/features/diffs/nativeReviewDiffTrace.ts new file mode 100644 index 0000000000000..c7f39b051d23f --- /dev/null +++ b/apps/mobile/src/features/diffs/nativeReviewDiffTrace.ts @@ -0,0 +1,70 @@ +// Opt in at bundle time for bounded Release diagnostics; never log source or RPC payloads. +const MAX_TRACE_EVENTS = 1_000; +let eventCount = 0; +let nextId = 0; +// Diagnostics must not keep requests, source loaders, or their row graphs alive. +const objectIds = new WeakMap(); +const primitiveIds = new Map(); +let fileSink: Promise | undefined; + +export function diffHighlightTraceHash(value: string): number { + let hash = 2166136261; + for (let index = 0; index < value.length; index++) + hash = Math.imul(hash ^ value.charCodeAt(index), 16777619); + return hash >>> 0; +} + +function appendTraceLine(line: string) { + fileSink ??= import("expo-file-system") + .then(({ File, Paths }) => { + const file = new File(Paths.document, "t3-diff-highlight-trace.jsonl"); + file.create({ overwrite: true, intermediates: true }); + return file; + }) + .catch(() => null); + // File.write is async in Expo 58. Serialize appends to preserve sequence order. + fileSink = fileSink.then(async (file) => { + if (!file) return null; + try { + await file.write(line + "\n", { append: true }); + return file; + } catch { + return null; + } + }); +} + +export function diffHighlightTraceId(value: unknown): number { + if (process.env.EXPO_PUBLIC_T3_DIFF_TRACE !== "1" || eventCount >= MAX_TRACE_EVENTS) return 0; + if ((typeof value === "object" && value !== null) || typeof value === "function") { + let id = objectIds.get(value); + if (id === undefined) { + id = ++nextId; + objectIds.set(value, id); + } + return id; + } + let id = primitiveIds.get(value); + if (id === undefined) { + id = ++nextId; + primitiveIds.set(value, id); + } + return id; +} + +export function traceDiffHighlight( + event: string, + fields: Readonly> = {}, +) { + if (process.env.EXPO_PUBLIC_T3_DIFF_TRACE !== "1" || eventCount >= MAX_TRACE_EVENTS) return; + eventCount += 1; + const line = JSON.stringify({ + event, + seq: eventCount, + at: Math.round(performance.now()), + ...fields, + }); + appendTraceLine(line); + console.warn("[diff-highlight-trace] " + line); + if (eventCount === MAX_TRACE_EVENTS) primitiveIds.clear(); +} diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffTypes.ts b/apps/mobile/src/features/diffs/nativeReviewDiffTypes.ts index 24ce1667aeebe..6d0427c262295 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffTypes.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffTypes.ts @@ -1,3 +1,8 @@ +export interface NativeReviewDiffSource { + readonly oldContents: string; + readonly newContents: string; +} + export interface NativeReviewDiffFile { readonly id: string; readonly path: string; @@ -5,4 +10,5 @@ export interface NativeReviewDiffFile { readonly language: string; readonly additions: number; readonly deletions: number; + readonly loadContents?: (signal?: AbortSignal) => Promise; } diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index 0f48b2ad9c06e..b013029db79a2 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -36,6 +36,11 @@ import { usePullRequestPermissions } from "./usePullRequestPermissions"; import { usePullRequestAction } from "./usePullRequestAction"; import { annotatePullRequestDiff, reviewPosition } from "./pullRequestDiffComments"; import { usePullRequestFileFilter } from "./usePullRequestFileFilter"; +import { + handlePullRequestDiffDebugEvent, + pullRequestDiffInitialPosition, +} from "./pullRequestDiffDebug"; +import { usePullRequestDiffHighlightFiles } from "./usePullRequestDiffHighlightFiles"; import { isPullRequestTestFile } from "./pullRequestFileFilters"; import { useFocusEffect, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { RegistryContext, useAtomRefresh } from "@effect/atom-react"; @@ -44,7 +49,7 @@ import { type PullRequestDetail, type PullRequestListEntry } from "@t3tools/cont import { PullRequestOverviewLoading } from "./PullRequestOverviewLoading"; import { copyPullRequestLink, sharePullRequestLink } from "./pullRequestLink"; import { pullRequestStatus } from "./pullRequestStatus"; -import { use, useEffect, useRef, useState } from "react"; +import { use, useEffect, useMemo, useRef, useState } from "react"; import { Linking, Platform, @@ -75,7 +80,6 @@ import { composerPullRequests } from "../../state/pull-requests"; import { useEnvironmentQuery } from "../../state/query"; import { usePullToRefresh } from "../../state/use-pull-to-refresh"; import { - isNativeReviewDiffDrawEvent, resolveNativeReviewDiffView, type NativeReviewDiffViewHandle, type NativeReviewDiffViewProps, @@ -804,12 +808,25 @@ export function PullRequestFileRouteScreen({ !result.isPending && !result.error; const activity = useEnvironmentQuery(composerPullRequests.activity(requestTarget(route.params))); - const baseData = buildNativeReviewDiffData(parsed); - const data = annotatePullRequestDiff( - baseData, - staleDiff || commit ? [] : (activity.data?.reviewThreads ?? []), - commit ? [] : reviewDraft.draft.comments, - diffHead.sha ?? undefined, + const baseData = useMemo(() => buildNativeReviewDiffData(parsed), [parsed]); + const highlightFiles = usePullRequestDiffHighlightFiles(route.params, parsed, baseData.files); + const data = useMemo( + () => + annotatePullRequestDiff( + { ...baseData, files: highlightFiles }, + staleDiff || commit ? [] : (activity.data?.reviewThreads ?? []), + commit ? [] : reviewDraft.draft.comments, + diffHead.sha ?? undefined, + ), + [ + baseData, + highlightFiles, + staleDiff, + commit, + activity.data?.reviewThreads, + reviewDraft.draft.comments, + diffHead.sha, + ], ); const viewedIds = data.files .filter((file) => (commit ? commitViewedPaths.has(file.path) : viewed.isViewed(file.path))) @@ -854,7 +871,9 @@ export function PullRequestFileRouteScreen({ const [collapsedIds, setCollapsedIds] = useState(EMPTY_IDS); const [visibleFileId, setVisibleFileId] = useState(null); const nativeView = useRef(null); - const jumpStarted = useRef(false); + // Native applies the entry destination before its first viewport. Imperative + // jumps below are for subsequent selections and filter changes only. + const jumpStarted = useRef(true); const lastSelection = useRef({ path: route.params.path, id: route.params.fileSelectionId }); useEffect(() => { if ( @@ -902,37 +921,17 @@ export function PullRequestFileRouteScreen({ useEffect(() => { if (!targetFile) loadMore(); }, [targetFile, loadMore]); - const { onDebug: updateVisibleRange } = bridge; - const onDebug: NonNullable = (event) => { - const drawn = isNativeReviewDiffDrawEvent(event.nativeEvent); - const decoded = - event.nativeEvent.message === "rows-decoded" && event.nativeEvent.rows === data.rows.length; - if (!drawn && !decoded) return; - // Filtering replaces the native row payload asynchronously. Old draw events - // must not consume the file jump or highlight the new payload's row indices. - if ( - typeof event.nativeEvent.totalRows === "number" && - event.nativeEvent.totalRows !== data.rows.length - ) - return; - if (drawn) updateVisibleRange(event); - if ( - targetFile && - !jumpStarted.current && - nativeView.current && - (Platform.OS !== "ios" || decoded) - ) { - jumpStarted.current = true; - if (focusIndex >= 0) return; - void nativeView.current.scrollToFile(targetFile.id, false).catch(() => { - jumpStarted.current = false; - }); - return; - } - if (!drawn) return; - const lastRow = event.nativeEvent.lastRowIndex; - if (typeof lastRow === "number" && lastRow >= data.rows.length - 40) loadMore(); - }; + const onDebug: NonNullable = (event) => + handlePullRequestDiffDebugEvent(event, { + rowCount: data.rows.length, + targetFileId: targetFile?.id, + focusIndex, + isIos: Platform.OS === "ios", + nativeView, + jumpStarted, + onDebug: bridge.onDebug, + loadMore, + }); const onToggleFile: NonNullable = (event) => { const id = event.nativeEvent.fileId; if (!id) return; @@ -1114,7 +1113,7 @@ export function PullRequestFileRouteScreen({ {NativeDiffView && data.rows.length && !seekingFile ? ( = 0 ? Math.max(0, focusIndex - 3) : undefined} + {...pullRequestDiffInitialPosition(targetFile?.id, focusIndex)} nativeViewRef={nativeView} refreshing={result.refreshing} onPullToRefresh={result.onPullRefresh} diff --git a/apps/mobile/src/features/pull-requests/pullRequestDiffComments.test.ts b/apps/mobile/src/features/pull-requests/pullRequestDiffComments.test.ts index df769c2b2af4b..2573436f3acba 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestDiffComments.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestDiffComments.test.ts @@ -24,6 +24,17 @@ const thread: PullRequestReviewThread = { comments: [], }; describe("PR comments in continuous native diffs", () => { + it("returns unannotated data unchanged without reading or indexing rows", () => { + const unannotated = { + ...data, + get rows(): NativeReviewDiffData["rows"] { + throw new Error("Empty annotation must not read rows"); + }, + }; + expect(annotatePullRequestDiff(unannotated, [], [], undefined)).toBe(unannotated); + expect(annotatePullRequestDiff(data, [], [], undefined).rows).toBe(data.rows); + }); + it("anchors old/new lines separately despite identical line numbers", () => { const annotated = annotatePullRequestDiff( data, diff --git a/apps/mobile/src/features/pull-requests/pullRequestDiffComments.ts b/apps/mobile/src/features/pull-requests/pullRequestDiffComments.ts index 73a24c3b90359..097ede3867e5d 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestDiffComments.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestDiffComments.ts @@ -34,6 +34,7 @@ export function annotatePullRequestDiff( drafts: readonly PendingReviewComment[], headSha: string | undefined, ): NativeReviewDiffData { + if (threads.length === 0 && drafts.length === 0) return data; const anchors = new Map(); const firstByPath = new Map(); const lines = new Map(); diff --git a/apps/mobile/src/features/pull-requests/pullRequestDiffDebug.test.ts b/apps/mobile/src/features/pull-requests/pullRequestDiffDebug.test.ts new file mode 100644 index 0000000000000..030e3ef439545 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/pullRequestDiffDebug.test.ts @@ -0,0 +1,82 @@ +import { expect, it, vi } from "vite-plus/test"; + +vi.mock("react-native", () => ({ View: () => null })); +vi.mock("expo", () => ({ requireNativeView: vi.fn() })); +vi.mock("../../components/ui/EmptyState", () => ({ EmptyState: () => null })); + +import { + handlePullRequestDiffDebugEvent, + pullRequestDiffInitialPosition, +} from "./pullRequestDiffDebug"; +import { createReviewDiffHighlightScheduler } from "../review/reviewDiffHighlightScheduler"; +import type { NativeReviewDiffViewProps } from "../diffs/nativeReviewDiffSurface"; + +it("enters at a file header without changing guide focus positioning", () => { + expect(pullRequestDiffInitialPosition("last-file", -1)).toEqual({ + initialFileId: "last-file", + initialRowIndex: -1, + }); + expect(pullRequestDiffInitialPosition("last-file", 904)).toEqual({ + initialFileId: "", + initialRowIndex: 901, + }); + expect(pullRequestDiffInitialPosition(undefined, -1)).toEqual({ + initialFileId: "", + initialRowIndex: -1, + }); +}); + +it.each([true, false])( + "starts warm highlighting at the declared file without a JS jump (iOS: %s)", + async (isIos) => { + vi.useFakeTimers(); + // A warm engine can issue its source RPC immediately when the scheduler flushes. + const sourceRequest = vi.fn(); + const scheduler = createReviewDiffHighlightScheduler(sourceRequest); + const scrollToFile = vi.fn(async () => {}); + const loadMore = vi.fn(); + const jumpStarted = { current: true }; + const onDebug: NonNullable = ({ nativeEvent }) => { + if (nativeEvent.message === "visible-range") + scheduler.update({ + firstRowIndex: nativeEvent.firstRowIndex as number, + lastRowIndex: nativeEvent.lastRowIndex as number, + }); + }; + const emit = (nativeEvent: Record) => + handlePullRequestDiffDebugEvent({ nativeEvent } as Parameters[0], { + rowCount: 1000, + targetFileId: "last-file", + focusIndex: -1, + isIos, + nativeView: { current: { scrollToFile, scrollToTop: vi.fn(async () => {}) } }, + jumpStarted, + onDebug, + loadMore, + }); + try { + const position = pullRequestDiffInitialPosition("last-file", -1); + emit({ message: "rows-decoded", rows: 1000 }); + // Native applies the declared file before publishing its viewport. A later + // imperative bridge command cannot retract an RPC started from a top range. + const first = position.initialFileId === "last-file" ? 900 : 0; + emit({ + message: "visible-range", + firstRowIndex: first, + lastRowIndex: first + 40, + totalRows: 1000, + }); + await vi.runOnlyPendingTimersAsync(); + expect(sourceRequest.mock.calls).toEqual([[{ firstRowIndex: 900, lastRowIndex: 940 }]]); + expect(scrollToFile).not.toHaveBeenCalled(); + // Near-end pagination and stale-payload filtering still use native ranges. + emit({ message: "visible-range", firstRowIndex: 950, lastRowIndex: 990, totalRows: 999 }); + expect(loadMore).not.toHaveBeenCalled(); + emit({ message: "visible-range", firstRowIndex: 950, lastRowIndex: 990, totalRows: 1000 }); + expect(loadMore).toHaveBeenCalledOnce(); + } finally { + scheduler.cancel(); + vi.useRealTimers(); + } + }, +); diff --git a/apps/mobile/src/features/pull-requests/pullRequestDiffDebug.ts b/apps/mobile/src/features/pull-requests/pullRequestDiffDebug.ts new file mode 100644 index 0000000000000..c82aa9deeb6d6 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/pullRequestDiffDebug.ts @@ -0,0 +1,63 @@ +import type { RefObject } from "react"; + +import { + isNativeReviewDiffDrawEvent, + type NativeReviewDiffViewHandle, + type NativeReviewDiffViewProps, +} from "../diffs/nativeReviewDiffSurface"; + +export function pullRequestDiffInitialPosition( + targetFileId: string | undefined, + focusIndex: number, +): Pick { + // Explicit empty values also clear a previous destination on a reused native view. + return { + initialRowIndex: focusIndex >= 0 ? Math.max(0, focusIndex - 3) : -1, + initialFileId: focusIndex >= 0 ? "" : (targetFileId ?? ""), + }; +} + +export function handlePullRequestDiffDebugEvent( + event: Parameters>[0], + input: { + readonly rowCount: number; + readonly targetFileId: string | undefined; + readonly focusIndex: number; + readonly isIos: boolean; + readonly nativeView: RefObject; + readonly jumpStarted: RefObject; + readonly onDebug: NonNullable; + readonly loadMore: () => void; + }, +) { + const drawn = isNativeReviewDiffDrawEvent(event.nativeEvent); + // Delivery outcomes and content mounts must reach the bridge even before rows decode. + if (!drawn) input.onDebug(event); + const decoded = + event.nativeEvent.message === "rows-decoded" && event.nativeEvent.rows === input.rowCount; + if (!drawn && !decoded) return; + // Filtering replaces the native row payload asynchronously. Old draw events + // must not consume the file jump or highlight the new payload's row indices. + if ( + typeof event.nativeEvent.totalRows === "number" && + event.nativeEvent.totalRows !== input.rowCount + ) + return; + if (drawn) input.onDebug(event); + if ( + input.targetFileId && + !input.jumpStarted.current && + input.nativeView.current && + (!input.isIos || decoded) + ) { + input.jumpStarted.current = true; + if (input.focusIndex >= 0) return; + void input.nativeView.current.scrollToFile(input.targetFileId, false).catch(() => { + input.jumpStarted.current = false; + }); + return; + } + if (!drawn) return; + const lastRow = event.nativeEvent.lastRowIndex; + if (typeof lastRow === "number" && lastRow >= input.rowCount - 40) input.loadMore(); +} diff --git a/apps/mobile/src/features/pull-requests/pullRequestDiffHighlightFiles.test.ts b/apps/mobile/src/features/pull-requests/pullRequestDiffHighlightFiles.test.ts new file mode 100644 index 0000000000000..3d419f262da39 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/pullRequestDiffHighlightFiles.test.ts @@ -0,0 +1,440 @@ +import { highlightCode } from "../../lib/highlights"; + +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; +import { AsyncResult } from "effect/reactivity"; + +import { highlightNativeReviewDiffVisibleRows } from "../diffs/nativeReviewDiffHighlighter"; +import type { NativeReviewDiffFile } from "../diffs/nativeReviewDiffTypes"; +import type { ReviewParsedDiff, ReviewRenderableFile } from "../review/reviewModel"; +import { combinePullRequestDiffPages } from "./pullRequestDiffPages"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { + createPullRequestDiffHighlightCache, + createPullRequestDiffHighlightFiles, +} from "./pullRequestDiffHighlightFiles"; + +vi.mock("expo", async () => { + const { createWasmHighlightsModule } = await import("../../lib/highlights.testing"); + return { requireOptionalNativeModule: () => createWasmHighlightsModule() }; +}); + +const target = { + environmentId: EnvironmentId.make("remote-environment"), + input: { + projectId: ProjectId.make("project"), + repository: "org/repo", + number: 23, + host: "forge.example.com", + }, +}; + +function file(id: string, path: string, previousPath: string | null): ReviewRenderableFile { + return { + id, + path, + previousPath, + cacheKey: id, + changeType: previousPath ? "rename-changed" : "change", + additions: 1, + deletions: 1, + languageHint: "typescript", + additionLines: [], + deletionLines: [], + rows: [], + }; +} + +function setup() { + const parsed = { + kind: "files", + fileCount: 2, + additions: 2, + deletions: 2, + notice: null, + files: [file("selected", "renderer.ts", "old-renderer.ts"), file("unseen", "unseen.ts", null)], + } satisfies ReviewParsedDiff; + const files: NativeReviewDiffFile[] = parsed.files.map((file) => ({ + id: file.id, + path: file.path, + language: "typescript", + additions: 1, + deletions: 1, + })); + return { parsed, files }; +} + +it("loads only the selected PR file using its remote target, commit, and rename paths", async () => { + const { parsed, files } = setup(); + const contents = { + oldContents: "/* Previous renderer\nconst removed = 1;\n*/", + newContents: "const heading = `Current renderer\nWelcome to the workspace.\n`;", + }; + const fetch = vi.fn(async () => AsyncResult.success(contents)); + const loaded = createPullRequestDiffHighlightFiles( + { target, commit: "abc123" }, + parsed, + files, + fetch, + ); + expect(fetch).not.toHaveBeenCalled(); + const rows = [ + { + kind: "line" as const, + id: "removed", + fileId: "selected", + change: "delete" as const, + oldLineNumber: 2, + newLineNumber: null, + content: "const removed = 1;", + }, + { + kind: "line" as const, + id: "added", + fileId: "selected", + change: "add" as const, + oldLineNumber: null, + newLineNumber: 2, + content: "Welcome to the workspace.", + }, + { + kind: "line" as const, + id: "unseen", + fileId: "unseen", + change: "add" as const, + oldLineNumber: null, + newLineNumber: 1, + content: "export const unseen = true;", + }, + ]; + const result = await highlightNativeReviewDiffVisibleRows({ + rows, + files: loaded, + scheme: "dark", + firstRowIndex: 0, + lastRowIndex: 1, + overscanRows: 0, + }); + expect(fetch.mock.calls).toEqual([ + [ + { + ...target, + input: { + ...target.input, + commit: "abc123", + changeType: "rename-changed", + oldPath: "old-renderer.ts", + newPath: "renderer.ts", + }, + }, + ], + ]); + expect(result.tokensByRowId.removed).toEqual( + highlightCode(contents.oldContents, "typescript", "dark")[1], + ); + expect(result.tokensByRowId.added).toEqual( + highlightCode(contents.newContents, "typescript", "dark")[1], + ); + expect(result.tokensByRowId.unseen).toBeUndefined(); + expect(await loaded[0]!.loadContents!()).toBe(contents); + expect(fetch).toHaveBeenCalledTimes(1); +}); + +it.each([null, "Some file contents are unavailable"])( + "retains fetched and unavailable source when another diff page is appended (notice: %s)", + async (notice) => { + const { parsed, files } = setup(); + const contents = { oldContents: "before", newContents: "after" }; + const fetch = vi.fn(async ({ input }: { input: { newPath: string } }) => { + if (input.newPath === "unseen.ts") throw { reason: "unsupported" }; + return AsyncResult.success(contents); + }); + const cache = createPullRequestDiffHighlightCache(); + const first = createPullRequestDiffHighlightFiles({ target }, parsed, files, fetch, cache); + expect(await first[0]!.loadContents!()).toBe(contents); + expect(await first[1]!.loadContents!()).toBeNull(); + const appended = file("appended", "appended.ts", null); + const next = createPullRequestDiffHighlightFiles( + { target }, + { ...parsed, files: [...parsed.files, appended], fileCount: 3, notice }, + [...files, { ...files[1]!, id: appended.id, path: appended.path }], + fetch, + cache, + ); + expect(await next[0]!.loadContents!()).toBe(contents); + expect(await next[1]!.loadContents!()).toBeNull(); + expect(fetch).toHaveBeenCalledTimes(2); + }, +); + +it("does not reuse source from another diff snapshot", async () => { + const { parsed, files } = setup(); + const fetch = vi.fn(async () => + AsyncResult.success({ oldContents: "", newContents: "const current = true;" }), + ); + const cache = createPullRequestDiffHighlightCache(); + const first = createPullRequestDiffHighlightFiles({ target }, parsed, files, fetch, cache); + const second = createPullRequestDiffHighlightFiles( + { target }, + { ...parsed, files: parsed.files.map((file) => ({ ...file })) }, + files, + fetch, + cache, + ); + await first[0]!.loadContents!(); + await second[0]!.loadContents!(); + expect(fetch).toHaveBeenCalledTimes(2); +}); + +it.each([ + [ + "environment", + { target: { ...target, environmentId: EnvironmentId.make("other") }, commit: "abc" }, + ], + [ + "project", + { + target: { ...target, input: { ...target.input, projectId: ProjectId.make("other") } }, + commit: "abc", + }, + ], + [ + "repository", + { target: { ...target, input: { ...target.input, repository: "other/repo" } }, commit: "abc" }, + ], + ["PR", { target: { ...target, input: { ...target.input, number: 24 } }, commit: "abc" }], + [ + "host", + { target: { ...target, input: { ...target.input, host: "other.example.com" } }, commit: "abc" }, + ], + ["commit", { target, commit: "def" }], + ["unpinned commit", { target }], +])("isolates retained file objects after changing the %s", async (_name, source) => { + const { parsed, files } = setup(); + const cache = createPullRequestDiffHighlightCache(); + const previous = { oldContents: "", newContents: "previous" }; + const current = { oldContents: "", newContents: "current" }; + const fetch = vi + .fn() + .mockResolvedValueOnce(AsyncResult.success(previous)) + .mockResolvedValueOnce(AsyncResult.success(current)); + const first = createPullRequestDiffHighlightFiles( + { target, commit: "abc" }, + parsed, + files, + fetch, + cache, + ); + expect(await first[0]!.loadContents!()).toBe(previous); + const second = createPullRequestDiffHighlightFiles(source, parsed, files, fetch, cache); + expect(await second[0]!.loadContents!()).toBe(current); + expect(await first[0]!.loadContents!()).toBe(previous); + expect(fetch).toHaveBeenCalledTimes(2); +}); + +it("does not reuse a replaced later-page file with the same ID", async () => { + const { parsed, files } = setup(); + const cache = createPullRequestDiffHighlightCache(); + const previous = { oldContents: "", newContents: "previous" }; + const current = { oldContents: "", newContents: "current" }; + const fetch = vi + .fn() + .mockResolvedValueOnce(AsyncResult.success(previous)) + .mockResolvedValueOnce(AsyncResult.success(current)); + const first = createPullRequestDiffHighlightFiles({ target }, parsed, files, fetch, cache); + expect(await first[1]!.loadContents!()).toBe(previous); + const second = createPullRequestDiffHighlightFiles( + { target }, + { ...parsed, files: [parsed.files[0]!, { ...parsed.files[1]! }] }, + files, + fetch, + cache, + ); + expect(await second[1]!.loadContents!()).toBe(current); + expect(fetch).toHaveBeenCalledTimes(2); +}); + +it.each(["permanent", "transient"])( + "does not reuse a replaced later-page file's %s failure with the same ID", + async (failure) => { + const { parsed, files } = setup(); + const cache = createPullRequestDiffHighlightCache(); + const contents = { oldContents: "", newContents: "current" }; + const fetch = vi.fn(async () => AsyncResult.success(contents)); + const error = failure === "permanent" ? { reason: "unsupported" } : new Error("Offline"); + fetch.mockRejectedValueOnce(error); + if (failure === "transient") fetch.mockRejectedValueOnce(error); + const first = createPullRequestDiffHighlightFiles({ target }, parsed, files, fetch, cache); + if (failure === "transient") await expect(first[1]!.loadContents!()).rejects.toThrow("Offline"); + expect(await first[1]!.loadContents!()).toBeNull(); + const second = createPullRequestDiffHighlightFiles( + { target }, + { ...parsed, files: [parsed.files[0]!, { ...parsed.files[1]! }] }, + files, + fetch, + cache, + ); + expect(await second[1]!.loadContents!()).toBe(contents); + expect(await first[1]!.loadContents!()).toBeNull(); + expect(fetch).toHaveBeenCalledTimes(failure === "permanent" ? 2 : 3); + }, +); + +it("does not reuse reconstructed source after an appended page makes completeness uncertain", async () => { + const { parsed, files } = setup(); + const cache = createPullRequestDiffHighlightCache(); + const reconstructed = { oldContents: "", newContents: "reconstructed" }; + const authoritative = { oldContents: "", newContents: "authoritative" }; + parsed.files[0] = { ...parsed.files[0]!, completePatchSource: () => reconstructed }; + const fetch = vi.fn(async () => AsyncResult.success(authoritative)); + const first = createPullRequestDiffHighlightFiles({ target }, parsed, files, fetch, cache); + expect(await first[0]!.loadContents!()).toBe(reconstructed); + const next = createPullRequestDiffHighlightFiles( + { target }, + { ...parsed, notice: "Some file contents are unavailable" }, + files, + fetch, + cache, + ); + expect(await next[0]!.loadContents!()).toBe(authoritative); + expect(fetch).toHaveBeenCalledOnce(); +}); + +it.each([ + ["characters", 2, 4_500_000], + ["file count", 5, 1], +])( + "shares the source cache's aggregate %s budget across appended pages", + async (_name, count, size) => { + const { parsed, files } = setup(); + const cache = createPullRequestDiffHighlightCache(); + const contents = { oldContents: "", newContents: "x".repeat(size) }; + const fetch = vi.fn(async () => AsyncResult.success(contents)); + const first = createPullRequestDiffHighlightFiles({ target }, parsed, files, fetch, cache); + expect(await first[0]!.loadContents!()).toBe(contents); + let next = first; + for (let i = 1; i < count; i++) { + const appended = file(`page-${i}`, `page-${i}.ts`, null); + parsed.files.push(appended); + files.push({ ...files[0]!, id: appended.id, path: appended.path }); + next = createPullRequestDiffHighlightFiles( + { target }, + { ...parsed }, + [...files], + fetch, + cache, + ); + expect(await next.at(-1)!.loadContents!()).toBe(contents); + } + expect(fetch).toHaveBeenCalledTimes(count); + expect(await next[0]!.loadContents!()).toBe(contents); + expect(fetch).toHaveBeenCalledTimes(count + 1); + }, +); + +function addedPatch(body: string, header = "@@ -0,0 +1,2 @@") { + return `diff --git a/file.c b/file.c\nnew file mode 100644\n--- /dev/null\n+++ b/file.c\n${header}\n${body}`; +} + +function loadPatch(patch: string, truncated = false) { + const parsed = combinePullRequestDiffPages( + [{ cursor: null, result: { patch, truncated, nextCursor: null } }], + new WeakMap(), + "complete-source", + sourceControlClients.get("github").changeRequest, + ); + if (parsed.kind !== "files") throw new Error("Expected parsed files"); + const files = parsed.files.map((file) => ({ + id: file.id, + path: file.path, + language: "c" as const, + additions: file.additions, + deletions: file.deletions, + })); + const authoritative = { oldContents: "provider old", newContents: "provider new" }; + const fetch = vi.fn(async () => AsyncResult.success(authoritative)); + const loaded = createPullRequestDiffHighlightFiles({ target }, parsed, files, fetch); + return { loaded, fetch, authoritative }; +} + +it.each([0, 1])( + "loads a complete 100k-line addition with empty-side start %s beyond the RPC cap", + async (emptyStart) => { + const source = "/* generated C */ int value = 1;\n".repeat(100_000); + const body = source.replace(/^/gm, "+").slice(0, -1); + const { loaded, fetch } = loadPatch(addedPatch(body, `@@ -${emptyStart},0 +1,100000 @@`)); + expect(source.length).toBeGreaterThan(1_048_576); + const contents = await loaded[0]!.loadContents!(); + expect(contents?.oldContents).toBe(""); + expect(contents?.newContents.length).toBe(source.length); + expect(contents?.newContents === source).toBe(true); + expect(fetch).not.toHaveBeenCalled(); + }, +); + +it.each([ + ["LF", "+first\n+last\n", "first\nlast\n"], + ["CRLF", "+first\r\n+last\r\n", "first\r\nlast\r\n"], + ["missing EOF newline", "+first\r\n+last\n\\ No newline at end of file\n", "first\r\nlast"], + ["final bare CR", "+first\r\n+last\r\n\\ No newline at end of file\n", "first\r\nlast\r"], +])("preserves %s for complete additions and deletions", async (_name, body, source) => { + for (const emptyStart of [0, 1]) { + const added = addedPatch(body, `@@ -${emptyStart},0 +1,2 @@`); + const deleted = added + .replace("new file mode", "deleted file mode") + .replace("--- /dev/null\n+++ b/file.c", "--- a/file.c\n+++ /dev/null") + .replace(`@@ -${emptyStart},0 +1,2 @@`, `@@ -1,2 +${emptyStart},0 @@`); + const deletionPatch = deleted.replace(body, body.replace(/\+/g, "-")); + for (const [patch, expected] of [ + [added, { oldContents: "", newContents: source }], + [deletionPatch, { oldContents: source, newContents: "" }], + ] as const) { + const { loaded, fetch } = loadPatch(patch); + expect(await loaded[0]!.loadContents!()).toEqual(expected); + expect(fetch).not.toHaveBeenCalled(); + } + } +}); + +it.each([ + ["too few lines", addedPatch("+first\n", "@@ -0,0 +1,2 @@")], + ["too many lines", addedPatch("+first\n+last\n", "@@ -0,0 +1 @@")], + ["zero repaired count", addedPatch("+first\n+last\n", "@@ -0,0 +1,0 @@")], + ["unterminated patch line", addedPatch("+first\n+last")], + ["missing count", addedPatch("+first\n+last\n", "@@ -0,0 +1, @@")], + ["gap", addedPatch("+first\n+last\n", "@@ -0,0 +2,2 @@")], + ["invalid empty-side start", addedPatch("+first\n+last\n", "@@ -2,0 +1,2 @@")], + ["converted underfull hunk", addedPatch("+first\n", "@@ -1,0 +1,2 @@")], + ["converted overfull hunk", addedPatch("+first\n+last\n", "@@ -1,0 +1 @@")], + ["converted nonempty old side", addedPatch("+first\n+last\n", "@@ -1,1 +1,2 @@")], + ["context", addedPatch(" first\n+last\n", "@@ -1 +1,2 @@")], + ["mixed sides", addedPatch("-first\n+last\n", "@@ -1 +1 @@")], + ["second hunk", addedPatch("+first\n@@ -0,0 +2 @@\n+last\n", "@@ -0,0 +1 @@")], + ["early EOF marker", addedPatch("+first\n\\ No newline at end of file\n+last\n")], + ["unknown marker", addedPatch("+first\n+last\n\\ unknown\n")], + ["trailing garbage", addedPatch("+first\n+last\nignored\n")], + ["submodule", addedPatch("+first\n+last\n").replace("100644", "160000")], + [ + "binary", + addedPatch("+first\n+last\n").replace("--- /dev/null", "Binary files differ\n--- /dev/null"), + ], + ["changed", addedPatch("+first\n+last\n").replace("new file mode 100644\n", "")], + [ + "rename", + addedPatch("+first\n+last\n").replace( + "new file mode 100644", + "similarity index 50%\nrename from old.c\nrename to file.c", + ), + ], + ["truncation marker", addedPatch("+first\n+last\n\n[truncated]\n")], +])("uses authoritative RPC source when completeness is unproven: %s", async (_name, patch) => { + const { loaded, fetch, authoritative } = loadPatch(patch); + expect(await loaded[0]!.loadContents!()).toBe(authoritative); + expect(fetch).toHaveBeenCalledOnce(); +}); + +it("rejects a complete-looking patch when the provider marks any page truncated", async () => { + const { loaded, fetch, authoritative } = loadPatch(addedPatch("+first\n+last\n"), true); + expect(await loaded[0]!.loadContents!()).toBe(authoritative); + expect(fetch).toHaveBeenCalledOnce(); +}); diff --git a/apps/mobile/src/features/pull-requests/pullRequestDiffHighlightFiles.ts b/apps/mobile/src/features/pull-requests/pullRequestDiffHighlightFiles.ts new file mode 100644 index 0000000000000..8d9432368f7e8 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/pullRequestDiffHighlightFiles.ts @@ -0,0 +1,113 @@ +import { + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import type { + PullRequestDiffFileContentsInput, + PullRequestDiffFileContentsResult, +} from "@t3tools/contracts"; + +import { createNativeReviewDiffSourceLoader } from "../diffs/nativeReviewDiffSource"; +import { + diffHighlightTraceHash, + diffHighlightTraceId, + traceDiffHighlight, +} from "../diffs/nativeReviewDiffTrace"; +import type { NativeReviewDiffFile, NativeReviewDiffSource } from "../diffs/nativeReviewDiffTypes"; +import type { ReviewParsedDiff, ReviewRenderableFile } from "../review/reviewModel"; +import type { pullRequestTarget } from "./usePullRequestDiff"; + +type FetchContents = (request: { + readonly environmentId: ReturnType["environmentId"]; + readonly input: PullRequestDiffFileContentsInput; +}) => Promise>; + +type SourceRequest = () => Promise; + +interface SourceSnapshot { + readonly firstFile: ReviewRenderableFile; + readonly scope: string; + readonly load: ReturnType>; + readonly requests: WeakMap< + ReviewRenderableFile, + { readonly completePatch: boolean; readonly request: SourceRequest } + >; +} + +export function createPullRequestDiffHighlightCache(): { snapshot?: SourceSnapshot } { + return {}; +} + +export function createPullRequestDiffHighlightFiles( + source: { + readonly target: ReturnType; + readonly commit?: string; + }, + parsed: ReviewParsedDiff, + files: ReadonlyArray, + fetchContents: FetchContents, + cache = createPullRequestDiffHighlightCache(), +): ReadonlyArray { + if (parsed.kind !== "files" || !parsed.files[0]) return files; + const byId = new Map(parsed.files.map((file) => [file.id, file])); + const { target, commit } = source; + traceDiffHighlight("source-target", { + environment: diffHighlightTraceHash(target.environmentId), + project: diffHighlightTraceHash(target.input.projectId), + pr: target.input.number, + gitcafe: target.input.host === "git.cafe", + }); + const scope = JSON.stringify([ + target.environmentId, + target.input.projectId, + target.input.repository, + target.input.number, + target.input.host, + commit, + ]); + const firstFile = parsed.files[0]; + // Appended pages share one loader budget; a new first file starts a new snapshot. + let snapshot = cache.snapshot; + if (!snapshot || snapshot.firstFile !== firstFile || snapshot.scope !== scope) { + snapshot = { + firstFile, + scope, + load: createNativeReviewDiffSourceLoader((request) => request()), + requests: new WeakMap(), + }; + cache.snapshot = snapshot; + } + const { load, requests } = snapshot; + return files.map((nativeFile) => { + const file = byId.get(nativeFile.id); + if (!file) return nativeFile; + const completePatch = parsed.notice === null && !!file.completePatchSource; + const cached = requests.get(file); + if (cached?.completePatch === completePatch) + return { ...nativeFile, loadContents: (signal) => load(cached.request, signal) }; + const request: SourceRequest = async () => { + if (completePatch && file.completePatchSource) return file.completePatchSource(); + traceDiffHighlight("source-params", { + file: diffHighlightTraceId(file.id), + oldPath: diffHighlightTraceHash(file.previousPath ?? file.path), + newPath: diffHighlightTraceHash(file.path), + changeType: file.changeType, + hasCommit: !!commit, + }); + const result = await fetchContents({ + ...target, + input: { + ...target.input, + ...(commit ? { commit } : {}), + changeType: file.changeType, + oldPath: file.previousPath ?? file.path, + newPath: file.path, + }, + }); + if (result._tag !== "Success") throw squashAtomCommandFailure(result); + return result.value; + }; + requests.set(file, { completePatch, request }); + return { ...nativeFile, loadContents: (signal) => load(request, signal) }; + }); +} diff --git a/apps/mobile/src/features/pull-requests/pullRequestDiffPages.ts b/apps/mobile/src/features/pull-requests/pullRequestDiffPages.ts index 8c3258e44b58c..c30f3a3198ad5 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestDiffPages.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestDiffPages.ts @@ -65,7 +65,8 @@ export function combinePullRequestDiffPages( additions: files.reduce((total, file) => total + file.additions, 0), deletions: files.reduce((total, file) => total + file.deletions, 0), notice: - pages.some((page) => page.result.truncated) || parsed.some((page) => page.kind === "raw") + pages.some((page) => page.result.truncated) || + parsed.some((page) => page.kind === "raw" || (page.kind === "files" && page.notice !== null)) ? `Some file contents are unavailable. Open the ${terminology.singular} on its provider to see the full changes.` : null, }; diff --git a/apps/mobile/src/features/pull-requests/usePullRequestDiff.test.tsx b/apps/mobile/src/features/pull-requests/usePullRequestDiff.test.tsx new file mode 100644 index 0000000000000..0548cbb241929 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/usePullRequestDiff.test.tsx @@ -0,0 +1,118 @@ +import { RegistryContext } from "@effect/atom-react"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import type { PullRequestDiffResult } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { Atom, AtomRegistry } from "effect/reactivity"; +import { act, useLayoutEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ diff: vi.fn() })); +vi.mock("../../state/pull-requests", () => ({ composerPullRequests: { diff: mocks.diff } })); + +import { usePullRequestDiff } from "./usePullRequestDiff"; + +function patch(path: string, nextCursor: string | null = null): PullRequestDiffResult { + return { + patch: `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -1 +1 @@\n-old\n+new\n`, + truncated: false, + nextCursor, + }; +} + +function setup() { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const registry = AtomRegistry.make(); + const requests: { + cursor: string | null; + resolve: (value: PullRequestDiffResult) => void; + reject: (error: Error) => void; + }[] = []; + const queries = Atom.family((cursor: string | null) => + Atom.make( + Effect.tryPromise( + () => + new Promise((resolve, reject) => { + requests.push({ cursor, resolve, reject }); + }), + ), + ).pipe(Atom.keepAlive), + ); + mocks.diff.mockImplementation((target: { input: { cursor?: string } }) => + queries(target.input.cursor ?? null), + ); + const root = createRoot(document.createElement("div")); + let current: ReturnType; + function Probe() { + const result = usePullRequestDiff( + { + environmentId: "environment", + projectId: "project", + repository: "repo", + number: 1, + }, + sourceControlClients.get("github").changeRequest, + ); + useLayoutEffect(() => { + current = result; + }); + return null; + } + act(() => + root.render( + + + , + ), + ); + return { + requests, + get current() { + return current; + }, + async succeed(index: number, result: PullRequestDiffResult) { + await act(async () => { + requests[index]!.resolve(result); + }); + }, + async fail(index: number) { + await act(async () => { + requests[index]!.reject(new Error("Connection lost")); + }); + }, + [Symbol.dispose]() { + act(() => root.unmount()); + registry.dispose(); + }, + }; +} + +it("retries a failed continuation without discarding loaded files or restarting page one", async () => { + using screen = setup(); + await screen.succeed(0, patch("first.ts", "second")); + act(() => screen.current.loadMore()); + await screen.fail(1); + expect(screen.current.error).not.toBeNull(); + expect(screen.current.patch).toContain("first.ts"); + + act(() => screen.current.refresh()); + expect(screen.requests.map((request) => request.cursor)).toEqual([null, "second", "second"]); + expect(screen.current.patch).toContain("first.ts"); + await screen.succeed(2, patch("second.ts")); + expect(screen.current.error).toBeNull(); + expect(screen.current.parsed.kind).toBe("files"); + if (screen.current.parsed.kind !== "files") throw new Error("Expected files"); + expect(screen.current.parsed.files.map((file) => file.path)).toEqual(["first.ts", "second.ts"]); + expect(screen.current.nextCursor).toBeNull(); +}); + +it("still refreshes from page one after successfully loading a continuation", async () => { + using screen = setup(); + await screen.succeed(0, patch("first.ts", "second")); + act(() => screen.current.loadMore()); + await screen.succeed(1, patch("second.ts")); + act(() => screen.current.refresh()); + await screen.succeed(2, patch("refreshed.ts")); + if (screen.current.parsed.kind !== "files") throw new Error("Expected files"); + expect(screen.current.parsed.files.map((file) => file.path)).toEqual(["refreshed.ts"]); +}); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts b/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts index cef9be5db173f..2b33c86871ac8 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts @@ -87,10 +87,15 @@ export function usePullRequestDiff( if (!canLoadMore) return; setState({ key, cursor: nextCursor, pages }); }, [canLoadMore, key, nextCursor, pages, setState]); + const { error, refresh: refreshPage } = query; const refresh = useCallback(() => { + if (cursor !== null && error !== null) { + refreshPage(); + return; + } setState({ key, cursor: null, pages: NO_PAGES }); refreshFirstPage(); - }, [key, refreshFirstPage, setState]); + }, [cursor, error, refreshPage, key, refreshFirstPage, setState]); const onPullRefresh = useCallback(() => { setState({ key, cursor: null, pages: NO_PAGES }); onRefresh(); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestDiffHighlightFiles.ts b/apps/mobile/src/features/pull-requests/usePullRequestDiffHighlightFiles.ts new file mode 100644 index 0000000000000..ddbc8520dcd14 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/usePullRequestDiffHighlightFiles.ts @@ -0,0 +1,78 @@ +import { useMemo } from "react"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; + +import { environmentCatalog } from "../../connection/catalog"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { composerPullRequests } from "../../state/pull-requests"; +import { useAtomCommand } from "../../state/use-atom-command"; +import type { NativeReviewDiffFile } from "../diffs/nativeReviewDiffTypes"; +import { diffHighlightTraceHash, traceDiffHighlight } from "../diffs/nativeReviewDiffTrace"; +import type { ReviewParsedDiff } from "../review/reviewModel"; +import { + createPullRequestDiffHighlightCache, + createPullRequestDiffHighlightFiles, +} from "./pullRequestDiffHighlightFiles"; +import { pullRequestTarget, type PullRequestDiffParams } from "./usePullRequestDiff"; + +function traceSourceEndpoint(environmentId: string, url: string) { + try { + const endpoint = new URL(url); + traceDiffHighlight("source-endpoint", { + environment: diffHighlightTraceHash(environmentId), + origin: diffHighlightTraceHash(endpoint.origin), + port: endpoint.port || (endpoint.protocol === "https:" ? "443" : "80"), + }); + } catch { + traceDiffHighlight("source-endpoint-invalid"); + } +} + +export function usePullRequestDiffHighlightFiles( + params: PullRequestDiffParams, + parsed: ReviewParsedDiff, + files: ReadonlyArray, +) { + const fetchContents = useAtomCommand(composerPullRequests.diffFileContents, { + reportFailure: false, + }); + const { environmentId, projectId, repository, number, host, commit } = params; + const cache = useMemo(() => createPullRequestDiffHighlightCache(), []); + return useMemo(() => { + if (process.env.EXPO_PUBLIC_T3_DIFF_TRACE === "1") { + const catalog = appAtomRegistry.get(environmentCatalog.catalogValueAtom); + const entry = catalog.entries.get(EnvironmentId.make(environmentId)); + const profile = entry && Option.getOrUndefined(entry.profile); + const url = + entry?.target._tag === "PrimaryConnectionTarget" + ? entry.target.httpBaseUrl + : profile?._tag === "BearerConnectionProfile" + ? profile.httpBaseUrl + : undefined; + if (url) { + traceSourceEndpoint(environmentId, url); + } else traceDiffHighlight("source-endpoint-unavailable"); + } + return createPullRequestDiffHighlightFiles( + { + target: pullRequestTarget({ environmentId, projectId, repository, number, host }), + commit, + }, + parsed, + files, + fetchContents, + cache, + ); + }, [ + environmentId, + projectId, + repository, + number, + host, + commit, + parsed, + files, + fetchContents, + cache, + ]); +} diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts index 12317f1bfa7b8..7e620836baccd 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts @@ -1,11 +1,19 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { diffWordsWithSpace } from "diff"; +import { AsyncResult } from "effect/reactivity"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { EnvironmentId, ProjectId, type PullRequestDiffResult } from "@t3tools/contracts"; +import { combinePullRequestDiffPages } from "../pull-requests/pullRequestDiffPages"; +import { createPullRequestDiffHighlightFiles } from "../pull-requests/pullRequestDiffHighlightFiles"; import { buildNativeReviewDiffData, getCachedNativeReviewDiffData, } from "./nativeReviewDiffAdapter"; import type { ReviewInlineComment } from "./reviewCommentSelection"; -import { buildReviewParsedDiff } from "./reviewModel"; +import { buildReviewParsedDiff, type ReviewParsedDiff } from "./reviewModel"; + +vi.mock("diff", { spy: true }); function makeComment(text: string): ReviewInlineComment { return { @@ -38,7 +46,202 @@ function filesPatch(paths: ReadonlyArray) { .join("\n"); } +describe("buildNativeReviewDiffData languages", () => { + it.each([ + ["js", "javascript"], + ["cjs", "javascript"], + ["mjs", "javascript"], + ["jsx", "jsx"], + ["ts", "typescript"], + ["mts", "typescript"], + ["cts", "typescript"], + ["tsx", "tsx"], + ])("assigns %s files their syntax language when deleted", (extension, language) => { + const path = `scripts/dev-runner.${extension}`; + const data = buildNativeReviewDiffData( + buildReviewParsedDiff( + [ + `diff --git a/${path} b/${path}`, + "deleted file mode 100644", + `--- a/${path}`, + "+++ /dev/null", + "@@ -1,2 +0,0 @@", + '-import process from "node:process";', + "-const port = process.env.PORT;", + "", + ].join("\n"), + "deleted-script-language", + ), + ); + + expect(data.files).toMatchObject([{ path, language }]); + expect(data.rows.filter((row) => row.kind === "line")).toMatchObject([ + { content: 'import process from "node:process";', change: "delete" }, + { content: "const port = process.env.PORT;", change: "delete" }, + ]); + }); +}); + describe("getCachedNativeReviewDiffData", () => { + it("keeps compact identities aligned through page append, filtering, comments, and source lookup", async () => { + const scope = JSON.stringify({ remote: 'long-target\\"'.repeat(100) }); + const cache = new WeakMap(); + const firstPage = { + cursor: null, + result: { + patch: filesPatch(["example.ts", "hidden.test.ts"]), + truncated: false, + nextCursor: "next", + }, + }; + const terminology = sourceControlClients.get("github").changeRequest; + const first = combinePullRequestDiffPages([firstPage], cache, scope, terminology); + if (first.kind !== "files") throw new Error("Expected parsed first page"); + const before = getCachedNativeReviewDiffData({ parsedDiff: first }); + const selectedRowId = before.rows.find((row) => row.kind === "line")!.id; + const combined = combinePullRequestDiffPages( + [ + firstPage, + { + cursor: "next", + result: { patch: filesPatch(["last.ts"]), truncated: false, nextCursor: null }, + }, + ], + cache, + scope, + terminology, + ); + if (combined.kind !== "files") throw new Error("Expected parsed appended pages"); + expect(combined.files[0]).toBe(first.files[0]); + const filtered = { + ...combined, + files: combined.files.filter((file) => !file.path.includes(".test.")), + }; + const input = { parsedDiff: filtered, comments: [makeComment("Feedback")] }; + const data = getCachedNativeReviewDiffData(input); + expect(getCachedNativeReviewDiffData(input)).toBe(data); + expect(new Set(data.rows.map((row) => row.id)).size).toBe(data.rows.length); + expect(data.rows.find((row) => row.id === selectedRowId)).toBe( + before.rows.find((row) => row.id === selectedRowId), + ); + // IDs used by native initialFileId/collapse and parsed source lookup share one namespace. + expect(data.files.map((file) => file.id)).toEqual(filtered.files.map((file) => file.id)); + for (const file of filtered.files) { + const header = data.rows.find((row) => row.kind === "file" && row.fileId === file.id)!; + expect(header.filePath).toBe(file.path); + const lines = file.rows.filter((row) => row.kind === "line"); + for (const [lineIndex, line] of lines.entries()) { + const nativeId = data.rowIdByCommentLineId.get(line.id)!; + const row = data.rows.find((row) => row.id === nativeId)!; + expect(row.fileId).toBe(file.id); + expect(row.content).toBe(line.content); + expect(nativeId.length).toBeLessThan(64); + const target = data.commentTargetsByRowId.get(nativeId)!; + expect(target.filePath).toBe(file.path); + expect(target.lines[lineIndex]).toBe(line); + expect(target.lineIndex).toBe(lineIndex); + expect(row.wordDiffRanges?.length).toBeGreaterThan(0); + expect( + row.wordDiffRanges?.map((range) => row.content!.slice(range.start, range.end)), + ).toEqual([line.change === "delete" ? "before" : "after"]); + } + } + const commentIndex = data.rows.findIndex((row) => row.id === "comment-1"); + expect(data.rows[commentIndex - 1]!.id).toBe(selectedRowId); + expect(data.rows[commentIndex]!.fileId).toBe(first.files[0]!.id); + + const target = { + environmentId: EnvironmentId.make("remote"), + input: { + projectId: ProjectId.make("project"), + repository: "org/repo", + number: 23, + }, + }; + const contents = { oldContents: "old source", newContents: "new source" }; + const fetch = vi.fn(async () => AsyncResult.success(contents)); + const highlightFiles = createPullRequestDiffHighlightFiles( + { target }, + filtered, + data.files, + fetch, + ); + expect(await highlightFiles[1]!.loadContents!()).toBe(contents); + expect(fetch).toHaveBeenCalledExactlyOnceWith({ + ...target, + input: { ...target.input, changeType: "change", oldPath: "last.ts", newPath: "last.ts" }, + }); + expect(JSON.stringify(data.rows).length).toBeLessThan(5000); + }); + + it("keeps Pierre's matching lines in the GitHub credential pinning excerpt", () => { + const parsed = buildReviewParsedDiff( + [ + "diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts", + "--- a/packages/source-control-github/src/server/GitHubPullRequestApi.ts", + "+++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts", + "@@ -1221,11 +1240,4 @@", + "- operation: input.operation,", + "- path: input.path,", + "- ...(Option.isSome(previous) ? { ifNoneMatch: previous.value.etag } : {}),", + "- })", + "- .pipe(", + "- Effect.provideService(GitHubRequestPolicy.PinnedGitHubCredential, {", + "- host,", + "- token,", + "- credentialFingerprint: fingerprint,", + "- }),", + "- );", + "+ token,", + "+ credentialFingerprint: fingerprint,", + "+ }),", + "+ );", + "", + ].join("\n"), + "credential-pinning", + ); + expect(parsed.kind).toBe("files"); + if (parsed.kind !== "files") return; + expect( + parsed.files[0]?.rows.find((row) => row.kind === "line" && row.newLineNumber === 1242), + ).toMatchObject({ + comparison: { change: "delete", tokenIndex: 9 }, + }); + const data = buildNativeReviewDiffData(parsed); + expect( + data.rows.find((row) => row.kind === "line" && row.newLineNumber === 1242)?.wordDiffRanges, + ).toBeUndefined(); + }); + + it.each([ + [true, false, "Old file has no newline at end of file."], + [false, true, "New file has no newline at end of file."], + [true, true, "Old and new files have no newline at end of file."], + [false, false, null], + ])("shows missing-newline notices for old=%s new=%s", (oldMissing, newMissing, notice) => { + const data = buildNativeReviewDiffData( + buildReviewParsedDiff( + [ + "diff --git a/example.ts b/example.ts", + "--- a/example.ts", + "+++ b/example.ts", + "@@ -1 +1 @@", + "-value", + ...(oldMissing ? ["\\ No newline at end of file"] : []), + "+value", + ...(newMissing ? ["\\ No newline at end of file"] : []), + "", + ].join("\n"), + "native-eof", + ), + ); + expect(data.rows.filter((row) => row.kind === "notice").map((row) => row.text)).toEqual( + notice ? [notice] : [], + ); + expect(data.rows.filter((row) => row.kind === "line")).toHaveLength(2); + expect(data.commentTargetsByRowId.size).toBe(2); + }); + it("keeps cached IDs and targets local to each parsed section and file layout", () => { const original = filesPatch(["example.ts", "second.ts"]); const variants = [ @@ -89,9 +292,9 @@ describe("buildNativeReviewDiffData row bound", () => { expect(data).toMatchObject({ additions: 201_000, deletions: 0 }); const hugeRows = data.rows.filter((row) => row.fileId === hugeHeader?.fileId); expect(hugeRows.filter((row) => row.kind === "line" || row.kind === "hunk")).toHaveLength( - 2_000, + 100_000, ); - expect(hugeRows.at(-2)).toMatchObject({ kind: "line", newLineNumber: 1_999 }); + expect(hugeRows.at(-2)).toMatchObject({ kind: "line", newLineNumber: 99_999 }); expect(hugeRows.at(-1)).toMatchObject({ kind: "notice", text: "This diff is too large to show in full.", @@ -106,3 +309,65 @@ describe("buildNativeReviewDiffData row bound", () => { expect(ordinaryRows.some((row) => row.kind === "notice")).toBe(false); }); }); + +describe("buildNativeReviewDiffData word comparison bound", () => { + function modifiedFilePatch(pairCount: number, contextCount = 0) { + const lineCount = pairCount + contextCount; + return [ + "diff --git a/example.ts b/example.ts", + "--- a/example.ts", + "+++ b/example.ts", + `@@ -1,${lineCount} +1,${lineCount} @@`, + ...Array.from({ length: contextCount }, (_, index) => ` const context${index} = true;`), + ...Array.from( + { length: pairCount }, + (_, index) => `-const value${index} = renderPanel({ label: "before", enabled: true });`, + ), + ...Array.from( + { length: pairCount }, + (_, index) => `+const value${index} = renderPanel({ label: "after", enabled: true });`, + ), + ].join("\n"); + } + + it("bounds eager word comparisons without truncating a large modified file", () => { + const parsedDiff = buildReviewParsedDiff(modifiedFilePatch(20_000), "native-word-bound"); + const comparisons = vi.mocked(diffWordsWithSpace).mockClear(); + const data = buildNativeReviewDiffData(parsedDiff); + + expect(comparisons).toHaveBeenCalledTimes(1_000); + expect(data).toMatchObject({ additions: 20_000, deletions: 20_000 }); + const lines = data.rows.filter((row) => row.kind === "line"); + expect(lines).toHaveLength(40_000); + expect(data.rows.some((row) => row.kind === "notice")).toBe(false); + for (const offset of [0, 20_000]) { + expect(lines[offset]?.wordDiffRanges?.length).toBeGreaterThan(0); + expect(lines[offset + 999]?.wordDiffRanges?.length).toBeGreaterThan(0); + expect(lines[offset + 1_000]?.wordDiffRanges).toBeUndefined(); + } + expect(lines.at(-1)).toMatchObject({ + content: 'const value19999 = renderPanel({ label: "after", enabled: true });', + newLineNumber: 20_000, + }); + expect(data.commentTargetsByRowId.size).toBe(40_000); + expect(data.commentTargetsByRowId.get(lines.at(-1)!.id)?.lineIndex).toBe(39_999); + }); + + it.each([0, 20_000])("preserves small word differences with %s context rows", (contextCount) => { + const parsedDiff = buildReviewParsedDiff( + modifiedFilePatch(2, contextCount), + "native-word-context", + ); + const comparisons = vi.mocked(diffWordsWithSpace).mockClear(); + const data = buildNativeReviewDiffData(parsedDiff); + + expect(comparisons).toHaveBeenCalledTimes(2); + const changes = data.rows.filter((row) => row.kind === "line" && row.change !== "context"); + expect(changes).toHaveLength(4); + for (const row of changes) { + expect( + row.wordDiffRanges?.map((range) => row.content!.slice(range.start, range.end)), + ).toEqual([row.change === "delete" ? "before" : "after"]); + } + }); +}); diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index d49302764b1ca..02c699fe0979d 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -17,8 +17,10 @@ import type { ReviewInlineComment } from "./reviewCommentSelection"; const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4; const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45; +// Keep eager word matching within the old 2,000-row budget while retaining more rendered rows. +const NATIVE_REVIEW_MAX_WORD_DIFF_COMPARISONS = 1_000; // Bound the rows serialized to the native heap before mapping and word matching. -const NATIVE_REVIEW_MAX_FILE_ROWS = 2_000; +const NATIVE_REVIEW_MAX_FILE_ROWS = 100_000; const TRUNCATED_FILE_NOTICE = "This diff is too large to show in full."; const NATIVE_HEX_COLOR = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})([\da-f]{2})?$/i; const NATIVE_RGBA_COLOR = @@ -311,63 +313,46 @@ function shouldUseWordDiffRanges( function addNativeWordDiffRanges( rows: ReadonlyArray, + lineRows: ReadonlyArray, ): ReadonlyArray { const nextRows = [...rows]; - let index = 0; - - while (index < nextRows.length) { - const deletedRowIndexes: number[] = []; - const addedRowIndexes: number[] = []; - const fileId = nextRows[index]?.fileId; - - while ( - nextRows[index]?.kind === "line" && - nextRows[index]?.change === "delete" && - nextRows[index]?.fileId === fileId - ) { - deletedRowIndexes.push(index); - index += 1; - } + const addedRowIndexes = new Map(); + const pairs: Array<{ deletionRowIndex: number; additionTokenIndex: number }> = []; + let lineIndex = 0; - while ( - nextRows[index]?.kind === "line" && - nextRows[index]?.change === "add" && - nextRows[index]?.fileId === fileId - ) { - addedRowIndexes.push(index); - index += 1; + // Pierre can split adjacent change blocks to align similar lines. Keep those pairings. + rows.forEach((row, rowIndex) => { + if (row.kind !== "line") return; + const source = lineRows[lineIndex++]; + if (source?.change === "add" && source.additionTokenIndex !== null) { + addedRowIndexes.set(source.additionTokenIndex, rowIndex); + } else if (source?.change === "delete" && source.comparison?.change === "add") { + pairs.push({ deletionRowIndex: rowIndex, additionTokenIndex: source.comparison.tokenIndex }); } + }); - const pairedCount = Math.min(deletedRowIndexes.length, addedRowIndexes.length); - for (let pairIndex = 0; pairIndex < pairedCount; pairIndex += 1) { - const deletedRowIndex = deletedRowIndexes[pairIndex]; - const addedRowIndex = addedRowIndexes[pairIndex]; - if (deletedRowIndex === undefined || addedRowIndex === undefined) { - continue; - } - const deletedRow = nextRows[deletedRowIndex]; - const addedRow = nextRows[addedRowIndex]; - if (!deletedRow?.content || !addedRow?.content) { - continue; - } - - const ranges = computeWordAltDiffRanges({ - deletionLine: deletedRow.content, - additionLine: addedRow.content, - }); - const deletionRanges = trimWordDiffRanges(deletedRow.content, ranges.deletion); - const additionRanges = trimWordDiffRanges(addedRow.content, ranges.addition); + let comparisonCount = 0; + for (const { deletionRowIndex, additionTokenIndex } of pairs) { + if (comparisonCount >= NATIVE_REVIEW_MAX_WORD_DIFF_COMPARISONS) break; + const addedRowIndex = addedRowIndexes.get(additionTokenIndex); + if (addedRowIndex === undefined) continue; + const deletedRow = nextRows[deletionRowIndex]; + const addedRow = nextRows[addedRowIndex]; + if (!deletedRow?.content || !addedRow?.content) continue; + + comparisonCount += 1; + const ranges = computeWordAltDiffRanges({ + deletionLine: deletedRow.content, + additionLine: addedRow.content, + }); + const deletionRanges = trimWordDiffRanges(deletedRow.content, ranges.deletion); + const additionRanges = trimWordDiffRanges(addedRow.content, ranges.addition); - if (shouldUseWordDiffRanges(deletedRow.content, deletionRanges)) { - nextRows[deletedRowIndex] = { ...deletedRow, wordDiffRanges: deletionRanges }; - } - if (shouldUseWordDiffRanges(addedRow.content, additionRanges)) { - nextRows[addedRowIndex] = { ...addedRow, wordDiffRanges: additionRanges }; - } + if (shouldUseWordDiffRanges(deletedRow.content, deletionRanges)) { + nextRows[deletionRowIndex] = { ...deletedRow, wordDiffRanges: deletionRanges }; } - - if (deletedRowIndexes.length === 0 && addedRowIndexes.length === 0) { - index += 1; + if (shouldUseWordDiffRanges(addedRow.content, additionRanges)) { + nextRows[addedRowIndex] = { ...addedRow, wordDiffRanges: additionRanges }; } } @@ -440,13 +425,21 @@ function prepareFileRows(file: ReviewRenderableFile): PreparedNativeReviewFileRo rows.push(createNoticeRow(file.id, "truncated", TRUNCATED_FILE_NOTICE)); } rows.push(...noticeRowsForFile(file)); + if (file.noEOFCRAdditions || file.noEOFCRDeletions) { + const side = file.noEOFCRAdditions + ? file.noEOFCRDeletions + ? "Old and new files have" + : "New file has" + : "Old file has"; + rows.push(createNoticeRow(file.id, "eof", `${side} no newline at end of file.`)); + } const prepared: PreparedNativeReviewFileRows = { fileId: file.id, filePath: file.path, lineCount: lineRows.length, truncated: sourceRows !== file.rows, - // Comments must not split the source deletion/addition runs used for word matching. - rows: addNativeWordDiffRanges(rows), + // Pair source lines before inserting comments so comment rows cannot affect matching. + rows: addNativeWordDiffRanges(rows, lineRows), commentTargetsByRowId, rowIdByCommentLineId, commentedRows: null, diff --git a/apps/mobile/src/features/review/reviewDiffHighlightScheduler.ts b/apps/mobile/src/features/review/reviewDiffHighlightScheduler.ts index d2e986e925b29..8da759a022367 100644 --- a/apps/mobile/src/features/review/reviewDiffHighlightScheduler.ts +++ b/apps/mobile/src/features/review/reviewDiffHighlightScheduler.ts @@ -6,7 +6,7 @@ export interface NativeReviewVisibleRange { export function createReviewDiffHighlightScheduler( request: (range: NativeReviewVisibleRange) => void, ) { - let requestedRange: NativeReviewVisibleRange = { firstRowIndex: 0, lastRowIndex: 80 }; + let requestedRange: NativeReviewVisibleRange | undefined; let visibleRange = requestedRange; let timer: ReturnType | undefined; @@ -17,19 +17,25 @@ export function createReviewDiffHighlightScheduler( const flush = () => { cancel(); requestedRange = visibleRange; - request(visibleRange); + if (visibleRange) request(visibleRange); }; return { update(nextRange: NativeReviewVisibleRange) { if ( - nextRange.firstRowIndex === visibleRange.firstRowIndex && + nextRange.firstRowIndex === visibleRange?.firstRowIndex && nextRange.lastRowIndex === visibleRange.lastRowIndex ) { return; } visibleRange = nextRange; cancel(); + if (!requestedRange) { + // Native publishes a pre-jump range and its initial destination together. + // Yield once so the destination wins before sending an unabortable source RPC. + timer = setTimeout(flush, 0); + return; + } const movedRows = Math.abs(nextRange.firstRowIndex - requestedRange.firstRowIndex) + Math.abs(nextRange.lastRowIndex - requestedRange.lastRowIndex); @@ -41,8 +47,10 @@ export function createReviewDiffHighlightScheduler( }, reset() { cancel(); - requestedRange = { firstRowIndex: 0, lastRowIndex: 80 }; + const previousRange = visibleRange; + requestedRange = undefined; visibleRange = requestedRange; + return previousRange; }, cancel, }; diff --git a/apps/mobile/src/features/review/reviewModel.test.ts b/apps/mobile/src/features/review/reviewModel.test.ts index c4f64329779fc..39d27de3d6d05 100644 --- a/apps/mobile/src/features/review/reviewModel.test.ts +++ b/apps/mobile/src/features/review/reviewModel.test.ts @@ -3,6 +3,123 @@ import { describe, expect, it } from "vite-plus/test"; import { applyReviewDiffMetadata, buildReviewParsedDiff } from "./reviewModel"; describe("buildReviewParsedDiff", () => { + it("keeps UI identities compact, stable, and isolated from parser cache keys", () => { + const patch = + "diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-before\n+after\n"; + const scope = JSON.stringify({ target: 'remote\\"scope'.repeat(100) }); + const parsed = buildReviewParsedDiff(patch + patch, scope); + const repeated = buildReviewParsedDiff(patch + patch, scope); + const otherSection = buildReviewParsedDiff(patch + patch, `${scope}:other`); + const changed = buildReviewParsedDiff((patch + patch).replaceAll("after", "changed"), scope); + if ( + parsed.kind !== "files" || + repeated.kind !== "files" || + otherSection.kind !== "files" || + changed.kind !== "files" + ) + throw new Error("Expected structured diffs"); + + expect(parsed.files).toHaveLength(2); + expect(parsed.files.map((file) => file.id)).toEqual(repeated.files.map((file) => file.id)); + expect(parsed.files.map((file) => file.cacheKey)).toEqual( + repeated.files.map((file) => file.cacheKey), + ); + const ids = parsed.files.flatMap((file) => [file.id, ...file.rows.map((row) => row.id)]); + expect(new Set(ids).size).toBe(ids.length); + for (const id of ids) expect(id.length).toBeLessThan(48); + for (const variant of [otherSection, changed]) { + const otherIds = variant.files.flatMap((file) => [ + file.id, + ...file.rows.map((row) => row.id), + ]); + expect(otherIds.some((id) => ids.includes(id))).toBe(false); + } + expect(parsed.files[0]!.cacheKey.length).toBeGreaterThan(scope.length); + expect(parsed.files[0]!.id).not.toBe(parsed.files[0]!.cacheKey); + expect(repeated.files[0]!.rows.map((row) => row.id)).toEqual( + parsed.files[0]!.rows.map((row) => row.id), + ); + }); + + it.each(["", "\n\n[truncated]"])( + "preserves the final blank context in the resourceTelemetry excerpt with suffix %j", + (suffix) => { + const parsed = buildReviewParsedDiff( + [ + "diff --git a/packages/contracts/src/resourceTelemetry.ts b/packages/contracts/src/resourceTelemetry.ts", + "--- a/packages/contracts/src/resourceTelemetry.ts", + "+++ b/packages/contracts/src/resourceTelemetry.ts", + "@@ -350,6 +371,7 @@", + " DesktopTelemetryRequestDesktopUpdate,", + " DesktopTelemetryCommitDesktopUpdate,", + " DesktopTelemetryCancelDesktopUpdate,", + "+ DesktopUpdateRestartPolicyResult,", + " ]);", + " export type DesktopTelemetryControlMessage = typeof DesktopTelemetryControlMessage.Type;", + " ", + "", + ].join("\n") + suffix, + "resource-telemetry", + ); + expect(parsed.kind).toBe("files"); + if (parsed.kind !== "files") return; + expect(parsed.files[0]?.rows.at(-1)).toMatchObject({ + kind: "line", + change: "context", + oldLineNumber: 355, + newLineNumber: 377, + content: "", + }); + expect(parsed.files[0]?.additionLines.at(-1)).toBe("\n"); + expect(parsed.files[0]?.deletionLines.at(-1)).toBe("\n"); + }, + ); + + it("preserves final content whitespace and gives distinct patches distinct cache keys", () => { + const patch = "diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -0,0 +1 @@\n+value \t\n"; + const parsed = buildReviewParsedDiff(patch, "whitespace"); + const withoutWhitespace = buildReviewParsedDiff( + patch.replace("value \t", "value"), + "whitespace", + ); + expect(parsed.kind).toBe("files"); + expect(withoutWhitespace.kind).toBe("files"); + if (parsed.kind !== "files" || withoutWhitespace.kind !== "files") return; + expect(parsed.files[0]?.rows.at(-1)).toMatchObject({ content: "value \t" }); + expect(parsed.files[0]?.additionLines).toEqual(["value \t\n"]); + expect(parsed.files[0]?.cacheKey).not.toBe(withoutWhitespace.files[0]?.cacheKey); + }); + + it.each([ + [true, false], + [false, true], + [true, true], + [false, false], + ])("preserves missing-newline flags for old=%s and new=%s", (oldMissing, newMissing) => { + const parsed = buildReviewParsedDiff( + [ + "diff --git a/a.ts b/a.ts", + "--- a/a.ts", + "+++ b/a.ts", + "@@ -1 +1 @@", + "-value", + ...(oldMissing ? ["\\ No newline at end of file"] : []), + "+value", + ...(newMissing ? ["\\ No newline at end of file"] : []), + "", + ].join("\n"), + "eof", + ); + expect(parsed.kind).toBe("files"); + if (parsed.kind !== "files") return; + expect(parsed.files[0]).toMatchObject({ + noEOFCRDeletions: oldMissing, + noEOFCRAdditions: newMissing, + deletionLines: [oldMissing ? "value" : "value\n"], + additionLines: [newMissing ? "value" : "value\n"], + }); + }); + it.each([ ["a/example.ts", "a/example.ts"], ["a/before.ts", "b/after.ts"], diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index 7b82f02d006eb..a5b077e139a20 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -2,6 +2,7 @@ import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types"; import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; import type { ReviewDiffPreviewSource } from "@t3tools/contracts"; +import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; import * as Order from "effect/Order"; @@ -60,6 +61,13 @@ export interface ReviewRenderableFile { readonly languageHint: string | null; readonly additionLines: ReadonlyArray; readonly deletionLines: ReadonlyArray; + // Only populated after validating the entire raw file block against its original hunk count. + readonly completePatchSource?: () => { + readonly oldContents: string; + readonly newContents: string; + }; + readonly noEOFCRAdditions?: boolean; + readonly noEOFCRDeletions?: boolean; readonly rows: ReadonlyArray; } @@ -139,13 +147,13 @@ function splitTruncationMarker(diff: string): { readonly text: string; readonly truncated: boolean; } { - const trimmed = diff.trimEnd(); - if (!trimmed.endsWith("[truncated]")) { - return { text: trimmed, truncated: false }; + const marker = /(?:(?:\r?\n){1,2}|^)\[truncated\]\s*$/.exec(diff); + if (!marker) { + return { text: diff, truncated: false }; } return { - text: trimmed.replace(/\n*\[truncated\]\s*$/, "").trimEnd(), + text: diff.slice(0, marker.index), truncated: true, }; } @@ -219,14 +227,22 @@ function fnv1a32(input: string, seed: number, multiplier: number): number { } function buildPatchCacheKey(patch: string, scope: string): string { - const normalizedPatch = patch.trim(); - const primary = fnv1a32(normalizedPatch, FNV_OFFSET_BASIS_32, FNV_PRIME_32).toString(36); - const secondary = fnv1a32( - normalizedPatch, - SECONDARY_HASH_SEED, - SECONDARY_HASH_MULTIPLIER, - ).toString(36); - return `${scope}:${normalizedPatch.length}:${primary}:${secondary}`; + const primary = fnv1a32(patch, FNV_OFFSET_BASIS_32, FNV_PRIME_32).toString(36); + const secondary = fnv1a32(patch, SECONDARY_HASH_SEED, SECONDARY_HASH_MULTIPLIER).toString(36); + return `${scope}:${patch.length}:${primary}:${secondary}`; +} + +// Re-parsing an unchanged page must preserve selection/collapse state. Intern only +// its cache key (never patch contents), keeping scoped Pierre keys off every UI row. +const reviewPatchIds = new Map(); + +function getReviewPatchId(cacheKey: string): string { + let id = reviewPatchIds.get(cacheKey); + if (id === undefined) { + id = `p${reviewPatchIds.size.toString(36)}`; + reviewPatchIds.set(cacheKey, id); + } + return id; } function getFileExtension(path: string): string | null { @@ -276,14 +292,17 @@ function fallbackHunkHeader(hunk: FileDiffMetadata["hunks"][number]): string { return `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`; } -function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray { +function buildRenderableRows( + file: FileDiffMetadata, + fileId: string, +): ReadonlyArray { const rows: ReviewRenderableRow[] = []; let rowIndex = 0; file.hunks.forEach((hunk, hunkIndex) => { rows.push({ kind: "hunk", - id: `${file.cacheKey ?? file.name}:hunk:${hunkIndex}`, + id: `${fileId}:hunk:${hunkIndex}`, header: fallbackHunkHeader(hunk), context: hunk.hunkContext ? stripTrailingNewline(hunk.hunkContext) : null, }); @@ -298,7 +317,7 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray length + line.length + 1, 0); + const prefix = added ? "+" : "-"; + for (let line = 0; line < count; line++) { + if (!lines[index++]?.startsWith(prefix)) return undefined; + } + let bodyEnd = rawPatch.length; + if (lines[index] === "\\ No newline at end of file") { + // Only the patch's LF is synthetic, including when the source ends in a bare CR. + bodyEnd -= "\\ No newline at end of file\n".length + 1; + index++; + } + if (index !== lines.length) return undefined; + // Keep reconstruction lazy so only requested source enters the bounded source cache. + return () => { + const contents = rawPatch.slice(bodyStart + 1, bodyEnd).replace(/\n[+-]/g, "\n"); + return added + ? { oldContents: "", newContents: contents } + : { oldContents: contents, newContents: "" }; + }; +} + +function mapRenderableFile( + file: FileDiffMetadata, + id: string, + rawPatch?: string | null, +): ReviewRenderableFile { const path = file.name || file.prevName || ""; const previousPath = file.prevName || null; const additions = file.hunks.reduce((total, hunk) => total + hunk.additionLines, 0); @@ -380,7 +466,7 @@ function mapRenderableFile(file: FileDiffMetadata): ReviewRenderableFile { const cacheKey = file.cacheKey ?? `${previousPath ?? "none"}:${path}:${file.type}`; return { - id: cacheKey, + id, cacheKey, path, previousPath, @@ -390,7 +476,10 @@ function mapRenderableFile(file: FileDiffMetadata): ReviewRenderableFile { languageHint: file.lang ?? null, additionLines: file.additionLines, deletionLines: file.deletionLines, - rows: buildRenderableRows(file), + completePatchSource: completePatchSource(file, rawPatch), + noEOFCRAdditions: file.hunks.some((hunk) => hunk.noEOFCRAdditions), + noEOFCRDeletions: file.hunks.some((hunk) => hunk.noEOFCRDeletions), + rows: buildRenderableRows(file, id), }; } @@ -471,12 +560,11 @@ export function buildReviewParsedDiff( diff: string | null | undefined, cacheScope: string, ): ReviewParsedDiff { - const normalized = diff?.trim(); - if (!normalized) { + if (!diff?.trim()) { return { kind: "empty" }; } - const { text, truncated } = splitTruncationMarker(normalized); + const { text, truncated } = splitTruncationMarker(diff); if (text.length === 0) { return { kind: "empty" }; } @@ -486,13 +574,28 @@ export function buildReviewParsedDiff( : null; try { - const parsedPatches = runDiffParserSilently(() => - parsePatchFiles(text, buildPatchCacheKey(text, cacheScope)), - ); + const cacheKey = buildPatchCacheKey(text, cacheScope); + const patchId = getReviewPatchId(cacheKey); + const parsedPatches = runDiffParserSilently(() => parsePatchFiles(text, cacheKey)); + const rawFiles = new Map(); + if (!truncated) { + for (const rawFile of text.split(/(?=^diff --git )/m)) { + const header = rawFile.slice(0, rawFile.indexOf("\n")); + rawFiles.set(header, rawFiles.has(header) ? null : rawFile); + } + } const files = pipe( parsedPatches, Arr.flatMap((patch) => patch.files), - Arr.map(mapRenderableFile), + Arr.map((file, index) => + mapRenderableFile( + file, + `${patchId}:f${index.toString(36)}`, + rawFiles.get( + `diff --git ${quoteGitPatchPath(`a/${file.name}`)} ${quoteGitPatchPath(`b/${file.name}`)}`, + ), + ), + ), ); if (files.length === 0) { diff --git a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts index 1728da6626864..d51000bb1f7d3 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import type { NativeSyntheticEvent } from "react-native"; import { createNativeReviewDiffTheme, type NativeReviewDiffData } from "./nativeReviewDiffAdapter"; @@ -33,6 +33,8 @@ export function useNativeReviewDiffBridge(input: { const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); const { themeAppearance: scheme, themeId } = useAppearancePreferences(); const appTheme = useUniwindTheme(); + const [surfaceGeneration, setSurfaceGeneration] = useState(0); + const hasMountedContentRef = useRef(false); const [collapsedCommentIds, setCollapsedCommentIds] = useState>( () => new Set(), ); @@ -53,28 +55,36 @@ export function useNativeReviewDiffBridge(input: { const styleJson = useMemo(() => JSON.stringify(nativeReviewDiffStyle), [nativeReviewDiffStyle]); const tokensResetKey = useMemo( () => - buildNativeReviewTokensResetKey({ + `${buildNativeReviewTokensResetKey({ threadKey, sectionId, scheme, diff, fileCount: data.files.length, rowCount: data.rows.length, - }), - [data.files.length, data.rows.length, diff, scheme, sectionId, threadKey], + })}:${surfaceGeneration}`, + [data.files.length, data.rows.length, diff, scheme, sectionId, surfaceGeneration, threadKey], ); - const { tokensPatchJson, updateVisibleRange } = useNativeReviewDiffHighlighting({ - files: data.files, - rows: data.rows, - scheme, - resetKey: tokensResetKey, - enabled: canHighlight, - }); + const { acknowledgeTokenPatch, rejectTokenPatch, tokensPatchJson, updateVisibleRange } = + useNativeReviewDiffHighlighting({ + files: data.files, + rows: data.rows, + scheme, + resetKey: tokensResetKey, + enabled: canHighlight, + }); const onDebug = useCallback( (event: NativeSyntheticEvent>) => { const payload = event.nativeEvent; const message = payload.message; + if (message === "surface-content-mounted") { + // The initial content already has a fresh hook; replacements lose accepted tokens. + if (hasMountedContentRef.current) setSurfaceGeneration((generation) => generation + 1); + hasMountedContentRef.current = true; + } + if (message === "tokens-patch-delivered") acknowledgeTokenPatch(payload); + if (message === "tokens-patch-failed") rejectTokenPatch(payload); if ( (message === "draw-metrics" || message === "visible-range") && typeof payload.firstRowIndex === "number" && @@ -86,7 +96,7 @@ export function useNativeReviewDiffBridge(input: { }); } }, - [updateVisibleRange], + [acknowledgeTokenPatch, rejectTokenPatch, updateVisibleRange], ); const onToggleComment = useCallback( diff --git a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts index 6a9b5ee121ce3..1658f8e76951b 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts @@ -1,9 +1,16 @@ -import { useEffect, useRef, useState } from "react"; - import type { HighlightScheme } from "../../lib/highlights"; -import { highlightNativeReviewDiffVisibleRows } from "../diffs/nativeReviewDiffHighlighter"; + +import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { AsyncResult } from "effect/reactivity"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { + highlightNativeReviewDiffVisibleRows, + type NativeReviewDiffHighlightChunk, +} from "../diffs/nativeReviewDiffHighlighter"; import type { NativeReviewDiffRow } from "../diffs/nativeReviewDiffSurface"; import type { NativeReviewDiffFile } from "../diffs/nativeReviewDiffTypes"; +import { diffHighlightTraceId, traceDiffHighlight } from "../diffs/nativeReviewDiffTrace"; import { createReviewDiffHighlightScheduler, @@ -23,50 +30,186 @@ export function useNativeReviewDiffHighlighting(input: { }) { const { enabled, files, resetKey, rows, scheme } = input; const highlightedRowIdsRef = useRef>(new Set()); - const [visibleRange, setVisibleRange] = useState({ - firstRowIndex: 0, - lastRowIndex: 80, - }); + const viewportScope = `${enabled}:${resetKey}:${rows.length}`; + const [viewport, setViewport] = useState<{ + scope: string; + range: NativeReviewVisibleRange | null; + }>(() => ({ scope: viewportScope, range: null })); const visibleChunkIndexRef = useRef(0); + // Keep undelivered chunks, rather than repeatedly serializing every highlighted row. + const pendingPatchesRef = useRef< + Array<{ resetKey: string; chunkIndex: number; json: string; rowIds: string[] }> + >([]); const [tokensPatchJson, setTokensPatchJson] = useState(() => createEmptyTokenPatch(resetKey)); - const [scheduler] = useState(() => createReviewDiffHighlightScheduler(setVisibleRange)); + const [scheduler] = useState(() => + createReviewDiffHighlightScheduler((range) => + setViewport((current) => ({ ...current, range })), + ), + ); useEffect(() => { - scheduler.reset(); + traceDiffHighlight("viewport-reset", { + reset: diffHighlightTraceId(resetKey), + enabled, + rows: rows.length, + }); + const previousRange = scheduler.reset(); highlightedRowIdsRef.current = new Set(); - visibleChunkIndexRef.current = 0; - setVisibleRange({ firstRowIndex: 0, lastRowIndex: 80 }); + pendingPatchesRef.current = []; + setViewport({ scope: viewportScope, range: null }); setTokensPatchJson(createEmptyTokenPatch(resetKey)); + // A token reset need not produce another native range event. Reuse only an + // observed viewport, with the same coalescing that lets a new jump replace it. + if (previousRange) scheduler.update(previousRange); return () => scheduler.cancel(); - }, [enabled, resetKey, rows.length, scheduler]); + }, [enabled, resetKey, rows.length, scheduler, viewportScope]); useEffect(() => { - if (!enabled || rows.length === 0) { + if (!enabled || rows.length === 0 || !viewport.range || viewport.scope !== viewportScope) { return; } - const result = highlightNativeReviewDiffVisibleRows({ - files, - rows, - scheme, - firstRowIndex: visibleRange.firstRowIndex, - lastRowIndex: visibleRange.lastRowIndex, - alreadyHighlightedRowIds: highlightedRowIdsRef.current, + const abortController = new AbortController(); + const requestRange = viewport.range; + const generation = diffHighlightTraceId(abortController); + const reset = diffHighlightTraceId(resetKey); + traceDiffHighlight("viewport-start", { + generation, + reset, + first: requestRange.firstRowIndex, + last: requestRange.lastRowIndex, + pending: pendingPatchesRef.current.length, }); - if (result.rowCount === 0) return; - for (const rowId of Object.keys(result.tokensByRowId)) { - highlightedRowIdsRef.current.add(rowId); - } + const enqueueChunk = (result: NativeReviewDiffHighlightChunk) => { + if (abortController.signal.aborted || result.rowCount === 0) return; + const retryableRowIds = new Set(result.retryableRowIds); + const rowIds = Object.keys(result.tokensByRowId); + for (const rowId of rowIds) { + if (!retryableRowIds.has(rowId)) highlightedRowIdsRef.current.add(rowId); + } + const chunkIndex = visibleChunkIndexRef.current++; + const json = JSON.stringify({ + resetKey, + chunkIndex, + fileId: "visible", + filePath: "visible rows", + language: "diff", + lineCount: result.rowCount, + durationMs: result.durationMs, + tokensByRowId: result.tokensByRowId, + }); + pendingPatchesRef.current.push({ resetKey, chunkIndex, json, rowIds }); + traceDiffHighlight("patch-queued", { + generation, + reset, + chunk: chunkIndex, + rows: rowIds.length, + pending: pendingPatchesRef.current.length, + }); + if (pendingPatchesRef.current.length === 1) setTokensPatchJson(json); + }; + + void (async () => { + // Deliver viewport tokens before offscreen source fetches can delay them. + let retryableViewportRowIds: ReadonlyArray = []; + for (const overscanRows of [0, undefined]) { + const deliveredRowIds = new Set(); + const settled = await settlePromise(() => + highlightNativeReviewDiffVisibleRows({ + files, + rows, + scheme, + firstRowIndex: requestRange.firstRowIndex, + lastRowIndex: requestRange.lastRowIndex, + overscanRows, + alreadyHighlightedRowIds: + retryableViewportRowIds.length > 0 + ? new Set([...highlightedRowIdsRef.current, ...retryableViewportRowIds]) + : highlightedRowIdsRef.current, + signal: abortController.signal, + onChunk: (chunk) => { + for (const rowId of Object.keys(chunk.tokensByRowId)) deliveredRowIds.add(rowId); + enqueueChunk(chunk); + }, + }), + ); + if (abortController.signal.aborted) return; + if (AsyncResult.isFailure(settled)) { + console.warn("[review-sheet] native visible highlight failed", { + error: squashAtomCommandFailure(settled), + resetKey, + scheme, + firstRowIndex: requestRange.firstRowIndex, + lastRowIndex: requestRange.lastRowIndex, + }); + return; + } + const result = settled.value; + traceDiffHighlight("viewport-result", { + generation, + reset, + overscan: overscanRows ?? 160, + rows: result.rowCount, + retryable: result.retryableRowIds.length, + }); + if (result.rowCount === 0) continue; + retryableViewportRowIds = result.retryableRowIds; + + const remaining = Object.fromEntries( + Object.entries(result.tokensByRowId).filter(([rowId]) => !deliveredRowIds.has(rowId)), + ); + enqueueChunk({ + ...result, + tokensByRowId: remaining, + rowCount: Object.keys(remaining).length, + }); + } + })(); + + return () => { + traceDiffHighlight("viewport-abort", { generation, reset }); + abortController.abort(); + }; + }, [enabled, files, resetKey, rows, scheme, viewport, viewportScope]); + + const acknowledgeTokenPatch = useCallback((payload: Readonly>) => { + const pending = pendingPatchesRef.current; + const head = pending[0]; + traceDiffHighlight("patch-ack", { + reset: diffHighlightTraceId(payload.resetKey), + chunk: typeof payload.chunkIndex === "number" ? payload.chunkIndex : -1, + matches: + !!head && payload.resetKey === head.resetKey && payload.chunkIndex === head.chunkIndex, + pending: pending.length, + }); + if (!head || payload.resetKey !== head.resetKey || payload.chunkIndex !== head.chunkIndex) + return; + pending.shift(); + setTokensPatchJson(pending[0]?.json ?? createEmptyTokenPatch(head.resetKey)); + }, []); - const chunkIndex = visibleChunkIndexRef.current; - visibleChunkIndexRef.current += 1; - setTokensPatchJson( - JSON.stringify({ resetKey, chunkIndex, tokensByRowId: result.tokensByRowId }), - ); - }, [enabled, files, resetKey, rows, scheme, visibleRange]); + const rejectTokenPatch = useCallback( + (payload: Readonly>) => { + const head = pendingPatchesRef.current[0]; + traceDiffHighlight("patch-rejected", { + reset: diffHighlightTraceId(payload.resetKey), + chunk: typeof payload.chunkIndex === "number" ? payload.chunkIndex : -1, + matches: + !!head && payload.resetKey === head.resetKey && payload.chunkIndex === head.chunkIndex, + }); + if (!head || payload.resetKey !== head.resetKey || payload.chunkIndex !== head.chunkIndex) + return; + for (const rowId of head.rowIds) highlightedRowIdsRef.current.delete(rowId); + // Retry these rows only on a later viewport request, not in a failure loop. + acknowledgeTokenPatch(payload); + }, + [acknowledgeTokenPatch], + ); return { + rejectTokenPatch, + acknowledgeTokenPatch, tokensPatchJson, updateVisibleRange: scheduler.update, }; diff --git a/apps/mobile/src/lib/highlights.test.ts b/apps/mobile/src/lib/highlights.test.ts index 6d082b53f0285..83469ef745e65 100644 --- a/apps/mobile/src/lib/highlights.test.ts +++ b/apps/mobile/src/lib/highlights.test.ts @@ -2,7 +2,7 @@ import { codeToTokens } from "@pierre/highlights"; import { pierreDark, pierreLight } from "@pierre/highlights/themes"; import { describe, expect, it, vi } from "vite-plus/test"; -import { highlightCode } from "./highlights"; +import { highlightCode, tokenizeCode } from "./highlights"; vi.mock("expo", async () => { const { createWasmHighlightsModule } = await import("./highlights.testing"); @@ -44,3 +44,16 @@ describe("highlightCode", () => { expect(second).toEqual([{ content: longLine, color: null, fontStyle: null }]); }); }); + +it("normalizes only requested lines from full-source records, including CRLF and a long comment opener", () => { + const code = `const first = 1;\r\n${" ".repeat(1_001)}/*\r\ninside\r\n*/\r\nconst last = 2;`; + const source = tokenizeCode(code, "typescript"); + expect(source.byteLength).toBeGreaterThan(0); + for (let line = 0; line < 5; line++) { + expect(source.highlightLine(line, "dark")).toEqual( + highlightCode(code, "typescript", "dark")[line], + ); + } + expect(source.highlightLine(2, "dark")?.[0]?.color).toBe("#737373"); + expect(source.highlightLine(100, "dark")).toBeUndefined(); +}); diff --git a/apps/mobile/src/lib/highlights.ts b/apps/mobile/src/lib/highlights.ts index f936db1083602..184f203ca879a 100644 --- a/apps/mobile/src/lib/highlights.ts +++ b/apps/mobile/src/lib/highlights.ts @@ -24,50 +24,71 @@ const nativeHighlights = requireOptionalNativeModule<{ tokenize(source: string, language: number): ArrayBuffer | null; }>("T3Highlights"); -function plainLines(code: string): HighlightedLines { - return code - .split(/\r?\n/u) - .map((line) => (line ? [{ content: line, color: null, fontStyle: null }] : [])); +export interface TokenizedCode { + /** Retained UTF-16 source, compact native records, and line indexes. */ + readonly byteLength: number; + readonly lineCount: number; + readonly highlightLine: ( + index: number, + scheme: HighlightScheme, + ) => ReadonlyArray | undefined; } -/** - * Tokenizes `code` synchronously, one token array per line. Lines split at LF - * and CRLF, and a trailing line break yields a final empty line. - */ -export function highlightCode( - code: string, - language: string, - scheme: HighlightScheme, -): HighlightedLines { +/** Keep native records compact; expand only lines a client will display. */ +export function tokenizeCode(code: string, language: string): TokenizedCode { const id = highlightLanguageId(language); const buffer = id === 0 ? null : (nativeHighlights?.tokenize(code, id) ?? null); - if (!buffer) return plainLines(code); - - // `(endUtf16, tokenId)` pairs. A LINE_END token ID ends a line, and its end - // is where the next line starts. Mirrors @pierre/highlights' lineRecordsToTokens. - const records = new Uint32Array(buffer); - const styles = HIGHLIGHTS_THEME_STYLES[scheme]; - const lines: HighlightedToken[][] = []; - let start = 0; - for (let record = 0; ; record += 2) { - const first = record; - while (record < records.length && records[record + 1] !== LINE_END) record += 2; - const end = record === first ? start : records[record - 2]!; - if (end - start > MAX_HIGHLIGHTED_LINE_LENGTH) { - lines.push([{ content: code.slice(start, end), color: null, fontStyle: null }]); - } else { + const records = buffer ? new Uint32Array(buffer) : null; + const offsets = [0]; + if (records) { + for (let record = 0; record < records.length; record += 2) { + if (records[record + 1] === LINE_END) offsets.push(record + 2); + } + } else { + for (let at = code.indexOf("\n"); at !== -1; at = code.indexOf("\n", at + 1)) + offsets.push(at + 1); + } + const lineStarts = Uint32Array.from(offsets); + return { + byteLength: code.length * 2 + (records?.byteLength ?? 0) + lineStarts.byteLength, + lineCount: lineStarts.length, + highlightLine(index, scheme) { + if (index < 0 || index >= lineStarts.length) return undefined; + const first = lineStarts[index]!; + const next = lineStarts[index + 1]; + if (!records) { + const end = next === undefined ? code.length : next - (code[next - 2] === "\r" ? 2 : 1); + const content = code.slice(first, end); + return content ? [{ content, color: null, fontStyle: null }] : []; + } + let start = first === 0 ? 0 : records[first - 2]!; + const last = next === undefined ? records.length : next - 2; + const end = last === first ? start : records[last - 2]!; + if (end - start > MAX_HIGHLIGHTED_LINE_LENGTH) { + return [{ content: code.slice(start, end), color: null, fontStyle: null }]; + } + const styles = HIGHLIGHTS_THEME_STYLES[scheme]; const line: HighlightedToken[] = []; - for (let at = first; at < record; at += 2) { + for (let at = first; at < last; at += 2) { const tokenEnd = records[at]!; if (tokenEnd <= start) continue; const [color, fontStyle] = styles[records[at + 1]!] ?? styles[0]!; line.push({ content: code.slice(start, tokenEnd), color, fontStyle }); start = tokenEnd; } - lines.push(line); - } - if (record >= records.length) break; - start = records[record]!; - } - return lines; + return line; + }, + }; +} + +/** LF and CRLF split lines; a trailing line break yields a final empty line. */ +export function highlightCode( + code: string, + language: string, + scheme: HighlightScheme, +): HighlightedLines { + const source = tokenizeCode(code, language); + return Array.from({ length: source.lineCount }, (_, index) => + source.highlightLine(index, scheme)!, + ); } diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 9cf6092d0ba42..4c371d2dbc42c 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from "vite-plus"; import "vite-plus/test/config"; const reactTests = [ + "src/features/diffs/nativeReviewDiffSurface.test.tsx", + "src/features/pull-requests/usePullRequestDiff.test.tsx", "src/components/ui/Dialog.android.test.tsx", "src/features/settings/SettingsServerControlsRouteScreen.test.tsx", "src/features/settings/components/BranchNamingSettings.test.tsx", diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts index 7ac94bfd96321..92e233f652328 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts @@ -164,6 +164,172 @@ const assertRequestId = (body: unknown) => /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, ); +describe("GitCafePullRequestProvider source contents", () => { + const comparisonBase = "a".repeat(40); + const snapshot = { version: pull.version, headOid, comparisonBaseOid: comparisonBase }; + const input = { + ...target, + changeType: "rename-changed" as const, + oldPath: "old name.ts", + newPath: "src/new #name.ts", + }; + const blob = (request: SentRequest) => ({ + commit: request.query.get("oid"), + oid: "b".repeat(40), + path: request.query.get("path"), + size: 16, + binary: false, + content: "const value = 1;", + }); + const routes = { + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/changes": snapshot, + "GET /repos/owner/repo/blob": blob, + }; + + it.effect( + "pins rename source to the comparison base and fork head, without reading branches", + () => { + const server = fakeGitCafe({ + ...routes, + "GET /repos/owner/repo/pulls/7": { + ...pull, + sourceRepo: { owner: "fork", name: "project" }, + }, + "GET /repos/fork/project/blob": blob, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider.getDiffFileContents!(input); + assert.deepStrictEqual(result, { + oldContents: "const value = 1;", + newContents: "const value = 1;", + }); + const changes = server.sent.find((request) => request.path.endsWith("/changes"))!; + assert.strictEqual(changes.query.get("expectedVersion"), "4"); + assert.strictEqual(changes.query.get("limit"), "1"); + assert.deepStrictEqual( + server.sent + .filter((request) => request.path.endsWith("/blob")) + .map((request) => ({ + path: request.path, + oid: request.query.get("oid"), + file: request.query.get("path"), + ref: request.query.get("ref"), + })), + [ + { path: "/repos/owner/repo/blob", oid: comparisonBase, file: input.oldPath, ref: null }, + { path: "/repos/fork/project/blob", oid: headOid, file: input.newPath, ref: null }, + ], + ); + assert.deepStrictEqual(server.writes(), []); + }).pipe(Effect.provide(server.layer)); + }, + ); + + it.effect.each(["new", "deleted"] as const)( + "skips the absent side of a %s file", + (changeType) => { + const server = fakeGitCafe(routes); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider.getDiffFileContents!({ ...input, changeType }); + assert.strictEqual(result[changeType === "new" ? "oldContents" : "newContents"], ""); + assert.lengthOf( + server.sent.filter((request) => request.path.endsWith("/blob")), + 1, + ); + }).pipe(Effect.provide(server.layer)); + }, + ); + + it.effect.each([ + { ...snapshot, version: 5 }, + { ...snapshot, headOid: baseOid }, + { ...snapshot, comparisonBaseOid: "main" }, + ])("refuses a changed or nonimmutable comparison snapshot", (response) => { + const server = fakeGitCafe({ ...routes, "GET /repos/owner/repo/pulls/7/changes": response }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider.getDiffFileContents!(input).pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + assert.isFalse(server.sent.some((request) => request.path.endsWith("/blob"))); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect.each([ + { binary: true, content: null }, + { content: "binary\0data" }, + { size: 1_048_577 }, + { size: 1, content: "x".repeat(1_048_577) }, + { size: 1, content: "รฉ".repeat(524_289) }, + { commit: baseOid }, + { path: "another.ts" }, + { content: null }, + ])("refuses unavailable, oversized, binary, or mismatched blobs", (fields) => { + const server = fakeGitCafe({ + ...routes, + "GET /repos/owner/repo/blob": (request: SentRequest) => ({ ...blob(request), ...fields }), + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider.getDiffFileContents!(input).pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + assert.deepStrictEqual(server.writes(), []); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect.each([ + [413, "oversized"], + [501, "unavailable"], + ] as const)("marks HTTP %s blob refusals permanently unavailable", ([status, reason]) => { + const server = fakeGitCafe({ + ...routes, + "GET /repos/owner/repo/blob": new Response("{}", { status }), + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const error = yield* provider.getDiffFileContents!({ ...input, changeType: "new" }).pipe( + Effect.flip, + ); + assert.deepInclude(error.cause as object, { + _tag: "GitCafeDiffSourceUnavailableError", + reason, + }); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("preserves authentication failures from blob access", () => { + const server = fakeGitCafe({ + ...routes, + "GET /repos/owner/repo/blob": new Response("{}", { status: 401 }), + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const error = yield* provider.getDiffFileContents!({ ...input, changeType: "new" }).pipe( + Effect.flip, + ); + assert.strictEqual(error.reason, "unauthenticated"); + assert.strictEqual(error.operation, "getDiffFileContents"); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect( + "refuses commit-specific contents consistently with unsupported GitCafe commit diffs", + () => { + const server = fakeGitCafe({}); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider.getDiffFileContents!({ ...input, commit: headOid }).pipe( + Effect.result, + ); + assert.strictEqual(result._tag, "Failure"); + assert.deepStrictEqual(server.sent, []); + }).pipe(Effect.provide(server.layer)); + }, + ); +}); + describe("GitCafePullRequestProvider", () => { it.effect("keeps a staging repository's requests and links on staging", () => { // CAFE_TOKEN follows CAFE_HOST, so staging reads the env token only when it is the target. diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts index 6d62801ad4bfa..e69961bcba4a7 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts @@ -116,6 +116,30 @@ const Listed = Schema.Struct({ }); const encodeIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); const BranchCommit = Schema.Struct({ oid: TrimmedNonEmptyString }); +const CommitOid = Schema.String.check(Schema.isPattern(/^[0-9a-f]{40}$/u)); +const isCommitOid = Schema.is(CommitOid); +const SourceSnapshot = Schema.Struct({ + version: NonNegativeInt, + headOid: CommitOid, + comparisonBaseOid: CommitOid, +}); +const SourceBlob = Schema.Struct({ + commit: CommitOid, + path: Schema.String, + size: NonNegativeInt, + binary: Schema.Boolean, + content: Schema.NullOr(Schema.String), +}); +const MAX_DIFF_FILE_BYTES = 1024 * 1024; + +class GitCafeDiffSourceUnavailableError extends Schema.TaggedError()( + "GitCafeDiffSourceUnavailableError", + { + path: Schema.String, + reason: Schema.Literals(["oversized", "binary", "unavailable"]), + cause: Schema.optional(Schema.Defect()), + }, +) {} const Snapshot = { version: NonNegativeInt, headOid: TrimmedNonEmptyString, @@ -271,6 +295,58 @@ export const make = Effect.gen(function* () { Effect.flatMap((path) => read(operation, input, { path }, Json.GitCafePullDetail)), ); + const readDiffSource = Effect.fn("GitCafePullRequestProvider.readDiffSource")(function* ( + input: ProviderRepositoryRef, + revision: string, + filePath: string, + ) { + const operation = "getDiffFileContents"; + const base = yield* repositoryPath(operation, input); + const query = new URLSearchParams({ oid: revision, path: filePath }); + const blob = yield* read(operation, input, { path: `${base}/blob?${query}` }, SourceBlob).pipe( + Effect.catchIf( + (error) => + isApiError(error.cause) && (error.cause.status === 413 || error.cause.status === 501), + (error) => + Effect.fail( + failure( + operation, + "GitCafe cannot provide this source file as bounded text.", + new GitCafeDiffSourceUnavailableError({ + path: filePath, + reason: + isApiError(error.cause) && error.cause.status === 413 + ? "oversized" + : "unavailable", + cause: error, + }), + ), + ), + ), + ); + if (blob.commit !== revision || blob.path !== filePath) + return yield* failure( + operation, + "GitCafe answered with a different source revision or path.", + ); + const reason = + blob.size > MAX_DIFF_FILE_BYTES || + (blob.content !== null && Buffer.byteLength(blob.content, "utf8") > MAX_DIFF_FILE_BYTES) + ? "oversized" + : blob.binary || blob.content?.includes("\0") + ? "binary" + : blob.content === null + ? "unavailable" + : null; + if (reason !== null) + return yield* failure( + operation, + "GitCafe cannot provide this source file as bounded text.", + new GitCafeDiffSourceUnavailableError({ path: filePath, reason }), + ); + return blob.content!; + }); + const readPages = Effect.fn("GitCafePullRequestProvider.readPages")(function* < S extends Schema.Codec, unknown, never, never>, >(operation: string, input: Pick, path: string, schema: S) { @@ -926,6 +1002,53 @@ export const make = Effect.gen(function* () { nextCursor: page.next === null ? null : encodeDiffCursor({ ...revision, after: page.next }), }; }), + getDiffFileContents: Effect.fn("GitCafePullRequestProvider.getDiffFileContents")( + function* (input) { + const operation = "getDiffFileContents"; + if (input.commit !== undefined) + return yield* failure(operation, "GitCafe commit diff contents are not supported yet."); + const pull = yield* readPull(operation, input); + if (!isCommitOid(pull.headOid)) + return yield* failure( + operation, + "GitCafe has not reported an immutable pull request head.", + ); + const path = yield* pullPath(operation, input); + const query = new URLSearchParams({ expectedVersion: String(pull.version), limit: "1" }); + // observedBaseOid is the target tip, not necessarily the diff's merge base. + const snapshot = yield* read( + operation, + input, + { path: `${path}/changes?${query}` }, + SourceSnapshot, + ); + if (snapshot.version !== pull.version || snapshot.headOid !== pull.headOid) + return yield* failure( + operation, + "GitCafe changed the pull request revision. Refresh the diff.", + ); + const sourceRepository = + pull.sourceRepo === null + ? input.repository + : `${pull.sourceRepo.owner}/${pull.sourceRepo.name}`; + const [oldContents, newContents] = yield* Effect.all( + [ + input.changeType === "new" + ? Effect.succeed("") + : readDiffSource(input, snapshot.comparisonBaseOid, input.oldPath), + input.changeType === "deleted" + ? Effect.succeed("") + : readDiffSource( + { ...input, repository: sourceRepository }, + snapshot.headOid, + input.newPath, + ), + ], + { concurrency: 2 }, + ); + return { oldContents, newContents }; + }, + ), runAction: Effect.fn("GitCafePullRequestProvider.runAction")(function* (input) { if (input.stackNumber !== undefined) return yield* runStackAction({ ...input, stackNumber: input.stackNumber }); diff --git a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts index b3a070b84170c..83279771cb4f0 100644 --- a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts +++ b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts @@ -336,6 +336,115 @@ describe("toStack", () => { }); describe("toDiff", () => { + it("marks the unterminated 93-byte tunnel-release migration as missing its final newline", () => { + const content = + 'ALTER TABLE "relay_managed_endpoint_allocations" ADD COLUMN "tunnel_released_at" varchar(64);'; + assert.strictEqual(Buffer.byteLength(content), 93); + const { patch } = GitCafePullRequestJson.toDiff( + decodeDiff({ + items: [ + { + path: "infra/relay/migrations/postgres/20261005232034_managed_endpoint_tunnel_released_at/migration.sql", + status: "added", + hunks: [ + { + oldStart: 1, + oldLines: 0, + newStart: 1, + newLines: 1, + lines: [{ origin: "+", content }], + }, + ], + }, + ], + }), + ); + assert.isTrue(patch.endsWith(`+${content}\n\\ No newline at end of file\n`)); + }); + + it.each(["+", "-", " "])("marks unterminated ordinary %j lines", (origin) => { + const { patch } = GitCafePullRequestJson.toDiff( + decodeDiff({ + items: [ + { + path: "file.ts", + status: "modified", + hunks: [ + { + oldStart: 1, + oldLines: origin === "+" ? 0 : 1, + newStart: 1, + newLines: origin === "-" ? 0 : 1, + lines: [{ origin, content: "value" }], + }, + ], + }, + ], + }), + ); + assert.isTrue(patch.endsWith(`${origin}value\n\\ No newline at end of file\n`)); + }); + + it.each([ + ["+", ">"], + ["-", "<"], + [" ", "="], + ])("emits one marker for an explicit %s/%s EOF pair", (origin, marker) => { + const { patch } = GitCafePullRequestJson.toDiff( + decodeDiff({ + items: [ + { + path: "file.ts", + status: "modified", + hunks: [ + { + oldStart: 1, + oldLines: origin === "+" ? 0 : 1, + newStart: 1, + newLines: origin === "-" ? 0 : 1, + lines: [ + { origin, content: "value" }, + { origin: marker, content: "\n" }, + ], + }, + ], + }, + ], + }), + ); + assert.strictEqual(patch.split("\\ No newline at end of file").length - 1, 1); + }); + + it.each([false, true])("preserves rename metadata for changed=%s", (changed) => { + const { patch } = GitCafePullRequestJson.toDiff( + decodeDiff({ + items: [ + { + path: "after.ts", + oldPath: "before.ts", + status: "renamed", + hunks: changed + ? [ + { + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 1, + lines: [ + { origin: "-", content: "before\n" }, + { origin: "+", content: "after\n" }, + ], + }, + ] + : [], + }, + ], + }), + ); + assert.include(patch, changed ? "similarity index 0%\n" : "similarity index 100%\n"); + assert.include(patch, "rename from before.ts\nrename to after.ts\n"); + }); + it("converts numeric hunk coordinates into a unified patch", () => { const diff = decodeDiff({ items: [ diff --git a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts index c932b243b23bd..87704efd08b87 100644 --- a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts +++ b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts @@ -567,6 +567,10 @@ export function toStack(stack: GitCafeStack, target: RepositoryOnHost): Provider }; } +function isEOFMarker(origin: string | undefined) { + return origin === "=" || origin === ">" || origin === "<"; +} + export function toDiff(diff: typeof GitCafeDiff.Type): ProviderDiffSlice { const chunks: Array = []; const omittedFileStats: Array<{ path: string; additions: number; deletions: number }> = []; @@ -582,7 +586,16 @@ export function toDiff(diff: typeof GitCafeDiff.Type): ProviderDiffSlice { if (file.status === "deleted") lines.push(`deleted file mode ${mode}`); if (file.status === "renamed" || file.status === "copied") { const action = file.status === "renamed" ? "rename" : "copy"; + const hasChanges = + (file.hunks?.length ?? 0) > 0 || + (file.additions ?? 0) > 0 || + (file.deletions ?? 0) > 0 || + file.binary || + file.tooLarge || + file.hunksOmitted; + // GitCafe has no similarity score; Pierre needs this header to retain rename metadata. lines.push( + `similarity index ${hasChanges ? 0 : 100}%`, `${action} from ${quoteGitPatchPath(oldPath)}`, `${action} to ${quoteGitPatchPath(file.path)}`, ); @@ -607,10 +620,19 @@ export function toDiff(diff: typeof GitCafeDiff.Type): ProviderDiffSlice { if (hunks.length > 0) lines.push(`--- ${before}`, `+++ ${after}`); for (const hunk of hunks) { lines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`); - for (const line of hunk.lines) { - // GitCafe marks a missing final newline with libgit2's own origins. - if (["=", ">", "<"].includes(line.origin)) lines.push("\\ No newline at end of file"); - else lines.push(`${line.origin}${line.content.replace(/\n$/u, "")}`); + for (const [index, line] of hunk.lines.entries()) { + if (isEOFMarker(line.origin)) { + lines.push("\\ No newline at end of file"); + } else { + lines.push(`${line.origin}${line.content.replace(/\n$/u, "")}`); + if ( + ["+", "-", " "].includes(line.origin) && + !line.content.endsWith("\n") && + !isEOFMarker(hunk.lines[index + 1]?.origin) + ) { + lines.push("\\ No newline at end of file"); + } + } } } } From 67c3afbd58e72d088fec7af69d52dd255a00199e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 24 Oct 2026 18:41:37 -0700 Subject: [PATCH 004/592] perf(shell): cold connects skip idle subagent threads, a third of the thread list (#24648) Co-authored-by: Claude Opus 5.5 --- .../features/threads/ThreadRouteScreen.tsx | 8 +- apps/mobile/src/state/shell.ts | 7 +- apps/mobile/src/state/thread-outbox-model.ts | 11 +- apps/mobile/src/state/thread-outbox.test.ts | 13 + apps/mobile/src/state/threads.ts | 22 +- .../src/state/use-composer-drafts.test.ts | 3 +- .../src/state/use-thread-outbox-drain.ts | 72 +++++- apps/server/src/auth/RpcAuthorization.ts | 2 + .../src/observability/RpcInstrumentation.ts | 2 + .../orchestration-v2/ProjectionStore.test.ts | 86 +++++++ .../src/orchestration-v2/ProjectionStore.ts | 42 +++- .../src/orchestration-v2/ShellStream.test.ts | 65 +++++ .../src/orchestration-v2/ShellStream.ts | 44 ++++ .../ThreadManagementService.ts | 7 +- apps/server/src/orchestration-v2/http.ts | 18 +- apps/server/src/ws.test.ts | 66 +++++ apps/server/src/ws.ts | 46 +++- apps/web/src/components/ChatView.tsx | 10 +- .../components/chat/useThreadLifecycle.tsx | 7 +- apps/web/src/composerDraftStore.test.ts | 3 +- apps/web/src/hooks/useThreadActions.ts | 29 ++- apps/web/src/state/shell.test.ts | 3 +- apps/web/src/state/threads.ts | 19 +- docs/internals/connection-runtime.md | 9 + .../client-runtime/src/state/orchestration.ts | 7 + .../src/state/shell-sync.test.ts | 94 +++++++- packages/client-runtime/src/state/shell.ts | 53 +++- .../src/state/shellSnapshotHttp.ts | 17 +- .../src/state/threadFamilies.test.ts | 193 +++++++++++++++ .../src/state/threadFamilies.ts | 226 ++++++++++++++++++ packages/client-runtime/src/state/threads.ts | 1 + packages/contracts/src/environmentHttp.ts | 5 + packages/contracts/src/orchestrationV2.ts | 70 ++++++ packages/contracts/src/rpc.ts | 17 ++ packages/contracts/src/server.ts | 5 + 35 files changed, 1233 insertions(+), 49 deletions(-) create mode 100644 packages/client-runtime/src/state/threadFamilies.test.ts create mode 100644 packages/client-runtime/src/state/threadFamilies.ts diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index d47e34551817e..b82cccfc294c8 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -77,7 +77,7 @@ import { useSelectedThreadWorktree } from "../../state/use-selected-thread-workt import { useThreadComposerState } from "../../state/use-thread-composer-state"; import { resolveMergeBackTargetThreadId } from "@t3tools/client-runtime/state/thread-relationships"; import { resolveLatestMergeBackRun } from "@t3tools/client-runtime/state/thread-workflows"; -import { threadEnvironment } from "../../state/threads"; +import { threadEnvironment, useThreadFamily } from "../../state/threads"; import { projectThreadContentPresentation } from "./threadContentPresentation"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { @@ -205,6 +205,12 @@ function LiveThreadRouteController(props: ThreadRouteScreenProps) { const connectionsReady = useConnectionsReady(); const { connectionState } = useRemoteConnectionStatus(); const { selectedThread } = useThreadSelection(); + // Subagent rows, the open child's own row and its ancestors come from the family when the + // shell leaves idle children out. + useThreadFamily( + selectedThread?.environmentId ?? null, + selectedThread?.lineage.rootThreadId ?? null, + ); const params = props.route.params ?? {}; const environmentIdRaw = firstRouteParam(params.environmentId); const threadIdRaw = firstRouteParam(params.threadId); diff --git a/apps/mobile/src/state/shell.ts b/apps/mobile/src/state/shell.ts index f62c78dace74b..dc1bbf967e880 100644 --- a/apps/mobile/src/state/shell.ts +++ b/apps/mobile/src/state/shell.ts @@ -3,6 +3,7 @@ import { createEnvironmentShellSummaryAtom, createEnvironmentSnapshotAtom, createShellEnvironmentAtoms, + EMPTY_SHELL_STATE, type EnvironmentShellState, } from "@t3tools/client-runtime/state/shell"; import { useAtomValue } from "@effect/atom-react"; @@ -21,11 +22,7 @@ export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ shellStateValueAtom: environmentShell.stateValueAtom, }); -const EMPTY_ENVIRONMENT_SHELL_STATE_ATOM = Atom.make({ - snapshot: Option.none(), - status: "empty", - error: Option.none(), -}); +const EMPTY_ENVIRONMENT_SHELL_STATE_ATOM = Atom.make(EMPTY_SHELL_STATE); const shellStatus = (state: EnvironmentShellState) => state.status; const shellHasError = (state: EnvironmentShellState) => Option.isSome(state.error); diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 9fece2315820f..55d5c10110a2e 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -4,6 +4,7 @@ import { fileAttachmentTooLargeMessage, } from "@t3tools/client-runtime/state/attachments"; import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; +import type { ThreadFamilyRead } from "@t3tools/client-runtime/state/threads"; import { CommandId, EnvironmentId, @@ -176,6 +177,11 @@ export function resolveThreadOutboxDeliveryAction(input: { readonly shellStatus: EnvironmentShellStatus; readonly environmentConnected: boolean; readonly threadBusy: boolean; + /** + * Set when the shell can leave idle subagent children out: absence alone is not deletion there, + * only a settled family read without the row is. + */ + readonly familyRead?: ThreadFamilyRead; }): ThreadOutboxDeliveryAction { if (input.isCreation) { if (input.threadExists) { @@ -187,7 +193,10 @@ export function resolveThreadOutboxDeliveryAction(input: { return input.environmentConnected && input.shellStatus === "live" ? "send" : "wait"; } if (!input.threadExists) { - return input.shellStatus === "live" ? "remove" : "wait"; + return input.shellStatus === "live" && + (input.familyRead === undefined || input.familyRead === "settled") + ? "remove" + : "wait"; } return input.environmentConnected ? "send" : "wait"; } diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index 0d08c2af1073b..66bd21b2d9bf5 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -1124,6 +1124,19 @@ describe("thread outbox", () => { ).toBe("send"); }); + it("removes a missing-thread message only after a settled family read when idle children can be omitted", () => { + const missing = { + isCreation: false, + threadExists: false, + shellStatus: "live", + environmentConnected: true, + threadBusy: false, + } as const; + expect(resolveThreadOutboxDeliveryAction({ ...missing, familyRead: "pending" })).toBe("wait"); + expect(resolveThreadOutboxDeliveryAction({ ...missing, familyRead: "failed" })).toBe("wait"); + expect(resolveThreadOutboxDeliveryAction({ ...missing, familyRead: "settled" })).toBe("remove"); + }); + it("sends existing-thread messages whenever connected so queued messages can steer", () => { expect( resolveThreadOutboxDeliveryAction({ diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index c056ce417f922..2b7d4db5b99db 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -6,18 +6,25 @@ import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState, createThreadEnvironmentAtoms, + createThreadFamilyAtoms, } from "@t3tools/client-runtime/state/threads"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; +import { useEffect } from "react"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; -import { environmentSnapshotAtom } from "./shell"; +import { appAtomRegistry } from "./atom-registry"; +import { environmentShell } from "./shell"; +export const threadFamilies = createThreadFamilyAtoms( + connectionAtomRuntime, + environmentShell.stateAtom, +); export const threadEnvironment = createThreadEnvironmentAtoms( connectionAtomRuntime, - environmentSnapshotAtom, + threadFamilies.snapshotAtom, ); export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( @@ -28,6 +35,17 @@ export const environmentThreadShells = createEnvironmentThreadShellAtoms({ snapshotAtom: threadEnvironment.snapshotAtom, }); +/** Keeps the subagent tree under `rootThreadId` in the thread list while the caller shows it. */ +export function useThreadFamily( + environmentId: EnvironmentId | null, + rootThreadId: ThreadId | null, +): void { + useEffect(() => { + if (environmentId === null || rootThreadId === null) return; + return threadFamilies.mount(appAtomRegistry, environmentId, rootThreadId); + }, [environmentId, rootThreadId]); +} + const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe( Atom.withLabel("mobile-environment-thread:empty"), ); diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 94dc617a50d49..02d68745d9e79 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -3,6 +3,7 @@ import { createEnvironmentThreadShellAtoms } from "@t3tools/client-runtime/state import { applyShellStreamEvent, createEnvironmentSnapshotAtom, + EMPTY_SHELL_STATE, type EnvironmentShellState, type EnvironmentShellStatus, } from "@t3tools/client-runtime/state/shell"; @@ -284,9 +285,9 @@ function modelReconciliationHarness( ], }; const shellStateAtom = Atom.make({ + ...EMPTY_SHELL_STATE, snapshot: Option.some(initialSnapshot), status: initialStatus, - error: Option.none(), }); const shellResultAtom = Atom.map(shellStateAtom, AsyncResult.success); const snapshotAtom = createEnvironmentSnapshotAtom(() => shellResultAtom)(environmentId); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 47c76d72136a8..1f473f6039f20 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -7,6 +7,7 @@ import { type EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { threadFamilyRead, type ThreadFamilyRead } from "@t3tools/client-runtime/state/threads"; import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations"; import { AuthOrchestrationOperateScope, @@ -66,7 +67,7 @@ import { type ThreadOutboxCommandStage, type ThreadOutboxFailureAction, } from "./thread-outbox-model"; -import { environmentThreadShells, threadEnvironment } from "./threads"; +import { environmentThreadShells, threadEnvironment, threadFamilies } from "./threads"; import { appendComposerDraftAttachments, composerDraftsAtom, @@ -614,6 +615,16 @@ export function useThreadOutboxDrain(): void { const retryNotBeforeRef = useRef(new Map()); const retryTimersRef = useRef(new Map>()); const acknowledgedExistingThreadMessageIdsRef = useRef(new Set()); + // Targets the shell may have left out hold their thread family until they leave the queue, so + // the row stays readable through dispatch. + const familyMountsRef = useRef(new Map void>()); + useEffect( + () => () => { + for (const release of familyMountsRef.current.values()) release(); + familyMountsRef.current.clear(); + }, + [], + ); const blockedRecoverySubscriptionsRef = useRef( new Map< MessageId, @@ -1102,6 +1113,12 @@ export function useThreadOutboxDrain(): void { acknowledgedExistingThreadMessageIdsRef.current.delete(messageId); } } + for (const [messageId, release] of familyMountsRef.current) { + if (!queuedMessageIds.has(messageId)) { + release(); + familyMountsRef.current.delete(messageId); + } + } for (const [threadKey, queuedMessages] of Object.entries(queuedMessagesByThreadKey)) { const nextQueuedMessage = queuedMessages[0]; @@ -1153,7 +1170,7 @@ export function useThreadOutboxDrain(): void { continue; } - const thread = findThread(threads, nextQueuedMessage); + let thread = findThread(threads, nextQueuedMessage); if (thread && scopedThreadKey(thread.environmentId, thread.id) !== threadKey) { continue; } @@ -1163,17 +1180,66 @@ export function useThreadOutboxDrain(): void { (candidate) => candidate.environmentId === nextQueuedMessage.environmentId, ); const shellStatus = shellStatuses.get(nextQueuedMessage.environmentId) ?? "empty"; + const serverConfig = serverConfigs.get(nextQueuedMessage.environmentId); + let familyRead: ThreadFamilyRead | undefined; + if ( + thread === undefined && + creation === undefined && + serverConfig?.shellThreadFamilies === true + ) { + // The family keeps the row readable through dispatch once it loads; the current shell + // generation's read decides whether the thread is gone. + if (!familyMountsRef.current.has(nextQueuedMessage.messageId)) { + familyMountsRef.current.set( + nextQueuedMessage.messageId, + threadFamilies.mount( + appAtomRegistry, + nextQueuedMessage.environmentId, + nextQueuedMessage.threadId, + ), + ); + } + familyRead = threadFamilyRead( + appAtomRegistry.get( + threadFamilies.familyAtom(nextQueuedMessage.environmentId, nextQueuedMessage.threadId), + ), + ); + if (familyRead === "settled") { + // The rows from this render can lag the family that just settled; decide from the + // merged rows as they are now. + thread = findThread( + appAtomRegistry.get(environmentThreadShells.threadShellsAtom), + nextQueuedMessage, + ); + } else if (familyRead === "failed") { + threadFamilies.refresh( + appAtomRegistry, + nextQueuedMessage.environmentId, + nextQueuedMessage.threadId, + ); + } + } const deliveryAction = resolveThreadOutboxDeliveryAction({ isCreation: creation !== undefined, threadExists: thread !== undefined, shellStatus, environmentConnected: environment?.connectionState === "connected", threadBusy: threadRuntimeIsActive(thread?.runtime), + ...(familyRead === undefined ? {} : { familyRead }), }); + if ( + deliveryAction === "wait" && + shellStatus === "live" && + familyRead !== undefined && + familyRead !== "settled" + ) { + // An empty family read changes no merged row, so nothing else would wake the drain. + scheduleQueuedMessageRetry(nextQueuedMessage.messageId); + continue; + } if (deliveryAction === "send" && !operableEnvironments.has(nextQueuedMessage.environmentId)) { continue; } - const serverConfig = serverConfigs.get(nextQueuedMessage.environmentId); const dispatchStep = resolveThreadOutboxDispatchStep({ deliveryAction, fileAttachments: nextQueuedMessage.attachments.filter( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 3f007b1905927..447b4e8e22ef8 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -48,6 +48,8 @@ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_V2_WS_METHODS.searchThread]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.searchThreadStream]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope, + [ORCHESTRATION_V2_WS_METHODS.getThreadFamily]: AuthOrchestrationReadScope, + [ORCHESTRATION_V2_WS_METHODS.getWorktreeThreadIds]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.launchThread]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 0c018691e10be..769fc2d1feb33 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -24,6 +24,8 @@ const RPC_AGGREGATES = { [ORCHESTRATION_V2_WS_METHODS.searchThread]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.searchThreadStream]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.getThreadFamily]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.getWorktreeThreadIds]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: "orchestrationV2", [ORCHESTRATION_V2_WS_METHODS.launchThread]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell]: "orchestration", diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 98d6e4c2e81a3..b2268d9a81f9f 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -1107,6 +1107,92 @@ const assertProjectScopedShells = Effect.fn("assertProjectScopedShells")(functio return { projectId, activeThreadId, forkThreadId, otherThreadId }; }); +it.effect.each([ + ["memory", ProjectionStore.layerMemory], + ["SQL", layerTest], +] as const)("%s shells read a subagent family and a worktree's threads", ([, layer]) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const projectId = ProjectId.make("project:family"); + const worktreePath = "/repo/.t3/worktrees/family"; + const id = (name: string) => ThreadId.make(`thread:family:${name}`); + const payloadOf = (input: { + readonly name: string; + readonly parent?: string; + readonly root?: string; + readonly worktreePath?: string; + readonly archived?: boolean; + }) => + ({ + createdBy: "user", + creationSource: input.parent === undefined ? "web" : "mcp", + id: id(input.name), + projectId, + title: input.name, + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: input.worktreePath ?? null, + activeProviderThreadId: null, + lineage: { + parentThreadId: input.parent === undefined ? null : id(input.parent), + relationshipToParent: input.parent === undefined ? null : "subagent", + rootThreadId: id(input.root ?? input.name), + }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: input.archived === true ? now : null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }) as const; + const createThread = (input: Parameters[0]) => + store.apply({ + id: EventId.make(`event:family:${input.name}`), + type: "thread.created", + threadId: id(input.name), + occurredAt: now, + payload: payloadOf(input), + }); + yield* createThread({ name: "root", worktreePath }); + yield* createThread({ name: "child", parent: "root", root: "root", worktreePath }); + yield* createThread({ name: "grandchild", parent: "child", root: "root" }); + yield* createThread({ name: "archived-child", parent: "root", root: "root", archived: true }); + yield* createThread({ name: "other", worktreePath: "/repo" }); + yield* createThread({ name: "other-child", parent: "other", root: "other", worktreePath }); + + const family = (threadId: ThreadId) => + store + .getShellSnapshot({ location: "active", subagentFamilyOf: threadId }) + .pipe(Effect.map((snapshot) => snapshot.threads.map((thread) => thread.id))); + // Any member, or the root itself, names the same active subagent tree. + assert.sameMembers([...(yield* family(id("root")))], [id("child"), id("grandchild")]); + assert.sameMembers([...(yield* family(id("grandchild")))], [id("child"), id("grandchild")]); + assert.deepEqual([...(yield* family(id("missing")))], []); + + // Includes another root's idle child on the same path, which a shell may leave out. + const onWorktree = yield* store.getShellSnapshot({ location: "active", worktreePath }); + assert.sameMembers( + onWorktree.threads.map((thread) => thread.id), + [id("root"), id("child"), id("other-child")], + ); + // A fork keeps its deleted source's root, so the deleted root still names the live tree. + yield* store.apply({ + id: EventId.make("event:family:root-deleted"), + type: "thread.deleted", + threadId: id("root"), + occurredAt: now, + payload: { ...payloadOf({ name: "root", worktreePath }), deletedAt: now }, + }); + assert.sameMembers([...(yield* family(id("root")))], [id("child"), id("grandchild")]); + }).pipe(Effect.provide(layer)), +); + it.effect("lists only active subagent IDs without loading shell data", () => Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 4ddc34ae4a4ff..84ec210dc39d6 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -406,6 +406,12 @@ export interface ShellSnapshotOptions { readonly location?: "active" | "archive"; readonly projectId?: ProjectId; readonly unsettledOnly?: boolean; + /** + * Only the subagent threads sharing this thread's `lineage.rootThreadId`. The thread may be + * deleted: forks keep a deleted source's root, and their subagent trees still need it. + */ + readonly subagentFamilyOf?: ThreadId; + readonly worktreePath?: string; } export interface ProjectionStoreV2Shape { @@ -5697,7 +5703,13 @@ export const layer: Layer.Layer = const selectShellThreadRows = ( threadId?: ThreadId, - { location, projectId, unsettledOnly = false }: ShellSnapshotOptions = {}, + { + location, + projectId, + unsettledOnly = false, + subagentFamilyOf, + worktreePath, + }: ShellSnapshotOptions = {}, ) => sql` SELECT @@ -5898,6 +5910,18 @@ export const layer: Layer.Layer = unsettledOnly ? sql` AND json_extract(t.payload_json, '$.settledAt') IS NULL AND json_extract(t.payload_json, '$.settledOverride') IS NOT 'settled'` : sql`` + }${ + subagentFamilyOf === undefined + ? sql`` + : sql` AND json_extract(t.payload_json, '$.lineage.relationshipToParent') = 'subagent' AND json_extract(t.payload_json, '$.lineage.rootThreadId') = ( + SELECT json_extract(member.payload_json, '$.lineage.rootThreadId') + FROM orchestration_v2_projection_threads member + WHERE member.thread_id = ${subagentFamilyOf} + )` + }${ + worktreePath === undefined + ? sql`` + : sql` AND json_extract(t.payload_json, '$.worktreePath') = ${worktreePath}` } ORDER BY t.updated_at ASC, t.thread_id ASC `; @@ -6712,6 +6736,22 @@ export const layerMemory: Layer.Layer = Layer.effect( ) { return false; } + if (options?.subagentFamilyOf !== undefined) { + const member = existing.get(options.subagentFamilyOf)?.thread; + if ( + member === undefined || + projection.thread.lineage.relationshipToParent !== "subagent" || + projection.thread.lineage.rootThreadId !== member.lineage.rootThreadId + ) { + return false; + } + } + if ( + options?.worktreePath !== undefined && + projection.thread.worktreePath !== options.worktreePath + ) { + return false; + } if (options?.location === "active") return projection.thread.archivedAt === null; if (options?.location === "archive") return projection.thread.archivedAt !== null; return true; diff --git a/apps/server/src/orchestration-v2/ShellStream.test.ts b/apps/server/src/orchestration-v2/ShellStream.test.ts index fcfaf8298f5ec..229400dd8ce96 100644 --- a/apps/server/src/orchestration-v2/ShellStream.test.ts +++ b/apps/server/src/orchestration-v2/ShellStream.test.ts @@ -17,9 +17,11 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { + activeShellRowsForSubscriber, bufferShellLiveStream, composeShellStreamWithEnrichment, dedupeShellEnrichment, + omitIdleSubagentShells, shellStreamItemFromEnrichmentRefresh, shellStreamItemsFromInitialSnapshot, shouldPublishShellApplicationEvent, @@ -139,6 +141,69 @@ function shellFixture(overrides: Partial): Orchestra return { id: "thread-a", archivedAt: null, ...overrides } as OrchestrationV2ThreadShell; } +describe("omitIdleSubagentShells", () => { + const row = ( + id: string, + parent: string | null, + overrides: Partial = {}, + ) => + shellFixture({ + id: ThreadId.make(id), + lineage: { + parentThreadId: parent === null ? null : ThreadId.make(parent), + relationshipToParent: parent === null ? null : "subagent", + rootThreadId: ThreadId.make("root"), + }, + status: "completed", + activityRunStatus: null, + pendingRuntimeRequest: null, + pendingBackgroundTasks: [], + ...overrides, + }); + + it("drops idle leaf children but keeps live ones and the middle of nested chains", () => { + const threads = [ + row("root", null), + row("idle", "root"), + row("running", "root", { activityRunStatus: "running" }), + row("queued", "root", { status: "queued" }), + row("asking", "root", { + pendingRuntimeRequest: { + id: "request" as never, + kind: "user_input", + createdAt: DateTime.makeUnsafe(0), + }, + }), + row("background", "root", { + pendingBackgroundTasks: [{ kind: "subagent" } as never], + }), + // Idle, but a woken grandchild walks through it to reach the root's badge. + row("middle", "root"), + row("grandchild", "middle"), + ]; + expect(omitIdleSubagentShells(threads).map((thread) => thread.id)).toEqual([ + "root", + "running", + "queued", + "asking", + "background", + "middle", + ]); + }); + + it("still filters the transcript events of omitted children for a subscriber that opts in", () => { + const threads = [row("root", null), row("idle", "root")]; + const omitted = activeShellRowsForSubscriber(threads, true); + expect(omitted.threads.map((thread) => thread.id)).toEqual(["root"]); + expect( + shouldPublishShellApplicationEvent( + { event: { threadId: "idle", type: "turn-item.updated" } } as ApplicationStoredEvent, + omitted.subagentThreadIds, + ), + ).toBe(false); + }); +}); + describe("shellStreamItemsFromInitialSnapshot", () => { it("keeps thread rows out of the metadata-only enrichment frame", () => { const snapshot = { diff --git a/apps/server/src/orchestration-v2/ShellStream.ts b/apps/server/src/orchestration-v2/ShellStream.ts index 7a1367aa67924..9985c779b9a14 100644 --- a/apps/server/src/orchestration-v2/ShellStream.ts +++ b/apps/server/src/orchestration-v2/ShellStream.ts @@ -9,6 +9,7 @@ import type { OrchestrationV2StoredEvent, } from "@t3tools/contracts"; import { + isLiveSubagentShell, OrchestrationProjectShell as ProjectShellSchema, OrchestrationV2ThreadShell as ThreadShellSchema, } from "@t3tools/contracts"; @@ -64,6 +65,49 @@ export function buildActiveShellSnapshot(input: { }; } +/** + * Idle subagent children have no sidebar row, so clients that opt in read them from their thread + * family instead. A child stays while it is live or the parent of another child, so the request + * and working walks over shell rows never meet a gap in a nested chain. + */ +export function omitIdleSubagentShells( + threads: ReadonlyArray, +): ReadonlyArray { + const parentIds = new Set(); + for (const thread of threads) { + if (thread.lineage.relationshipToParent === "subagent" && thread.lineage.parentThreadId) { + parentIds.add(thread.lineage.parentThreadId); + } + } + return threads.filter( + (thread) => + thread.lineage.relationshipToParent !== "subagent" || + isLiveSubagentShell(thread) || + parentIds.has(thread.id), + ); +} + +/** + * The active rows a shell subscriber gets, and the subagent ids whose transcript events its stream + * drops. The ids come from every row, so an omitted child that wakes keeps that filter. + */ +export function activeShellRowsForSubscriber( + threads: ReadonlyArray, + omitIdleSubagents: boolean, +): { + readonly threads: ReadonlyArray; + readonly subagentThreadIds: Set; +} { + return { + threads: omitIdleSubagents ? omitIdleSubagentShells(threads) : threads, + subagentThreadIds: new Set( + threads + .filter((thread) => thread.lineage.relationshipToParent === "subagent") + .map((thread) => thread.id), + ), + }; +} + /** * One transaction covers the reads so they agree; the threads are decoded after it commits, * because the server shares one SQLite connection and decoding a large shell takes longer than diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index f4c79c2721532..6fe1ee2fae619 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -6,6 +6,7 @@ import type { ProjectionRecordField, ProjectionRecordFilter, ProjectionRecords, + ShellSnapshotOptions, } from "./ProjectionStore.ts"; import { type ChatAttachment, @@ -383,9 +384,9 @@ export interface ThreadManagementServiceShape { readonly location?: "active" | "archive"; }) => Effect.Effect; readonly listActiveSubagentThreadIds: Orchestrator.OrchestratorV2["Service"]["listActiveSubagentThreadIds"]; - readonly readShellSnapshot: (options?: { - readonly location?: "active" | "archive"; - }) => Effect.Effect< + readonly readShellSnapshot: ( + options?: Pick, + ) => Effect.Effect< Effect.Effect, Orchestrator.OrchestratorV2Error >; diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index cc8e987880df8..d4ee3a6cc45a8 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -30,7 +30,11 @@ import { } from "./threadHistoryPaging.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ProjectStore from "./ProjectStore.ts"; -import { buildActiveShellSnapshot, loadShellSnapshotParts } from "./ShellStream.ts"; +import { + buildActiveShellSnapshot, + loadShellSnapshotParts, + omitIdleSubagentShells, +} from "./ShellStream.ts"; import { boundedSnapshotResponseFields } from "./ThreadStream.ts"; import { projectThreadProjectionForWire } from "./WireProjection.ts"; @@ -75,7 +79,9 @@ export const layer = HttpApiBuilder.group( ), ); - const loadShellSnapshot = Effect.fn("http.orchestration.loadShellSnapshot")(function* () { + const loadShellSnapshot = Effect.fn("http.orchestration.loadShellSnapshot")(function* ( + omitIdleSubagents: boolean, + ) { const base = buildActiveShellSnapshot( yield* loadShellSnapshotParts({ sql, @@ -85,7 +91,11 @@ export const layer = HttpApiBuilder.group( }), ); const projects = yield* enrichProjectShells(base.projects); - return { ...base, projects }; + return { + ...base, + threads: omitIdleSubagents ? omitIdleSubagentShells(base.threads) : base.threads, + projects, + }; }); const loadThreadSnapshot = Effect.fn("http.orchestration.loadThreadSnapshot")(function* ( @@ -154,7 +164,7 @@ export const layer = HttpApiBuilder.group( Effect.fn("environment.orchestration.shellSnapshot")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationReadScope); - return yield* loadShellSnapshot().pipe( + return yield* loadShellSnapshot(args.query.omitIdleSubagents === "1").pipe( traceLocalHandlerWork, Effect.catch((cause) => failEnvironmentInternal("orchestration_snapshot_failed", cause), diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 787fdc268f4a4..894f95c0d8204 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -8,6 +8,7 @@ import { WS_METHODS, WsSubscribeServerConfigRpc, type OrchestrationV2StoredEvent, + type OrchestrationV2ThreadShell, type OrchestrationProjectShell, type ServerConfig, type ServerConfigStreamEvent, @@ -290,6 +291,71 @@ it.live.each(["thread", "shell"] as const)( }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), ); +it.effect("shell snapshots leave idle subagent children out only for clients that ask", () => + Effect.gen(function* () { + const shell = (id: string, parent: string | null, activityRunStatus: "running" | null) => + ({ + id: ThreadId.make(id), + archivedAt: null, + status: activityRunStatus ?? "completed", + activityRunStatus, + pendingRuntimeRequest: null, + pendingBackgroundTasks: [], + lineage: { + parentThreadId: parent === null ? null : ThreadId.make(parent), + relationshipToParent: parent === null ? null : "subagent", + rootThreadId: ThreadId.make("parent"), + }, + }) as unknown as OrchestrationV2ThreadShell; + const threads = [ + shell("parent", null, null), + shell("idle", "parent", null), + shell("live", "parent", "running"), + ]; + const firstSnapshotThreadIds = (input: { readonly omitIdleSubagents?: boolean }) => + subscribeOrchestrationV2Shell(input).pipe( + Effect.flatMap((stream) => stream.pipe(Stream.take(1), Stream.runCollect)), + Effect.map(([item]) => + item?.kind === "snapshot" ? item.snapshot.threads.map((thread) => thread.id) : [], + ), + Effect.provideService(ThreadManagement.ThreadManagementService, { + readShellSnapshot: () => + Effect.succeed( + Effect.succeed({ + schemaVersion: 1, + snapshotSequence: 0, + threads, + archivedThreads: [], + }), + ), + } as unknown as ThreadManagement.ThreadManagementService["Service"]), + Effect.provideService(ProjectStore.ProjectStoreV2, { + listShells: () => Effect.succeed([]), + } as unknown as ProjectStore.ProjectStoreV2["Service"]), + Effect.provideService(ProjectService.ProjectService, { + getShell: () => Effect.succeedNone, + } as unknown as ProjectService.ProjectService["Service"]), + Effect.provideServiceEffect( + ProjectEnrichment.ProjectEnrichmentService, + PubSub.unbounded().pipe( + Effect.map( + (changes) => + ({ + subscribeChanges: PubSub.subscribe(changes), + }) as unknown as ProjectEnrichment.ProjectEnrichmentService["Service"], + ), + ), + ), + ); + + assert.deepEqual(yield* firstSnapshotThreadIds({}), ["parent", "idle", "live"]); + assert.deepEqual(yield* firstSnapshotThreadIds({ omitIdleSubagents: true }), [ + "parent", + "live", + ]); + }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), +); + // Build the production RPC layer; unrelated capabilities fail if this test calls them. const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(ServerSecretStore.ServerSecretStore)({}), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f9f289ea30a86..3316829f48343 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -129,6 +129,7 @@ import { coalesceStoredThreadEvents, composeShellStreamWithEnrichment, dedupeShellEnrichment, + activeShellRowsForSubscriber, loadShellSnapshotParts, shellStreamItemFromEnrichmentRefresh, shellStreamItemFromThreadShell, @@ -979,6 +980,7 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc function* (input: { readonly afterSequence?: number; readonly requestCompletionMarker?: boolean; + readonly omitIdleSubagents?: boolean; }) { const sql = yield* SqlClient.SqlClient; const threadManagement = yield* ThreadManagementService.ThreadManagementService; @@ -1015,14 +1017,15 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc latestSequence: applicationEvents.latestApplicationSequence, }), ); - subagentThreadIds = new Set( - base.threads - .filter((thread) => thread.lineage.relationshipToParent === "subagent") - .map((thread) => thread.id), - ); + const rows = activeShellRowsForSubscriber(base.threads, input.omitIdleSubagents === true); + subagentThreadIds = rows.subagentThreadIds; const enriched = yield* enrichProjectShells(base.projects); return { - snapshot: { ...base, projects: enriched.projects } as OrchestrationV2ShellSnapshot, + snapshot: { + ...base, + threads: rows.threads, + projects: enriched.projects, + } as OrchestrationV2ShellSnapshot, resolvedRepositoryIdentityRoots: enriched.resolvedRepositoryIdentityRoots, }; }); @@ -1772,6 +1775,7 @@ export const layerWsRpc = ( }, settings, shellResumeCompletionMarker: true, + shellThreadFamilies: true, threadResumeCompletionMarker: true, threadSnapshotPagination: true, threadFind: true, @@ -1818,6 +1822,26 @@ export const layerWsRpc = ( ), ); + // Rows the shell can omit, read with the same snapshot sequence the shell uses. + const readOmittableShellRows = Effect.fn("ws.orchestrationV2.readOmittableShellRows")( + function* ( + options: { readonly subagentFamilyOf: ThreadId } | { readonly worktreePath: string }, + message: string, + ) { + const { threads, snapshotSequence } = yield* loadShellSnapshotParts({ + sql, + readThreads: threadManagement.readShellSnapshot({ location: "active", ...options }), + listProjects: Effect.succeed([]), + latestSequence: applicationEvents.latestApplicationSequence, + }).pipe( + Effect.mapError( + (cause) => new OrchestrationV2GetShellSnapshotError({ message, cause }), + ), + ); + return { snapshotSequence, threads: threads.threads }; + }, + ); + const subscribeOrchestrationV2ArchivedShell = Effect.fn( "ws.orchestrationV2.subscribeArchivedShell", )(function* () { @@ -1966,6 +1990,16 @@ export const layerWsRpc = ( ), [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: (_input) => getOrchestrationV2ArchivedShellSnapshot, + [ORCHESTRATION_V2_WS_METHODS.getThreadFamily]: (input) => + readOmittableShellRows( + { subagentFamilyOf: input.threadId }, + "Failed to load the thread family", + ), + [ORCHESTRATION_V2_WS_METHODS.getWorktreeThreadIds]: (input) => + readOmittableShellRows( + { worktreePath: input.worktreePath }, + "Failed to load the worktree's threads", + ).pipe(Effect.map(({ threads }) => ({ threadIds: threads.map((thread) => thread.id) }))), [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: (input) => Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId }).pipe( Effect.andThen( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6252b087abe4b..800998728cf22 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -222,7 +222,7 @@ import { primaryServerKeybindingsAtom, serverEnvironment, } from "../state/server"; -import { threadEnvironment } from "../state/threads"; +import { threadEnvironment, useThreadFamily } from "../state/threads"; import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import { vcsEnvironment } from "../state/vcs"; import { @@ -466,6 +466,14 @@ export default function ChatView(props: ChatViewProps) { }); const serverThreadProjection = useQuietThreadProjection(routeThreadDetailRef); const serverProjection = serverThreadProjection?.projection ?? null; + // Subagent rows, an open child's own row and its ancestors come from the family when the shell + // leaves idle children out. Every source agrees on the root, so the key never flips. + useThreadFamily( + routeKind === "server" ? environmentId : null, + serverThreadShell?.lineage.rootThreadId ?? + serverProjection?.thread.lineage.rootThreadId ?? + null, + ); // Only the shell-less fallback needs these; with a shell, the shell carries them. const fallbackDetailRef = serverThreadShell === null ? routeThreadDetailRef : null; const fallbackUpdatedAt = useThreadUpdatedAt(fallbackDetailRef); diff --git a/apps/web/src/components/chat/useThreadLifecycle.tsx b/apps/web/src/components/chat/useThreadLifecycle.tsx index 8d90dbc31d16b..8e9af1b2d8997 100644 --- a/apps/web/src/components/chat/useThreadLifecycle.tsx +++ b/apps/web/src/components/chat/useThreadLifecycle.tsx @@ -418,8 +418,11 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { const threadRef = scopeThreadRef(environmentId, threadId); const isEnvironmentLive = () => appAtomRegistry.get(environmentShell.stateValueAtom(environmentId)).status === "live"; + // The shell can leave idle subagent children out, so absence is not deletion there: the + // route decides from the thread's own detail. + const shellOmitsChildren = serverConfig?.shellThreadFamilies === true; // A just-launched child can reach this timeline before its shell syncs. - if (isEnvironmentLive() && readThreadShell(threadRef) === null) { + if (!shellOmitsChildren && isEnvironmentLive() && readThreadShell(threadRef) === null) { const location = router.latestLocation; await waitForThreadShell(threadRef, RELATED_THREAD_SHELL_WAIT_MS); if ( @@ -431,7 +434,7 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { } } const shell = readThreadShell(threadRef); - if (shell === null ? isEnvironmentLive() : shell.deletedAt != null) { + if (shell === null ? !shellOmitsChildren && isEnvironmentLive() : shell.deletedAt != null) { toastManager.add({ type: "info", title: "This thread is no longer available" }); return; } diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index a7eb9b1c1a047..238cd8663510e 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -3,6 +3,7 @@ import { createEnvironmentThreadShellAtoms } from "@t3tools/client-runtime/state import { applyShellStreamEvent, createEnvironmentSnapshotAtom, + EMPTY_SHELL_STATE, type EnvironmentShellState, type EnvironmentShellStatus, } from "@t3tools/client-runtime/state/shell"; @@ -1238,9 +1239,9 @@ function modelReconciliationHarness( ], }; const shellStateAtom = Atom.make({ + ...EMPTY_SHELL_STATE, snapshot: Option.some(initialSnapshot), status: initialStatus, - error: Option.none(), }); const shellResultAtom = Atom.map(shellStateAtom, AsyncResult.success); const snapshotAtom = createEnvironmentSnapshotAtom(() => shellResultAtom)(environmentId); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 26adcfcd612fe..7796502826db1 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -28,6 +28,7 @@ import { environmentSession, readEnvironmentScope } from "../state/session"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentServerConfigsAtom } from "../state/server"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; +import { orchestrationEnvironment } from "../state/orchestration"; import { threadEnvironment } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useNewThreadHandler } from "./useHandleNewThread"; @@ -290,6 +291,10 @@ export function useThreadActions() { const loadSessionState = useAtomQueryRunner(environmentSession.sessionStateAtom, { reportFailure: false, }); + const loadWorktreeThreadIds = useAtomQueryRunner(orchestrationEnvironment.worktreeThreadIds, { + reportFailure: false, + refresh: true, + }); const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false, }); @@ -435,16 +440,32 @@ export function useThreadActions() { deletedIds && deletedIds.size > 0 ? threads.filter((entry) => entry.id === threadRef.threadId || !deletedIds.has(entry.id)) : threads; - const orphanedWorktreePath = getOrphanedWorktreePathForThread( + const environmentConfig = appAtomRegistry + .get(environmentServerConfigsAtom) + .get(threadRef.environmentId); + let orphanedWorktreePath = getOrphanedWorktreePathForThread( survivingThreads, threadRef.threadId, ); + // A shell that leaves idle subagent children out cannot prove nobody else uses the worktree, + // so the server's list decides. A failed read keeps the worktree. + if (orphanedWorktreePath !== null && environmentConfig?.shellThreadFamilies === true) { + const occupants = await loadWorktreeThreadIds({ + environmentId: threadRef.environmentId, + input: { worktreePath: orphanedWorktreePath }, + }); + if ( + occupants._tag !== "Success" || + occupants.value.threadIds.some( + (threadId) => threadId !== threadRef.threadId && deletedIds?.has(threadId) !== true, + ) + ) { + orphanedWorktreePath = null; + } + } const displayWorktreePath = orphanedWorktreePath ? formatWorktreePathForDisplay(orphanedWorktreePath) : null; - const environmentConfig = appAtomRegistry - .get(environmentServerConfigsAtom) - .get(threadRef.environmentId); const localApi = readLocalApi(); let canDeleteWorktree = false; if ( diff --git a/apps/web/src/state/shell.test.ts b/apps/web/src/state/shell.test.ts index 670566616a32a..4d5920a94ad85 100644 --- a/apps/web/src/state/shell.test.ts +++ b/apps/web/src/state/shell.test.ts @@ -3,7 +3,7 @@ import { PrimaryConnectionTarget, } from "@t3tools/client-runtime/connection"; import type { EnvironmentCatalogState } from "@t3tools/client-runtime/state/connections"; -import type { EnvironmentShellState } from "@t3tools/client-runtime/state/shell"; +import { EMPTY_SHELL_STATE, type EnvironmentShellState } from "@t3tools/client-runtime/state/shell"; import { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { Atom, AtomRegistry } from "effect/reactivity"; @@ -16,6 +16,7 @@ const REMOTE = EnvironmentId.make("remote"); function shellState(status: EnvironmentShellState["status"]): EnvironmentShellState { return { + ...EMPTY_SHELL_STATE, status, snapshot: status === "empty" diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index 2bf5c26c6abca..99b67bc2b6b68 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -8,18 +8,22 @@ import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState, createThreadEnvironmentAtoms, + createThreadFamilyAtoms, } from "@t3tools/client-runtime/state/threads"; import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; +import { useEffect } from "react"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; -import { environmentSnapshotAtom } from "./shell"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentShell } from "./shell"; +const threadFamilies = createThreadFamilyAtoms(connectionAtomRuntime, environmentShell.stateAtom); export const threadEnvironment = createThreadEnvironmentAtoms( connectionAtomRuntime, - environmentSnapshotAtom, + threadFamilies.snapshotAtom, ); const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( @@ -30,6 +34,17 @@ export const environmentThreadShells = createEnvironmentThreadShellAtoms({ snapshotAtom: threadEnvironment.snapshotAtom, }); +/** Keeps the subagent tree under `rootThreadId` in the thread list while the caller shows it. */ +export function useThreadFamily( + environmentId: EnvironmentId | null, + rootThreadId: ThreadId | null, +): void { + useEffect(() => { + if (environmentId === null || rootThreadId === null) return; + return threadFamilies.mount(appAtomRegistry, environmentId, rootThreadId); + }, [environmentId, rootThreadId]); +} + const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe( Atom.withLabel("web-environment-thread:empty"), ); diff --git a/docs/internals/connection-runtime.md b/docs/internals/connection-runtime.md index 35c8b0a736583..8bd6a950d96d8 100644 --- a/docs/internals/connection-runtime.md +++ b/docs/internals/connection-runtime.md @@ -59,6 +59,15 @@ connection nor overwrite newer live data during a reconnect. Loading and resuming snapshots belongs to the shared state services, so every view agrees on which data is current. +Servers that advertise `shellThreadFamilies` leave idle subagent children out of +shell snapshots when the client asks. A child stays while it has a live or +queued run, a pending request or background work, or a subagent child of its +own, so badge and working walks never meet a gap. Other child rows come from the +[thread family](../../packages/client-runtime/src/state/threadFamilies.ts) that +a thread view or a queued message mounts. A missing shell row therefore does not +prove a thread is gone: code that deletes data or a worktree on absence must +check the family or the server first. + [Thread detail](../../packages/client-runtime/src/state/threads.ts) separates subscription lifetime from cache lifetime. Mounted consumers share one live stream, which stops when the last consumer unmounts; hidden mounted routes still diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index 8bef9762a29d9..24ec67e5b38e2 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -128,5 +128,12 @@ export function createOrchestrationEnvironmentAtoms( label: "environment-data:orchestration:archived-shell-snapshot", tag: ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot, }), + // Read right before deleting a worktree, so never served from cache. + worktreeThreadIds: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:orchestration:worktree-thread-ids", + tag: ORCHESTRATION_V2_WS_METHODS.getWorktreeThreadIds, + staleTimeMs: 0, + idleTtlMs: 0, + }), }; } diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 5ce6d7dbaa290..0e76e321bf3ae 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -27,7 +27,7 @@ import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as Persistence from "../platform/persistence.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; -import { makeEnvironmentShellState } from "./shell.ts"; +import { type EnvironmentShellState, makeEnvironmentShellState } from "./shell.ts"; import * as ShellSnapshotLoader from "./shellSnapshotHttp.ts"; import { v2Project, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; @@ -478,6 +478,98 @@ describe("environment shell synchronization", () => { }), ); + it.effect("opts in to idle-child omission and counts authoritative snapshots and removals", () => + Effect.gen(function* () { + const events = yield* Queue.unbounded(); + const subscribeInput = yield* Deferred.make(); + const loadOptions = yield* Ref.make>([]); + const client = { + [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: (input: unknown) => + Stream.unwrap( + Deferred.succeed(subscribeInput, input).pipe(Effect.as(Stream.fromQueue(events))), + ), + } as unknown as WsRpcProtocolClient; + const familySession: RpcSession.RpcSession = { + ...session(client), + initialConfig: Effect.succeed({ + shellResumeCompletionMarker: true, + shellThreadFamilies: true, + } as never), + }; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(familySession)), + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.void, + loadThread: () => Effect.succeed(Option.none()), + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const snapshotLoader = ShellSnapshotLoader.ShellSnapshotLoader.of({ + load: (_prepared, options) => + Ref.update(loadOptions, (all) => [...all, options]).pipe( + Effect.as(Option.some(LIVE_SHELL_SNAPSHOT)), + ), + }); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader.ShellSnapshotLoader, snapshotLoader), + ); + const stateWhere = (predicate: (state: EnvironmentShellState) => boolean) => + SubscriptionRef.changes(shellState).pipe(Stream.filter(predicate), Stream.runHead); + + // Both transports must omit, or omitted children would come back through the socket. + expect(yield* Deferred.await(subscribeInput)).toMatchObject({ + afterSequence: 1, + omitIdleSubagents: true, + }); + expect(yield* Ref.get(loadOptions)).toEqual([{ omitIdleSubagents: true }]); + const loaded = Option.getOrThrow(yield* stateWhere((state) => state.generation > 0)); + expect(loaded.removedAt.size).toBe(0); + + const gone = ThreadId.make("thread-gone"); + yield* Queue.offer(events, { + kind: "snapshot", + snapshot: { ...LIVE_SHELL_SNAPSHOT, threads: [] }, + resolvedRepositoryIdentityRoots: [], + }); + yield* Queue.offer(events, { + kind: "thread.removed", + sequence: 2, + location: "active", + threadId: gone, + }); + const removed = Option.getOrThrow(yield* stateWhere((state) => state.removedAt.size > 0)); + // An enrichment refresh is not authoritative. + expect(removed.generation).toBe(loaded.generation); + expect(removed.removedAt.get(gone)).toBe(2); + + yield* Queue.offer(events, { + kind: "snapshot", + snapshot: { ...LIVE_SHELL_SNAPSHOT, snapshotSequence: 5 }, + }); + const reset = Option.getOrThrow( + yield* stateWhere((state) => state.generation > loaded.generation), + ); + expect(reset.removedAt.size).toBe(0); + }), + ); + it.effect("fills deferred cached pull request links without overwriting newer rows", () => Effect.gen(function* () { const link = (number: number): ThreadPullRequestLink => ({ diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index 0a7e2aa7c6664..a01465d2dc7e5 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -45,12 +45,26 @@ export interface EnvironmentShellState { readonly snapshot: Option.Option; readonly status: EnvironmentShellStatus; readonly error: Option.Option; + /** + * Changes with each authoritative snapshot (not enrichment refreshes); 0 until the first one. + * Thread families read for an older generation may miss changes from before the latest snapshot, + * so they are read again. + */ + readonly generation: number; + /** Sequences of the thread removals applied since the latest authoritative snapshot. */ + readonly removedAt: ReadonlyMap; } -const EMPTY_SHELL_STATE: EnvironmentShellState = { +const EMPTY_REMOVALS: ReadonlyMap = new Map(); +// Shared by every shell state, so a rebuilt one never reuses a generation another already used. +let lastShellGeneration = 0; + +export const EMPTY_SHELL_STATE: EnvironmentShellState = { snapshot: Option.none(), status: "empty", error: Option.none(), + generation: 0, + removedAt: EMPTY_REMOVALS, }; function shellStatusForSnapshot( @@ -86,9 +100,9 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") ); const cachedSnapshot = Option.map(cached, withoutDeferredPullRequests); const state = yield* SubscriptionRef.make({ + ...EMPTY_SHELL_STATE, snapshot: cachedSnapshot, status: shellStatusForSnapshot(cachedSnapshot), - error: Option.none(), }); const awaitingCompletion = yield* Ref.make(false); const lastAuthoritativeSession = yield* Ref.make(null); @@ -194,6 +208,8 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") } continue; } + const authoritative = + item.kind === "snapshot" && item.resolvedRepositoryIdentityRoots === undefined; const nextSnapshot = item.kind === "snapshot" ? mergeShellSnapshotProjects( @@ -214,10 +230,21 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") }); if (nextSnapshot === null) continue; receivedSnapshot ||= item.kind === "snapshot"; + const removal = + item.kind === "thread.removed" && + Option.exists(next.snapshot, (snapshot) => item.sequence > snapshot.snapshotSequence) + ? item + : null; next = { snapshot: Option.some(nextSnapshot), status: waiting ? "synchronizing" : "live", error: Option.none(), + generation: authoritative ? ++lastShellGeneration : next.generation, + removedAt: authoritative + ? EMPTY_REMOVALS + : removal === null + ? next.removedAt + : new Map(next.removedAt).set(removal.threadId, removal.sequence), }; } yield* Ref.set(awaitingCompletion, waiting); @@ -321,9 +348,15 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") ORCHESTRATION_V2_WS_METHODS.subscribeShell, Effect.fn("EnvironmentShellState.makeSubscribeInput")(function* (session) { yield* Ref.set(activeSubscriptionSession, session); - const supportsCompletionMarker = yield* session.initialConfig.pipe( - Effect.map((config) => config.shellResumeCompletionMarker === true), - Effect.orElseSucceed(() => false), + const { supportsCompletionMarker, omitIdleSubagents } = yield* session.initialConfig.pipe( + Effect.map((config) => ({ + supportsCompletionMarker: config.shellResumeCompletionMarker === true, + omitIdleSubagents: config.shellThreadFamilies === true, + })), + Effect.orElseSucceed(() => ({ + supportsCompletionMarker: false, + omitIdleSubagents: false, + })), ); yield* Ref.set(awaitingCompletion, supportsCompletionMarker); yield* setSynchronizing; @@ -349,7 +382,8 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") }), ), ); - const httpSnapshot = yield* snapshotLoader.load(prepared); + // HTTP and the socket must agree, or omitted children would return through the socket. + const httpSnapshot = yield* snapshotLoader.load(prepared, { omitIdleSubagents }); if (Option.isSome(httpSnapshot)) { yield* cancelActiveFill; const previous = yield* SubscriptionRef.get(state); @@ -371,8 +405,12 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") } } + const omission = omitIdleSubagents ? { omitIdleSubagents: true as const } : {}; if (!canResume || Option.isNone(current.snapshot)) { - return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}; + return { + ...omission, + ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), + }; } if (!supportsCompletionMarker) { yield* SubscriptionRef.update(state, (value) => ({ @@ -383,6 +421,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") } return { afterSequence: current.snapshot.value.snapshotSequence, + ...omission, ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), }; }), diff --git a/packages/client-runtime/src/state/shellSnapshotHttp.ts b/packages/client-runtime/src/state/shellSnapshotHttp.ts index cdb5ca77db56f..cacab7b6724fc 100644 --- a/packages/client-runtime/src/state/shellSnapshotHttp.ts +++ b/packages/client-runtime/src/state/shellSnapshotHttp.ts @@ -33,17 +33,22 @@ export const fetchEnvironmentShellSnapshot = Effect.fn( RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"] >; readonly timeoutMs?: number; + readonly omitIdleSubagents?: boolean; }) { + const endpoint = { + query: input.omitIdleSubagents === true ? { omitIdleSubagents: "1" } : {}, + }; return yield* executeAuthenticatedEnvironmentHttpRequest({ ...input, group: "orchestration", method: "GET", - url: (urls) => urls.shellSnapshot(), + url: (urls) => urls.shellSnapshot(endpoint), timeoutMs: input.timeoutMs ?? DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS, request: ({ client, headers }) => client .shellSnapshot({ headers: withOrchestrationProtocolHeader(headers), + ...endpoint, responseMode: "response-only", }) .pipe(Effect.flatMap(decodeShellSnapshotResponse)), @@ -80,6 +85,7 @@ export class ShellSnapshotLoader extends Context.Service< { readonly load: ( prepared: PreparedConnection, + options?: { readonly omitIdleSubagents?: boolean }, ) => Effect.Effect>; } >()("@t3tools/client-runtime/state/shellSnapshotHttp/ShellSnapshotLoader") {} @@ -95,8 +101,13 @@ export const layer: Layer.Layer - fetchEnvironmentShellSnapshot({ prepared, signer, remoteAuthorization }).pipe( + load: (prepared, options) => + fetchEnvironmentShellSnapshot({ + prepared, + signer, + remoteAuthorization, + ...(options?.omitIdleSubagents === true ? { omitIdleSubagents: true } : {}), + }).pipe( Effect.map(Option.some), Effect.provideService(HttpClient.HttpClient, httpClient), Effect.catchCause((cause) => diff --git a/packages/client-runtime/src/state/threadFamilies.test.ts b/packages/client-runtime/src/state/threadFamilies.test.ts new file mode 100644 index 0000000000000..e6d284edee368 --- /dev/null +++ b/packages/client-runtime/src/state/threadFamilies.test.ts @@ -0,0 +1,193 @@ +import { + EnvironmentId, + ORCHESTRATION_V2_WS_METHODS, + OrchestrationV2GetShellSnapshotError, + ThreadId, + type OrchestrationV2ShellSnapshot, + type OrchestrationV2ThreadFamily, + type OrchestrationV2ThreadShell, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import type * as RpcSession from "../rpc/session.ts"; +import { EMPTY_SHELL_STATE, type EnvironmentShellState } from "./shell.ts"; +import { + createThreadFamilyAtoms, + threadFamilyRead, + threadFamilyRowsMissingFromShell, +} from "./threadFamilies.ts"; + +const row = (id: string, title = id) => + ({ id: ThreadId.make(id), title }) as unknown as OrchestrationV2ThreadShell; + +describe("threadFamilyRowsMissingFromShell", () => { + it("adds only the rows the shell left out, once, with shell rows winning", () => { + const missing = threadFamilyRowsMissingFromShell({ + shellThreads: [row("parent"), row("live", "live from shell")], + families: [ + { snapshotSequence: 4, threads: [row("live", "stale family copy"), row("idle")] }, + { snapshotSequence: 4, threads: [row("idle"), row("other")] }, + ], + removedAt: new Map(), + }); + expect(missing.map((thread) => thread.id)).toEqual(["idle", "other"]); + }); + + it("drops a family copy read before the shell removed that thread", () => { + const missing = threadFamilyRowsMissingFromShell({ + shellThreads: [], + families: [ + { + snapshotSequence: 5, + threads: [row("archived-after-read"), row("removed-before-read")], + }, + ], + removedAt: new Map([ + [ThreadId.make("archived-after-read"), 7], + [ThreadId.make("removed-before-read"), 3], + ]), + }); + expect(missing.map((thread) => thread.id)).toEqual(["removed-before-read"]); + }); +}); + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-family"), + label: "Family environment", + httpBaseUrl: "https://family.example.test", + wsBaseUrl: "wss://family.example.test", +}); + +describe("createThreadFamilyAtoms", () => { + it.effect("reads a family per shell generation and retries a failed read", () => + Effect.scoped( + Effect.gen(function* () { + const replies = + yield* Queue.unbounded< + Effect.Effect + >(); + const client = { + [ORCHESTRATION_V2_WS_METHODS.getThreadFamily]: () => Effect.flatten(Queue.take(replies)), + } as unknown as WsRpcProtocolClient; + const session = { + client, + initialConfig: Effect.succeed({ shellThreadFamilies: true } as never), + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + } as unknown as RpcSession.RpcSession; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make({ + ...AVAILABLE_CONNECTION_STATE, + desired: true, + network: "online", + phase: "connected", + attempt: 1, + generation: 1, + }), + session: yield* SubscriptionRef.make(Option.some(session)), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const run: EnvironmentRegistry.EnvironmentRegistry["Service"]["run"] = ( + _environmentId, + effect, + ) => Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const followStream: EnvironmentRegistry.EnvironmentRegistry["Service"]["followStream"] = ( + _environmentId, + stream, + ) => Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const runtime = Atom.runtime( + Layer.succeed( + EnvironmentRegistry.EnvironmentRegistry, + EnvironmentRegistry.EnvironmentRegistry.of({ + run, + followStream, + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]), + ), + ); + const parent = row("parent"); + const child = row("idle-child"); + const shellSnapshot = { + schemaVersion: 1, + snapshotSequence: 3, + projects: [], + threads: [parent], + archivedThreads: [], + } as OrchestrationV2ShellSnapshot; + const shellAt = (generation: number): EnvironmentShellState => ({ + ...EMPTY_SHELL_STATE, + status: "live", + snapshot: Option.some(shellSnapshot), + generation, + }); + const shellState = Atom.make>( + AsyncResult.success(shellAt(0)), + ); + const families = createThreadFamilyAtoms(runtime, () => shellState); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const family = families.familyAtom(TARGET.environmentId, parent.id); + const mergedIds = () => + registry.get(families.snapshotAtom(TARGET.environmentId))?.threads.map(({ id }) => id); + // Waits for the read to finish either way; the assertions read its outcome. + const settle = AtomRegistry.getResult(registry, family, { suspendOnWaiting: true }).pipe( + Effect.ignore, + Effect.tap(() => Effect.yieldNow), + ); + registry.mount(families.snapshotAtom(TARGET.environmentId)); + families.mount(registry, TARGET.environmentId, parent.id); + + // A cached shell from before this session's first snapshot reads nothing. + expect(threadFamilyRead(registry.get(family))).toBe("pending"); + expect(yield* Queue.size(replies)).toBe(0); + + registry.set(shellState, AsyncResult.success(shellAt(1))); + yield* Queue.offer(replies, Effect.succeed({ snapshotSequence: 3, threads: [child] })); + yield* settle; + expect(threadFamilyRead(registry.get(family))).toBe("settled"); + expect(mergedIds()).toEqual(["parent", "idle-child"]); + + // A new authoritative snapshot never reuses the old read, even while the next one waits + // or fails. + registry.set(shellState, AsyncResult.success(shellAt(2))); + expect(threadFamilyRead(registry.get(family))).toBe("pending"); + expect(mergedIds()).toEqual(["parent"]); + yield* Queue.offer( + replies, + Effect.fail(new OrchestrationV2GetShellSnapshotError({ message: "read failed" })), + ); + yield* settle; + expect(threadFamilyRead(registry.get(family))).toBe("failed"); + expect(mergedIds()).toEqual(["parent"]); + + families.refresh(registry, TARGET.environmentId, parent.id); + expect(threadFamilyRead(registry.get(family))).toBe("pending"); + yield* Queue.offer(replies, Effect.succeed({ snapshotSequence: 5, threads: [child] })); + yield* settle; + expect(threadFamilyRead(registry.get(family))).toBe("settled"); + expect(mergedIds()).toEqual(["parent", "idle-child"]); + }), + ), + ); +}); diff --git a/packages/client-runtime/src/state/threadFamilies.ts b/packages/client-runtime/src/state/threadFamilies.ts new file mode 100644 index 0000000000000..84b511f16a21c --- /dev/null +++ b/packages/client-runtime/src/state/threadFamilies.ts @@ -0,0 +1,226 @@ +import { + ORCHESTRATION_V2_WS_METHODS, + type EnvironmentId, + type OrchestrationV2ShellSnapshot, + type OrchestrationV2ThreadFamily, + type OrchestrationV2ThreadShell, + type ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; + +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import type * as EnvironmentRegistry from "../connection/registry.ts"; +import { EnvironmentRpcUnavailableError, request } from "../rpc/client.ts"; +import { arrayElementsEqual } from "./entities.ts"; +import { createEnvironmentQueryAtomFamily, selectEnvironmentQueryData } from "./runtime.ts"; +import type { EnvironmentShellState } from "./shell.ts"; + +const EMPTY_FAMILY: OrchestrationV2ThreadFamily = { snapshotSequence: 0, threads: [] }; +const EMPTY_MOUNTS: ReadonlyMap = new Map(); +// Generation 0 is the cached shell from before this session's first authoritative snapshot. A read +// for it would be thrown away once that snapshot lands, so it waits instead. +const AWAITING_AUTHORITATIVE_SHELL = AsyncResult.initial(true); + +/** Whether a family read can decide anything yet. Only a settled read can prove a thread is gone. */ +export type ThreadFamilyRead = "pending" | "failed" | "settled"; + +export function threadFamilyRead( + result: AsyncResult.AsyncResult, +): ThreadFamilyRead { + // A failure whose retry is in flight is pending, so callers do not restart that retry. + if (result.waiting) return "pending"; + if (AsyncResult.isFailure(result)) return "failed"; + return AsyncResult.isSuccess(result) ? "settled" : "pending"; +} + +/** + * Family rows the shell does not have. A shell row is at least as new as any family copy, since + * shell deltas carry every change; a family copy read before the shell removed that thread is + * stale. + */ +export function threadFamilyRowsMissingFromShell(input: { + readonly shellThreads: ReadonlyArray; + readonly families: ReadonlyArray; + readonly removedAt: ReadonlyMap; +}): ReadonlyArray { + const present = new Set(input.shellThreads.map((thread) => thread.id)); + const missing: OrchestrationV2ThreadShell[] = []; + for (const family of input.families) { + for (const thread of family.threads) { + if (present.has(thread.id)) continue; + if ((input.removedAt.get(thread.id) ?? -1) > family.snapshotSequence) continue; + present.add(thread.id); + missing.push(thread); + } + } + return missing; +} + +/** + * Shells that omit idle subagent children (`omitIdleSubagents`) leave those rows to thread + * families: the active subagent tree that shares a thread's `lineage.rootThreadId`. A view, or a + * queued message, mounts the family it needs; it is read once per shell generation. Family rows + * merge behind shell rows and are never persisted. Shell deltas still carry every change, so a + * changed child always arrives as a shell row, which wins. + */ +export function createThreadFamilyAtoms( + runtime: Atom.AtomRuntime, + shellStateAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom>, +) { + const shellStateValue = (state: AsyncResult.AsyncResult) => + Option.getOrNull(AsyncResult.value(state)); + const generationAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => shellStateValue(get(shellStateAtom(environmentId)))?.generation ?? 0).pipe( + Atom.withLabel(`environment-shell-generation:${environmentId}`), + ), + ); + + // The generation is part of the key, so a read from before the latest authoritative snapshot is + // never used again, even while a new read is pending or failed. + const familyQueryAtom = createEnvironmentQueryAtomFamily(runtime, { + label: "environment-data:orchestration:thread-family", + staleTimeMs: Number.POSITIVE_INFINITY, + idleTtlMs: 60_000, + execute: Effect.fn("ThreadFamilies.read")(function* (input: { + readonly threadId: ThreadId; + readonly generation: number; + }) { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const session = yield* SubscriptionRef.get(supervisor.session); + if (Option.isNone(session)) { + return yield* new EnvironmentRpcUnavailableError({ + environmentId: supervisor.target.environmentId, + message: "The environment has no session.", + }); + } + // A config failure fails the read: an empty family would read as "the thread is gone". + const config = yield* session.value.initialConfig; + // Servers without families send every child in the shell, so absent means gone. + if (config.shellThreadFamilies !== true) return EMPTY_FAMILY; + return yield* request(ORCHESTRATION_V2_WS_METHODS.getThreadFamily, { + threadId: input.threadId, + }); + }), + }); + const queryFor = (environmentId: EnvironmentId, threadId: ThreadId, generation: number) => + familyQueryAtom({ environmentId, input: { threadId, generation } }); + + const familyAtom = Atom.family((key: string) => { + const [environmentId, threadId] = JSON.parse(key) as [EnvironmentId, ThreadId]; + return Atom.make((get) => { + const generation = get(generationAtom(environmentId)); + return generation === 0 + ? AWAITING_AUTHORITATIVE_SHELL + : get(queryFor(environmentId, threadId, generation)); + }).pipe(Atom.withLabel(`environment-thread-family:${key}`)); + }); + const familyKey = (environmentId: EnvironmentId, threadId: ThreadId) => + JSON.stringify([environmentId, threadId]); + + /** Reads the current generation's family again, so a failed read does not last the session. */ + const refresh = ( + registry: AtomRegistry.AtomRegistry, + environmentId: EnvironmentId, + threadId: ThreadId, + ) => { + const generation = registry.get(generationAtom(environmentId)); + if (generation !== 0) registry.refresh(queryFor(environmentId, threadId, generation)); + }; + + const mountsAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make(EMPTY_MOUNTS).pipe( + Atom.keepAlive, + Atom.withLabel(`environment-thread-family-mounts:${environmentId}`), + ), + ); + + /** Keeps a thread's family in the merged rows until the returned release runs. */ + const mount = ( + registry: AtomRegistry.AtomRegistry, + environmentId: EnvironmentId, + threadId: ThreadId, + ): (() => void) => { + const atom = mountsAtom(environmentId); + const mounts = registry.get(atom); + registry.set(atom, new Map(mounts).set(threadId, (mounts.get(threadId) ?? 0) + 1)); + // Mounting again after a failure retries instead of keeping the failure until reconnect. + if ( + threadFamilyRead(registry.get(familyAtom(familyKey(environmentId, threadId)))) === "failed" + ) { + refresh(registry, environmentId, threadId); + } + let released = false; + return () => { + if (released) return; + released = true; + const next = new Map(registry.get(atom)); + const count = (next.get(threadId) ?? 0) - 1; + if (count > 0) next.set(threadId, count); + else next.delete(threadId); + registry.set(atom, next); + }; + }; + + const snapshotAtom = Atom.family((environmentId: EnvironmentId) => { + let previous: { + readonly snapshot: OrchestrationV2ShellSnapshot; + readonly removedAt: ReadonlyMap; + readonly families: ReadonlyArray; + readonly merged: OrchestrationV2ShellSnapshot; + } | null = null; + return Atom.make((get): OrchestrationV2ShellSnapshot | null => { + const state = shellStateValue(get(shellStateAtom(environmentId))); + const snapshot = state === null ? null : Option.getOrNull(state.snapshot); + const mounts = get(mountsAtom(environmentId)); + if (snapshot === null || state === null || mounts.size === 0) return snapshot; + const families = [...mounts.keys()].flatMap((threadId) => { + const family = selectEnvironmentQueryData( + get(familyAtom(familyKey(environmentId, threadId))), + ); + return family === null ? [] : [family]; + }); + // Most shell updates move only the cursor. With the same rows, removals and families, keep + // the merged rows so thread lists skip their rebuild. + const unchanged = + previous !== null && + previous.snapshot.threads === snapshot.threads && + previous.removedAt === state.removedAt && + arrayElementsEqual(previous.families, families); + if (unchanged && previous !== null && previous.snapshot === snapshot) return previous.merged; + let merged: OrchestrationV2ShellSnapshot; + if (unchanged && previous !== null) { + merged = + previous.merged === previous.snapshot + ? snapshot + : { ...snapshot, threads: previous.merged.threads }; + } else { + const extras = threadFamilyRowsMissingFromShell({ + shellThreads: snapshot.threads, + removedAt: state.removedAt, + families, + }); + merged = + extras.length === 0 + ? snapshot + : { ...snapshot, threads: [...snapshot.threads, ...extras] }; + } + previous = { snapshot, removedAt: state.removedAt, families, merged }; + return merged; + }).pipe(Atom.withLabel(`environment-snapshot-with-families:${environmentId}`)); + }); + + return { + /** The shell snapshot plus the rows of every mounted family. */ + snapshotAtom, + /** The current generation's family read, for decisions that need a settled answer. */ + familyAtom: (environmentId: EnvironmentId, threadId: ThreadId) => + familyAtom(familyKey(environmentId, threadId)), + mount, + refresh, + }; +} diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 31985507aac1a..e060433d41aae 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -1047,5 +1047,6 @@ export * from "./composerPathSearch.ts"; export * from "./threadCommands.ts"; export * from "./threadFeedback.ts"; export * from "./threadDetail.ts"; +export * from "./threadFamilies.ts"; export * from "./threadShell.ts"; export * from "./threadState.ts"; diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index e7c057ea0cfa8..9e2ce04272a8d 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -667,6 +667,10 @@ const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({ threadId: ThreadId, }); +const EnvironmentOrchestrationShellSnapshotQuery = Schema.Struct({ + omitIdleSubagents: Schema.optionalKey(Schema.String), +}); + const EnvironmentOrchestrationThreadBoundedSnapshotQuery = Schema.Struct({ compactTurnItems: Schema.optionalKey(Schema.String), }); @@ -688,6 +692,7 @@ class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration") .add( HttpApiEndpoint.get("shellSnapshot", "/api/orchestration/shell", { headers: OrchestrationProtocolHeaders, + query: EnvironmentOrchestrationShellSnapshotQuery, success: OrchestrationV2ShellSnapshot, error: EnvironmentOrchestrationSnapshotErrors, }).middleware(EnvironmentAuthenticatedAuth), diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 61b21a97ddc3e..8897ead60f92b 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1962,6 +1962,33 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ }); export type OrchestrationV2ThreadShell = typeof OrchestrationV2ThreadShell.Type; +const LIVE_SHELL_STATUSES: ReadonlySet = new Set([ + "queued", + "preparing", + "starting", + "running", + "waiting", +]); + +/** + * A subagent child that can still need the user or hold its parent. Shells that omit idle children + * keep these, so every reader of live child rows (pending requests, working parents, background + * work) still finds them. + */ +export function isLiveSubagentShell( + thread: Pick< + OrchestrationV2ThreadShell, + "activityRunStatus" | "status" | "pendingRuntimeRequest" | "pendingBackgroundTasks" + >, +): boolean { + return ( + (thread.activityRunStatus ?? null) !== null || + LIVE_SHELL_STATUSES.has(thread.status) || + thread.pendingRuntimeRequest !== null || + (thread.pendingBackgroundTasks?.length ?? 0) > 0 + ); +} + export const OrchestrationV2ThreadShellSnapshot = Schema.Struct({ schemaVersion: PositiveInt, snapshotSequence: NonNegativeInt, @@ -3190,6 +3217,8 @@ export const ORCHESTRATION_V2_WS_METHODS = { searchThread: "orchestration.searchThread", searchThreadStream: "orchestration.searchThreadStream", getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot", + getThreadFamily: "orchestration.getThreadFamily", + getWorktreeThreadIds: "orchestration.getWorktreeThreadIds", getThreadProjection: "orchestration.getThreadProjection", getWorkflowScript: "orchestration.getWorkflowScript", stopWorkflow: "orchestration.stopWorkflow", @@ -3307,9 +3336,42 @@ export type OrchestrationV2GetThreadProjectionInput = export const OrchestrationV2SubscribeShellInput = Schema.Struct({ afterSequence: Schema.optionalKey(NonNegativeInt), requestCompletionMarker: Schema.optionalKey(Schema.Boolean), + /** + * Snapshots leave out idle subagent children; read them with `getThreadFamily`. Send only to + * servers that advertise `shellThreadFamilies`. + */ + omitIdleSubagents: Schema.optionalKey(Schema.Boolean), }); export type OrchestrationV2SubscribeShellInput = typeof OrchestrationV2SubscribeShellInput.Type; +export const OrchestrationV2GetThreadFamilyInput = Schema.Struct({ + threadId: ThreadId, +}); +export type OrchestrationV2GetThreadFamilyInput = typeof OrchestrationV2GetThreadFamilyInput.Type; + +/** + * The active subagent threads that share the requested thread's `lineage.rootThreadId`: its whole + * subagent tree, in any run state. An unknown thread has an empty family; a deleted one still + * names its root. + */ +export const OrchestrationV2ThreadFamily = Schema.Struct({ + snapshotSequence: NonNegativeInt, + threads: Schema.Array(OrchestrationV2ThreadShell), +}); +export type OrchestrationV2ThreadFamily = typeof OrchestrationV2ThreadFamily.Type; + +export const OrchestrationV2GetWorktreeThreadIdsInput = Schema.Struct({ + worktreePath: TrimmedNonEmptyString, +}); +export type OrchestrationV2GetWorktreeThreadIdsInput = + typeof OrchestrationV2GetWorktreeThreadIdsInput.Type; + +/** Active threads on a worktree path, including subagent children a shell may leave out. */ +export const OrchestrationV2WorktreeThreadIds = Schema.Struct({ + threadIds: Schema.Array(ThreadId), +}); +export type OrchestrationV2WorktreeThreadIds = typeof OrchestrationV2WorktreeThreadIds.Type; + export const OrchestrationV2SubscribeThreadInput = Schema.Struct({ threadId: ThreadId, afterSequence: Schema.optionalKey(NonNegativeInt), @@ -3679,6 +3741,14 @@ export const OrchestrationV2RpcSchemas = { input: Schema.Struct({}), output: OrchestrationV2ArchivedShellSnapshot, }, + getThreadFamily: { + input: OrchestrationV2GetThreadFamilyInput, + output: OrchestrationV2ThreadFamily, + }, + getWorktreeThreadIds: { + input: OrchestrationV2GetWorktreeThreadIdsInput, + output: OrchestrationV2WorktreeThreadIds, + }, getThreadProjection: { input: OrchestrationV2GetThreadProjectionInput, output: OrchestrationV2ThreadProjection, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 9723ea5a87236..9257872ffaba4 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1681,6 +1681,21 @@ const WsOrchestrationV2GetArchivedShellSnapshotRpc = Rpc.make( }, ); +const WsOrchestrationV2GetThreadFamilyRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.getThreadFamily, { + payload: OrchestrationV2RpcSchemas.getThreadFamily.input, + success: OrchestrationV2RpcSchemas.getThreadFamily.output, + error: Schema.Union([OrchestrationV2GetShellSnapshotError, EnvironmentAuthorizationError]), +}); + +const WsOrchestrationV2GetWorktreeThreadIdsRpc = Rpc.make( + ORCHESTRATION_V2_WS_METHODS.getWorktreeThreadIds, + { + payload: OrchestrationV2RpcSchemas.getWorktreeThreadIds.input, + success: OrchestrationV2RpcSchemas.getWorktreeThreadIds.output, + error: Schema.Union([OrchestrationV2GetShellSnapshotError, EnvironmentAuthorizationError]), + }, +); + const WsOrchestrationV2GetThreadProjectionRpc = Rpc.make( ORCHESTRATION_V2_WS_METHODS.getThreadProjection, { @@ -2082,6 +2097,8 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationV2SearchThreadRpc, WsOrchestrationV2SearchThreadStreamRpc, WsOrchestrationV2GetArchivedShellSnapshotRpc, + WsOrchestrationV2GetThreadFamilyRpc, + WsOrchestrationV2GetWorktreeThreadIdsRpc, WsOrchestrationV2GetThreadProjectionRpc, WsOrchestrationV2LaunchThreadRpc, WsOrchestrationV2SubscribeArchivedShellRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index af8e6df5f7691..3faa049acc119 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -626,6 +626,11 @@ export const ServerConfig = Schema.Struct({ observability: ServerObservability, settings: ServerSettings, shellResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), + /** + * The shell can leave idle subagent children out of snapshots (`omitIdleSubagents`), and + * `getThreadFamily` and `getWorktreeThreadIds` serve the rows it leaves out. + */ + shellThreadFamilies: Schema.optionalKey(Schema.Boolean), shellRevealInFileManager: Schema.optionalKey(Schema.Boolean), shellRevealInFileManagerKind: Schema.optionalKey(FileManagerRevealKind), threadResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), From fb76594d7b9f38d27d8d093733a791d0005c921c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 24 Oct 2026 18:48:21 -0700 Subject: [PATCH 005/592] feat: share a thread with a guest who watches live and sends messages you approve (#24104) Co-authored-by: Claude Opus 5.5 --- apps/mobile/generated-uniwind-themes.css | 24 + .../scripts/generate-uniwind-themes.mts | 5 + .../features/threads/ThreadDetailScreen.tsx | 5 + .../src/features/threads/ThreadFeed.tsx | 111 ++- .../features/threads/ThreadGuestShareBar.tsx | 189 ++++ .../features/threads/ThreadQueueControl.tsx | 14 +- .../features/threads/guestMessageStyle.tsx | 30 + .../features/threads/pending-thread-feed.ts | 41 +- .../features/threads/thread-list-v2-items.tsx | 6 + .../src/features/threads/threadListV2.test.ts | 12 + .../src/features/threads/threadListV2.ts | 6 + .../threadQueueControlPresentation.test.ts | 16 + .../threads/threadQueueControlPresentation.ts | 4 +- .../src/guest/GuestShareService.test.ts | 810 ++++++++++++++++++ apps/server/src/guest/GuestShareService.ts | 638 ++++++++++++++ apps/server/src/guest/guestAttachments.ts | 103 +++ apps/server/src/guest/guestView.test.ts | 35 + apps/server/src/guest/guestView.ts | 38 + apps/server/src/guest/http.test.ts | 28 + apps/server/src/guest/http.ts | 346 ++++++++ .../src/observability/RpcInstrumentation.ts | 1 + .../src/orchestration-v2/GuestShare.test.ts | 343 ++++++++ .../src/orchestration-v2/GuestShareDecider.ts | 255 ++++++ .../src/orchestration-v2/Orchestrator.ts | 181 +++- .../orchestration-v2/ProjectionMaintenance.ts | 1 + .../src/orchestration-v2/ProjectionStore.ts | 56 +- .../ProviderTurnControlService.test.ts | 1 + .../src/orchestration-v2/ThreadForkService.ts | 2 + .../ThreadMessageIntake.test.ts | 85 +- .../orchestration-v2/ThreadMessageIntake.ts | 32 +- .../ThreadSettlementService.test.ts | 15 + .../ThreadSettlementService.ts | 11 +- .../src/orchestration-v2/runtimeLayer.ts | 4 + .../testkit/OrchestratorScenario.ts | 2 + apps/server/src/relay/AgentAwarenessRelay.ts | 1 + apps/server/src/server.ts | 2 + apps/server/src/ws.test.ts | 2 + apps/server/src/ws.ts | 6 + apps/web/src/components/ChatView.tsx | 91 +- apps/web/src/components/CommandPalette.tsx | 20 + apps/web/src/components/LegacySidebar.tsx | 12 +- apps/web/src/components/Sidebar.logic.test.ts | 13 + apps/web/src/components/Sidebar.logic.ts | 32 +- apps/web/src/components/Sidebar.tsx | 11 +- .../ThreadNotificationCoordinator.tsx | 10 +- apps/web/src/components/chat/ChatHeader.tsx | 7 + .../components/chat/CommandOutputPanel.tsx | 16 +- .../chat/GuestMessageAttachments.tsx | 102 +++ .../components/chat/GuestShareControls.tsx | 326 +++++++ .../src/components/chat/MessagesTimeline.tsx | 10 +- .../src/components/chat/QueuedRunsControl.tsx | 23 +- .../components/chat/TimelineAssistantRows.tsx | 26 +- .../chat/TimelineUserMessageRow.tsx | 18 +- .../components/chat/TimelineV2EventRow.tsx | 2 + .../components/chat/TimelineWorkEntryRow.tsx | 2 + .../src/components/chat/V2ItemInspector.tsx | 34 +- .../src/components/chat/guestMessageStyle.tsx | 14 + .../components/chat/guestShareAvailability.ts | 18 + .../components/chat/guestShareDialogState.ts | 17 + .../src/components/chat/timelineRowContext.ts | 36 +- .../src/components/guest/GuestHtmlRender.tsx | 93 ++ .../guest/GuestPage.browser.test.tsx | 400 +++++++++ apps/web/src/components/guest/GuestPage.tsx | 763 +++++++++++++++++ .../guest/guestStream.logic.test.ts | 222 +++++ .../src/components/guest/guestStream.logic.ts | 232 +++++ .../components/guest/guestTurnItemSource.ts | 102 +++ .../components/settings/BetaSettingsPanel.tsx | 106 +++ .../components/settings/SettingsPanels.tsx | 27 - .../settings/SettingsSidebarNav.tsx | 2 + .../src/components/settings/settingsSearch.ts | 16 +- .../components/sidebar/SidebarThreadRow.tsx | 45 +- .../src/components/threadActionMenu.logic.ts | 7 + .../src/connection/runtime.browser.test.ts | 29 + apps/web/src/connection/runtime.ts | 18 +- apps/web/src/contextMenuFallback.ts | 6 + apps/web/src/guestPage.ts | 6 + .../web/src/hooks/useThreadActionMenu.test.ts | 1 + apps/web/src/hooks/useThreadActionMenu.ts | 8 +- apps/web/src/index.css | 8 + apps/web/src/routeTree.gen.ts | 42 + apps/web/src/routes/__root.tsx | 16 + apps/web/src/routes/guest.tsx | 7 + apps/web/src/routes/pair.tsx | 6 +- apps/web/src/routes/settings.beta.tsx | 5 + apps/web/src/state/queries.ts | 22 +- docs/README.md | 1 + docs/internals/environment-auth.md | 12 + docs/user/guest-links.md | 48 ++ docs/user/thread-sidebar.md | 2 +- .../client-runtime/src/operations/commands.ts | 45 +- packages/client-runtime/src/state/models.ts | 3 + .../src/state/orchestrationV2Projection.ts | 1 + packages/client-runtime/src/state/server.ts | 4 + .../src/state/threadCommands.ts | 16 + .../client-runtime/src/state/threadSettled.ts | 5 + .../src/state/threadWorkflows.ts | 3 +- .../client-runtime/src/userMessage.test.ts | 19 + packages/client-runtime/src/userMessage.ts | 16 +- packages/contracts/src/baseSchemas.ts | 4 + .../contracts/src/clientRpcPermissions.ts | 3 + packages/contracts/src/composerContext.ts | 17 + packages/contracts/src/guestShare.test.ts | 22 + packages/contracts/src/guestShare.ts | 138 +++ packages/contracts/src/guestStream.ts | 42 + packages/contracts/src/index.ts | 2 + packages/contracts/src/orchestrationV2.ts | 46 + packages/contracts/src/rpc.ts | 13 + packages/contracts/src/settings.ts | 3 + .../src/server/subagentProjection.ts | 2 + 109 files changed, 6770 insertions(+), 187 deletions(-) create mode 100644 apps/mobile/src/features/threads/ThreadGuestShareBar.tsx create mode 100644 apps/mobile/src/features/threads/guestMessageStyle.tsx create mode 100644 apps/server/src/guest/GuestShareService.test.ts create mode 100644 apps/server/src/guest/GuestShareService.ts create mode 100644 apps/server/src/guest/guestAttachments.ts create mode 100644 apps/server/src/guest/guestView.test.ts create mode 100644 apps/server/src/guest/guestView.ts create mode 100644 apps/server/src/guest/http.test.ts create mode 100644 apps/server/src/guest/http.ts create mode 100644 apps/server/src/orchestration-v2/GuestShare.test.ts create mode 100644 apps/server/src/orchestration-v2/GuestShareDecider.ts create mode 100644 apps/web/src/components/chat/GuestMessageAttachments.tsx create mode 100644 apps/web/src/components/chat/GuestShareControls.tsx create mode 100644 apps/web/src/components/chat/guestMessageStyle.tsx create mode 100644 apps/web/src/components/chat/guestShareAvailability.ts create mode 100644 apps/web/src/components/chat/guestShareDialogState.ts create mode 100644 apps/web/src/components/guest/GuestHtmlRender.tsx create mode 100644 apps/web/src/components/guest/GuestPage.browser.test.tsx create mode 100644 apps/web/src/components/guest/GuestPage.tsx create mode 100644 apps/web/src/components/guest/guestStream.logic.test.ts create mode 100644 apps/web/src/components/guest/guestStream.logic.ts create mode 100644 apps/web/src/components/guest/guestTurnItemSource.ts create mode 100644 apps/web/src/components/settings/BetaSettingsPanel.tsx create mode 100644 apps/web/src/connection/runtime.browser.test.ts create mode 100644 apps/web/src/guestPage.ts create mode 100644 apps/web/src/routes/guest.tsx create mode 100644 apps/web/src/routes/settings.beta.tsx create mode 100644 docs/user/guest-links.md create mode 100644 packages/contracts/src/guestShare.test.ts create mode 100644 packages/contracts/src/guestShare.ts create mode 100644 packages/contracts/src/guestStream.ts diff --git a/apps/mobile/generated-uniwind-themes.css b/apps/mobile/generated-uniwind-themes.css index caf91e40348b0..039c7d694ed5b 100644 --- a/apps/mobile/generated-uniwind-themes.css +++ b/apps/mobile/generated-uniwind-themes.css @@ -110,6 +110,8 @@ --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%); --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225); --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612); + --color-adaptive-fuchsia-500-a50-300-a40: oklch(66.7% 0.295 322.15 / 50%); + --color-adaptive-fuchsia-600-300: oklch(59.1% 0.293 322.896); --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); @@ -271,6 +273,8 @@ --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%); --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223); --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978); + --color-adaptive-fuchsia-500-a50-300-a40: oklch(83.3% 0.145 321.434 / 40%); + --color-adaptive-fuchsia-600-300: oklch(83.3% 0.145 321.434); --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); @@ -432,6 +436,8 @@ --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%); --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225); --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612); + --color-adaptive-fuchsia-500-a50-300-a40: oklch(66.7% 0.295 322.15 / 50%); + --color-adaptive-fuchsia-600-300: oklch(59.1% 0.293 322.896); --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); @@ -593,6 +599,8 @@ --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%); --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223); --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978); + --color-adaptive-fuchsia-500-a50-300-a40: oklch(83.3% 0.145 321.434 / 40%); + --color-adaptive-fuchsia-600-300: oklch(83.3% 0.145 321.434); --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); @@ -754,6 +762,8 @@ --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%); --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225); --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612); + --color-adaptive-fuchsia-500-a50-300-a40: oklch(66.7% 0.295 322.15 / 50%); + --color-adaptive-fuchsia-600-300: oklch(59.1% 0.293 322.896); --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); @@ -915,6 +925,8 @@ --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%); --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223); --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978); + --color-adaptive-fuchsia-500-a50-300-a40: oklch(83.3% 0.145 321.434 / 40%); + --color-adaptive-fuchsia-600-300: oklch(83.3% 0.145 321.434); --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); @@ -1076,6 +1088,8 @@ --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%); --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225); --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612); + --color-adaptive-fuchsia-500-a50-300-a40: oklch(66.7% 0.295 322.15 / 50%); + --color-adaptive-fuchsia-600-300: oklch(59.1% 0.293 322.896); --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); @@ -1237,6 +1251,8 @@ --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%); --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223); --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978); + --color-adaptive-fuchsia-500-a50-300-a40: oklch(83.3% 0.145 321.434 / 40%); + --color-adaptive-fuchsia-600-300: oklch(83.3% 0.145 321.434); --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); @@ -1398,6 +1414,8 @@ --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%); --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225); --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612); + --color-adaptive-fuchsia-500-a50-300-a40: oklch(66.7% 0.295 322.15 / 50%); + --color-adaptive-fuchsia-600-300: oklch(59.1% 0.293 322.896); --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); @@ -1559,6 +1577,8 @@ --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%); --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223); --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978); + --color-adaptive-fuchsia-500-a50-300-a40: oklch(83.3% 0.145 321.434 / 40%); + --color-adaptive-fuchsia-600-300: oklch(83.3% 0.145 321.434); --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); @@ -1720,6 +1740,8 @@ --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%); --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225); --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612); + --color-adaptive-fuchsia-500-a50-300-a40: oklch(66.7% 0.295 322.15 / 50%); + --color-adaptive-fuchsia-600-300: oklch(59.1% 0.293 322.896); --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); @@ -1881,6 +1903,8 @@ --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%); --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223); --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978); + --color-adaptive-fuchsia-500-a50-300-a40: oklch(83.3% 0.145 321.434 / 40%); + --color-adaptive-fuchsia-600-300: oklch(83.3% 0.145 321.434); --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); diff --git a/apps/mobile/scripts/generate-uniwind-themes.mts b/apps/mobile/scripts/generate-uniwind-themes.mts index d930766e5fd04..0aa52aab7ce30 100644 --- a/apps/mobile/scripts/generate-uniwind-themes.mts +++ b/apps/mobile/scripts/generate-uniwind-themes.mts @@ -86,6 +86,11 @@ const ADAPTIVE_COLORS: Readonly + {props.feedbackSubmissions.map((submission) => ( ; + readonly environmentId: EnvironmentId; + readonly onPressPreview: (source: FilePreviewSource) => void; + readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; +}) { + return props.attachments.map((attachment) => + isImageAttachment(attachment) ? ( + + ) : isFileAttachment(attachment) ? ( + + ) : ( + + ), + ); +} + function MessageAttachmentUnknown(props: { readonly name: string }) { return ( @@ -1203,7 +1239,7 @@ function compactionLabel(label: string, title: string | undefined) { } function renderFeedEntry( - info: { item: PendingThreadFeedEntry; index: number }, + info: { item: ThreadFeedRow; index: number }, props: Pick< ThreadFeedProps, | "environmentId" @@ -1251,6 +1287,25 @@ function renderFeedEntry( const entry = info.item; const { markdownStyles, iconSubtleColor, userBubbleColor } = props; + if (entry.type === "guest-pending") { + return ( + ( + + )} + /> + ); + } + if (entry.type === "run-fold") { return ( @@ -1504,6 +1559,9 @@ function renderFeedEntry( Sent by T3 Code ) : null} + {presentation.guestName !== undefined ? ( + + ) : null} + {presentation.attribution === "guest" ? : null} {foldRowId !== null ? ( writeComposerContextClipboard(message.text, { version: 1, @@ -1722,29 +1781,12 @@ function renderFeedEntry( /> ) : null} - {attachments.map((attachment) => { - return isImageAttachment(attachment) ? ( - - ) : isFileAttachment(attachment) ? ( - - ) : ( - - ); - })} + {showAssistantMeta && message.checkpointSummary ? ( scopeThreadRef(props.environmentId, props.threadId), + [props.environmentId, props.threadId], + ); + const guestShare = useThreadGuestShare(guestThreadRef); const deviceType = useDeviceType(); const duoColumns = useNativeWorkspaceColumnsSupported() && isDuoDevice(deviceType); const openPullRequest = useOpenPullRequest(); @@ -2662,7 +2709,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { reportHeaderMaterialVisibility(false); }, [feedThreadKey, reportHeaderMaterialVisibility]); - const presentedFeed = useThreadFeedPresentation(props, expandedTurnIds, expandedWorkGroups); + const presentedFeed = appendGuestPendingMessages( + useThreadFeedPresentation(props, expandedTurnIds, expandedWorkGroups), + guestShare, + ); const setupAnchorIndex = presentedFeed.findIndex( (entry) => entry.type === "message" && entry.message.role === "user", ); @@ -2880,7 +2930,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } }; - const shouldRestoreVisibleContentPosition = (entry: ThreadFeedEntry) => { + const shouldRestoreVisibleContentPosition = (entry: ThreadFeedRow) => { const disclosureAnchorKey = disclosureAnchorKeyRef.current; return disclosureAnchorKey === null || entry.id === disclosureAnchorKey; }; @@ -2964,7 +3014,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // Without this, every row above the viewport is assumed to be // estimatedItemSize tall, and scrolling up through unmeasured content // corrects each row's height as it mounts โ€” the feed visibly jumps. - const getFixedItemSize = (entry: ThreadFeedEntry) => { + const getFixedItemSize = (entry: ThreadFeedRow) => { + if (entry.type === "guest-pending") return undefined; if (entry.type === "html-render") { return htmlRenderRowHeight(htmlRenderFrameHeight(entry.render, contentWidth)); } @@ -3010,7 +3061,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (hasHtmlRenders) listRef.current?.clearCaches({ mode: "sizes" }); }, [beginReflow, contentWidth, hasHtmlRenders, listRef]); - const renderItem = (info: { item: PendingThreadFeedEntry; index: number }) => ( + const renderItem = (info: { item: ThreadFeedRow; index: number }) => ( (null); + +function guestShareAtom(ref: ScopedThreadRef) { + let previous: ThreadGuestShare | null = null; + return Atom.make((get) => { + const next = + get(environmentThreadDetails.threadAtom(ref))?.projection.thread.guestShare ?? null; + // The thread record is replaced on unrelated updates; only share changes matter here. + if (JSON.stringify(next) !== JSON.stringify(previous)) previous = next; + return previous; + }); +} + +export function useThreadGuestShare(threadRef: ScopedThreadRef | null): ThreadGuestShare | null { + const shareAtom = useMemo( + () => (threadRef === null ? NO_GUEST_SHARE_ATOM : guestShareAtom(threadRef)), + [threadRef], + ); + return useAtomValue(shareAtom); +} + +function alertOnFailure(failureTitle: string, result: AtomCommandResult) { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Dialog.alert(failureTitle, error instanceof Error ? error.message : "Try again."); + } +} + +/** + * Owner controls for a guest link, above the composer: who the link is for and a way to stop + * sharing. Links are created on web. Waiting messages show in the feed, see + * `ThreadGuestPendingMessage`. + */ +export function ThreadGuestShareBar(props: { + readonly threadRef: ScopedThreadRef | null; + readonly canOperateThread: boolean; +}) { + const { threadRef } = props; + const share = useThreadGuestShare(threadRef); + const closeGuestShare = useAtomCommand(threadEnvironment.closeGuestShare, "stop guest sharing"); + const [busy, setBusy] = useState(false); + // The server keeps an expired link so waiting messages stay resolvable; the label flips when + // it expires. + // Keyed by the expiry, so a replaced link (even one already expired) gets its own timer. + const [expiredAt, setExpiredAt] = useState(null); + const expiresAt = share?.expiresAt; + useEffect(() => { + if (expiresAt === undefined) return; + const timer = setTimeout( + () => setExpiredAt(expiresAt), + Math.max(0, Date.parse(expiresAt) - Date.now()), + ); + return () => clearTimeout(timer); + }, [expiresAt]); + if (threadRef === null || !share) return null; + + const disabled = busy || !props.canOperateThread; + const stopSharing = () => + Dialog.confirm({ + title: "Stop sharing?", + message: "The guest loses access and the link stops working.", + confirmText: "Stop sharing", + destructive: true, + onConfirm: () => { + if (disabled) return; + setBusy(true); + void closeGuestShare({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }).then((result) => { + setBusy(false); + alertOnFailure("Could not stop sharing", result); + }); + }, + }); + + return ( + + + + {expiredAt === share.expiresAt + ? "Guest link expired" + : share.guestName === null + ? "Guest link active" + : `${share.guestName} is a guest`} + + + ) : ( +
+ {image.name} +
+ )} + + ); + })} + + ) : null} + {files.map((file) => { + const identity = ( + <> + + {file.name} + + {formatAttachmentSize(file.sizeBytes)} + + + ); + return props.fileHref ? ( +
+ {identity} + + ) : props.onOpenFile ? ( + + ) : ( +
+ {identity} +
+ ); + })} + + ); +} diff --git a/apps/web/src/components/chat/GuestShareControls.tsx b/apps/web/src/components/chat/GuestShareControls.tsx new file mode 100644 index 0000000000000..797cb0e5c52b9 --- /dev/null +++ b/apps/web/src/components/chat/GuestShareControls.tsx @@ -0,0 +1,326 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + AuthOrchestrationOperateScope, + type ChatAttachment, + type GuestMessageId, + type ScopedThreadRef, + type ThreadGuestShare, +} from "@t3tools/contracts"; +import { CheckIcon, CopyIcon, UserIcon } from "lucide-react"; +import { useState } from "react"; + +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { useNowMinute } from "~/hooks/useNowMinute"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { useQuietThreadProjection } from "~/state/entities"; +import { serverEnvironment } from "~/state/server"; +import { useEnvironmentScope } from "~/state/session"; +import { threadEnvironment } from "~/state/threads"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { cn } from "~/lib/utils"; +import { useOrchestrationCommand } from "~/state/use-orchestration-command"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { useAssetUrls } from "~/assets/assetUrls"; +import { type ChatFileAttachment, isImageAttachment } from "../../types"; +import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; +import { GuestMessageAttachments } from "./GuestMessageAttachments"; +import { GuestMessageName, guestBubbleClassName } from "./guestMessageStyle"; +import { guestShareDialogThreadAtom } from "./guestShareDialogState"; + +function useThreadGuestShare(threadRef: ScopedThreadRef | null): ThreadGuestShare | null { + return useQuietThreadProjection(threadRef)?.projection.thread.guestShare ?? null; +} + +export function GuestShareDialogHost() { + const threadRef = useAtomValue(guestShareDialogThreadAtom); + if (threadRef === null) return null; + return ( + appAtomRegistry.set(guestShareDialogThreadAtom, null)} + /> + ); +} + +type CreateState = + | { readonly status: "idle" } + | { readonly status: "creating" } + | { readonly status: "created"; readonly url: string } + | { readonly status: "failed"; readonly message: string }; + +function GuestShareDialog({ + threadRef, + onClose, +}: { + readonly threadRef: ScopedThreadRef; + readonly onClose: () => void; +}) { + const activeShare = useThreadGuestShare(threadRef); + const canCreate = useAtomValue( + serverEnvironment.createGuestShare.permissionAtom(threadRef.environmentId), + ); + const createGuestShare = useAtomCommand(serverEnvironment.createGuestShare, { + reportFailure: false, + }); + const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "guest link" }); + const [state, setState] = useState({ status: "idle" }); + + const create = async () => { + setState({ status: "creating" }); + const result = await createGuestShare({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }); + if (result._tag === "Success") { + setState({ status: "created", url: result.value.url }); + return; + } + if (isAtomCommandInterrupted(result)) { + setState({ status: "idle" }); + return; + } + const error = squashAtomCommandFailure(result); + setState({ + status: "failed", + message: error instanceof Error ? error.message : "Could not create a guest link.", + }); + }; + + return ( + { + if (!open && state.status !== "creating") onClose(); + }} + > + + + Share with guest + + The guest sees the thread as you do, including command output and the agent's + reasoning. Each message they send waits for you. + + + + {state.status === "created" ? ( +
+ event.currentTarget.select()} + /> + +
+ ) : ( +

+ {activeShare !== null + ? "A new link ends the current one." + : "The link works once and expires in 24 hours."} +

+ )} + {state.status === "failed" ? ( +

+ {state.message} +

+ ) : null} +
+ + {state.status === "created" ? ( + + ) : ( + <> + + + + )} + +
+
+ ); +} + +/** Header status for a shared thread, with the way to end the share. */ +export function GuestShareIndicator({ threadRef }: { readonly threadRef: ScopedThreadRef }) { + const share = useThreadGuestShare(threadRef); + const closeGuestShare = useOrchestrationCommand(threadEnvironment.closeGuestShare, { + label: "stop guest share", + }); + const [closing, setClosing] = useState(false); + if (share === null) return null; + return ( +
+ + + +
+ ); +} + +function GuestShareStatus({ share }: { readonly share: ThreadGuestShare }) { + const nowMinute = useNowMinute(); + // The share stays on the thread after it expires, until the owner clears it. + if (share.expiresAt.slice(0, 16) <= nowMinute) return Guest link expired; + return ( + {share.guestName !== null ? `${share.guestName} is a guest` : "Guest link active"} + ); +} + +/** + * A guest's messages wait at the end of the timeline until the owner sends or dismisses each one. + * Sent ones then show in the timeline as normal user messages with the guest's name. + */ +export function GuestPendingMessages({ + threadRef, + onImageExpand, + onFileOpen, +}: { + readonly threadRef: ScopedThreadRef; + readonly onImageExpand: (preview: ExpandedImagePreview) => void; + readonly onFileOpen: (attachment: ChatFileAttachment) => void; +}) { + const share = useThreadGuestShare(threadRef); + const canOperate = useEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope); + const resolveGuestMessage = useOrchestrationCommand(threadEnvironment.resolveGuestMessage, { + label: "resolve guest message", + }); + const [busyIds, setBusyIds] = useState>(new Set()); + const pending = share?.messages.filter((message) => message.status === "pending") ?? []; + // The files are the thread's attachments, which the owner's environment serves like any other. + const images = pending.flatMap((message) => message.attachments ?? []).filter(isImageAttachment); + const imageUrls = useAssetUrls( + threadRef.environmentId, + images.map((image) => ({ _tag: "attachment" as const, attachmentId: image.id })), + ); + const imageUrlById = new Map( + images.flatMap((image, index) => { + const url = imageUrls[index]; + return url ? [[image.id, url] as const] : []; + }), + ); + if (share === null || pending.length === 0) return null; + const guestName = share.guestName ?? "Guest"; + const openImage = (attachment: ChatAttachment) => { + const preview = buildExpandedImagePreview( + images.map((image) => ({ ...image, previewUrl: imageUrlById.get(image.id) ?? "" })), + attachment.id, + ); + if (preview) onImageExpand(preview); + }; + + const resolve = async (guestMessageId: GuestMessageId, decision: "send" | "dismiss") => { + setBusyIds((ids) => new Set(ids).add(guestMessageId)); + await resolveGuestMessage({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, guestMessageId, decision }, + }); + setBusyIds((ids) => { + const next = new Set(ids); + next.delete(guestMessageId); + return next; + }); + }; + + return ( +
    + {pending.map((message) => { + const busy = busyIds.has(message.id); + return ( +
  1. + +
    + imageUrlById.get(attachment.id)} + onOpenImage={openImage} + onOpenFile={onFileOpen} + /> + {message.text ? ( +

    + {message.text} +

    + ) : null} +
    + {canOperate ? ( +
    + + +
    + ) : null} +
  2. + ); + })} +
+ ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index ee61f27e20c91..6da2d5c6f037a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -212,15 +212,17 @@ interface MessagesTimelineProps { paintOnly?: boolean; /** Off where another region already speaks the thread's status, like a subagent's bar. */ announceTurnStatus?: boolean; - onOpenTurnDiff: (runId: RunId, filePath?: string) => void; + onOpenTurnDiff?: (runId: RunId, filePath?: string) => void; onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void; + renderHtmlRender?: TimelineRowSharedState["renderHtmlRender"]; + turnItemSource?: TimelineRowSharedState["turnItemSource"]; parentThreadLink?: { readonly threadId: ThreadId; readonly title: string; } | null; /** Set on a workflow coordinator's thread: its script message renders as this opening. */ workflowOpening?: WorkflowOpening | null; - onForkFromRun: (input: { + onForkFromRun?: (input: { readonly sourceThreadId: ThreadId; readonly runId: RunId; }) => Promise; @@ -328,6 +330,8 @@ const ConversationTimeline = memo(function ConversationTimeline({ announceTurnStatus = true, onOpenTurnDiff, onOpenThread, + renderHtmlRender, + turnItemSource, parentThreadLink = null, workflowOpening = null, onForkFromRun, @@ -931,6 +935,8 @@ const ConversationTimeline = memo(function ConversationTimeline({ openPullRequest, onOpenTurnDiff, onOpenThread, + renderHtmlRender, + turnItemSource, workflowOpening, onForkFromRun, onRollbackCheckpoint, diff --git a/apps/web/src/components/chat/QueuedRunsControl.tsx b/apps/web/src/components/chat/QueuedRunsControl.tsx index 15ea2318ba6cf..98c7de52f1bcc 100644 --- a/apps/web/src/components/chat/QueuedRunsControl.tsx +++ b/apps/web/src/components/chat/QueuedRunsControl.tsx @@ -119,12 +119,15 @@ export function QueuedRunsControl({ (message) => message.inputIntent === "queued_turn" && !acknowledgedMessageIds.has(message.id), ); const items = [ - ...queued.map(({ run, text, attachments }, serverIndex) => ({ + ...queued.map(({ run, text, attachments, context }, serverIndex) => ({ key: run.id, runId: run.id, messageId: run.userMessageId, serverIndex, text, + // A guest's message keeps the guest's words; the owner can cancel it, not rewrite it. + editable: context?.guest === undefined, + guestName: context?.guest?.name, attachments, thumbnails: attachments .filter((attachment) => attachment.type === "image") @@ -141,6 +144,8 @@ export function QueuedRunsControl({ messageId: null, serverIndex: null, text: message.text, + editable: true, + guestName: undefined, attachments: [] as ReadonlyArray, thumbnails: (message.attachments ?? []) .filter(isImageAttachment) @@ -202,7 +207,14 @@ export function QueuedRunsControl({ // moving the caret inside that draft. editLatest(repeat) { const latest = queued.at(-1); - if (!latest || props.editingRunId !== null || busyRunId !== null) return false; + if ( + !latest || + latest.context?.guest !== undefined || + props.editingRunId !== null || + busyRunId !== null + ) { + return false; + } if (!repeat) { setExpanded(true); props.onEditQueuedRun({ @@ -380,6 +392,9 @@ export function QueuedRunsControl({ ))} ) : null} + {item.guestName === undefined ? null : ( + {item.guestName} + )} }> {previewText} @@ -408,7 +423,9 @@ export function QueuedRunsControl({ size="icon-xs" variant="ghost-muted" aria-label="Edit queued message" - disabled={item.runId === null || busyRunId !== null} + disabled={ + item.runId === null || !item.editable || busyRunId !== null + } onClick={() => { if (item.runId !== null && item.messageId !== null) { props.onEditQueuedRun({ diff --git a/apps/web/src/components/chat/TimelineAssistantRows.tsx b/apps/web/src/components/chat/TimelineAssistantRows.tsx index 6f99213057f96..89b52ae5c5cba 100644 --- a/apps/web/src/components/chat/TimelineAssistantRows.tsx +++ b/apps/web/src/components/chat/TimelineAssistantRows.tsx @@ -53,12 +53,14 @@ export function AssistantTimelineRow({ row }: { row: Extract - + {ctx.onOpenTurnDiff ? ( + + ) : null} {row.showAssistantMeta ? ( { setBusy(true); - void ctx - .onForkFromRun({ sourceThreadId: projectedItem.sourceThreadId, runId }) - .finally(() => setBusy(false)); + void onForkFromRun({ sourceThreadId: projectedItem.sourceThreadId, runId }).finally( + () => setBusy(false), + ); }} aria-label="Fork from this response" /> @@ -244,6 +247,9 @@ export function HtmlRenderTimelineRow({ const { onAppFullscreenChange } = ctx; const onFullscreenChange = (fullscreen: boolean) => onAppFullscreenChange(row.id, fullscreen); + if (ctx.renderHtmlRender) { + return
{ctx.renderHtmlRender(row)}
; + } return (
image.name.startsWith("preview-annotation-")); const revertTurnCount = row.revertTurnCount; const chippedAttachmentIds = new Set( @@ -427,13 +430,20 @@ function UserTimelineRow({ row }: { row: Extract ) : null} + {/* The row's heading already names the guest for screen readers. */} + {userMessage.guestName !== undefined ? ( +
+ +
+ ) : null} {row.message.inputIntent === "steer" || row.message.inputIntent === "promoted_queued_to_steer" ? ( ) : null}
- You + + {userMessage.guestName === undefined ? "You" : `${userMessage.guestName} (guest)`} + {(regularImages.length > 0 || userVideos.length > 0) && (
{regularImages.map((image) => ( diff --git a/apps/web/src/components/chat/TimelineV2EventRow.tsx b/apps/web/src/components/chat/TimelineV2EventRow.tsx index a4d2adf902f3c..9a64ca0f03152 100644 --- a/apps/web/src/components/chat/TimelineV2EventRow.tsx +++ b/apps/web/src/components/chat/TimelineV2EventRow.tsx @@ -126,6 +126,7 @@ export function V2EventTimelineRow({ row }: { row: Extract
@@ -201,6 +202,7 @@ export function V2EventTimelineRow({ row }: { row: Extract
diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx index 457e960e9fb62..76daf03c10671 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx @@ -534,6 +534,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { onOpenTurnDiff={ctx.onOpenTurnDiff} onRollbackCheckpoint={ctx.onRollbackCheckpoint} onImageExpand={ctx.onImageExpand} + source={ctx.turnItemSource} /> ) : ( <> @@ -545,6 +546,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { projectedItem={workEntry.projectedItem} environmentId={ctx.activeThreadEnvironmentId} onImageExpand={onImageExpand} + source={ctx.turnItemSource} /> ) : null} diff --git a/apps/web/src/components/chat/V2ItemInspector.tsx b/apps/web/src/components/chat/V2ItemInspector.tsx index a93e8a5b611b5..b0727a1599f5b 100644 --- a/apps/web/src/components/chat/V2ItemInspector.tsx +++ b/apps/web/src/components/chat/V2ItemInspector.tsx @@ -31,6 +31,7 @@ import { CommandOutputPanel, useCommandOutputStreaming } from "./CommandOutputPa import { resolveExternalWebLinkHref } from "./externalLinkContextMenu"; import type { ExpandedImagePreview } from "./ExpandedImagePreview"; import { ShellCommandBlock } from "./ShellCommandBlock"; +import type { TurnItemSource } from "./timelineRowContext"; interface V2ItemInspectorProps { readonly projectedItem: OrchestrationV2ProjectedTurnItem; @@ -38,12 +39,13 @@ interface V2ItemInspectorProps { readonly cwd?: string | undefined; readonly workspaceRoot?: string | undefined; readonly onOpenThread: (threadId: ThreadId) => void; - readonly onOpenTurnDiff: (runId: RunId, filePath?: string) => void; + readonly onOpenTurnDiff?: ((runId: RunId, filePath?: string) => void) | undefined; readonly onRollbackCheckpoint?: (input: { readonly checkpointId: string; readonly scopeId: string; }) => void; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; + readonly source?: TurnItemSource | undefined; } function SyntaxTokens({ @@ -112,6 +114,7 @@ function StructuredValue({ function useFetchedTurnItem( projectedItem: OrchestrationV2ProjectedTurnItem, environmentId: EnvironmentId, + source: TurnItemSource | undefined, skip = false, ) { const wireItem = projectedItem.item; @@ -125,6 +128,7 @@ function useFetchedTurnItem( revision: turnItemDetailRevision(wireItem), } : null, + source?.detail, ); const fetchedItem = detail.data?.item; const item = fetchedItem?.type === wireItem.type ? fetchedItem : wireItem; @@ -188,8 +192,9 @@ export function FetchedToolOutput(props: { readonly projectedItem: OrchestrationV2ProjectedTurnItem; readonly environmentId: EnvironmentId; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; + readonly source?: TurnItemSource | undefined; }) { - const { output } = useFetchedTurnItem(props.projectedItem, props.environmentId); + const { output } = useFetchedTurnItem(props.projectedItem, props.environmentId, props.source); return (
@@ -236,10 +241,16 @@ function ToolCallBody( export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspectorProps) { // Command rows stream their output, including the final output, from the server. + const environmentStreamsOutput = useCommandOutputStreaming(props.environmentId); const streamsOutput = - useCommandOutputStreaming(props.environmentId) && + (props.source !== undefined || environmentStreamsOutput) && props.projectedItem.item.type === "command_execution"; - const fetched = useFetchedTurnItem(props.projectedItem, props.environmentId, streamsOutput); + const fetched = useFetchedTurnItem( + props.projectedItem, + props.environmentId, + props.source, + streamsOutput, + ); const item = fetched.item; const outputState = fetched.output; const support = useV2ItemSupport({ @@ -275,6 +286,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec environmentId={props.environmentId} threadId={props.projectedItem.sourceThreadId} itemId={props.projectedItem.sourceItemId} + source={props.source?.commandOutput} /> ) : undefined } @@ -293,11 +305,11 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec -{formatDiffCount(item.deletions ?? 0)} ) : null} - {item.runId !== null ? ( + {item.runId !== null && props.onOpenTurnDiff ? ( @@ -386,7 +398,15 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec {item.type === "approval_request" ? : null} {item.type === "user_input_request" ? ( - question.question).join("\n\n")} /> + + [question.question, ...question.options.map((option) => `โ€ข ${option.label}`)].join( + "\n", + ), + ) + .join("\n\n")} + /> ) : null} {item.type === "notification" ? : null} {item.type === "system_notice" ? : null} diff --git a/apps/web/src/components/chat/guestMessageStyle.tsx b/apps/web/src/components/chat/guestMessageStyle.tsx new file mode 100644 index 0000000000000..90e49994f8e88 --- /dev/null +++ b/apps/web/src/components/chat/guestMessageStyle.tsx @@ -0,0 +1,14 @@ +/** Bubble classes for a guest's message. A pending one has a dashed edge and no fill. */ +export function guestBubbleClassName(pending: boolean): string { + return pending ? "border border-dashed border-guest/50 bg-transparent" : "bg-guest-surface"; +} + +/** The guest's name above their message, where "Sent by T3 Code" sits for server messages. */ +export function GuestMessageName({ name }: { readonly name: string }) { + return ( +

+ {name} + (guest) +

+ ); +} diff --git a/apps/web/src/components/chat/guestShareAvailability.ts b/apps/web/src/components/chat/guestShareAvailability.ts new file mode 100644 index 0000000000000..618b8f042e460 --- /dev/null +++ b/apps/web/src/components/chat/guestShareAvailability.ts @@ -0,0 +1,18 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; + +import { environmentPresentations } from "~/state/presentation"; +import { serverEnvironment } from "~/state/server"; + +/** + * Whether this environment offers "Share with guest": the user may create links there, and its + * Guest links beta is on (Settings > Beta). The server refuses links while the beta is off. + */ +export const canShareWithGuestAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + (get) => + get(serverEnvironment.createGuestShare.permissionAtom(environmentId)) && + get(environmentPresentations.serverConfigsAtom).get(environmentId)?.settings + .guestSharingEnabled === true, + ).pipe(Atom.withLabel(`guest-share:available:${environmentId}`)), +); diff --git a/apps/web/src/components/chat/guestShareDialogState.ts b/apps/web/src/components/chat/guestShareDialogState.ts new file mode 100644 index 0000000000000..f8d0e5d095f79 --- /dev/null +++ b/apps/web/src/components/chat/guestShareDialogState.ts @@ -0,0 +1,17 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; + +import { appAtomRegistry } from "~/rpc/atomRegistry"; + +/** + * Which thread has the guest share dialog open. The thread menu and the command palette both + * close as their command runs, so the chat view renders the dialog once. + */ +export const guestShareDialogThreadAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("guest-share:dialog-thread"), +); + +export function openGuestShareDialog(threadRef: ScopedThreadRef): void { + appAtomRegistry.set(guestShareDialogThreadAtom, threadRef); +} diff --git a/apps/web/src/components/chat/timelineRowContext.ts b/apps/web/src/components/chat/timelineRowContext.ts index fdfb833ecc6aa..aa0c919ab10c5 100644 --- a/apps/web/src/components/chat/timelineRowContext.ts +++ b/apps/web/src/components/chat/timelineRowContext.ts @@ -8,9 +8,12 @@ import { type ServerProviderSkill, type RunId, ThreadId, + type TurnItemId, } from "@t3tools/contracts"; +import type { AsyncResult, Atom } from "effect/reactivity"; +import type { CommandOutputView } from "@t3tools/client-runtime/state/command-output"; import type { WorkflowOpening } from "@t3tools/client-runtime/state/subagentRuntime"; -import { createContext, type MouseEvent } from "react"; +import { createContext, type MouseEvent, type ReactNode } from "react"; import { type LegendListRef } from "@legendapp/list/react"; import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; import { type ChatFileAttachment } from "../../types"; @@ -18,8 +21,21 @@ import { ExpandedImagePreview } from "./ExpandedImagePreview"; import { type AssistantCitationTarget } from "./AssistantCitationSource"; import { type MessagesTimelineRow, type WorkGroupScrollAnchor } from "./MessagesTimeline.logic"; import { type TimestampFormat } from "@t3tools/contracts/settings"; +import type { TurnItemDetailSource } from "../../state/queries"; import { type HandoffTimelineRun } from "./V2LifecycleRow"; +/** + * Where an expanded row reads an item's detail and a command's output. The owner's rows read them + * from the environment; the guest page reads them through its own routes. + */ +export interface TurnItemSource { + readonly detail: TurnItemDetailSource; + readonly commandOutput: (target: { + readonly threadId: ThreadId; + readonly itemId: TurnItemId; + }) => Atom.Atom>; +} + export interface TimelineRowSharedState { citationRequest: AssistantCitationTarget | null; listRef: React.RefObject; @@ -40,13 +56,21 @@ export interface TimelineRowSharedState { findActive: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; displayThreadKey?: string; - onOpenTurnDiff: (runId: RunId, filePath?: string) => void; + /** Unset where there is no diff to open, so the timeline shows no "Open diff" button. */ + onOpenTurnDiff: ((runId: RunId, filePath?: string) => void) | undefined; + /** + * Draws a published HTML visual. Unset, the row loads it from the environment; the guest page + * loads it through its own route. + */ + renderHtmlRender: ((row: Extract) => ReactNode) | undefined; + /** Unset, expanded rows read detail and command output from the environment. */ + turnItemSource: TurnItemSource | undefined; onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void; workflowOpening: WorkflowOpening | null; - onForkFromRun: (input: { - readonly sourceThreadId: ThreadId; - readonly runId: RunId; - }) => Promise; + /** Absent hides "Fork from this response", as in the guest view. */ + onForkFromRun: + | ((input: { readonly sourceThreadId: ThreadId; readonly runId: RunId }) => Promise) + | undefined; onRollbackCheckpoint: (input: { readonly checkpointId: string; readonly scopeId: string; diff --git a/apps/web/src/components/guest/GuestHtmlRender.tsx b/apps/web/src/components/guest/GuestHtmlRender.tsx new file mode 100644 index 0000000000000..75b7445ac6e8b --- /dev/null +++ b/apps/web/src/components/guest/GuestHtmlRender.tsx @@ -0,0 +1,93 @@ +import { GUEST_RENDER_ROUTE, GUEST_SHARE_PARAM } from "@t3tools/contracts"; +import { + type HtmlRenderReference, + HTML_RENDER_COLUMN_WIDTH, + htmlRenderFrameHeight, + htmlRenderResult, + htmlRenderThemeFragment, + htmlRenderThemeMessage, + readHtmlRenderContentHeight, + readHtmlRenderLinkRequest, +} from "@t3tools/shared/htmlRender"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; + +import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme"; +import { observeResize } from "~/lib/observeResize"; + +/** + * A visual the agent published, as the guest sees it. The server sends it with a sandbox CSP and + * the frame is sandboxed too, so the page runs scripts in an opaque origin. It can size itself + * and ask to open a web link; it cannot reach the guest's session. + */ +export function GuestHtmlRender({ + itemId, + reference, + shareId, +}: { + readonly itemId: string; + readonly reference: HtmlRenderReference; + readonly shareId: string; +}) { + const theme = useHtmlRenderTheme(); + const boxRef = useRef(null); + const frameRef = useRef(null); + const [width, setWidth] = useState(HTML_RENDER_COLUMN_WIDTH); + const [contentHeight, setContentHeight] = useState(); + // The theme rides in the fragment for the first paint; later changes are posted. + const [src] = useState( + () => + `${GUEST_RENDER_ROUTE}?${GUEST_SHARE_PARAM}=${encodeURIComponent(shareId)}&item=${encodeURIComponent(itemId)}${htmlRenderThemeFragment(theme)}`, + ); + useLayoutEffect(() => { + const box = boxRef.current; + if (!box) return; + setWidth(box.clientWidth); + return observeResize(box, ([entry]) => { + if (entry) setWidth(entry.contentRect.width); + }); + }, []); + useEffect(() => { + frameRef.current?.contentWindow?.postMessage(htmlRenderThemeMessage(theme), "*"); + }, [theme]); + useEffect(() => { + const onMessage = (event: MessageEvent) => { + const frame = frameRef.current; + if (frame === null || event.source !== frame.contentWindow) return; + const height = readHtmlRenderContentHeight(event.data); + if (height !== undefined) { + setContentHeight(height); + return; + } + // Only while the reader is using this frame, so a page cannot open links on its own. + const link = readHtmlRenderLinkRequest(event.data); + if ( + link === undefined || + document.activeElement !== frame || + navigator.userActivation?.isActive === false + ) { + return; + } + window.open(link.url, "_blank", "noopener,noreferrer"); + frame.contentWindow?.postMessage(htmlRenderResult(link.id), "*"); + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, []); + + return ( +
+
Research
Results', + ); + const doc = new DOMParser().parseFromString(html, "text/html"); + expect(doc.querySelector("td")?.textContent).toBe("Research"); + expect(doc.images).toHaveLength(1); + expect(doc.images[0]!.getAttribute("src")).toBe("data:image/png;base64,iVBORw0KGgo="); + expect(doc.querySelector("script,style,iframe,a,[onerror]")).toBeNull(); + expect(html).not.toContain("https://"); + }); +}); it("opens a known empty attachment even when its range response is inaccessible", async () => { refresh.mockResolvedValue("https://unreachable.invalid/empty.txt"); await using screen = await render( @@ -2295,3 +2330,432 @@ it("toggles a Mermaid attachment between diagram and source through the lazy pre ); expect(screen.container.querySelector('[role="alert"]')).toBeNull(); }); + +it("navigates, searches, zooms, and selects mixed-height PDF pages", async () => { + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Count 3 /Kids [3 0 R 5 0 R 7 0 R] >>", + ]; + for (const [index, title] of [ + "Quarterly outlook", + "Delivery milestones", + "Budget and risks", + ].entries()) { + const text = `/GS1 gs BT /F1 24 Tf 30 ${index === 0 ? 70 : 700} Td ${index === 0 ? "(Quarter) Tj /F2 24 Tf (ly outlook) Tj" : `(${title}) Tj`} ET`; + objects.push( + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 ${index === 0 ? 140 : 792}] /Rotate ${index === 2 ? 90 : 0} /Resources << /Font << /F1 9 0 R /F2 10 0 R >> /ExtGState << /GS1 << /TR << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 2 >> >> >> >> /Contents ${4 + index * 2} 0 R >>`, + `<< /Length ${text.length} >>\nstream\n${text}\nendstream`, + ); + } + objects.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); + objects.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>"); + let source = "%PDF-1.4\n"; + const offsets = [0]; + for (const [index, object] of objects.entries()) { + offsets.push(source.length); + source += `${index + 1} 0 obj\n${object}\nendobj\n`; + } + const xref = source.length; + source += `xref\n0 11\n0000000000 65535 f \n${offsets + .slice(1) + .map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`) + .join("")}trailer\n<< /Size 11 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`; + const file = new Blob([source], { type: "application/pdf" }); + const { default: PdfViewer } = await import("./PdfViewer"); + const url = URL.createObjectURL(file); + try { + await using screen = await render( +
+ +
, + ); + await vi.waitFor(() => expect(screen.container.textContent).toContain("1 / 3 pages"), { + timeout: 15_000, + }); + const region = screen.container.querySelector('[role="region"]')!; + await vi.waitFor(() => { + expect(region.scrollWidth).toBe(region.clientWidth); + expect(region.querySelector("canvas")!.getBoundingClientRect().width).toBeLessThan( + region.clientWidth, + ); + expect(screen.container.textContent).toContain("Fit width"); + }); + await act(() => { + (screen.container.firstElementChild as HTMLElement).style.width = "500px"; + }); + await vi.waitFor(() => + expect(region.querySelector("canvas")!.getBoundingClientRect().width).toBe(468), + ); + await screen.getByRole("button", { name: "Next page", exact: true }).click(); + await vi.waitFor(() => expect(screen.container.textContent).toContain("2 / 3 pages")); + await screen.getByRole("button", { name: "Previous page", exact: true }).click(); + await vi.waitFor(() => expect(screen.container.textContent).toContain("1 / 3 pages")); + await screen.getByRole("button", { name: "Zoom in", exact: true }).click(); + await vi.waitFor(() => expect(region.scrollWidth).toBeGreaterThan(region.clientWidth)); + await screen.getByRole("button", { name: "Fit width", exact: true }).click(); + await vi.waitFor(() => expect(region.scrollWidth).toBe(region.clientWidth)); + await screen.getByRole("searchbox", { name: "Find in PDF" }).fill("Quarterly outlook"); + await vi.waitFor(() => { + expect(screen.container.querySelector('[role="status"]')?.textContent).toBe("1/1"); + expect( + Array.from(region.querySelectorAll(".pdf-find-match")).map((span) => span.textContent), + ).toEqual(["Quarter", "ly outlook"]); + }); + await screen.getByRole("searchbox", { name: "Find in PDF" }).fill("Budget"); + await vi.waitFor(() => expect(screen.container.textContent).toContain("3 / 3 pages")); + await screen.getByRole("button", { name: "Zoom in", exact: true }).click(); + await vi.waitFor(() => expect(screen.container.textContent).toContain("3 / 3 pages")); + await vi.waitFor(() => { + const span = Array.from(screen.container.querySelectorAll("[data-pdf-text-layer] span")).find( + (node) => node.textContent === "Budget and risks", + ); + expect(span).toBeDefined(); + const textBounds = span!.getBoundingClientRect(); + const pageBounds = span!.parentElement!.getBoundingClientRect(); + expect(textBounds.width).toBeGreaterThan(0); + expect(textBounds.height).toBeGreaterThan(0); + expect(textBounds.left).toBeGreaterThanOrEqual(pageBounds.left - 1); + expect(textBounds.top).toBeGreaterThanOrEqual(pageBounds.top - 1); + expect(textBounds.right).toBeLessThanOrEqual(pageBounds.right + 1); + expect(textBounds.bottom).toBeLessThanOrEqual(pageBounds.bottom + 1); + const range = document.createRange(); + range.selectNodeContents(span!); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + expect(selection.toString()).toBe("Budget and risks"); + expect(getComputedStyle(span!).userSelect).toBe("text"); + selection.removeAllRanges(); + }); + await screen.getByRole("button", { name: "Fit width", exact: true }).click(); + await vi.waitFor(() => expect(screen.container.textContent).toContain("3 / 3 pages")); + await screen.getByRole("searchbox", { name: "Find in PDF" }).fill(" "); + await vi.waitFor(() => + expect(screen.container.querySelector('[role="status"]')?.textContent).toBe("0/0"), + ); + } finally { + URL.revokeObjectURL(url); + } +}); +it("mounts a bounded set of saved notebook outputs as the reader scrolls", async () => { + const notebook = { + nbformat: 4, + metadata: {}, + cells: Array.from({ length: 20 }, (_, cell) => ({ + cell_type: "code", + source: [], + metadata: {}, + outputs: Array.from({ length: 100 }, (_, output) => ({ + output_type: "display_data", + data: { "text/html": `

saved ${cell}-${output}

` }, + })), + })), + }; + const file = new Blob([JSON.stringify(notebook)]); + await using screen = await render( +
+ +
, + ); + await vi.waitFor(() => + expect(screen.container.querySelectorAll("iframe").length).toBeGreaterThan(0), + ); + expect(screen.container.querySelectorAll("iframe").length).toBeLessThanOrEqual(16); + const first = screen.container.querySelector("iframe")!; + expect(first.srcdoc).toContain("saved 0-0"); + const viewport = screen.container.querySelector(".scrollbar-gutter-both")!; + await act(() => { + viewport.scrollTop = viewport.scrollHeight; + }); + await vi.waitFor(() => { + expect( + [...screen.container.querySelectorAll("iframe")].some((frame) => + frame.srcdoc.includes("saved 19-99"), + ), + ).toBe(true); + expect(first.isConnected).toBe(false); + }); + expect(screen.container.querySelectorAll("iframe").length).toBeLessThanOrEqual(16); + await act(() => { + viewport.scrollTop = 0; + }); + await vi.waitFor(() => + expect(screen.container.querySelector("iframe")?.srcdoc).toContain("saved 0-0"), + ); +}); + +it.each([ + [String.raw`{\rtf1\uc1\u945{\b bold}Hello}`, "ฮฑboldHello"], + [String.raw`{\rtf1\uc1{\u945}Hello}`, "ฮฑHello"], + [ + String.raw`{\rtf1\ansi{\fonttbl{\f0\fcharset204 Cyrillic;}{\f1\fcharset0 Latin;}}\f0\'c0{\f1\'c0}\'c1}`, + "ะร€ะ‘", + ], + [String.raw`{\rtf1\ansi\deff1{\fonttbl{\f1\fcharset204 Cyrillic;}}\'c0\plain\'c1}`, "ะะ‘"], +])("retains RTF grouped Unicode and font text in %s", async (source, expected) => { + const { openRtf } = await import("./extendedDocumentPreview"); + const result = openRtf(new TextEncoder().encode(source).buffer); + if (result.type !== "document") throw new Error("Expected rich text document"); + const html = result.pages.map((page) => page.html).join(""); + expect(new DOMParser().parseFromString(html, "text/html").body.textContent).toBe(expected); +}); + +it.each(["odt", "odp"] as const)("preserves rendered %s spaces and tabs", async (kind) => { + const archive = new JSZip().file( + "content.xml", + `${kind === "odt" ? "" : ""}ABC${kind === "odt" ? "" : ""}`, + ); + const bytes = await archive.generateAsync({ type: "arraybuffer" }); + const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), { + type: "module", + }); + try { + const result = await new Promise((resolve) => { + worker.addEventListener( + "message", + (event: MessageEvent) => resolve(event.data), + { once: true }, + ); + worker.postMessage({ type: "open", kind, bytes }, [bytes]); + }); + if (result.type !== "document") throw new Error("Expected document"); + await using screen = await render( + , + ); + await vi.waitFor(() => { + const doc = screen.container.querySelector("iframe")?.contentDocument; + const spans = doc?.querySelectorAll("span[data-odf-whitespace]"); + expect(doc?.querySelector("p")?.textContent).toBe("A B\tC"); + expect(spans).toHaveLength(2); + for (const span of spans!) { + expect(doc!.defaultView!.getComputedStyle(span).whiteSpace).toBe("pre-wrap"); + expect(span.getBoundingClientRect().width).toBeGreaterThan(0); + } + expect(spans![0]!.getBoundingClientRect().width).toBeGreaterThan(10); + }); + } finally { + worker.terminate(); + } +}); + +it.each(["rtf", "epub", "ods"] as const)( + "preserves %s document text through the worker and attachment viewer", + async (kind) => { + const archive = new JSZip(); + if (kind === "epub") { + archive.file( + "META-INF/container.xml", + '', + ); + archive.file( + "book.opf", + '', + ); + archive.file( + "chapter.xhtml", + "Handbook

first
second
third


", + ); + } else if (kind === "ods") { + archive.file( + "content.xml", + 'Northnested noteprivate noterevised from 120', + ); + } + const file = + kind === "rtf" + ? new Blob([ + String.raw`{\rtf1\ansi 10\endash 20 \emdash \bullet \lquote a\rquote \ldblquote b\rdblquote\par Name\cell Value\cell\row 12\cell 34\cell\row{\*\private hidden\endash\cell\row}Unicode:\u8211\endash end}`, + ]) + : await archive.generateAsync({ type: "blob" }); + const bytes = await file.arrayBuffer(); + const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), { + type: "module", + }); + try { + const result = await new Promise((resolve) => { + worker.addEventListener( + "message", + (event: MessageEvent) => resolve(event.data), + { once: true }, + ); + worker.postMessage({ type: "open", kind, bytes }, [bytes]); + }); + if (kind === "ods") { + expect(result.type).toBe("ods"); + if (result.type !== "ods") throw new Error("Expected spreadsheet"); + expect(result.sheets[0]!.rows).toEqual([["North", "150"]]); + } else { + expect(result.type).toBe("document"); + if (result.type !== "document") throw new Error("Expected document"); + const body = new DOMParser().parseFromString(result.pages[0]!.html, "text/html").body; + if (kind === "epub") { + expect(body.querySelectorAll("br")).toHaveLength(2); + expect(body.querySelectorAll("hr")).toHaveLength(1); + } else { + expect(body.textContent).toContain("10โ€“20 โ€”โ€ขโ€˜aโ€™โ€œbโ€"); + expect(body.textContent).toContain("Name\tValue\t12\t34\t"); + expect(body.textContent).toContain("Unicode:โ€“end"); + expect(body.textContent).not.toContain("hidden"); + expect(body.querySelectorAll("br")).toHaveLength(3); + } + } + } finally { + worker.terminate(); + } + await using screen = await render( +
+ +
, + ); + await vi.waitFor(() => { + if (kind === "ods") { + expect(screen.container.textContent).toContain("North"); + expect(screen.container.textContent).toContain("150"); + expect(screen.container.textContent).not.toContain("private note"); + expect(screen.container.textContent).toContain("1 rows ยท 2 columns"); + expect(screen.container.textContent).not.toContain("Limited to 500"); + } else { + const frame = screen.container.querySelector("iframe"); + expect(frame?.srcdoc).toContain(kind === "rtf" ? "10โ€“20" : "first
second
third"); + } + }); + expect(screen.container.querySelector('[role="alert"]')).toBeNull(); + }, +); + +it.each(["epub", "odt", "ipynb", "html", "encoded-html"] as const)( + "bounds %s image pixels before returning browser markup", + async (kind) => { + const canvas = new OffscreenCanvas(2, 3); + canvas.getContext("2d"); + const blob = await canvas.convertToBlob({ type: "image/png" }); + for (const width of [2, 12000]) { + const image = await blob.arrayBuffer(); + new DataView(image).setUint32(16, width); + new DataView(image).setUint32(20, width === 2 ? 3 : 12000); + const archive = new JSZip(); + archive.file("cover.png", image); + if (kind === "epub") { + archive.file( + "META-INF/container.xml", + '', + ); + archive.file( + "book.opf", + '', + ); + archive.file( + "chapter.xhtml", + '

retained caption

', + ); + } else + archive.file( + "content.xml", + 'retained caption', + ); + const base64 = btoa(String.fromCharCode(...new Uint8Array(image))); + const bytes = + kind === "ipynb" || kind === "html" || kind === "encoded-html" + ? new TextEncoder().encode( + JSON.stringify({ + nbformat: 4, + cells: [ + { + cell_type: "code", + source: "plot()", + outputs: [ + { + data: { + ...(kind === "html" || kind === "encoded-html" + ? { + "text/html": `
retained caption
`, + } + : { "image/png": base64 }), + "text/plain": "retained caption", + }, + }, + ], + }, + ], + }), + ).buffer + : await archive.generateAsync({ type: "arraybuffer" }); + const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), { + type: "module", + }); + try { + const result = await new Promise((resolve) => { + worker.addEventListener( + "message", + (event: MessageEvent) => resolve(event.data), + { once: true }, + ); + worker.postMessage( + { + type: "open", + kind: kind === "html" || kind === "encoded-html" ? "ipynb" : kind, + bytes, + }, + [bytes], + ); + }); + if (result.type === "notebook") { + const output = result.cells[0]!.outputs[0]!; + expect(output.kind).toBe( + width === 2 ? (kind === "html" || kind === "encoded-html" ? "html" : "image") : "text", + ); + if (output.kind === "html") { + const rendered = new DOMParser().parseFromString( + sanitizeDocumentHtml(output.text), + "text/html", + ); + expect(rendered.images).toHaveLength(1); + expect(rendered.querySelector("td")!.textContent).toBe("retained caption"); + } + if (width !== 2) expect(result.cells[0]!.outputs[0]!.text).toBe("retained caption"); + } else { + expect(result.type).toBe("document"); + if (result.type !== "document") throw new Error("Expected document"); + expect(result.pages[0]!.html.includes(" { + const { BinaryDataFactory } = await import("./pdfDocument"); + const factory = new BinaryDataFactory(); + const fetchAsset = vi.fn( + async () => + new Response("app", { + headers: { "content-type": "text/html; charset=utf-8" }, + }), + ); + using _fetch = stubGlobal("fetch", fetchAsset); + await expect(factory.fetch({ kind, filename })).rejects.toThrow( + `PDF ${directory} asset unavailable.`, + ); + fetchAsset.mockImplementation(async () => new Response(new Uint8Array([1, 2, 3]))); + await expect(factory.fetch({ kind, filename })).resolves.toEqual(new Uint8Array([1, 2, 3])); + await expect(factory.fetch({ kind, filename: "missing" })).rejects.toThrow( + `Unsupported PDF ${directory} asset.`, + ); +}); diff --git a/apps/web/src/components/files/BrowserDocumentFrame.tsx b/apps/web/src/components/files/BrowserDocumentFrame.tsx index 670695cf5a30a..ab7b67a609f7f 100644 --- a/apps/web/src/components/files/BrowserDocumentFrame.tsx +++ b/apps/web/src/components/files/BrowserDocumentFrame.tsx @@ -22,7 +22,17 @@ import { matchesKeybindingShortcut, normalizeEventKey, } from "@t3tools/shared/keybindings"; -import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react"; +import { + lazy, + Suspense, + useEffect, + useEffectEvent, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { isElectron } from "~/env"; +import { FileSurfaceLoading } from "./fileSurfaceChrome"; import { useOpenLink } from "~/browser/useOpenLink"; import { ExpandedImageDialog } from "~/components/chat/ExpandedImageDialog"; @@ -61,9 +71,34 @@ const HTML_RENDER_SHORTCUT_COMMANDS: ReadonlySet = new Set([ export const isPdfPreviewFile = (path: string): boolean => /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? ""); +const PdfViewer = lazy(() => import("./PdfViewer")); + +function PdfDocument(props: { src: string; title: string }) { + const [src] = useState(props.src); + // The Electron shell does not enable PDF plugins. WebKit embeds on iOS + // expose only the first page, even when pdfViewerEnabled reports support. + const native = + !isElectron && + navigator.pdfViewerEnabled && + !/iPad|iPhone|iPod/.test(navigator.userAgent) && + !(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); + return native ? ( + // oxlint-disable-next-line react/iframe-missing-sandbox -- Chromium's PDF plugin requires an unsandboxed frame; src is an exact-file asset grant. +