From 3dae9e63b57665fc8e28edd7294f1221b2327fb9 Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Tue, 21 Apr 2026 01:36:29 +0300 Subject: [PATCH 1/5] chore(web): port path normalization helpers (prereq for project grouping) Extracts normalizeProjectPathForComparison and path regex helpers from upstream #2024 in isolation, so they're available as a dependency for the configurable project grouping port (#2055). Adds @marcode/shared/path subpath export. Co-Authored-By: Claude Opus 4.7 --- apps/web/src/lib/projectPaths.test.ts | 117 ++++++++++++ apps/web/src/lib/projectPaths.ts | 259 ++++++++++++++++++++++++++ packages/shared/package.json | 4 + packages/shared/src/path.test.ts | 34 ++++ packages/shared/src/path.ts | 22 +++ 5 files changed, 436 insertions(+) create mode 100644 apps/web/src/lib/projectPaths.test.ts create mode 100644 apps/web/src/lib/projectPaths.ts create mode 100644 packages/shared/src/path.test.ts create mode 100644 packages/shared/src/path.ts diff --git a/apps/web/src/lib/projectPaths.test.ts b/apps/web/src/lib/projectPaths.test.ts new file mode 100644 index 00000000000..7989622e1d5 --- /dev/null +++ b/apps/web/src/lib/projectPaths.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; + +import { + appendBrowsePathSegment, + canNavigateUp, + getBrowseDirectoryPath, + findProjectByPath, + getBrowseLeafPathSegment, + getBrowseParentPath, + hasTrailingPathSeparator, + inferProjectTitleFromPath, + isExplicitRelativeProjectPath, + isFilesystemBrowseQuery, + normalizeProjectPathForComparison, + normalizeProjectPathForDispatch, + isUnsupportedWindowsProjectPath, + resolveProjectPathForDispatch, +} from "./projectPaths"; + +describe("projectPaths", () => { + it("normalizes trailing separators for dispatch and comparison", () => { + expect(normalizeProjectPathForDispatch(" /repo/app/ ")).toBe("/repo/app"); + expect(normalizeProjectPathForComparison("/repo/app/")).toBe("/repo/app"); + }); + + it("normalizes windows-style paths for comparison", () => { + expect(normalizeProjectPathForComparison("C:/Work/Repo/")).toBe("c:\\work\\repo"); + expect(normalizeProjectPathForComparison("C:\\Work\\Repo\\")).toBe("c:\\work\\repo"); + }); + + it("finds existing projects even when the input formatting differs", () => { + const existing = findProjectByPath( + [ + { id: "project-1", cwd: "/repo/app" }, + { id: "project-2", cwd: "C:\\Work\\Repo" }, + ], + "C:/Work/Repo/", + ); + + expect(existing?.id).toBe("project-2"); + }); + + it("infers project titles from normalized paths", () => { + expect(inferProjectTitleFromPath("/repo/app/")).toBe("app"); + expect(inferProjectTitleFromPath("C:\\Work\\Repo\\")).toBe("Repo"); + expect(inferProjectTitleFromPath("/home/user\\project/")).toBe("user\\project"); + }); + + it("detects browse queries across supported path styles", () => { + expect(isFilesystemBrowseQuery(".")).toBe(false); + expect(isFilesystemBrowseQuery("..")).toBe(false); + expect(isFilesystemBrowseQuery("./")).toBe(true); + expect(isFilesystemBrowseQuery("../")).toBe(true); + expect(isFilesystemBrowseQuery("~/projects")).toBe(true); + expect(isFilesystemBrowseQuery("..\\docs")).toBe(true); + expect(isFilesystemBrowseQuery("notes")).toBe(false); + }); + + it("only treats windows-style paths as browse queries on windows", () => { + expect(isFilesystemBrowseQuery("C:\\Work\\Repo\\", "MacIntel")).toBe(false); + expect(isFilesystemBrowseQuery("C:\\Work\\Repo\\", "Win32")).toBe(true); + expect(isUnsupportedWindowsProjectPath("C:\\Work\\Repo\\", "MacIntel")).toBe(true); + expect(isUnsupportedWindowsProjectPath("C:\\Work\\Repo\\", "Win32")).toBe(false); + }); + + it("detects explicit relative project paths", () => { + expect(isExplicitRelativeProjectPath(".")).toBe(true); + expect(isExplicitRelativeProjectPath("..")).toBe(true); + expect(isExplicitRelativeProjectPath("./docs")).toBe(true); + expect(isExplicitRelativeProjectPath("..\\docs")).toBe(true); + expect(isExplicitRelativeProjectPath("/repo/docs")).toBe(false); + }); + + it("resolves explicit relative paths against the current project", () => { + expect(resolveProjectPathForDispatch(".", "/repo/app")).toBe("/repo/app"); + expect(resolveProjectPathForDispatch("..", "/repo/app")).toBe("/repo"); + expect(resolveProjectPathForDispatch("./docs", "/repo/app")).toBe("/repo/app/docs"); + expect(resolveProjectPathForDispatch("../docs", "/repo/app")).toBe("/repo/docs"); + expect(resolveProjectPathForDispatch("./Repo", "C:\\Work")).toBe("C:\\Work\\Repo"); + expect(resolveProjectPathForDispatch("./docs", "/home/user\\project")).toBe( + "/home/user\\project/docs", + ); + }); + + it("navigates browse paths with matching separators", () => { + expect(appendBrowsePathSegment("/repo/", "src")).toBe("/repo/src/"); + expect(appendBrowsePathSegment("C:\\Work\\", "Repo")).toBe("C:\\Work\\Repo\\"); + expect(appendBrowsePathSegment("/home/user\\project/", "docs")).toBe( + "/home/user\\project/docs/", + ); + expect(getBrowseParentPath("/repo/src/")).toBe("/repo/"); + expect(getBrowseParentPath("C:\\Work\\Repo\\")).toBe("C:\\Work\\"); + expect(getBrowseParentPath("\\\\server\\share\\")).toBeNull(); + expect(getBrowseParentPath("\\\\server\\share\\repo\\")).toBe("\\\\server\\share\\"); + expect(getBrowseParentPath("C:\\")).toBeNull(); + expect(getBrowseParentPath("/home/user\\project/docs/")).toBe("/home/user\\project/"); + }); + + it("detects browse path boundaries", () => { + expect(hasTrailingPathSeparator("/repo/src/")).toBe(true); + expect(hasTrailingPathSeparator("/repo/src")).toBe(false); + expect(getBrowseDirectoryPath("/repo/src")).toBe("/repo/"); + expect(getBrowseDirectoryPath("/repo/src/")).toBe("/repo/src/"); + expect(getBrowseLeafPathSegment("/repo/src")).toBe("src"); + expect(getBrowseLeafPathSegment("C:\\Work\\Repo\\Docs")).toBe("Docs"); + expect(getBrowseDirectoryPath("/home/user\\project/docs")).toBe("/home/user\\project/"); + expect(getBrowseLeafPathSegment("/home/user\\project/docs")).toBe("docs"); + }); + + it("only allows browse-up after entering a directory", () => { + expect(canNavigateUp("~/repo")).toBe(false); + expect(canNavigateUp("~/a")).toBe(false); + expect(canNavigateUp("~/repo/")).toBe(true); + expect(canNavigateUp("\\\\server\\share\\")).toBe(false); + expect(canNavigateUp("\\\\server\\share\\repo\\")).toBe(true); + }); +}); diff --git a/apps/web/src/lib/projectPaths.ts b/apps/web/src/lib/projectPaths.ts new file mode 100644 index 00000000000..359316ec430 --- /dev/null +++ b/apps/web/src/lib/projectPaths.ts @@ -0,0 +1,259 @@ +import { + isExplicitRelativePath, + isUncPath, + isWindowsAbsolutePath, + isWindowsDrivePath, +} from "@marcode/shared/path"; +import { isWindowsPlatform } from "./utils"; + +function isRootPath(value: string): boolean { + return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value); +} + +function getAbsolutePathKind(value: string): "unix" | "windows" | null { + if (isWindowsDrivePath(value) || isUncPath(value)) { + return "windows"; + } + + if (value.startsWith("/")) { + return "unix"; + } + + return null; +} + +function trimTrailingPathSeparators(value: string): string { + if (value.length === 0 || isRootPath(value)) { + return value; + } + + const trimmed = + getAbsolutePathKind(value) === "unix" + ? value.replace(/\/+$/g, "") + : value.replace(/[\\/]+$/g, ""); + if (trimmed.length === 0) { + return value; + } + + return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed; +} + +function preferredPathSeparator(value: string): "/" | "\\" { + const absolutePathKind = getAbsolutePathKind(value); + if (absolutePathKind === "windows") { + return "\\"; + } + if (absolutePathKind === "unix") { + return "/"; + } + + return value.includes("\\") ? "\\" : "/"; +} + +export function hasTrailingPathSeparator(value: string): boolean { + return (getAbsolutePathKind(value) === "unix" ? /\/$/ : /[\\/]$/).test(value); +} + +export { isExplicitRelativePath as isExplicitRelativeProjectPath }; + +function splitPathSegments(value: string, separator: "/" | "\\"): string[] { + return value.split(separator === "/" ? /\/+/ : /[\\/]+/).filter(Boolean); +} + +function getLastPathSeparatorIndex(value: string): number { + if (getAbsolutePathKind(value) === "unix") { + return value.lastIndexOf("/"); + } + + return Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\")); +} + +function splitAbsolutePath(value: string): { + root: string; + separator: "/" | "\\"; + segments: string[]; +} | null { + if (isWindowsDrivePath(value)) { + const root = `${value.slice(0, 2)}\\`; + const segments = splitPathSegments(value.slice(root.length), "\\"); + return { root, separator: "\\", segments }; + } + if (isUncPath(value)) { + const segments = splitPathSegments(value, "\\"); + const [server, share, ...rest] = segments; + if (!server || !share) { + return null; + } + return { + root: `\\\\${server}\\${share}\\`, + separator: "\\", + segments: rest, + }; + } + if (value.startsWith("/")) { + return { + root: "/", + separator: "/", + segments: splitPathSegments(value.slice(1), "/"), + }; + } + return null; +} + +export function isFilesystemBrowseQuery( + value: string, + platform = typeof navigator === "undefined" ? "" : navigator.platform, +): boolean { + const allowWindowsPaths = isWindowsPlatform(platform); + return ( + value.startsWith("./") || + value.startsWith("../") || + value.startsWith(".\\") || + value.startsWith("..\\") || + value.startsWith("/") || + value.startsWith("~/") || + (allowWindowsPaths && isWindowsAbsolutePath(value)) + ); +} + +export function isUnsupportedWindowsProjectPath(value: string, platform: string): boolean { + return isWindowsAbsolutePath(value) && !isWindowsPlatform(platform); +} + +export function normalizeProjectPathForDispatch(value: string): string { + return trimTrailingPathSeparators(value.trim()); +} + +export function resolveProjectPathForDispatch(value: string, cwd?: string | null): string { + const trimmedValue = value.trim(); + if (!isExplicitRelativePath(trimmedValue) || !cwd) { + return normalizeProjectPathForDispatch(trimmedValue); + } + + const absoluteBase = splitAbsolutePath(normalizeProjectPathForDispatch(cwd)); + if (!absoluteBase) { + return normalizeProjectPathForDispatch(trimmedValue); + } + + const nextSegments = [...absoluteBase.segments]; + for (const segment of trimmedValue.split(/[\\/]+/)) { + if (segment.length === 0 || segment === ".") { + continue; + } + if (segment === "..") { + nextSegments.pop(); + continue; + } + nextSegments.push(segment); + } + + const joinedPath = nextSegments.join(absoluteBase.separator); + if (joinedPath.length === 0) { + return normalizeProjectPathForDispatch(absoluteBase.root); + } + + return normalizeProjectPathForDispatch(`${absoluteBase.root}${joinedPath}`); +} + +export function normalizeProjectPathForComparison(value: string): string { + const normalized = normalizeProjectPathForDispatch(value); + if (isWindowsDrivePath(normalized) || normalized.startsWith("\\\\")) { + return normalized.replaceAll("/", "\\").toLowerCase(); + } + return normalized; +} + +export function findProjectByPath( + projects: ReadonlyArray, + candidatePath: string, +): T | undefined { + const normalizedCandidate = normalizeProjectPathForComparison(candidatePath); + if (normalizedCandidate.length === 0) { + return undefined; + } + + return projects.find( + (project) => normalizeProjectPathForComparison(project.cwd) === normalizedCandidate, + ); +} + +export function inferProjectTitleFromPath(value: string): string { + const normalized = normalizeProjectPathForDispatch(value); + const absolutePath = splitAbsolutePath(normalized); + if (absolutePath) { + return absolutePath.segments.findLast(Boolean) ?? normalized; + } + + const segments = normalized.split(/[/\\]/); + return segments.findLast(Boolean) ?? normalized; +} + +export function appendBrowsePathSegment(currentPath: string, segment: string): string { + const separator = preferredPathSeparator(currentPath); + return `${getBrowseDirectoryPath(currentPath)}${segment}${separator}`; +} + +export function getBrowseLeafPathSegment(currentPath: string): string { + const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath); + return currentPath.slice(lastSeparatorIndex + 1); +} + +export function getBrowseDirectoryPath(currentPath: string): string { + if (hasTrailingPathSeparator(currentPath)) { + return currentPath; + } + + const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath); + if (lastSeparatorIndex < 0) { + return currentPath; + } + + return currentPath.slice(0, lastSeparatorIndex + 1); +} + +export function ensureBrowseDirectoryPath(currentPath: string): string { + const trimmed = currentPath.trim(); + if (trimmed.length === 0) { + return trimmed; + } + + if (hasTrailingPathSeparator(trimmed)) { + return trimmed; + } + + return `${trimmed}${preferredPathSeparator(trimmed)}`; +} + +export function getBrowseParentPath(currentPath: string): string | null { + const trimmed = trimTrailingPathSeparators(currentPath); + const absolutePath = splitAbsolutePath(trimmed); + if (absolutePath) { + if (absolutePath.segments.length === 0) { + return null; + } + + if (absolutePath.segments.length === 1) { + return absolutePath.root; + } + + const parentSegments = absolutePath.segments.slice(0, -1).join(absolutePath.separator); + return `${absolutePath.root}${parentSegments}${absolutePath.separator}`; + } + + const separator = preferredPathSeparator(currentPath); + const lastSeparatorIndex = getLastPathSeparatorIndex(trimmed); + + if (lastSeparatorIndex < 0) { + return null; + } + + if (lastSeparatorIndex === 2 && /^[a-zA-Z]:/.test(trimmed)) { + return `${trimmed.slice(0, 2)}${separator}`; + } + + return trimmed.slice(0, lastSeparatorIndex + 1); +} + +export function canNavigateUp(currentPath: string): boolean { + return hasTrailingPathSeparator(currentPath) && getBrowseParentPath(currentPath) !== null; +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 2bec15bf8be..e716a574337 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -59,6 +59,10 @@ "./qrCode": { "types": "./src/qrCode.ts", "import": "./src/qrCode.ts" + }, + "./path": { + "types": "./src/path.ts", + "import": "./src/path.ts" } }, "scripts": { diff --git a/packages/shared/src/path.test.ts b/packages/shared/src/path.test.ts new file mode 100644 index 00000000000..912e1e13d75 --- /dev/null +++ b/packages/shared/src/path.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + isExplicitRelativePath, + isUncPath, + isWindowsAbsolutePath, + isWindowsDrivePath, +} from "./path"; + +describe("path helpers", () => { + it("detects windows drive paths", () => { + expect(isWindowsDrivePath("C:\\repo")).toBe(true); + expect(isWindowsDrivePath("D:/repo")).toBe(true); + expect(isWindowsDrivePath("/repo")).toBe(false); + }); + + it("detects UNC paths", () => { + expect(isUncPath("\\\\server\\share\\repo")).toBe(true); + expect(isUncPath("C:\\repo")).toBe(false); + }); + + it("detects windows absolute paths", () => { + expect(isWindowsAbsolutePath("C:\\repo")).toBe(true); + expect(isWindowsAbsolutePath("\\\\server\\share\\repo")).toBe(true); + expect(isWindowsAbsolutePath("./repo")).toBe(false); + }); + + it("detects explicit relative paths", () => { + expect(isExplicitRelativePath(".")).toBe(true); + expect(isExplicitRelativePath("..")).toBe(true); + expect(isExplicitRelativePath("./repo")).toBe(true); + expect(isExplicitRelativePath("..\\repo")).toBe(true); + expect(isExplicitRelativePath("~/repo")).toBe(false); + }); +}); diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts new file mode 100644 index 00000000000..2bb2ca0238d --- /dev/null +++ b/packages/shared/src/path.ts @@ -0,0 +1,22 @@ +export function isWindowsDrivePath(value: string): boolean { + return /^[a-zA-Z]:([/\\]|$)/.test(value); +} + +export function isUncPath(value: string): boolean { + return value.startsWith("\\\\"); +} + +export function isWindowsAbsolutePath(value: string): boolean { + return isUncPath(value) || isWindowsDrivePath(value); +} + +export function isExplicitRelativePath(value: string): boolean { + return ( + value === "." || + value === ".." || + value.startsWith("./") || + value.startsWith("../") || + value.startsWith(".\\") || + value.startsWith("..\\") + ); +} From 6673ca823566c10d283656ccd0ebb4f83651d1b3 Mon Sep 17 00:00:00 2001 From: Tristan <121109260+tlh38@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:29:25 -0400 Subject: [PATCH 2/5] feat(sidebar): configurable project grouping (port upstream #2055) Port upstream t3code #2055 (188a40c3) to add three sidebar project grouping modes (repository, repository_path, separate) plus per-project overrides via nested context-menu submenus. MarCode reconciliation: - Preserved skeleton loading (SidebarProjectsSkeleton) in Sidebar.tsx - Preserved cross-environment thread fetching via memberProjectRefs - Kept ThreadStatusIndicators import (from #2107 GitLab MR port) - Kept Opus 4.7 default effort = "high" in ClaudeProvider - Kept turn notification wiring, Jira embedded defaults, windowState persistence, custom draft-thread system in ChatView Co-Authored-By: Claude Opus 4.7 --- apps/desktop/src/clientPersistence.test.ts | 4 + apps/desktop/src/main.ts | 84 +- .../Layers/RepositoryIdentityResolver.test.ts | 28 + .../Layers/RepositoryIdentityResolver.ts | 4 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 4 +- .../src/provider/Layers/ClaudeAdapter.ts | 11 +- apps/web/src/components/ChatView.browser.tsx | 22 +- apps/web/src/components/ChatView.tsx | 59 +- apps/web/src/components/Sidebar.tsx | 815 +++++++++++++----- apps/web/src/contextMenuFallback.test.ts | 221 +++++ apps/web/src/contextMenuFallback.ts | 162 +++- apps/web/src/environmentGrouping.test.ts | 169 +++- apps/web/src/environments/runtime/service.ts | 7 +- apps/web/src/hooks/useHandleNewThread.ts | 11 +- apps/web/src/localApi.test.ts | 100 +-- apps/web/src/logicalProject.ts | 148 +++- apps/web/src/routes/__root.tsx | 30 +- apps/web/src/sidebarProjectGrouping.ts | 118 +++ packages/contracts/src/environment.ts | 1 + packages/contracts/src/ipc.ts | 1 + packages/contracts/src/settings.ts | 15 + 21 files changed, 1647 insertions(+), 367 deletions(-) create mode 100644 apps/web/src/contextMenuFallback.test.ts create mode 100644 apps/web/src/sidebarProjectGrouping.ts diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index 1e41260af57..2ee00ad0298 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -53,6 +53,10 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, diffWordWrap: true, favorites: [], + sidebarProjectGroupingMode: "repository_path", + sidebarProjectGroupingOverrides: { + "environment-1:/tmp/project-a": "separate", + }, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", timestampFormat: "24-hour", diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index cad12ed6b1f..dddba2f4159 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -125,6 +125,34 @@ const DESKTOP_UPDATE_CHANNEL = "latest"; const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const; +function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] { + const normalizedItems: ContextMenuItem[] = []; + + for (const sourceItem of source) { + if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") { + continue; + } + + const normalizedItem: ContextMenuItem = { + id: sourceItem.id, + label: sourceItem.label, + destructive: sourceItem.destructive === true, + disabled: sourceItem.disabled === true, + }; + + if (sourceItem.children) { + const normalizedChildren = normalizeContextMenuItems(sourceItem.children); + if (normalizedChildren.length === 0) { + continue; + } + normalizedItem.children = normalizedChildren; + } + + normalizedItems.push(normalizedItem); + } + + return normalizedItems; +} type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; type LinuxDesktopNamedApp = Electron.App & { @@ -1537,14 +1565,7 @@ function registerIpcHandlers(): void { ipcMain.handle( CONTEXT_MENU_CHANNEL, async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => { - const normalizedItems = items - .filter((item) => typeof item.id === "string" && typeof item.label === "string") - .map((item) => ({ - id: item.id, - label: item.label, - destructive: item.destructive === true, - disabled: item.disabled === true, - })); + const normalizedItems = normalizeContextMenuItems(items); if (normalizedItems.length === 0) { return null; } @@ -1565,28 +1586,37 @@ function registerIpcHandlers(): void { if (!window) return null; return new Promise((resolve) => { - const template: MenuItemConstructorOptions[] = []; - let hasInsertedDestructiveSeparator = false; - for (const item of normalizedItems) { - if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); - hasInsertedDestructiveSeparator = true; - } - const itemOption: MenuItemConstructorOptions = { - label: item.label, - enabled: !item.disabled, - click: () => resolve(item.id), - }; - if (item.destructive) { - const destructiveIcon = getDestructiveMenuIcon(); - if (destructiveIcon) { - itemOption.icon = destructiveIcon; + const buildTemplate = ( + entries: readonly ContextMenuItem[], + ): MenuItemConstructorOptions[] => { + const template: MenuItemConstructorOptions[] = []; + let hasInsertedDestructiveSeparator = false; + for (const item of entries) { + if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { + template.push({ type: "separator" }); + hasInsertedDestructiveSeparator = true; } + const itemOption: MenuItemConstructorOptions = { + label: item.label, + enabled: !item.disabled, + }; + if (item.children && item.children.length > 0) { + itemOption.submenu = buildTemplate(item.children); + } else { + itemOption.click = () => resolve(item.id); + } + if (item.destructive && (!item.children || item.children.length === 0)) { + const destructiveIcon = getDestructiveMenuIcon(); + if (destructiveIcon) { + itemOption.icon = destructiveIcon; + } + } + template.push(itemOption); } - template.push(itemOption); - } + return template; + }; - const menu = Menu.buildFromTemplate(template); + const menu = Menu.buildFromTemplate(buildTemplate(normalizedItems)); menu.popup({ window, ...popupPosition, diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts index 276106b68c3..056b95cbaa5 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -1,3 +1,5 @@ +import { realpathSync } from "node:fs"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { Duration, Effect, FileSystem, Layer } from "effect"; @@ -10,6 +12,10 @@ import { RepositoryIdentityResolverLive, } from "./RepositoryIdentityResolver.ts"; +const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/"); +const normalizeResolvedPath = (value: string) => + normalizePathSeparators(realpathSync.native(value)); + const git = (cwd: string, args: ReadonlyArray) => Effect.promise(() => runProcess("git", ["-C", cwd, ...args])); @@ -41,6 +47,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity).not.toBeNull(); expect(identity?.canonicalKey).toBe("github.com/marcodehq/marcode"); + expect(normalizeResolvedPath(identity?.rootPath ?? "")).toBe(normalizeResolvedPath(cwd)); expect(identity?.displayName).toBe("marcodehq/marcode"); expect(identity?.provider).toBe("github"); expect(identity?.owner).toBe("marcodehq"); @@ -48,6 +55,27 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(RepositoryIdentityResolverLive)), ); + it.effect("returns the git top-level root path when resolving from a nested workspace", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const repoRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "marcode-repository-identity-nested-root-test-", + }); + const nestedWorkspace = `${repoRoot}/packages/web`; + + yield* fileSystem.makeDirectory(nestedWorkspace, { recursive: true }); + yield* git(repoRoot, ["init"]); + yield* git(repoRoot, ["remote", "add", "origin", "git@github.com:MarCodeHQ/marcode.git"]); + + const resolver = yield* RepositoryIdentityResolver; + const identity = yield* resolver.resolve(nestedWorkspace); + + expect(identity).not.toBeNull(); + expect(identity?.canonicalKey).toBe("github.com/marcodehq/marcode"); + expect(normalizeResolvedPath(identity?.rootPath ?? "")).toBe(normalizeResolvedPath(repoRoot)); + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + ); + it.effect("returns null for non-git folders and repos without remotes", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts index ca44ef94377..72274fdec1c 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts @@ -42,6 +42,7 @@ function pickPrimaryRemote( function buildRepositoryIdentity(input: { readonly remoteName: string; readonly remoteUrl: string; + readonly rootPath: string; }): RepositoryIdentity { const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); const hostingProvider = detectGitHostingProviderFromRemoteUrl(input.remoteUrl); @@ -57,6 +58,7 @@ function buildRepositoryIdentity(input: { remoteName: input.remoteName, remoteUrl: input.remoteUrl, }, + rootPath: input.rootPath, ...(repositoryPath ? { displayName: repositoryPath } : {}), ...(hostingProvider ? { provider: hostingProvider.kind } : {}), ...(owner ? { owner } : {}), @@ -108,7 +110,7 @@ async function resolveRepositoryIdentityFromCacheKey( } const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.stdout)); - return remote ? buildRepositoryIdentity(remote) : null; + return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null; } catch { return null; } diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 163997b848d..76a8acbae64 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -375,7 +375,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("forwards xhigh effort for Claude Opus 4.7", () => { + it.effect("maps xhigh effort for Claude Opus 4.7 to the SDK-supported max value", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -393,7 +393,7 @@ describe("ClaudeAdapterLive", () => { }); const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "xhigh"); + assert.equal(createInput?.options.effort, "max"); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 8042f5aa56c..a4d9ef9a402 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -86,6 +86,7 @@ type ClaudeToolResultStreamKind = Extract< RuntimeContentStreamKind, "command_output" | "file_change_output" >; +type ClaudeSdkEffort = NonNullable; type PromptQueueItem = | { @@ -237,11 +238,17 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause): ReadonlyArray function getEffectiveClaudeAgentEffort( effort: ClaudeAgentEffort | null | undefined, -): Exclude | null { +): ClaudeSdkEffort | null { if (!effort) { return null; } - return effort === "ultrathink" ? null : effort; + if (effort === "ultrathink") { + return null; + } + if (effort === "xhigh") { + return "max"; + } + return effort; } function isClaudeInterruptedMessage(message: string): boolean { diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 5edb23d51b0..47069777ce2 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -42,6 +42,7 @@ import { __resetLocalApiForTests } from "../localApi"; import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; +import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; @@ -66,7 +67,18 @@ const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID); const THREAD_KEY = scopedThreadKey(THREAD_REF); const UUID_ROUTE_RE = /^\/draft\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const PROJECT_DRAFT_KEY = `${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`; -const PROJECT_KEY = scopedProjectKey(scopeProjectRef(LOCAL_ENVIRONMENT_ID, PROJECT_ID)); +const PROJECT_LOGICAL_KEY = deriveLogicalProjectKeyFromSettings( + { + environmentId: LOCAL_ENVIRONMENT_ID, + id: PROJECT_ID, + cwd: "/repo/project", + repositoryIdentity: null, + }, + { + sidebarProjectGroupingMode: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingOverrides, + }, +); const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); const ATTACHMENT_SVG = ""; @@ -1740,12 +1752,12 @@ describe("ChatView timeline estimator parity (full app)", () => { }, ); - it("re-expands the bootstrap project using its scoped key", async () => { + it("re-expands the bootstrap project using its logical key", async () => { useUiStateStore.setState({ projectExpandedById: { - [PROJECT_KEY]: false, + [PROJECT_LOGICAL_KEY]: false, }, - projectOrder: [PROJECT_KEY], + projectOrder: [PROJECT_LOGICAL_KEY], threadLastVisitedAtById: {}, }); @@ -1760,7 +1772,7 @@ describe("ChatView timeline estimator parity (full app)", () => { try { await vi.waitFor( () => { - expect(useUiStateStore.getState().projectExpandedById[PROJECT_KEY]).toBe(true); + expect(useUiStateStore.getState().projectExpandedById[PROJECT_LOGICAL_KEY]).toBe(true); }, { timeout: 8_000, interval: 16 }, ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ccf03edd785..7267c75a951 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -164,7 +164,7 @@ import { import { useSettings } from "../hooks/useSettings"; import { resolveAppModelSelection } from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { deriveLogicalProjectKey } from "../logicalProject"; +import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, @@ -1139,6 +1139,63 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: ); const activeProject = useProjectById(activeThread?.projectId); + // Compute the list of environments this logical project spans, used to + // drive the environment picker in BranchToolbar. + const allProjects = useStore(useShallow(selectProjectsAcrossEnvironments)); + const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); + const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); + const logicalProjectEnvironments = useMemo(() => { + if (!activeProject) return []; + const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); + const memberProjects = allProjects.filter( + (p) => deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) === logicalKey, + ); + const seen = new Set(); + const envs: Array<{ + environmentId: EnvironmentId; + projectId: ProjectId; + label: string; + isPrimary: boolean; + }> = []; + for (const p of memberProjects) { + if (seen.has(p.environmentId)) continue; + seen.add(p.environmentId); + const isPrimary = p.environmentId === primaryEnvironmentId; + const savedRecord = savedEnvironmentRegistry[p.environmentId]; + const runtimeState = savedEnvironmentRuntimeById[p.environmentId]; + const label = resolveEnvironmentOptionLabel({ + isPrimary, + environmentId: p.environmentId, + runtimeLabel: runtimeState?.descriptor?.label ?? null, + savedLabel: savedRecord?.label ?? null, + }); + envs.push({ + environmentId: p.environmentId, + projectId: p.id, + label, + isPrimary, + }); + } + // Sort: primary first, then alphabetical + envs.sort((a, b) => { + if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1; + return a.label.localeCompare(b.label); + }); + return envs; + }, [ + activeProject, + allProjects, + projectGroupingSettings, + primaryEnvironmentId, + savedEnvironmentRegistry, + savedEnvironmentRuntimeById, + ]); + const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; + const openPullRequestDialog = useCallback( (reference?: string) => { if (!canCheckoutPullRequestIntoThread) { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 811c4a49ecc..38599a4e5c5 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -37,12 +37,13 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd- import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { + type ContextMenuItem, DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, type EnvironmentId, ProjectId, - type ScopedProjectRef, type ScopedThreadRef, + type SidebarProjectGroupingMode, type ThreadEnvMode, ThreadId, type GitStatusResult, @@ -67,7 +68,6 @@ import { selectBootstrapCompleteForActiveEnvironment, selectProjectByRef, selectProjectsAcrossEnvironments, - selectSidebarThreadsForProjectRef, selectSidebarThreadsForProjectRefs, selectSidebarThreadsAcrossEnvironments, selectThreadByRef, @@ -113,7 +113,26 @@ import { } from "./desktopUpdate.logic"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; -import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; +import { + Menu, + MenuGroup, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuTrigger, +} from "./ui/menu"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, @@ -152,12 +171,18 @@ import { CommandDialogTrigger } from "./ui/command"; import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; -import { deriveLogicalProjectKey } from "../logicalProject"; +import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey } from "../logicalProject"; import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; -import type { Project, SidebarThreadSummary } from "../types"; +import type { SidebarThreadSummary } from "../types"; +import { + buildPhysicalToLogicalProjectKeyMap, + buildSidebarProjectSnapshots, + type SidebarProjectGroupMember, + type SidebarProjectSnapshot, +} from "../sidebarProjectGrouping"; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", @@ -173,6 +198,11 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { easing: "ease-out", } as const; const EMPTY_THREAD_JUMP_LABELS = new Map(); +const PROJECT_GROUPING_MODE_LABELS: Record = { + repository: "Group by repository", + repository_path: "Group by repository path", + separate: "Keep separate", +}; function threadJumpLabelMapsEqual( left: ReadonlyMap, @@ -192,6 +222,28 @@ function threadJumpLabelMapsEqual( return true; } +function formatProjectMemberActionLabel( + member: SidebarProjectGroupMember, + groupedProjectCount: number, +): string { + if (groupedProjectCount <= 1) { + return member.name; + } + + return member.environmentLabel ? `${member.environmentLabel} — ${member.cwd}` : member.cwd; +} + +function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string { + switch (mode) { + case "repository": + return "Projects from the same repository share one sidebar row."; + case "repository_path": + return "Projects group only when both the repository and repo-relative path match."; + case "separate": + return "Every project path gets its own sidebar row."; + } +} + function buildThreadJumpLabelMap(input: { keybindings: ReturnType; platform: string; @@ -222,16 +274,6 @@ function buildThreadJumpLabelMap(input: { return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; } -type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; - -type SidebarProjectSnapshot = Project & { - projectKey: string; - environmentPresence: EnvironmentPresence; - memberProjectRefs: readonly ScopedProjectRef[]; - /** Labels for remote environments this project lives in. */ - remoteEnvironmentLabels: readonly string[]; -}; - interface SidebarThreadRowProps { thread: SidebarThreadSummary; projectCwd: string | null; @@ -884,6 +926,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const defaultThreadEnvMode = useSettings( (settings) => settings.defaultThreadEnvMode, ); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); + const { updateSettings } = useUpdateSettings(); const router = useRouter(); const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); const toggleProject = useUiStateStore((state) => state.toggleProject); @@ -961,53 +1008,27 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec useShallow( useMemo( () => (state: import("../store").AppState) => - selectSidebarThreadsForProjectRef( - state, - scopeProjectRef(project.environmentId, project.id), - ), - [project.environmentId, project.id], - ), - ), - ); - // For grouped projects that span multiple environments, also fetch - // threads from the other member project refs. - const otherMemberRefs = useMemo( - () => - project.memberProjectRefs.filter( - (ref) => ref.environmentId !== project.environmentId || ref.projectId !== project.id, - ), - [project.memberProjectRefs, project.environmentId, project.id], - ); - const otherMemberThreads = useStore( - useShallow( - useMemo( - () => - otherMemberRefs.length === 0 - ? () => [] as SidebarThreadSummary[] - : (state: import("../store").AppState) => - selectSidebarThreadsForProjectRefs(state, otherMemberRefs), - [otherMemberRefs], + selectSidebarThreadsForProjectRefs(state, project.memberProjectRefs), + [project.memberProjectRefs], ), ), ); - const allSidebarThreads = useMemo( - () => - otherMemberThreads.length === 0 ? sidebarThreads : [...sidebarThreads, ...otherMemberThreads], - [sidebarThreads, otherMemberThreads], - ); const sidebarThreadByKey = useMemo( () => new Map( - allSidebarThreads.map( + sidebarThreads.map( (thread) => [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, ), ), - [allSidebarThreads], + [sidebarThreads], ); - // All threads from the representative + other member environments are - // already fetched into allSidebarThreads, so we can use them directly. - const projectThreads = allSidebarThreads; + // Keep a ref so callbacks can read the latest map without appearing in + // dependency arrays (avoids invalidating every thread-row memo on each + // thread-list change). + const sidebarThreadByKeyRef = useRef(sidebarThreadByKey); + sidebarThreadByKeyRef.current = sidebarThreadByKey; + const projectThreads = sidebarThreads; const projectExpanded = useUiStateStore( (state) => state.projectExpandedById[project.projectKey] ?? true, ); @@ -1024,9 +1045,43 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const [renamingThreadKey, setRenamingThreadKey] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState(null); + const [projectRenameTarget, setProjectRenameTarget] = useState( + null, + ); + const [projectRenameTitle, setProjectRenameTitle] = useState(""); + const [projectGroupingTarget, setProjectGroupingTarget] = + useState(null); + const [projectGroupingSelection, setProjectGroupingSelection] = useState< + SidebarProjectGroupingMode | "inherit" + >("inherit"); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const confirmArchiveButtonRefs = useRef(new Map()); + const memberProjectByScopedKey = useMemo( + () => + new Map( + project.memberProjects.map((member) => [ + scopedProjectKey(scopeProjectRef(member.environmentId, member.id)), + member, + ]), + ), + [project.memberProjects], + ); + const memberThreadCountByPhysicalKey = useMemo(() => { + const counts = new Map( + project.memberProjects.map((member) => [member.physicalProjectKey, 0] as const), + ); + for (const thread of projectThreads) { + const member = memberProjectByScopedKey.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ); + if (!member) { + continue; + } + counts.set(member.physicalProjectKey, (counts.get(member.physicalProjectKey) ?? 0) + 1); + } + return counts; + }, [memberProjectByScopedKey, project.memberProjects, projectThreads]); const { projectStatus, visibleProjectThreads, orderedProjectThreadKeys } = useMemo(() => { const lastVisitedAtByThreadKey = new Map( @@ -1201,6 +1256,88 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef], ); + const openProjectRenameDialog = useCallback((member: SidebarProjectGroupMember) => { + setProjectRenameTarget(member); + setProjectRenameTitle(member.name); + }, []); + + const openProjectGroupingDialog = useCallback( + (member: SidebarProjectGroupMember) => { + const overrideKey = deriveProjectGroupingOverrideKey(member); + setProjectGroupingTarget(member); + setProjectGroupingSelection( + projectGroupingSettings.sidebarProjectGroupingOverrides[overrideKey] ?? "inherit", + ); + }, + [projectGroupingSettings.sidebarProjectGroupingOverrides], + ); + + const handleRemoveProject = useCallback( + async (member: SidebarProjectGroupMember) => { + const api = readLocalApi(); + if (!api) { + return; + } + + if ((memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0) > 0) { + toastManager.add({ + type: "warning", + title: "Project is not empty", + description: "Delete all threads in this project before removing it.", + }); + return; + } + + const message = [ + `Remove project "${member.name}"?`, + `Path: ${member.cwd}`, + ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), + "This removes only this project entry.", + ].join("\n"); + const confirmed = await api.dialogs.confirm(message); + if (!confirmed) { + return; + } + + const memberProjectRef = scopeProjectRef(member.environmentId, member.id); + + try { + const projectDraftThread = getDraftThreadByProjectRef(memberProjectRef); + if (projectDraftThread) { + clearComposerDraftForThread(projectDraftThread.draftId); + } + clearProjectDraftThreadId(memberProjectRef); + const projectApi = readEnvironmentApi(member.environmentId); + if (!projectApi) { + throw new Error("Project API unavailable."); + } + await projectApi.orchestration.dispatchCommand({ + type: "project.delete", + commandId: newCommandId(), + projectId: member.id, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error removing project."; + console.error("Failed to remove project", { + projectId: member.id, + environmentId: member.environmentId, + error, + }); + toastManager.add({ + type: "error", + title: `Failed to remove "${member.name}"`, + description: message, + }); + } + }, + [ + clearComposerDraftForThread, + clearProjectDraftThreadId, + getDraftThreadByProjectRef, + memberThreadCountByPhysicalKey, + ], + ); + const handleProjectButtonContextMenu = useCallback( (event: React.MouseEvent) => { event.preventDefault(); @@ -1209,73 +1346,103 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const api = readLocalApi(); if (!api) return; + const actionHandlers = new Map Promise | void>(); + const makeLeaf = ( + action: "rename" | "grouping" | "copy-path" | "delete", + member: SidebarProjectGroupMember, + options?: { + destructive?: boolean; + disabled?: boolean; + }, + ): ContextMenuItem => { + const id = `${action}:${member.physicalProjectKey}`; + actionHandlers.set(id, () => { + switch (action) { + case "rename": + openProjectRenameDialog(member); + return; + case "grouping": + openProjectGroupingDialog(member); + return; + case "copy-path": + copyPathToClipboard(member.cwd, { path: member.cwd }); + return; + case "delete": + return handleRemoveProject(member); + } + }); + + return { + id, + label: formatProjectMemberActionLabel(member, project.groupedProjectCount), + ...(options?.destructive ? { destructive: true } : {}), + ...(options?.disabled ? { disabled: true } : {}), + }; + }; + + const buildTargetedItem = ( + action: "rename" | "grouping" | "copy-path" | "delete", + label: string, + options?: { + destructive?: boolean; + isDisabled?: (member: SidebarProjectGroupMember) => boolean; + }, + ): ContextMenuItem => { + if (project.memberProjects.length === 1) { + const singleMember = project.memberProjects[0]!; + return { + ...makeLeaf(action, singleMember, { + ...(options?.destructive ? { destructive: true } : {}), + ...(options?.isDisabled?.(singleMember) ? { disabled: true } : {}), + }), + label, + }; + } + + return { + id: `${action}:submenu`, + label, + children: project.memberProjects.map((member) => + makeLeaf(action, member, { + ...(options?.destructive ? { destructive: true } : {}), + ...(options?.isDisabled?.(member) ? { disabled: true } : {}), + }), + ), + }; + }; + const clicked = await api.contextMenu.show( [ - { id: "copy-path", label: "Copy Project Path" }, - { id: "delete", label: "Remove project", destructive: true }, + buildTargetedItem("rename", "Rename project"), + buildTargetedItem("grouping", "Project grouping…"), + buildTargetedItem("copy-path", "Copy Project Path"), + buildTargetedItem("delete", "Remove project", { + destructive: true, + isDisabled: (member) => + (memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0) > 0, + }), ], { x: event.clientX, y: event.clientY, }, ); - if (clicked === "copy-path") { - copyPathToClipboard(project.cwd, { path: project.cwd }); - return; - } - if (clicked !== "delete") return; - if (projectThreads.length > 0) { - toastManager.add({ - type: "warning", - title: "Project is not empty", - description: "Delete all threads in this project before removing it.", - }); + if (!clicked) { return; } - const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`); - if (!confirmed) return; - - try { - const projectDraftThread = getDraftThreadByProjectRef( - scopeProjectRef(project.environmentId, project.id), - ); - if (projectDraftThread) { - clearComposerDraftForThread(projectDraftThread.draftId); - } - clearProjectDraftThreadId(scopeProjectRef(project.environmentId, project.id)); - const projectApi = readEnvironmentApi(project.environmentId); - if (!projectApi) { - throw new Error("Project API unavailable."); - } - await projectApi.orchestration.dispatchCommand({ - type: "project.delete", - commandId: newCommandId(), - projectId: project.id, - }); - } catch (error) { - const message = - error instanceof Error ? error.message : "Unknown error removing project."; - console.error("Failed to remove project", { projectId: project.id, error }); - toastManager.add({ - type: "error", - title: `Failed to remove "${project.name}"`, - description: message, - }); - } + await actionHandlers.get(clicked)?.(); })(); }, [ - clearComposerDraftForThread, - clearProjectDraftThreadId, copyPathToClipboard, - getDraftThreadByProjectRef, - project.cwd, - project.environmentId, - project.id, - project.name, - projectThreads.length, + handleRemoveProject, + memberThreadCountByPhysicalKey, + openProjectGroupingDialog, + openProjectRenameDialog, + project.groupedProjectCount, + project.memberProjects, suppressProjectClickForContextMenuRef, ], ); @@ -1387,10 +1554,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ], ); - const handleCreateThreadClick = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); + const createThreadForProjectMember = useCallback( + (member: SidebarProjectGroupMember) => { const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; const currentRouteTarget = resolveThreadRouteTarget(currentRouteParams); @@ -1406,12 +1571,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ? (draftStore.getDraftSession(currentRouteTarget.draftId) ?? null) : null; const seedContext = resolveSidebarNewThreadSeedContext({ - projectId: project.id, + projectId: member.id, defaultEnvMode: resolveSidebarNewThreadEnvMode({ defaultEnvMode: defaultThreadEnvMode, }), activeThread: - currentActiveThread && currentActiveThread.projectId === project.id + currentActiveThread && currentActiveThread.projectId === member.id ? { projectId: currentActiveThread.projectId, branch: currentActiveThread.branch, @@ -1419,7 +1584,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } : null, activeDraftThread: - currentActiveDraftThread && currentActiveDraftThread.projectId === project.id + currentActiveDraftThread && currentActiveDraftThread.projectId === member.id ? { projectId: currentActiveDraftThread.projectId, branch: currentActiveDraftThread.branch, @@ -1428,7 +1593,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } : null, }); - void handleNewThread(scopeProjectRef(project.environmentId, project.id), { + void handleNewThread(scopeProjectRef(member.environmentId, member.id), { ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}), ...(seedContext.worktreePath !== undefined ? { worktreePath: seedContext.worktreePath } @@ -1436,7 +1601,47 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec envMode: seedContext.envMode, }); }, - [defaultThreadEnvMode, handleNewThread, project.environmentId, project.id, router], + [defaultThreadEnvMode, handleNewThread, router], + ); + + const handleCreateThreadClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (project.memberProjects.length === 1) { + createThreadForProjectMember(project.memberProjects[0]!); + return; + } + + void (async () => { + const api = readLocalApi(); + if (!api) { + return; + } + const clicked = await api.contextMenu.show( + project.memberProjects.map((member) => ({ + id: member.physicalProjectKey, + label: formatProjectMemberActionLabel(member, project.groupedProjectCount), + })), + { + x: event.clientX, + y: event.clientY, + }, + ); + if (!clicked) { + return; + } + const targetMember = project.memberProjects.find( + (member) => member.physicalProjectKey === clicked, + ); + if (!targetMember) { + return; + } + createThreadForProjectMember(targetMember); + })(); + }, + [createThreadForProjectMember, project.groupedProjectCount, project.memberProjects], ); const attemptArchiveThread = useCallback( @@ -1507,6 +1712,88 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [], ); + const closeProjectRenameDialog = useCallback(() => { + setProjectRenameTarget(null); + setProjectRenameTitle(""); + }, []); + + const submitProjectRename = useCallback(async () => { + if (!projectRenameTarget) { + return; + } + + const trimmed = projectRenameTitle.trim(); + if (trimmed.length === 0) { + toastManager.add({ + type: "warning", + title: "Project title cannot be empty", + }); + return; + } + + if (trimmed === projectRenameTarget.name) { + closeProjectRenameDialog(); + return; + } + + const api = readEnvironmentApi(projectRenameTarget.environmentId); + if (!api) { + toastManager.add({ + type: "error", + title: "Failed to rename project", + description: "Project API unavailable.", + }); + return; + } + + try { + await api.orchestration.dispatchCommand({ + type: "project.meta.update", + commandId: newCommandId(), + projectId: projectRenameTarget.id, + title: trimmed, + }); + closeProjectRenameDialog(); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to rename project", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle]); + + const closeProjectGroupingDialog = useCallback(() => { + setProjectGroupingTarget(null); + setProjectGroupingSelection("inherit"); + }, []); + + const saveProjectGroupingPreference = useCallback(() => { + if (!projectGroupingTarget) { + return; + } + + const overrideKey = deriveProjectGroupingOverrideKey(projectGroupingTarget); + const nextOverrides = { + ...projectGroupingSettings.sidebarProjectGroupingOverrides, + }; + if (projectGroupingSelection === "inherit") { + delete nextOverrides[overrideKey]; + } else { + nextOverrides[overrideKey] = projectGroupingSelection; + } + updateSettings({ + sidebarProjectGroupingOverrides: nextOverrides, + }); + closeProjectGroupingDialog(); + }, [ + closeProjectGroupingDialog, + projectGroupingSelection, + projectGroupingSettings.sidebarProjectGroupingOverrides, + projectGroupingTarget, + updateSettings, + ]); + const handleThreadContextMenu = useCallback( async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { const api = readLocalApi(); @@ -1519,7 +1806,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec projectThread.id === threadRef.threadId, ) ?? null; if (!thread) return; - const threadWorkspacePath = thread.worktreePath ?? project.cwd ?? null; + const threadProject = memberProjectByScopedKey.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ); + const threadWorkspacePath = thread.worktreePath ?? threadProject?.cwd ?? project.cwd ?? null; const clicked = await api.contextMenu.show( [ { id: "rename", label: "Rename thread" }, @@ -1578,6 +1868,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec copyThreadIdToClipboard, deleteThread, markThreadUnread, + memberProjectByScopedKey, project.cwd, projectThreads, ], @@ -1622,8 +1913,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec /> )} - - {project.name} + + + {project.displayName} + + {project.groupedProjectCount > 1 ? ( + + {project.groupedProjectCount} projects + + ) : null} {/* Environment badge – visible by default, crossfades with the @@ -1656,7 +1954,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
+ + + + + + { + if (!open) { + closeProjectGroupingDialog(); + } + }} + > + + + Project grouping + + {projectGroupingTarget + ? `Choose how ${projectGroupingTarget.cwd} should be grouped in the sidebar.` + : "Choose how this project should be grouped in the sidebar."} + + + +
+ Grouping rule + +
+

