Skip to content
Merged
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
51 changes: 51 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";

import {
computeProjectDisambiguationPaths,
getVisibleThreadsForProject,
getProjectSortTimestamp,
hasUnseenCompletion,
Expand Down Expand Up @@ -742,3 +743,53 @@ describe("sortProjectsForSidebar", () => {
expect(timestamp).toBe(Date.parse("2026-03-09T10:10:00.000Z"));
});
});

describe("computeProjectDisambiguationPaths", () => {
it("returns empty map when all projects have unique names", () => {
const result = computeProjectDisambiguationPaths([
{ id: "p1", name: "api", cwd: "/home/user/api" },
{ id: "p2", name: "web", cwd: "/home/user/web" },
]);
expect(result.size).toBe(0);
});

it("disambiguates projects with the same name using parent directory", () => {
const result = computeProjectDisambiguationPaths([
{ id: "p1", name: "server", cwd: "/home/user/work/server" },
{ id: "p2", name: "server", cwd: "/home/user/personal/server" },
]);
expect(result.get("p1")).toBe("work");
expect(result.get("p2")).toBe("personal");
});

it("walks up multiple levels when parent directories also collide", () => {
const result = computeProjectDisambiguationPaths([
{ id: "p1", name: "server", cwd: "/home/user/work/apps/server" },
{ id: "p2", name: "server", cwd: "/home/user/personal/apps/server" },
]);
// One parent level ("apps") is the same, so it needs two levels
expect(result.get("p1")).toBe("work/apps");
expect(result.get("p2")).toBe("personal/apps");
});

it("does not include unique-named projects in the result", () => {
const result = computeProjectDisambiguationPaths([
{ id: "p1", name: "server", cwd: "/home/user/work/server" },
{ id: "p2", name: "server", cwd: "/home/user/personal/server" },
{ id: "p3", name: "client", cwd: "/home/user/client" },
]);
expect(result.has("p3")).toBe(false);
expect(result.size).toBe(2);
});

it("handles three or more projects with the same name", () => {
const result = computeProjectDisambiguationPaths([
{ id: "p1", name: "server", cwd: "/a/server" },
{ id: "p2", name: "server", cwd: "/b/server" },
{ id: "p3", name: "server", cwd: "/c/server" },
]);
expect(result.get("p1")).toBe("a");
expect(result.get("p2")).toBe("b");
expect(result.get("p3")).toBe("c");
});
});
69 changes: 69 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,75 @@ export function getProjectSortTimestamp(
return toSortableTimestamp(project.updatedAt ?? project.createdAt) ?? Number.NEGATIVE_INFINITY;
}

// ── Path disambiguation for duplicate project names ──────────────────

type DisambiguableProject = { id: string; name: string; cwd: string };

/**
* Computes disambiguating path labels for projects that share the same display name.
* Returns a Map from projectId to a short path hint (e.g. "~/work/api" vs "~/personal").
* Projects with unique names will not appear in the map.
*/
export function computeProjectDisambiguationPaths(
projects: readonly DisambiguableProject[],
): Map<string, string> {
const result = new Map<string, string>();

// Group projects by display name
const byName = new Map<string, DisambiguableProject[]>();
for (const project of projects) {
const group = byName.get(project.name) ?? [];
group.push(project);
byName.set(project.name, group);
}

for (const [, group] of byName) {
if (group.length <= 1) continue;

// Split each project's cwd into path segments (excluding the final segment which is the name)
const segmentsList = group.map((project) => {
const parts = project.cwd.replace(/\\/g, "/").replace(/\/+$/, "").split("/");
// Remove the last segment (the project folder name itself)
parts.pop();
return parts;
});

// Walk up the path segments until we find enough to disambiguate each project
// Start with 1 parent segment and increase until all are unique
let depth = 1;
const maxDepth = Math.max(...segmentsList.map((s) => s.length));

while (depth <= maxDepth) {
const suffixes = segmentsList.map((segments) => {
const start = Math.max(0, segments.length - depth);
return segments.slice(start).join("/");
});

// Check if all suffixes are unique
const unique = new Set(suffixes);
if (unique.size === group.length) {
for (let i = 0; i < group.length; i++) {
result.set(group[i]!.id, suffixes[i]!);
}
break;
}
depth++;
}

// If we exhausted depth and still have duplicates (identical paths), use full parent path
if (depth > maxDepth) {
for (let i = 0; i < group.length; i++) {
const fullParent = segmentsList[i]!.join("/");
if (fullParent) {
result.set(group[i]!.id, fullParent);
}
}
}
}

return result;
}

