Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
resolveSidebarV2Status,
resolveThreadStatusPill,
shouldClearThreadSelectionOnMouseDown,
sortLogicalProjectsForSidebar,
sortThreadsForSidebarV2,
sortProjectsForSidebar,
sortScopedProjectsForSidebar,
Expand Down Expand Up @@ -1304,3 +1305,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"]);
});
});
46 changes: 46 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,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<
Expand Down Expand Up @@ -701,6 +709,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<string, TThread[]>();
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
Expand Down
100 changes: 73 additions & 27 deletions apps/web/src/components/SidebarV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,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";
Expand All @@ -79,8 +81,10 @@ import {
firstValidTimestampMs,
hasUnseenCompletion,
isTrailingDoubleClick,
orderItemsByPreferredIds,
resolveAdjacentThreadId,
resolveSidebarV2Status,
sortLogicalProjectsForSidebar,
sortThreadsForSidebarV2,
} from "./Sidebar.logic";
import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators";
Expand Down Expand Up @@ -723,12 +727,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,
Expand Down Expand Up @@ -763,6 +770,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(
() =>
Expand Down Expand Up @@ -824,23 +865,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<string | null>(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(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
() =>
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(() => {
Expand All @@ -857,9 +904,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}`)),
Comment thread
cursor[bot] marked this conversation as resolved.
);
const active: EnvironmentThreadShell[] = [];
const settled: EnvironmentThreadShell[] = [];
Expand Down Expand Up @@ -893,7 +939,7 @@ export default function SidebarV2() {
autoSettleAfterDays,
changeRequestStateByKey,
nowMinute,
scopedProject,
scopedProjectKeys,
serverConfigs,
threads,
]);
Expand Down Expand Up @@ -1411,7 +1457,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) {
Comment thread
cursor[bot] marked this conversation as resolved.
if (isMobile) setOpenMobile(false);
void startNewThreadFromContext({
activeDraftThread: newThreadContext.activeDraftThread,
Expand All @@ -1423,7 +1469,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
Expand Down Expand Up @@ -1485,25 +1531,25 @@ export default function SidebarV2() {
</div>
</div>
</SidebarGroup>
{projects.length > 0 ? (
{projectGroups.length > 0 ? (
<SidebarGroup className="px-2 pb-2 pt-0">
<div className="flex items-center gap-1">
<Menu>
<MenuTrigger
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 ? (
<ProjectFavicon
environmentId={scopedProject.environmentId}
cwd={scopedProject.workspaceRoot}
environmentId={scopedProjectGroup.environmentId}
cwd={scopedProjectGroup.workspaceRoot}
className="size-4 shrink-0"
/>
) : (
<FolderIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />
)}
<span className="min-w-0 flex-1 truncate">
{scopedProject?.title ?? "All projects"}
{scopedProjectGroup?.displayName ?? "All projects"}
</span>
<ChevronDownIcon className="size-4 shrink-0 text-sidebar-muted-foreground/70" />
</MenuTrigger>
Expand All @@ -1522,8 +1568,8 @@ export default function SidebarV2() {
<FolderIcon className="size-3.5" />
<span className="min-w-0 truncate">All projects</span>
</MenuRadioItem>
{projects.map((project) => {
const scopeKey = `${project.environmentId}:${project.id}`;
{projectGroups.map((project) => {
const scopeKey = project.projectKey;
return (
<MenuRadioItem
key={scopeKey}
Expand All @@ -1536,7 +1582,7 @@ export default function SidebarV2() {
cwd={project.workspaceRoot}
className="size-3.5"
/>
<span className="min-w-0 truncate">{project.title}</span>
<span className="min-w-0 truncate">{project.displayName}</span>
</MenuRadioItem>
);
})}
Expand Down Expand Up @@ -1684,8 +1730,8 @@ export default function SidebarV2() {
Add project
</button>
</>
) : scopedProject ? (
`No threads in ${scopedProject.title} yet`
) : scopedProjectGroup ? (
`No threads in ${scopedProjectGroup.displayName} yet`
) : (
"No threads yet"
)}
Expand Down
Loading
Loading