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 {...@@ -37,6 +37,7 @@ import {
37 MenuPopup,37 MenuPopup,
38 MenuRadioGroup,38 MenuRadioGroup,
39 MenuRadioItem,39 MenuRadioItem,
40 MenuRadioItemIndicator,
40 MenuSeparator,41 MenuSeparator,
41 MenuSub,42 MenuSub,
42 MenuSubPopup,43 MenuSubPopup,
...@@ -205,6 +206,7 @@ function PullRequestFilterRadioGroup<Value extends string>({...@@ -205,6 +206,7 @@ function PullRequestFilterRadioGroup<Value extends string>({
205 <PullRequestFilterOptionIcon option={option} />206 <PullRequestFilterOptionIcon option={option} />
206 <span className="min-w-0 flex-1 truncate">{option.label}</span>207 <span className="min-w-0 flex-1 truncate">{option.label}</span>
207 {option.unavailable ? <span className="shrink-0">· Unavailable</span> : null}208 {option.unavailable ? <span className="shrink-0">· Unavailable</span> : null}
209 <MenuRadioItemIndicator />
208 </span>210 </span>
209 </MenuRadioItem>211 </MenuRadioItem>
210 );212 );
...@@ -455,8 +457,8 @@ export function PullRequestFiltersMenu({...@@ -455,8 +457,8 @@ export function PullRequestFiltersMenu({
455 readonly environmentId: EnvironmentId;457 readonly environmentId: EnvironmentId;
456 readonly title: string;458 readonly title: string;
457 readonly workspaceRoot: string;459 readonly workspaceRoot: string;
458 readonly faviconPath?: string | null;460 readonly faviconPath?: string | null | undefined;
459 readonly projectIcon?: ProjectIconOverride | null;461 readonly projectIcon?: ProjectIconOverride | null | undefined;
460 }>;462 }>;
461 projectId: ProjectId | undefined;463 projectId: ProjectId | undefined;
462 /**464 /**
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 @@...@@ -1,7 +1,7 @@
1"use client";1"use client";
22
3import { Menu as MenuPrimitive } from "@base-ui/react/menu";3import { Menu as MenuPrimitive } from "@base-ui/react/menu";
4import { ChevronRightIcon } from "lucide-react";4import { CheckIcon, ChevronRightIcon } from "lucide-react";
5import type * as React from "react";5import type * as React from "react";
66
7import { cn } from "~/lib/utils";7import { cn } from "~/lib/utils";
...@@ -177,6 +177,23 @@ function MenuRadioItem({...@@ -177,6 +177,23 @@ function MenuRadioItem({
177 );177 );
178}178}
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
180function MenuGroupLabel({197function MenuGroupLabel({
181 className,198 className,
182 inset,199 inset,
...@@ -300,6 +317,7 @@ export {...@@ -300,6 +317,7 @@ export {
300 MenuRadioGroup as DropdownMenuRadioGroup,317 MenuRadioGroup as DropdownMenuRadioGroup,
301 MenuRadioItem,318 MenuRadioItem,
302 MenuRadioItem as DropdownMenuRadioItem,319 MenuRadioItem as DropdownMenuRadioItem,
320 MenuRadioItemIndicator,
303 MenuGroupLabel,321 MenuGroupLabel,
304 MenuGroupLabel as DropdownMenuLabel,322 MenuGroupLabel as DropdownMenuLabel,
305 MenuSeparator,323 MenuSeparator,
apps/web/src/routes/_chat.pull-requests.tsx+5-21
...@@ -86,6 +86,7 @@ import {...@@ -86,6 +86,7 @@ import {
86 writePullRequestListPreferences,86 writePullRequestListPreferences,
87} from "../components/pullRequest/pullRequestListPreferences";87} from "../components/pullRequest/pullRequestListPreferences";
88import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic";88import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic";
89import { pullRequestFilterProjects } from "../components/pullRequest/pullRequestProjectFilter.logic";
89import { environmentMachineIcon } from "../components/EnvironmentMachineIcon";90import { environmentMachineIcon } from "../components/EnvironmentMachineIcon";
90import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel";91import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel";
91import {92import {
...@@ -356,27 +357,6 @@ function PullRequestsRouteView() {...@@ -356,27 +357,6 @@ function PullRequestsRouteView() {
356 ),357 ),
357 [environments],358 [environments],
358 );359 );
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]);
380 // The scope the URL asks for, once the environments have had their say about whether it exists.360 // The scope the URL asks for, once the environments have had their say about whether it exists.
381 const scopedProjectId = useMemo(361 const scopedProjectId = useMemo(
382 () => resolveProjectScope(search.projectId, projects, projectsKnown),362 () => resolveProjectScope(search.projectId, projects, projectsKnown),
...@@ -386,6 +366,10 @@ function PullRequestsRouteView() {...@@ -386,6 +366,10 @@ function PullRequestsRouteView() {
386 () => findScopedProject(projects, scopedEnvironmentId, scopedProjectId),366 () => findScopedProject(projects, scopedEnvironmentId, scopedProjectId),
387 [projects, scopedEnvironmentId, scopedProjectId],367 [projects, scopedEnvironmentId, scopedProjectId],
388 );368 );
369 const scopedProjects = useMemo(
370 () => pullRequestFilterProjects(projects, environmentLabels, scopedProject),
371 [environmentLabels, projects, scopedProject],
372 );
389373
390 // A link from a thread or the sidebar only knows the repository, so the owning project is374 // A link from a thread or the sidebar only knows the repository, so the owning project is
391 // resolved here; an explicit `projectId` in the URL still wins.375 // resolved here; an explicit `projectId` in the URL still wins.