export function sortProjectsForSidebar<TProject extends SidebarProject, TThread extends Thread>(
projects: readonly TProject[],
threads: readonly TThread[],
Expand Down
108 changes: 86 additions & 22 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ import { isNonEmpty as isNonEmptyString } from "effect/String";
import { useTheme } from "~/hooks/useTheme";
import { FavesDropdown } from "~/components/FavesDropdown";
import {
computeProjectDisambiguationPaths,
getVisibleThreadsForProject,
isActionableThreadStatus,
resolveProjectStatusIndicator,
Expand All @@ -111,6 +112,7 @@ import {
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { WorkspaceFileTree } from "~/components/WorkspaceFileTree";
import { EditableThreadTitle } from "~/components/EditableThreadTitle";
import { useProjectTitleEditor } from "~/hooks/useProjectTitleEditor";
import { useThreadTitleEditor } from "~/hooks/useThreadTitleEditor";
import {
buildProjectScriptDraftsFromPackageScripts,
Expand Down Expand Up @@ -372,9 +374,7 @@ export default function Sidebar() {
const navigate = useNavigate();
const pathname = useLocation({ select: (loc) => loc.pathname });
const isOnSubPage =
pathname === "/settings" ||
pathname === "/pr-review" ||
pathname === "/merge-conflicts";
pathname === "/settings" || pathname === "/pr-review" || pathname === "/merge-conflicts";
const { settings: appSettings, updateSettings } = useAppSettings();
const { resolvedTheme } = useTheme();
const { handleNewThread } = useHandleNewThread();
Expand All @@ -398,9 +398,9 @@ export default function Sidebar() {
const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
ReadonlySet<ProjectId>
>(() => new Set());
const [filesCollapsedByProject, setFilesCollapsedByProject] = useState<
ReadonlySet<ProjectId>
>(() => new Set());
const [filesCollapsedByProject, setFilesCollapsedByProject] = useState<ReadonlySet<ProjectId>>(
() => new Set(),
);
const dragInProgressRef = useRef(false);
const suppressProjectClickAfterDragRef = useRef(false);
const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);
Expand Down Expand Up @@ -428,6 +428,19 @@ export default function Sidebar() {
setDraftTitle,
startEditing,
} = useThreadTitleEditor();
const {
editingProjectId,
draftProjectTitle,
bindProjectInputRef,
cancelProjectEditing,
commitProjectEditing,
setDraftProjectTitle,
startProjectEditing,
} = useProjectTitleEditor();
const projectDisambiguationPaths = useMemo(
() => computeProjectDisambiguationPaths(projects),
[projects],
);
const projectCwdById = useMemo(
() => new Map(projects.map((project) => [project.id, project.cwd] as const)),
[projects],
Expand Down Expand Up @@ -979,9 +992,23 @@ export default function Sidebar() {
const api = readNativeApi();
if (!api) return;
const clicked = await api.contextMenu.show(
[{ id: "delete", label: "Remove project", destructive: true }],
[
{ id: "rename", label: "Rename project" },
{ id: "delete", label: "Remove project", destructive: true },
],
position,
);

if (clicked === "rename") {
const project = projects.find((entry) => entry.id === projectId);
if (!project) return;
startProjectEditing({
projectId: project.id,
title: project.name,
});
return;
}

if (clicked !== "delete") return;

const project = projects.find((entry) => entry.id === projectId);
Expand Down Expand Up @@ -1026,6 +1053,7 @@ export default function Sidebar() {
clearProjectDraftThreadId,
getDraftThreadByProjectId,
projects,
startProjectEditing,
threads,
],
);
Expand Down Expand Up @@ -1396,21 +1424,57 @@ export default function Sidebar() {
/>
)}
<ProjectFavicon cwd={project.cwd} />
<span
className="flex-1 truncate text-[13px] font-semibold tracking-[0.01em]"
style={{
color:
appSettings.sidebarAccentProjectNames && appSettings.sidebarAccentColorOverride
? appSettings.sidebarAccentColorOverride
: appSettings.sidebarAccentProjectNames
? isDark
? pColor.textDark
: pColor.text
: undefined,
}}
>
{project.name}
</span>
{editingProjectId === project.id ? (
<input
ref={bindProjectInputRef}
type="text"
value={draftProjectTitle}
className="min-w-0 flex-1 rounded border border-primary/40 bg-background px-1 text-[13px] font-semibold tracking-[0.01em] outline-none focus:border-primary"
onChange={(e) => setDraftProjectTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void commitProjectEditing();
} else if (e.key === "Escape") {
e.preventDefault();
cancelProjectEditing();
}
}}
onBlur={() => void commitProjectEditing()}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="min-w-0 flex-1">
<span
className="block truncate text-[13px] font-semibold tracking-[0.01em]"
style={{
color:
appSettings.sidebarAccentProjectNames &&
appSettings.sidebarAccentColorOverride
? appSettings.sidebarAccentColorOverride
: appSettings.sidebarAccentProjectNames
? isDark
? pColor.textDark
: pColor.text
: undefined,
}}
onDoubleClick={(e) => {
e.stopPropagation();
startProjectEditing({
projectId: project.id,
title: project.name,
});
}}
>
{project.name}
</span>
{projectDisambiguationPaths.has(project.id) && (
<span className="block truncate text-[10px] leading-tight text-muted-foreground/50">
{projectDisambiguationPaths.get(project.id)}
</span>
)}
</span>
)}
</SidebarMenuButton>
<Tooltip>
<TooltipTrigger
Expand Down
Loading
Loading