diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md
index 13cbe26cb..cd2c63fb9 100644
--- a/apps/ade-cli/README.md
+++ b/apps/ade-cli/README.md
@@ -162,12 +162,14 @@ ade init /path/to/project # adds an explicit path
…or call the same JSON-RPC methods directly:
```text
-projects.list { } -> ProjectRecord[]
+projects.list { } -> ProjectRecord[] # each record also carries a host-resolved icon
projects.add { rootPath } -> ProjectRecord
projects.remove { projectId } -> { removed }
projects.touch { projectId } -> ProjectRecord
```
+`projects.list` stamps each returned record with an `icon: { dataUrl, sourcePath, mimeType }` resolved on the host (`resolveRemoteProjectIcon` in `src/services/projects/projectIconResolver.ts`) — a best-effort, electron-free icon lookup (`.ade/ade.yaml` override, conventional icon/logo files, `index.html` ``, capped at 2 MB) so a desktop connected over the remote runtime can show the real project logo in its tab instead of a blank folder. A per-project resolution failure degrades to a null icon and never breaks the list.
+
Adding a project creates `/.ade/` if needed but does not run any heavy onboarding. The first project-scoped JSON-RPC call lazily builds an `AdeRuntime` for that root via `ProjectScopeRegistry`.
## RPC surface
diff --git a/apps/ade-cli/src/multiProjectRpcServer.test.ts b/apps/ade-cli/src/multiProjectRpcServer.test.ts
index 44761abe9..f14e9602a 100644
--- a/apps/ade-cli/src/multiProjectRpcServer.test.ts
+++ b/apps/ade-cli/src/multiProjectRpcServer.test.ts
@@ -80,6 +80,8 @@ describe("multi-project RPC server", () => {
method: "projects.list",
params: {},
});
+ // Both projects.add and projects.list stamp the host-resolved icon; the
+ // temp project root has no icon file, so each yields the same all-null icon.
expect(listed).toEqual([added]);
const projectId = (added as { projectId: string }).projectId;
diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts
index 664c45d5e..dcc07114e 100644
--- a/apps/ade-cli/src/multiProjectRpcServer.ts
+++ b/apps/ade-cli/src/multiProjectRpcServer.ts
@@ -22,6 +22,7 @@ import {
type JsonRpcRequest,
} from "./jsonrpc";
import { resolveMachineAdeLayout } from "./services/projects/machineLayout";
+import { resolveRemoteProjectIcon } from "./services/projects/projectIconResolver";
import {
ProjectRegistry,
type ProjectId,
@@ -186,6 +187,60 @@ function createMachineProjectScaffoldService() {
});
}
+type ResolvedProjectIcon = ReturnType;
+
+// Frozen so the shared fallback can't be mutated by a consumer and corrupt
+// every subsequent budget-exceeded project record.
+const EMPTY_PROJECT_ICON: ResolvedProjectIcon = Object.freeze({
+ dataUrl: null,
+ sourcePath: null,
+ mimeType: null,
+});
+
+// `projects.list` is on the connect-critical path (bootstrapRemoteRuntime awaits
+// it before a target is "connected"), so bound the icon work it does: resolve at
+// most this many icons and this many inlined bytes per call. A large or
+// slow-filesystem registry then can't stall a connect just to render tab
+// artwork — projects past the budget fall back to a null icon.
+const LIST_ICON_COUNT_BUDGET = 64;
+const LIST_ICON_BYTE_BUDGET = 12 * 1024 * 1024;
+
+// Stamp a single project record with its host-resolved icon so a remote desktop
+// can render the real project logo. Used for the records returned by
+// add/create/clone (which feed the desktop's cached connection.projects), so a
+// freshly registered project opens with its icon instead of a blank folder.
+// Best-effort: a failed resolve degrades to a null icon and never throws.
+function decorateProjectWithIcon(
+ record: T,
+): T & { icon: ResolvedProjectIcon } {
+ return { ...record, icon: resolveRemoteProjectIcon(record.rootPath) };
+}
+
+// Decorate a full project list with icons under the connect-path budget above.
+// Icons are resolved for the most-recently-opened projects first (those most
+// likely to be open as tabs) while the returned array stays in registry order.
+function decorateProjectListWithIcons(
+ records: readonly T[],
+): Array {
+ const icons = new Map();
+ let count = 0;
+ let bytes = 0;
+ const byRecency = records
+ .map((record, index) => ({ record, index }))
+ .sort((a, b) => b.record.lastOpenedAt - a.record.lastOpenedAt);
+ for (const { record, index } of byRecency) {
+ if (count >= LIST_ICON_COUNT_BUDGET || bytes >= LIST_ICON_BYTE_BUDGET) break;
+ const icon = resolveRemoteProjectIcon(record.rootPath);
+ count += 1;
+ if (icon.dataUrl) bytes += icon.dataUrl.length;
+ icons.set(index, icon);
+ }
+ return records.map((record, index) => ({
+ ...record,
+ icon: icons.get(index) ?? EMPTY_PROJECT_ICON,
+ }));
+}
+
function defaultParentDir(projectRegistry: ProjectRegistry): string {
const first = projectRegistry.list()[0]?.rootPath;
if (first) return path.dirname(first);
@@ -474,7 +529,7 @@ export function createMultiProjectRpcRequestHandler(
}
if (method === "projects.list") {
- return projectRegistry.list();
+ return decorateProjectListWithIcons(projectRegistry.list());
}
if (method === "projects.add") {
@@ -486,7 +541,7 @@ export function createMultiProjectRpcRequestHandler(
"projects.add requires rootPath.",
);
}
- return projectRegistry.add(rootPath);
+ return decorateProjectWithIcon(projectRegistry.add(rootPath));
}
if (method === "projects.remove") {
@@ -548,7 +603,7 @@ export function createMultiProjectRpcRequestHandler(
await createMachineProjectScaffoldService().createLocalProject(
readCreateProjectInput(params),
);
- return projectRegistry.add(result.rootPath);
+ return decorateProjectWithIcon(projectRegistry.add(result.rootPath));
}
if (method === "projects.clone") {
@@ -556,7 +611,7 @@ export function createMultiProjectRpcRequestHandler(
await createMachineProjectScaffoldService().cloneRepository(
readCloneProjectInput(params),
);
- return projectRegistry.add(result.rootPath);
+ return decorateProjectWithIcon(projectRegistry.add(result.rootPath));
}
if (method === "projects.listMyGitHubRepos") {
diff --git a/apps/ade-cli/src/services/projects/projectIconResolver.test.ts b/apps/ade-cli/src/services/projects/projectIconResolver.test.ts
new file mode 100644
index 000000000..30df38b73
--- /dev/null
+++ b/apps/ade-cli/src/services/projects/projectIconResolver.test.ts
@@ -0,0 +1,166 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { resolveRemoteProjectIcon } from "./projectIconResolver";
+
+const tempRoots = new Set();
+
+function makeTempRoot(prefix = "ade-project-icon-"): string {
+ // realpath collapses the macOS /var -> /private/var tmpdir symlink so the
+ // resolver's within-root containment checks compare like-for-like paths.
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
+ tempRoots.add(root);
+ return root;
+}
+
+function writeFileEnsuringDir(filePath: string, data: Buffer | string): void {
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ fs.writeFileSync(filePath, data);
+}
+
+function decodeDataUrl(dataUrl: string): { mime: string; bytes: Buffer } {
+ const match = /^data:([^;]+);base64,(.*)$/s.exec(dataUrl);
+ if (!match) throw new Error(`not a base64 data url: ${dataUrl.slice(0, 32)}…`);
+ return { mime: match[1], bytes: Buffer.from(match[2], "base64") };
+}
+
+afterEach(() => {
+ for (const root of tempRoots) {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+ tempRoots.clear();
+});
+
+describe("resolveRemoteProjectIcon", () => {
+ it("returns an all-null icon when the project has no recognizable icon", () => {
+ const root = makeTempRoot();
+ writeFileEnsuringDir(path.join(root, "README.md"), "# no icon here");
+
+ const icon = resolveRemoteProjectIcon(root);
+
+ expect(icon.dataUrl).toBeNull();
+ expect(icon.sourcePath).toBeNull();
+ expect(icon.mimeType).toBeNull();
+ });
+
+ it("inlines a conventional PNG icon as a base64 data URL with the right mime", () => {
+ const root = makeTempRoot();
+ const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
+ writeFileEnsuringDir(path.join(root, "logo.png"), pngBytes);
+
+ const icon = resolveRemoteProjectIcon(root);
+
+ expect(icon.mimeType).toBe("image/png");
+ expect(icon.sourcePath).toBe(path.join(root, "logo.png"));
+ expect(icon.dataUrl).not.toBeNull();
+ const decoded = decodeDataUrl(icon.dataUrl as string);
+ expect(decoded.mime).toBe("image/png");
+ // The inlined bytes must round-trip exactly, not a re-encoded approximation.
+ expect(decoded.bytes.equals(pngBytes)).toBe(true);
+ });
+
+ it("maps SVG icons to image/svg+xml", () => {
+ const root = makeTempRoot();
+ writeFileEnsuringDir(path.join(root, "public", "favicon.svg"), "");
+
+ const icon = resolveRemoteProjectIcon(root);
+
+ expect(icon.mimeType).toBe("image/svg+xml");
+ expect(icon.sourcePath).toBe(path.join(root, "public", "favicon.svg"));
+ expect(decodeDataUrl(icon.dataUrl as string).bytes.toString("utf8")).toBe("");
+ });
+
+ it("honors an explicit project.iconPath override in .ade/ade.yaml", () => {
+ const root = makeTempRoot();
+ // A conventional candidate exists, but the override must win.
+ writeFileEnsuringDir(path.join(root, "logo.png"), Buffer.from([1]));
+ writeFileEnsuringDir(path.join(root, "brand", "custom-icon.png"), Buffer.from([9, 9, 9]));
+ writeFileEnsuringDir(
+ path.join(root, ".ade", "ade.yaml"),
+ "version: 1\nproject:\n iconPath: brand/custom-icon.png\n",
+ );
+
+ const icon = resolveRemoteProjectIcon(root);
+
+ expect(icon.sourcePath).toBe(path.join(root, "brand", "custom-icon.png"));
+ expect(decodeDataUrl(icon.dataUrl as string).bytes.equals(Buffer.from([9, 9, 9]))).toBe(true);
+ });
+
+ it("treats an explicit null iconPath as 'no icon' even when a candidate exists", () => {
+ const root = makeTempRoot();
+ writeFileEnsuringDir(path.join(root, "logo.png"), Buffer.from([1, 2, 3]));
+ writeFileEnsuringDir(
+ path.join(root, ".ade", "ade.yaml"),
+ "version: 1\nproject:\n iconPath: null\n",
+ );
+
+ const icon = resolveRemoteProjectIcon(root);
+
+ expect(icon.dataUrl).toBeNull();
+ expect(icon.sourcePath).toBeNull();
+ expect(icon.mimeType).toBeNull();
+ });
+
+ it("resolves an icon referenced by index.html ", () => {
+ const root = makeTempRoot();
+ writeFileEnsuringDir(
+ path.join(root, "index.html"),
+ '',
+ );
+ writeFileEnsuringDir(path.join(root, "brand.png"), Buffer.from([7, 7]));
+
+ const icon = resolveRemoteProjectIcon(root);
+
+ expect(icon.sourcePath).toBe(path.join(root, "brand.png"));
+ expect(icon.mimeType).toBe("image/png");
+ });
+
+ it("rejects an iconPath override that escapes the project root via ..", () => {
+ const root = makeTempRoot();
+ const outside = path.join(path.dirname(root), `outside-${path.basename(root)}.png`);
+ fs.writeFileSync(outside, Buffer.from([0xde, 0xad]));
+ tempRoots.add(outside); // ensure cleanup
+ writeFileEnsuringDir(
+ path.join(root, ".ade", "ade.yaml"),
+ `version: 1\nproject:\n iconPath: ../${path.basename(outside)}\n`,
+ );
+
+ const icon = resolveRemoteProjectIcon(root);
+
+ expect(icon.dataUrl).toBeNull();
+ expect(icon.sourcePath).toBeNull();
+ });
+
+ it("does not follow a symlinked directory that points outside the project root", () => {
+ const root = makeTempRoot();
+ const outsideDir = makeTempRoot("ade-project-icon-outside-");
+ fs.writeFileSync(path.join(outsideDir, "favicon.png"), Buffer.from([0xca, 0xfe]));
+ // `public` is a symlink escaping the root; the public/favicon.png candidate
+ // must not be read.
+ fs.symlinkSync(outsideDir, path.join(root, "public"));
+
+ const icon = resolveRemoteProjectIcon(root);
+
+ expect(icon.dataUrl).toBeNull();
+ expect(icon.sourcePath).toBeNull();
+ });
+
+ it("skips an oversized icon but keeps its metadata", () => {
+ const root = makeTempRoot();
+ const big = Buffer.alloc(2 * 1024 * 1024 + 1, 0);
+ writeFileEnsuringDir(path.join(root, "logo.png"), big);
+
+ const icon = resolveRemoteProjectIcon(root);
+
+ expect(icon.dataUrl).toBeNull();
+ expect(icon.mimeType).toBe("image/png");
+ expect(icon.sourcePath).toBe(path.join(root, "logo.png"));
+ });
+
+ it("returns an all-null icon for an empty or whitespace root path", () => {
+ const icon = resolveRemoteProjectIcon(" ");
+ expect(icon).toEqual({ dataUrl: null, sourcePath: null, mimeType: null });
+ });
+});
diff --git a/apps/ade-cli/src/services/projects/projectIconResolver.ts b/apps/ade-cli/src/services/projects/projectIconResolver.ts
new file mode 100644
index 000000000..e934b6b3b
--- /dev/null
+++ b/apps/ade-cli/src/services/projects/projectIconResolver.ts
@@ -0,0 +1,97 @@
+import fs from "node:fs";
+import path from "node:path";
+import {
+ resolveProjectIcon,
+ resolveProjectIconPath,
+} from "../../../../desktop/src/main/services/projects/projectIconResolver";
+
+/**
+ * Resolves a project's icon on the machine that hosts the project files, so a
+ * desktop connected to this brain over the remote runtime can show the real
+ * project logo in its project tab instead of a blank folder.
+ *
+ * This reuses the desktop's `resolveProjectIcon` (already in the brain bundle —
+ * `cli.ts` imports the same module chain for the mobile sync icon path), so the
+ * icon a remote desktop sees is exactly the one the host machine would show,
+ * and we inherit its mtime-keyed result cache. Two things are layered on top:
+ * 1. A wire-size cap — these icons travel inline in the `projects.list`
+ * payload, so anything too large is dropped.
+ * 2. A size preflight via `resolveProjectIconPath` BEFORE `resolveProjectIcon`
+ * reads/encodes/caches the data URL, so an oversized icon is never inlined
+ * or retained in the resolver's cache just to be discarded.
+ */
+export type RemoteProjectIcon = {
+ dataUrl: string | null;
+ sourcePath: string | null;
+ mimeType: string | null;
+};
+
+// Cap on the raw icon file. base64 inflates ~33%, so a 2 MB file yields a
+// ~2.7 MB data URL — an acceptable ceiling for inline transport, and well below
+// the desktop resolver's 10 MB on-disk limit.
+const REMOTE_ICON_MAX_FILE_BYTES = 2 * 1024 * 1024;
+
+// Frozen so the shared singleton can't be mutated by a caller and silently
+// corrupt every subsequent resolve.
+const EMPTY_ICON: RemoteProjectIcon = Object.freeze({
+ dataUrl: null,
+ sourcePath: null,
+ mimeType: null,
+});
+
+function mimeTypeForIconPath(filePath: string): string | null {
+ switch (path.extname(filePath).toLowerCase()) {
+ case ".svg":
+ return "image/svg+xml";
+ case ".ico":
+ return "image/x-icon";
+ case ".png":
+ return "image/png";
+ case ".jpg":
+ case ".jpeg":
+ return "image/jpeg";
+ case ".webp":
+ return "image/webp";
+ default:
+ return null;
+ }
+}
+
+export function resolveRemoteProjectIcon(projectRoot: string): RemoteProjectIcon {
+ if (typeof projectRoot !== "string" || projectRoot.trim().length === 0) {
+ return EMPTY_ICON;
+ }
+ const root = projectRoot.trim();
+
+ let iconPath: string | null;
+ try {
+ iconPath = resolveProjectIconPath(root);
+ } catch {
+ return EMPTY_ICON;
+ }
+ if (!iconPath) return EMPTY_ICON;
+
+ // Preflight the file size BEFORE resolveProjectIcon reads, base64-encodes, and
+ // caches the full data URL. Without this, an oversized icon (under the
+ // desktop resolver's 10 MB cap) would be inlined and retained in the shared
+ // result cache even though we drop it from the wire.
+ let size: number;
+ try {
+ size = fs.statSync(iconPath).size;
+ } catch {
+ return EMPTY_ICON;
+ }
+ if (size > REMOTE_ICON_MAX_FILE_BYTES) {
+ return {
+ dataUrl: null,
+ sourcePath: iconPath,
+ mimeType: mimeTypeForIconPath(iconPath),
+ };
+ }
+
+ try {
+ return resolveProjectIcon(root);
+ } catch {
+ return EMPTY_ICON;
+ }
+}
diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts
index 659bdaae8..c0b2fe31d 100644
--- a/apps/desktop/src/main/main.ts
+++ b/apps/desktop/src/main/main.ts
@@ -1101,6 +1101,9 @@ app.whenReady().then(async () => {
projectId,
rootPath,
displayName: readString(record, "displayName") ?? path.basename(rootPath),
+ // Restore the cached project logo so the tab shows it immediately on a
+ // cold start, before the remote reconnects and refreshes the icon.
+ iconDataUrl: readString(record, "iconDataUrl") ?? null,
};
};
const savedRemoteProjectBinding = parseSavedRemoteProjectBinding(
diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts
index df89b2f94..4dacc12a9 100644
--- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts
+++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts
@@ -868,6 +868,7 @@ describe("registerRuntimeBridge", () => {
projectId: "project-1",
rootPath: "/srv/ade",
displayName: "ADE",
+ iconDataUrl: null,
});
expect(remoteConnectMock).toHaveBeenCalledWith(target, {
@@ -883,6 +884,7 @@ describe("registerRuntimeBridge", () => {
projectId: "project-1",
rootPath: "/srv/ade",
displayName: "ADE",
+ iconDataUrl: null,
});
});
diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.ts
index 7a00e278a..ebb58ea64 100644
--- a/apps/desktop/src/main/services/ipc/runtimeBridge.ts
+++ b/apps/desktop/src/main/services/ipc/runtimeBridge.ts
@@ -704,6 +704,7 @@ export function registerRuntimeBridge({
projectId: project.projectId,
rootPath: project.rootPath,
displayName: project.displayName || path.basename(project.rootPath),
+ iconDataUrl: project.icon?.dataUrl ?? null,
};
if (
isLatestOpenRequest() &&
diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts
index 3e4263868..570aa43a5 100644
--- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts
+++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts
@@ -1057,7 +1057,11 @@ describe("local runtime connection pool", () => {
const pool = new LocalRuntimeConnectionPool("1.2.3", logger as never);
(pool as unknown as { createConnection: () => Promise }).createConnection = createConnection;
- await expect(pool.ensureProject(rootPath)).resolves.toEqual(project);
+ // ensureProject coerces the record, which stamps a null icon by default.
+ await expect(pool.ensureProject(rootPath)).resolves.toEqual({
+ ...project,
+ icon: null,
+ });
expect(createConnection).toHaveBeenCalledTimes(2);
expect(firstClient.call).not.toHaveBeenCalled();
diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts
index 7cfbf1060..36ac902ac 100644
--- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts
+++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts
@@ -11,6 +11,7 @@ import type { RemoteTargetRegistry } from "./remoteTargetRegistry";
import {
bootstrapRemoteRuntime,
buildRemoteRuntimeEnvironmentPrefix,
+ coerceProjects,
normalizeRemoteArch,
normalizeRuntimeVersion,
resolveRemoteRuntimeLayout,
@@ -1577,3 +1578,48 @@ describe("bootstrapRemoteRuntime upload flow", () => {
expect(commands.some((command) => command.includes("runtime stop --text"))).toBe(false);
});
});
+
+describe("coerceProjects icon coercion", () => {
+ const baseRecord = {
+ projectId: "p1",
+ rootPath: "/home/user/proj",
+ displayName: "proj",
+ addedAt: 1,
+ lastOpenedAt: 2,
+ gitOriginUrl: null,
+ };
+
+ it("coerces a well-formed icon from the wire into a ProjectIcon", () => {
+ const [record] = coerceProjects([
+ {
+ ...baseRecord,
+ icon: {
+ dataUrl: "data:image/png;base64,AAA",
+ sourcePath: "/home/user/proj/logo.png",
+ mimeType: "image/png",
+ },
+ },
+ ]);
+ expect(record.icon).toEqual({
+ dataUrl: "data:image/png;base64,AAA",
+ sourcePath: "/home/user/proj/logo.png",
+ mimeType: "image/png",
+ });
+ });
+
+ it("defaults the icon to null when the host omits it (older brain)", () => {
+ const [record] = coerceProjects([baseRecord]);
+ expect(record.icon).toBeNull();
+ });
+
+ it("rejects malformed icon payloads, coercing each to null", () => {
+ const [fromString, fromArray, fromWrongTypes] = coerceProjects([
+ { ...baseRecord, projectId: "a", icon: "not-an-object" },
+ { ...baseRecord, projectId: "b", icon: ["nope"] },
+ { ...baseRecord, projectId: "c", icon: { dataUrl: 123, sourcePath: false } },
+ ]);
+ expect(fromString.icon).toBeNull();
+ expect(fromArray.icon).toBeNull();
+ expect(fromWrongTypes.icon).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts
index b18d4e0fc..cbbc867b8 100644
--- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts
+++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts
@@ -4,6 +4,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { Client, ConnectConfig, SFTPWrapper } from "ssh2";
+import type { ProjectIcon } from "../../../shared/types/core";
import type {
RemoteRuntimeCapabilities,
RemoteRuntimeConnectResult,
@@ -1255,6 +1256,16 @@ async function openValidatedRuntimeClient(args: {
}
}
+function coerceProjectIcon(value: unknown): ProjectIcon | null {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
+ const record = value as Record;
+ const dataUrl = typeof record.dataUrl === "string" ? record.dataUrl : null;
+ const sourcePath = typeof record.sourcePath === "string" ? record.sourcePath : null;
+ const mimeType = typeof record.mimeType === "string" ? record.mimeType : null;
+ if (dataUrl === null && sourcePath === null && mimeType === null) return null;
+ return { dataUrl, sourcePath, mimeType };
+}
+
export function coerceProjects(value: unknown): RemoteRuntimeProjectRecord[] {
if (!Array.isArray(value)) return [];
return value.flatMap((entry) => {
@@ -1270,6 +1281,7 @@ export function coerceProjects(value: unknown): RemoteRuntimeProjectRecord[] {
addedAt: typeof record.addedAt === "number" ? record.addedAt : 0,
lastOpenedAt: typeof record.lastOpenedAt === "number" ? record.lastOpenedAt : 0,
gitOriginUrl: typeof record.gitOriginUrl === "string" ? record.gitOriginUrl : null,
+ icon: coerceProjectIcon(record.icon),
}];
});
}
diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx
index b4e8c95f0..7cfe9a288 100644
--- a/apps/desktop/src/renderer/components/app/TopBar.tsx
+++ b/apps/desktop/src/renderer/components/app/TopBar.tsx
@@ -530,6 +530,7 @@ function ProjectTabIcon({
animate,
disabled,
readOnly = false,
+ iconDataUrlOverride,
onAccentColorChange,
}: {
rootPath: string;
@@ -537,6 +538,13 @@ function ProjectTabIcon({
animate: boolean;
disabled: boolean;
readOnly?: boolean;
+ /**
+ * When defined, the caller owns this tab's icon (remote tabs, whose files
+ * live on another machine). A non-empty data URL is rendered directly; null
+ * falls back to the folder glyph. Either way the local resolveIcon path is
+ * skipped, since it can only read the local filesystem.
+ */
+ iconDataUrlOverride?: string | null;
onAccentColorChange?: (rootPath: string, color: string | null) => void;
}) {
const [icon, setIcon] = useState(() =>
@@ -548,8 +556,22 @@ function ProjectTabIcon({
const [removing, setRemoving] = useState(false);
const [iconError, setIconError] = useState(null);
+ // Remote tabs supply their icon via the override (resolved on the host), so
+ // the local resolveIcon path is bypassed entirely.
+ const managedIcon = iconDataUrlOverride !== undefined;
+ const overrideIcon: ProjectIcon | null = iconDataUrlOverride
+ ? { dataUrl: iconDataUrlOverride, sourcePath: null, mimeType: null }
+ : null;
+ const displayIcon: ProjectIcon | null = managedIcon ? overrideIcon : icon;
+
useEffect(() => {
setFailed(false);
+ // Caller-managed icons (remote tabs) never resolve against the local
+ // filesystem — the project lives on another machine.
+ if (managedIcon) {
+ setIcon(null);
+ return;
+ }
// Honor `disabled` (e.g. project marked missing) BEFORE consulting the
// cache. Otherwise a project that was successfully resolved earlier in
// the session keeps showing its stale icon after it goes missing.
@@ -584,7 +606,7 @@ function ProjectTabIcon({
cancelled = true;
window.clearTimeout(timer);
};
- }, [disabled, isCurrent, rootPath]);
+ }, [disabled, isCurrent, rootPath, managedIcon, iconDataUrlOverride]);
useEffect(() => {
let cancelled = false;
@@ -620,11 +642,11 @@ function ProjectTabIcon({
);
const iconNode =
- !icon?.dataUrl || failed ? (
+ !displayIcon?.dataUrl || failed ? (
fallbackIcon
) : (
{remoteTab.displayName}
@@ -1977,7 +2000,7 @@ export function TopBar() {
)}
diff --git a/apps/desktop/src/shared/types/core.ts b/apps/desktop/src/shared/types/core.ts
index c636ad006..d4adfcdf4 100644
--- a/apps/desktop/src/shared/types/core.ts
+++ b/apps/desktop/src/shared/types/core.ts
@@ -150,6 +150,12 @@ export type OpenProjectBinding =
projectId: string;
rootPath: string;
displayName: string;
+ /**
+ * The remote project's icon as a base64 data URL, resolved on the host
+ * machine. Lets the project tab show the real project logo instead of a
+ * blank folder. Null/absent when the host couldn't resolve an icon.
+ */
+ iconDataUrl?: string | null;
};
export type AppNavigationTarget =
diff --git a/apps/desktop/src/shared/types/remoteRuntime.ts b/apps/desktop/src/shared/types/remoteRuntime.ts
index 53ed19f6f..d05f45c7a 100644
--- a/apps/desktop/src/shared/types/remoteRuntime.ts
+++ b/apps/desktop/src/shared/types/remoteRuntime.ts
@@ -1,3 +1,5 @@
+import type { ProjectIcon } from "./core";
+
export type RemoteRuntimeTargetRouteSource =
| "manual"
| "bonjour"
@@ -70,6 +72,13 @@ export type RemoteRuntimeProjectRecord = {
addedAt: number;
lastOpenedAt: number;
gitOriginUrl: string | null;
+ /**
+ * The project's icon as resolved on the host machine, inlined as a base64
+ * data URL so a connected desktop can render the real project logo in its
+ * tab instead of a blank folder. Null/absent when the host can't resolve an
+ * icon (older host, no icon file, or an oversized icon dropped from the wire).
+ */
+ icon?: ProjectIcon | null;
};
export type RemoteRuntimeConnectResult = {
diff --git a/docs/features/project-home/README.md b/docs/features/project-home/README.md
index 806ff629e..54f0cb3c1 100644
--- a/docs/features/project-home/README.md
+++ b/docs/features/project-home/README.md
@@ -235,9 +235,11 @@ Shared types:
`LaneOverlayPolicy`, `ProxyConfig`, `PortLease`, `LanePreviewInfo`.
- `apps/desktop/src/shared/types/core.ts` — `ProjectIcon` (`{ dataUrl,
sourcePath, mimeType }`), `RecentProjectSummary` (`kind`, `remote`,
- `pinned`), remote `OpenProjectBinding` metadata, and `ProjectDetail`
- dirty breakdowns consumed by the TopBar tab strip, welcome rows,
- project browser preview, and mobile-facing project catalog.
+ `pinned`), remote `OpenProjectBinding` metadata (including
+ `iconDataUrl`, the host-resolved logo for the remote project tab),
+ and `ProjectDetail` dirty breakdowns consumed by the TopBar tab
+ strip, welcome rows, project browser preview, and mobile-facing
+ project catalog.
Preload bridge:
@@ -491,6 +493,26 @@ detection or override). The override is committed to `.ade/ade.yaml`
the `.ade/project-icons/` directory is part of the tracked shared
scaffold so the actual bytes travel with the override.
+Remote project tabs cannot run the local resolver — the project files
+live on another machine. Instead the host brain resolves the icon and
+inlines it: the ade-cli `projects.list` RPC stamps each record with an
+`icon: { dataUrl, sourcePath, mimeType }` produced by
+`resolveRemoteProjectIcon` (`apps/ade-cli/src/services/projects/projectIconResolver.ts`),
+a compact electron-free port of the desktop resolver that covers the
+`.ade/ade.yaml` override, the conventional icon/logo files, and an
+`index.html` `` (resolution is best-effort and
+capped at 2 MB so it stays inline-safe on the wire; a failure for one
+project degrades to a null icon rather than breaking the list). That
+icon rides through `RemoteRuntimeProjectRecord.icon` →
+`OpenProjectBinding.iconDataUrl` → the remote project tab. `TopBar`'s
+`ProjectTabIcon` takes an `iconDataUrlOverride`: when the caller owns
+the icon (remote tabs), it renders the data URL directly and skips the
+local `resolveIcon` path entirely (falling back to the folder glyph
+when the host returned no icon). The binding's `iconDataUrl` is
+persisted to `globalState.lastRemoteProjectBinding` and restored on a
+cold start so the real logo shows immediately, before the remote
+reconnects and refreshes it.
+
The mobile companion gets the icon through a dedicated path: the host's
`mobileProjectSummaryForContext` / `mobileProjectSummaryForRecent` in
`apps/desktop/src/main/main.ts` runs `resolveMobileProjectIconDataUrl`
diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md
index 025a6249d..f0c04941d 100644
--- a/docs/features/remote-runtime/README.md
+++ b/docs/features/remote-runtime/README.md
@@ -57,9 +57,16 @@ The wire transport is the same JSON-RPC the local machine runtime answers. The r
remote runtime are localized through a local TCP forward before the renderer
opens them.
- `apps/ade-cli/src/multiProjectRpcServer.ts` — runtime-level project catalog
- and sync methods plus project-scoped action dispatch.
-- `apps/ade-cli/src/services/projects/` — machine project registry and
- per-project service scope cache.
+ and sync methods plus project-scoped action dispatch. `projects.list` inlines
+ each project's host-resolved icon (`icon: { dataUrl, sourcePath, mimeType }`)
+ so a connected desktop can render the real project logo in its remote tab
+ instead of a blank folder.
+- `apps/ade-cli/src/services/projects/` — machine project registry,
+ per-project service scope cache, and `projectIconResolver.ts`
+ (`resolveRemoteProjectIcon`, an electron-free port of the desktop icon
+ resolver: `.ade/ade.yaml` override + conventional icon/logo files +
+ `index.html` ``, best-effort and capped at 2 MB to stay
+ inline-safe on the wire).
- `apps/ade-cli/scripts/build-static.mjs` — produces the static
`ade-` SEA binary and the `.native.tar.gz` of native modules,
resolves the runtime version from the CLI / desktop package metadata, and
@@ -147,7 +154,7 @@ After install, the headless machine can already serve clients. Desktop ADE on a
## What works remotely
-Remote project bindings route lanes, agent chat, PTYs, terminal IO, file operations, file-watch notifications, git actions, PR actions, PR queue automation, PR AI conflict-resolution sessions, PR issue-resolution launch flows, AI PR summaries, issue inventory, and event streaming through the remote runtime. Remote lane preview URLs are opened through a local TCP forward created by the desktop, so a dev server bound to `127.0.0.1` on the remote can be inspected from the local window. Agent CLI failures (Claude / Codex / Cursor / Droid not installed or not authenticated) surface as inline `AgentCliAuthCard` cards in chat; the install / login buttons open a tracked terminal in the active runtime, so a remote project runs the install or login command on the remote machine.
+Remote project bindings route lanes, agent chat, PTYs, terminal IO, file operations, file-watch notifications, git actions, PR actions, PR queue automation, PR AI conflict-resolution sessions, PR issue-resolution launch flows, AI PR summaries, issue inventory, and event streaming through the remote runtime. Remote lane preview URLs are opened through a local TCP forward created by the desktop, so a dev server bound to `127.0.0.1` on the remote can be inspected from the local window. A connected remote project's tab shows the real project logo and a yellow connected accent: the host brain resolves the icon and inlines it on `projects.list`, the desktop threads it through `RemoteRuntimeProjectRecord.icon` → `OpenProjectBinding.iconDataUrl` to the tab, and persists it so the logo is restored on a cold start before the remote reconnects. Agent CLI failures (Claude / Codex / Cursor / Droid not installed or not authenticated) surface as inline `AgentCliAuthCard` cards in chat; the install / login buttons open a tracked terminal in the active runtime, so a remote project runs the install or login command on the remote machine.
Local project bindings use the local ADE runtime for the same surfaces — agent chat, session history, PTYs, terminal reads/writes, file operations and watchers, diffs, lanes, PRs, PR queues, PR issue-resolution launch flows, PR AI conflict-resolution sessions, issue inventory, tests, processes, project config, and most git operations. Electron main still owns desktop-only services that physically require an Electron host.