From 8886041436281a4dc1efae615b425c2ce660913d Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 13 Apr 2026 22:46:18 -0500 Subject: [PATCH 1/2] Add project icon context menu and shared icon helpers - add a sidebar menu action and dialog to edit project icons - share icon fallback/discovery constants between server and web - normalize project icon paths in settings and save flow --- apps/server/src/projectFaviconRoute.ts | 29 +-- .../components/ProjectIconEditorDialog.tsx | 179 ++++++++++++++++++ apps/web/src/components/Sidebar.test.ts | 1 + apps/web/src/components/Sidebar.tsx | 60 ++++++ apps/web/src/lib/projectIcons.test.ts | 37 ++++ apps/web/src/lib/projectIcons.ts | 34 ++++ apps/web/src/lib/projectMeta.ts | 17 ++ apps/web/src/routes/_chat.settings.index.tsx | 15 +- apps/web/src/routes/_chat.settings.tsx | 15 +- packages/shared/package.json | 4 + packages/shared/src/projectIcons.ts | 24 +++ 11 files changed, 371 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/components/ProjectIconEditorDialog.tsx create mode 100644 apps/web/src/lib/projectIcons.test.ts create mode 100644 apps/web/src/lib/projectMeta.ts create mode 100644 packages/shared/src/projectIcons.ts diff --git a/apps/server/src/projectFaviconRoute.ts b/apps/server/src/projectFaviconRoute.ts index 54a5f6f1..b017cd51 100644 --- a/apps/server/src/projectFaviconRoute.ts +++ b/apps/server/src/projectFaviconRoute.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import http from "node:http"; import path from "node:path"; +import { PROJECT_ICON_FALLBACK_CANDIDATES } from "@okcode/shared/projectIcons"; const FAVICON_MIME_TYPES: Record = { ".png": "image/png", @@ -11,30 +12,6 @@ const FAVICON_MIME_TYPES: Record = { const FALLBACK_FAVICON_SVG = ``; -// Well-known favicon paths checked in order. -const FAVICON_CANDIDATES = [ - "favicon.svg", - "favicon.ico", - "favicon.png", - "public/favicon.svg", - "public/favicon.ico", - "public/favicon.png", - "app/favicon.ico", - "app/favicon.png", - "app/icon.svg", - "app/icon.png", - "app/icon.ico", - "src/favicon.ico", - "src/favicon.svg", - "src/app/favicon.ico", - "src/app/icon.svg", - "src/app/icon.png", - "assets/icon.svg", - "assets/icon.png", - "assets/logo.svg", - "assets/logo.png", -]; - // Files that may contain a or icon metadata declaration. const ICON_SOURCE_FILES = [ "index.html", @@ -173,11 +150,11 @@ export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerRespons }; const tryCandidates = (index: number): void => { - if (index >= FAVICON_CANDIDATES.length) { + if (index >= PROJECT_ICON_FALLBACK_CANDIDATES.length) { trySourceFiles(0); return; } - const candidate = path.join(projectCwd, FAVICON_CANDIDATES[index]!); + const candidate = path.join(projectCwd, PROJECT_ICON_FALLBACK_CANDIDATES[index]!); if (!isPathWithinProject(projectCwd, candidate)) { tryCandidates(index + 1); return; diff --git a/apps/web/src/components/ProjectIconEditorDialog.tsx b/apps/web/src/components/ProjectIconEditorDialog.tsx new file mode 100644 index 00000000..4132ea10 --- /dev/null +++ b/apps/web/src/components/ProjectIconEditorDialog.tsx @@ -0,0 +1,179 @@ +import { useEffect, useRef, useState } from "react"; +import type { Project } from "~/types"; +import { readNativeApi } from "~/nativeApi"; + +import { normalizeProjectIconPath, resolveSuggestedProjectIconPath } from "~/lib/projectIcons"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; +import { ProjectIcon } from "./ProjectIcon"; + +export function ProjectIconEditorDialog({ + project, + open, + onOpenChange, + onSave, +}: { + project: Project | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onSave: (iconPath: string | null) => Promise; +}) { + const projectId = project?.id ?? null; + const projectCwd = project?.cwd ?? null; + const projectIconPath = normalizeProjectIconPath(project?.iconPath); + const [draft, setDraft] = useState(""); + const [suggestedIconPath, setSuggestedIconPath] = useState(null); + const [isLoadingSuggestion, setIsLoadingSuggestion] = useState(false); + const draftWasTouchedRef = useRef(false); + + useEffect(() => { + if (!open || !projectId || !projectCwd) { + setDraft(""); + setSuggestedIconPath(null); + setIsLoadingSuggestion(false); + draftWasTouchedRef.current = false; + return; + } + + draftWasTouchedRef.current = false; + setDraft(projectIconPath ?? ""); + setSuggestedIconPath(null); + + if (projectIconPath) { + setIsLoadingSuggestion(false); + return; + } + + const api = readNativeApi(); + if (!api) { + setIsLoadingSuggestion(false); + return; + } + + let cancelled = false; + setIsLoadingSuggestion(true); + void resolveSuggestedProjectIconPath(api, projectCwd) + .then((nextSuggestion) => { + if (cancelled) return; + setSuggestedIconPath(nextSuggestion); + if (!draftWasTouchedRef.current && !projectIconPath && nextSuggestion) { + setDraft(nextSuggestion); + } + }) + .catch(() => { + if (!cancelled) { + setSuggestedIconPath(null); + } + }) + .finally(() => { + if (!cancelled) { + setIsLoadingSuggestion(false); + } + }); + + return () => { + cancelled = true; + }; + }, [open, projectCwd, projectIconPath, projectId]); + + const resolvedDraft = normalizeProjectIconPath(draft); + const currentValue = projectIconPath; + const canSave = Boolean(project) && resolvedDraft !== currentValue; + const effectivePreviewIconPath = resolvedDraft ?? suggestedIconPath ?? currentValue ?? null; + + if (!project || !projectId || !projectCwd) { + return null; + } + + const commit = async (iconPath: string | null) => { + await onSave(iconPath); + onOpenChange(false); + }; + + return ( + + + + Project icon + + Set a path relative to the project root. Leave it blank to fall back to the detected + favicon or icon file. + + + +
+
+ +
+
{project.name}
+
+ {isLoadingSuggestion + ? "Looking for an icon file..." + : suggestedIconPath + ? `Suggested: ${suggestedIconPath}` + : "No obvious icon file found. Leave blank to use the fallback icon."} +
+
+
+ +
+ + { + draftWasTouchedRef.current = true; + setDraft(event.target.value); + }} + placeholder={suggestedIconPath ?? "public/favicon.svg"} + autoComplete="off" + spellCheck={false} + /> +
+
+ + + +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/Sidebar.test.ts b/apps/web/src/components/Sidebar.test.ts index e6eb1e7c..422eb121 100644 --- a/apps/web/src/components/Sidebar.test.ts +++ b/apps/web/src/components/Sidebar.test.ts @@ -14,6 +14,7 @@ describe("Sidebar file tree shortcut", () => { it("uses the project context menu for renaming instead of double click", () => { const src = readFileSync(resolve(import.meta.dirname, "./Sidebar.tsx"), "utf8"); + expect(src).toContain('{ id: "edit-icon", label: "Change project icon" }'); expect(src).toContain('{ id: "rename", label: "Rename project" }'); expect(src).toContain("onContextMenu={(event) => {"); expect(src).not.toContain("onDoubleClick={(e) => {"); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 63c92e7c..5f5a57bf 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 { ProjectIconEditorDialog } from "~/components/ProjectIconEditorDialog"; import { ProjectIcon } from "~/components/ProjectIcon"; import { useClientMode } from "~/hooks/useClientMode"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; @@ -67,6 +68,8 @@ import { useProjectTitleEditor } from "~/hooks/useProjectTitleEditor"; import { useTheme } from "~/hooks/useTheme"; import { useThreadTitleEditor } from "~/hooks/useThreadTitleEditor"; import { resolveImportedProjectScripts } from "~/lib/projectImport"; +import { normalizeProjectIconPath } from "~/lib/projectIcons"; +import { updateProjectIconOverride } from "~/lib/projectMeta"; import { getProjectColor } from "~/projectColors"; import { useRightPanelStore } from "~/rightPanelStore"; import { @@ -586,6 +589,10 @@ export default function Sidebar() { const [addProjectError, setAddProjectError] = useState(null); const [manualProjectPathEntry, setManualProjectPathEntry] = useState(false); const [cloneDialogOpen, setCloneDialogOpen] = useState(false); + const [projectIconDialogOpen, setProjectIconDialogOpen] = useState(false); + const [projectIconDialogProjectId, setProjectIconDialogProjectId] = useState( + null, + ); const addProjectInputRef = useRef(null); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet @@ -634,6 +641,9 @@ export default function Sidebar() { () => new Map(projects.map((project) => [project.id, project] as const)), [projects], ); + const projectIconDialogProject = projectIconDialogProjectId + ? (projectById.get(projectIconDialogProjectId) ?? null) + : null; const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], @@ -708,6 +718,18 @@ export default function Sidebar() { lastAutoExpandedThreadIdRef.current = routeThreadId; setProjectExpanded(activeProjectId, true); }, [activeProjectId, routeThreadId, setProjectExpanded]); + + useEffect(() => { + if (!projectIconDialogProjectId) { + return; + } + if (projectById.has(projectIconDialogProjectId)) { + return; + } + setProjectIconDialogOpen(false); + setProjectIconDialogProjectId(null); + }, [projectById, projectIconDialogProjectId]); + const threadGitTargets = useMemo( () => sidebarThreads.map((thread) => ({ @@ -1241,12 +1263,21 @@ export default function Sidebar() { if (!api) return; const clicked = await api.contextMenu.show( [ + { id: "edit-icon", label: "Change project icon" }, { id: "rename", label: "Rename project" }, { id: "delete", label: "Remove project", destructive: true }, ], position, ); + if (clicked === "edit-icon") { + if (projectById.has(projectId)) { + setProjectIconDialogProjectId(projectId); + setProjectIconDialogOpen(true); + } + return; + } + if (clicked === "rename") { const project = projectById.get(projectId); if (!project) return; @@ -1301,6 +1332,8 @@ export default function Sidebar() { clearProjectDraftThreadId, getDraftThreadByProjectId, projectById, + setProjectIconDialogOpen, + setProjectIconDialogProjectId, sortedThreadsByProjectId, startProjectEditing, ], @@ -1872,10 +1905,37 @@ export default function Sidebar() { }); }, []); + const saveProjectIconOverrideFromDialog = useCallback( + async (iconPath: string | null) => { + if (!projectIconDialogProject) { + return; + } + const api = readNativeApi(); + if (!api) { + return; + } + + const currentIconPath = normalizeProjectIconPath(projectIconDialogProject.iconPath); + const nextIconPath = normalizeProjectIconPath(iconPath); + if (currentIconPath === nextIconPath) { + return; + } + + await updateProjectIconOverride(api, projectIconDialogProject.id, nextIconPath); + }, + [projectIconDialogProject], + ); + const wordmark = ; return ( <> + {isElectron ? ( <> diff --git a/apps/web/src/lib/projectIcons.test.ts b/apps/web/src/lib/projectIcons.test.ts new file mode 100644 index 00000000..a16cd276 --- /dev/null +++ b/apps/web/src/lib/projectIcons.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import type { NativeApi } from "@okcode/contracts"; + +import { normalizeProjectIconPath, resolveSuggestedProjectIconPath } from "./projectIcons"; + +describe("project icon helpers", () => { + it("normalizes icon paths by trimming and treating blanks as null", () => { + expect(normalizeProjectIconPath(" public/icon.svg ")).toBe("public/icon.svg"); + expect(normalizeProjectIconPath(" ")).toBeNull(); + expect(normalizeProjectIconPath(null)).toBeNull(); + }); + + it("prefers the first well-known fallback candidate that exists in the workspace", async () => { + const searchEntries = vi.fn(async ({ query }: { query: string }) => { + if (query === "favicon") { + return { + entries: [], + truncated: false, + }; + } + + return { + entries: [ + { path: "assets/logo.svg", kind: "file" }, + { path: "public/favicon.ico", kind: "file" }, + ], + truncated: false, + }; + }); + + const api = { projects: { searchEntries } } as unknown as Pick; + const suggestion = await resolveSuggestedProjectIconPath(api, "/repo"); + + expect(suggestion).toBe("public/favicon.ico"); + expect(searchEntries).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/lib/projectIcons.ts b/apps/web/src/lib/projectIcons.ts index cfc3964e..94b0f259 100644 --- a/apps/web/src/lib/projectIcons.ts +++ b/apps/web/src/lib/projectIcons.ts @@ -1,3 +1,9 @@ +import type { NativeApi } from "@okcode/contracts"; +import { + PROJECT_ICON_DISCOVERY_QUERIES, + PROJECT_ICON_FALLBACK_CANDIDATES, +} from "@okcode/shared/projectIcons"; + export function resolveProjectIconUrl(input: { cwd: string; iconPath?: string | null | undefined; @@ -9,3 +15,31 @@ export function resolveProjectIconUrl(input: { } return `/api/project-favicon?${searchParams.toString()}`; } + +export function normalizeProjectIconPath(input: string | null | undefined): string | null { + const trimmed = input?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +export async function resolveSuggestedProjectIconPath( + api: Pick, + cwd: string, +): Promise { + for (const query of PROJECT_ICON_DISCOVERY_QUERIES) { + const result = await api.projects.searchEntries({ + cwd, + query, + limit: 80, + }); + const candidatePaths = new Set( + result.entries.filter((entry) => entry.kind === "file").map((entry) => entry.path), + ); + for (const candidate of PROJECT_ICON_FALLBACK_CANDIDATES) { + if (candidatePaths.has(candidate)) { + return candidate; + } + } + } + + return null; +} diff --git a/apps/web/src/lib/projectMeta.ts b/apps/web/src/lib/projectMeta.ts new file mode 100644 index 00000000..d4a38ed4 --- /dev/null +++ b/apps/web/src/lib/projectMeta.ts @@ -0,0 +1,17 @@ +import type { NativeApi, ProjectId } from "@okcode/contracts"; + +import { newCommandId } from "./utils"; +import { normalizeProjectIconPath } from "./projectIcons"; + +export async function updateProjectIconOverride( + api: NativeApi, + projectId: ProjectId, + iconPath: string | null | undefined, +): Promise { + await api.orchestration.dispatchCommand({ + type: "project.meta.update", + commandId: newCommandId(), + projectId, + iconPath: normalizeProjectIconPath(iconPath), + }); +} diff --git a/apps/web/src/routes/_chat.settings.index.tsx b/apps/web/src/routes/_chat.settings.index.tsx index e02574dd..d778a26a 100644 --- a/apps/web/src/routes/_chat.settings.index.tsx +++ b/apps/web/src/routes/_chat.settings.index.tsx @@ -63,12 +63,14 @@ import { globalEnvironmentVariablesQueryOptions, projectEnvironmentVariablesQueryOptions, } from "../lib/environmentVariablesReactQuery"; +import { normalizeProjectIconPath } from "../lib/projectIcons"; +import { updateProjectIconOverride } from "../lib/projectMeta"; import { getSelectableThreadProviders, isProviderReadyForThreadSelection, } from "../lib/providerAvailability"; import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; -import { cn, newCommandId } from "../lib/utils"; +import { cn } from "../lib/utils"; import { ensureNativeApi } from "../nativeApi"; import { useStore } from "../store"; import { PairingLink } from "../components/mobile/PairingLink"; @@ -672,19 +674,14 @@ function SettingsRouteView() { if (!selectedProject) { throw new Error("Select a project before saving the project icon."); } - const nextIconPath = projectIconDraft.trim(); - const currentIconPath = selectedProject.iconPath ?? ""; + const nextIconPath = normalizeProjectIconPath(projectIconDraft); + const currentIconPath = normalizeProjectIconPath(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, - }); + await updateProjectIconOverride(api, selectedProject.id, nextIconPath); }, [projectIconDraft, selectedProject]); const testOpenclawGateway = useCallback(async () => { diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index c6568e9c..5cc88014 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -87,6 +87,8 @@ import { globalEnvironmentVariablesQueryOptions, projectEnvironmentVariablesQueryOptions, } from "../lib/environmentVariablesReactQuery"; +import { normalizeProjectIconPath } from "../lib/projectIcons"; +import { updateProjectIconOverride } from "../lib/projectMeta"; import { applyCustomTheme, clearFontOverride, @@ -114,7 +116,7 @@ import { serverConfigQueryOptions, serverQueryKeys, } from "../lib/serverReactQuery"; -import { cn, newCommandId } from "../lib/utils"; +import { cn } from "../lib/utils"; import { ensureNativeApi, readNativeApi } from "../nativeApi"; import { useStore } from "../store"; import { PairingLink } from "../components/mobile/PairingLink"; @@ -1123,19 +1125,14 @@ function SettingsRouteView() { if (!selectedProject) { throw new Error("Select a project before saving the project icon."); } - const nextIconPath = projectIconDraft.trim(); - const currentIconPath = selectedProject.iconPath ?? ""; + const nextIconPath = normalizeProjectIconPath(projectIconDraft); + const currentIconPath = normalizeProjectIconPath(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, - }); + await updateProjectIconOverride(api, selectedProject.id, nextIconPath); }, [projectIconDraft, selectedProject]); const testOpenclawGateway = useCallback(async () => { diff --git a/packages/shared/package.json b/packages/shared/package.json index 7c2288fd..c8e132c4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -63,6 +63,10 @@ "./keybindings": { "types": "./src/keybindings.ts", "import": "./src/keybindings.ts" + }, + "./projectIcons": { + "types": "./src/projectIcons.ts", + "import": "./src/projectIcons.ts" } }, "scripts": { diff --git a/packages/shared/src/projectIcons.ts b/packages/shared/src/projectIcons.ts new file mode 100644 index 00000000..8f8219a6 --- /dev/null +++ b/packages/shared/src/projectIcons.ts @@ -0,0 +1,24 @@ +export const PROJECT_ICON_FALLBACK_CANDIDATES = [ + "favicon.svg", + "favicon.ico", + "favicon.png", + "public/favicon.svg", + "public/favicon.ico", + "public/favicon.png", + "app/favicon.ico", + "app/favicon.png", + "app/icon.svg", + "app/icon.png", + "app/icon.ico", + "src/favicon.ico", + "src/favicon.svg", + "src/app/favicon.ico", + "src/app/icon.svg", + "src/app/icon.png", + "assets/icon.svg", + "assets/icon.png", + "assets/logo.svg", + "assets/logo.png", +] as const; + +export const PROJECT_ICON_DISCOVERY_QUERIES = ["favicon", "icon", "logo"] as const; From a427c52c5598733adbbe5c6b7cef45c017a244af Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 13 Apr 2026 22:47:36 -0500 Subject: [PATCH 2/2] Bump workspace versions to 0.23.3 - Update all package manifests recorded in bun.lock - Keep workspace versions aligned across apps and packages --- bun.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bun.lock b/bun.lock index 64e27a6d..d9adbe28 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,7 @@ }, "apps/desktop": { "name": "@okcode/desktop", - "version": "0.23.1", + "version": "0.23.3", "dependencies": { "effect": "catalog:", "electron": "40.6.0", @@ -103,7 +103,7 @@ }, "apps/mobile": { "name": "@okcode/mobile", - "version": "0.23.1", + "version": "0.23.3", "dependencies": { "@capacitor/android": "^8.3.0", "@capacitor/app": "^8.1.0", @@ -123,7 +123,7 @@ }, "apps/server": { "name": "okcodes", - "version": "0.23.1", + "version": "0.23.3", "bin": { "okcode": "./dist/index.mjs", }, @@ -155,7 +155,7 @@ }, "apps/web": { "name": "@okcode/web", - "version": "0.23.1", + "version": "0.23.3", "dependencies": { "@base-ui/react": "^1.2.0", "@codemirror/language": "^6.12.3", @@ -218,7 +218,7 @@ }, "packages/contracts": { "name": "@okcode/contracts", - "version": "0.23.1", + "version": "0.23.3", "dependencies": { "effect": "catalog:", },