From 283409a5f1f212582624359a1a88686a78822a35 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 13 Apr 2026 14:52:40 -0500 Subject: [PATCH] Add per-project icon overrides - persist icon_path on projects - serve project-specific favicons in the UI - add a settings control to edit project icons --- .../Layers/CheckpointDiffQuery.test.ts | 1 + .../Layers/ProjectionOverviewQuery.ts | 2 + .../Layers/ProjectionPipeline.ts | 2 + .../Layers/ProjectionSnapshotQuery.test.ts | 4 + .../Layers/ProjectionSnapshotQuery.ts | 2 + .../orchestration/commandInvariants.test.ts | 5 + .../src/orchestration/decider.limits.test.ts | 1 + .../decider.projectScripts.test.ts | 53 ++++++++ apps/server/src/orchestration/decider.ts | 2 + apps/server/src/orchestration/projector.ts | 2 + .../persistence/Layers/ProjectionProjects.ts | 7 + .../persistence/Layers/ProjectionThreads.ts | 8 +- apps/server/src/persistence/Migrations.ts | 2 + .../025_ProjectionProjectIconPath.ts | 11 ++ .../Services/ProjectionProjects.ts | 1 + apps/server/src/projectFaviconRoute.test.ts | 15 +++ apps/server/src/projectFaviconRoute.ts | 25 ++++ apps/web/src/components/ChatView.browser.tsx | 1 + apps/web/src/components/ChatView.tsx | 6 +- .../components/KeybindingsToast.browser.tsx | 1 + apps/web/src/components/ProjectIcon.tsx | 23 ++++ apps/web/src/components/Sidebar.tsx | 4 +- .../src/components/settings/SettingsShell.tsx | 3 + apps/web/src/lib/projectIcons.ts | 11 ++ apps/web/src/routes/_chat.settings.index.tsx | 120 +++++++++++++++-- apps/web/src/routes/_chat.settings.tsx | 123 ++++++++++++++++-- apps/web/src/store.test.ts | 2 + apps/web/src/store.ts | 1 + apps/web/src/types.ts | 1 + apps/web/src/wsNativeApi.test.ts | 1 + packages/contracts/src/orchestration.test.ts | 2 + packages/contracts/src/orchestration.ts | 6 + packages/shared/src/modelSelection.ts | 3 +- 33 files changed, 423 insertions(+), 28 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/025_ProjectionProjectIconPath.ts create mode 100644 apps/web/src/components/ProjectIcon.tsx create mode 100644 apps/web/src/lib/projectIcons.ts diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index 76a5a2c1..33c683be 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -32,6 +32,7 @@ function makeSnapshot(input: { title: "Project", workspaceRoot: input.workspaceRoot, defaultModel: null, + iconPath: null, scripts: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", diff --git a/apps/server/src/orchestration/Layers/ProjectionOverviewQuery.ts b/apps/server/src/orchestration/Layers/ProjectionOverviewQuery.ts index 3c25a50a..827f46bd 100644 --- a/apps/server/src/orchestration/Layers/ProjectionOverviewQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionOverviewQuery.ts @@ -185,6 +185,7 @@ const makeProjectionOverviewQuery = Effect.gen(function* () { p.workspace_root AS "workspaceRoot", p.default_model AS "defaultModel", p.default_model_selection AS "defaultModelSelection", + p.icon_path AS "iconPath", p.scripts_json AS "scripts", p.created_at AS "createdAt", p.updated_at AS "updatedAt", @@ -364,6 +365,7 @@ const makeProjectionOverviewQuery = Effect.gen(function* () { workspaceRoot: row.workspaceRoot, defaultModel: row.defaultModel, defaultModelSelection: row.defaultModelSelection, + iconPath: row.iconPath, scripts: row.scripts, activeThreadCount: row.activeThreadCount, createdAt: row.createdAt, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index fa152f0d..fd679574 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -399,6 +399,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { workspaceRoot: event.payload.workspaceRoot, defaultModel: event.payload.defaultModel, defaultModelSelection: event.payload.defaultModelSelection, + iconPath: event.payload.iconPath, scripts: event.payload.scripts, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -425,6 +426,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { ...(event.payload.defaultModelSelection !== undefined ? { defaultModelSelection: event.payload.defaultModelSelection } : {}), + ...(event.payload.iconPath !== undefined ? { iconPath: event.payload.iconPath } : {}), ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}), updatedAt: event.payload.updatedAt, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 5d649186..d89a0525 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -35,6 +35,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { title, workspace_root, default_model, + default_model_selection, + icon_path, scripts_json, created_at, updated_at, @@ -45,6 +47,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'Project 1', '/tmp/project-1', 'gpt-5-codex', + NULL, + NULL, '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', '2026-02-24T00:00:00.000Z', '2026-02-24T00:00:01.000Z', diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 78b0c644..dc51fa98 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -154,6 +154,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model AS "defaultModel", default_model_selection AS "defaultModelSelection", + icon_path AS "iconPath", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -552,6 +553,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspaceRoot: row.workspaceRoot, defaultModel: row.defaultModel, defaultModelSelection: row.defaultModelSelection, + iconPath: row.iconPath, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index d674346c..f50201e8 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -30,6 +30,7 @@ const readModel: OrchestrationReadModel = { title: "Project A", workspaceRoot: "/tmp/project-a", defaultModel: "gpt-5-codex", + iconPath: null, scripts: [], createdAt: now, updatedAt: now, @@ -40,6 +41,7 @@ const readModel: OrchestrationReadModel = { title: "Project B", workspaceRoot: "/tmp/project-b", defaultModel: "gpt-5-codex", + iconPath: null, scripts: [], createdAt: now, updatedAt: now, @@ -50,6 +52,7 @@ const readModel: OrchestrationReadModel = { title: "Project Archived", workspaceRoot: "/tmp/project-archived", defaultModel: "gpt-5-codex", + iconPath: null, scripts: [], createdAt: now, updatedAt: now, @@ -182,6 +185,7 @@ describe("commandInvariants", () => { type: "project.meta.update", commandId: CommandId.makeUnsafe("cmd-project-update"), projectId: ProjectId.makeUnsafe("project-a"), + iconPath: null, }, projectId: ProjectId.makeUnsafe("project-a"), }), @@ -195,6 +199,7 @@ describe("commandInvariants", () => { type: "project.meta.update", commandId: CommandId.makeUnsafe("cmd-project-update-archived"), projectId: ProjectId.makeUnsafe("project-archived"), + iconPath: null, }, projectId: ProjectId.makeUnsafe("project-archived"), }), diff --git a/apps/server/src/orchestration/decider.limits.test.ts b/apps/server/src/orchestration/decider.limits.test.ts index 44a3d5f8..38cbc5ca 100644 --- a/apps/server/src/orchestration/decider.limits.test.ts +++ b/apps/server/src/orchestration/decider.limits.test.ts @@ -23,6 +23,7 @@ function makeProject(input: { title: input.id, workspaceRoot: `/tmp/${input.id}`, defaultModel: "gpt-5-codex", + iconPath: null, scripts: [], createdAt: input.updatedAt, updatedAt: input.updatedAt, diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index a2a80dee..bf5458a3 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -29,6 +29,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-scripts"), title: "Scripts", workspaceRoot: "/tmp/scripts", + iconPath: null, createdAt: now, }, readModel, @@ -62,6 +63,7 @@ describe("decider project scripts", () => { title: "Scripts", workspaceRoot: "/tmp/scripts-populated", scripts: Array.from(scripts), + iconPath: null, createdAt: now, }, readModel, @@ -93,6 +95,8 @@ describe("decider project scripts", () => { title: "Scripts", workspaceRoot: "/tmp/scripts", defaultModel: null, + defaultModelSelection: null, + iconPath: null, scripts: [], createdAt: now, updatedAt: now, @@ -117,6 +121,7 @@ describe("decider project scripts", () => { commandId: CommandId.makeUnsafe("cmd-project-update-scripts"), projectId: asProjectId("project-scripts"), scripts: Array.from(scripts), + iconPath: null, }, readModel, }), @@ -127,6 +132,52 @@ describe("decider project scripts", () => { expect((event.payload as { scripts?: unknown[] }).scripts).toEqual(scripts); }); + it("propagates iconPath in project.meta.update payload", async () => { + const now = new Date().toISOString(); + const initial = createEmptyReadModel(now); + const readModel = await Effect.runPromise( + projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project-create-icon"), + aggregateKind: "project", + aggregateId: asProjectId("project-icon"), + type: "project.created", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-project-create-icon"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-project-create-icon"), + metadata: {}, + payload: { + projectId: asProjectId("project-icon"), + title: "Icon", + workspaceRoot: "/tmp/icon", + defaultModel: null, + defaultModelSelection: null, + iconPath: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ); + + const result = await Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.makeUnsafe("cmd-project-update-icon"), + projectId: asProjectId("project-icon"), + iconPath: "public/brand.svg", + }, + readModel, + }), + ); + + const event = Array.isArray(result) ? result[0] : result; + expect(event.type).toBe("project.meta-updated"); + expect((event.payload as { iconPath?: string | null }).iconPath).toBe("public/brand.svg"); + }); + it("emits user message and turn-start-requested events for thread.turn.start", async () => { const now = new Date().toISOString(); const initial = createEmptyReadModel(now); @@ -147,6 +198,8 @@ describe("decider project scripts", () => { title: "Project", workspaceRoot: "/tmp/project", defaultModel: null, + defaultModelSelection: null, + iconPath: null, scripts: [], createdAt: now, updatedAt: now, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 0694ba61..2fa0bcf3 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -138,6 +138,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" undefined, ) : null), + iconPath: command.iconPath ?? null, scripts: command.scripts ?? [], createdAt: command.createdAt, updatedAt: command.createdAt, @@ -206,6 +207,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ), } : {}), + ...(command.iconPath !== undefined ? { iconPath: command.iconPath } : {}), ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), updatedAt: occurredAt, }, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 0241f8eb..49de5427 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -185,6 +185,7 @@ export function projectEvent( workspaceRoot: payload.workspaceRoot, defaultModel: payload.defaultModel, defaultModelSelection: payload.defaultModelSelection, + iconPath: payload.iconPath, scripts: payload.scripts, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -228,6 +229,7 @@ export function projectEvent( ), } : {}), + ...(payload.iconPath !== undefined ? { iconPath: payload.iconPath } : {}), ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), updatedAt: payload.updatedAt, } diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index 39deb63f..97c08dbe 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -19,6 +19,7 @@ const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( defaultModelSelection: Schema.NullOr( Schema.fromJsonString(ProjectionProject.fields.defaultModelSelection), ), + iconPath: Schema.NullOr(Schema.String), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), ); @@ -43,6 +44,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root, default_model, default_model_selection, + icon_path, scripts_json, created_at, updated_at, @@ -54,6 +56,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.workspaceRoot}, ${row.defaultModel}, ${row.defaultModelSelection}, + ${row.iconPath}, ${row.scripts}, ${row.createdAt}, ${row.updatedAt}, @@ -65,6 +68,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root = excluded.workspace_root, default_model = excluded.default_model, default_model_selection = excluded.default_model_selection, + icon_path = excluded.icon_path, scripts_json = excluded.scripts_json, created_at = excluded.created_at, updated_at = excluded.updated_at, @@ -83,6 +87,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model AS "defaultModel", default_model_selection AS "defaultModelSelection", + icon_path AS "iconPath", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -103,6 +108,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model AS "defaultModel", default_model_selection AS "defaultModelSelection", + icon_path AS "iconPath", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -125,6 +131,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { upsertProjectionProjectRow({ ...row, defaultModelSelection: row.defaultModelSelection ?? null, + iconPath: row.iconPath ?? null, }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 0115818e..584d4e3d 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -17,9 +17,7 @@ import { // selection will have a NULL model_selection column. const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ - modelSelection: Schema.NullOr( - Schema.fromJsonString(ProjectionThread.fields.modelSelection), - ), + modelSelection: Schema.NullOr(Schema.fromJsonString(ProjectionThread.fields.modelSelection)), }), ); @@ -144,9 +142,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { upsertProjectionThreadRow({ ...row, modelSelection: row.modelSelection ?? null, - }).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.upsert:query")), - ); + }).pipe(Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.upsert:query"))); const getById: ProjectionThreadRepositoryShape["getById"] = (input) => getProjectionThreadRow(input).pipe( diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 2407f083..2902f479 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -36,6 +36,7 @@ import Migration0021 from "./Migrations/021_ProjectionPendingUserInputs.ts"; import Migration0022 from "./Migrations/022_DecisionWorkspace.ts"; import Migration0023 from "./Migrations/023_ProjectionPendingUserInputsBackfill.ts"; import Migration0024 from "./Migrations/024_OpenclawGatewayConfig.ts"; +import Migration0025 from "./Migrations/025_ProjectionProjectIconPath.ts"; import { Effect } from "effect"; /** @@ -73,6 +74,7 @@ const loader = Migrator.fromRecord({ "22_DecisionWorkspace": Migration0022, "23_ProjectionPendingUserInputsBackfill": Migration0023, "24_OpenclawGatewayConfig": Migration0024, + "25_ProjectionProjectIconPath": Migration0025, }); /** diff --git a/apps/server/src/persistence/Migrations/025_ProjectionProjectIconPath.ts b/apps/server/src/persistence/Migrations/025_ProjectionProjectIconPath.ts new file mode 100644 index 00000000..de356a1c --- /dev/null +++ b/apps/server/src/persistence/Migrations/025_ProjectionProjectIconPath.ts @@ -0,0 +1,11 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN icon_path TEXT + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index af692cae..9a1ea2fc 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -18,6 +18,7 @@ export const ProjectionProject = Schema.Struct({ workspaceRoot: Schema.String, defaultModel: Schema.NullOr(Schema.String), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), + iconPath: Schema.optional(Schema.NullOr(Schema.String)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/server/src/projectFaviconRoute.test.ts b/apps/server/src/projectFaviconRoute.test.ts index 0d65b87a..a7418c02 100644 --- a/apps/server/src/projectFaviconRoute.test.ts +++ b/apps/server/src/projectFaviconRoute.test.ts @@ -98,6 +98,21 @@ describe("tryHandleProjectFaviconRequest", () => { }); }); + it("serves an explicit icon override when provided", async () => { + const projectDir = makeTempDir("okcode-favicon-route-override-"); + const iconPath = path.join(projectDir, "public", "brand", "override.svg"); + fs.mkdirSync(path.dirname(iconPath), { recursive: true }); + fs.writeFileSync(iconPath, "override", "utf8"); + + await withRouteServer(async (baseUrl) => { + const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}&icon=${encodeURIComponent("public/brand/override.svg")}`; + const response = await request(baseUrl, pathname); + expect(response.statusCode).toBe(200); + expect(response.contentType).toContain("image/svg+xml"); + expect(response.body).toBe("override"); + }); + }); + it("resolves icon href from source files when no well-known favicon exists", async () => { const projectDir = makeTempDir("okcode-favicon-route-source-"); const iconPath = path.join(projectDir, "public", "brand", "logo.svg"); diff --git a/apps/server/src/projectFaviconRoute.ts b/apps/server/src/projectFaviconRoute.ts index cf234ad8..54a5f6f1 100644 --- a/apps/server/src/projectFaviconRoute.ts +++ b/apps/server/src/projectFaviconRoute.ts @@ -107,6 +107,31 @@ export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerRespons return true; } + const overrideIconPath = url.searchParams.get("icon"); + if (overrideIconPath) { + const candidates = resolveIconHref(projectCwd, overrideIconPath); + const serveOverrideOrFallback = (index: number): void => { + if (index >= candidates.length) { + serveFallbackFavicon(res); + return; + } + const candidate = candidates[index]!; + if (!isPathWithinProject(projectCwd, candidate)) { + serveOverrideOrFallback(index + 1); + return; + } + fs.stat(candidate, (err, stats) => { + if (err || !stats?.isFile()) { + serveOverrideOrFallback(index + 1); + return; + } + serveFaviconFile(candidate, res); + }); + }; + serveOverrideOrFallback(0); + return true; + } + const tryResolvedPaths = (paths: string[], index: number, onExhausted: () => void): void => { if (index >= paths.length) { onExhausted(); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 6c3fd2a2..6e8f869f 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -231,6 +231,7 @@ function createSnapshotForTargetUser(options: { title: "Project", workspaceRoot: "/repo/project", defaultModel: "gpt-5", + iconPath: null, scripts: [], createdAt: NOW_ISO, updatedAt: NOW_ISO, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 730606b1..af2682c3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -24,7 +24,11 @@ import { RuntimeMode, } from "@okcode/contracts"; import { applyClaudePromptEffortPrefix, getDefaultModel } from "@okcode/shared/model"; -import { getModelSelectionModel, getModelSelectionOptions, modelSelectionsAreEqual } from "@okcode/shared/modelSelection"; +import { + getModelSelectionModel, + getModelSelectionOptions, + modelSelectionsAreEqual, +} from "@okcode/shared/modelSelection"; import { Suspense, lazy, diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index dedd4eed..61088dd8 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -65,6 +65,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { title: "Project", workspaceRoot: "/repo/project", defaultModel: "gpt-5", + iconPath: null, scripts: [], createdAt: NOW_ISO, updatedAt: NOW_ISO, diff --git a/apps/web/src/components/ProjectIcon.tsx b/apps/web/src/components/ProjectIcon.tsx new file mode 100644 index 00000000..ae3fed31 --- /dev/null +++ b/apps/web/src/components/ProjectIcon.tsx @@ -0,0 +1,23 @@ +import { cn } from "../lib/utils"; +import { resolveProjectIconUrl } from "../lib/projectIcons"; + +export function ProjectIcon({ + cwd, + iconPath, + className, +}: { + cwd: string; + iconPath?: string | null | undefined; + className?: string; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d944ba8b..63c92e7c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -59,6 +59,7 @@ import { } from "react"; import { CloneRepositoryDialog } from "~/components/CloneRepositoryDialog"; import { EditableThreadTitle } from "~/components/EditableThreadTitle"; +import { ProjectIcon } from "~/components/ProjectIcon"; import { useClientMode } from "~/hooks/useClientMode"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useCurrentWorktreeCleanupCandidates } from "~/hooks/useCurrentWorktreeCleanupCandidates"; @@ -1442,7 +1443,7 @@ export default function Sidebar() { ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined} size="sm" className={cn( - "h-auto min-w-0 flex-1 gap-0 rounded-md text-left hover:bg-transparent", + "h-auto min-w-0 flex-1 gap-1.5 rounded-md text-left hover:bg-transparent", isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : "cursor-pointer", )} style={SIDEBAR_PROJECT_ROW_STYLE} @@ -1459,6 +1460,7 @@ export default function Sidebar() { }); }} > + {editingProjectId === project.id ? ( }, { id: "environment", label: "Environment", icon: }, + { id: "projects", label: "Projects", icon: }, { id: "git", label: "Git", icon: }, { id: "models", label: "Models", icon: }, { diff --git a/apps/web/src/lib/projectIcons.ts b/apps/web/src/lib/projectIcons.ts new file mode 100644 index 00000000..cfc3964e --- /dev/null +++ b/apps/web/src/lib/projectIcons.ts @@ -0,0 +1,11 @@ +export function resolveProjectIconUrl(input: { + cwd: string; + iconPath?: string | null | undefined; +}): string { + const searchParams = new URLSearchParams({ cwd: input.cwd }); + const iconPath = input.iconPath?.trim(); + if (iconPath) { + searchParams.set("icon", iconPath); + } + return `/api/project-favicon?${searchParams.toString()}`; +} diff --git a/apps/web/src/routes/_chat.settings.index.tsx b/apps/web/src/routes/_chat.settings.index.tsx index 79f3008e..e02574dd 100644 --- a/apps/web/src/routes/_chat.settings.index.tsx +++ b/apps/web/src/routes/_chat.settings.index.tsx @@ -68,10 +68,11 @@ import { isProviderReadyForThreadSelection, } from "../lib/providerAvailability"; import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; -import { cn } from "../lib/utils"; +import { cn, newCommandId } from "../lib/utils"; import { ensureNativeApi } from "../nativeApi"; import { useStore } from "../store"; import { PairingLink } from "../components/mobile/PairingLink"; +import { ProjectIcon } from "../components/ProjectIcon"; import { getProviderLabel as getProviderStatusLabelName, getProviderStatusDescription, @@ -526,6 +527,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), ); @@ -543,6 +545,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; @@ -662,6 +668,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); @@ -1783,17 +1808,24 @@ function SettingsRouteView() { } /> + + )} + {activeSection === "projects" && ( + {selectedProject.name} · {selectedProject.cwd} ) : ( - Open a project to edit project variables. + Open a project to edit project settings. ) } control={ @@ -1812,11 +1844,18 @@ function SettingsRouteView() { {projects.map((project) => ( -
- {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], ); }