Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/desktop/src/clientPersistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
84 changes: 57 additions & 27 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand Down Expand Up @@ -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;
}
Expand All @@ -1565,28 +1586,37 @@ function registerIpcHandlers(): void {
if (!window) return null;

return new Promise<string | null>((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,
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OrchestrationProject> = 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,
Expand Down
40 changes: 34 additions & 6 deletions apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<string>) =>
Effect.promise(() => runProcess("git", ["-C", cwd, ...args]));

Expand Down Expand Up @@ -41,13 +47,35 @@ 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");
expect(identity?.name).toBe("marcode");
}).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;
Expand All @@ -69,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)),
);

Expand Down
6 changes: 4 additions & 2 deletions apps/server/src/project/Layers/RepositoryIdentityResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ function parseRemoteFetchUrls(stdout: string): Map<string, string> {
function pickPrimaryRemote(
remotes: ReadonlyMap<string, string>,
): { 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 };
Expand All @@ -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);
Expand All @@ -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 } : {}),
Expand Down Expand Up @@ -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;
}
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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),
Expand Down
11 changes: 9 additions & 2 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
type SDKResultMessage,
type SettingSource,
type SDKUserMessage,
ModelUsage,

Check warning on line 20 in apps/server/src/provider/Layers/ClaudeAdapter.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint(no-unused-vars)

Identifier 'ModelUsage' is imported but never used.
} from "@anthropic-ai/claude-agent-sdk";
import {
ApprovalRequestId,
Expand Down Expand Up @@ -86,6 +86,7 @@
RuntimeContentStreamKind,
"command_output" | "file_change_output"
>;
type ClaudeSdkEffort = NonNullable<ClaudeQueryOptions["effort"]>;

type PromptQueueItem =
| {
Expand Down Expand Up @@ -237,11 +238,17 @@

function getEffectiveClaudeAgentEffort(
effort: ClaudeAgentEffort | null | undefined,
): Exclude<ClaudeAgentEffort, "ultrathink"> | 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 {
Expand Down
22 changes: 17 additions & 5 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@
DEFAULT_SERVER_SETTINGS,
} from "@marcode/contracts";
import {
scopedProjectKey,

Check warning on line 21 in apps/web/src/components/ChatView.browser.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint(no-unused-vars)

Identifier 'scopedProjectKey' is imported but never used.
scopedThreadKey,
scopeProjectRef,

Check warning on line 23 in apps/web/src/components/ChatView.browser.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint(no-unused-vars)

Identifier 'scopeProjectRef' is imported but never used.
scopeThreadRef,
} from "@marcode/client-runtime";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
Expand All @@ -42,6 +42,7 @@
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";
Expand All @@ -66,7 +67,18 @@
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 = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'></svg>";
Expand Down Expand Up @@ -1740,12 +1752,12 @@
},
);

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: {},
});

Expand All @@ -1760,7 +1772,7 @@
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 },
);
Expand Down
Loading
Loading