Skip to content

Commit 6673ca8

Browse files
tlh38claude
authored andcommitted
feat(sidebar): configurable project grouping (port upstream pingdotgg#2055)
Port upstream t3code pingdotgg#2055 (188a40c) 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 pingdotgg#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 <noreply@anthropic.com>
1 parent 3dae9e6 commit 6673ca8

21 files changed

Lines changed: 1647 additions & 367 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/project/Layers/RepositoryIdentityResolver.test.ts

Lines changed: 28 additions & 0 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;

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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
);

apps/web/src/components/ChatView.tsx

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ import {
164164
import { useSettings } from "../hooks/useSettings";
165165
import { resolveAppModelSelection } from "../modelSelection";
166166
import { isTerminalFocused } from "../lib/terminalFocus";
167-
import { deriveLogicalProjectKey } from "../logicalProject";
167+
import { deriveLogicalProjectKeyFromSettings } from "../logicalProject";
168168
import {
169169
useSavedEnvironmentRegistryStore,
170170
useSavedEnvironmentRuntimeStore,
@@ -1139,6 +1139,63 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }:
11391139
);
11401140
const activeProject = useProjectById(activeThread?.projectId);
11411141

1142+
// Compute the list of environments this logical project spans, used to
1143+
// drive the environment picker in BranchToolbar.
1144+
const allProjects = useStore(useShallow(selectProjectsAcrossEnvironments));
1145+
const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId);
1146+
const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId);
1147+
const projectGroupingSettings = useSettings((settings) => ({
1148+
sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
1149+
sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
1150+
}));
1151+
const logicalProjectEnvironments = useMemo(() => {
1152+
if (!activeProject) return [];
1153+
const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings);
1154+
const memberProjects = allProjects.filter(
1155+
(p) => deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) === logicalKey,
1156+
);
1157+
const seen = new Set<string>();
1158+
const envs: Array<{
1159+
environmentId: EnvironmentId;
1160+
projectId: ProjectId;
1161+
label: string;
1162+
isPrimary: boolean;
1163+
}> = [];
1164+
for (const p of memberProjects) {
1165+
if (seen.has(p.environmentId)) continue;
1166+
seen.add(p.environmentId);
1167+
const isPrimary = p.environmentId === primaryEnvironmentId;
1168+
const savedRecord = savedEnvironmentRegistry[p.environmentId];
1169+
const runtimeState = savedEnvironmentRuntimeById[p.environmentId];
1170+
const label = resolveEnvironmentOptionLabel({
1171+
isPrimary,
1172+
environmentId: p.environmentId,
1173+
runtimeLabel: runtimeState?.descriptor?.label ?? null,
1174+
savedLabel: savedRecord?.label ?? null,
1175+
});
1176+
envs.push({
1177+
environmentId: p.environmentId,
1178+
projectId: p.id,
1179+
label,
1180+
isPrimary,
1181+
});
1182+
}
1183+
// Sort: primary first, then alphabetical
1184+
envs.sort((a, b) => {
1185+
if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1;
1186+
return a.label.localeCompare(b.label);
1187+
});
1188+
return envs;
1189+
}, [
1190+
activeProject,
1191+
allProjects,
1192+
projectGroupingSettings,
1193+
primaryEnvironmentId,
1194+
savedEnvironmentRegistry,
1195+
savedEnvironmentRuntimeById,
1196+
]);
1197+
const hasMultipleEnvironments = logicalProjectEnvironments.length > 1;
1198+
11421199
const openPullRequestDialog = useCallback(
11431200
(reference?: string) => {
11441201
if (!canCheckoutPullRequestIntoThread) {

0 commit comments

Comments
 (0)