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 @@...@@ -1,6 +1,11 @@
1import { sortProjectsByGroupOrder } from "@t3tools/client-runtime/state/project-grouping";
2import { useMemo } from "react";
1import { ScrollView } from "react-native";3import { ScrollView } from "react-native";
2import type { ControlPillMenuProps } from "../../components/ControlPillMenu.types";4import type { ControlPillMenuProps } from "../../components/ControlPillMenu.types";
3import { ListFilterMenu } from "../workspace/ListFilterMenu";5import { ListFilterMenu } from "../workspace/ListFilterMenu";
6import { useProjects, useThreadShells } from "../../state/entities";
7import { useMobileProjectGroupingSettings } from "../../state/project-grouping";
8import { buildHomeProjectScopes, sortHomeProjectScopes } from "../home/homeThreadList";
4import { DEFAULT_PR_LIST_CONTROLS, type PullRequestListControls } from "./pullRequestList";9import { DEFAULT_PR_LIST_CONTROLS, type PullRequestListControls } from "./pullRequestList";
510
6const STATES = { open: "Open", closed: "Closed", merged: "Merged", all: "All states" } as const;11const 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...@@ -28,6 +33,30 @@ function choices(field: string, options: Readonly<Record<string, string>>, selec
28 }));33 }));
29}34}
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
31export function PullRequestListFilters(props: {60export function PullRequestListFilters(props: {
32 readonly environments: readonly { id: string; label: string }[];61 readonly environments: readonly { id: string; label: string }[];
33 readonly environmentId: string | null;62 readonly environmentId: string | null;
...@@ -39,6 +68,7 @@ export function PullRequestListFilters(props: {...@@ -39,6 +68,7 @@ export function PullRequestListFilters(props: {
39 readonly projects: readonly { id: string; title: string; environmentId: string }[];68 readonly projects: readonly { id: string; title: string; environmentId: string }[];
40}) {69}) {
41 const { value } = props;70 const { value } = props;
71 const projects = useProjectsInSidebarOrder(props.projects);
42 const selectedLabels = value.filters.labels?.flat() ?? [];72 const selectedLabels = value.filters.labels?.flat() ?? [];
43 const selectedLabelSet = new Set(selectedLabels);73 const selectedLabelSet = new Set(selectedLabels);
44 const author = value.filters.author;74 const author = value.filters.author;
...@@ -76,7 +106,7 @@ export function PullRequestListFilters(props: {...@@ -76,7 +106,7 @@ export function PullRequestListFilters(props: {
76 props.onChange({ ...value, sort: next as PullRequestListControls["sort"] });106 props.onChange({ ...value, sort: next as PullRequestListControls["sort"] });
77 break;107 break;
78 case "project": {108 case "project": {
79 const project = props.projects.find(109 const project = projects.find(
80 (project) => JSON.stringify([project.environmentId, project.id]) === next,110 (project) => JSON.stringify([project.environmentId, project.id]) === next,
81 );111 );
82 props.onChange({112 props.onChange({
...@@ -165,7 +195,7 @@ export function PullRequestListFilters(props: {...@@ -165,7 +195,7 @@ export function PullRequestListFilters(props: {
165 {195 {
166 id: "projects",196 id: "projects",
167 title:197 title:
168 props.projects.find(198 projects.find(
169 (project) =>199 (project) =>
170 project.id === value.projectId && project.environmentId === value.projectEnvironmentId,200 project.id === value.projectId && project.environmentId === value.projectEnvironmentId,
171 )?.title ?? "Project",201 )?.title ?? "Project",
...@@ -173,7 +203,7 @@ export function PullRequestListFilters(props: {...@@ -173,7 +203,7 @@ export function PullRequestListFilters(props: {
173 "project",203 "project",
174 Object.fromEntries([204 Object.fromEntries([
175 ["", "All projects"],205 ["", "All projects"],
176 ...props.projects.map((project) => [206 ...projects.map((project) => [
177 JSON.stringify([project.environmentId, project.id]),207 JSON.stringify([project.environmentId, project.id]),
178 project.title,208 project.title,
179 ]),209 ]),
apps/web/src/components/Sidebar.tsx+4-41
...@@ -128,21 +128,21 @@ import { isMacPlatform } from "~/lib/utils";...@@ -128,21 +128,21 @@ import { isMacPlatform } from "~/lib/utils";
128import { useOpenPrLink } from "../lib/openPullRequestLink";128import { useOpenPrLink } from "../lib/openPullRequestLink";
129import { readLocalApi } from "../localApi";129import { readLocalApi } from "../localApi";
130import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";130import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";
131import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";131import { selectProjectGroupingSettings } from "../logicalProject";
132import {132import {
133 buildSidebarProjectSnapshots,
134 projectGroupsSpanEnvironments,133 projectGroupsSpanEnvironments,
135 resolveSidebarProjectScopeKey,134 resolveSidebarProjectScopeKey,
136 getSidebarProjectSettingsKey,135 getSidebarProjectSettingsKey,
137 type SidebarProjectSnapshot,136 type SidebarProjectSnapshot,
138} from "../sidebarProjectGrouping";137} from "../sidebarProjectGrouping";
139import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";138import { useUiStateStore } from "../uiStateStore";
140import {139import {
141 getThreadKeysToDeselectAfterDelete,140 getThreadKeysToDeselectAfterDelete,
142 useThreadSelectionStore,141 useThreadSelectionStore,
143} from "../threadSelectionStore";142} from "../threadSelectionStore";
144import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions";143import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions";
145import { useHandleNewThread } from "../hooks/useHandleNewThread";144import { useHandleNewThread } from "../hooks/useHandleNewThread";
145import { useSidebarProjectGroups } from "../hooks/useSidebarProjectGroups";
146import { useTerminalFocus } from "../hooks/useTerminalFocus";146import { useTerminalFocus } from "../hooks/useTerminalFocus";
147import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";147import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
148import { registerSettleAndAdvance } from "../settleAndAdvance";148import { registerSettleAndAdvance } from "../settleAndAdvance";
...@@ -224,7 +224,6 @@ import {...@@ -224,7 +224,6 @@ import {
224 sidebarThreadKeyAtY,224 sidebarThreadKeyAtY,
225 sortInboxThreadsByReturn,225 sortInboxThreadsByReturn,
226 sortPinnedThreadsForSidebar,226 sortPinnedThreadsForSidebar,
227 sortSidebarV2ProjectGroups,
228 sortThreadsForSidebar,227 sortThreadsForSidebar,
229 sortWorkingThreadsBySend,228 sortWorkingThreadsBySend,
230 useThreadJumpHintVisibility,229 useThreadJumpHintVisibility,
...@@ -2420,14 +2419,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {...@@ -2420,14 +2419,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
24202419
2421export default function Sidebar() {2420export default function Sidebar() {
2422 const projects = useProjects();2421 const projects = useProjects();
2423 const projectOrder = useUiStateStore((store) => store.projectOrder);
2424 const threads = useThreadShells();2422 const threads = useThreadShells();
2425 const router = useRouter();2423 const router = useRouter();
2426 const { isMobile, setOpenMobile } = useSidebar();2424 const { isMobile, setOpenMobile } = useSidebar();
2427 const keybindings = useAtomValue(primaryServerKeybindingsAtom);2425 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
2428 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);2426 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
2429 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);2427 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
2430 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
2431 const timestampFormat = useClientSettings((s) => s.timestampFormat);2428 const timestampFormat = useClientSettings((s) => s.timestampFormat);
2432 const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled);2429 const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled);
2433 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);2430 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
...@@ -2545,41 +2542,7 @@ export default function Sidebar() {...@@ -2545,41 +2542,7 @@ export default function Sidebar() {
2545 [environments],2542 [environments],
2546 );2543 );
2547 const environmentMachineById = useEnvironmentMachines();2544 const environmentMachineById = useEnvironmentMachines();
2548 const orderedProjects = useMemo(2545 const projectGroups = useSidebarProjectGroups();
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 );
2583 const projectGroupsRef = useRef(projectGroups);2546 const projectGroupsRef = useRef(projectGroups);
2584 projectGroupsRef.current = projectGroups;2547 projectGroupsRef.current = projectGroups;
2585 // Threads on non-primary environments (T3 Connect, hosted) resolve their2548 // 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...@@ -5,6 +5,7 @@ import type { PullRequestSpeedActionResult } from "~/components/pullRequest/Pull
5import { usePullRequestCloseBatch } from "~/components/pullRequest/usePullRequestActions";5import { usePullRequestCloseBatch } from "~/components/pullRequest/usePullRequestActions";
6import { SidebarPointerSensor } from "~/components/Sidebar.pointer";6import { SidebarPointerSensor } from "~/components/Sidebar.pointer";
7import { resolveSidebarSweepKeys } from "~/components/Sidebar.logic";7import { resolveSidebarSweepKeys } from "~/components/Sidebar.logic";
8import { sortProjectsByGroupOrder } from "@t3tools/client-runtime/state/project-grouping";
8import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts";9import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts";
9import type {10import type {
10 EnvironmentId,11 EnvironmentId,
...@@ -45,6 +46,7 @@ import {...@@ -45,6 +46,7 @@ import {
45 useMemo,46 useMemo,
46 useRef,47 useRef,
47 useState,48 useState,
49 type ComponentProps,
48 type ReactNode,50 type ReactNode,
49 type RefObject,51 type RefObject,
50} from "react";52} from "react";
...@@ -154,6 +156,7 @@ import {...@@ -154,6 +156,7 @@ import {
154} from "../rightPanelStore";156} from "../rightPanelStore";
155import { useDebouncedValue } from "../state/queries";157import { useDebouncedValue } from "../state/queries";
156import { useAllEnvironmentShellsBootstrapped, useProjects } from "../state/entities";158import { useAllEnvironmentShellsBootstrapped, useProjects } from "../state/entities";
159import { useSidebarProjectGroups } from "../hooks/useSidebarProjectGroups";
157import { useEnvironments } from "../state/environments";160import { useEnvironments } from "../state/environments";
158import {161import {
159 pullRequestEnvironment,162 pullRequestEnvironment,
...@@ -2061,7 +2064,7 @@ function PullRequestsRouteView() {...@@ -2061,7 +2064,7 @@ function PullRequestsRouteView() {
2061 />2064 />
2062 );2065 );
2063 const filtersMenu = (2066 const filtersMenu = (
2064 <PullRequestFiltersMenu2067 <PullRequestFiltersMenuInSidebarOrder
2065 onOpenChange={setFiltersOpen}2068 onOpenChange={setFiltersOpen}
2066 state={search.state}2069 state={search.state}
2067 stateOptions={STATE_TABS}2070 stateOptions={STATE_TABS}
...@@ -2389,6 +2392,18 @@ function PullRequestsRouteView() {...@@ -2389,6 +2392,18 @@ function PullRequestsRouteView() {
2389}2392}
23902393
2391/** A compact stand-in for one pill group when the header is narrow. */2394/** 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
2392function CompactFilterMenu<Value extends string>({2407function CompactFilterMenu<Value extends string>({
2393 label,2408 label,
2394 triggerIcon,2409 triggerIcon,
packages/client-runtime/src/state/projectGrouping.test.ts+38
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8 deriveLogicalProjectKeyFromSettings,8 deriveLogicalProjectKeyFromSettings,
9 derivePhysicalProjectKey,9 derivePhysicalProjectKey,
10 resolveScratchProjectScopeKey,10 resolveScratchProjectScopeKey,
11 sortProjectsByGroupOrder,
11 type ProjectGroupingSettings,12 type ProjectGroupingSettings,
12} from "./projectGrouping.ts";13} from "./projectGrouping.ts";
1314
...@@ -445,3 +446,40 @@ describe("buildProjectGroups", () => {...@@ -445,3 +446,40 @@ describe("buildProjectGroups", () => {
445 expect(groups[0]?.members.map((member) => member.project.id)).toEqual(["winner", "sibling"]);446 expect(groups[0]?.members.map((member) => member.project.id)).toEqual(["winner", "sibling"]);
446 });447 });
447});448});
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: {...@@ -376,3 +376,25 @@ export function buildProjectGroups<TProject extends EnvironmentProject>(input: {
376 };376 };
377 });377 });
378}378}
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}