Skip to content

Commit fbbfa24

Browse files
authored
Merge pull request #49 from tyulyukov/marcode/port-configurable-project-grouping
feat(sidebar): configurable project grouping (port upstream pingdotgg#2055)
2 parents 84b6b29 + fe26093 commit fbbfa24

28 files changed

Lines changed: 2111 additions & 375 deletions

apps/desktop/src/clientPersistence.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ const clientSettings: ClientSettings = {
5353
confirmThreadDelete: false,
5454
diffWordWrap: true,
5555
favorites: [],
56+
sidebarProjectGroupingMode: "repository_path",
57+
sidebarProjectGroupingOverrides: {
58+
"environment-1:/tmp/project-a": "separate",
59+
},
5660
sidebarProjectSortOrder: "manual",
5761
sidebarThreadSortOrder: "created_at",
5862
timestampFormat: "24-hour",

apps/desktop/src/main.ts

Lines changed: 57 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,34 @@ const DESKTOP_UPDATE_CHANNEL = "latest";
125125
const DESKTOP_UPDATE_ALLOW_PRERELEASE = false;
126126
const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
127127
const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const;
128+
function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] {
129+
const normalizedItems: ContextMenuItem[] = [];
130+
131+
for (const sourceItem of source) {
132+
if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") {
133+
continue;
134+
}
135+
136+
const normalizedItem: ContextMenuItem = {
137+
id: sourceItem.id,
138+
label: sourceItem.label,
139+
destructive: sourceItem.destructive === true,
140+
disabled: sourceItem.disabled === true,
141+
};
142+
143+
if (sourceItem.children) {
144+
const normalizedChildren = normalizeContextMenuItems(sourceItem.children);
145+
if (normalizedChildren.length === 0) {
146+
continue;
147+
}
148+
normalizedItem.children = normalizedChildren;
149+
}
150+
151+
normalizedItems.push(normalizedItem);
152+
}
153+
154+
return normalizedItems;
155+
}
128156

