authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-07-23 21:36:56+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-23 18:06:56+02:00
log3afb4a9ef9e71ddf44035797cd224301bc5645a1
tree9b73f4be72085eb0589d999c94bf4f74684d45f6
parent315b27385d7eb36a56b410fa8b624fc2ed246278
signature Signed by PGP key B5690EEEBB952194

Restore grouped project filtering in Sidebar V2 (#4282)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>

5 files changed, 405 insertions(+), 74 deletions(-)

apps/web/src/components/Sidebar.logic.test.ts+38
......@@ -22,6 +22,7 @@ import {
2222 resolveThreadStatusPill,
2323 shouldNavigateAfterProjectRemoval,
2424 shouldClearThreadSelectionOnMouseDown,
25 sortLogicalProjectsForSidebar,
2526 sortThreadsForSidebarV2,
2627 sortProjectsForSidebar,
2728 sortScopedProjectsForSidebar,
......@@ -1353,3 +1354,40 @@ describe("sortScopedProjectsForSidebar", () => {
13531354 ]);
13541355 });
13551356});
1357
1358describe("sortLogicalProjectsForSidebar", () => {
1359 it("uses saved order only in manual mode and activity order otherwise", () => {
1360 const olderProjectId = ProjectId.make("project-older");
1361 const newerProjectId = ProjectId.make("project-newer");
1362 const projects = [
1363 {
1364 ...makeProject({ id: olderProjectId, title: "Older project" }),
1365 projectKey: "logical-older",
1366 memberProjectRefs: [{ environmentId: localEnvironmentId, projectId: olderProjectId }],
1367 },
1368 {
1369 ...makeProject({ id: newerProjectId, title: "Newer project" }),
1370 projectKey: "logical-newer",
1371 memberProjectRefs: [{ environmentId: localEnvironmentId, projectId: newerProjectId }],
1372 },
1373 ];
1374 const threads = [
1375 makeThread({
1376 projectId: olderProjectId,
1377 updatedAt: "2026-03-09T10:01:00.000Z",
1378 }),
1379 makeThread({
1380 id: ThreadId.make("thread-newer"),
1381 projectId: newerProjectId,
1382 updatedAt: "2026-03-09T10:05:00.000Z",
1383 }),
1384 ];
1385
1386 expect(sortLogicalProjectsForSidebar(projects, threads, "manual")).toEqual(projects);
1387 expect(
1388 sortLogicalProjectsForSidebar(projects, threads, "updated_at").map(
1389 (project) => project.projectKey,
1390 ),
1391 ).toEqual(["logical-newer", "logical-older"]);
1392 });
1393});
apps/web/src/components/Sidebar.logic.ts+46
......@@ -36,6 +36,14 @@ type ScopedSidebarThread = ThreadSortInput & {
3636 archivedAt: string | null;
3737};
3838
39type LogicalSidebarProject = SidebarProject & {
40 projectKey: string;
41 memberProjectRefs: readonly {
42 environmentId: string;
43 projectId: string;
44 }[];
45};
46
3947export type ThreadTraversalDirection = "previous" | "next";
4048
4149export async function archiveSelectedThreadEntries<
......@@ -727,6 +735,44 @@ export function sortProjectsForSidebar<
727735 );
728736}
729737
738export function sortLogicalProjectsForSidebar<
739 TProject extends LogicalSidebarProject,
740 TThread extends ScopedSidebarThread,
741>(
742 projects: readonly TProject[],
743 threads: readonly TThread[],
744 sortOrder: SidebarProjectSortOrder,
745): TProject[] {
746 const groupKeyByProjectRef = new Map(
747 projects.flatMap((project) =>
748 project.memberProjectRefs.map(
749 (projectRef) =>
750 [`${projectRef.environmentId}\0${projectRef.projectId}`, project.projectKey] as const,
751 ),
752 ),
753 );
754 const threadsByProjectKey = new Map<string, TThread[]>();
755 for (const thread of threads) {
756 if (thread.archivedAt !== null) continue;
757 const projectKey = groupKeyByProjectRef.get(`${thread.environmentId}\0${thread.projectId}`);
758 if (!projectKey) continue;
759 const existing = threadsByProjectKey.get(projectKey);
760 if (existing) {
761 existing.push(thread);
762 } else {
763 threadsByProjectKey.set(projectKey, [thread]);
764 }
765 }
766
767 return sortProjectsByActivity(
768 projects,
769 sortOrder,
770 (project) => threadsByProjectKey.get(project.projectKey) ?? [],
771 (left, right) =>
772 left.title.localeCompare(right.title) || left.projectKey.localeCompare(right.projectKey),
773 );
774}
775
730776/**
731777 * Sorts the cross-environment project collection used by landing surfaces.
732778 * Project ids are only unique within an environment, and archived threads
apps/web/src/components/SidebarV2.tsx+143-73
......@@ -1,10 +1,7 @@
11import { autoAnimate } from "@formkit/auto-animate";
22import { useAtomValue } from "@effect/atom-react";
33import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled";
4import type {
5 EnvironmentProject,
6 EnvironmentThreadShell,
7} from "@t3tools/client-runtime/state/models";
4import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
85import {
96 scopeProjectRef,
107 scopeThreadRef,
......@@ -61,7 +58,12 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina
6158import { isMacPlatform } from "~/lib/utils";
6259import { useOpenPrLink } from "../lib/openPullRequestLink";
6360import { readLocalApi } from "../localApi";
64import { useUiStateStore } from "../uiStateStore";
61import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";
62import {
63 buildSidebarProjectSnapshots,
64 type SidebarProjectSnapshot,
65} from "../sidebarProjectGrouping";
66import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
6567import { useThreadSelectionStore } from "../threadSelectionStore";
6668import { useThreadActions } from "../hooks/useThreadActions";
6769import { useHandleNewThread } from "../hooks/useHandleNewThread";
......@@ -84,9 +86,11 @@ import {
8486 firstValidTimestampMs,
8587 hasUnseenCompletion,
8688 isTrailingDoubleClick,
89 orderItemsByPreferredIds,
8790 resolveAdjacentThreadId,
8891 resolveSidebarV2Status,
8992 shouldNavigateAfterProjectRemoval,
93 sortLogicalProjectsForSidebar,
9094 sortThreadsForSidebarV2,
9195} from "./Sidebar.logic";
9296import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
......@@ -716,12 +720,15 @@ function latestTurnDiff(
716720
717721export default function SidebarV2() {
718722 const projects = useProjects();
723 const projectOrder = useUiStateStore((store) => store.projectOrder);
719724 const threads = useThreadShells();
720725 const router = useRouter();
721726 const { isMobile, setOpenMobile } = useSidebar();
722727 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
723728 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
724729 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
730 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
731 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
725732 const { settleThread, unsettleThread, deleteThread } = useThreadActions();
726733 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
727734 reportFailure: false,
......@@ -762,6 +769,40 @@ export default function SidebarV2() {
762769 ),
763770 [environments],
764771 );
772 const orderedProjects = useMemo(
773 () =>
774 orderItemsByPreferredIds({
775 items: projects,
776 preferredIds: projectOrder,
777 getId: getProjectOrderKey,
778 getPreferenceIds: (project) => [
779 getProjectOrderKey(project),
780 legacyProjectCwdPreferenceKey(project.workspaceRoot),
781 ],
782 }),
783 [projectOrder, projects],
784 );
785 const unsortedProjectGroups = useMemo(
786 () =>
787 buildSidebarProjectSnapshots({
788 projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects,
789 settings: projectGroupingSettings,
790 primaryEnvironmentId,
791 resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null,
792 }),
793 [
794 environmentLabelById,
795 orderedProjects,
796 primaryEnvironmentId,
797 projectGroupingSettings,
798 projects,
799 sidebarProjectSortOrder,
800 ],
801 );
802 const projectGroups = useMemo(
803 () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder),
804 [sidebarProjectSortOrder, threads, unsortedProjectGroups],
805 );
765806 const serverProviders = useAtomValue(primaryServerProvidersAtom);
766807 const providerEntryByInstanceId = useMemo(
767808 () =>
......@@ -823,23 +864,29 @@ export default function SidebarV2() {
823864 // Project scope: one menu above the list. Scoping filters the list without
824865 // making the header width depend on the number or length of project names.
825866 const [projectScopeKey, setProjectScopeKey] = useState<string | null>(null);
826 const scopedProject = useMemo(
867 const scopedProjectGroup = useMemo(
827868 () =>
828869 projectScopeKey === null
829870 ? null
830 : (projects.find(
831 (project) => `${project.environmentId}:${project.id}` === projectScopeKey,
832 ) ?? null),
833 [projectScopeKey, projects],
871 : (projectGroups.find((project) => project.projectKey === projectScopeKey) ?? null),
872 [projectGroups, projectScopeKey],
873 );
874 const scopedProjectKeys = useMemo(
875 () =>
876 scopedProjectGroup === null
877 ? null
878 : new Set(
879 scopedProjectGroup.memberProjectRefs.map(
880 (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`,
881 ),
882 ),
883 [scopedProjectGroup],
834884 );
835885 useEffect(() => {
836 if (
837 projectScopeKey !== null &&
838 !projects.some((project) => `${project.environmentId}:${project.id}` === projectScopeKey)
839 ) {
886 if (projectScopeKey !== null && scopedProjectGroup === null) {
840887 setProjectScopeKey(null);
841888 }
842 }, [projectScopeKey, projects]);
889 }, [projectScopeKey, scopedProjectGroup]);
843890 // Scope flips drop the selection: rows selected under the old scope may be
844891 // hidden now, and bulk actions must never count or touch invisible rows.
845892 useEffect(() => {
......@@ -847,66 +894,90 @@ export default function SidebarV2() {
847894 }, [clearSelection, projectScopeKey]);
848895
849896 const handleRemoveProject = useCallback(
850 async (project: EnvironmentProject) => {
897 async (projectGroup: SidebarProjectSnapshot) => {
851898 const api = readLocalApi();
852899 if (!api) return;
853900
854 const projectThreads = threads.filter(
855 (thread) =>
856 thread.environmentId === project.environmentId && thread.projectId === project.id,
901 const memberKeys = new Set(
902 projectGroup.memberProjectRefs.map(
903 (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`,
904 ),
905 );
906 const projectThreads = threads.filter((thread) =>
907 memberKeys.has(`${thread.environmentId}:${thread.projectId}`),
857908 );
858909 const confirmed = await settlePromise(() =>
859910 api.dialogs.confirm(
860911 projectThreads.length > 0
861912 ? [
862 `Remove project "${project.title}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`,
863 `Path: ${project.workspaceRoot}`,
913 `Remove project "${projectGroup.displayName}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`,
914 ...(projectGroup.memberProjects.length === 1
915 ? [`Path: ${projectGroup.workspaceRoot}`]
916 : [
917 `This removes ${projectGroup.memberProjects.length} grouped project entries.`,
918 ]),
864919 "This permanently clears conversation history for those threads.",
865 "This removes only the project entry, not the files on disk.",
920 "This removes only the project entries, not the files on disk.",
866921 "This action cannot be undone.",
867922 ].join("\n")
868923 : [
869 `Remove project "${project.title}"?`,
870 `Path: ${project.workspaceRoot}`,
871 "This removes only the project entry, not the files on disk.",
924 `Remove project "${projectGroup.displayName}"?`,
925 ...(projectGroup.memberProjects.length === 1
926 ? [`Path: ${projectGroup.workspaceRoot}`]
927 : [
928 `This removes ${projectGroup.memberProjects.length} grouped project entries.`,
929 ]),
930 "This removes only the project entries, not the files on disk.",
872931 ].join("\n"),
873932 ),
874933 );
875934 if (confirmed._tag === "Failure" || !confirmed.value) return;
876935
877 const projectRef = scopeProjectRef(project.environmentId, project.id);
878 const result = await deleteProject({
879 environmentId: project.environmentId,
880 input: {
881 projectId: project.id,
882 ...(projectThreads.length > 0 ? { force: true } : {}),
883 },
884 });
885 if (result._tag === "Failure") {
886 if (!isAtomCommandInterrupted(result)) {
887 const error = squashAtomCommandFailure(result);
888 toastManager.add(
889 stackedThreadToast({
890 type: "error",
891 title: `Failed to remove "${project.title}"`,
892 description: error instanceof Error ? error.message : "An error occurred.",
893 }),
894 );
936 const draftStore = useComposerDraftStore.getState();
937 let shouldNavigate = false;
938 for (const project of projectGroup.memberProjects) {
939 const memberThreads = projectThreads.filter(
940 (thread) =>
941 thread.environmentId === project.environmentId && thread.projectId === project.id,
942 );
943 const projectRef = scopeProjectRef(project.environmentId, project.id);
944 const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef);
945 const memberRemovalNeedsNavigation = shouldNavigateAfterProjectRemoval({
946 routeTarget: routeTargetRef.current,
947 projectThreads: memberThreads,
948 projectDraftId: projectDraftThread?.draftId ?? null,
949 });
950
951 const result = await deleteProject({
952 environmentId: project.environmentId,
953 input: {
954 projectId: project.id,
955 ...(memberThreads.length > 0 ? { force: true } : {}),
956 },
957 });
958 if (result._tag === "Failure") {
959 if (!isAtomCommandInterrupted(result)) {
960 const error = squashAtomCommandFailure(result);
961 toastManager.add(
962 stackedThreadToast({
963 type: "error",
964 title: `Failed to remove "${project.title}"`,
965 description: error instanceof Error ? error.message : "An error occurred.",
966 }),
967 );
968 }
969 if (shouldNavigate) {
970 void router.navigate({ to: "/" });
971 }
972 return;
895973 }
896 return;
897 }
898974
899 const draftStore = useComposerDraftStore.getState();
900 const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef);
901 const shouldNavigate = shouldNavigateAfterProjectRemoval({
902 routeTarget: routeTargetRef.current,
903 projectThreads,
904 projectDraftId: projectDraftThread?.draftId ?? null,
905 });
906 if (projectDraftThread) {
907 draftStore.clearDraftThread(projectDraftThread.draftId);
975 shouldNavigate ||= memberRemovalNeedsNavigation;
976 if (projectDraftThread) {
977 draftStore.clearDraftThread(projectDraftThread.draftId);
978 }
979 draftStore.clearProjectDraftThreadId(projectRef);
908980 }
909 draftStore.clearProjectDraftThreadId(projectRef);
910981
911982 if (shouldNavigate) {
912983 void router.navigate({ to: "/" });
......@@ -925,9 +996,8 @@ export default function SidebarV2() {
925996 const visible = threads.filter(
926997 (thread) =>
927998 thread.archivedAt === null &&
928 (scopedProject === null ||
929 (thread.environmentId === scopedProject.environmentId &&
930 thread.projectId === scopedProject.id)),
999 (scopedProjectKeys === null ||
1000 scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)),
9311001 );
9321002 const active: EnvironmentThreadShell[] = [];
9331003 const settled: EnvironmentThreadShell[] = [];
......@@ -961,7 +1031,7 @@ export default function SidebarV2() {
9611031 autoSettleAfterDays,
9621032 changeRequestStateByKey,
9631033 nowMinute,
964 scopedProject,
1034 scopedProjectKeys,
9651035 serverConfigs,
9661036 threads,
9671037 ]);
......@@ -1479,7 +1549,7 @@ export default function SidebarV2() {
14791549 // for multi-project setups.
14801550 const handleNewThreadClick = useCallback(() => {
14811551 // One project: nothing to pick, create immediately.
1482 if (projects.length <= 1) {
1552 if (projectGroups.length <= 1) {
14831553 if (isMobile) setOpenMobile(false);
14841554 void startNewThreadFromContext({
14851555 activeDraftThread: newThreadContext.activeDraftThread,
......@@ -1491,7 +1561,7 @@ export default function SidebarV2() {
14911561 }
14921562 if (isMobile) setOpenMobile(false);
14931563 openCommandPalette({ open: "new-thread-in" });
1494 }, [isMobile, newThreadContext, projects.length, setOpenMobile]);
1564 }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]);
14951565
14961566 const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle");
14971567 // Same resolution as v1: prefer the local-thread binding, fall back to
......@@ -1553,7 +1623,7 @@ export default function SidebarV2() {
15531623 </div>
15541624 </div>
15551625 </SidebarGroup>
1556 {projects.length > 0 ? (
1626 {projectGroups.length > 0 ? (
15571627 <SidebarGroup className="px-2 pb-2 pt-0">
15581628 <div className="flex items-center gap-1">
15591629 <Menu>
......@@ -1561,17 +1631,17 @@ export default function SidebarV2() {
15611631 aria-label="Filter threads by project"
15621632 className="flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md px-2 text-left text-sm font-medium text-sidebar-muted-foreground outline-none hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar"
15631633 >
1564 {scopedProject ? (
1634 {scopedProjectGroup ? (
15651635 <ProjectFavicon
1566 environmentId={scopedProject.environmentId}
1567 cwd={scopedProject.workspaceRoot}
1636 environmentId={scopedProjectGroup.environmentId}
1637 cwd={scopedProjectGroup.workspaceRoot}
15681638 className="size-4 shrink-0"
15691639 />
15701640 ) : (
15711641 <FolderIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />
15721642 )}
15731643 <span className="min-w-0 flex-1 truncate">
1574 {scopedProject?.title ?? "All projects"}
1644 {scopedProjectGroup?.displayName ?? "All projects"}
15751645 </span>
15761646 <ChevronDownIcon className="size-4 shrink-0 text-sidebar-muted-foreground/70" />
15771647 </MenuTrigger>
......@@ -1590,8 +1660,8 @@ export default function SidebarV2() {
15901660 <FolderIcon className="size-4 shrink-0" />
15911661 <span className="min-w-0 truncate text-sm">All projects</span>
15921662 </MenuRadioItem>
1593 {projects.map((project) => {
1594 const scopeKey = `${project.environmentId}:${project.id}`;
1663 {projectGroups.map((project) => {
1664 const scopeKey = project.projectKey;
15951665 return (
15961666 <MenuRadioItem
15971667 key={scopeKey}
......@@ -1604,11 +1674,11 @@ export default function SidebarV2() {
16041674 cwd={project.workspaceRoot}
16051675 className="size-4 shrink-0"
16061676 />
1607 <span className="min-w-0 truncate text-sm">{project.title}</span>
1677 <span className="min-w-0 truncate text-sm">{project.displayName}</span>
16081678 <button
16091679 type="button"
1610 aria-label={`Remove project ${project.title}`}
1611 title={`Remove ${project.title}`}
1680 aria-label={`Remove project ${project.displayName}`}
1681 title={`Remove ${project.displayName}`}
16121682 className="ml-auto inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground/55 outline-none transition-colors hover:bg-destructive/12 hover:text-destructive focus-visible:bg-destructive/12 focus-visible:text-destructive focus-visible:ring-2 focus-visible:ring-destructive/40"
16131683 onPointerDown={(event) => event.stopPropagation()}
16141684 onClick={(event) => {
......@@ -1766,8 +1836,8 @@ export default function SidebarV2() {
17661836 Add project
17671837 </button>
17681838 </>
1769 ) : scopedProject ? (
1770 `No threads in ${scopedProject.title} yet`
1839 ) : scopedProjectGroup ? (
1840 `No threads in ${scopedProjectGroup.displayName} yet`
17711841 ) : (
17721842 "No threads yet"
17731843 )}
apps/web/src/environmentGrouping.test.ts+109
......@@ -5,12 +5,16 @@ import {
55 deriveLogicalProjectKey,
66 deriveLogicalProjectKeyFromSettings,
77 derivePhysicalProjectKey,
8 getProjectOrderKey,
89 resolveProjectGroupingMode,
910} from "./logicalProject";
1011import {
1112 buildPhysicalToLogicalProjectKeyMap,
13 buildSidebarProjectPickerEntries,
1214 buildSidebarProjectSnapshots,
1315} from "./sidebarProjectGrouping";
16import { orderItemsByPreferredIds } from "./components/Sidebar.logic";
17import { legacyProjectCwdPreferenceKey } from "./uiStateStore";
1418import type { Project } from "./types";
1519
1620const primaryEnvironmentId = EnvironmentId.make("env-primary");
......@@ -59,6 +63,24 @@ describe("environment grouping", () => {
5963 expect(deriveLogicalProjectKey(remote)).toBe(repositoryIdentity.canonicalKey);
6064 });
6165
66 it("counts cross-environment copies as one new-thread project choice", () => {
67 const primary = makeProject({ repositoryIdentity });
68 const remote = makeProject({
69 id: ProjectId.make("project-remote"),
70 environmentId: remoteEnvironmentId,
71 repositoryIdentity,
72 });
73
74 const projectGroupCount = buildSidebarProjectSnapshots({
75 projects: [primary, remote],
76 settings: defaultGroupingSettings,
77 primaryEnvironmentId,
78 resolveEnvironmentLabel: () => null,
79 }).length;
80
81 expect(projectGroupCount).toBe(1);
82 });
83
6284 it("keeps projects without repository identity physically scoped", () => {
6385 const primary = makeProject();
6486 const remote = makeProject({
......@@ -216,6 +238,24 @@ describe("environment grouping", () => {
216238 canonical.id,
217239 remote.id,
218240 ]);
241 expect(snapshots[0]?.memberProjectRefs).toEqual([
242 {
243 environmentId: primaryEnvironmentId,
244 projectId: staleWithoutRepositoryIdentity.id,
245 },
246 { environmentId: primaryEnvironmentId, projectId: canonical.id },
247 { environmentId: remoteEnvironmentId, projectId: remote.id },
248 ]);
249
250 const [pickerEntry] = buildSidebarProjectPickerEntries({
251 groups: snapshots,
252 preferredProjectRef: {
253 environmentId: primaryEnvironmentId,
254 projectId: staleWithoutRepositoryIdentity.id,
255 },
256 });
257 expect(pickerEntry?.isPreferred).toBe(true);
258 expect(pickerEntry?.targetProject.id).toBe(canonical.id);
219259 });
220260
221261 it("routes duplicate physical project keys to the winning logical group", () => {
......@@ -240,4 +280,73 @@ describe("environment grouping", () => {
240280 repositoryIdentity.canonicalKey,
241281 );
242282 });
283
284 it("builds one picker entry per logical project and targets the preferred environment", () => {
285 const primary = makeProject({ repositoryIdentity });
286 const remote = makeProject({
287 id: ProjectId.make("project-remote"),
288 environmentId: remoteEnvironmentId,
289 repositoryIdentity,
290 });
291 const separate = makeProject({
292 id: ProjectId.make("project-separate"),
293 title: "separate",
294 workspaceRoot: "/tmp/separate",
295 });
296 const groups = buildSidebarProjectSnapshots({
297 projects: [separate, primary, remote],
298 settings: defaultGroupingSettings,
299 primaryEnvironmentId,
300 resolveEnvironmentLabel: () => null,
301 });
302
303 const entries = buildSidebarProjectPickerEntries({
304 groups,
305 preferredProjectRef: {
306 environmentId: remoteEnvironmentId,
307 projectId: remote.id,
308 },
309 });
310
311 expect(entries).toHaveLength(2);
312 expect(entries[0]?.group.projectKey).toBe(repositoryIdentity.canonicalKey);
313 expect(entries[0]?.targetProject).toMatchObject({
314 environmentId: remoteEnvironmentId,
315 id: remote.id,
316 });
317 expect(entries[0]?.isPreferred).toBe(true);
318 expect(entries[1]?.group.displayName).toBe("separate");
319 });
320
321 it("keeps manual project order when building grouped sidebar entries", () => {
322 const primary = makeProject({ repositoryIdentity });
323 const remote = makeProject({
324 id: ProjectId.make("project-remote"),
325 environmentId: remoteEnvironmentId,
326 repositoryIdentity,
327 });
328 const separate = makeProject({
329 id: ProjectId.make("project-separate"),
330 title: "separate",
331 workspaceRoot: "/tmp/separate",
332 });
333 const orderedProjects = orderItemsByPreferredIds({
334 items: [primary, remote, separate],
335 preferredIds: [getProjectOrderKey(separate), getProjectOrderKey(primary)],
336 getId: getProjectOrderKey,
337 getPreferenceIds: (project) => [
338 getProjectOrderKey(project),
339 legacyProjectCwdPreferenceKey(project.workspaceRoot),
340 ],
341 });
342
343 const groups = buildSidebarProjectSnapshots({
344 projects: orderedProjects,
345 settings: defaultGroupingSettings,
346 primaryEnvironmentId,
347 resolveEnvironmentLabel: () => null,
348 });
349
350 expect(groups.map((group) => group.displayName)).toEqual(["separate", "shared-repo"]);
351 });
243352});
apps/web/src/sidebarProjectGrouping.ts+69-1
......@@ -31,6 +31,12 @@ export interface SidebarProjectSnapshot extends Project {
3131 remoteEnvironmentLabels: readonly string[];
3232}
3333
34export interface SidebarProjectPickerEntry {
35 group: SidebarProjectSnapshot;
36 targetProject: SidebarProjectGroupMember;
37 isPreferred: boolean;
38}
39
3440interface SidebarProjectGroupCandidate {
3541 readonly logicalKey: string;
3642 readonly project: Project;
......@@ -133,6 +139,26 @@ export function buildSidebarProjectSnapshots(input: {
133139 }
134140 }
135141
142 const projectRefsByLogicalKey = new Map<string, ScopedProjectRef[]>();
143 const seenProjectRefs = new Set<string>();
144 for (const project of input.projects) {
145 const physicalProjectKey = derivePhysicalProjectKey(project);
146 const logicalKey =
147 winnersByPhysicalKey.get(physicalProjectKey)?.logicalKey ??
148 deriveLogicalProjectKeyFromSettings(project, input.settings);
149 const projectRefKey = `${project.environmentId}:${project.id}`;
150 if (seenProjectRefs.has(projectRefKey)) continue;
151 seenProjectRefs.add(projectRefKey);
152
153 const projectRef = scopeProjectRef(project.environmentId, project.id);
154 const existingRefs = projectRefsByLogicalKey.get(logicalKey);
155 if (existingRefs) {
156 existingRefs.push(projectRef);
157 } else {
158 projectRefsByLogicalKey.set(logicalKey, [projectRef]);
159 }
160 }
161
136162 const result: SidebarProjectSnapshot[] = [];
137163 const seen = new Set<string>();
138164 for (const project of input.projects) {
......@@ -185,10 +211,52 @@ export function buildSidebarProjectSnapshots(input: {
185211 hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only",
186212 allRemoteMembersAreDesktopLocal,
187213 memberProjects: members,
188 memberProjectRefs: members.map((member) => scopeProjectRef(member.environmentId, member.id)),
214 memberProjectRefs: projectRefsByLogicalKey.get(logicalKey) ?? [],
189215 remoteEnvironmentLabels,
190216 });
191217 }
192218
193219 return result;
194220}
221
222export function buildSidebarProjectPickerEntries(input: {
223 groups: ReadonlyArray<SidebarProjectSnapshot>;
224 preferredProjectRef: ScopedProjectRef | null;
225}) {
226 const entries = input.groups.flatMap((group): SidebarProjectPickerEntry[] => {
227 const isPreferred = input.preferredProjectRef
228 ? group.memberProjectRefs.some(
229 (projectRef) =>
230 projectRef.environmentId === input.preferredProjectRef?.environmentId &&
231 projectRef.projectId === input.preferredProjectRef.projectId,
232 )
233 : false;
234 const preferredProject = isPreferred
235 ? (group.memberProjects.find(
236 (project) =>
237 project.environmentId === input.preferredProjectRef?.environmentId &&
238 project.id === input.preferredProjectRef?.projectId,
239 ) ??
240 group.memberProjects.find(
241 (project) => project.environmentId === input.preferredProjectRef?.environmentId,
242 ))
243 : null;
244 const targetProject =
245 preferredProject ??
246 group.memberProjects.find(
247 (project) => project.environmentId === group.environmentId && project.id === group.id,
248 ) ??
249 group.memberProjects[0];
250 if (!targetProject) return [];
251
252 return [{ group, targetProject, isPreferred }];
253 });
254 const preferredIndex = entries.findIndex((entry) => entry.isPreferred);
255 if (preferredIndex <= 0) return entries;
256
257 return [
258 entries[preferredIndex]!,
259 ...entries.slice(0, preferredIndex),
260 ...entries.slice(preferredIndex + 1),
261 ];
262}