From 464fad9a433c246b4a5b73c8640787c174c2c195 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:50:04 -0700 Subject: [PATCH] fix(web,mobile): pull request project filter follows the sidebar order (#19180) --- .../pull-requests/PullRequestListFilters.tsx | 36 ++++++++++++-- apps/web/src/components/Sidebar.tsx | 45 ++--------------- apps/web/src/hooks/useSidebarProjectGroups.ts | 49 +++++++++++++++++++ apps/web/src/routes/_chat.pull-requests.tsx | 17 ++++++- .../src/state/projectGrouping.test.ts | 38 ++++++++++++++ .../src/state/projectGrouping.ts | 22 +++++++++ 6 files changed, 162 insertions(+), 45 deletions(-) create mode 100644 apps/web/src/hooks/useSidebarProjectGroups.ts diff --git a/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx b/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx index 069fe077a4c53ee342a993d59c9e1470f84076b3..24296142593dc05dde23f54ea8b2a62fbe9c1fbf 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx @@ -1,6 +1,11 @@ +import { sortProjectsByGroupOrder } from "@t3tools/client-runtime/state/project-grouping"; +import { useMemo } from "react"; import { ScrollView } from "react-native"; import type { ControlPillMenuProps } from "../../components/ControlPillMenu.types"; import { ListFilterMenu } from "../workspace/ListFilterMenu"; +import { useProjects, useThreadShells } from "../../state/entities"; +import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; +import { buildHomeProjectScopes, sortHomeProjectScopes } from "../home/homeThreadList"; import { DEFAULT_PR_LIST_CONTROLS, type PullRequestListControls } from "./pullRequestList"; const STATES = { open: "Open", closed: "Closed", merged: "Merged", all: "All states" } as const; @@ -28,6 +33,30 @@ function choices(field: string, options: Readonly>, selec })); } +/** The project list's own order, which is the order its sidebar and new-task picker show. */ +function useProjectsInSidebarOrder( + projects: readonly Project[], +) { + const allProjects = useProjects(); + const threads = useThreadShells(); + const { sidebarProjectGroupingMode } = useMobileProjectGroupingSettings(); + const scopes = useMemo( + () => + sortHomeProjectScopes({ + scopes: buildHomeProjectScopes({ + projects: allProjects, + environmentId: null, + projectGroupingMode: sidebarProjectGroupingMode, + }), + threads, + pendingTasks: [], + projectSortOrder: "updated_at", + }).map((scope) => ({ memberProjectRefs: scope.projectRefs })), + [allProjects, sidebarProjectGroupingMode, threads], + ); + return useMemo(() => sortProjectsByGroupOrder(projects, scopes), [projects, scopes]); +} + export function PullRequestListFilters(props: { readonly environments: readonly { id: string; label: string }[]; readonly environmentId: string | null; @@ -39,6 +68,7 @@ export function PullRequestListFilters(props: { readonly projects: readonly { id: string; title: string; environmentId: string }[]; }) { const { value } = props; + const projects = useProjectsInSidebarOrder(props.projects); const selectedLabels = value.filters.labels?.flat() ?? []; const selectedLabelSet = new Set(selectedLabels); const author = value.filters.author; @@ -76,7 +106,7 @@ export function PullRequestListFilters(props: { props.onChange({ ...value, sort: next as PullRequestListControls["sort"] }); break; case "project": { - const project = props.projects.find( + const project = projects.find( (project) => JSON.stringify([project.environmentId, project.id]) === next, ); props.onChange({ @@ -165,7 +195,7 @@ export function PullRequestListFilters(props: { { id: "projects", title: - props.projects.find( + projects.find( (project) => project.id === value.projectId && project.environmentId === value.projectEnvironmentId, )?.title ?? "Project", @@ -173,7 +203,7 @@ export function PullRequestListFilters(props: { "project", Object.fromEntries([ ["", "All projects"], - ...props.projects.map((project) => [ + ...projects.map((project) => [ JSON.stringify([project.environmentId, project.id]), project.title, ]), diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d280ba557689057789d6adb6588cfd10da7a224c..5771a1100a10f471d58ae942c76cdeff321cde6c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -128,21 +128,21 @@ import { isMacPlatform } from "~/lib/utils"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { readLocalApi } from "../localApi"; import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; -import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; +import { selectProjectGroupingSettings } from "../logicalProject"; import { - buildSidebarProjectSnapshots, projectGroupsSpanEnvironments, resolveSidebarProjectScopeKey, getSidebarProjectSettingsKey, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; -import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; +import { useUiStateStore } from "../uiStateStore"; import { getThreadKeysToDeselectAfterDelete, useThreadSelectionStore, } from "../threadSelectionStore"; import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useSidebarProjectGroups } from "../hooks/useSidebarProjectGroups"; import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { registerSettleAndAdvance } from "../settleAndAdvance"; @@ -224,7 +224,6 @@ import { sidebarThreadKeyAtY, sortInboxThreadsByReturn, sortPinnedThreadsForSidebar, - sortSidebarV2ProjectGroups, sortThreadsForSidebar, sortWorkingThreadsBySend, useThreadJumpHintVisibility, @@ -2420,14 +2419,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { export default function Sidebar() { const projects = useProjects(); - const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); - const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const timestampFormat = useClientSettings((s) => s.timestampFormat); const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -2545,41 +2542,7 @@ export default function Sidebar() { [environments], ); const environmentMachineById = useEnvironmentMachines(); - const orderedProjects = useMemo( - () => - orderItemsByPreferredIds({ - items: projects, - preferredIds: projectOrder, - getId: getProjectOrderKey, - getPreferenceIds: (project) => [ - getProjectOrderKey(project), - legacyProjectCwdPreferenceKey(project.workspaceRoot), - ], - }), - [projectOrder, projects], - ); - const unsortedProjectGroups = useMemo( - () => - buildSidebarProjectSnapshots({ - projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects, - groupScratchProjects: true, - settings: projectGroupingSettings, - primaryEnvironmentId, - resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, - }), - [ - environmentLabelById, - orderedProjects, - primaryEnvironmentId, - projectGroupingSettings, - projects, - sidebarProjectSortOrder, - ], - ); - const projectGroups = useMemo( - () => sortSidebarV2ProjectGroups(unsortedProjectGroups, threads, sidebarProjectSortOrder), - [sidebarProjectSortOrder, threads, unsortedProjectGroups], - ); + const projectGroups = useSidebarProjectGroups(); const projectGroupsRef = useRef(projectGroups); projectGroupsRef.current = projectGroups; // Threads on non-primary environments (T3 Connect, hosted) resolve their diff --git a/apps/web/src/hooks/useSidebarProjectGroups.ts b/apps/web/src/hooks/useSidebarProjectGroups.ts new file mode 100644 index 0000000000000000000000000000000000000000..97d5672099e11b4c0f1c74693d1368ec87ba67f0 --- /dev/null +++ b/apps/web/src/hooks/useSidebarProjectGroups.ts @@ -0,0 +1,49 @@ +import { useMemo } from "react"; + +import { orderItemsByPreferredIds, sortSidebarV2ProjectGroups } from "../components/Sidebar.logic"; +import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; +import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; +import { useEnvironmentIdentities, usePrimaryEnvironmentId } from "../state/environments"; +import { useProjects, useThreadShells } from "../state/entities"; +import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; +import { useClientSettings } from "./useSettings"; + +/** The sidebar's project groups, in the order the sidebar shows them. */ +export function useSidebarProjectGroups() { + const projects = useProjects(); + const threads = useThreadShells(); + const projectOrder = useUiStateStore((store) => store.projectOrder); + const sortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); + const groupingSettings = useClientSettings(selectProjectGroupingSettings); + const environments = useEnvironmentIdentities(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const orderedProjects = useMemo( + () => + orderItemsByPreferredIds({ + items: projects, + preferredIds: projectOrder, + getId: getProjectOrderKey, + getPreferenceIds: (project) => [ + getProjectOrderKey(project), + legacyProjectCwdPreferenceKey(project.workspaceRoot), + ], + }), + [projectOrder, projects], + ); + const unsortedGroups = useMemo(() => { + const labels = new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ); + return buildSidebarProjectSnapshots({ + projects: sortOrder === "manual" ? orderedProjects : projects, + groupScratchProjects: true, + settings: groupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => labels.get(environmentId) ?? null, + }); + }, [environments, groupingSettings, orderedProjects, primaryEnvironmentId, projects, sortOrder]); + return useMemo( + () => sortSidebarV2ProjectGroups(unsortedGroups, threads, sortOrder), + [sortOrder, threads, unsortedGroups], + ); +} diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index bbd05e76cbbbbef04093faa44e6fcb0dd9970da0..f7154177f57b156fd3d1c2279a6cfaa942b607be 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -5,6 +5,7 @@ import type { PullRequestSpeedActionResult } from "~/components/pullRequest/Pull import { usePullRequestCloseBatch } from "~/components/pullRequest/usePullRequestActions"; import { SidebarPointerSensor } from "~/components/Sidebar.pointer"; import { resolveSidebarSweepKeys } from "~/components/Sidebar.logic"; +import { sortProjectsByGroupOrder } from "@t3tools/client-runtime/state/project-grouping"; import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts"; import type { EnvironmentId, @@ -45,6 +46,7 @@ import { useMemo, useRef, useState, + type ComponentProps, type ReactNode, type RefObject, } from "react"; @@ -154,6 +156,7 @@ import { } from "../rightPanelStore"; import { useDebouncedValue } from "../state/queries"; import { useAllEnvironmentShellsBootstrapped, useProjects } from "../state/entities"; +import { useSidebarProjectGroups } from "../hooks/useSidebarProjectGroups"; import { useEnvironments } from "../state/environments"; import { pullRequestEnvironment, @@ -2061,7 +2064,7 @@ function PullRequestsRouteView() { /> ); const filtersMenu = ( - , +) { + const sidebarProjectGroups = useSidebarProjectGroups(); + const projects = useMemo( + () => sortProjectsByGroupOrder(props.projects, sidebarProjectGroups), + [props.projects, sidebarProjectGroups], + ); + return ; +} + function CompactFilterMenu({ label, triggerIcon, diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts index 32861c9ccace822dfffce78068f34d1290688d9b..e5501407c9652cd6c3ecac16271e90bb9b05b86f 100644 --- a/packages/client-runtime/src/state/projectGrouping.test.ts +++ b/packages/client-runtime/src/state/projectGrouping.test.ts @@ -8,6 +8,7 @@ import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKey, resolveScratchProjectScopeKey, + sortProjectsByGroupOrder, type ProjectGroupingSettings, } from "./projectGrouping.ts"; @@ -445,3 +446,40 @@ describe("buildProjectGroups", () => { expect(groups[0]?.members.map((member) => member.project.id)).toEqual(["winner", "sibling"]); }); }); + +describe("sortProjectsByGroupOrder", () => { + const remote = EnvironmentId.make("remote"); + const ref = (projectId: string, env = environmentId) => ({ + environmentId: env, + projectId: ProjectId.make(projectId), + }); + const project = (id: string, env: string = environmentId) => ({ id, environmentId: env }); + + it("follows the group order, keeping ungrouped projects last in their own order", () => { + const sorted = sortProjectsByGroupOrder( + [ + project("alpha"), + project("zeta"), + project("orphan-b"), + project("beta"), + project("orphan-a"), + ], + [{ memberProjectRefs: [ref("zeta")] }, { memberProjectRefs: [ref("beta"), ref("alpha")] }], + ); + expect(sorted.map((entry) => entry.id)).toEqual([ + "zeta", + "alpha", + "beta", + "orphan-b", + "orphan-a", + ]); + }); + + it("tells the same project id on two environments apart", () => { + const sorted = sortProjectsByGroupOrder( + [project("shared"), project("shared", remote)], + [{ memberProjectRefs: [ref("shared", remote)] }, { memberProjectRefs: [ref("shared")] }], + ); + expect(sorted.map((entry) => entry.environmentId)).toEqual([remote, environmentId]); + }); +}); diff --git a/packages/client-runtime/src/state/projectGrouping.ts b/packages/client-runtime/src/state/projectGrouping.ts index 7d615de7b126b0eac3206206dbb6dbaf3039c5f0..169cd2adc15f93b2fbdce355ab17bb195633fd31 100644 --- a/packages/client-runtime/src/state/projectGrouping.ts +++ b/packages/client-runtime/src/state/projectGrouping.ts @@ -376,3 +376,25 @@ export function buildProjectGroups(input: { }; }); } + +/** + * Orders projects the way a sorted group list shows them: by the first group holding each + * project. Projects in no group follow, and ties keep their incoming order. + */ +export function sortProjectsByGroupOrder< + TProject extends { readonly id: string; readonly environmentId: string }, +>( + projects: ReadonlyArray, + groups: ReadonlyArray<{ readonly memberProjectRefs: ReadonlyArray }>, +): TProject[] { + const rankByProject = new Map(); + groups.forEach((group, rank) => { + for (const ref of group.memberProjectRefs) { + const key = `${ref.environmentId}\0${ref.projectId}`; + if (!rankByProject.has(key)) rankByProject.set(key, rank); + } + }); + const rankOf = (project: TProject) => + rankByProject.get(`${project.environmentId}\0${project.id}`) ?? groups.length; + return [...projects].sort((left, right) => rankOf(left) - rankOf(right)); +} -- 2.54.0