History

Restore grouped project filtering in Sidebar V2 (#4282)

shivam committed 3 months ago

Commit 3afb4a91 parent 315b2735 files · +405 −74Browse files

Unified · Split

5 files
  1. apps/web/src/components/Sidebar.logic.test.ts +38 −0
  2. apps/web/src/components/Sidebar.logic.ts +46 −0
  3. apps/web/src/components/SidebarV2.tsx +143 −73
  4. apps/web/src/environmentGrouping.test.ts +109 −0
  5. apps/web/src/sidebarProjectGrouping.ts +69 −1
apps/web/src/components/Sidebar.logic.test.ts+38−0
@@ -22,6 +22,7 @@ import { · 21 unmodified lines
2222 resolveThreadStatusPill,
2323 shouldNavigateAfterProjectRemoval,
2424 shouldClearThreadSelectionOnMouseDown,
25+ sortLogicalProjectsForSidebar,
2526 sortThreadsForSidebarV2,
2627 sortProjectsForSidebar,
2728 sortScopedProjectsForSidebar,
@@ -1353,3 +1354,40 @@ describe("sortScopedProjectsForSidebar", () => { · 1325 unmodified lines
13531354 ]);
13541355 });
13551356 });
1357+
1358+describe("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−0
@@ -36,6 +36,14 @@ type ScopedSidebarThread = ThreadSortInput & { · 35 unmodified lines
3636 archivedAt: string | null;
3737 };
3838
39+type LogicalSidebarProject = SidebarProject & {
40+ projectKey: string;
41+ memberProjectRefs: readonly {
42+ environmentId: string;
43+ projectId: string;
44+ }[];
45+};
46+
3947 export type ThreadTraversalDirection = "previous" | "next";
4048
4149 export async function archiveSelectedThreadEntries<
@@ -727,6 +735,44 @@ export function sortProjectsForSidebar< · 685 unmodified lines
727735 );
728736 }
729737
738+export 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 @@
11 import { autoAnimate } from "@formkit/auto-animate";
22 import { useAtomValue } from "@effect/atom-react";
33 import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled";
4−import type {
5− EnvironmentProject,
6− EnvironmentThreadShell,
7−} from "@t3tools/client-runtime/state/models";
4+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
85 import {
96 scopeProjectRef,
107 scopeThreadRef,
@@ -61,7 +58,12 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina · 50 unmodified lines
6158 import { isMacPlatform } from "~/lib/utils";
6259 import { useOpenPrLink } from "../lib/openPullRequestLink";
6360 import { readLocalApi } from "../localApi";
64−import { useUiStateStore } from "../uiStateStore";
61+import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";
62+import {
63+ buildSidebarProjectSnapshots,
64+ type SidebarProjectSnapshot,
65+} from "../sidebarProjectGrouping";
66+import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
6567 import { useThreadSelectionStore } from "../threadSelectionStore";
6668 import { useThreadActions } from "../hooks/useThreadActions";
6769 import { useHandleNewThread } from "../hooks/useHandleNewThread";
@@ -84,9 +86,11 @@ import { · 16 unmodified lines
8486 firstValidTimestampMs,
8587 hasUnseenCompletion,
8688 isTrailingDoubleClick,
89+ orderItemsByPreferredIds,
8790 resolveAdjacentThreadId,
8891 resolveSidebarV2Status,
8992 shouldNavigateAfterProjectRemoval,
93+ sortLogicalProjectsForSidebar,
9094 sortThreadsForSidebarV2,
9195 } from "./Sidebar.logic";
9296 import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
@@ -716,12 +720,15 @@ function latestTurnDiff( · 623 unmodified lines
716720
717721 export 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() { · 34 unmodified lines
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() { · 55 unmodified lines
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() { · 1 unmodified line
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() { · 12 unmodified lines
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() { · 27 unmodified lines
9611031 autoSettleAfterDays,
9621032 changeRequestStateByKey,
9631033 nowMinute,
964− scopedProject,
1034+ scopedProjectKeys,
9651035 serverConfigs,
9661036 threads,
9671037 ]);
@@ -1479,7 +1549,7 @@ export default function SidebarV2() { · 511 unmodified lines
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() { · 5 unmodified lines
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() { · 55 unmodified lines
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() { · 1 unmodified line
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() { · 12 unmodified lines
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() { · 6 unmodified lines
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() { · 151 unmodified lines
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−0
@@ -5,12 +5,16 @@ import { · 4 unmodified lines
55 deriveLogicalProjectKey,
66 deriveLogicalProjectKeyFromSettings,
77 derivePhysicalProjectKey,
8+ getProjectOrderKey,
89 resolveProjectGroupingMode,
910 } from "./logicalProject";
1011 import {
1112 buildPhysicalToLogicalProjectKeyMap,
13+ buildSidebarProjectPickerEntries,
1214 buildSidebarProjectSnapshots,
1315 } from "./sidebarProjectGrouping";
16+import { orderItemsByPreferredIds } from "./components/Sidebar.logic";
17+import { legacyProjectCwdPreferenceKey } from "./uiStateStore";
1418 import type { Project } from "./types";
1519
1620 const primaryEnvironmentId = EnvironmentId.make("env-primary");
@@ -59,6 +63,24 @@ describe("environment grouping", () => { · 42 unmodified lines
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", () => { · 151 unmodified lines
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", () => { · 18 unmodified lines
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 { · 30 unmodified lines
3131 remoteEnvironmentLabels: readonly string[];
3232 }
3333
34+export interface SidebarProjectPickerEntry {
35+ group: SidebarProjectSnapshot;
36+ targetProject: SidebarProjectGroupMember;
37+ isPreferred: boolean;
38+}
39+
3440 interface SidebarProjectGroupCandidate {
3541 readonly logicalKey: string;
3642 readonly project: Project;
@@ -133,6 +139,26 @@ export function buildSidebarProjectSnapshots(input: { · 96 unmodified lines
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: { · 46 unmodified lines
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+
222+export 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+}