-
{project.name}
-
- {project.cwd}
-
+
+
+
+ {project.name}
+
+ {project.cwd}
+
+
))}
@@ -1826,6 +1865,11 @@ function SettingsRouteView() {
No projects available.
)
}
+ />
+
+
+
+
+
+ {projectIconDraft.trim().length > 0
+ ? projectIconDraft.trim()
+ : (selectedProject.iconPath ?? "Using the project favicon")}
+
+ ) : (
+ Open or create a project to edit the icon.
+ )
+ }
+ control={
+
+ setProjectIconDraft(event.target.value)}
+ onBlur={() => {
+ void saveProjectIconOverride();
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ void saveProjectIconOverride();
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ setProjectIconDraft(selectedProject?.iconPath ?? "");
+ }
+ }}
+ placeholder="public/icon.svg"
+ className="w-full sm:w-64"
+ aria-label="Project icon path"
+ disabled={!selectedProject}
+ />
+
+
+ }
+ />
)}
diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx
index 5bec7874..c6568e9c 100644
--- a/apps/web/src/routes/_chat.settings.tsx
+++ b/apps/web/src/routes/_chat.settings.tsx
@@ -4,6 +4,7 @@ import {
CheckCircle2Icon,
ChevronDownIcon,
CpuIcon,
+ FolderIcon,
GlobeIcon,
GitBranchIcon,
ImportIcon,
@@ -113,10 +114,11 @@ import {
serverConfigQueryOptions,
serverQueryKeys,
} from "../lib/serverReactQuery";
-import { cn } from "../lib/utils";
+import { cn, newCommandId } from "../lib/utils";
import { ensureNativeApi, readNativeApi } from "../nativeApi";
import { useStore } from "../store";
import { PairingLink } from "../components/mobile/PairingLink";
+import { ProjectIcon } from "../components/ProjectIcon";
import {
getProviderLabel as getProviderStatusLabelName,
getProviderStatusDescription,
@@ -131,6 +133,7 @@ type SettingsSectionId =
| "authentication"
| "hotkeys"
| "environment"
+ | "projects"
| "git"
| "models"
| "mobile"
@@ -153,6 +156,7 @@ function useSettingsNavItems(): SettingsNavItem[] {
},
{ id: "hotkeys", label: "Hotkeys", icon: },
{ id: "environment", label: "Environment", icon: },
+ { id: "projects", label: "Projects", icon: },
{ id: "git", label: "Git", icon: },
{ id: "models", label: "Models", icon: },
{
@@ -872,6 +876,7 @@ function SettingsRouteView() {
const globalEnvironmentVariablesQuery = useQuery(globalEnvironmentVariablesQueryOptions());
const activeProjectId = selectedProjectId ?? projects[0]?.id ?? null;
const selectedProject = projects.find((project) => project.id === activeProjectId) ?? null;
+ const [projectIconDraft, setProjectIconDraft] = useState("");
const selectedProjectEnvironmentVariablesQuery = useQuery(
projectEnvironmentVariablesQueryOptions(activeProjectId),
);
@@ -898,6 +903,10 @@ function SettingsRouteView() {
}
}, [projects, selectedProjectId]);
+ useEffect(() => {
+ setProjectIconDraft(selectedProject?.iconPath ?? "");
+ }, [selectedProject?.iconPath, selectedProject?.id]);
+
const codexBinaryPath = settings.codexBinaryPath;
const codexHomePath = settings.codexHomePath;
const claudeBinaryPath = settings.claudeBinaryPath;
@@ -1110,6 +1119,25 @@ function SettingsRouteView() {
[queryClient, selectedProject],
);
+ const saveProjectIconOverride = useCallback(async () => {
+ if (!selectedProject) {
+ throw new Error("Select a project before saving the project icon.");
+ }
+ const nextIconPath = projectIconDraft.trim();
+ const currentIconPath = selectedProject.iconPath ?? "";
+ if (nextIconPath === currentIconPath) {
+ return;
+ }
+
+ const api = ensureNativeApi();
+ await api.orchestration.dispatchCommand({
+ type: "project.meta.update",
+ commandId: newCommandId(),
+ projectId: selectedProject.id,
+ iconPath: nextIconPath.length > 0 ? nextIconPath : null,
+ });
+ }, [projectIconDraft, selectedProject]);
+
const testOpenclawGateway = useCallback(async () => {
if (openclawTestLoading) return;
setOpenclawTestLoading(true);
@@ -3381,17 +3409,24 @@ function SettingsRouteView() {
}
/>
+
+ )}
+ {activeSection === "projects" && (
+
{selectedProject.name} · {selectedProject.cwd}
) : (
- Open a project to edit project variables.
+ Open a project to edit project settings.
)
}
control={
@@ -3410,11 +3445,18 @@ function SettingsRouteView() {
{projects.map((project) => (
-
-
{project.name}
-
- {project.cwd}
-
+
+
+
+ {project.name}
+
+ {project.cwd}
+
+
))}
@@ -3426,6 +3468,11 @@ function SettingsRouteView() {
)
}
+ />
+
+
+
+
+
+ {projectIconDraft.trim().length > 0
+ ? projectIconDraft.trim()
+ : (selectedProject.iconPath ?? "Using the project favicon")}
+
+ ) : (
+ Open or create a project to edit the icon.
+ )
+ }
+ control={
+
+ setProjectIconDraft(event.target.value)}
+ onBlur={() => {
+ void saveProjectIconOverride();
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ void saveProjectIconOverride();
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ setProjectIconDraft(selectedProject?.iconPath ?? "");
+ }
+ }}
+ placeholder="public/icon.svg"
+ className="w-full sm:w-64"
+ aria-label="Project icon path"
+ disabled={!selectedProject}
+ />
+
+
+ }
+ />
)}
diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts
index b1b8da36..a267d73d 100644
--- a/apps/web/src/store.test.ts
+++ b/apps/web/src/store.test.ts
@@ -90,6 +90,7 @@ function makeReadModel(thread: OrchestrationReadModel["threads"][number]): Orche
title: "Project",
workspaceRoot: "/tmp/project",
defaultModel: "gpt-5.3-codex",
+ iconPath: null,
createdAt: "2026-02-27T00:00:00.000Z",
updatedAt: "2026-02-27T00:00:00.000Z",
deletedAt: null,
@@ -108,6 +109,7 @@ function makeReadModelProject(
title: "Project",
workspaceRoot: "/tmp/project",
defaultModel: "gpt-5.3-codex",
+ iconPath: null,
createdAt: "2026-02-27T00:00:00.000Z",
updatedAt: "2026-02-27T00:00:00.000Z",
deletedAt: null,
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts
index 38539eec..a4887828 100644
--- a/apps/web/src/store.ts
+++ b/apps/web/src/store.ts
@@ -194,6 +194,7 @@ function mapProjectsFromReadModel(
: (project.defaultModel ?? DEFAULT_MODEL_BY_PROVIDER.codex),
),
defaultModelSelection: project.defaultModelSelection ?? null,
+ iconPath: project.iconPath ?? null,
expanded: resolveProjectExpandedState({
existingExpanded: existing?.expanded,
persistedExpanded: persistedProjectExpansionByCwd.get(project.workspaceRoot),
diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts
index 7cb08280..cd060f14 100644
--- a/apps/web/src/types.ts
+++ b/apps/web/src/types.ts
@@ -94,6 +94,7 @@ export interface Project {
cwd: string;
model: string;
defaultModelSelection?: ModelSelection | null;
+ iconPath?: string | null;
expanded: boolean;
createdAt?: string | undefined;
updatedAt?: string | undefined;
diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts
index c27a7952..9a56a445 100644
--- a/apps/web/src/wsNativeApi.test.ts
+++ b/apps/web/src/wsNativeApi.test.ts
@@ -309,6 +309,7 @@ describe("wsNativeApi", () => {
title: "Project",
workspaceRoot: "/tmp/workspace",
defaultModel: null,
+ iconPath: null,
scripts: [],
createdAt: "2026-02-24T00:00:00.000Z",
updatedAt: "2026-02-24T00:00:00.000Z",
diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts
index f1df8759..28bc62b4 100644
--- a/packages/contracts/src/orchestration.test.ts
+++ b/packages/contracts/src/orchestration.test.ts
@@ -90,6 +90,7 @@ it.effect("trims branded ids and command string fields at decode boundaries", ()
title: " Project Title ",
workspaceRoot: " /tmp/workspace ",
defaultModel: " gpt-5.2 ",
+ iconPath: " public/icon.svg ",
createdAt: "2026-01-01T00:00:00.000Z",
});
assert.strictEqual(parsed.commandId, "cmd-1");
@@ -97,6 +98,7 @@ it.effect("trims branded ids and command string fields at decode boundaries", ()
assert.strictEqual(parsed.title, "Project Title");
assert.strictEqual(parsed.workspaceRoot, "/tmp/workspace");
assert.strictEqual(parsed.defaultModel, "gpt-5.2");
+ assert.strictEqual(parsed.iconPath, "public/icon.svg");
}),
);
diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts
index 4a510649..c3a4b492 100644
--- a/packages/contracts/src/orchestration.ts
+++ b/packages/contracts/src/orchestration.ts
@@ -270,6 +270,7 @@ export const OrchestrationProject = Schema.Struct({
workspaceRoot: TrimmedNonEmptyString,
defaultModel: Schema.NullOr(TrimmedNonEmptyString),
defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
+ iconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
scripts: Schema.Array(ProjectScript),
createdAt: IsoDateTime,
updatedAt: IsoDateTime,
@@ -431,6 +432,7 @@ export const OrchestrationOverviewProject = Schema.Struct({
workspaceRoot: TrimmedNonEmptyString,
defaultModel: Schema.NullOr(TrimmedNonEmptyString),
defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
+ iconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
scripts: Schema.Array(ProjectScript),
activeThreadCount: NonNegativeInt,
createdAt: IsoDateTime,
@@ -487,6 +489,7 @@ export const ProjectCreateCommand = Schema.Struct({
workspaceRoot: TrimmedNonEmptyString,
defaultModel: Schema.optional(TrimmedNonEmptyString),
defaultModelSelection: Schema.optional(ModelSelection),
+ iconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
scripts: Schema.optional(Schema.Array(ProjectScript)),
createdAt: IsoDateTime,
});
@@ -499,6 +502,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({
workspaceRoot: Schema.optional(TrimmedNonEmptyString),
defaultModel: Schema.optional(TrimmedNonEmptyString),
defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
+ iconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
scripts: Schema.optional(Schema.Array(ProjectScript)),
});
@@ -806,6 +810,7 @@ export const ProjectCreatedPayload = Schema.Struct({
workspaceRoot: TrimmedNonEmptyString,
defaultModel: Schema.NullOr(TrimmedNonEmptyString),
defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
+ iconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
scripts: Schema.Array(ProjectScript),
createdAt: IsoDateTime,
updatedAt: IsoDateTime,
@@ -817,6 +822,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({
workspaceRoot: Schema.optional(TrimmedNonEmptyString),
defaultModel: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
+ iconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
scripts: Schema.optional(Schema.Array(ProjectScript)),
updatedAt: IsoDateTime,
});
diff --git a/packages/shared/src/modelSelection.ts b/packages/shared/src/modelSelection.ts
index c57001e0..4dada48b 100644
--- a/packages/shared/src/modelSelection.ts
+++ b/packages/shared/src/modelSelection.ts
@@ -79,8 +79,7 @@ export function modelSelectionsAreEqual(
const bKeys = Object.keys(bOpts).sort();
if (aKeys.join(",") !== bKeys.join(",")) return false;
return aKeys.every(
- (k) =>
- (aOpts as Record)[k] === (bOpts as Record)[k],
+ (k) => (aOpts as Record)[k] === (bOpts as Record)[k],
);
}