+ {projectGroupingSelection === "inherit" + ? projectGroupingModeDescription(projectGroupingSettings.sidebarProjectGroupingMode) + : projectGroupingModeDescription(projectGroupingSelection)} +

+
+ + + + +
+
); }); @@ -1768,13 +2184,17 @@ type SortableProjectHandleProps = Pick< function ProjectSortMenu({ projectSortOrder, threadSortOrder, + projectGroupingMode, onProjectSortOrderChange, onThreadSortOrderChange, + onProjectGroupingModeChange, }: { projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; + projectGroupingMode: SidebarProjectGroupingMode; onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; + onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; }) { return ( @@ -1827,6 +2247,30 @@ function ProjectSortMenu({ ))} + + +
+ Group projects +
+ { + if (value === "repository" || value === "repository_path" || value === "separate") { + onProjectGroupingModeChange(value); + } + }} + > + {( + Object.entries(PROJECT_GROUPING_MODE_LABELS) as Array< + [SidebarProjectGroupingMode, string] + > + ).map(([value, label]) => ( + + {label} + + ))} + +
); @@ -1954,6 +2398,7 @@ interface SidebarProjectsContentProps { handleDesktopUpdateButtonClick: () => void; projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; + projectGroupingMode: SidebarProjectGroupingMode; updateSettings: ReturnType["updateSettings"]; handleStartAddProject: () => void; isManualProjectSorting: boolean; @@ -1993,6 +2438,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( handleDesktopUpdateButtonClick, projectSortOrder, threadSortOrder, + projectGroupingMode, updateSettings, handleStartAddProject, isManualProjectSorting, @@ -2033,6 +2479,13 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }, [updateSettings], ); + const handleProjectGroupingModeChange = useCallback( + (groupingMode: SidebarProjectGroupingMode) => { + updateSettings({ sidebarProjectGroupingMode: groupingMode }); + }, + [updateSettings], + ); + return ( @@ -2090,8 +2543,10 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( s.sidebarProjectSortOrder); const defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode); const addProjectBaseDirectory = useSettings((s) => s.addProjectBaseDirectory); + const sidebarProjectGroupingMode = useSettings((s) => s.sidebarProjectGroupingMode); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); const { updateSettings } = useUpdateSettings(); const { handleNewThread } = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); @@ -2249,80 +2709,36 @@ export default function Sidebar() { // cross-environment grouping. Projects that share a repositoryIdentity // canonicalKey are treated as one logical project in the sidebar. const physicalToLogicalKey = useMemo(() => { - const mapping = new Map(); - for (const project of orderedProjects) { - const physicalKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id)); - mapping.set(physicalKey, deriveLogicalProjectKey(project)); - } - return mapping; - }, [orderedProjects]); + return buildPhysicalToLogicalProjectKeyMap({ + projects: orderedProjects, + settings: projectGroupingSettings, + }); + }, [orderedProjects, projectGroupingSettings]); + const projectPhysicalKeyByScopedRef = useMemo( + () => + new Map( + orderedProjects.map((project) => [ + scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + derivePhysicalProjectKey(project), + ]), + ), + [orderedProjects], + ); const sidebarProjects = useMemo(() => { - // Group projects by logical key while preserving insertion order from - // orderedProjects. - const groupedMembers = new Map(); - for (const project of orderedProjects) { - const logicalKey = deriveLogicalProjectKey(project); - const existing = groupedMembers.get(logicalKey); - if (existing) { - existing.push(project); - } else { - groupedMembers.set(logicalKey, [project]); - } - } - - const result: SidebarProjectSnapshot[] = []; - const seen = new Set(); - for (const project of orderedProjects) { - const logicalKey = deriveLogicalProjectKey(project); - if (seen.has(logicalKey)) continue; - seen.add(logicalKey); - - const members = groupedMembers.get(logicalKey)!; - // Prefer the primary environment's project as the representative. - const representative: Project | undefined = - (primaryEnvironmentId - ? members.find((p) => p.environmentId === primaryEnvironmentId) - : undefined) ?? members[0]; - if (!representative) continue; - const hasLocal = - primaryEnvironmentId !== null && - members.some((p) => p.environmentId === primaryEnvironmentId); - const hasRemote = - primaryEnvironmentId !== null - ? members.some((p) => p.environmentId !== primaryEnvironmentId) - : false; - - const refs = members.map((p) => scopeProjectRef(p.environmentId, p.id)); - const remoteLabels = members - .filter((p) => primaryEnvironmentId !== null && p.environmentId !== primaryEnvironmentId) - .map((p) => { - const rt = savedEnvironmentRuntimeById[p.environmentId]; - const saved = savedEnvironmentRegistry[p.environmentId]; - return rt?.descriptor?.label ?? saved?.label ?? p.environmentId; - }); - const snapshot: SidebarProjectSnapshot = { - id: representative.id, - environmentId: representative.environmentId, - name: representative.name, - cwd: representative.cwd, - repositoryIdentity: representative.repositoryIdentity ?? null, - defaultModelSelection: representative.defaultModelSelection, - createdAt: representative.createdAt, - updatedAt: representative.updatedAt, - scripts: representative.scripts, - jiraBoard: representative.jiraBoard, - projectKey: logicalKey, - environmentPresence: - hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", - memberProjectRefs: refs, - remoteEnvironmentLabels: remoteLabels, - }; - result.push(snapshot); - } - return result; + return buildSidebarProjectSnapshots({ + projects: orderedProjects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => { + const rt = savedEnvironmentRuntimeById[environmentId]; + const saved = savedEnvironmentRegistry[environmentId]; + return rt?.descriptor?.label ?? saved?.label ?? null; + }, + }); }, [ orderedProjects, + projectGroupingSettings, primaryEnvironmentId, savedEnvironmentRegistry, savedEnvironmentRuntimeById, @@ -2350,18 +2766,22 @@ export default function Sidebar() { } const activeThread = sidebarThreadByKey.get(routeThreadKey); if (!activeThread) return null; - const physicalKey = scopedProjectKey( - scopeProjectRef(activeThread.environmentId, activeThread.projectId), - ); + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)); return physicalToLogicalKey.get(physicalKey) ?? physicalKey; - }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey]); + }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); // Group threads by logical project key so all threads from grouped projects // are displayed together. const threadsByProjectKey = useMemo(() => { const next = new Map(); for (const thread of sidebarThreads) { - const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; const existing = next.get(logicalKey); if (existing) { @@ -2371,7 +2791,7 @@ export default function Sidebar() { } } return next; - }, [sidebarThreads, physicalToLogicalKey]); + }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); const getCurrentSidebarShortcutContext = useCallback( () => ({ terminalFocus: isTerminalFocused(), @@ -2530,8 +2950,10 @@ export default function Sidebar() { const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); const overProject = sidebarProjects.find((project) => project.projectKey === over.id); if (!activeProject || !overProject) return; - const activeMemberKeys = activeProject.memberProjectRefs.map(scopedProjectKey); - const overMemberKeys = overProject.memberProjectRefs.map(scopedProjectKey); + const activeMemberKeys = activeProject.memberProjects.map( + (member) => member.physicalProjectKey, + ); + const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey); reorderProjects(activeMemberKeys, overMemberKeys); }, [sidebarProjectSortOrder, reorderProjects, sidebarProjects], @@ -2580,7 +3002,10 @@ export default function Sidebar() { id: project.projectKey, })); const sortableThreads = visibleThreads.map((thread) => { - const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); return { ...thread, projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, @@ -2597,6 +3022,7 @@ export default function Sidebar() { }, [ sidebarProjectSortOrder, physicalToLogicalKey, + projectPhysicalKeyByScopedRef, sidebarProjectByKey, sidebarProjects, visibleThreads, @@ -2930,6 +3356,7 @@ export default function Sidebar() { handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick} projectSortOrder={sidebarProjectSortOrder} threadSortOrder={sidebarThreadSortOrder} + projectGroupingMode={sidebarProjectGroupingMode} updateSettings={updateSettings} handleStartAddProject={handleStartAddProject} isManualProjectSorting={isManualProjectSorting} diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts new file mode 100644 index 00000000000..598d0d8bbed --- /dev/null +++ b/apps/web/src/contextMenuFallback.test.ts @@ -0,0 +1,221 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { showContextMenuFallback } from "./contextMenuFallback"; + +type FakeListener = (event: FakeDomEvent) => void; + +class FakeDomEvent { + defaultPrevented = false; + + constructor( + readonly type: string, + init: Record = {}, + ) { + Object.assign(this, init); + } + + preventDefault() { + this.defaultPrevented = true; + } +} + +class FakeElement { + children: FakeElement[] = []; + parent: FakeElement | null = null; + style: Record & { cssText?: string } = {}; + dataset: Record = {}; + className = ""; + disabled = false; + type = ""; + private textValue = ""; + private readonly listeners = new Map(); + + constructor(readonly tagName: string) {} + + appendChild(child: FakeElement) { + child.parent = this; + this.children.push(child); + return child; + } + + remove() { + if (!this.parent) { + return; + } + const index = this.parent.children.indexOf(this); + if (index >= 0) { + this.parent.children.splice(index, 1); + } + this.parent = null; + } + + addEventListener(type: string, listener: FakeListener) { + const existing = this.listeners.get(type) ?? []; + existing.push(listener); + this.listeners.set(type, existing); + } + + dispatchEvent(event: FakeDomEvent) { + for (const listener of this.listeners.get(event.type) ?? []) { + listener(event); + } + return true; + } + + set textContent(value: string) { + this.textValue = value; + } + + get textContent() { + return `${this.textValue}${this.children.map((child) => child.textContent).join("")}`; + } + + querySelectorAll(tagName: string): FakeElement[] { + const matches: FakeElement[] = []; + if (this.tagName === tagName) { + matches.push(this); + } + for (const child of this.children) { + matches.push(...child.querySelectorAll(tagName)); + } + return matches; + } + + getBoundingClientRect() { + const left = Number.parseInt(this.style.left ?? "0", 10) || 0; + const top = Number.parseInt(this.style.top ?? "0", 10) || 0; + const width = this.tagName === "div" ? 180 : 140; + const height = this.tagName === "div" ? 120 : 28; + return { + left, + top, + width, + height, + right: left + width, + bottom: top + height, + }; + } +} + +class FakeBody extends FakeElement { + private html = ""; + + constructor() { + super("body"); + } + + set innerHTML(value: string) { + this.html = value; + this.children = []; + } + + get innerHTML() { + return this.html; + } +} + +class FakeDocument { + body = new FakeBody(); + private readonly listeners = new Map(); + + createElement(tagName: string) { + return new FakeElement(tagName); + } + + addEventListener(type: string, listener: FakeListener) { + const existing = this.listeners.get(type) ?? []; + existing.push(listener); + this.listeners.set(type, existing); + } + + removeEventListener(type: string, listener: FakeListener) { + const existing = this.listeners.get(type); + if (!existing) { + return; + } + const index = existing.indexOf(listener); + if (index >= 0) { + existing.splice(index, 1); + } + } + + querySelectorAll(tagName: string) { + return this.body.querySelectorAll(tagName); + } +} + +function findButton(label: string): FakeElement | undefined { + return (document as unknown as FakeDocument) + .querySelectorAll("button") + .find((button) => button.textContent.includes(label)); +} + +beforeEach(() => { + vi.stubGlobal("document", new FakeDocument()); + vi.stubGlobal("window", { + innerWidth: 1280, + innerHeight: 800, + }); + vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => { + callback(0); + return 0; + }); + vi.stubGlobal( + "MouseEvent", + class extends FakeDomEvent { + constructor(type: string, init: Record = {}) { + super(type, init); + } + }, + ); + vi.stubGlobal( + "KeyboardEvent", + class extends FakeDomEvent { + constructor(type: string, init: Record = {}) { + super(type, init); + } + }, + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("showContextMenuFallback", () => { + it("resolves a clicked flat menu item", async () => { + const selectionPromise = showContextMenuFallback([ + { id: "rename", label: "Rename" }, + { id: "delete", label: "Delete", destructive: true }, + ]); + + const renameButton = findButton("Rename"); + expect(renameButton).toBeTruthy(); + renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + await expect(selectionPromise).resolves.toBe("rename"); + }); + + it("opens nested submenus and resolves the clicked leaf id", async () => { + const selectionPromise = showContextMenuFallback([ + { + id: "rename:submenu", + label: "Rename project", + children: [ + { id: "rename:project-a", label: "/tmp/project-a" }, + { id: "rename:project-b", label: "/tmp/project-b" }, + ], + }, + ]); + + const parentButton = findButton("Rename project"); + expect(parentButton).toBeTruthy(); + parentButton?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + + const childButton = findButton("/tmp/project-b"); + expect(childButton).toBeTruthy(); + childButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + await expect(selectionPromise).resolves.toBe("rename:project-b"); + }); +}); diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 6e50aa40105..38492afd48b 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -1,9 +1,22 @@ import type { ContextMenuItem } from "@marcode/contracts"; +function clampMenuPosition(menu: HTMLDivElement, preferredLeft: number, preferredTop: number) { + const rect = menu.getBoundingClientRect(); + const left = Math.min( + Math.max(4, preferredLeft), + Math.max(4, window.innerWidth - rect.width - 4), + ); + const top = Math.min( + Math.max(4, preferredTop), + Math.max(4, window.innerHeight - rect.height - 4), + ); + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; +} + /** * Imperative DOM-based context menu for non-Electron environments. - * Shows a positioned dropdown and returns a promise that resolves - * with the clicked item id, or null if dismissed. + * Supports nested submenus and resolves with the clicked leaf item id. */ export function showContextMenuFallback( items: readonly ContextMenuItem[], @@ -13,62 +26,117 @@ export function showContextMenuFallback( const overlay = document.createElement("div"); overlay.style.cssText = "position:fixed;inset:0;z-index:9999"; - const menu = document.createElement("div"); - menu.className = - "fixed z-[10000] min-w-[140px] rounded-md border border-border bg-popover py-1 shadow-xl animate-in fade-in zoom-in-95"; - - const x = position?.x ?? 0; - const y = position?.y ?? 0; - menu.style.top = `${y}px`; - menu.style.left = `${x}px`; + const menuStack: HTMLDivElement[] = []; - function cleanup(result: T | null) { + const cleanup = (result: T | null) => { document.removeEventListener("keydown", onKeyDown); overlay.remove(); - menu.remove(); + for (const menu of menuStack) { + menu.remove(); + } resolve(result); - } + }; - function onKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") { - e.preventDefault(); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); cleanup(null); } - } + }; - overlay.addEventListener("mousedown", () => cleanup(null)); - document.addEventListener("keydown", onKeyDown); - - for (const item of items) { - const btn = document.createElement("button"); - btn.type = "button"; - btn.textContent = item.label; - const isDestructiveAction = item.destructive === true || item.id === "delete"; - const isDisabled = item.disabled === true; - btn.disabled = isDisabled; - btn.className = isDisabled - ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-muted-foreground/60 cursor-not-allowed" - : isDestructiveAction - ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default" - : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default"; - if (!isDisabled) { - btn.addEventListener("click", () => cleanup(item.id)); + const closeMenusFromLevel = (level: number) => { + while (menuStack.length > level) { + menuStack.pop()?.remove(); } - menu.appendChild(btn); - } + }; - document.body.appendChild(overlay); - document.body.appendChild(menu); + const openMenu = ( + entries: readonly ContextMenuItem[], + preferredLeft: number, + preferredTop: number, + level: number, + ) => { + closeMenusFromLevel(level); - // Adjust if menu overflows viewport - requestAnimationFrame(() => { - const rect = menu.getBoundingClientRect(); - if (rect.right > window.innerWidth) { - menu.style.left = `${window.innerWidth - rect.width - 4}px`; - } - if (rect.bottom > window.innerHeight) { - menu.style.top = `${window.innerHeight - rect.height - 4}px`; + const menu = document.createElement("div"); + menu.className = + "fixed z-[10000] min-w-[160px] rounded-md border border-border bg-popover py-1 shadow-xl animate-in fade-in zoom-in-95"; + menu.style.left = `${preferredLeft}px`; + menu.style.top = `${preferredTop}px`; + menu.dataset.level = String(level); + + for (const item of entries) { + const button = document.createElement("button"); + button.type = "button"; + const hasChildren = Array.isArray(item.children) && item.children.length > 0; + const isLeafDestructive = + !hasChildren && (item.destructive === true || item.id === ("delete" as T)); + const isDisabled = item.disabled === true; + button.disabled = isDisabled; + button.className = isDisabled + ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-muted-foreground/60 cursor-not-allowed" + : isLeafDestructive + ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default" + : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default"; + + const label = document.createElement("span"); + label.className = "min-w-0 flex-1 truncate"; + label.textContent = item.label; + button.appendChild(label); + + if (hasChildren) { + const chevron = document.createElement("span"); + chevron.className = "shrink-0 text-muted-foreground/70"; + chevron.textContent = "›"; + button.appendChild(chevron); + } + + if (!isDisabled) { + if (hasChildren) { + button.addEventListener("mouseenter", () => { + const rect = button.getBoundingClientRect(); + const nextLeft = rect.right + 4; + const nextTop = rect.top; + openMenu(item.children!, nextLeft, nextTop, level + 1); + + const childMenu = menuStack[level + 1]; + if (!childMenu) { + return; + } + const childRect = childMenu.getBoundingClientRect(); + if (childRect.right > window.innerWidth) { + clampMenuPosition(childMenu, rect.left - childRect.width - 4, rect.top); + } + }); + button.addEventListener("click", (event) => { + event.preventDefault(); + }); + } else { + button.addEventListener("mouseenter", () => { + closeMenusFromLevel(level + 1); + }); + button.addEventListener("click", () => cleanup(item.id)); + } + } + + menu.appendChild(button); } - }); + + menu.addEventListener("mouseenter", () => { + closeMenusFromLevel(level + 1); + }); + + document.body.appendChild(menu); + menuStack[level] = menu; + + requestAnimationFrame(() => { + clampMenuPosition(menu, preferredLeft, preferredTop); + }); + }; + + overlay.addEventListener("mousedown", () => cleanup(null)); + document.addEventListener("keydown", onKeyDown); + document.body.appendChild(overlay); + openMenu(items, position?.x ?? 0, position?.y ?? 0, 0); }); } diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 8c216e5b38d..d730bb6efd4 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -10,7 +10,12 @@ import { type AppState, type EnvironmentState, } from "./store"; -import { deriveLogicalProjectKey } from "./logicalProject"; +import { + deriveLogicalProjectKey, + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKey, + resolveProjectGroupingMode, +} from "./logicalProject"; import type { Project, SidebarThreadSummary } from "./types"; import { DEFAULT_INTERACTION_MODE } from "./types"; @@ -31,6 +36,10 @@ const threadL1 = ThreadId.make("thread-local-only-1"); const threadRO1 = ThreadId.make("thread-remote-only-1"); const SHARED_REPO_CANONICAL_KEY = "github.com/example/shared-repo"; +const DEFAULT_GROUPING_SETTINGS = { + sidebarProjectGroupingMode: "repository" as const, + sidebarProjectGroupingOverrides: {}, +}; // ── Factory Helpers ────────────────────────────────────────────────── @@ -239,9 +248,7 @@ describe("environment grouping", () => { environmentId: primaryEnvId, name: "local-only", }); - const key = deriveLogicalProjectKey(project); - expect(key).toContain(primaryEnvId); - expect(key).toContain(localOnlyProjectId); + expect(deriveLogicalProjectKey(project)).toBe(derivePhysicalProjectKey(project)); }); it("groups projects from different environments that share the same canonical key", () => { @@ -274,6 +281,134 @@ describe("environment grouping", () => { expect(deriveLogicalProjectKey(primary)).toBe(deriveLogicalProjectKey(remote)); }); + it("groups repo root and nested projects from the same repository by default", () => { + const rootProject = makeProject({ + id: sharedProjectPrimaryId, + environmentId: primaryEnvId, + name: "shared-repo", + cwd: "/workspace/repo", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + const nestedProject = makeProject({ + id: localOnlyProjectId, + environmentId: primaryEnvId, + name: "web", + cwd: "/workspace/repo/apps/web", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + + expect(deriveLogicalProjectKey(rootProject)).toBe(SHARED_REPO_CANONICAL_KEY); + expect(deriveLogicalProjectKey(nestedProject)).toBe(SHARED_REPO_CANONICAL_KEY); + }); + + it("uses repository path grouping when requested", () => { + const rootProject = makeProject({ + id: sharedProjectPrimaryId, + environmentId: primaryEnvId, + name: "shared-repo", + cwd: "/workspace/repo", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + const nestedProject = makeProject({ + id: localOnlyProjectId, + environmentId: primaryEnvId, + name: "web", + cwd: "/workspace/repo/apps/web", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + + expect( + deriveLogicalProjectKey(rootProject, { + groupingMode: "repository_path", + }), + ).toBe(SHARED_REPO_CANONICAL_KEY); + expect( + deriveLogicalProjectKey(nestedProject, { + groupingMode: "repository_path", + }), + ).toBe(`${SHARED_REPO_CANONICAL_KEY}::apps/web`); + }); + + it("groups matching nested project paths across environments when repo roots differ", () => { + const primary = makeProject({ + id: sharedProjectPrimaryId, + environmentId: primaryEnvId, + name: "web", + cwd: "/workspace/repo/apps/web", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + const remote = makeProject({ + id: sharedProjectRemoteId, + environmentId: remoteEnvId, + name: "web", + cwd: "/srv/checkout/apps/web", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/srv/checkout", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + + expect( + deriveLogicalProjectKey(primary, { + groupingMode: "repository_path", + }), + ).toBe(`${SHARED_REPO_CANONICAL_KEY}::apps/web`); + expect( + deriveLogicalProjectKey(primary, { + groupingMode: "repository_path", + }), + ).toBe( + deriveLogicalProjectKey(remote, { + groupingMode: "repository_path", + }), + ); + }); + it("does NOT group projects without shared canonical key", () => { const local = makeProject({ id: localOnlyProjectId, @@ -287,6 +422,32 @@ describe("environment grouping", () => { }); expect(deriveLogicalProjectKey(local)).not.toBe(deriveLogicalProjectKey(remote)); }); + + it("uses per-project overrides from settings", () => { + const project = makeProject({ + id: sharedProjectPrimaryId, + environmentId: primaryEnvId, + name: "shared-repo", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + + expect(resolveProjectGroupingMode(project, DEFAULT_GROUPING_SETTINGS)).toBe("repository"); + expect( + deriveLogicalProjectKeyFromSettings(project, { + ...DEFAULT_GROUPING_SETTINGS, + sidebarProjectGroupingOverrides: { + [derivePhysicalProjectKey(project)]: "separate", + }, + }), + ).toBe(derivePhysicalProjectKey(project)); + }); }); describe("selectProjectsAcrossEnvironments", () => { diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index fe840196004..4d30add0fea 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -12,9 +12,7 @@ import { Throttler } from "@tanstack/react-pacer"; import { createKnownEnvironment, getKnownEnvironmentWsBaseUrl, - scopedProjectKey, scopedThreadKey, - scopeProjectRef, scopeThreadRef, } from "@marcode/client-runtime"; @@ -68,6 +66,7 @@ import { useTerminalStateStore } from "~/terminalStateStore"; import { useUiStateStore } from "~/uiStateStore"; import { WsTransport } from "../../rpc/wsTransport"; import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient"; +import { derivePhysicalProjectKey } from "../../logicalProject"; type EnvironmentServiceState = { readonly queryClient: QueryClient; @@ -184,7 +183,7 @@ function reconcileSnapshotDerivedState() { useUiStateStore.getState().syncProjects( projects.map((project) => ({ - key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + key: derivePhysicalProjectKey(project), cwd: project.cwd, })), ); @@ -255,7 +254,7 @@ function applyRecoveredEventBatch( const projects = selectProjectsAcrossEnvironments(useStore.getState()); useUiStateStore.getState().syncProjects( projects.map((project) => ({ - key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + key: derivePhysicalProjectKey(project), cwd: project.cwd, })), ); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index b84bedb6577..ff8a8537044 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -10,14 +10,19 @@ import { } from "../composerDraftStore"; import { newDraftId, newThreadId } from "../lib/utils"; import { orderItemsByPreferredIds } from "../components/Sidebar.logic"; -import { deriveLogicalProjectKey } from "../logicalProject"; +import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { selectProjectsAcrossEnvironments, useStore } from "../store"; import { createThreadSelectorByRef } from "../storeSelectors"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { useUiStateStore } from "../uiStateStore"; +import { useSettings } from "./useSettings"; function useNewThreadState() { const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store))); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); const router = useRouter(); const getCurrentRouteTarget = useCallback(() => { const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; @@ -48,7 +53,7 @@ function useNewThreadState() { candidate.environmentId === projectRef.environmentId, ); const logicalProjectKey = project - ? deriveLogicalProjectKey(project) + ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) : scopedProjectKey(projectRef); const hasBranchOption = options?.branch !== undefined; const hasWorktreePathOption = options?.worktreePath !== undefined; @@ -128,7 +133,7 @@ function useNewThreadState() { }); })(); }, - [getCurrentRouteTarget, router, projects], + [getCurrentRouteTarget, projectGroupingSettings, router, projects], ); } diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 78e56115ad0..26ad145270c 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -514,14 +514,30 @@ describe("wsApi", () => { }); it("reads and writes persistence through the desktop bridge when available", async () => { - const getClientSettings = vi.fn().mockResolvedValue({ + const clientSettings = { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, favorites: [], - sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", - timestampFormat: "24-hour", + sidebarProjectGroupingMode: "repository_path" as const, + sidebarProjectGroupingOverrides: { + "environment-local:/tmp/project": "separate" as const, + }, + sidebarProjectSortOrder: "manual" as const, + sidebarThreadSortOrder: "created_at" as const, + timestampFormat: "24-hour" as const, + turnNotificationMode: "off" as const, + turnNotificationSoundId: "default", + turnNotificationCustomSounds: [], + turnNotificationAdvancedSounds: false, + turnNotificationSoundMap: { + "turn-events": "default", + "approval-needed": "default", + "user-input-needed": "default", + }, + }; + const getClientSettings = vi.fn().mockResolvedValue({ + ...clientSettings, }); const setClientSettings = vi.fn().mockResolvedValue(undefined); const getSavedEnvironmentRegistry = vi.fn().mockResolvedValue([]); @@ -543,24 +559,7 @@ describe("wsApi", () => { const api = createLocalApi(rpcClientMock as never); await api.persistence.getClientSettings(); - await api.persistence.setClientSettings({ - confirmThreadArchive: true, - confirmThreadDelete: false, - diffWordWrap: true, - favorites: [], - sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", - timestampFormat: "24-hour", - turnNotificationMode: "off", - turnNotificationSoundId: "default", - turnNotificationCustomSounds: [], - turnNotificationAdvancedSounds: false, - turnNotificationSoundMap: { - "turn-events": "default", - "approval-needed": "default", - "user-input-needed": "default", - }, - }); + await api.persistence.setClientSettings(clientSettings); await api.persistence.getSavedEnvironmentRegistry(); await api.persistence.setSavedEnvironmentRegistry([]); await api.persistence.getSavedEnvironmentSecret(EnvironmentId.make("environment-local")); @@ -571,24 +570,7 @@ describe("wsApi", () => { await api.persistence.removeSavedEnvironmentSecret(EnvironmentId.make("environment-local")); expect(getClientSettings).toHaveBeenCalledWith(); - expect(setClientSettings).toHaveBeenCalledWith({ - confirmThreadArchive: true, - confirmThreadDelete: false, - diffWordWrap: true, - favorites: [], - sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", - timestampFormat: "24-hour", - turnNotificationMode: "off", - turnNotificationSoundId: "default", - turnNotificationCustomSounds: [], - turnNotificationAdvancedSounds: false, - turnNotificationSoundMap: { - "turn-events": "default", - "approval-needed": "default", - "user-input-needed": "default", - }, - }); + expect(setClientSettings).toHaveBeenCalledWith(clientSettings); expect(getSavedEnvironmentRegistry).toHaveBeenCalledWith(); expect(setSavedEnvironmentRegistry).toHaveBeenCalledWith([]); expect(getSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local"); @@ -599,16 +581,19 @@ describe("wsApi", () => { it("falls back to browser storage for persistence when the desktop bridge is missing", async () => { const { createLocalApi } = await import("./localApi"); const api = createLocalApi(rpcClientMock as never); - - await api.persistence.setClientSettings({ + const clientSettings = { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, favorites: [], - sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", - timestampFormat: "24-hour", - turnNotificationMode: "off", + sidebarProjectGroupingMode: "repository_path" as const, + sidebarProjectGroupingOverrides: { + "environment-local:/tmp/project": "separate" as const, + }, + sidebarProjectSortOrder: "manual" as const, + sidebarThreadSortOrder: "created_at" as const, + timestampFormat: "24-hour" as const, + turnNotificationMode: "off" as const, turnNotificationSoundId: "default", turnNotificationCustomSounds: [], turnNotificationAdvancedSounds: false, @@ -617,7 +602,9 @@ describe("wsApi", () => { "approval-needed": "default", "user-input-needed": "default", }, - }); + }; + + await api.persistence.setClientSettings(clientSettings); await api.persistence.setSavedEnvironmentRegistry([ { environmentId: EnvironmentId.make("environment-local"), @@ -633,24 +620,7 @@ describe("wsApi", () => { "bearer-token", ); - await expect(api.persistence.getClientSettings()).resolves.toEqual({ - confirmThreadArchive: true, - confirmThreadDelete: false, - diffWordWrap: true, - favorites: [], - sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", - timestampFormat: "24-hour", - turnNotificationAdvancedSounds: false, - turnNotificationCustomSounds: [], - turnNotificationMode: "off", - turnNotificationSoundId: "default", - turnNotificationSoundMap: { - "approval-needed": "default", - "turn-events": "default", - "user-input-needed": "default", - }, - }); + await expect(api.persistence.getClientSettings()).resolves.toEqual(clientSettings); await expect(api.persistence.getSavedEnvironmentRegistry()).resolves.toEqual([ { environmentId: EnvironmentId.make("environment-local"), diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts index be0363fa5e1..feb0cbb3423 100644 --- a/apps/web/src/logicalProject.ts +++ b/apps/web/src/logicalProject.ts @@ -1,19 +1,157 @@ import { scopedProjectKey, scopeProjectRef } from "@marcode/client-runtime"; -import type { ScopedProjectRef } from "@marcode/contracts"; +import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@marcode/contracts"; +import { normalizeProjectPathForComparison } from "./lib/projectPaths"; import type { Project } from "./types"; +export interface ProjectGroupingSettings { + sidebarProjectGroupingMode: SidebarProjectGroupingMode; + sidebarProjectGroupingOverrides: Record; +} + +export type ProjectGroupingMode = SidebarProjectGroupingMode; + +function uniqueNonEmptyValues(values: ReadonlyArray): string[] { + const seen = new Set(); + const unique: string[] = []; + for (const value of values) { + const trimmed = value?.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + unique.push(trimmed); + } + return unique; +} + +function deriveRepositoryRelativeProjectPath( + project: Pick, +): string | null { + const rootPath = project.repositoryIdentity?.rootPath?.trim(); + if (!rootPath) { + return null; + } + + const normalizedProjectPath = normalizeProjectPathForComparison(project.cwd); + const normalizedRootPath = normalizeProjectPathForComparison(rootPath); + if (normalizedProjectPath.length === 0 || normalizedRootPath.length === 0) { + return null; + } + + if (normalizedProjectPath === normalizedRootPath) { + return ""; + } + + const separator = normalizedRootPath.includes("\\") ? "\\" : "/"; + const rootPrefix = `${normalizedRootPath}${separator}`; + if (!normalizedProjectPath.startsWith(rootPrefix)) { + return null; + } + + return normalizedProjectPath.slice(rootPrefix.length).replaceAll("\\", "/"); +} + +export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: string): string { + return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`; +} + +export function derivePhysicalProjectKey(project: Pick): string { + return derivePhysicalProjectKeyFromPath(project.environmentId, project.cwd); +} + +export function deriveProjectGroupingOverrideKey( + project: Pick, +): string { + return derivePhysicalProjectKey(project); +} + +export function resolveProjectGroupingMode( + project: Pick, + settings: ProjectGroupingSettings, +): SidebarProjectGroupingMode { + return ( + settings.sidebarProjectGroupingOverrides[deriveProjectGroupingOverrideKey(project)] ?? + settings.sidebarProjectGroupingMode + ); +} + +function deriveRepositoryScopedKey( + project: Pick, + groupingMode: SidebarProjectGroupingMode, +): string | null { + const canonicalKey = project.repositoryIdentity?.canonicalKey; + if (!canonicalKey) { + return null; + } + + if (groupingMode === "repository") { + return canonicalKey; + } + + const relativeProjectPath = deriveRepositoryRelativeProjectPath(project); + if (relativeProjectPath === null) { + return canonicalKey; + } + + return relativeProjectPath.length === 0 + ? canonicalKey + : `${canonicalKey}::${relativeProjectPath}`; +} + export function deriveLogicalProjectKey( - project: Pick, + project: Pick, + options?: { + groupingMode?: SidebarProjectGroupingMode; + }, ): string { + const groupingMode = options?.groupingMode ?? "repository"; + if (groupingMode === "separate") { + return derivePhysicalProjectKey(project); + } + return ( - project.repositoryIdentity?.canonicalKey ?? + deriveRepositoryScopedKey(project, groupingMode) ?? + derivePhysicalProjectKey(project) ?? scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) ); } +export function deriveLogicalProjectKeyFromSettings( + project: Pick, + settings: ProjectGroupingSettings, +): string { + return deriveLogicalProjectKey(project, { + groupingMode: resolveProjectGroupingMode(project, settings), + }); +} + export function deriveLogicalProjectKeyFromRef( projectRef: ScopedProjectRef, - project: Pick | null | undefined, + project: Pick | null | undefined, + options?: { + groupingMode?: SidebarProjectGroupingMode; + }, ): string { - return project?.repositoryIdentity?.canonicalKey ?? scopedProjectKey(projectRef); + return project ? deriveLogicalProjectKey(project, options) : scopedProjectKey(projectRef); +} + +export function deriveProjectGroupLabel(input: { + representative: Pick; + members: ReadonlyArray>; +}): string { + const sharedDisplayNames = uniqueNonEmptyValues( + input.members.map((member) => member.repositoryIdentity?.displayName), + ); + if (sharedDisplayNames.length === 1) { + return sharedDisplayNames[0]!; + } + + const sharedRepositoryNames = uniqueNonEmptyValues( + input.members.map((member) => member.repositoryIdentity?.name), + ); + if (sharedRepositoryNames.length === 1) { + return sharedRepositoryNames[0]!; + } + + return input.representative.name; } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 3dc512040c3..45a5a3cd300 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -24,6 +24,11 @@ import { AnchoredToastProvider, ToastProvider, toastManager } from "../component import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { readLocalApi } from "../localApi"; import { useRuntimeToolOutputStore } from "../runtimeToolOutputStore"; +import { useSettings } from "../hooks/useSettings"; +import { + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKeyFromPath, +} from "../logicalProject"; import { getServerConfigUpdatedNotification, ServerConfigUpdatedNotification, @@ -226,6 +231,10 @@ function EventRouter() { const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); const readPathname = useEffectEvent(() => pathname); const handledBootstrapThreadIdRef = useRef(null); const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); @@ -248,14 +257,21 @@ function EventRouter() { if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { return; } - useUiStateStore - .getState() - .setProjectExpanded( - scopedProjectKey( - scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId), - ), - true, + const bootstrapEnvironmentState = + useStore.getState().environmentStateById[payload.environment.environmentId]; + const bootstrapProject = + bootstrapEnvironmentState?.projectById[payload.bootstrapProjectId] ?? null; + const bootstrapProjectKey = + (bootstrapProject + ? deriveLogicalProjectKeyFromSettings(bootstrapProject, projectGroupingSettings) + : null) ?? + (serverConfig?.cwd + ? derivePhysicalProjectKeyFromPath(payload.environment.environmentId, serverConfig.cwd) + : null) ?? + scopedProjectKey( + scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId), ); + useUiStateStore.getState().setProjectExpanded(bootstrapProjectKey, true); if (readPathname() !== "/") { return; diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts new file mode 100644 index 00000000000..68cd84bf310 --- /dev/null +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -0,0 +1,118 @@ +import { scopeProjectRef } from "@marcode/client-runtime"; +import type { EnvironmentId, ScopedProjectRef } from "@marcode/contracts"; +import { + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKey, + deriveProjectGroupLabel, + type ProjectGroupingSettings, +} from "./logicalProject"; +import type { Project } from "./types"; + +export type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; + +export interface SidebarProjectGroupMember extends Project { + physicalProjectKey: string; + environmentLabel: string | null; +} + +export interface SidebarProjectSnapshot extends Project { + projectKey: string; + displayName: string; + groupedProjectCount: number; + environmentPresence: EnvironmentPresence; + memberProjects: readonly SidebarProjectGroupMember[]; + memberProjectRefs: readonly ScopedProjectRef[]; + remoteEnvironmentLabels: readonly string[]; +} + +export function buildPhysicalToLogicalProjectKeyMap(input: { + projects: ReadonlyArray; + settings: ProjectGroupingSettings; +}): Map { + const mapping = new Map(); + for (const project of input.projects) { + mapping.set( + derivePhysicalProjectKey(project), + deriveLogicalProjectKeyFromSettings(project, input.settings), + ); + } + return mapping; +} + +export function buildSidebarProjectSnapshots(input: { + projects: ReadonlyArray; + settings: ProjectGroupingSettings; + primaryEnvironmentId: EnvironmentId | null; + resolveEnvironmentLabel: (environmentId: EnvironmentId) => string | null; +}): SidebarProjectSnapshot[] { + const groupedMembers = new Map(); + for (const project of input.projects) { + const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings); + const member: SidebarProjectGroupMember = { + ...project, + physicalProjectKey: derivePhysicalProjectKey(project), + environmentLabel: input.resolveEnvironmentLabel(project.environmentId), + }; + const existing = groupedMembers.get(logicalKey); + if (existing) { + existing.push(member); + } else { + groupedMembers.set(logicalKey, [member]); + } + } + + const result: SidebarProjectSnapshot[] = []; + const seen = new Set(); + for (const project of input.projects) { + const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings); + if (seen.has(logicalKey)) { + continue; + } + seen.add(logicalKey); + + const members = groupedMembers.get(logicalKey) ?? []; + const representative = + (input.primaryEnvironmentId + ? members.find((member) => member.environmentId === input.primaryEnvironmentId) + : null) ?? members[0]; + if (!representative) { + continue; + } + + const hasLocal = + input.primaryEnvironmentId !== null && + members.some((member) => member.environmentId === input.primaryEnvironmentId); + const hasRemote = + input.primaryEnvironmentId !== null + ? members.some((member) => member.environmentId !== input.primaryEnvironmentId) + : false; + const remoteEnvironmentLabels = members + .filter( + (member) => + input.primaryEnvironmentId !== null && + member.environmentId !== input.primaryEnvironmentId, + ) + .flatMap((member) => (member.environmentLabel ? [member.environmentLabel] : [])) + .filter((label, index, labels) => labels.indexOf(label) === index); + + result.push({ + ...representative, + projectKey: logicalKey, + displayName: + members.length > 1 + ? deriveProjectGroupLabel({ + representative, + members, + }) + : representative.name, + groupedProjectCount: members.length, + environmentPresence: + hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", + memberProjects: members, + memberProjectRefs: members.map((member) => scopeProjectRef(member.environmentId, member.id)), + remoteEnvironmentLabels, + }); + } + + return result; +} diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index bc3b5459cd9..28adbf21781 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -51,6 +51,7 @@ export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type; export const RepositoryIdentity = Schema.Struct({ canonicalKey: TrimmedNonEmptyString, locator: RepositoryIdentityLocator, + rootPath: Schema.optionalKey(TrimmedNonEmptyString), displayName: Schema.optionalKey(TrimmedNonEmptyString), provider: Schema.optionalKey(TrimmedNonEmptyString), owner: Schema.optionalKey(TrimmedNonEmptyString), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 3b3d030d9e6..d4440ef0ff7 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -79,6 +79,7 @@ export interface ContextMenuItem { label: string; destructive?: boolean; disabled?: boolean; + children?: readonly ContextMenuItem[]; } export type DesktopUpdateStatus = diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 226cf1897f8..8422ec930de 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -56,6 +56,14 @@ export const NotificationSoundMap = Schema.Struct({ }); export type NotificationSoundMap = typeof NotificationSoundMap.Type; +export const SidebarProjectGroupingMode = Schema.Literals([ + "repository", + "repository_path", + "separate", +]); +export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type; +export const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository"; + export const ClientSettingsSchema = Schema.Struct({ confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), @@ -66,6 +74,13 @@ export const ClientSettingsSchema = Schema.Struct({ model: TrimmedNonEmptyString, }), ).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), + ), + sidebarProjectGroupingOverrides: Schema.Record( + TrimmedNonEmptyString, + SidebarProjectGroupingMode, + ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_SORT_ORDER)), ), From c5f4c9115eaab44640be189441c46f87362f000e Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Tue, 21 Apr 2026 13:50:23 +0300 Subject: [PATCH 3/5] fix(sidebar): handle undefined sidebarProjectGroupingOverrides on stale settings readClientSettings() loads client-settings.json as a raw JSON blob without running the schema decoder, so users with pre-upgrade settings files have no sidebarProjectGroupingOverrides key. Dereferencing it with bracket access threw "Cannot read properties of undefined (reading '\${envId}:\${path}')" and blocked the app from opening. - resolveProjectGroupingMode: optional-chain the overrides record and fall back to DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE when the mode itself is missing - openProjectGroupingDialog: optional-chain the overrides lookup - Grouping-rule Select "global default" label falls back to the default mode when settings.sidebarProjectGroupingMode is missing Co-Authored-By: Claude Opus 4.7 --- apps/web/src/components/Sidebar.tsx | 5 +++-- apps/web/src/logicalProject.ts | 11 ++++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 38599a4e5c5..fd872819d5a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -39,6 +39,7 @@ import { CSS } from "@dnd-kit/utilities"; import { type ContextMenuItem, DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, type DesktopUpdateState, type EnvironmentId, ProjectId, @@ -1266,7 +1267,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const overrideKey = deriveProjectGroupingOverrideKey(member); setProjectGroupingTarget(member); setProjectGroupingSelection( - projectGroupingSettings.sidebarProjectGroupingOverrides[overrideKey] ?? "inherit", + projectGroupingSettings.sidebarProjectGroupingOverrides?.[overrideKey] ?? "inherit", ); }, [projectGroupingSettings.sidebarProjectGroupingOverrides], @@ -2089,7 +2090,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec {projectGroupingSelection === "inherit" - ? `Use global default (${PROJECT_GROUPING_MODE_LABELS[projectGroupingSettings.sidebarProjectGroupingMode]})` + ? `Use global default (${PROJECT_GROUPING_MODE_LABELS[projectGroupingSettings.sidebarProjectGroupingMode ?? DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE]})` : PROJECT_GROUPING_MODE_LABELS[projectGroupingSelection]} diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts index feb0cbb3423..6e623e83463 100644 --- a/apps/web/src/logicalProject.ts +++ b/apps/web/src/logicalProject.ts @@ -1,5 +1,9 @@ import { scopedProjectKey, scopeProjectRef } from "@marcode/client-runtime"; -import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@marcode/contracts"; +import { + DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, + type ScopedProjectRef, + type SidebarProjectGroupingMode, +} from "@marcode/contracts"; import { normalizeProjectPathForComparison } from "./lib/projectPaths"; import type { Project } from "./types"; @@ -70,8 +74,9 @@ export function resolveProjectGroupingMode( settings: ProjectGroupingSettings, ): SidebarProjectGroupingMode { return ( - settings.sidebarProjectGroupingOverrides[deriveProjectGroupingOverrideKey(project)] ?? - settings.sidebarProjectGroupingMode + settings.sidebarProjectGroupingOverrides?.[deriveProjectGroupingOverrideKey(project)] ?? + settings.sidebarProjectGroupingMode ?? + DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE ); } From 51d100446f8a1380c9ffa357b539faa77474ef8e Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Tue, 21 Apr 2026 14:07:31 +0300 Subject: [PATCH 4/5] fix(sidebar): prefer origin remote and deep-merge persisted client settings Forks with both origin and upstream were surfacing the upstream repo name because pickPrimaryRemote prioritized upstream. Flip the order so a user's own fork wins, making the canonicalKey consistent with their working tree. Client settings hydration replaced the default snapshot with raw persisted JSON, so fields added in later versions (e.g. sidebarProjectGroupingMode) stayed undefined on upgrade. Deep-merge with DEFAULT_CLIENT_SETTINGS so missing keys inherit defaults. Co-Authored-By: Claude Opus 4.7 --- .../Layers/RepositoryIdentityResolver.test.ts | 12 ++++++------ .../src/project/Layers/RepositoryIdentityResolver.ts | 2 +- apps/web/src/hooks/useSettings.ts | 4 +++- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts index 056b95cbaa5..748f18d5dd5 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -97,24 +97,24 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(RepositoryIdentityResolverLive)), ); - it.effect("prefers upstream over origin when both remotes are configured", () => + it.effect("prefers origin over upstream so forks surface their own name", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const cwd = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "marcode-repository-identity-upstream-test-", + prefix: "marcode-repository-identity-origin-priority-test-", }); yield* git(cwd, ["init"]); - yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/marcode.git"]); + yield* git(cwd, ["remote", "add", "origin", "git@github.com:tyulyukov/marcode.git"]); yield* git(cwd, ["remote", "add", "upstream", "git@github.com:MarCodeHQ/marcode.git"]); const resolver = yield* RepositoryIdentityResolver; const identity = yield* resolver.resolve(cwd); expect(identity).not.toBeNull(); - expect(identity?.locator.remoteName).toBe("upstream"); - expect(identity?.canonicalKey).toBe("github.com/marcodehq/marcode"); - expect(identity?.displayName).toBe("marcodehq/marcode"); + expect(identity?.locator.remoteName).toBe("origin"); + expect(identity?.canonicalKey).toBe("github.com/tyulyukov/marcode"); + expect(identity?.displayName).toBe("tyulyukov/marcode"); }).pipe(Effect.provide(RepositoryIdentityResolverLive)), ); diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts index 72274fdec1c..f7f8ed74953 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts @@ -27,7 +27,7 @@ function parseRemoteFetchUrls(stdout: string): Map { function pickPrimaryRemote( remotes: ReadonlyMap, ): { readonly remoteName: string; readonly remoteUrl: string } | null { - for (const preferredRemoteName of ["upstream", "origin"] as const) { + for (const preferredRemoteName of ["origin", "upstream"] as const) { const remoteUrl = remotes.get(preferredRemoteName); if (remoteUrl) { return { remoteName: preferredRemoteName, remoteUrl }; diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 852dede7e91..c8a8effa284 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -78,7 +78,9 @@ async function hydrateClientSettings(): Promise { try { const persistedSettings = await ensureLocalApi().persistence.getClientSettings(); if (persistedSettings) { - replaceClientSettingsSnapshot(persistedSettings); + replaceClientSettingsSnapshot( + deepMerge(DEFAULT_CLIENT_SETTINGS, persistedSettings) as ClientSettings, + ); } } catch (error) { console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error); From fe26093397d2242a6ec4e1793e8d024989960aea Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Tue, 21 Apr 2026 14:48:12 +0300 Subject: [PATCH 5/5] fix(orchestration): resolve repositoryIdentity in listing snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing projects never got grouped in the sidebar because getListingSnapshot (the Phase 1 bootstrap used to paint the sidebar) omitted repositoryIdentity. New projects happened to work because project.created events carry the resolved identity, but rows read at startup only had whatever the listing snapshot populated — which was nothing. Mirror the resolver call from getSnapshot so every listed project has identity and logical-project grouping can key off it. Co-Authored-By: Claude Opus 4.7 --- .../orchestration/Layers/ProjectionSnapshotQuery.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 19714e3e209..fad3cf7fd80 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1130,10 +1130,22 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { userMessageAtByThread.set(row.threadId, row.latestUserMessageAt); } + const repositoryIdentities = new Map( + yield* Effect.forEach( + projectRows, + (row) => + repositoryIdentityResolver + .resolve(row.workspaceRoot) + .pipe(Effect.map((identity) => [row.projectId, identity] as const)), + { concurrency: repositoryIdentityResolutionConcurrency }, + ), + ); + const projects: ReadonlyArray = projectRows.map((row) => ({ id: row.projectId, title: row.title, workspaceRoot: row.workspaceRoot, + repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, defaultModelSelection: row.defaultModelSelection, scripts: row.scripts, jiraBoard: row.jiraBoard,