authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 23:10:18-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 23:10:18-07:00
loge5a87e8b9ca9db21e0291ddbd54438c5fe56b277
tree029246a843de6fcedecc6dd78f1e8e498fd832d1
parent2fa5ef4c7bf3aafabe98392d25be7eb86847ce8f
signature Signed by PGP key B5690EEEBB952194

fix(web): deduplicate PR project filter choices (#9948)


5 files changed, 239 insertions(+), 24 deletions(-)

apps/web/src/components/pullRequest/PullRequestListFilters.tsx+4-2
......@@ -37,6 +37,7 @@ import {
3737 MenuPopup,
3838 MenuRadioGroup,
3939 MenuRadioItem,
40 MenuRadioItemIndicator,
4041 MenuSeparator,
4142 MenuSub,
4243 MenuSubPopup,
......@@ -205,6 +206,7 @@ function PullRequestFilterRadioGroup<Value extends string>({
205206 <PullRequestFilterOptionIcon option={option} />
206207 <span className="min-w-0 flex-1 truncate">{option.label}</span>
207208 {option.unavailable ? <span className="shrink-0">· Unavailable</span> : null}
209 <MenuRadioItemIndicator />
208210 </span>
209211 </MenuRadioItem>
210212 );
......@@ -455,8 +457,8 @@ export function PullRequestFiltersMenu({
455457 readonly environmentId: EnvironmentId;
456458 readonly title: string;
457459 readonly workspaceRoot: string;
458 readonly faviconPath?: string | null;
459 readonly projectIcon?: ProjectIconOverride | null;
460 readonly faviconPath?: string | null | undefined;
461 readonly projectIcon?: ProjectIconOverride | null | undefined;
460462 }>;
461463 projectId: ProjectId | undefined;
462464 /**
apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts created+158
......@@ -0,0 +1,158 @@
1import { EnvironmentId, ProjectId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { findScopedProject } from "./pullRequestList.logic";
5import { pullRequestFilterProjects } from "./pullRequestProjectFilter.logic";
6
7const cups = EnvironmentId.make("env-cups");
8const nucbox = EnvironmentId.make("env-nucbox");
9const labels = new Map([
10 [cups, "cups"],
11 [nucbox, "nucbox-1"],
12]);
13
14function project(
15 id: string,
16 environmentId = nucbox,
17 canonicalKey: string | null = "github.com/pingdotgg/t3code",
18) {
19 return {
20 id: ProjectId.make(id),
21 environmentId,
22 title: "t3code",
23 workspaceRoot: `/work/${id}`,
24 repositoryIdentity: canonicalKey === null ? null : { canonicalKey },
25 faviconPath: `${id}/favicon.png`,
26 };
27}
28
29describe("pull request project filter choices", () => {
30 it("collapses three checkouts on one server without dropping another server's copy", () => {
31 const projects = [
32 project("main"),
33 project("worktree-1"),
34 project("worktree-2"),
35 project("main", cups),
36 ];
37
38 const choices = pullRequestFilterProjects(projects, labels);
39
40 expect(choices.map(({ id, environmentId, title }) => ({ id, environmentId, title }))).toEqual([
41 { id: "main", environmentId: cups, title: "t3code · cups" },
42 { id: "main", environmentId: nucbox, title: "t3code · nucbox-1" },
43 ]);
44 expect(choices[1]?.workspaceRoot).toBe("/work/main");
45 expect(choices[1]?.faviconPath).toBe("main/favicon.png");
46 });
47
48 it("keeps a saved worktree selection as the repository's only choice", () => {
49 const projects = [project("main"), project("worktree"), project("worktree", cups)];
50 const selected = findScopedProject(projects, nucbox, "worktree");
51
52 const choices = pullRequestFilterProjects(projects, labels, selected);
53
54 expect(choices.filter((choice) => choice.environmentId === nucbox)).toEqual([
55 { ...projects[1], title: "t3code · nucbox-1" },
56 ]);
57 expect(findScopedProject(choices, nucbox, "worktree")).toBeDefined();
58 expect(findScopedProject(choices, nucbox, "main")).toBeUndefined();
59 expect(findScopedProject(choices, cups, "worktree")).toBeDefined();
60 });
61
62 it("matches canonical repositories regardless of casing", () => {
63 const main = project("main");
64 const worktree = project("worktree", nucbox, "GitHub.com/PingDotGG/T3Code");
65
66 expect(pullRequestFilterProjects([main, worktree], labels)).toEqual([main]);
67 });
68
69 it("does not add a server suffix after duplicate checkouts have collapsed", () => {
70 const main = project("main");
71
72 expect(pullRequestFilterProjects([main, project("worktree")], labels)).toEqual([main]);
73 expect(main.title).toBe("t3code");
74 });
75
76 it("distinguishes same-named repositories on one server by checkout path", () => {
77 const projects = [
78 project("upstream"),
79 project("fork", nucbox, "github.com/juliusmarminge/t3code"),
80 ];
81
82 const choices = pullRequestFilterProjects(projects, labels);
83
84 expect(choices.map((choice) => choice.title)).toEqual([
85 "t3code · nucbox-1 · /work/fork",
86 "t3code · nucbox-1 · /work/upstream",
87 ]);
88 });
89
90 it("keeps repositories on different hosts separate", () => {
91 const choices = pullRequestFilterProjects(
92 [project("github"), project("enterprise", nucbox, "git.example.com/pingdotgg/t3code")],
93 labels,
94 );
95
96 expect(choices.map((choice) => choice.id)).toEqual(["enterprise", "github"]);
97 });
98
99 it("does not merge projects whose repository identity is unknown", () => {
100 const choices = pullRequestFilterProjects(
101 [project("first", nucbox, null), project("second", nucbox, null)],
102 labels,
103 );
104
105 expect(choices.map((choice) => choice.title)).toEqual([
106 "t3code · nucbox-1 · /work/first",
107 "t3code · nucbox-1 · /work/second",
108 ]);
109 });
110
111 it("distinguishes servers with the same display name and checkout path", () => {
112 const first = project("main");
113 const second = project("main", cups);
114 const repeatedLabels = new Map([
115 [cups, "nucbox-1"],
116 [nucbox, "nucbox-1"],
117 ]);
118
119 const choices = pullRequestFilterProjects([first, second], repeatedLabels);
120
121 expect(choices.map((choice) => choice.title)).toEqual([
122 "t3code · nucbox-1 · /work/main · env-cups",
123 "t3code · nucbox-1 · /work/main · env-nucbox",
124 ]);
125 });
126
127 it("uses the environment id when its label is unavailable", () => {
128 const choices = pullRequestFilterProjects(
129 [project("main"), project("remote", cups)],
130 new Map(),
131 );
132
133 expect(choices.map((choice) => choice.title)).toEqual([
134 "t3code · env-cups",
135 "t3code · env-nucbox",
136 ]);
137 });
138
139 it("can distinguish unresolved project records that also share a checkout path", () => {
140 const first = project("first", nucbox, null);
141 const second = { ...project("second", nucbox, null), workspaceRoot: first.workspaceRoot };
142
143 const choices = pullRequestFilterProjects([first, second], labels);
144
145 expect(choices.map((choice) => choice.title)).toEqual([
146 "t3code · nucbox-1 · /work/first · env-nucbox · first",
147 "t3code · nucbox-1 · /work/first · env-nucbox · second",
148 ]);
149 });
150
151 it("leaves unrelated names unchanged and orders them alphabetically", () => {
152 const app = { ...project("app"), title: "Zebra" };
153 const tools = { ...project("tools", nucbox, "github.com/acme/tools"), title: "Alpha" };
154
155 expect(pullRequestFilterProjects([app, tools], labels)).toEqual([tools, app]);
156 expect(pullRequestFilterProjects([], labels)).toEqual([]);
157 });
158});
apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts created+53
......@@ -0,0 +1,53 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2
3import type { AssignableProject } from "./pullRequestProjectAssignment.logic";
4
5interface FilterProject extends AssignableProject {
6 readonly title: string;
7 readonly workspaceRoot: string;
8}
9
10function distinguishTitles<Project extends FilterProject>(
11 projects: ReadonlyArray<Project>,
12 suffix: (project: Project) => string,
13) {
14 const counts = new Map<string, number>();
15 for (const project of projects) {
16 counts.set(project.title, (counts.get(project.title) ?? 0) + 1);
17 }
18 return projects.map((project) =>
19 (counts.get(project.title) ?? 0) > 1
20 ? { ...project, title: `${project.title} · ${suffix(project)}` }
21 : project,
22 );
23}
24
25/** One choice per repository per server, retaining the selected checkout for saved scopes. */
26export function pullRequestFilterProjects<Project extends FilterProject>(
27 projects: ReadonlyArray<Project>,
28 environmentLabels: ReadonlyMap<EnvironmentId, string>,
29 selectedProject?: Pick<AssignableProject, "id" | "environmentId">,
30) {
31 const byRepository = new Map<string, Project>();
32 for (const project of projects) {
33 const repository = project.repositoryIdentity?.canonicalKey?.toLowerCase();
34 const key = JSON.stringify([
35 project.environmentId,
36 repository ? ["repository", repository] : ["project", project.id],
37 ]);
38 const selected =
39 project.id === selectedProject?.id && project.environmentId === selectedProject.environmentId;
40 if (!byRepository.has(key) || selected) byRepository.set(key, project);
41 }
42
43 const byServer = distinguishTitles(
44 [...byRepository.values()],
45 (project) => environmentLabels.get(project.environmentId) ?? project.environmentId,
46 );
47 const byPath = distinguishTitles(byServer, (project) => project.workspaceRoot);
48 // Separate environments can share both their display name and their checkout path.
49 const byEnvironment = distinguishTitles(byPath, (project) => project.environmentId);
50 return distinguishTitles(byEnvironment, (project) => project.id).toSorted((left, right) =>
51 left.title.localeCompare(right.title),
52 );
53}
apps/web/src/components/ui/menu.tsx+19-1
......@@ -1,7 +1,7 @@
11"use client";
22
33import { Menu as MenuPrimitive } from "@base-ui/react/menu";
4import { ChevronRightIcon } from "lucide-react";
4import { CheckIcon, ChevronRightIcon } from "lucide-react";
55import type * as React from "react";
66
77import { cn } from "~/lib/utils";
......@@ -177,6 +177,23 @@ function MenuRadioItem({
177177 );
178178}
179179
180function MenuRadioItemIndicator({
181 className,
182 children,
183 ...props
184}: MenuPrimitive.RadioItemIndicator.Props) {
185 return (
186 <MenuPrimitive.RadioItemIndicator
187 aria-hidden
188 className={cn("flex shrink-0", className)}
189 data-slot="menu-radio-item-indicator"
190 {...props}
191 >
192 {children ?? <CheckIcon className="size-3.5" />}
193 </MenuPrimitive.RadioItemIndicator>
194 );
195}
196
180197function MenuGroupLabel({
181198 className,
182199 inset,
......@@ -300,6 +317,7 @@ export {
300317 MenuRadioGroup as DropdownMenuRadioGroup,
301318 MenuRadioItem,
302319 MenuRadioItem as DropdownMenuRadioItem,
320 MenuRadioItemIndicator,
303321 MenuGroupLabel,
304322 MenuGroupLabel as DropdownMenuLabel,
305323 MenuSeparator,
apps/web/src/routes/_chat.pull-requests.tsx+5-21
......@@ -86,6 +86,7 @@ import {
8686 writePullRequestListPreferences,
8787} from "../components/pullRequest/pullRequestListPreferences";
8888import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic";
89import { pullRequestFilterProjects } from "../components/pullRequest/pullRequestProjectFilter.logic";
8990import { environmentMachineIcon } from "../components/EnvironmentMachineIcon";
9091import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel";
9192import {
......@@ -356,27 +357,6 @@ function PullRequestsRouteView() {
356357 ),
357358 [environments],
358359 );
359 const scopedProjects = useMemo(() => {
360 // Two machines can hold the same repository, so a title the workspace carries twice is told
361 // apart by the environment it lives on rather than left as two identical rows.
362 const titleCounts = new Map<string, number>();
363 for (const project of projects) {
364 titleCounts.set(project.title, (titleCounts.get(project.title) ?? 0) + 1);
365 }
366 return projects
367 .map((project) => ({
368 id: project.id,
369 environmentId: project.environmentId,
370 title:
371 (titleCounts.get(project.title) ?? 0) > 1
372 ? `${project.title} · ${environmentLabels.get(project.environmentId) ?? project.environmentId}`
373 : project.title,
374 workspaceRoot: project.workspaceRoot,
375 faviconPath: project.faviconPath ?? null,
376 projectIcon: project.projectIcon ?? null,
377 }))
378 .toSorted((left, right) => left.title.localeCompare(right.title));
379 }, [environmentLabels, projects]);
380360 // The scope the URL asks for, once the environments have had their say about whether it exists.
381361 const scopedProjectId = useMemo(
382362 () => resolveProjectScope(search.projectId, projects, projectsKnown),
......@@ -386,6 +366,10 @@ function PullRequestsRouteView() {
386366 () => findScopedProject(projects, scopedEnvironmentId, scopedProjectId),
387367 [projects, scopedEnvironmentId, scopedProjectId],
388368 );
369 const scopedProjects = useMemo(
370 () => pullRequestFilterProjects(projects, environmentLabels, scopedProject),
371 [environmentLabels, projects, scopedProject],
372 );
389373
390374 // A link from a thread or the sidebar only knows the repository, so the owning project is
391375 // resolved here; an explicit `projectId` in the URL still wins.