Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 79 additions & 4 deletions apps/mobile/src/features/threads/ThreadBackgroundWorkSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 = {
Expand Down Expand Up @@ -59,9 +64,17 @@ export function ThreadBackgroundWorkSheet({ route }: StaticScreenProps<Backgroun
Nothing running in the background.
</Text>
) : (
presentation.items.map((item) => (
<BackgroundWorkRow key={item.taskId} item={item} onOpen={openThread} />
))
presentation.items.map((item) => {
const link =
item.kind === "monitor"
? findPullRequestForWatchTask(item.taskId, thread?.pullRequests)
: undefined;
return link ? (
<PullRequestMonitorRow key={item.taskId} link={link} />
) : (
<BackgroundWorkRow key={item.taskId} item={item} onOpen={openThread} />
);
})
)}
</ScrollView>
);
Expand Down Expand Up @@ -103,6 +116,68 @@ export function ThreadBackgroundWorkSheet({ route }: StaticScreenProps<Backgroun
);
}

const CHECKS_LABELS = {
passing: "checks passing",
pending: "checks pending",
failing: "checks failing",
} as const;

function PullRequestMonitorRow(props: { readonly link: ThreadPullRequestLink }) {
const { link } = props;
// 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 title = snapshot?.title ?? `Pull request #${link.number}`;
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={`Watching pull request ${link.number}, ${title}${status ? `, ${status.label}` : ""}${snapshot?.checksState && checks ? `, ${CHECKS_LABELS[snapshot.checksState]}` : ""}`}
accessibilityHint="Opens this pull request on GitHub"
onPress={() => {
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"
>
<View className="flex-row items-center gap-3 border-b border-border py-3.5">
<SymbolView
name={{ ios: "bolt", android: "bolt" }}
size={15}
tintColorClassName="accent-foreground-muted"
type="monochrome"
/>
<View className="min-w-0 flex-1 gap-0.5">
<Text className="font-t3-medium text-sm text-foreground" numberOfLines={2}>
{title}
</Text>
<View className="flex-row flex-wrap items-center gap-x-1.5 gap-y-0.5">
<Text className={cn("font-t3-medium text-xs", status?.text ?? "text-foreground-muted")}>
#{link.number}
</Text>
<Text className="text-xs text-foreground-muted">
{status ? `Watching · ${status.label}` : "Watching"}
</Text>
{checks && snapshot?.checksState ? (
<View className="flex-row items-center gap-1">
<SymbolView name={checks.icon} size={12} tintColorClassName={checks.tint} />
<Text className="text-xs text-foreground-muted">
{CHECKS_LABELS[snapshot.checksState]}
</Text>
</View>
) : null}
</View>
</View>
<SymbolView name="arrow.up.right" size={12} tintColorClassName="accent-icon-subtle" />
</View>
</Pressable>
);
}

function BackgroundWorkRow(props: {
readonly item: PendingBackgroundWorkItem;
readonly onOpen: (threadId: ThreadId) => void;
Expand Down
24 changes: 24 additions & 0 deletions packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ProviderInstanceId, ProviderThreadId } from "@t3tools/contracts";
import {
backgroundWorkHoldsCompletion,
derivePendingBackgroundWork,
findPullRequestForWatchTask,
} from "./orchestrationV2PendingBackgroundWork.ts";

describe("derivePendingBackgroundWork", () => {
Expand Down Expand Up @@ -404,6 +405,29 @@ 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);
// 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({
Expand Down
29 changes: 25 additions & 4 deletions packages/shared/src/orchestrationV2PendingBackgroundWork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,17 +294,38 @@ export function derivePendingBackgroundWork(input: {
return Array.from(byTaskId.values());
}

type PullRequestWatchLink = Pick<
ThreadPullRequestLink,
"host" | "repository" | "number" | "source" | "watch"
>;

const isWatchedLink = (link: Pick<ThreadPullRequestLink, "source" | "watch">) =>
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: 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<Link extends PullRequestWatchLink>(
taskId: string,
pullRequests: ReadonlyArray<Link> | undefined,
): Link | undefined {
return pullRequests?.find(
(link) => isWatchedLink(link) && pullRequestWatchTaskId(link) === taskId,
);
}

function pullRequestWatchTasks(
pullRequests: ReadonlyArray<PendingBackgroundWorkPullRequest> | undefined,
): Array<PendingBackgroundWorkTask> {
return (pullRequests ?? []).flatMap((link) =>
link.watch === undefined || link.source === "stack-dismissed"
!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,
},
Expand Down
Loading