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 {...@@ -22,6 +22,7 @@ import {
22 resolveThreadStatusPill,22 resolveThreadStatusPill,
23 shouldNavigateAfterProjectRemoval,23 shouldNavigateAfterProjectRemoval,
24 shouldClearThreadSelectionOnMouseDown,24 shouldClearThreadSelectionOnMouseDown,
25 sortLogicalProjectsForSidebar,
25 sortThreadsForSidebarV2,26 sortThreadsForSidebarV2,
26 sortProjectsForSidebar,27 sortProjectsForSidebar,
27 sortScopedProjectsForSidebar,28 sortScopedProjectsForSidebar,
...@@ -1353,3 +1354,40 @@ describe("sortScopedProjectsForSidebar", () => {...@@ -1353,3 +1354,40 @@ describe("sortScopedProjectsForSidebar", () => {
1353 ]);1354 ]);
1354 });1355 });
1355});1356});
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 & {...@@ -36,6 +36,14 @@ type ScopedSidebarThread = ThreadSortInput & {
36 archivedAt: string | null;36 archivedAt: string | null;
37};37};
3838
39type LogicalSidebarProject = SidebarProject & {
40 projectKey: string;
41 memberProjectRefs: readonly {
42 environmentId: string;
43 projectId: string;
44 }[];
45};
46
39export type ThreadTraversalDirection = "previous" | "next";47export type ThreadTraversalDirection = "previous" | "next";
4048
41export async function archiveSelectedThreadEntries<49export async function archiveSelectedThreadEntries<
...@@ -727,6 +735,44 @@ export function sortProjectsForSidebar<...@@ -727,6 +735,44 @@ export function sortProjectsForSidebar<
727 );735 );
728}736}
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
730/**776/**
731 * Sorts the cross-environment project collection used by landing surfaces.777 * Sorts the cross-environment project collection used by landing surfaces.
732 * Project ids are only unique within an environment, and archived threads778 * Project ids are only unique within an environment, and archived threads
apps/web/src/components/SidebarV2.tsx+143-73
...@@ -1,10 +1,7 @@...@@ -1,10 +1,7 @@
1import { autoAnimate } from "@formkit/auto-animate";1import { autoAnimate } from "@formkit/auto-animate";
2import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
3import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled";3import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled";
4import type {4import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
5 EnvironmentProject,
6 EnvironmentThreadShell,
7} from "@t3tools/client-runtime/state/models";
8import {5import {
9 scopeProjectRef,6 scopeProjectRef,
10 scopeThreadRef,7 scopeThreadRef,
...@@ -61,7 +58,12 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina...@@ -61,7 +58,12 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina
61import { isMacPlatform } from "~/lib/utils";58import { isMacPlatform } from "~/lib/utils";
62import { useOpenPrLink } from "../lib/openPullRequestLink";59import { useOpenPrLink } from "../lib/openPullRequestLink";
63import { readLocalApi } from "../localApi";60import { 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";
65import { useThreadSelectionStore } from "../threadSelectionStore";67import { useThreadSelectionStore } from "../threadSelectionStore";
66import { useThreadActions } from "../hooks/useThreadActions";68import { useThreadActions } from "../hooks/useThreadActions";
67import { useHandleNewThread } from "../hooks/useHandleNewThread";69import { useHandleNewThread } from "../hooks/useHandleNewThread";
...@@ -84,9 +86,11 @@ import {...@@ -84,9 +86,11 @@ import {
84 firstValidTimestampMs,86 firstValidTimestampMs,
85 hasUnseenCompletion,87 hasUnseenCompletion,
86 isTrailingDoubleClick,88 isTrailingDoubleClick,
89 orderItemsByPreferredIds,
87 resolveAdjacentThreadId,90 resolveAdjacentThreadId,
88 resolveSidebarV2Status,91 resolveSidebarV2Status,
89 shouldNavigateAfterProjectRemoval,92 shouldNavigateAfterProjectRemoval,
93 sortLogicalProjectsForSidebar,
90 sortThreadsForSidebarV2,94 sortThreadsForSidebarV2,
91} from "./Sidebar.logic";95} from "./Sidebar.logic";
92import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";96import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
...@@ -716,12 +720,15 @@ function latestTurnDiff(...@@ -716,12 +720,15 @@ function latestTurnDiff(
716720
717export default function SidebarV2() {721export default function SidebarV2() {
718 const projects = useProjects();722 const projects = useProjects();
723 const projectOrder = useUiStateStore((store) => store.projectOrder);
719 const threads = useThreadShells();724 const threads = useThreadShells();
720 const router = useRouter();725 const router = useRouter();
721 const { isMobile, setOpenMobile } = useSidebar();726 const { isMobile, setOpenMobile } = useSidebar();
722 const keybindings = useAtomValue(primaryServerKeybindingsAtom);727 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
723 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);728 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
724 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);729 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
730 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
731 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
725 const { settleThread, unsettleThread, deleteThread } = useThreadActions();732 const { settleThread, unsettleThread, deleteThread } = useThreadActions();
726 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {733 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
727 reportFailure: false,734 reportFailure: false,
...@@ -762,6 +769,40 @@ export default function SidebarV2() {...@@ -762,6 +769,40 @@ export default function SidebarV2() {
762 ),769 ),
763 [environments],770 [environments],
764 );771 );
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 );
765 const serverProviders = useAtomValue(primaryServerProvidersAtom);806 const serverProviders = useAtomValue(primaryServerProvidersAtom);
766 const providerEntryByInstanceId = useMemo(807 const providerEntryByInstanceId = useMemo(
767 () =>808 () =>
...@@ -823,23 +864,29 @@ export default function SidebarV2() {...@@ -823,23 +864,29 @@ export default function SidebarV2() {
823 // Project scope: one menu above the list. Scoping filters the list without864 // Project scope: one menu above the list. Scoping filters the list without
824 // making the header width depend on the number or length of project names.865 // making the header width depend on the number or length of project names.
825 const [projectScopeKey, setProjectScopeKey] = useState<string | null>(null);866 const [projectScopeKey, setProjectScopeKey] = useState<string | null>(null);
826 const scopedProject = useMemo(867 const scopedProjectGroup = useMemo(
827 () =>868 () =>
828 projectScopeKey === null869 projectScopeKey === null
829 ? null870 ? null
830 : (projects.find(871 : (projectGroups.find((project) => project.projectKey === projectScopeKey) ?? null),
831 (project) => `${project.environmentId}:${project.id}` === projectScopeKey,872 [projectGroups, projectScopeKey],
832 ) ?? null),873 );
833 [projectScopeKey, projects],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],
834 );884 );
835 useEffect(() => {885 useEffect(() => {
836 if (886 if (projectScopeKey !== null && scopedProjectGroup === null) {
837 projectScopeKey !== null &&
838 !projects.some((project) => `${project.environmentId}:${project.id}` === projectScopeKey)
839 ) {
840 setProjectScopeKey(null);887 setProjectScopeKey(null);
841 }888 }
842 }, [projectScopeKey, projects]);889 }, [projectScopeKey, scopedProjectGroup]);
843 // Scope flips drop the selection: rows selected under the old scope may be890 // Scope flips drop the selection: rows selected under the old scope may be
844 // hidden now, and bulk actions must never count or touch invisible rows.891 // hidden now, and bulk actions must never count or touch invisible rows.
845 useEffect(() => {892 useEffect(() => {
...@@ -847,66 +894,90 @@ export default function SidebarV2() {...@@ -847,66 +894,90 @@ export default function SidebarV2() {
847 }, [clearSelection, projectScopeKey]);894 }, [clearSelection, projectScopeKey]);
848895
849 const handleRemoveProject = useCallback(896 const handleRemoveProject = useCallback(
850 async (project: EnvironmentProject) => {897 async (projectGroup: SidebarProjectSnapshot) => {
851 const api = readLocalApi();898 const api = readLocalApi();
852 if (!api) return;899 if (!api) return;
853900
854 const projectThreads = threads.filter(901 const memberKeys = new Set(
855 (thread) =>902 projectGroup.memberProjectRefs.map(
856 thread.environmentId === project.environmentId && thread.projectId === project.id,903 (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`,
904 ),
905 );
906 const projectThreads = threads.filter((thread) =>
907 memberKeys.has(`${thread.environmentId}:${thread.projectId}`),
857 );908 );
858 const confirmed = await settlePromise(() =>909 const confirmed = await settlePromise(() =>
859 api.dialogs.confirm(910 api.dialogs.confirm(
860 projectThreads.length > 0911 projectThreads.length > 0
861 ? [912 ? [
862 `Remove project "${project.title}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`,913 `Remove project "${projectGroup.displayName}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`,
863 `Path: ${project.workspaceRoot}`,914 ...(projectGroup.memberProjects.length === 1
915 ? [`Path: ${projectGroup.workspaceRoot}`]
916 : [
917 `This removes ${projectGroup.memberProjects.length} grouped project entries.`,
918 ]),
864 "This permanently clears conversation history for those threads.",919 "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.",
866 "This action cannot be undone.",921 "This action cannot be undone.",
867 ].join("\n")922 ].join("\n")
868 : [923 : [
869 `Remove project "${project.title}"?`,924 `Remove project "${projectGroup.displayName}"?`,
870 `Path: ${project.workspaceRoot}`,925 ...(projectGroup.memberProjects.length === 1
871 "This removes only the project entry, not the files on disk.",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.",
872 ].join("\n"),931 ].join("\n"),
873 ),932 ),
874 );933 );
875 if (confirmed._tag === "Failure" || !confirmed.value) return;934 if (confirmed._tag === "Failure" || !confirmed.value) return;
876935
877 const projectRef = scopeProjectRef(project.environmentId, project.id);936 const draftStore = useComposerDraftStore.getState();
878 const result = await deleteProject({937 let shouldNavigate = false;
879 environmentId: project.environmentId,938 for (const project of projectGroup.memberProjects) {
880 input: {939 const memberThreads = projectThreads.filter(
881 projectId: project.id,940 (thread) =>
882 ...(projectThreads.length > 0 ? { force: true } : {}),941 thread.environmentId === project.environmentId && thread.projectId === project.id,
883 },942 );
884 });943 const projectRef = scopeProjectRef(project.environmentId, project.id);
885 if (result._tag === "Failure") {944 const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef);
886 if (!isAtomCommandInterrupted(result)) {945 const memberRemovalNeedsNavigation = shouldNavigateAfterProjectRemoval({
887 const error = squashAtomCommandFailure(result);946 routeTarget: routeTargetRef.current,
888 toastManager.add(947 projectThreads: memberThreads,
889 stackedThreadToast({948 projectDraftId: projectDraftThread?.draftId ?? null,
890 type: "error",949 });
891 title: `Failed to remove "${project.title}"`,950
892 description: error instanceof Error ? error.message : "An error occurred.",951 const result = await deleteProject({
893 }),952 environmentId: project.environmentId,
894 );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;
895 }973 }
896 return;
897 }
898974
899 const draftStore = useComposerDraftStore.getState();975 shouldNavigate ||= memberRemovalNeedsNavigation;
900 const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef);976 if (projectDraftThread) {
901 const shouldNavigate = shouldNavigateAfterProjectRemoval({977 draftStore.clearDraftThread(projectDraftThread.draftId);
902 routeTarget: routeTargetRef.current,978 }
903 projectThreads,979 draftStore.clearProjectDraftThreadId(projectRef);
904 projectDraftId: projectDraftThread?.draftId ?? null,
905 });
906 if (projectDraftThread) {
907 draftStore.clearDraftThread(projectDraftThread.draftId);
908 }980 }
909 draftStore.clearProjectDraftThreadId(projectRef);
910981
911 if (shouldNavigate) {982 if (shouldNavigate) {
912 void router.navigate({ to: "/" });983 void router.navigate({ to: "/" });
...@@ -925,9 +996,8 @@ export default function SidebarV2() {...@@ -925,9 +996,8 @@ export default function SidebarV2() {
925 const visible = threads.filter(996 const visible = threads.filter(
926 (thread) =>997 (thread) =>
927 thread.archivedAt === null &&998 thread.archivedAt === null &&
928 (scopedProject === null ||999 (scopedProjectKeys === null ||
929 (thread.environmentId === scopedProject.environmentId &&1000 scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)),
930 thread.projectId === scopedProject.id)),
931 );1001 );
932 const active: EnvironmentThreadShell[] = [];1002 const active: EnvironmentThreadShell[] = [];
933 const settled: EnvironmentThreadShell[] = [];1003 const settled: EnvironmentThreadShell[] = [];
...@@ -961,7 +1031,7 @@ export default function SidebarV2() {...@@ -961,7 +1031,7 @@ export default function SidebarV2() {
961 autoSettleAfterDays,1031 autoSettleAfterDays,
962 changeRequestStateByKey,1032 changeRequestStateByKey,
963 nowMinute,1033 nowMinute,
964 scopedProject,1034 scopedProjectKeys,
965 serverConfigs,1035 serverConfigs,
966 threads,1036 threads,
967 ]);1037 ]);
...@@ -1479,7 +1549,7 @@ export default function SidebarV2() {...@@ -1479,7 +1549,7 @@ export default function SidebarV2() {
1479 // for multi-project setups.1549 // for multi-project setups.
1480 const handleNewThreadClick = useCallback(() => {1550 const handleNewThreadClick = useCallback(() => {
1481 // One project: nothing to pick, create immediately.1551 // One project: nothing to pick, create immediately.
1482 if (projects.length <= 1) {1552 if (projectGroups.length <= 1) {
1483 if (isMobile) setOpenMobile(false);1553 if (isMobile) setOpenMobile(false);
1484 void startNewThreadFromContext({1554 void startNewThreadFromContext({
1485 activeDraftThread: newThreadContext.activeDraftThread,1555 activeDraftThread: newThreadContext.activeDraftThread,
...@@ -1491,7 +1561,7 @@ export default function SidebarV2() {...@@ -1491,7 +1561,7 @@ export default function SidebarV2() {
1491 }1561 }
1492 if (isMobile) setOpenMobile(false);1562 if (isMobile) setOpenMobile(false);
1493 openCommandPalette({ open: "new-thread-in" });1563 openCommandPalette({ open: "new-thread-in" });
1494 }, [isMobile, newThreadContext, projects.length, setOpenMobile]);1564 }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]);
14951565
1496 const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle");1566 const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle");
1497 // Same resolution as v1: prefer the local-thread binding, fall back to1567 // Same resolution as v1: prefer the local-thread binding, fall back to
...@@ -1553,7 +1623,7 @@ export default function SidebarV2() {...@@ -1553,7 +1623,7 @@ export default function SidebarV2() {
1553 </div>1623 </div>
1554 </div>1624 </div>
1555 </SidebarGroup>1625 </SidebarGroup>
1556 {projects.length > 0 ? (1626 {projectGroups.length > 0 ? (
1557 <SidebarGroup className="px-2 pb-2 pt-0">1627 <SidebarGroup className="px-2 pb-2 pt-0">
1558 <div className="flex items-center gap-1">1628 <div className="flex items-center gap-1">
1559 <Menu>1629 <Menu>
...@@ -1561,17 +1631,17 @@ export default function SidebarV2() {...@@ -1561,17 +1631,17 @@ export default function SidebarV2() {
1561 aria-label="Filter threads by project"1631 aria-label="Filter threads by project"
1562 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"1632 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"
1563 >1633 >
1564 {scopedProject ? (1634 {scopedProjectGroup ? (
1565 <ProjectFavicon1635 <ProjectFavicon
1566 environmentId={scopedProject.environmentId}1636 environmentId={scopedProjectGroup.environmentId}
1567 cwd={scopedProject.workspaceRoot}1637 cwd={scopedProjectGroup.workspaceRoot}
1568 className="size-4 shrink-0"1638 className="size-4 shrink-0"
1569 />1639 />
1570 ) : (1640 ) : (
1571 <FolderIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />1641 <FolderIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />
1572 )}1642 )}
1573 <span className="min-w-0 flex-1 truncate">1643 <span className="min-w-0 flex-1 truncate">
1574 {scopedProject?.title ?? "All projects"}1644 {scopedProjectGroup?.displayName ?? "All projects"}
1575 </span>1645 </span>
1576 <ChevronDownIcon className="size-4 shrink-0 text-sidebar-muted-foreground/70" />1646 <ChevronDownIcon className="size-4 shrink-0 text-sidebar-muted-foreground/70" />
1577 </MenuTrigger>1647 </MenuTrigger>
...@@ -1590,8 +1660,8 @@ export default function SidebarV2() {...@@ -1590,8 +1660,8 @@ export default function SidebarV2() {
1590 <FolderIcon className="size-4 shrink-0" />1660 <FolderIcon className="size-4 shrink-0" />
1591 <span className="min-w-0 truncate text-sm">All projects</span>1661 <span className="min-w-0 truncate text-sm">All projects</span>
1592 </MenuRadioItem>1662 </MenuRadioItem>
1593 {projects.map((project) => {1663 {projectGroups.map((project) => {
1594 const scopeKey = `${project.environmentId}:${project.id}`;1664 const scopeKey = project.projectKey;
1595 return (1665 return (
1596 <MenuRadioItem1666 <MenuRadioItem
1597 key={scopeKey}1667 key={scopeKey}
...@@ -1604,11 +1674,11 @@ export default function SidebarV2() {...@@ -1604,11 +1674,11 @@ export default function SidebarV2() {
1604 cwd={project.workspaceRoot}1674 cwd={project.workspaceRoot}
1605 className="size-4 shrink-0"1675 className="size-4 shrink-0"
1606 />1676 />
1607 <span className="min-w-0 truncate text-sm">{project.title}</span>1677 <span className="min-w-0 truncate text-sm">{project.displayName}</span>
1608 <button1678 <button
1609 type="button"1679 type="button"
1610 aria-label={`Remove project ${project.title}`}1680 aria-label={`Remove project ${project.displayName}`}
1611 title={`Remove ${project.title}`}1681 title={`Remove ${project.displayName}`}
1612 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"1682 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"
1613 onPointerDown={(event) => event.stopPropagation()}1683 onPointerDown={(event) => event.stopPropagation()}
1614 onClick={(event) => {1684 onClick={(event) => {
...@@ -1766,8 +1836,8 @@ export default function SidebarV2() {...@@ -1766,8 +1836,8 @@ export default function SidebarV2() {
1766 Add project1836 Add project
1767 </button>1837 </button>
1768 </>1838 </>
1769 ) : scopedProject ? (1839 ) : scopedProjectGroup ? (
1770 `No threads in ${scopedProject.title} yet`1840 `No threads in ${scopedProjectGroup.displayName} yet`
1771 ) : (1841 ) : (
1772 "No threads yet"1842 "No threads yet"
1773 )}1843 )}
apps/web/src/environmentGrouping.test.ts+109
...@@ -5,12 +5,16 @@ import {...@@ -5,12 +5,16 @@ import {
5 deriveLogicalProjectKey,5 deriveLogicalProjectKey,
6 deriveLogicalProjectKeyFromSettings,6 deriveLogicalProjectKeyFromSettings,
7 derivePhysicalProjectKey,7 derivePhysicalProjectKey,
8 getProjectOrderKey,
8 resolveProjectGroupingMode,9 resolveProjectGroupingMode,
9} from "./logicalProject";10} from "./logicalProject";
10import {11import {
11 buildPhysicalToLogicalProjectKeyMap,12 buildPhysicalToLogicalProjectKeyMap,
13 buildSidebarProjectPickerEntries,
12 buildSidebarProjectSnapshots,14 buildSidebarProjectSnapshots,
13} from "./sidebarProjectGrouping";15} from "./sidebarProjectGrouping";
16import { orderItemsByPreferredIds } from "./components/Sidebar.logic";
17import { legacyProjectCwdPreferenceKey } from "./uiStateStore";
14import type { Project } from "./types";18import type { Project } from "./types";
1519
16const primaryEnvironmentId = EnvironmentId.make("env-primary");20const primaryEnvironmentId = EnvironmentId.make("env-primary");
...@@ -59,6 +63,24 @@ describe("environment grouping", () => {...@@ -59,6 +63,24 @@ describe("environment grouping", () => {
59 expect(deriveLogicalProjectKey(remote)).toBe(repositoryIdentity.canonicalKey);63 expect(deriveLogicalProjectKey(remote)).toBe(repositoryIdentity.canonicalKey);
60 });64 });
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
62 it("keeps projects without repository identity physically scoped", () => {84 it("keeps projects without repository identity physically scoped", () => {
63 const primary = makeProject();85 const primary = makeProject();
64 const remote = makeProject({86 const remote = makeProject({
...@@ -216,6 +238,24 @@ describe("environment grouping", () => {...@@ -216,6 +238,24 @@ describe("environment grouping", () => {
216 canonical.id,238 canonical.id,
217 remote.id,239 remote.id,
218 ]);240 ]);
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);
219 });259 });
220260
221 it("routes duplicate physical project keys to the winning logical group", () => {261 it("routes duplicate physical project keys to the winning logical group", () => {
...@@ -240,4 +280,73 @@ describe("environment grouping", () => {...@@ -240,4 +280,73 @@ describe("environment grouping", () => {
240 repositoryIdentity.canonicalKey,280 repositoryIdentity.canonicalKey,
241 );281 );
242 });282 });
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 });
243});352});
apps/web/src/sidebarProjectGrouping.ts+69-1
...@@ -31,6 +31,12 @@ export interface SidebarProjectSnapshot extends Project {...@@ -31,6 +31,12 @@ export interface SidebarProjectSnapshot extends Project {
31 remoteEnvironmentLabels: readonly string[];31 remoteEnvironmentLabels: readonly string[];
32}32}
3333
34export interface SidebarProjectPickerEntry {
35 group: SidebarProjectSnapshot;
36 targetProject: SidebarProjectGroupMember;
37 isPreferred: boolean;
38}
39
34interface SidebarProjectGroupCandidate {40interface SidebarProjectGroupCandidate {
35 readonly logicalKey: string;41 readonly logicalKey: string;
36 readonly project: Project;42 readonly project: Project;
...@@ -133,6 +139,26 @@ export function buildSidebarProjectSnapshots(input: {...@@ -133,6 +139,26 @@ export function buildSidebarProjectSnapshots(input: {
133 }139 }
134 }140 }
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
136 const result: SidebarProjectSnapshot[] = [];162 const result: SidebarProjectSnapshot[] = [];
137 const seen = new Set<string>();163 const seen = new Set<string>();
138 for (const project of input.projects) {164 for (const project of input.projects) {
...@@ -185,10 +211,52 @@ export function buildSidebarProjectSnapshots(input: {...@@ -185,10 +211,52 @@ export function buildSidebarProjectSnapshots(input: {
185 hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only",211 hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only",
186 allRemoteMembersAreDesktopLocal,212 allRemoteMembersAreDesktopLocal,
187 memberProjects: members,213 memberProjects: members,
188 memberProjectRefs: members.map((member) => scopeProjectRef(member.environmentId, member.id)),214 memberProjectRefs: projectRefsByLogicalKey.get(logicalKey) ?? [],
189 remoteEnvironmentLabels,215 remoteEnvironmentLabels,
190 });216 });
191 }217 }
192218
193 return result;219 return result;
194}220}
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}