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
14 changes: 14 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,20 @@ describe("resolveAdjacentThreadId", () => {
direction: "previous",
}),
).toBe(threads[2]);
expect(
resolveAdjacentThreadId({
threadIds: threads,
currentThreadId: ThreadId.make("hidden-thread"),
direction: "next",
}),
).toBeNull();
expect(
resolveAdjacentThreadId({
threadIds: threads,
currentThreadId: ThreadId.make("hidden-thread"),
direction: "previous",
}),
).toBeNull();
expect(
resolveAdjacentThreadId({
threadIds: threads,
Expand Down
185 changes: 174 additions & 11 deletions apps/web/src/components/Sidebar.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ import {
import { Input } from "./ui/input";
import {
Menu,
MenuCheckboxItem,
MenuGroup,
MenuPopup,
MenuRadioGroup,
Expand Down Expand Up @@ -243,6 +244,13 @@ const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> =
const SIDEBAR_ICON_ACTION_BUTTON_CLASS =
"inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring";

interface SidebarEnvironmentVisibilityOption {
environmentId: string;
label: string;
visible: boolean;
projectCount: number;
}

function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) {
useEnvironmentThread(threadRef.environmentId, threadRef.threadId);
return null;
Expand Down Expand Up @@ -2700,6 +2708,76 @@ function ProjectSortMenu({
);
}

function EnvironmentVisibilityMenu({
environments,
hiddenCount,
onVisibilityChange,
}: {
environments: readonly SidebarEnvironmentVisibilityOption[];
hiddenCount: number;
onVisibilityChange: (environmentId: string, visible: boolean) => void;
}) {
if (environments.length <= 1) {
return null;
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

const visibleEnvironmentCount = environments.filter((environment) => environment.visible).length;

return (
<Menu>
<Tooltip>
<TooltipTrigger
render={
<MenuTrigger className="inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" />
}
>
<Globe2Icon className="size-3.5" />
</TooltipTrigger>
<TooltipPopup side="right">
{hiddenCount > 0
? `${hiddenCount} hidden environment${hiddenCount === 1 ? "" : "s"}`
: "Environments"}
</TooltipPopup>
</Tooltip>
<MenuPopup
align="start"
collisionAvoidance={{ side: "none", align: "none", fallbackAxisSide: "none" }}
side="bottom"
className="min-w-56"
>
<MenuGroup>
<div className="px-2 py-1 font-medium text-muted-foreground sm:text-xs">Environments</div>
{environments.map((environment) => {
const isLastVisibleEnvironment = environment.visible && visibleEnvironmentCount <= 1;
return (
<MenuCheckboxItem
key={environment.environmentId}
checked={environment.visible}
className="min-h-7 py-1 sm:text-xs"
disabled={isLastVisibleEnvironment}
variant="switch"
onCheckedChange={(checked) => {
if (isLastVisibleEnvironment && checked !== true) {
return;
}
onVisibilityChange(environment.environmentId, checked === true);
}}
>
<span className="grid min-w-0 grid-cols-[1fr_auto] items-center gap-3">
<span className="truncate">{environment.label}</span>
<span className="text-muted-foreground/60">
{environment.projectCount} project{environment.projectCount === 1 ? "" : "s"}
</span>
</span>
</MenuCheckboxItem>
);
})}
</MenuGroup>
</MenuPopup>
</Menu>
);
}

function SortableProjectItem({
projectId,
disabled = false,
Expand Down Expand Up @@ -2841,7 +2919,10 @@ interface SidebarProjectsContentProps {
threadSortOrder: SidebarThreadSortOrder;
projectGroupingMode: SidebarProjectGroupingMode;
threadPreviewCount: SidebarThreadPreviewCount;
environmentVisibilityOptions: readonly SidebarEnvironmentVisibilityOption[];
hiddenEnvironmentCount: number;
updateSettings: ReturnType<typeof useUpdateClientSettings>;
setSidebarEnvironmentVisible: (environmentId: string, visible: boolean) => void;
openAddProject: () => void;
isManualProjectSorting: boolean;
projectDnDSensors: ReturnType<typeof useSensors>;
Expand Down Expand Up @@ -2882,7 +2963,10 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
threadSortOrder,
projectGroupingMode,
threadPreviewCount,
environmentVisibilityOptions,
hiddenEnvironmentCount,
updateSettings,
setSidebarEnvironmentVisible,
openAddProject,
isManualProjectSorting,
projectDnDSensors,
Expand Down Expand Up @@ -2934,6 +3018,12 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
},
[updateSettings],
);
const handleEnvironmentVisibilityChange = useCallback(
(environmentId: string, visible: boolean) => {
setSidebarEnvironmentVisible(environmentId, visible);
},
[setSidebarEnvironmentVisible],
);

return (
<SidebarContent className="gap-0">
Expand Down Expand Up @@ -3000,6 +3090,11 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
onProjectGroupingModeChange={handleProjectGroupingModeChange}
onThreadPreviewCountChange={handleThreadPreviewCountChange}
/>
<EnvironmentVisibilityMenu
environments={environmentVisibilityOptions}
hiddenCount={hiddenEnvironmentCount}
onVisibilityChange={handleEnvironmentVisibilityChange}
/>
<Tooltip>
<TooltipTrigger
render={
Expand Down Expand Up @@ -3107,7 +3202,13 @@ export default function Sidebar() {
const sidebarThreads = useThreadShells();
const projectExpandedById = useUiStateStore((store) => store.projectExpandedById);
const projectOrder = useUiStateStore((store) => store.projectOrder);
const sidebarEnvironmentHiddenById = useUiStateStore(
(store) => store.sidebarEnvironmentHiddenById,
);
const reorderProjects = useUiStateStore((store) => store.reorderProjects);
const setSidebarEnvironmentVisible = useUiStateStore(
(store) => store.setSidebarEnvironmentVisible,
);
const navigate = useNavigate();
const pathname = useLocation({ select: (loc) => loc.pathname });
const isOnSettings = pathname.startsWith("/settings");
Expand Down Expand Up @@ -3162,6 +3263,27 @@ export default function Sidebar() {
),
[environments],
);
const environmentVisibilityOptions = useMemo<SidebarEnvironmentVisibilityOption[]>(() => {
const projectCountsByEnvironmentId = new Map<string, number>();
for (const project of projects) {
projectCountsByEnvironmentId.set(
project.environmentId,
(projectCountsByEnvironmentId.get(project.environmentId) ?? 0) + 1,
);
}
const canHideSidebarEnvironments = environments.length > 1;
return environments.map((environment) => ({
environmentId: environment.environmentId,
label: environment.label,
visible:
!canHideSidebarEnvironments ||
sidebarEnvironmentHiddenById[environment.environmentId] !== true,
projectCount: projectCountsByEnvironmentId.get(environment.environmentId) ?? 0,
}));
}, [environments, projects, sidebarEnvironmentHiddenById]);
const hiddenEnvironmentCount = environmentVisibilityOptions.filter(
(environment) => !environment.visible,
).length;
const orderedProjects = useMemo(() => {
return orderItemsByPreferredIds({
items: projects,
Expand All @@ -3173,28 +3295,59 @@ export default function Sidebar() {
],
});
}, [projectOrder, projects]);
const visibleOrderedProjects = useMemo(
() =>
orderedProjects.filter(
(project) =>
environments.length <= 1 || sidebarEnvironmentHiddenById[project.environmentId] !== true,
),
[environments.length, orderedProjects, sidebarEnvironmentHiddenById],
);
const visibleSidebarThreads = useMemo(
() =>
sidebarThreads.filter(
(thread) =>
environments.length <= 1 || sidebarEnvironmentHiddenById[thread.environmentId] !== true,
),
[environments.length, sidebarEnvironmentHiddenById, sidebarThreads],
);

// Build a mapping from physical project key → logical project key for
// cross-environment grouping. Projects that share a repositoryIdentity
// canonicalKey are treated as one logical project in the sidebar.
const physicalToLogicalKey = useMemo(() => {
return buildPhysicalToLogicalProjectKeyMap({
projects: orderedProjects,
projects: visibleOrderedProjects,
settings: projectGroupingSettings,
});
}, [orderedProjects, projectGroupingSettings]);
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
}, [visibleOrderedProjects, projectGroupingSettings]);
const projectPhysicalKeyByScopedRef = useMemo(
() =>
new Map(
orderedProjects.map((project) => [
visibleOrderedProjects.map((project) => [
scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
derivePhysicalProjectKey(project),
]),
),
[orderedProjects],
[visibleOrderedProjects],
);

const sidebarProjects = useMemo<SidebarProjectSnapshot[]>(() => {
return buildSidebarProjectSnapshots({
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
projects: visibleOrderedProjects,
settings: projectGroupingSettings,
primaryEnvironmentId,
resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null,
isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId),
});
}, [
environmentLabelById,
desktopLocalEnvironmentIds,
visibleOrderedProjects,
projectGroupingSettings,
primaryEnvironmentId,
]);
const sidebarProjectsForProjectOrder = useMemo<SidebarProjectSnapshot[]>(() => {
return buildSidebarProjectSnapshots({
projects: orderedProjects,
settings: projectGroupingSettings,
Expand All @@ -3209,6 +3362,13 @@ export default function Sidebar() {
projectGroupingSettings,
primaryEnvironmentId,
]);
const sidebarProjectForProjectOrderByKey = useMemo(
() =>
new Map(
sidebarProjectsForProjectOrder.map((project) => [project.projectKey, project] as const),
),
[sidebarProjectsForProjectOrder],
);

const sidebarProjectByKey = useMemo(
() => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)),
Expand Down Expand Up @@ -3243,7 +3403,7 @@ export default function Sidebar() {
// are displayed together.
const threadsByProjectKey = useMemo(() => {
const next = new Map<string, SidebarThreadSummary[]>();
for (const thread of sidebarThreads) {
for (const thread of visibleSidebarThreads) {
const physicalKey =
projectPhysicalKeyByScopedRef.get(
scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
Expand All @@ -3257,7 +3417,7 @@ export default function Sidebar() {
}
}
return next;
}, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]);
}, [visibleSidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]);
const getCurrentSidebarShortcutContext = useCallback(
() => ({
terminalFocus: isTerminalFocused(),
Expand Down Expand Up @@ -3320,16 +3480,16 @@ export default function Sidebar() {
dragInProgressRef.current = false;
const { active, over } = event;
if (!over || active.id === over.id) return;
const activeProject = sidebarProjects.find((project) => project.projectKey === active.id);
const overProject = sidebarProjects.find((project) => project.projectKey === over.id);
const activeProject = sidebarProjectForProjectOrderByKey.get(String(active.id));
const overProject = sidebarProjectForProjectOrderByKey.get(String(over.id));
if (!activeProject || !overProject) return;
const activeMemberKeys = activeProject.memberProjects.map(
(member) => member.physicalProjectKey,
);
const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey);
reorderProjects(orderedProjects.map(getProjectOrderKey), activeMemberKeys, overMemberKeys);
},
[orderedProjects, sidebarProjectSortOrder, reorderProjects, sidebarProjects],
[orderedProjects, sidebarProjectForProjectOrderByKey, sidebarProjectSortOrder, reorderProjects],
);

const handleProjectDragStart = useCallback(
Expand Down Expand Up @@ -3366,8 +3526,8 @@ export default function Sidebar() {
}, []);

const visibleThreads = useMemo(
() => sidebarThreads.filter((thread) => thread.archivedAt === null),
[sidebarThreads],
() => visibleSidebarThreads.filter((thread) => thread.archivedAt === null),
[visibleSidebarThreads],
);
const sortedProjects = useMemo(() => {
const sortableProjects = sidebarProjects.map((project) => ({
Expand Down Expand Up @@ -3714,7 +3874,10 @@ export default function Sidebar() {
threadSortOrder={sidebarThreadSortOrder}
projectGroupingMode={sidebarProjectGroupingMode}
threadPreviewCount={sidebarThreadPreviewCount}
environmentVisibilityOptions={environmentVisibilityOptions}
hiddenEnvironmentCount={hiddenEnvironmentCount}
updateSettings={updateSettings}
setSidebarEnvironmentVisible={setSidebarEnvironmentVisible}
openAddProject={openAddProjectCommandPalette}
isManualProjectSorting={isManualProjectSorting}
projectDnDSensors={projectDnDSensors}
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/ui/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@ function MenuPopup({
alignOffset,
side = "bottom",
anchor,
collisionAvoidance,
...props
}: MenuPrimitive.Popup.Props & {
align?: MenuPrimitive.Positioner.Props["align"];
sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"];
alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"];
side?: MenuPrimitive.Positioner.Props["side"];
anchor?: MenuPrimitive.Positioner.Props["anchor"];
collisionAvoidance?: MenuPrimitive.Positioner.Props["collisionAvoidance"];
}) {
return (
<MenuPrimitive.Portal>
Expand All @@ -43,6 +45,7 @@ function MenuPopup({
alignOffset={alignOffset}
anchor={anchor}
className="z-50"
collisionAvoidance={collisionAvoidance}
data-slot="menu-positioner"
side={side}
sideOffset={sideOffset}
Expand Down
Loading
Loading