From 5da517ff5a4086e0a35ead9cc7d53570a58d6663 Mon Sep 17 00:00:00 2001 From: ravdeep kainth Date: Mon, 19 Oct 2026 10:59:27 +1100 Subject: [PATCH 1/3] feat(mobile): show the pull request in PR-watch monitor rows --- .../threads/ThreadBackgroundWorkSheet.tsx | 83 ++++++++++++++++++- ...chestrationV2PendingBackgroundWork.test.ts | 18 ++++ .../orchestrationV2PendingBackgroundWork.ts | 12 ++- 3 files changed, 108 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadBackgroundWorkSheet.tsx b/apps/mobile/src/features/threads/ThreadBackgroundWorkSheet.tsx index c4db6d7a93752..b5448b8e4e083 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,68 @@ 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 && snapshot?.checksState ? ( + + + + {CHECKS_LABELS[snapshot.checksState]} + + + ) : 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 934d7f8175776..270041b979129 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("derivePendingBackgroundWork", () => { @@ -404,6 +405,23 @@ describe("derivePendingBackgroundWork kinds", () => { ).toEqual(["pull-request-watch:github.com/acme/app#1"]); }); + 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); + }); + 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 8073bcccd0bb5..74291538f3e88 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts @@ -295,6 +295,16 @@ export function derivePendingBackgroundWork(input: { return Array.from(byTaskId.values()); } +const pullRequestWatchTaskId = (link: Parameters[0]) => + `pull-request-watch:${threadPullRequestKeyOf(link)}`; + +/** Maps a PR-watch monitor's task id back to the linked PR it watches. */ +export function findPullRequestForWatchTask< + Link extends Parameters[0], +>(taskId: string, pullRequests: ReadonlyArray | undefined): Link | undefined { + return pullRequests?.find((link) => pullRequestWatchTaskId(link) === taskId); +} + function pullRequestWatchTasks( pullRequests: ReadonlyArray | undefined, ): Array { @@ -303,7 +313,7 @@ function pullRequestWatchTasks( ? [] : [ { - taskId: `pull-request-watch:${threadPullRequestKeyOf(link)}`, + taskId: pullRequestWatchTaskId(link), description: `Watching pull request #${link.number}`, kind: "monitor" as const, }, From 819dbad227a5fe29ff39c988137ae306b3ea83fd Mon Sep 17 00:00:00 2001 From: ravdeep kainth Date: Sat, 24 Oct 2026 22:47:29 +1100 Subject: [PATCH 2/3] fix(shared): PR-watch rows match only links that produce a watch monitor findPullRequestForWatchTask now applies the same watched, not stack-dismissed check as pullRequestWatchTasks, so a repeated legacy link cannot claim the row. Co-Authored-By: Claude Opus 5.5 --- ...rchestrationV2PendingBackgroundWork.test.ts | 6 ++++++ .../orchestrationV2PendingBackgroundWork.ts | 18 +++++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts index 270041b979129..6ed8f27849a7a 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts @@ -420,6 +420,12 @@ describe("derivePendingBackgroundWork kinds", () => { // 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", () => { diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts index 875381ed31f27..b6a74d1d31b75 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts @@ -294,26 +294,34 @@ export function derivePendingBackgroundWork(input: { return Array.from(byTaskId.values()); } -type PullRequestWatchIdentity = Pick; +type PullRequestWatchLink = Pick< + ThreadPullRequestLink, + "host" | "repository" | "number" | "source" | "watch" +>; + +const isWatchedLink = (link: Pick) => + link.watch !== undefined && link.source !== "stack-dismissed"; // 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: PullRequestWatchIdentity) => +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( +export function findPullRequestForWatchTask( taskId: string, pullRequests: ReadonlyArray | undefined, ): Link | undefined { - return pullRequests?.find((link) => pullRequestWatchTaskId(link) === taskId); + return pullRequests?.find( + (link) => isWatchedLink(link) && pullRequestWatchTaskId(link) === taskId, + ); } function pullRequestWatchTasks( pullRequests: ReadonlyArray | undefined, ): Array { return (pullRequests ?? []).flatMap((link) => - link.watch === undefined || link.source === "stack-dismissed" + !isWatchedLink(link) ? [] : [ { From e3cc757341497ccf7825012b7b689b101bf27535 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 20:57:47 +0000 Subject: [PATCH 3/3] fix(mobile): keep PR monitor accessibility host neutral --- .../threads/ThreadBackgroundWorkSheet.tsx | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadBackgroundWorkSheet.tsx b/apps/mobile/src/features/threads/ThreadBackgroundWorkSheet.tsx index b5448b8e4e083..4298840a0f500 100644 --- a/apps/mobile/src/features/threads/ThreadBackgroundWorkSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadBackgroundWorkSheet.tsx @@ -127,16 +127,15 @@ function PullRequestMonitorRow(props: { readonly link: ThreadPullRequestLink }) // The snapshot arrives from the host after the link, so the row must stay tappable without it. const snapshot = link.snapshot; const status = snapshot ? pullRequestStatus(snapshot) : null; - const checks = - snapshot?.state === "open" && snapshot.checksState - ? pullRequestChecksStatus(snapshot.checksState) - : null; + const checksState = snapshot?.state === "open" ? snapshot.checksState : undefined; + const checks = checksState ? pullRequestChecksStatus(checksState) : null; + const checksLabel = checksState ? CHECKS_LABELS[checksState] : undefined; const title = snapshot?.title ?? `Pull request #${link.number}`; return ( { void tryOpenExternalUrl(link.url, "pull-request").then((opened) => { if (!opened) Dialog.alert("Unable to open PR", "The pull request could not be opened."); @@ -162,12 +161,10 @@ function PullRequestMonitorRow(props: { readonly link: ThreadPullRequestLink }) {status ? `Watching · ${status.label}` : "Watching"} - {checks && snapshot?.checksState ? ( + {checks ? ( - - {CHECKS_LABELS[snapshot.checksState]} - + {checksLabel} ) : null}