authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-10-14 10:50:04-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 17:50:04+00:00
log464fad9a433c246b4a5b73c8640787c174c2c195
treea512c96ffeb9567158fb5e15409bd02bd6259409
parentcb395ee13b5b5ddd4352e7c3d4216181501cc2e5
signature Signed by PGP key B5690EEEBB952194

fix(web,mobile): pull request project filter follows the sidebar order (#19180)


6 files changed, 162 insertions(+), 45 deletions(-)

apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx+33-3
......@@ -1,6 +1,11 @@
1import { sortProjectsByGroupOrder } from "@t3tools/client-runtime/state/project-grouping";
2import { useMemo } from "react";
13import { ScrollView } from "react-native";
24import type { ControlPillMenuProps } from "../../components/ControlPillMenu.types";
35import { ListFilterMenu } from "../workspace/ListFilterMenu";
6import { useProjects, useThreadShells } from "../../state/entities";
7import { useMobileProjectGroupingSettings } from "../../state/project-grouping";
8import { buildHomeProjectScopes, sortHomeProjectScopes } from "../home/homeThreadList";
49import { DEFAULT_PR_LIST_CONTROLS, type PullRequestListControls } from "./pullRequestList";
510
611const STATES = { open: "Open", closed: "Closed", merged: "Merged", all: "All states" } as const;
......@@ -28,6 +33,30 @@ function choices(field: string, options: Readonly<Record<string, string>>, selec
2833 }));
2934}
3035
36/** The project list's own order, which is the order its sidebar and new-task picker show. */
37function useProjectsInSidebarOrder<Project extends { id: string; environmentId: string }>(
38 projects: readonly Project[],
39) {
40 const allProjects = useProjects();
41 const threads = useThreadShells();
42 const { sidebarProjectGroupingMode } = useMobileProjectGroupingSettings();
43 const scopes = useMemo(
44 () =>
45 sortHomeProjectScopes({
46 scopes: buildHomeProjectScopes({
47 projects: allProjects,
48 environmentId: null,
49 projectGroupingMode: sidebarProjectGroupingMode,
50 }),
51 threads,
52 pendingTasks: [],
53 projectSortOrder: "updated_at",
54 }).map((scope) => ({ memberProjectRefs: scope.projectRefs })),
55 [allProjects, sidebarProjectGroupingMode, threads],
56 );
57 return useMemo(() => sortProjectsByGroupOrder(projects, scopes), [projects, scopes]);
58}
59
3160export function PullRequestListFilters(props: {
3261 readonly environments: readonly { id: string; label: string }[];
3362 readonly environmentId: string | null;
......@@ -39,6 +68,7 @@ export function PullRequestListFilters(props: {
3968 readonly projects: readonly { id: string; title: string; environmentId: string }[];
4069}) {
4170 const { value } = props;
71 const projects = useProjectsInSidebarOrder(props.projects);
4272 const selectedLabels = value.filters.labels?.flat() ?? [];
4373 const selectedLabelSet = new Set(selectedLabels);
4474 const author = value.filters.author;
......@@ -76,7 +106,7 @@ export function PullRequestListFilters(props: {
76106 props.onChange({ ...value, sort: next as PullRequestListControls["sort"] });
77107 break;
78108 case "project": {
79 const project = props.projects.find(
109 const project = projects.find(
80110 (project) => JSON.stringify([project.environmentId, project.id]) === next,
81111 );
82112 props.onChange({
......@@ -165,7 +195,7 @@ export function PullRequestListFilters(props: {
165195 {
166196 id: "projects",
167197 title:
168 props.projects.find(
198 projects.find(
169199 (project) =>
170200 project.id === value.projectId && project.environmentId === value.projectEnvironmentId,
171201 )?.title ?? "Project",
......@@ -173,7 +203,7 @@ export function PullRequestListFilters(props: {
173203 "project",
174204 Object.fromEntries([
175205 ["", "All projects"],
176 ...props.projects.map((project) => [
206 ...projects.map((project) => [
177207 JSON.stringify([project.environmentId, project.id]),
178208 project.title,
179209 ]),
apps/web/src/components/Sidebar.tsx+4-41
......@@ -128,21 +128,21 @@ import { isMacPlatform } from "~/lib/utils";
128128import { useOpenPrLink } from "../lib/openPullRequestLink";
129129import { readLocalApi } from "../localApi";
130130import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";
131import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";
131import { selectProjectGroupingSettings } from "../logicalProject";
132132import {
133 buildSidebarProjectSnapshots,
134133 projectGroupsSpanEnvironments,
135134 resolveSidebarProjectScopeKey,
136135 getSidebarProjectSettingsKey,
137136 type SidebarProjectSnapshot,
138137} from "../sidebarProjectGrouping";
139import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
138import { useUiStateStore } from "../uiStateStore";
140139import {
141140 getThreadKeysToDeselectAfterDelete,
142141 useThreadSelectionStore,
143142} from "../threadSelectionStore";
144143import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions";
145144import { useHandleNewThread } from "../hooks/useHandleNewThread";
145import { useSidebarProjectGroups } from "../hooks/useSidebarProjectGroups";
146146import { useTerminalFocus } from "../hooks/useTerminalFocus";
147147import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
148148import { registerSettleAndAdvance } from "../settleAndAdvance";
......@@ -224,7 +224,6 @@ import {
224224 sidebarThreadKeyAtY,
225225 sortInboxThreadsByReturn,
226226 sortPinnedThreadsForSidebar,
227 sortSidebarV2ProjectGroups,
228227 sortThreadsForSidebar,
229228 sortWorkingThreadsBySend,
230229 useThreadJumpHintVisibility,
......@@ -2420,14 +2419,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
24202419
24212420export default function Sidebar() {
24222421 const projects = useProjects();
2423 const projectOrder = useUiStateStore((store) => store.projectOrder);
24242422 const threads = useThreadShells();
24252423 const router = useRouter();
24262424 const { isMobile, setOpenMobile } = useSidebar();
24272425 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
24282426 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
24292427 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
2430 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
24312428 const timestampFormat = useClientSettings((s) => s.timestampFormat);
24322429 const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled);
24332430 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
......@@ -2545,41 +2542,7 @@ export default function Sidebar() {
25452542 [environments],
25462543 );
25472544 const environmentMachineById = useEnvironmentMachines();
2548 const orderedProjects = useMemo(
2549 () =>
2550 orderItemsByPreferredIds({
2551 items: projects,
2552 preferredIds: projectOrder,
2553 getId: getProjectOrderKey,
2554 getPreferenceIds: (project) => [
2555 getProjectOrderKey(project),
2556 legacyProjectCwdPreferenceKey(project.workspaceRoot),
2557 ],
2558 }),
2559 [projectOrder, projects],
2560 );
2561 const unsortedProjectGroups = useMemo(
2562 () =>
2563 buildSidebarProjectSnapshots({
2564 projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects,
2565 groupScratchProjects: true,
2566 settings: projectGroupingSettings,
2567 primaryEnvironmentId,
2568 resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null,
2569 }),
2570 [
2571 environmentLabelById,
2572 orderedProjects,
2573 primaryEnvironmentId,
2574 projectGroupingSettings,
2575 projects,
2576 sidebarProjectSortOrder,
2577 ],
2578 );
2579 const projectGroups = useMemo(
2580 () => sortSidebarV2ProjectGroups(unsortedProjectGroups, threads, sidebarProjectSortOrder),
2581 [sidebarProjectSortOrder, threads, unsortedProjectGroups],
2582 );
2545 const projectGroups = useSidebarProjectGroups();
25832546 const projectGroupsRef = useRef(projectGroups);
25842547 projectGroupsRef.current = projectGroups;
25852548 // Threads on non-primary environments (T3 Connect, hosted) resolve their
apps/web/src/hooks/useSidebarProjectGroups.ts created+49
......@@ -0,0 +1,49 @@
1import { useMemo } from "react";
2
3import { orderItemsByPreferredIds, sortSidebarV2ProjectGroups } from "../components/Sidebar.logic";
4import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";
5import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping";
6import { useEnvironmentIdentities, usePrimaryEnvironmentId } from "../state/environments";
7import { useProjects, useThreadShells } from "../state/entities";
8import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
9import { useClientSettings } from "./useSettings";
10
11/** The sidebar's project groups, in the order the sidebar shows them. */
12export function useSidebarProjectGroups() {
13 const projects = useProjects();
14 const threads = useThreadShells();
15 const projectOrder = useUiStateStore((store) => store.projectOrder);
16 const sortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
17 const groupingSettings = useClientSettings(selectProjectGroupingSettings);
18 const environments = useEnvironmentIdentities();
19 const primaryEnvironmentId = usePrimaryEnvironmentId();
20 const orderedProjects = useMemo(
21 () =>
22 orderItemsByPreferredIds({
23 items: projects,
24 preferredIds: projectOrder,
25 getId: getProjectOrderKey,
26 getPreferenceIds: (project) => [
27 getProjectOrderKey(project),
28 legacyProjectCwdPreferenceKey(project.workspaceRoot),
29 ],
30 }),
31 [projectOrder, projects],
32 );
33 const unsortedGroups = useMemo(() => {
34 const labels = new Map(
35 environments.map((environment) => [environment.environmentId, environment.label] as const),
36 );
37 return buildSidebarProjectSnapshots({
38 projects: sortOrder === "manual" ? orderedProjects : projects,
39 groupScratchProjects: true,
40 settings: groupingSettings,
41 primaryEnvironmentId,
42 resolveEnvironmentLabel: (environmentId) => labels.get(environmentId) ?? null,
43 });
44 }, [environments, groupingSettings, orderedProjects, primaryEnvironmentId, projects, sortOrder]);
45 return useMemo(
46 () => sortSidebarV2ProjectGroups(unsortedGroups, threads, sortOrder),
47 [sortOrder, threads, unsortedGroups],
48 );
49}
apps/web/src/routes/_chat.pull-requests.tsx+16-1
......@@ -5,6 +5,7 @@ import type { PullRequestSpeedActionResult } from "~/components/pullRequest/Pull
55import { usePullRequestCloseBatch } from "~/components/pullRequest/usePullRequestActions";
66import { SidebarPointerSensor } from "~/components/Sidebar.pointer";
77import { resolveSidebarSweepKeys } from "~/components/Sidebar.logic";
8import { sortProjectsByGroupOrder } from "@t3tools/client-runtime/state/project-grouping";
89import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts";
910import type {
1011 EnvironmentId,
......@@ -45,6 +46,7 @@ import {
4546 useMemo,
4647 useRef,
4748 useState,
49 type ComponentProps,
4850 type ReactNode,
4951 type RefObject,
5052} from "react";
......@@ -154,6 +156,7 @@ import {
154156} from "../rightPanelStore";
155157import { useDebouncedValue } from "../state/queries";
156158import { useAllEnvironmentShellsBootstrapped, useProjects } from "../state/entities";
159import { useSidebarProjectGroups } from "../hooks/useSidebarProjectGroups";
157160import { useEnvironments } from "../state/environments";
158161import {
159162 pullRequestEnvironment,
......@@ -2061,7 +2064,7 @@ function PullRequestsRouteView() {
20612064 />
20622065 );
20632066 const filtersMenu = (
2064 <PullRequestFiltersMenu
2067 <PullRequestFiltersMenuInSidebarOrder
20652068 onOpenChange={setFiltersOpen}
20662069 state={search.state}
20672070 stateOptions={STATE_TABS}
......@@ -2389,6 +2392,18 @@ function PullRequestsRouteView() {
23892392}
23902393
23912394/** A compact stand-in for one pill group when the header is narrow. */
2395/** Lists the projects in the sidebar's order; it re-renders with thread activity, so the page does not. */
2396function PullRequestFiltersMenuInSidebarOrder(
2397 props: ComponentProps<typeof PullRequestFiltersMenu>,
2398) {
2399 const sidebarProjectGroups = useSidebarProjectGroups();
2400 const projects = useMemo(
2401 () => sortProjectsByGroupOrder(props.projects, sidebarProjectGroups),
2402 [props.projects, sidebarProjectGroups],
2403 );
2404 return <PullRequestFiltersMenu {...props} projects={projects} />;
2405}
2406
23922407function CompactFilterMenu<Value extends string>({
23932408 label,
23942409 triggerIcon,
packages/client-runtime/src/state/projectGrouping.test.ts+38
......@@ -8,6 +8,7 @@ import {
88 deriveLogicalProjectKeyFromSettings,
99 derivePhysicalProjectKey,
1010 resolveScratchProjectScopeKey,
11 sortProjectsByGroupOrder,
1112 type ProjectGroupingSettings,
1213} from "./projectGrouping.ts";
1314
......@@ -445,3 +446,40 @@ describe("buildProjectGroups", () => {
445446 expect(groups[0]?.members.map((member) => member.project.id)).toEqual(["winner", "sibling"]);
446447 });
447448});
449
450describe("sortProjectsByGroupOrder", () => {
451 const remote = EnvironmentId.make("remote");
452 const ref = (projectId: string, env = environmentId) => ({
453 environmentId: env,
454 projectId: ProjectId.make(projectId),
455 });
456 const project = (id: string, env: string = environmentId) => ({ id, environmentId: env });
457
458 it("follows the group order, keeping ungrouped projects last in their own order", () => {
459 const sorted = sortProjectsByGroupOrder(
460 [
461 project("alpha"),
462 project("zeta"),
463 project("orphan-b"),
464 project("beta"),
465 project("orphan-a"),
466 ],
467 [{ memberProjectRefs: [ref("zeta")] }, { memberProjectRefs: [ref("beta"), ref("alpha")] }],
468 );
469 expect(sorted.map((entry) => entry.id)).toEqual([
470 "zeta",
471 "alpha",
472 "beta",
473 "orphan-b",
474 "orphan-a",
475 ]);
476 });
477
478 it("tells the same project id on two environments apart", () => {
479 const sorted = sortProjectsByGroupOrder(
480 [project("shared"), project("shared", remote)],
481 [{ memberProjectRefs: [ref("shared", remote)] }, { memberProjectRefs: [ref("shared")] }],
482 );
483 expect(sorted.map((entry) => entry.environmentId)).toEqual([remote, environmentId]);
484 });
485});
packages/client-runtime/src/state/projectGrouping.ts+22
......@@ -376,3 +376,25 @@ export function buildProjectGroups<TProject extends EnvironmentProject>(input: {
376376 };
377377 });
378378}
379
380/**
381 * Orders projects the way a sorted group list shows them: by the first group holding each
382 * project. Projects in no group follow, and ties keep their incoming order.
383 */
384export function sortProjectsByGroupOrder<
385 TProject extends { readonly id: string; readonly environmentId: string },
386>(
387 projects: ReadonlyArray<TProject>,
388 groups: ReadonlyArray<{ readonly memberProjectRefs: ReadonlyArray<ScopedProjectRef> }>,
389): TProject[] {
390 const rankByProject = new Map<string, number>();
391 groups.forEach((group, rank) => {
392 for (const ref of group.memberProjectRefs) {
393 const key = `${ref.environmentId}\0${ref.projectId}`;
394 if (!rankByProject.has(key)) rankByProject.set(key, rank);
395 }
396 });
397 const rankOf = (project: TProject) =>
398 rankByProject.get(`${project.environmentId}\0${project.id}`) ?? groups.length;
399 return [...projects].sort((left, right) => rankOf(left) - rankOf(right));
400}