From ce8d699b9ec4030796eb8bac51c6ad156bf5f23b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 23 Jul 2026 00:15:50 +0200 Subject: [PATCH 1/5] restore grouped projects in Sidebar V2 Co-authored-by: codex --- apps/web/src/components/Sidebar.logic.test.ts | 38 ++++++ apps/web/src/components/Sidebar.logic.ts | 46 ++++++++ apps/web/src/components/SidebarV2.tsx | 95 +++++++++++---- apps/web/src/environmentGrouping.test.ts | 109 ++++++++++++++++++ apps/web/src/sidebarProjectGrouping.ts | 70 ++++++++++- 5 files changed, 332 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 9c71c7beaf9..72b5f8f6b21 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -22,6 +22,7 @@ import { resolveThreadStatusPill, shouldNavigateAfterProjectRemoval, shouldClearThreadSelectionOnMouseDown, + sortLogicalProjectsForSidebar, sortThreadsForSidebarV2, sortProjectsForSidebar, sortScopedProjectsForSidebar, @@ -1353,3 +1354,40 @@ describe("sortScopedProjectsForSidebar", () => { ]); }); }); + +describe("sortLogicalProjectsForSidebar", () => { + it("uses saved order only in manual mode and activity order otherwise", () => { + const olderProjectId = ProjectId.make("project-older"); + const newerProjectId = ProjectId.make("project-newer"); + const projects = [ + { + ...makeProject({ id: olderProjectId, title: "Older project" }), + projectKey: "logical-older", + memberProjectRefs: [{ environmentId: localEnvironmentId, projectId: olderProjectId }], + }, + { + ...makeProject({ id: newerProjectId, title: "Newer project" }), + projectKey: "logical-newer", + memberProjectRefs: [{ environmentId: localEnvironmentId, projectId: newerProjectId }], + }, + ]; + const threads = [ + makeThread({ + projectId: olderProjectId, + updatedAt: "2026-03-09T10:01:00.000Z", + }), + makeThread({ + id: ThreadId.make("thread-newer"), + projectId: newerProjectId, + updatedAt: "2026-03-09T10:05:00.000Z", + }), + ]; + + expect(sortLogicalProjectsForSidebar(projects, threads, "manual")).toEqual(projects); + expect( + sortLogicalProjectsForSidebar(projects, threads, "updated_at").map( + (project) => project.projectKey, + ), + ).toEqual(["logical-newer", "logical-older"]); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index feb06891901..04124feb58a 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -36,6 +36,14 @@ type ScopedSidebarThread = ThreadSortInput & { archivedAt: string | null; }; +type LogicalSidebarProject = SidebarProject & { + projectKey: string; + memberProjectRefs: readonly { + environmentId: string; + projectId: string; + }[]; +}; + export type ThreadTraversalDirection = "previous" | "next"; export async function archiveSelectedThreadEntries< @@ -727,6 +735,44 @@ export function sortProjectsForSidebar< ); } +export function sortLogicalProjectsForSidebar< + TProject extends LogicalSidebarProject, + TThread extends ScopedSidebarThread, +>( + projects: readonly TProject[], + threads: readonly TThread[], + sortOrder: SidebarProjectSortOrder, +): TProject[] { + const groupKeyByProjectRef = new Map( + projects.flatMap((project) => + project.memberProjectRefs.map( + (projectRef) => + [`${projectRef.environmentId}\0${projectRef.projectId}`, project.projectKey] as const, + ), + ), + ); + const threadsByProjectKey = new Map(); + for (const thread of threads) { + if (thread.archivedAt !== null) continue; + const projectKey = groupKeyByProjectRef.get(`${thread.environmentId}\0${thread.projectId}`); + if (!projectKey) continue; + const existing = threadsByProjectKey.get(projectKey); + if (existing) { + existing.push(thread); + } else { + threadsByProjectKey.set(projectKey, [thread]); + } + } + + return sortProjectsByActivity( + projects, + sortOrder, + (project) => threadsByProjectKey.get(project.projectKey) ?? [], + (left, right) => + left.title.localeCompare(right.title) || left.projectKey.localeCompare(right.projectKey), + ); +} + /** * Sorts the cross-environment project collection used by landing surfaces. * Project ids are only unique within an environment, and archived threads diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 40e6377ede4..4a28bbb0a69 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -61,7 +61,9 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { isMacPlatform } from "~/lib/utils"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { readLocalApi } from "../localApi"; -import { useUiStateStore } from "../uiStateStore"; +import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; +import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; +import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; @@ -84,6 +86,7 @@ import { firstValidTimestampMs, hasUnseenCompletion, isTrailingDoubleClick, + orderItemsByPreferredIds, resolveAdjacentThreadId, resolveSidebarV2Status, shouldNavigateAfterProjectRemoval, @@ -716,12 +719,15 @@ function latestTurnDiff( export default function SidebarV2() { const projects = useProjects(); + const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); + const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const { settleThread, unsettleThread, deleteThread } = useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, @@ -762,6 +768,40 @@ export default function SidebarV2() { ), [environments], ); + const orderedProjects = useMemo( + () => + orderItemsByPreferredIds({ + items: projects, + preferredIds: projectOrder, + getId: getProjectOrderKey, + getPreferenceIds: (project) => [ + getProjectOrderKey(project), + legacyProjectCwdPreferenceKey(project.workspaceRoot), + ], + }), + [projectOrder, projects], + ); + const unsortedProjectGroups = useMemo( + () => + buildSidebarProjectSnapshots({ + projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, + }), + [ + environmentLabelById, + orderedProjects, + primaryEnvironmentId, + projectGroupingSettings, + projects, + sidebarProjectSortOrder, + ], + ); + const projectGroups = useMemo( + () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), + [sidebarProjectSortOrder, threads, unsortedProjectGroups], + ); const serverProviders = useAtomValue(primaryServerProvidersAtom); const providerEntryByInstanceId = useMemo( () => @@ -823,23 +863,29 @@ export default function SidebarV2() { // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. const [projectScopeKey, setProjectScopeKey] = useState(null); - const scopedProject = useMemo( + const scopedProjectGroup = useMemo( () => projectScopeKey === null ? null - : (projects.find( - (project) => `${project.environmentId}:${project.id}` === projectScopeKey, - ) ?? null), - [projectScopeKey, projects], + : (projectGroups.find((project) => project.projectKey === projectScopeKey) ?? null), + [projectGroups, projectScopeKey], + ); + const scopedProjectKeys = useMemo( + () => + scopedProjectGroup === null + ? null + : new Set( + scopedProjectGroup.memberProjectRefs.map( + (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`, + ), + ), + [scopedProjectGroup], ); useEffect(() => { - if ( - projectScopeKey !== null && - !projects.some((project) => `${project.environmentId}:${project.id}` === projectScopeKey) - ) { + if (projectScopeKey !== null && scopedProjectGroup === null) { setProjectScopeKey(null); } - }, [projectScopeKey, projects]); + }, [projectScopeKey, scopedProjectGroup]); // Scope flips drop the selection: rows selected under the old scope may be // hidden now, and bulk actions must never count or touch invisible rows. useEffect(() => { @@ -925,9 +971,8 @@ export default function SidebarV2() { const visible = threads.filter( (thread) => thread.archivedAt === null && - (scopedProject === null || - (thread.environmentId === scopedProject.environmentId && - thread.projectId === scopedProject.id)), + (scopedProjectKeys === null || + scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), ); const active: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; @@ -961,7 +1006,7 @@ export default function SidebarV2() { autoSettleAfterDays, changeRequestStateByKey, nowMinute, - scopedProject, + scopedProjectKeys, serverConfigs, threads, ]); @@ -1479,7 +1524,7 @@ export default function SidebarV2() { // for multi-project setups. const handleNewThreadClick = useCallback(() => { // One project: nothing to pick, create immediately. - if (projects.length <= 1) { + if (projectGroups.length <= 1) { if (isMobile) setOpenMobile(false); void startNewThreadFromContext({ activeDraftThread: newThreadContext.activeDraftThread, @@ -1553,7 +1598,7 @@ export default function SidebarV2() { - {projects.length > 0 ? ( + {projectGroups.length > 0 ? (
@@ -1561,17 +1606,17 @@ export default function SidebarV2() { aria-label="Filter threads by project" 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" > - {scopedProject ? ( + {scopedProjectGroup ? ( ) : ( )} - {scopedProject?.title ?? "All projects"} + {scopedProjectGroup?.displayName ?? "All projects"} @@ -1590,8 +1635,8 @@ export default function SidebarV2() { All projects - {projects.map((project) => { - const scopeKey = `${project.environmentId}:${project.id}`; + {projectGroups.map((project) => { + const scopeKey = project.projectKey; return ( - ) : scopedProject ? ( - `No threads in ${scopedProject.title} yet` + ) : scopedProjectGroup ? ( + `No threads in ${scopedProjectGroup.displayName} yet` ) : ( "No threads yet" )} diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 2e4e9c7f3dd..17d86ca0912 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -5,12 +5,16 @@ import { deriveLogicalProjectKey, deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKey, + getProjectOrderKey, resolveProjectGroupingMode, } from "./logicalProject"; import { buildPhysicalToLogicalProjectKeyMap, + buildSidebarProjectPickerEntries, buildSidebarProjectSnapshots, } from "./sidebarProjectGrouping"; +import { orderItemsByPreferredIds } from "./components/Sidebar.logic"; +import { legacyProjectCwdPreferenceKey } from "./uiStateStore"; import type { Project } from "./types"; const primaryEnvironmentId = EnvironmentId.make("env-primary"); @@ -59,6 +63,24 @@ describe("environment grouping", () => { expect(deriveLogicalProjectKey(remote)).toBe(repositoryIdentity.canonicalKey); }); + it("counts cross-environment copies as one new-thread project choice", () => { + const primary = makeProject({ repositoryIdentity }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + + const projectGroupCount = buildSidebarProjectSnapshots({ + projects: [primary, remote], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }).length; + + expect(projectGroupCount).toBe(1); + }); + it("keeps projects without repository identity physically scoped", () => { const primary = makeProject(); const remote = makeProject({ @@ -216,6 +238,24 @@ describe("environment grouping", () => { canonical.id, remote.id, ]); + expect(snapshots[0]?.memberProjectRefs).toEqual([ + { + environmentId: primaryEnvironmentId, + projectId: staleWithoutRepositoryIdentity.id, + }, + { environmentId: primaryEnvironmentId, projectId: canonical.id }, + { environmentId: remoteEnvironmentId, projectId: remote.id }, + ]); + + const [pickerEntry] = buildSidebarProjectPickerEntries({ + groups: snapshots, + preferredProjectRef: { + environmentId: primaryEnvironmentId, + projectId: staleWithoutRepositoryIdentity.id, + }, + }); + expect(pickerEntry?.isPreferred).toBe(true); + expect(pickerEntry?.targetProject.id).toBe(canonical.id); }); it("routes duplicate physical project keys to the winning logical group", () => { @@ -240,4 +280,73 @@ describe("environment grouping", () => { repositoryIdentity.canonicalKey, ); }); + + it("builds one picker entry per logical project and targets the preferred environment", () => { + const primary = makeProject({ repositoryIdentity }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + const separate = makeProject({ + id: ProjectId.make("project-separate"), + title: "separate", + workspaceRoot: "/tmp/separate", + }); + const groups = buildSidebarProjectSnapshots({ + projects: [separate, primary, remote], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }); + + const entries = buildSidebarProjectPickerEntries({ + groups, + preferredProjectRef: { + environmentId: remoteEnvironmentId, + projectId: remote.id, + }, + }); + + expect(entries).toHaveLength(2); + expect(entries[0]?.group.projectKey).toBe(repositoryIdentity.canonicalKey); + expect(entries[0]?.targetProject).toMatchObject({ + environmentId: remoteEnvironmentId, + id: remote.id, + }); + expect(entries[0]?.isPreferred).toBe(true); + expect(entries[1]?.group.displayName).toBe("separate"); + }); + + it("keeps manual project order when building grouped sidebar entries", () => { + const primary = makeProject({ repositoryIdentity }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + const separate = makeProject({ + id: ProjectId.make("project-separate"), + title: "separate", + workspaceRoot: "/tmp/separate", + }); + const orderedProjects = orderItemsByPreferredIds({ + items: [primary, remote, separate], + preferredIds: [getProjectOrderKey(separate), getProjectOrderKey(primary)], + getId: getProjectOrderKey, + getPreferenceIds: (project) => [ + getProjectOrderKey(project), + legacyProjectCwdPreferenceKey(project.workspaceRoot), + ], + }); + + const groups = buildSidebarProjectSnapshots({ + projects: orderedProjects, + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }); + + expect(groups.map((group) => group.displayName)).toEqual(["separate", "shared-repo"]); + }); }); diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 7ca9370b514..32299b565f4 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -31,6 +31,12 @@ export interface SidebarProjectSnapshot extends Project { remoteEnvironmentLabels: readonly string[]; } +export interface SidebarProjectPickerEntry { + group: SidebarProjectSnapshot; + targetProject: SidebarProjectGroupMember; + isPreferred: boolean; +} + interface SidebarProjectGroupCandidate { readonly logicalKey: string; readonly project: Project; @@ -133,6 +139,26 @@ export function buildSidebarProjectSnapshots(input: { } } + const projectRefsByLogicalKey = new Map(); + const seenProjectRefs = new Set(); + for (const project of input.projects) { + const physicalProjectKey = derivePhysicalProjectKey(project); + const logicalKey = + winnersByPhysicalKey.get(physicalProjectKey)?.logicalKey ?? + deriveLogicalProjectKeyFromSettings(project, input.settings); + const projectRefKey = `${project.environmentId}:${project.id}`; + if (seenProjectRefs.has(projectRefKey)) continue; + seenProjectRefs.add(projectRefKey); + + const projectRef = scopeProjectRef(project.environmentId, project.id); + const existingRefs = projectRefsByLogicalKey.get(logicalKey); + if (existingRefs) { + existingRefs.push(projectRef); + } else { + projectRefsByLogicalKey.set(logicalKey, [projectRef]); + } + } + const result: SidebarProjectSnapshot[] = []; const seen = new Set(); for (const project of input.projects) { @@ -185,10 +211,52 @@ export function buildSidebarProjectSnapshots(input: { hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", allRemoteMembersAreDesktopLocal, memberProjects: members, - memberProjectRefs: members.map((member) => scopeProjectRef(member.environmentId, member.id)), + memberProjectRefs: projectRefsByLogicalKey.get(logicalKey) ?? [], remoteEnvironmentLabels, }); } return result; } + +export function buildSidebarProjectPickerEntries(input: { + groups: ReadonlyArray; + preferredProjectRef: ScopedProjectRef | null; +}) { + const entries = input.groups.flatMap((group): SidebarProjectPickerEntry[] => { + const isPreferred = input.preferredProjectRef + ? group.memberProjectRefs.some( + (projectRef) => + projectRef.environmentId === input.preferredProjectRef?.environmentId && + projectRef.projectId === input.preferredProjectRef.projectId, + ) + : false; + const preferredProject = isPreferred + ? (group.memberProjects.find( + (project) => + project.environmentId === input.preferredProjectRef?.environmentId && + project.id === input.preferredProjectRef?.projectId, + ) ?? + group.memberProjects.find( + (project) => project.environmentId === input.preferredProjectRef?.environmentId, + )) + : null; + const targetProject = + preferredProject ?? + group.memberProjects.find( + (project) => project.environmentId === group.environmentId && project.id === group.id, + ) ?? + group.memberProjects[0]; + if (!targetProject) return []; + + return [{ group, targetProject, isPreferred }]; + }); + const preferredIndex = entries.findIndex((entry) => entry.isPreferred); + if (preferredIndex <= 0) return entries; + + return [ + entries[preferredIndex]!, + ...entries.slice(0, preferredIndex), + ...entries.slice(preferredIndex + 1), + ]; +} From a4061ce9ad6eb6ce51bd5e37f44068f401ea09c0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 23 Jul 2026 01:13:47 +0200 Subject: [PATCH 2/5] fix grouped project callback dependency Co-authored-by: codex --- apps/web/src/components/SidebarV2.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 4a28bbb0a69..d8d06b0ba26 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1536,7 +1536,7 @@ export default function SidebarV2() { } if (isMobile) setOpenMobile(false); openCommandPalette({ open: "new-thread-in" }); - }, [isMobile, newThreadContext, projects.length, setOpenMobile]); + }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]); const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); // Same resolution as v1: prefer the local-thread binding, fall back to From 2e484a2707b081c11e6253017767c9d0b8a9285b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 23 Jul 2026 14:22:07 +0200 Subject: [PATCH 3/5] restore grouped project sorter import Co-authored-by: codex --- apps/web/src/components/SidebarV2.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index d8d06b0ba26..eefdd88875b 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -90,6 +90,7 @@ import { resolveAdjacentThreadId, resolveSidebarV2Status, shouldNavigateAfterProjectRemoval, + sortLogicalProjectsForSidebar, sortThreadsForSidebarV2, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; From ebb4fa0b5632ee74b140a80c007dc0401e4b5bf0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 23 Jul 2026 14:31:48 +0200 Subject: [PATCH 4/5] fix grouped project menu actions Co-authored-by: codex --- apps/web/src/components/SidebarV2.tsx | 116 +++++++++++++++----------- 1 file changed, 68 insertions(+), 48 deletions(-) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index eefdd88875b..3035ebca6f5 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1,10 +1,7 @@ import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; -import type { - EnvironmentProject, - EnvironmentThreadShell, -} from "@t3tools/client-runtime/state/models"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { scopeProjectRef, scopeThreadRef, @@ -62,7 +59,10 @@ import { isMacPlatform } from "~/lib/utils"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { readLocalApi } from "../localApi"; import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; -import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; +import { + buildSidebarProjectSnapshots, + type SidebarProjectSnapshot, +} from "../sidebarProjectGrouping"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; @@ -894,66 +894,86 @@ export default function SidebarV2() { }, [clearSelection, projectScopeKey]); const handleRemoveProject = useCallback( - async (project: EnvironmentProject) => { + async (projectGroup: SidebarProjectSnapshot) => { const api = readLocalApi(); if (!api) return; - const projectThreads = threads.filter( - (thread) => - thread.environmentId === project.environmentId && thread.projectId === project.id, + const memberKeys = new Set( + projectGroup.memberProjectRefs.map( + (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`, + ), + ); + const projectThreads = threads.filter((thread) => + memberKeys.has(`${thread.environmentId}:${thread.projectId}`), ); const confirmed = await settlePromise(() => api.dialogs.confirm( projectThreads.length > 0 ? [ - `Remove project "${project.title}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`, - `Path: ${project.workspaceRoot}`, + `Remove project "${projectGroup.displayName}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`, + ...(projectGroup.memberProjects.length === 1 + ? [`Path: ${projectGroup.workspaceRoot}`] + : [ + `This removes ${projectGroup.memberProjects.length} grouped project entries.`, + ]), "This permanently clears conversation history for those threads.", - "This removes only the project entry, not the files on disk.", + "This removes only the project entries, not the files on disk.", "This action cannot be undone.", ].join("\n") : [ - `Remove project "${project.title}"?`, - `Path: ${project.workspaceRoot}`, - "This removes only the project entry, not the files on disk.", + `Remove project "${projectGroup.displayName}"?`, + ...(projectGroup.memberProjects.length === 1 + ? [`Path: ${projectGroup.workspaceRoot}`] + : [ + `This removes ${projectGroup.memberProjects.length} grouped project entries.`, + ]), + "This removes only the project entries, not the files on disk.", ].join("\n"), ), ); if (confirmed._tag === "Failure" || !confirmed.value) return; - const projectRef = scopeProjectRef(project.environmentId, project.id); - const result = await deleteProject({ - environmentId: project.environmentId, - input: { - projectId: project.id, - ...(projectThreads.length > 0 ? { force: true } : {}), - }, - }); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Failed to remove "${project.title}"`, - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); + const draftStore = useComposerDraftStore.getState(); + let shouldNavigate = false; + for (const project of projectGroup.memberProjects) { + const memberThreads = projectThreads.filter( + (thread) => + thread.environmentId === project.environmentId && thread.projectId === project.id, + ); + const projectRef = scopeProjectRef(project.environmentId, project.id); + const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); + shouldNavigate ||= shouldNavigateAfterProjectRemoval({ + routeTarget: routeTargetRef.current, + projectThreads: memberThreads, + projectDraftId: projectDraftThread?.draftId ?? null, + }); + + const result = await deleteProject({ + environmentId: project.environmentId, + input: { + projectId: project.id, + ...(memberThreads.length > 0 ? { force: true } : {}), + }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to remove "${project.title}"`, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; } - return; - } - const draftStore = useComposerDraftStore.getState(); - const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); - const shouldNavigate = shouldNavigateAfterProjectRemoval({ - routeTarget: routeTargetRef.current, - projectThreads, - projectDraftId: projectDraftThread?.draftId ?? null, - }); - if (projectDraftThread) { - draftStore.clearDraftThread(projectDraftThread.draftId); + if (projectDraftThread) { + draftStore.clearDraftThread(projectDraftThread.draftId); + } + draftStore.clearProjectDraftThreadId(projectRef); } - draftStore.clearProjectDraftThreadId(projectRef); if (shouldNavigate) { void router.navigate({ to: "/" }); @@ -1650,11 +1670,11 @@ export default function SidebarV2() { cwd={project.workspaceRoot} className="size-4 shrink-0" /> - {project.title} + {project.displayName}