129157
type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"];
130158
type LinuxDesktopNamedApp = Electron.App & {
@@ -1537,14 +1565,7 @@ function registerIpcHandlers(): void {
15371565
ipcMain.handle(
15381566
CONTEXT_MENU_CHANNEL,
15391567
async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => {
1540-
const normalizedItems = items
1541-
.filter((item) => typeof item.id === "string" && typeof item.label === "string")
1542-
.map((item) => ({
1543-
id: item.id,
1544-
label: item.label,
1545-
destructive: item.destructive === true,
1546-
disabled: item.disabled === true,
1547-
}));
1568+
const normalizedItems = normalizeContextMenuItems(items);
15481569
if (normalizedItems.length === 0) {
15491570
return null;
15501571
}
@@ -1565,28 +1586,37 @@ function registerIpcHandlers(): void {
15651586
if (!window) return null;
15661587

15671588
return new Promise<string | null>((resolve) => {
1568-
const template: MenuItemConstructorOptions[] = [];
1569-
let hasInsertedDestructiveSeparator = false;
1570-
for (const item of normalizedItems) {
1571-
if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
1572-
template.push({ type: "separator" });
1573-
hasInsertedDestructiveSeparator = true;
1574-
}
1575-
const itemOption: MenuItemConstructorOptions = {
1576-
label: item.label,
1577-
enabled: !item.disabled,
1578-
click: () => resolve(item.id),
1579-
};
1580-
if (item.destructive) {
1581-
const destructiveIcon = getDestructiveMenuIcon();
1582-
if (destructiveIcon) {
1583-
itemOption.icon = destructiveIcon;
1589+
const buildTemplate = (
1590+
entries: readonly ContextMenuItem[],
1591+
): MenuItemConstructorOptions[] => {
1592+
const template: MenuItemConstructorOptions[] = [];
1593+
let hasInsertedDestructiveSeparator = false;
1594+
for (const item of entries) {
1595+
if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
1596+
template.push({ type: "separator" });
1597+
hasInsertedDestructiveSeparator = true;
15841598
}
1599+
const itemOption: MenuItemConstructorOptions = {
1600+
label: item.label,
1601+
enabled: !item.disabled,
1602+
};
1603+
if (item.children && item.children.length > 0) {
1604+
itemOption.submenu = buildTemplate(item.children);
1605+
} else {
1606+
itemOption.click = () => resolve(item.id);
1607+
}
1608+
if (item.destructive && (!item.children || item.children.length === 0)) {
1609+
const destructiveIcon = getDestructiveMenuIcon();
1610+
if (destructiveIcon) {
1611+
itemOption.icon = destructiveIcon;
1612+
}
1613+
}
1614+
template.push(itemOption);
15851615
}
1586-
template.push(itemOption);
1587-
}
1616+
return template;
1617+
};
15881618

1589-
const menu = Menu.buildFromTemplate(template);
1619+
const menu = Menu.buildFromTemplate(buildTemplate(normalizedItems));
15901620
menu.popup({
15911621
window,
15921622
...popupPosition,

apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1130,10 +1130,22 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
11301130
userMessageAtByThread.set(row.threadId, row.latestUserMessageAt);
11311131
}
11321132

1133+
const repositoryIdentities = new Map(
1134+
yield* Effect.forEach(
1135+
projectRows,
1136+
(row) =>
1137+
repositoryIdentityResolver
1138+
.resolve(row.workspaceRoot)
1139+
.pipe(Effect.map((identity) => [row.projectId, identity] as const)),
1140+
{ concurrency: repositoryIdentityResolutionConcurrency },
1141+
),
1142+
);
1143+
11331144
const projects: ReadonlyArray<OrchestrationProject> = projectRows.map((row) => ({
11341145
id: row.projectId,
11351146
title: row.title,
11361147
workspaceRoot: row.workspaceRoot,
1148+
repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null,
11371149
defaultModelSelection: row.defaultModelSelection,
11381150
scripts: row.scripts,
11391151
jiraBoard: row.jiraBoard,

apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { realpathSync } from "node:fs";
2+
13
import * as NodeServices from "@effect/platform-node/NodeServices";
24
import { expect, it } from "@effect/vitest";
35
import { Duration, Effect, FileSystem, Layer } from "effect";
@@ -10,6 +12,10 @@ import {
1012
RepositoryIdentityResolverLive,
1113
} from "./RepositoryIdentityResolver.ts";
1214

15+
const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/");
16+
const normalizeResolvedPath = (value: string) =>
17+
normalizePathSeparators(realpathSync.native(value));
18+
1319
const git = (cwd: string, args: ReadonlyArray<string>) =>
1420
Effect.promise(() => runProcess("git", ["-C", cwd, ...args]));
1521

@@ -41,13 +47,35 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
4147

4248
expect(identity).not.toBeNull();
4349
expect(identity?.canonicalKey).toBe("github.com/marcodehq/marcode");
50+
expect(normalizeResolvedPath(identity?.rootPath ?? "")).toBe(normalizeResolvedPath(cwd));
4451
expect(identity?.displayName).toBe("marcodehq/marcode");
4552
expect(identity?.provider).toBe("github");
4653
expect(identity?.owner).toBe("marcodehq");
4754
expect(identity?.name).toBe("marcode");
4855
}).pipe(Effect.provide(RepositoryIdentityResolverLive)),
4956
);
5057

58+
it.effect("returns the git top-level root path when resolving from a nested workspace", () =>
59+
Effect.gen(function* () {
60+
const fileSystem = yield* FileSystem.FileSystem;
61+
const repoRoot = yield* fileSystem.makeTempDirectoryScoped({
62+
prefix: "marcode-repository-identity-nested-root-test-",
63+
});
64+
const nestedWorkspace = `${repoRoot}/packages/web`;
65+
66+
yield* fileSystem.makeDirectory(nestedWorkspace, { recursive: true });
67+
yield* git(repoRoot, ["init"]);
68+
yield* git(repoRoot, ["remote", "add", "origin", "git@github.com:MarCodeHQ/marcode.git"]);
69+
70+
const resolver = yield* RepositoryIdentityResolver;
71+
const identity = yield* resolver.resolve(nestedWorkspace);
72+
73+
expect(identity).not.toBeNull();
74+
expect(identity?.canonicalKey).toBe("github.com/marcodehq/marcode");
75+
expect(normalizeResolvedPath(identity?.rootPath ?? "")).toBe(normalizeResolvedPath(repoRoot));
76+
}).pipe(Effect.provide(RepositoryIdentityResolverLive)),
77+
);
78+
5179
it.effect("returns null for non-git folders and repos without remotes", () =>
5280
Effect.gen(function* () {
5381
const fileSystem = yield* FileSystem.FileSystem;
@@ -69,24 +97,24 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
6997
}).pipe(Effect.provide(RepositoryIdentityResolverLive)),
7098
);
7199

72-
it.effect("prefers upstream over origin when both remotes are configured", () =>
100+
it.effect("prefers origin over upstream so forks surface their own name", () =>
73101
Effect.gen(function* () {
74102
const fileSystem = yield* FileSystem.FileSystem;
75103
const cwd = yield* fileSystem.makeTempDirectoryScoped({
76-
prefix: "marcode-repository-identity-upstream-test-",
104+
prefix: "marcode-repository-identity-origin-priority-test-",
77105
});
78106

79107
yield* git(cwd, ["init"]);
80-
yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/marcode.git"]);
108+
yield* git(cwd, ["remote", "add", "origin", "git@github.com:tyulyukov/marcode.git"]);
81109
yield* git(cwd, ["remote", "add", "upstream", "git@github.com:MarCodeHQ/marcode.git"]);
82110

83111
const resolver = yield* RepositoryIdentityResolver;
84112
const identity = yield* resolver.resolve(cwd);
85113

86114
expect(identity).not.toBeNull();
87-
expect(identity?.locator.remoteName).toBe("upstream");
88-
expect(identity?.canonicalKey).toBe("github.com/marcodehq/marcode");
89-
expect(identity?.displayName).toBe("marcodehq/marcode");
115+
expect(identity?.locator.remoteName).toBe("origin");
116+
expect(identity?.canonicalKey).toBe("github.com/tyulyukov/marcode");
117+
expect(identity?.displayName).toBe("tyulyukov/marcode");
90118
}).pipe(Effect.provide(RepositoryIdentityResolverLive)),
91119
);
92120

apps/server/src/project/Layers/RepositoryIdentityResolver.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ function parseRemoteFetchUrls(stdout: string): Map<string, string> {
2727
function pickPrimaryRemote(
2828
remotes: ReadonlyMap<string, string>,
2929
): { readonly remoteName: string; readonly remoteUrl: string } | null {
30-
for (const preferredRemoteName of ["upstream", "origin"] as const) {
30+
for (const preferredRemoteName of ["origin", "upstream"] as const) {
3131
const remoteUrl = remotes.get(preferredRemoteName);
3232
if (remoteUrl) {
3333
return { remoteName: preferredRemoteName, remoteUrl };
@@ -42,6 +42,7 @@ function pickPrimaryRemote(
4242
function buildRepositoryIdentity(input: {
4343
readonly remoteName: string;
4444
readonly remoteUrl: string;
45+
readonly rootPath: string;
4546
}): RepositoryIdentity {
4647
const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl);
4748
const hostingProvider = detectGitHostingProviderFromRemoteUrl(input.remoteUrl);
@@ -57,6 +58,7 @@ function buildRepositoryIdentity(input: {
5758
remoteName: input.remoteName,
5859
remoteUrl: input.remoteUrl,
5960
},
61+
rootPath: input.rootPath,
6062
...(repositoryPath ? { displayName: repositoryPath } : {}),
6163
...(hostingProvider ? { provider: hostingProvider.kind } : {}),
6264
...(owner ? { owner } : {}),
@@ -108,7 +110,7 @@ async function resolveRepositoryIdentityFromCacheKey(
108110
}
109111

110112
const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.stdout));
111-
return remote ? buildRepositoryIdentity(remote) : null;
113+
return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null;
112114
} catch {
113115
return null;
114116
}

apps/server/src/provider/Layers/ClaudeAdapter.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@ describe("ClaudeAdapterLive", () => {
375375
);
376376
});
377377

378-
it.effect("forwards xhigh effort for Claude Opus 4.7", () => {
378+
it.effect("maps xhigh effort for Claude Opus 4.7 to the SDK-supported max value", () => {
379379
const harness = makeHarness();
380380
return Effect.gen(function* () {
381381
const adapter = yield* ClaudeAdapter;
@@ -393,7 +393,7 @@ describe("ClaudeAdapterLive", () => {
393393
});
394394

395395
const createInput = harness.getLastCreateQueryInput();
396-
assert.equal(createInput?.options.effort, "xhigh");
396+
assert.equal(createInput?.options.effort, "max");
397397
}).pipe(
398398
Effect.provideService(Random.Random, makeDeterministicRandomService()),
399399
Effect.provide(harness.layer),

apps/server/src/provider/Layers/ClaudeAdapter.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ type ClaudeToolResultStreamKind = Extract<
8686
RuntimeContentStreamKind,
8787
"command_output" | "file_change_output"
8888
>;
89+
type ClaudeSdkEffort = NonNullable<ClaudeQueryOptions["effort"]>;
8990

9091
type PromptQueueItem =
9192
| {
@@ -237,11 +238,17 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause<Error>): ReadonlyArray
237238

238239
function getEffectiveClaudeAgentEffort(
239240
effort: ClaudeAgentEffort | null | undefined,
240-
): Exclude<ClaudeAgentEffort, "ultrathink"> | null {
241+
): ClaudeSdkEffort | null {
241242
if (!effort) {
242243
return null;
243244
}
244-
return effort === "ultrathink" ? null : effort;
245+
if (effort === "ultrathink") {
246+
return null;
247+
}
248+
if (effort === "xhigh") {
249+
return "max";
250+
}
251+
return effort;
245252
}
246253

247254
function isClaudeInterruptedMessage(message: string): boolean {

apps/web/src/components/ChatView.browser.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { __resetLocalApiForTests } from "../localApi";
4242
import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
4343
import { getServerConfig } from "../rpc/serverState";
4444
import { getRouter } from "../router";
45+
import { deriveLogicalProjectKeyFromSettings } from "../logicalProject";
4546
import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store";
4647
import { useTerminalStateStore } from "../terminalStateStore";
4748
import { useUiStateStore } from "../uiStateStore";
@@ -66,7 +67,18 @@ const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID);
6667
const THREAD_KEY = scopedThreadKey(THREAD_REF);
6768
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}$/;
6869
const PROJECT_DRAFT_KEY = `${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`;
69-
const PROJECT_KEY = scopedProjectKey(scopeProjectRef(LOCAL_ENVIRONMENT_ID, PROJECT_ID));
70+
const PROJECT_LOGICAL_KEY = deriveLogicalProjectKeyFromSettings(
71+
{
72+
environmentId: LOCAL_ENVIRONMENT_ID,
73+
id: PROJECT_ID,
74+
cwd: "/repo/project",
75+
repositoryIdentity: null,
76+
},
77+
{
78+
sidebarProjectGroupingMode: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingMode,
79+
sidebarProjectGroupingOverrides: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingOverrides,
80+
},
81+
);
7082
const NOW_ISO = "2026-03-04T12:00:00.000Z";
7183
const BASE_TIME_MS = Date.parse(NOW_ISO);
7284
const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'></svg>";
@@ -1740,12 +1752,12 @@ describe("ChatView timeline estimator parity (full app)", () => {
17401752
},
17411753
);
17421754

1743-
it("re-expands the bootstrap project using its scoped key", async () => {
1755+
it("re-expands the bootstrap project using its logical key", async () => {
17441756
useUiStateStore.setState({
17451757
projectExpandedById: {
1746-
[PROJECT_KEY]: false,
1758+
[PROJECT_LOGICAL_KEY]: false,
17471759
},
1748-
projectOrder: [PROJECT_KEY],
1760+
projectOrder: [PROJECT_LOGICAL_KEY],
17491761
threadLastVisitedAtById: {},
17501762
});
17511763

@@ -1760,7 +1772,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
17601772
try {
17611773
await vi.waitFor(
17621774
() => {
1763-
expect(useUiStateStore.getState().projectExpandedById[PROJECT_KEY]).toBe(true);
1775+
expect(useUiStateStore.getState().projectExpandedById[PROJECT_LOGICAL_KEY]).toBe(true);
17641776
},
17651777
{ timeout: 8_000, interval: 16 },
17661778
);

0 commit comments

Comments
 (0)