@@ -859,7 +862,10 @@ export function PullRequestDetailPanel({
Conflicts
) : checksSummary ? (
-
+
+ {detail && checksState !== null ? (
+
+ ) : null}
{checksSummary}
) : null}
@@ -1269,7 +1275,13 @@ export function PullRequestDetailPanel({
className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground"
aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"}
>
-
+ {/* The rollup icon opens the checks behind the summary; with none reported
+ there is nothing to open, so the plain glyph stays. */}
+ {detail && checksState !== null ? (
+
+ ) : (
+
+ )}
{checksSummary}
) : tab === "timeline" ? (
diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx
index f301b2aa51d..62d3d236058 100644
--- a/apps/web/src/components/pullRequest/PullRequestRow.tsx
+++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx
@@ -5,6 +5,7 @@ import { getSourceControlPresentationForKind } from "~/sourceControlPresentation
import { formatRelativeTimeLabel } from "~/timestampFormat";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
+import { PullRequestChecksPopover } from "./PullRequestChecksPopover";
import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic";
import {
PullRequestActorLabel,
@@ -93,6 +94,17 @@ function PullRequestRowImpl({
{entry.reviewDecision === "approved" ? "Approved" : "Changes requested"}
) : null}
+ {entry.checksState === undefined ? null : (
+
+ )}
{matchedElsewhere ? (
matched in the description
diff --git a/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx b/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx
new file mode 100644
index 00000000000..23d70ae7b7d
--- /dev/null
+++ b/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx
@@ -0,0 +1,85 @@
+import type { EnvironmentId, ProjectId, PullRequestCheck } from "@t3tools/contracts";
+import { Children, isValidElement, type ReactNode } from "react";
+import { describe, expect, it } from "vite-plus/test";
+
+import { PullRequestChecksPopover } from "./PullRequestChecksPopover";
+import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic";
+import { PullRequestRow } from "./PullRequestRow";
+import { pullRequestChecksState } from "./pullRequestPresentation";
+
+function check(status: PullRequestCheck["status"]): PullRequestCheck {
+ return { name: `check-${status}`, status, description: null, url: null };
+}
+
+describe("pullRequestChecksState", () => {
+ it("lets a failure outrank a run still going, and reports nothing without checks", () => {
+ expect(pullRequestChecksState([check("success"), check("pending"), check("failure")])).toBe(
+ "failing",
+ );
+ expect(pullRequestChecksState([check("success"), check("cancelled")])).toBe("failing");
+ expect(pullRequestChecksState([check("success"), check("pending")])).toBe("pending");
+ expect(pullRequestChecksState([check("success")])).toBe("passing");
+ // Skipped and neutral are neither a pass nor a failure, so they are no verdict at all.
+ expect(pullRequestChecksState([check("skipped"), check("neutral")])).toBe(null);
+ expect(pullRequestChecksState([])).toBe(null);
+ });
+});
+
+/** Every element of the tree the row returned, so a nested indicator can be looked for. */
+function flatten(node: ReactNode): ReadonlyArray> {
+ const found: unknown[] = [];
+ for (const child of Children.toArray(node)) {
+ if (!isValidElement(child)) continue;
+ found.push(child);
+ found.push(...flatten((child.props as { readonly children?: ReactNode }).children));
+ }
+ return found as ReadonlyArray>;
+}
+
+function entry(overrides: Partial): EnvironmentPullRequestEntry {
+ return {
+ environmentId: "env-1" as EnvironmentId,
+ projectId: "project-1" as ProjectId,
+ provider: "github",
+ repository: "pingdotgg/t3code",
+ number: 1,
+ title: "Add the pull requests page",
+ url: "https://github.com/pingdotgg/t3code/pull/1",
+ author: null,
+ headBranch: "feat/page",
+ baseBranch: "main",
+ state: "open",
+ isDraft: false,
+ mergeability: "mergeable",
+ additions: 1,
+ deletions: 0,
+ createdAt: "2026-07-01T00:00:00Z",
+ updatedAt: "2026-07-02T00:00:00Z",
+ viewerReviewRequested: false,
+ labels: [],
+ ...overrides,
+ } as EnvironmentPullRequestEntry;
+}
+
+function row(overrides: Partial): ReactNode {
+ return PullRequestRow.type({
+ entry: entry(overrides),
+ selected: false,
+ showProjectTitle: false,
+ showProvider: false,
+ onSelect: () => {},
+ });
+}
+
+describe("PullRequestRow checks indicator", () => {
+ function indicators(node: ReactNode): number {
+ return flatten(node).filter(
+ (element) => (element as { type?: unknown }).type === PullRequestChecksPopover,
+ ).length;
+ }
+
+ it("shows the indicator only for a row the host reported a rollup for", () => {
+ expect(indicators(row({ checksState: "failing" }))).toBe(1);
+ expect(indicators(row({}))).toBe(0);
+ });
+});
diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx
index 28e952015ad..3704002924b 100644
--- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx
+++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx
@@ -2,12 +2,14 @@ import type {
PullRequestActor,
PullRequestCheck,
PullRequestCheckStatus,
+ PullRequestChecksState,
PullRequestMergeability,
PullRequestState,
} from "@t3tools/contracts";
import {
CircleCheckIcon,
CircleDashedIcon,
+ CircleDotIcon,
CircleXIcon,
GitMergeIcon,
GitPullRequestClosedIcon,
@@ -144,6 +146,51 @@ export function PullRequestCheckStatusIcon({ status }: { status: PullRequestChec
);
}
+/**
+ * The rollup a listing row carries, which is one word rather than the checks behind it. The
+ * headline is GitHub's own wording, so a reader who knows that page reads this one the same way.
+ */
+const CHECKS_STATE_PRESENTATION = {
+ passing: {
+ label: "All checks have passed",
+ Icon: CircleCheckIcon,
+ toneClassName: "text-emerald-600 dark:text-emerald-300/90",
+ },
+ failing: {
+ label: "Some checks were not successful",
+ Icon: CircleXIcon,
+ toneClassName: "text-destructive",
+ },
+ pending: {
+ label: "Some checks haven't completed yet",
+ Icon: CircleDotIcon,
+ toneClassName: "text-amber-600 dark:text-amber-400/90",
+ },
+} as const satisfies Record<
+ PullRequestChecksState,
+ { label: string; Icon: typeof CircleCheckIcon; toneClassName: string }
+>;
+
+export function pullRequestChecksStatePresentation(state: PullRequestChecksState) {
+ return CHECKS_STATE_PRESENTATION[state];
+}
+
+/**
+ * The same rollup the server sends with a listing row, worked out here from the checks a detail
+ * already holds — so the header shows the icon without a second field travelling with it.
+ *
+ * Null for a change request with no checks: nothing to show beats a tick nobody earned.
+ */
+export function pullRequestChecksState(
+ checks: ReadonlyArray,
+): PullRequestChecksState | null {
+ if (checks.length === 0) return null;
+ const statuses = checks.map((check) => check.status);
+ if (statuses.includes("failure") || statuses.includes("cancelled")) return "failing";
+ if (statuses.includes("pending")) return "pending";
+ return statuses.includes("success") ? "passing" : null;
+}
+
export function PullRequestActorAvatar({
actor,
className,
From 76281bdc256f96d9e19732a28f2f4a1ca97f88ef Mon Sep 17 00:00:00 2001
From: Bil0000 <62337003+Bil0000@users.noreply.github.com>
Date: Tue, 11 Aug 2026 16:05:50 +0000
Subject: [PATCH 39/41] feat(web): assign shared projects to one connection and
read label search as OR groups
---
.../pullRequest/pullRequestList.logic.test.ts | 75 ++++++++++-
.../pullRequest/pullRequestList.logic.ts | 30 ++++-
...pullRequestProjectAssignment.logic.test.ts | 119 ++++++++++++++++++
.../pullRequestProjectAssignment.logic.ts | 54 ++++++++
apps/web/src/routes/_chat.pull-requests.tsx | 82 +++++++++---
5 files changed, 331 insertions(+), 29 deletions(-)
create mode 100644 apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.test.ts
create mode 100644 apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts
diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts
index 77c31ee9c04..4fe85851807 100644
--- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts
+++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts
@@ -245,11 +245,37 @@ describe("narrowing rows by the filters a host may not have applied", () => {
});
it("matches labels and authors however either was capitalized", () => {
- expect(narrow({ labels: ["needs DESIGN"] })).toEqual([4]);
+ expect(narrow({ labels: [["needs DESIGN"]] })).toEqual([4]);
expect(narrow({ excludedLabels: ["needs design"] })).toEqual([1, 2, 3]);
expect(narrow({ author: "hubot" })).toEqual([4]);
});
+ it("takes a group of labels as an either-or", () => {
+ const sized = [
+ entry({ number: 10, labels: [{ name: "size:S", color: null }] }),
+ entry({ number: 11, labels: [{ name: "size:XS", color: null }] }),
+ entry({ number: 12, labels: [{ name: "size:L", color: null }] }),
+ ];
+ const kept = (labels: ReadonlyArray>) =>
+ sized.filter((row) => matchesPullRequestFilters(row, { labels })).map((row) => row.number);
+
+ expect(kept([["size:S", "size:XS"]])).toEqual([10, 11]);
+ expect(kept([["size:XXL"]])).toEqual([]);
+ });
+
+ it("takes two groups as an and, each satisfied on its own", () => {
+ const row = entry({
+ number: 20,
+ labels: [
+ { name: "size:S", color: null },
+ { name: "bug", color: null },
+ ],
+ });
+ expect(matchesPullRequestFilters(row, { labels: [["size:S", "size:XS"], ["bug"]] })).toBe(true);
+ // The second group holds nothing this row carries, so the first one satisfied is not enough.
+ expect(matchesPullRequestFilters(row, { labels: [["size:S"], ["wip"]] })).toBe(false);
+ });
+
it("holds on to every row for a filter no row carries the answer to", () => {
expect(narrow({ checks: "passing" })).toEqual([1, 2, 3, 4]);
});
@@ -259,10 +285,34 @@ describe("reading qualifiers out of a typed query", () => {
it("keeps quoted values whole and separates negation", () => {
expect(parsePullRequestQuery('label:"needs design" -label:wip Fix the parser')).toEqual({
text: "Fix the parser",
- filters: { labels: ["needs design"], excludedLabels: ["wip"] },
+ filters: { labels: [["needs design"]], excludedLabels: ["wip"] },
});
});
+ it("reads a comma-separated list as one group either name satisfies", () => {
+ expect(parsePullRequestQuery("label:size:S,size:XS").filters.labels).toEqual([
+ ["size:S", "size:XS"],
+ ]);
+ });
+
+ it("keeps the spaces in a quoted name of a list, and drops an empty part", () => {
+ expect(parsePullRequestQuery('label:"needs design","wip"').filters.labels).toEqual([
+ ["needs design", "wip"],
+ ]);
+ expect(parsePullRequestQuery("label:a,,b").filters.labels).toEqual([["a", "b"]]);
+ });
+
+ it("excludes every name of a negated list", () => {
+ expect(parsePullRequestQuery("-label:a,b").filters).toEqual({ excludedLabels: ["a", "b"] });
+ });
+
+ it("makes each label qualifier its own group, so the groups are an AND", () => {
+ expect(parsePullRequestQuery("label:bug label:a,b").filters.labels).toEqual([
+ ["bug"],
+ ["a", "b"],
+ ]);
+ });
+
it("reads the scalar qualifiers in GitHub's own spelling", () => {
expect(
parsePullRequestQuery("author:octocat DRAFT:false review:changes_requested status:failure")
@@ -277,7 +327,7 @@ describe("reading qualifiers out of a typed query", () => {
it("leaves an unknown value and a stray colon as text, and reads an unknown key as a label", () => {
const parsed = parsePullRequestQuery("milestone:v2 draft:maybe status: parser");
- expect(parsed.filters).toEqual({ labels: ["milestone:v2"] });
+ expect(parsed.filters).toEqual({ labels: [["milestone:v2"]] });
// A known key whose value it does not take is text, so "status:" itself stays findable.
expect(parsed.text).toBe("draft:maybe status: parser");
});
@@ -701,13 +751,26 @@ describe("the project an id names", () => {
describe("colon-namespaced labels typed as a search", () => {
it("reads an unknown key as the label it almost always is", () => {
- expect(parsePullRequestQuery("size:XXL").filters.labels).toEqual(["size:XXL"]);
- expect(parsePullRequestQuery("vouch:trusted").filters.labels).toEqual(["vouch:trusted"]);
+ expect(parsePullRequestQuery("size:XXL").filters.labels).toEqual([["size:XXL"]]);
+ expect(parsePullRequestQuery("vouch:trusted").filters.labels).toEqual([["vouch:trusted"]]);
expect(parsePullRequestQuery("size:XXL").text).toBe("");
});
+ it("reads the key as the namespace of every bare name in its list", () => {
+ expect(parsePullRequestQuery("size:S,XS").filters.labels).toEqual([["size:S", "size:XS"]]);
+ });
+
+ it("leaves a name that already carries its own namespace alone", () => {
+ // `size:S,size:XS` is the same pair written out; prefixing again would ask for `size:size:XS`.
+ expect(parsePullRequestQuery("size:S,size:XS").filters.labels).toEqual([["size:S", "size:XS"]]);
+ });
+
it("excludes one the same way", () => {
expect(parsePullRequestQuery("-size:XXL").filters.excludedLabels).toEqual(["size:XXL"]);
+ expect(parsePullRequestQuery("-size:S,XS").filters.excludedLabels).toEqual([
+ "size:S",
+ "size:XS",
+ ]);
});
it("keeps a quoted token as text, which is the way back to a literal search", () => {
@@ -724,7 +787,7 @@ describe("colon-namespaced labels typed as a search", () => {
it("mixes with the keys it does know, and with plain words", () => {
const parsed = parsePullRequestQuery("area:web draft:true wizard label:bug");
- expect(parsed.filters).toEqual({ labels: ["area:web", "bug"], draft: "only" });
+ expect(parsed.filters).toEqual({ labels: [["area:web"], ["bug"]], draft: "only" });
expect(parsed.text).toBe("wizard");
});
diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts
index 5b032567643..95693382ed7 100644
--- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts
+++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts
@@ -96,6 +96,14 @@ function qualifierValue(raw: string): string {
return raw.replaceAll('"', "").trim();
}
+/** A qualifier's value as the list it may be: split on commas, each name unquoted on its own. */
+function splitQualifierList(raw: string): string[] {
+ return raw
+ .split(",")
+ .map((part) => qualifierValue(part))
+ .filter((part) => part.length > 0);
+}
+
/**
* A typed query split into the qualifiers the hosts can act on and the text that is left. Written
* GitHub's way — `label:foo`, `-label:"needs design"`, `author:octocat`, `draft:true`,
@@ -115,7 +123,7 @@ export function parsePullRequestQuery(raw: string): {
readonly filters: PullRequestListFilters;
} {
const text: string[] = [];
- const labels: string[] = [];
+ const labels: string[][] = [];
const excludedLabels: string[] = [];
let author: string | undefined;
let draft: PullRequestListFilters["draft"];
@@ -126,9 +134,14 @@ export function parsePullRequestQuery(raw: string): {
const value = qualifier === null ? "" : qualifierValue(qualifier[3] ?? "");
const negated = qualifier?.[1] === "-";
switch (value.length === 0 ? "" : (qualifier?.[2]?.toLowerCase() ?? "")) {
- case "label":
- (negated ? excludedLabels : labels).push(value);
+ case "label": {
+ // GitHub's own OR: `label:a,b` is one qualifier satisfied by either name. Negated, the
+ // comma excludes each — a row carrying any of them goes.
+ const names = splitQualifierList(qualifier?.[3] ?? "");
+ if (negated) excludedLabels.push(...names);
+ else labels.push(names);
continue;
+ }
case "author":
if (negated) break;
author = value;
@@ -156,7 +169,14 @@ export function parsePullRequestQuery(raw: string): {
// An unknown key, read as the namespaced label it almost always is. A pasted link is
// not one — `https://…` would otherwise become a label named after its own scheme.
if (!value.startsWith("/")) {
- (negated ? excludedLabels : labels).push(`${qualifier?.[2] ?? ""}:${value}`);
+ // The key names the namespace, so the bare parts of `size:S,XS` are both sizes. A part
+ // that already carries a colon names its whole label — `size:S,size:XS` is the same
+ // pair written out, and prefixing it again would ask for `size:size:XS`.
+ const names = splitQualifierList(qualifier?.[3] ?? "").map((name) =>
+ name.includes(":") ? name : `${qualifier?.[2] ?? ""}:${name}`,
+ );
+ if (negated) excludedLabels.push(...names);
+ else labels.push(names);
continue;
}
}
@@ -252,7 +272,7 @@ export function matchesPullRequestFilters(
(filters.review === "none"
? entry.reviewDecision === undefined
: entry.reviewDecision === filters.review)) &&
- (filters.labels === undefined || filters.labels.every(holds)) &&
+ (filters.labels === undefined || filters.labels.every((group) => group.some(holds))) &&
(filters.excludedLabels === undefined || !filters.excludedLabels.some(holds)) &&
(filters.author === undefined ||
entry.author?.login.toLowerCase() === filters.author.trim().toLowerCase())
diff --git a/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.test.ts
new file mode 100644
index 00000000000..510381f5324
--- /dev/null
+++ b/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.test.ts
@@ -0,0 +1,119 @@
+import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ assignProjectsToEnvironments,
+ type AssignableProject,
+} from "./pullRequestProjectAssignment.logic";
+
+const project = (id: string, environmentId: string, canonicalKey?: string): AssignableProject => ({
+ id: id as ProjectId,
+ environmentId: environmentId as EnvironmentId,
+ repositoryIdentity: canonicalKey === undefined ? null : { canonicalKey },
+});
+
+const envs = (...ids: ReadonlyArray) => ids as ReadonlyArray;
+
+const plain = (assignment: Map) =>
+ Object.fromEntries([...assignment].map(([id, projectIds]) => [id, projectIds]));
+
+describe("one server per repository", () => {
+ it("lets the first server list a repository both hold", () => {
+ const assignment = assignProjectsToEnvironments(
+ [
+ project("a1", "env-1", "github.com/acme/app"),
+ project("b1", "env-1", "github.com/acme/tools"),
+ project("a2", "env-2", "github.com/acme/app"),
+ project("c2", "env-2", "github.com/acme/site"),
+ ],
+ envs("env-1", "env-2"),
+ "env-1" as EnvironmentId,
+ );
+ expect(plain(assignment)).toEqual({ "env-1": ["a1", "b1"], "env-2": ["c2"] });
+ });
+
+ it("prefers the named server over the first one", () => {
+ const assignment = assignProjectsToEnvironments(
+ [
+ project("a1", "env-1", "github.com/acme/app"),
+ project("a2", "env-2", "github.com/acme/app"),
+ ],
+ envs("env-1", "env-2"),
+ "env-2" as EnvironmentId,
+ );
+ expect(plain(assignment)).toEqual({ "env-2": ["a2"] });
+ });
+
+ it("drops a server with nothing of its own", () => {
+ const assignment = assignProjectsToEnvironments(
+ [
+ project("a1", "env-1", "github.com/acme/app"),
+ project("a2", "env-2", "github.com/acme/app"),
+ ],
+ envs("env-1", "env-2"),
+ "env-1" as EnvironmentId,
+ );
+ expect(assignment.has("env-2" as EnvironmentId)).toBe(false);
+ });
+
+ it("keeps every copy of a project that has no identity to compare", () => {
+ const assignment = assignProjectsToEnvironments(
+ [project("p1", "env-1"), project("p2", "env-2")],
+ envs("env-1", "env-2"),
+ "env-1" as EnvironmentId,
+ );
+ expect(plain(assignment)).toEqual({ "env-1": ["p1"], "env-2": ["p2"] });
+ });
+
+ it("keeps a repository listed by the one server that holds it", () => {
+ const assignment = assignProjectsToEnvironments(
+ [
+ project("a1", "env-1", "github.com/acme/app"),
+ project("b2", "env-2", "gitlab.com/acme/app"),
+ ],
+ envs("env-1", "env-2"),
+ "env-1" as EnvironmentId,
+ );
+ expect(plain(assignment)).toEqual({ "env-1": ["a1"], "env-2": ["b2"] });
+ });
+
+ it("keeps a server's own worktrees of the repository it lists", () => {
+ const assignment = assignProjectsToEnvironments(
+ [
+ project("a1", "env-1", "github.com/acme/app"),
+ project("a1-wt", "env-1", "GitHub.com/acme/app"),
+ project("a2", "env-2", "github.com/acme/app"),
+ ],
+ envs("env-1", "env-2"),
+ "env-1" as EnvironmentId,
+ );
+ expect(plain(assignment)).toEqual({ "env-1": ["a1", "a1-wt"] });
+ });
+
+ it("ignores a project on a server that is not being read", () => {
+ const assignment = assignProjectsToEnvironments(
+ [
+ project("a1", "env-1", "github.com/acme/app"),
+ project("a2", "env-2", "github.com/acme/app"),
+ ],
+ envs("env-2"),
+ "env-2" as EnvironmentId,
+ );
+ expect(plain(assignment)).toEqual({ "env-2": ["a2"] });
+ });
+
+ it("answers the same whatever order the projects arrive in", () => {
+ const projects = [
+ project("a2", "env-2", "github.com/acme/app"),
+ project("a1", "env-1", "github.com/acme/app"),
+ ];
+ const forward = assignProjectsToEnvironments(projects, envs("env-1", "env-2"), null);
+ const backward = assignProjectsToEnvironments(
+ projects.toReversed(),
+ envs("env-1", "env-2"),
+ null,
+ );
+ expect(plain(forward)).toEqual({ "env-1": ["a1"] });
+ expect(plain(backward)).toEqual(plain(forward));
+ });
+});
diff --git a/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts b/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts
new file mode 100644
index 00000000000..b9aaf2d1989
--- /dev/null
+++ b/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts
@@ -0,0 +1,54 @@
+import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
+
+/** The little of a project this needs: who holds it, and which repository it is a copy of. */
+export interface AssignableProject {
+ readonly id: ProjectId;
+ readonly environmentId: EnvironmentId;
+ readonly repositoryIdentity?: { readonly canonicalKey?: string | undefined } | null | undefined;
+}
+
+/**
+ * Two servers can hold the same repository, and both would list the same pull requests. The
+ * remote's normalized URL (`canonicalKey`) is what says "same repository" across machines — it
+ * comes from the remote, not from a local path — so it is what one copy is picked by.
+ *
+ * A project with no identity is never de-duplicated: nothing proves it is a copy of anything, and
+ * dropping it would lose its rows outright.
+ */
+export function assignProjectsToEnvironments(
+ projects: ReadonlyArray,
+ environmentIds: ReadonlyArray,
+ preferredEnvironmentId?: EnvironmentId | null,
+): Map {
+ const rank = new Map(environmentIds.map((id, index) => [id, index] as const));
+ // Which server lists each repository: the preferred one where it has it, else the first.
+ const owner = new Map();
+ for (const project of projects) {
+ const key = project.repositoryIdentity?.canonicalKey?.toLowerCase();
+ if (!key) continue;
+ const environmentRank = rank.get(project.environmentId);
+ if (environmentRank === undefined) continue;
+ const current = owner.get(key);
+ if (current === undefined) {
+ owner.set(key, project.environmentId);
+ continue;
+ }
+ if (current === preferredEnvironmentId) continue;
+ if (
+ project.environmentId === preferredEnvironmentId ||
+ environmentRank < (rank.get(current) ?? Number.MAX_SAFE_INTEGER)
+ ) {
+ owner.set(key, project.environmentId);
+ }
+ }
+ const assignment = new Map();
+ for (const project of projects) {
+ if (!rank.has(project.environmentId)) continue;
+ const key = project.repositoryIdentity?.canonicalKey?.toLowerCase();
+ if (key && owner.get(key) !== project.environmentId) continue;
+ const listed = assignment.get(project.environmentId);
+ if (listed === undefined) assignment.set(project.environmentId, [project.id]);
+ else listed.push(project.id);
+ }
+ return assignment;
+}
diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx
index ac5d3ad8216..6bb2197affa 100644
--- a/apps/web/src/routes/_chat.pull-requests.tsx
+++ b/apps/web/src/routes/_chat.pull-requests.tsx
@@ -51,6 +51,7 @@ import {
type PullRequestDiffStats,
type PullRequestPartitionsSnapshot,
} from "../components/pullRequest/pullRequestList.logic";
+import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic";
import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel";
import {
PullRequestFiltersMenu,
@@ -457,8 +458,61 @@ function PullRequestsRouteView() {
[menuFilters, typedParsed.filters],
);
const hasLocalFilters = Object.keys(localFilters).length > 0;
+ // Scoping to a project scopes to the server that owns it, saving every other server a read
+ // that could only answer with nothing. Where an id is ambiguous — two servers holding the
+ // same project id, with no server named — every server is asked for it, and the ones without
+ // it answer empty: two honest copies beat a coin toss between them.
+ const queryEnvironmentIds = useMemo(
+ () =>
+ scopedProject === undefined
+ ? environmentIds
+ : environmentIds.filter((environmentId) => environmentId === scopedProject.environmentId),
+ [environmentIds, scopedProject],
+ );
+ /**
+ * Which projects each server is asked about. Two servers holding the same repository would both
+ * list the same pull requests, so each repository is listed by one of them — the first, which is
+ * where the page's actions land — and the others are asked only for what is theirs alone. A
+ * server left with nothing of its own is not read at all.
+ *
+ * Left alone while the projects are still arriving, and while the scope is a single project:
+ * that path deliberately asks both servers holding an ambiguous id.
+ */
+ const environmentQueries = useMemo((): ReadonlyArray<{
+ readonly environmentId: EnvironmentId;
+ readonly projectIds?: ReadonlyArray;
+ }> => {
+ const plain = queryEnvironmentIds.map((environmentId) => ({ environmentId }));
+ if (!projectsKnown || scopedProjectId !== undefined) return plain;
+ const assignment = assignProjectsToEnvironments(
+ projects,
+ queryEnvironmentIds,
+ queryEnvironmentIds[0],
+ );
+ const totals = new Map();
+ for (const project of projects) {
+ totals.set(project.environmentId, (totals.get(project.environmentId) ?? 0) + 1);
+ }
+ return queryEnvironmentIds.flatMap((environmentId) => {
+ const projectIds = assignment.get(environmentId);
+ if (projectIds === undefined) return [];
+ // It lists everything it holds anyway, so the filter is left off and a one-server workspace
+ // asks exactly the question it asked before.
+ if (projectIds.length === (totals.get(environmentId) ?? 0)) return [{ environmentId }];
+ return [{ environmentId, projectIds }];
+ });
+ }, [projects, projectsKnown, queryEnvironmentIds, scopedProjectId]);
+ // Part of the scope, since a different split is a different question and its answers must not
+ // be filed under the same page state.
+ const assignmentKey = useMemo(
+ () =>
+ environmentQueries
+ .map(({ environmentId, projectIds }) => `${environmentId}#${projectIds?.join("+") ?? "*"}`)
+ .join("|"),
+ [environmentQueries],
+ );
// Page size is view state, not a URL concern: a shared link should open the first page.
- const scopeKey = `${environmentKey}:${search.state}:${search.involvement}:${scopedProjectId ?? ""}:${search.host ?? ""}:${search.draft ?? ""}:${search.review ?? ""}:${search.checks ?? ""}`;
+ const scopeKey = `${environmentKey}:${assignmentKey}:${search.state}:${search.involvement}:${scopedProjectId ?? ""}:${search.host ?? ""}:${search.draft ?? ""}:${search.review ?? ""}:${search.checks ?? ""}`;
const filterKey = `${scopeKey}:${sentQuery}`;
// Where the next slice carries on from, per repository within each environment, as that
// environment handed it back. Sending it is what makes a second page cost a second page rather
@@ -487,21 +541,10 @@ function PullRequestsRouteView() {
setPage({ key: filterKey, size: PAGE_SIZE, cursors: null, regrown: [] });
}, [filterKey]);
- // Scoping to a project scopes to the server that owns it, saving every other server a read
- // that could only answer with nothing. Where an id is ambiguous — two servers holding the
- // same project id, with no server named — every server is asked for it, and the ones without
- // it answer empty: two honest copies beat a coin toss between them.
- const queryEnvironmentIds = useMemo(
- () =>
- scopedProject === undefined
- ? environmentIds
- : environmentIds.filter((environmentId) => environmentId === scopedProject.environmentId),
- [environmentIds, scopedProject],
- );
/** The listing input each environment is asked for, which differs only in its continuation. */
const listTargets = useMemo(
() =>
- queryEnvironmentIds.flatMap((environmentId) => {
+ environmentQueries.flatMap(({ environmentId, projectIds }) => {
const cursors = sentCursors?.[environmentId];
// A continuation asks the environments that said where to carry on from, plus the ones
// that have more to give but no cursor to give it from — those are read again at the
@@ -521,6 +564,7 @@ function PullRequestsRouteView() {
involvement: search.involvement,
limit: pageSize,
...(scopedProjectId ? { projectId: scopedProjectId } : {}),
+ ...(projectIds ? { projectIds } : {}),
...(search.host ? { host: search.host } : {}),
...(hasFilters ? { filters } : {}),
...(sentParsed.text ? { query: sentParsed.text } : {}),
@@ -533,7 +577,7 @@ function PullRequestsRouteView() {
filters,
hasFilters,
pageSize,
- queryEnvironmentIds,
+ environmentQueries,
scopedProjectId,
search.host,
search.involvement,
@@ -557,13 +601,14 @@ function PullRequestsRouteView() {
*/
const baselineTargets = useMemo(
() =>
- queryEnvironmentIds.map((environmentId) => ({
+ environmentQueries.map(({ environmentId, projectIds }) => ({
environmentId,
input: {
state: search.state,
involvement: search.involvement,
limit: PAGE_SIZE,
...(scopedProjectId ? { projectId: scopedProjectId } : {}),
+ ...(projectIds ? { projectIds } : {}),
...(search.host ? { host: search.host } : {}),
...(menuFiltered ? { filters: menuFilters } : {}),
} satisfies PullRequestListInput,
@@ -571,7 +616,7 @@ function PullRequestsRouteView() {
[
menuFiltered,
menuFilters,
- queryEnvironmentIds,
+ environmentQueries,
scopedProjectId,
search.host,
search.involvement,
@@ -591,13 +636,14 @@ function PullRequestsRouteView() {
const partitionTargets = useMemo(() => {
if (!partitionsWanted) return { authored: NO_LIST_TARGETS, reviewing: NO_LIST_TARGETS };
const targetsFor = (involvement: PullRequestInvolvement) =>
- queryEnvironmentIds.map((environmentId) => ({
+ environmentQueries.map(({ environmentId, projectIds }) => ({
environmentId,
input: {
state: search.state,
involvement,
limit: PAGE_SIZE,
...(scopedProjectId ? { projectId: scopedProjectId } : {}),
+ ...(projectIds ? { projectIds } : {}),
...(search.host ? { host: search.host } : {}),
...(menuFiltered ? { filters: menuFilters } : {}),
} satisfies PullRequestListInput,
@@ -607,7 +653,7 @@ function PullRequestsRouteView() {
menuFiltered,
menuFilters,
partitionsWanted,
- queryEnvironmentIds,
+ environmentQueries,
scopedProjectId,
search.host,
search.state,
From 74c41aa03a60386f21234737aed90d6abe034653 Mon Sep 17 00:00:00 2001
From: Bil0000 <62337003+Bil0000@users.noreply.github.com>
Date: Tue, 11 Aug 2026 16:22:25 +0000
Subject: [PATCH 40/41] feat(web): resolve which connections hold a shared
project's repository
Listing assigns a shared project to one connection so each pull request
appears once. Acting on it is a separate question: the copy the listing
did not pick is just as real, and it is where the reader may mean to
work. resolvePickableEnvironments answers which connections hold the
repository, reusing the assignment module's own identity key, and
answers with nothing where there is no choice to offer.
---
...pullRequestProjectAssignment.logic.test.ts | 130 +++++++++++++++++-
.../pullRequestProjectAssignment.logic.ts | 83 ++++++++++-
2 files changed, 207 insertions(+), 6 deletions(-)
diff --git a/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.test.ts
index 510381f5324..3ee59a5b046 100644
--- a/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.test.ts
+++ b/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.test.ts
@@ -3,13 +3,19 @@ import { describe, expect, it } from "vite-plus/test";
import {
assignProjectsToEnvironments,
+ resolvePickableEnvironments,
type AssignableProject,
} from "./pullRequestProjectAssignment.logic";
-const project = (id: string, environmentId: string, canonicalKey?: string): AssignableProject => ({
+const project = (
+ id: string,
+ environmentId: string,
+ canonicalKey?: string,
+): AssignableProject & { workspaceRoot: string } => ({
id: id as ProjectId,
environmentId: environmentId as EnvironmentId,
repositoryIdentity: canonicalKey === undefined ? null : { canonicalKey },
+ workspaceRoot: `/srv/${environmentId}/${id}`,
});
const envs = (...ids: ReadonlyArray) => ids as ReadonlyArray;
@@ -117,3 +123,125 @@ describe("one server per repository", () => {
expect(plain(backward)).toEqual(plain(forward));
});
});
+
+const connected = (...ids: ReadonlyArray) =>
+ ids.map((id) => ({ environmentId: id as EnvironmentId, label: `Server ${id}` }));
+
+const on = (environmentId: string, projectId: string) => ({
+ environmentId: environmentId as EnvironmentId,
+ projectId: projectId as ProjectId,
+});
+
+describe("where a pull request can be acted on", () => {
+ it("offers every server holding the repository, the panel's own first", () => {
+ const pickable = resolvePickableEnvironments(
+ on("env-2", "a2"),
+ [
+ project("a1", "env-1", "github.com/acme/app"),
+ project("a2", "env-2", "github.com/acme/app"),
+ project("c3", "env-3", "github.com/acme/site"),
+ ],
+ connected("env-1", "env-2", "env-3"),
+ );
+ expect(pickable).toEqual([
+ {
+ environmentId: "env-2",
+ projectId: "a2",
+ workspaceRoot: "/srv/env-2/a2",
+ label: "Server env-2",
+ },
+ {
+ environmentId: "env-1",
+ projectId: "a1",
+ workspaceRoot: "/srv/env-1/a1",
+ label: "Server env-1",
+ },
+ ]);
+ });
+
+ it("matches copies however the remote is cased", () => {
+ const pickable = resolvePickableEnvironments(
+ on("env-1", "a1"),
+ [
+ project("a1", "env-1", "github.com/acme/app"),
+ project("a2", "env-2", "GitHub.com/ACME/app"),
+ ],
+ connected("env-1", "env-2"),
+ );
+ expect(pickable.map((entry) => entry.environmentId)).toEqual(["env-1", "env-2"]);
+ });
+
+ it("offers nothing where one server holds the repository", () => {
+ expect(
+ resolvePickableEnvironments(
+ on("env-1", "a1"),
+ [
+ project("a1", "env-1", "github.com/acme/app"),
+ project("c2", "env-2", "github.com/acme/site"),
+ ],
+ connected("env-1", "env-2"),
+ ),
+ ).toEqual([]);
+ });
+
+ it("offers nothing for a project with no identity to compare", () => {
+ expect(
+ resolvePickableEnvironments(
+ on("env-1", "p1"),
+ [project("p1", "env-1"), project("p2", "env-2")],
+ connected("env-1", "env-2"),
+ ),
+ ).toEqual([]);
+ });
+
+ it("offers nothing while the projects are still arriving", () => {
+ expect(resolvePickableEnvironments(on("env-1", "a1"), [], connected("env-1", "env-2"))).toEqual(
+ [],
+ );
+ });
+
+ it("leaves out a server that is not connected", () => {
+ expect(
+ resolvePickableEnvironments(
+ on("env-1", "a1"),
+ [
+ project("a1", "env-1", "github.com/acme/app"),
+ project("a2", "env-2", "github.com/acme/app"),
+ ],
+ connected("env-1"),
+ ),
+ ).toEqual([]);
+ });
+
+ it("names one copy per server, so two worktrees are one choice", () => {
+ const pickable = resolvePickableEnvironments(
+ on("env-1", "a1"),
+ [
+ project("a1", "env-1", "github.com/acme/app"),
+ project("a1-wt", "env-1", "github.com/acme/app"),
+ project("a2", "env-2", "github.com/acme/app"),
+ project("a2-wt", "env-2", "github.com/acme/app"),
+ ],
+ connected("env-1", "env-2"),
+ );
+ expect(pickable.map((entry) => entry.projectId)).toEqual(["a1", "a2"]);
+ });
+
+ it("keeps the panel's own worktree rather than the server's first copy", () => {
+ const pickable = resolvePickableEnvironments(
+ on("env-1", "a1-wt"),
+ [
+ project("a1", "env-1", "github.com/acme/app"),
+ project("a1-wt", "env-1", "github.com/acme/app"),
+ project("a2", "env-2", "github.com/acme/app"),
+ ],
+ connected("env-1", "env-2"),
+ );
+ expect(pickable[0]).toEqual({
+ environmentId: "env-1",
+ projectId: "a1-wt",
+ workspaceRoot: "/srv/env-1/a1-wt",
+ label: "Server env-1",
+ });
+ });
+});
diff --git a/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts b/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts
index b9aaf2d1989..c546f56fee9 100644
--- a/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts
+++ b/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts
@@ -8,9 +8,16 @@ export interface AssignableProject {
}
/**
- * Two servers can hold the same repository, and both would list the same pull requests. The
- * remote's normalized URL (`canonicalKey`) is what says "same repository" across machines — it
- * comes from the remote, not from a local path — so it is what one copy is picked by.
+ * The remote's normalized URL is what says "same repository" across machines — it comes from the
+ * remote, not from a local path. Empty where the project has no identity to compare with.
+ */
+function repositoryKey(project: AssignableProject): string | undefined {
+ return project.repositoryIdentity?.canonicalKey?.toLowerCase();
+}
+
+/**
+ * Two servers can hold the same repository, and both would list the same pull requests, so one
+ * copy is picked by its repository key.
*
* A project with no identity is never de-duplicated: nothing proves it is a copy of anything, and
* dropping it would lose its rows outright.
@@ -24,7 +31,7 @@ export function assignProjectsToEnvironments(
// Which server lists each repository: the preferred one where it has it, else the first.
const owner = new Map();
for (const project of projects) {
- const key = project.repositoryIdentity?.canonicalKey?.toLowerCase();
+ const key = repositoryKey(project);
if (!key) continue;
const environmentRank = rank.get(project.environmentId);
if (environmentRank === undefined) continue;
@@ -44,7 +51,7 @@ export function assignProjectsToEnvironments(
const assignment = new Map();
for (const project of projects) {
if (!rank.has(project.environmentId)) continue;
- const key = project.repositoryIdentity?.canonicalKey?.toLowerCase();
+ const key = repositoryKey(project);
if (key && owner.get(key) !== project.environmentId) continue;
const listed = assignment.get(project.environmentId);
if (listed === undefined) assignment.set(project.environmentId, [project.id]);
@@ -52,3 +59,69 @@ export function assignProjectsToEnvironments(
}
return assignment;
}
+
+/** A copy of the repository the reader could act on, named by the server holding it. */
+export interface PickableEnvironment {
+ readonly environmentId: EnvironmentId;
+ readonly projectId: ProjectId;
+ readonly workspaceRoot: string;
+ readonly label: string;
+}
+
+/**
+ * Which servers a pull request could be acted on, given the one it is listed under.
+ *
+ * Listing picks a single server per repository so each pull request appears once; acting is a
+ * different question. Checking the branch out, or handing it to a thread, happens wherever the
+ * reader means to work — and the copy the listing happened not to pick is just as real.
+ *
+ * Empty where there is no choice to offer: one server, no repository identity to match copies by,
+ * or projects not read yet. The caller renders nothing then, so a lone server keeps the surface it
+ * has always had.
+ */
+export function resolvePickableEnvironments(
+ current: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId },
+ projects: ReadonlyArray,
+ environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }>,
+): ReadonlyArray {
+ const own = projects.find(
+ (project) =>
+ project.environmentId === current.environmentId && project.id === current.projectId,
+ );
+ const key = own === undefined ? undefined : repositoryKey(own);
+ const ownLabel = environments.find(
+ (environment) => environment.environmentId === current.environmentId,
+ )?.label;
+ if (own === undefined || !key || ownLabel === undefined) return [];
+ const others = environments.flatMap((environment) => {
+ if (environment.environmentId === current.environmentId) return [];
+ // One entry per server, whichever copy comes first: a server holding two worktrees of the
+ // repository is still one place to act, and what is being picked here is the server.
+ const copy = projects.find(
+ (project) =>
+ project.environmentId === environment.environmentId && repositoryKey(project) === key,
+ );
+ return copy === undefined
+ ? []
+ : [
+ {
+ environmentId: environment.environmentId,
+ projectId: copy.id,
+ workspaceRoot: copy.workspaceRoot,
+ label: environment.label,
+ },
+ ];
+ });
+ if (others.length === 0) return [];
+ // The panel's own server first: it is what everything else on the panel is showing, so it is
+ // also what acting means until the reader says otherwise.
+ return [
+ {
+ environmentId: current.environmentId,
+ projectId: own.id,
+ workspaceRoot: own.workspaceRoot,
+ label: ownLabel,
+ },
+ ...others,
+ ];
+}
From 2462e9e144246a1597a4fe494e59c7978a4f43de Mon Sep 17 00:00:00 2001
From: Bil0000 <62337003+Bil0000@users.noreply.github.com>
Date: Tue, 11 Aug 2026 16:22:41 +0000
Subject: [PATCH 41/41] feat(web): pick which connection the detail panel acts
on
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Where more than one connected server holds the pull request's
repository, the check-out menu and the hand-off menu each offer the
servers by label, the panel's own first. The choice moves all three
things a hand-off touches — the prepare call, the thread it opens and
the composer the task lands in — onto that server's copy of the project.
Offered only on the page: beside a thread the hand-offs land in that
thread's own composer, which is already on one server. With a single
server holding the repository nothing is rendered, and the choice is
forgotten as soon as the panel shows another pull request.
---
.../pullRequest/PullRequestDetailPanel.tsx | 104 +++++++++++++++++-
1 file changed, 100 insertions(+), 4 deletions(-)
diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
index 16033afc0fe..b9d9f2925e4 100644
--- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
+++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
@@ -31,6 +31,7 @@ import {
MoreHorizontalIcon,
PanelRightIcon,
RefreshCwIcon,
+ ServerIcon,
TriangleAlertIcon,
} from "lucide-react";
import {
@@ -51,6 +52,8 @@ import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";
import { cn } from "~/lib/utils";
import { readLocalApi } from "~/localApi";
import type { ReviewCommentContext } from "~/reviewCommentContext";
+import { useProjects } from "~/state/entities";
+import { useEnvironments } from "~/state/environments";
import { useEnvironmentQuery } from "~/state/query";
import { useLiveRefresh } from "~/hooks/useLiveRefresh";
import { pullRequestEnvironment } from "~/state/pullRequests";
@@ -99,6 +102,10 @@ import {
resolveBaseFreshness,
type PullRequestFinding,
} from "./pullRequestDetail.logic";
+import {
+ resolvePickableEnvironments,
+ type PickableEnvironment,
+} from "./pullRequestProjectAssignment.logic";
import { PullRequestChecksPopover } from "./PullRequestChecksPopover";
import {
PullRequestActorLabel,
@@ -175,6 +182,49 @@ const lastHandoffPromptByDraft = new Map();
const composerTargetKey = (target: ScopedThreadRef | DraftId): string =>
typeof target === "string" ? target : scopedThreadKey(target);
+/**
+ * Which server the checkout and the hand-offs land on, where more than one of them holds this
+ * repository. The list picked one of them to show the pull request under, so that everything on
+ * it is read from somewhere; where the reader wants to work is a separate answer, and this is
+ * where they give it.
+ */
+function ActOnEnvironmentPicker({
+ environments,
+ value,
+ onChange,
+ disabled,
+}: {
+ environments: ReadonlyArray;
+ value: EnvironmentId;
+ onChange: (environmentId: EnvironmentId) => void;
+ disabled: boolean;
+}) {
+ return (
+ <>
+
+ onChange(environmentId as EnvironmentId)}
+ >
+ {environments.map((environment) => (
+
+ {/* The radio item lays its children out as one block, so the icon and the label
+ need their own row to share a line. */}
+
+
+ {environment.label}
+
+
+ ))}
+
+ >
+ );
+}
+
export function PullRequestDetailPanel({
environmentId,
reference,
@@ -377,9 +427,37 @@ export function PullRequestDetailPanel({
const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false });
const [actionPending, setActionPending] = useState(false);
const newThread = useNewThreadHandler();
+ const { environments } = useEnvironments();
+ const projects = useProjects();
+ // Beside a thread there is nothing to pick: the hand-offs land in that thread's composer, and
+ // the thread is already on one server's copy of the branch.
+ const pickableEnvironments = useMemo(
+ () =>
+ context === "page"
+ ? resolvePickableEnvironments(
+ { environmentId, projectId: reference.projectId },
+ projects,
+ environments,
+ )
+ : [],
+ [context, environmentId, environments, projects, reference.projectId],
+ );
+ // Which server the reader chose, and only for the pull request they chose it on: this one panel
+ // shows a different pull request every time it is opened, and the choice does not follow.
+ const [actingScope, setActingScope] = useState<{
+ readonly pullRequestKey: string;
+ readonly environmentId: EnvironmentId;
+ } | null>(null);
+ const chosenEnvironmentId =
+ actingScope?.pullRequestKey === pullRequestKey ? actingScope.environmentId : environmentId;
+ // Null wherever there is no choice on offer — one server, or a chosen one that has since gone —
+ // and then the panel's own server and its own checkout are the answer, as they always were.
+ const acting =
+ pickableEnvironments.find((entry) => entry.environmentId === chosenEnvironmentId) ?? null;
+ const actingEnvironmentId = acting?.environmentId ?? environmentId;
const prepareThread = usePreparePullRequestThreadAction({
- environmentId,
- cwd: detail?.workspaceRoot ?? null,
+ environmentId: actingEnvironmentId,
+ cwd: acting?.workspaceRoot ?? detail?.workspaceRoot ?? null,
});
const perform = async (
@@ -487,7 +565,7 @@ export function PullRequestDetailPanel({
return;
}
setHandoff(kind);
- const projectRef = scopeProjectRef(environmentId, detail.projectId);
+ const projectRef = scopeProjectRef(actingEnvironmentId, acting?.projectId ?? detail.projectId);
const opened = await openThreadWithTask(projectRef, task);
setHandoff(null);
if (opened === null) {
@@ -539,7 +617,9 @@ export function PullRequestDetailPanel({
type: "loading",
title: "Preparing the pull request checkout...",
});
- const projectRef = scopeProjectRef(environmentId, detail.projectId);
+ // Wherever the reader chose to act: the thread, the checkout it is pointed at and the composer
+ // the task lands in are all one server's, and picking another one moves all three.
+ const projectRef = scopeProjectRef(actingEnvironmentId, acting?.projectId ?? detail.projectId);
// The thread is opened before the checkout rather than after it, because the project's setup
// script only runs for a checkout that knows which thread it is for — and a worktree with no
// dependencies installed is not something anyone can test.
@@ -910,6 +990,14 @@ export function PullRequestDetailPanel({
{handoff === "findings" ? "Preparing..." : "Fix findings in a thread"}
+ {pickableEnvironments.length > 0 ? (
+ setActingScope({ pullRequestKey, environmentId: next })}
+ disabled={handoff !== null}
+ />
+ ) : null}
{detail.state === "open" ? (
<>
@@ -1043,6 +1131,14 @@ export function PullRequestDetailPanel({
+ {pickableEnvironments.length > 0 ? (
+
setActingScope({ pullRequestKey, environmentId: next })}
+ disabled={handoff !== null}
+ />
+ ) : null}
) : null}