diff --git a/apps/mobile/src/features/threads/ThreadBackgroundWorkSheet.tsx b/apps/mobile/src/features/threads/ThreadBackgroundWorkSheet.tsx index c4db6d7a93752..4298840a0f500 100644 --- a/apps/mobile/src/features/threads/ThreadBackgroundWorkSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadBackgroundWorkSheet.tsx @@ -5,7 +5,8 @@ import { presentPendingBackgroundWork, type PendingBackgroundWorkItem, } from "@t3tools/client-runtime/state/thread-execution"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { EnvironmentId, ThreadId, ThreadPullRequestLink } from "@t3tools/contracts"; +import { findPullRequestForWatchTask } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import * as Haptics from "expo-haptics"; import { Platform, Pressable, ScrollView, View } from "react-native"; import { ScreenStack } from "react-native-screens"; @@ -14,11 +15,15 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidSheetHeader } from "../../components/ui/AndroidScreenHeader"; import { SymbolView } from "../../components/ui/AppSymbol"; import { AppText as Text } from "../../components/ui/AppText"; +import * as Dialog from "../../components/ui/Dialog"; import { ScreenContent } from "../../components/ui/ScreenContent"; +import { cn } from "../../lib/cn"; +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { NativeStackScreenOptions, nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; import { NativeHeader, NativeScreen } from "../../native/ThemedScreenStack"; import { environmentThreadShells } from "../../state/threads"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; +import { pullRequestChecksStatus, pullRequestStatus } from "../pull-requests/pullRequestStatus"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); const KIND_LABELS = { @@ -59,9 +64,17 @@ export function ThreadBackgroundWorkSheet({ route }: StaticScreenProps ) : ( - presentation.items.map((item) => ( - - )) + presentation.items.map((item) => { + const link = + item.kind === "monitor" + ? findPullRequestForWatchTask(item.taskId, thread?.pullRequests) + : undefined; + return link ? ( + + ) : ( + + ); + }) )} ); @@ -103,6 +116,65 @@ export function ThreadBackgroundWorkSheet({ route }: StaticScreenProps { + void tryOpenExternalUrl(link.url, "pull-request").then((opened) => { + if (!opened) Dialog.alert("Unable to open PR", "The pull request could not be opened."); + }); + }} + className="active:opacity-70" + > + + + + + {title} + + + + #{link.number} + + + {status ? `Watching ยท ${status.label}` : "Watching"} + + {checks ? ( + + + {checksLabel} + + ) : null} + + + + + + ); +} + function BackgroundWorkRow(props: { readonly item: PendingBackgroundWorkItem; readonly onOpen: (threadId: ThreadId) => void; diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts index 1b5331c0d1bed..fead6288e67c6 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts @@ -3,6 +3,7 @@ import { ProviderInstanceId, ProviderThreadId } from "@t3tools/contracts"; import { backgroundWorkHoldsCompletion, derivePendingBackgroundWork, + findPullRequestForWatchTask, } from "./orchestrationV2PendingBackgroundWork.ts"; describe("backgroundWorkHoldsCompletion", () => { @@ -434,6 +435,29 @@ describe("derivePendingBackgroundWork kinds", () => { ]); }); + it("maps a watch monitor's task id back to its linked pull request", () => { + const [task] = derivePendingBackgroundWork({ + latestRun: { id: "run-1" as never, ordinal: 1, status: "completed" }, + providerThreads: [], + turnItems: [], + pullRequests, + }); + expect(findPullRequestForWatchTask(task!.taskId, pullRequests)).toBe(pullRequests[0]); + expect( + findPullRequestForWatchTask("pull-request-watch:github.com/acme/app#9", pullRequests), + ).toBe(undefined); + expect(findPullRequestForWatchTask("some-command-id", pullRequests)).toBe(undefined); + // Same number in another repository must not match. + const otherRepo = { ...pullRequests[0]!, repository: "acme/other" }; + expect(findPullRequestForWatchTask(task!.taskId, [otherRepo])).toBe(undefined); + // Legacy link lists can repeat an identity; only the watched copy owns the monitor. + const dismissed = { ...pullRequests[0]!, source: "stack-dismissed" as const }; + expect(findPullRequestForWatchTask(task!.taskId, [dismissed, pullRequests[0]!])).toBe( + pullRequests[0], + ); + expect(findPullRequestForWatchTask(task!.taskId, [dismissed])).toBe(undefined); + }); + it("lists no watch while a run is active", () => { expect( derivePendingBackgroundWork({ diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts index 6dc635b5153a6..98a377003e257 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts @@ -289,20 +289,43 @@ export function derivePendingBackgroundWork(input: { return Array.from(byTaskId.values()); } +type WatchStateFields = Pick & { + readonly snapshot?: Pick, "state"> | null; +}; + +type PullRequestWatchLink = Pick & + WatchStateFields; + +// The watch reactor ends a merged PR's watch without waking the agent, so it holds nothing. +const isWatchedLink = (link: WatchStateFields) => + link.watch !== undefined && + link.source !== "stack-dismissed" && + link.snapshot?.state !== "merged"; + +// Links store their normalized identity, and this id only has to be unique within the +// thread, so it reads those fields instead of parsing the URL. +const pullRequestWatchTaskId = (link: PullRequestWatchLink) => + `pull-request-watch:${link.host.toLowerCase()}/${link.repository.toLowerCase()}#${link.number}`; + +/** Maps a PR-watch monitor's task id back to the linked PR it watches. */ +export function findPullRequestForWatchTask( + taskId: string, + pullRequests: ReadonlyArray | undefined, +): Link | undefined { + return pullRequests?.find( + (link) => isWatchedLink(link) && pullRequestWatchTaskId(link) === taskId, + ); +} + function pullRequestWatchTasks( pullRequests: ReadonlyArray | undefined, ): Array { return (pullRequests ?? []).flatMap((link) => - // The watch reactor ends a merged PR's watch without waking the agent, so it holds nothing. - link.watch === undefined || - link.source === "stack-dismissed" || - link.snapshot?.state === "merged" + !isWatchedLink(link) ? [] : [ { - // Links store their normalized identity, and this id only has to be unique within the - // thread, so it reads those fields instead of parsing the URL. - taskId: `pull-request-watch:${link.host.toLowerCase()}/${link.repository.toLowerCase()}#${link.number}`, + taskId: pullRequestWatchTaskId(link), description: `Watching pull request #${link.number}`, kind: "monitor" as const, awaited: true as const,