From e3a2f481aafacdd4d1a2a3f09812b19e9a7b7382 Mon Sep 17 00:00:00 2001 From: flamboh Date: Tue, 13 Oct 2026 21:43:09 +0000 Subject: [PATCH 1/2] fix(web,mobile): pull request filters no longer offer the Scratch folder --- .../pull-requests/PullRequestsScreen.tsx | 13 +++++-- .../pull-requests/pullRequestList.test.ts | 37 +++++++++++++++++++ .../features/pull-requests/pullRequestList.ts | 15 ++++++++ apps/web/src/routes/_chat.pull-requests.tsx | 7 +++- 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx index c37e957a4d1b1..e3e8d3d0eb120 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx @@ -34,6 +34,7 @@ import { scopedPullRequestListKey as pullRequestKey, type EnvironmentPullRequestListEntry, PR_LIST_PAGE_SIZE, + withoutScratchProjectScope, } from "./pullRequestList"; import { useDebouncedValue } from "../../state/queries"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; @@ -102,7 +103,12 @@ export function PullRequestsScreen( }, [navigation, selectAndroidWorkspace], ); - const controls = listControls.controlsFor(environmentId); + const allProjects = useProjects(); + const savedControls = listControls.controlsFor(environmentId); + const controls = useMemo( + () => withoutScratchProjectScope(savedControls, allProjects), + [allProjects, savedControls], + ); const targets = useMemo( () => environments @@ -175,8 +181,9 @@ export function PullRequestsScreen( ].sort(), })); } - const projects = useProjects().filter( - (project) => environmentId === null || project.environmentId === environmentId, + const projects = allProjects.filter( + (project) => + !project.isScratch && (environmentId === null || project.environmentId === environmentId), ); const renderPullRequest = useCallback( ({ item }: { readonly item: EnvironmentPullRequestListEntry }) => ( diff --git a/apps/mobile/src/features/pull-requests/pullRequestList.test.ts b/apps/mobile/src/features/pull-requests/pullRequestList.test.ts index f3a085e02b453..de5dd166ff1d4 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestList.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestList.test.ts @@ -18,6 +18,7 @@ import { pullRequestLabelColor, scopedPullRequestListKey, pullRequestListRowsAreEqual, + withoutScratchProjectScope, } from "./pullRequestList"; function entry(number: number, patch: Partial = {}): PullRequestListEntry { @@ -373,3 +374,39 @@ describe("mobile PR scan and filters", () => { expect(pullRequestLabelColor("not-a-color")).toBeNull(); }); }); + +describe("withoutScratchProjectScope", () => { + const projects = [ + { id: "repo", environmentId: "env-a" }, + { id: "scratch", environmentId: "env-a", isScratch: true as const }, + ]; + + it("reads a scope saved on the Scratch folder as all projects", () => { + const controls = withoutScratchProjectScope( + { ...DEFAULT_PR_LIST_CONTROLS, projectId: "scratch", projectEnvironmentId: "env-a" }, + projects, + ); + expect(controls.projectId).toBeUndefined(); + expect(controls.projectEnvironmentId).toBeUndefined(); + }); + + it("keeps a repository scope, and one whose projects have not arrived yet", () => { + const repository = { + ...DEFAULT_PR_LIST_CONTROLS, + projectId: "repo", + projectEnvironmentId: "env-a", + }; + expect(withoutScratchProjectScope(repository, projects)).toBe(repository); + const pending = { ...repository, projectId: "scratch" }; + expect(withoutScratchProjectScope(pending, [])).toBe(pending); + }); + + it("keeps another server's project that shares the Scratch folder's id", () => { + const elsewhere = { + ...DEFAULT_PR_LIST_CONTROLS, + projectId: "scratch", + projectEnvironmentId: "env-b", + }; + expect(withoutScratchProjectScope(elsewhere, projects)).toBe(elsewhere); + }); +}); diff --git a/apps/mobile/src/features/pull-requests/pullRequestList.ts b/apps/mobile/src/features/pull-requests/pullRequestList.ts index 4527ba1e295d0..f0deeef6c9744 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestList.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestList.ts @@ -15,6 +15,21 @@ export const DEFAULT_PR_LIST_CONTROLS: PullRequestListControls = { sort: "updated", }; +/** A scope saved on the Scratch folder is no longer offered, so it reads as "All projects". */ +export function withoutScratchProjectScope( + controls: PullRequestListControls, + projects: readonly { id: string; environmentId: string; isScratch?: true }[], +): PullRequestListControls { + return projects.some( + (project) => + project.isScratch && + project.id === controls.projectId && + project.environmentId === controls.projectEnvironmentId, + ) + ? { ...controls, projectId: undefined, projectEnvironmentId: undefined } + : controls; +} + export function pullRequestListKey( item: Pick, ) { diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index f5ff710c1ac5b..bbd05e76cbbbb 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -411,9 +411,12 @@ function PullRequestsRouteView() { // the one wrong answer the empty state can give. const projectsKnown = useAllEnvironmentShellsBootstrapped(); // Only the projects the page can actually read: one on an environment that cannot list pull - // requests could neither be listed nor acted on. + // requests could neither be listed nor acted on, and the Scratch folder is never a repository. const projects = useMemo( - () => allProjects.filter((project) => environmentIds.includes(project.environmentId)), + () => + allProjects.filter( + (project) => !project.isScratch && environmentIds.includes(project.environmentId), + ), [allProjects, environmentIds], ); const environmentLabels = useMemo( From 20302c2b07d21674cadf4ac3a633ba9da3d6fde7 Mon Sep 17 00:00:00 2001 From: flamboh Date: Tue, 13 Oct 2026 21:48:12 +0000 Subject: [PATCH 2/2] fix(web,mobile): pull request project filter follows the sidebar order --- .../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 069fe077a4c53..24296142593dc 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 0243fc502a444..6659e37d2eb5f 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 { startNewThreadFromContext } from "../lib/chatThreadActions"; @@ -223,7 +223,6 @@ import { sidebarThreadKeyAtY, sortInboxThreadsByReturn, sortPinnedThreadsForSidebar, - sortSidebarV2ProjectGroups, sortThreadsForSidebar, sortWorkingThreadsBySend, useThreadJumpHintVisibility, @@ -2419,14 +2418,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); @@ -2544,41 +2541,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 0000000000000..97d5672099e11 --- /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 bbd05e76cbbbb..f7154177f57b1 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 32861c9ccace8..e5501407c9652 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 7d615de7b126b..169cd2adc15f9 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)); +}