diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx
index ec0793c540e..57370f5d68e 100644
--- a/packages/app/src/components/dialog-edit-project.tsx
+++ b/packages/app/src/components/dialog-edit-project.tsx
@@ -7,7 +7,8 @@ import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
-import { type LocalProject, getAvatarColors } from "@/context/layout"
+import { type LocalProject } from "@/context/layout"
+import { getAvatarColors } from "@/context/project-avatar"
import { getFilename } from "@opencode-ai/util/path"
import { Avatar } from "@opencode-ai/ui/avatar"
import { useLanguage } from "@/context/language"
@@ -26,7 +27,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
const [store, setStore] = createStore({
name: defaultName(),
color: props.project.icon?.color || "pink",
- iconUrl: props.project.icon?.override || "",
+ iconUrl: props.project.icon?.url || props.project.icon?.override || "",
startup: props.project.commands?.start ?? "",
saving: false,
dragOver: false,
@@ -83,9 +84,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
if (props.project.id && props.project.id !== "global") {
await globalSDK.client.project.update({
projectID: props.project.id,
- directory: props.project.worktree,
name,
- icon: { color: store.color, override: store.iconUrl },
+ icon: { color: store.color, url: store.iconUrl },
commands: { start },
})
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx
index e4ef3639362..8b2c6c0c19c 100644
--- a/packages/app/src/components/session/session-new-view.tsx
+++ b/packages/app/src/components/session/session-new-view.tsx
@@ -3,8 +3,9 @@ import { DateTime } from "luxon"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { useLanguage } from "@/context/language"
+import { getAvatarColors, pickProjectIconSrc } from "@/context/project-avatar"
import { Icon } from "@opencode-ai/ui/icon"
-import { Mark } from "@opencode-ai/ui/logo"
+import { Avatar } from "@opencode-ai/ui/avatar"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
const MAIN_WORKTREE = "main"
@@ -20,6 +21,11 @@ export function NewSessionView(props: NewSessionViewProps) {
const sdk = useSDK()
const language = useLanguage()
+ const iconSrc = createMemo(() =>
+ pickProjectIconSrc({ id: sync.project?.id, icon: sync.project?.icon, fallback: sync.data.icon }),
+ )
+ const color = createMemo(() => sync.project?.icon?.color)
+
const sandboxes = createMemo(() => sync.project?.sandboxes ?? [])
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
const current = createMemo(() => {
@@ -53,7 +59,12 @@ export function NewSessionView(props: NewSessionViewProps) {
-
+
{language.t("session.new.title")}
diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts
index 13494b7ade0..9681f072556 100644
--- a/packages/app/src/context/global-sync/bootstrap.ts
+++ b/packages/app/src/context/global-sync/bootstrap.ts
@@ -123,7 +123,11 @@ export async function bootstrapDirectory(input: {
if (input.store.status !== "complete") input.setStore("status", "loading")
const blockingRequests = {
- project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
+ project: () =>
+ input.sdk.project.current().then((x) => {
+ input.setStore("project", x.data!.id)
+ input.setStore("icon", x.data?.icon?.url ?? x.data?.icon?.override)
+ }),
provider: () =>
input.sdk.provider.list().then((x) => {
input.setStore("provider", normalizeProviderList(x.data!))
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx
index 78928118d72..9ffa2f05e0f 100644
--- a/packages/app/src/context/layout.tsx
+++ b/packages/app/src/context/layout.tsx
@@ -11,25 +11,11 @@ import { decode64 } from "@/utils/base64"
import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
import { createPathHelpers } from "./file/path"
+import { type AvatarColorKey, pickAvailableColor, pickProjectIcon } from "./project-avatar"
-const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
const DEFAULT_PANEL_WIDTH = 344
const DEFAULT_SESSION_WIDTH = 600
const DEFAULT_TERMINAL_HEIGHT = 280
-export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
-
-export function getAvatarColors(key?: string) {
- if (key && AVATAR_COLOR_KEYS.includes(key as AvatarColorKey)) {
- return {
- background: `var(--avatar-background-${key})`,
- foreground: `var(--avatar-text-${key})`,
- }
- }
- return {
- background: "var(--surface-info-base)",
- foreground: "var(--text-base)",
- }
-}
type SessionTabs = {
active?: string
@@ -378,12 +364,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const [colors, setColors] = createStore
>({})
const colorRequested = new Map()
- function pickAvailableColor(used: Set): AvatarColorKey {
- const available = AVATAR_COLOR_KEYS.filter((c) => !used.has(c))
- if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)]
- return available[Math.floor(Math.random() * available.length)]
- }
-
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = globalSync.child(project.worktree, { bootstrap: false })
const projectID = childStore.project
@@ -402,8 +382,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
...(metadata ?? {}),
...project,
icon: {
- url: metadata?.icon?.url,
- override: metadata?.icon?.override ?? childStore.icon,
+ ...pickProjectIcon({ child: childStore.icon, meta: metadata?.icon }),
color: metadata?.icon?.color,
},
}
@@ -424,60 +403,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
}
- const roots = createMemo(() => {
- const map = new Map()
- for (const project of globalSync.data.project) {
- const sandboxes = project.sandboxes ?? []
- for (const sandbox of sandboxes) {
- map.set(sandbox, project.worktree)
- }
- }
- return map
- })
-
- const rootFor = (directory: string) => {
- const map = roots()
- if (map.size === 0) return directory
-
- const visited = new Set()
- const chain = [directory]
-
- while (chain.length) {
- const current = chain[chain.length - 1]
- if (!current) return directory
-
- const next = map.get(current)
- if (!next) return current
-
- if (visited.has(next)) return directory
- visited.add(next)
- chain.push(next)
- }
-
- return directory
- }
-
- createEffect(() => {
- const projects = server.projects.list()
- const seen = new Set(projects.map((project) => project.worktree))
-
- batch(() => {
- for (const project of projects) {
- const root = rootFor(project.worktree)
- if (root === project.worktree) continue
-
- server.projects.close(project.worktree)
-
- if (!seen.has(root)) {
- server.projects.open(root)
- seen.add(root)
- }
-
- if (project.expanded) server.projects.expand(root)
- }
- })
- })
-
const enriched = createMemo(() => server.projects.list().map(enrich))
const list = createMemo(() => {
const projects = enriched()
@@ -497,7 +422,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
for (const project of projects) {
if (!project.id) continue
if (project.id === "global") continue
- globalSync.project.icon(project.worktree, project.icon?.override)
+ globalSync.project.icon(project.worktree, project.icon?.url ?? project.icon?.override)
}
})
@@ -566,10 +491,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
projects: {
list,
open(directory: string) {
- const root = rootFor(directory)
- if (server.projects.list().find((x) => x.worktree === root)) return
- globalSync.project.loadSessions(root)
- server.projects.open(root)
+ if (server.projects.list().find((x) => x.worktree === directory)) return
+ globalSync.project.loadSessions(directory)
+ server.projects.open(directory)
},
close(directory: string) {
server.projects.close(directory)
diff --git a/packages/app/src/context/project-avatar.test.ts b/packages/app/src/context/project-avatar.test.ts
new file mode 100644
index 00000000000..f11abf167c5
--- /dev/null
+++ b/packages/app/src/context/project-avatar.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, test } from "bun:test"
+import { pickProjectIcon } from "./project-avatar"
+
+describe("pickProjectIcon", () => {
+ test("prefers child icon over metadata", () => {
+ const icon = pickProjectIcon({
+ child: "child-icon",
+ meta: { url: "root-url", override: "root-override" },
+ })
+
+ expect(icon.url).toBe("child-icon")
+ expect(icon.override).toBe("child-icon")
+ })
+
+ test("falls back to metadata url then override", () => {
+ const fromUrl = pickProjectIcon({
+ meta: { url: "root-url", override: "root-override" },
+ })
+ const fromOverride = pickProjectIcon({
+ meta: { override: "root-override" },
+ })
+
+ expect(fromUrl.url).toBe("root-url")
+ expect(fromUrl.override).toBe("root-override")
+ expect(fromOverride.url).toBe("root-override")
+ expect(fromOverride.override).toBe("root-override")
+ })
+})
diff --git a/packages/app/src/context/project-avatar.ts b/packages/app/src/context/project-avatar.ts
new file mode 100644
index 00000000000..d82752f4128
--- /dev/null
+++ b/packages/app/src/context/project-avatar.ts
@@ -0,0 +1,39 @@
+export const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
+
+export const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
+
+export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
+
+export function getAvatarColors(key?: string) {
+ if (key && AVATAR_COLOR_KEYS.includes(key as AvatarColorKey)) {
+ return {
+ background: `var(--avatar-background-${key})`,
+ foreground: `var(--avatar-text-${key})`,
+ }
+ }
+ return {
+ background: "var(--surface-info-base)",
+ foreground: "var(--text-base)",
+ }
+}
+
+export function pickAvailableColor(used: Set): AvatarColorKey {
+ const available = AVATAR_COLOR_KEYS.filter((key) => !used.has(key))
+ if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)]
+ return available[Math.floor(Math.random() * available.length)]
+}
+
+export function pickProjectIcon(input: { child?: string; meta?: { url?: string; override?: string } }) {
+ const url = input.child ?? input.meta?.url ?? input.meta?.override
+ const override = input.child ?? input.meta?.override ?? input.meta?.url
+ return { url, override }
+}
+
+export function pickProjectIconSrc(input: {
+ id?: string
+ icon?: { url?: string; override?: string }
+ fallback?: string
+}) {
+ if (input.id === OPENCODE_PROJECT_ID) return "https://opencode.ai/favicon.svg"
+ return input.icon?.url ?? input.icon?.override ?? input.fallback
+}
diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx
index f8e16f3e122..24fa5016cef 100644
--- a/packages/app/src/pages/layout/sidebar-items.tsx
+++ b/packages/app/src/pages/layout/sidebar-items.tsx
@@ -12,15 +12,14 @@ import { A, useNavigate, useParams } from "@solidjs/router"
import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
-import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
+import { type LocalProject, useLayout } from "@/context/layout"
+import { getAvatarColors, pickProjectIconSrc } from "@/context/project-avatar"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { hasProjectPermissions } from "./helpers"
-const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
-
export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {
const globalSync = useGlobalSync()
const notification = useNotification()
@@ -43,9 +42,7 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
x.trim())
+ .catch(() => undefined)
+ if (!text) return
+ if (text.startsWith("data:")) return text
+ if (text.startsWith("http://") || text.startsWith("https://")) return text
+ const line = text
+ .split(/\r?\n/)
+ .map((x) => x.trim())
+ .find((x) => x.toUpperCase().startsWith("URL="))
+ if (!line) return
+ const url = line.slice(4).trim()
+ if (!url.startsWith("data:") && !url.startsWith("http://") && !url.startsWith("https://")) return
+ return url
+ }
+
+ async function iconData(file: string) {
+ if (path.extname(file).toLowerCase() === ".url") return iconURL(file)
+ const mime = Filesystem.mimeType(file)
+ if (!mime.startsWith("image/")) return
+ const buffer = await Filesystem.readBytes(file)
+ const base64 = buffer.toString("base64")
+ return `data:${mime};base64,${base64}`
+ }
+
+ async function configuredIcon(worktree: string) {
+ const files = await Glob.scan(".opencode/icon/**/*", {
+ cwd: worktree,
+ absolute: true,
+ include: "file",
+ })
+
+ for (const file of files.toSorted(sortPath)) {
+ const url = await iconData(file).catch(() => undefined)
+ if (!url) continue
+ return { url }
+ }
+
+ const roots = await Glob.scan(".opencode/icon.{ico,png,svg,jpg,jpeg,webp,avif,gif,url}", {
+ cwd: worktree,
+ absolute: true,
+ include: "file",
+ })
+
+ for (const file of roots.toSorted(sortPath)) {
+ const url = await iconData(file).catch(() => undefined)
+ if (!url) continue
+ return { url }
+ }
+
+ const favicons = await Glob.scan(".opencode/**/favicon.{ico,png,svg,jpg,jpeg,webp,avif,gif,url}", {
+ cwd: worktree,
+ absolute: true,
+ include: "file",
+ })
+
+ for (const file of favicons.toSorted(sortPath)) {
+ const url = await iconData(file).catch(() => undefined)
+ if (!url) continue
+ return { url }
+ }
+ }
+
export const Info = z
.object({
id: ProjectID.zod,
@@ -70,7 +139,7 @@ export namespace Project {
export function fromRow(row: Row): Info {
const icon =
row.icon_url || row.icon_color
- ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined }
+ ? { url: row.icon_url ?? undefined, override: row.icon_url ?? undefined, color: row.icon_color ?? undefined }
: undefined
return {
id: ProjectID.make(row.id),
@@ -231,10 +300,33 @@ export namespace Project {
},
}
- if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing)
+ const icon = await configuredIcon(directory)
+ .then(async (item) => {
+ if (item) return item
+ if (directory === data.worktree) return
+ return configuredIcon(data.worktree)
+ })
+ .then((item) => {
+ if (!item) return existing.icon
+ return {
+ ...existing.icon,
+ ...item,
+ override: item.url ?? existing.icon?.override,
+ }
+ })
+ .catch((error) => {
+ log.warn("failed to load project icon from .opencode", { error, directory, worktree: data.worktree })
+ return existing.icon
+ })
+ const seeded = {
+ ...existing,
+ icon,
+ }
+
+ if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(seeded)
const result: Info = {
- ...existing,
+ ...seeded,
worktree: data.worktree,
vcs: data.vcs as Info["vcs"],
time: {
@@ -250,7 +342,7 @@ export namespace Project {
worktree: result.worktree,
vcs: result.vcs ?? null,
name: result.name,
- icon_url: result.icon?.url,
+ icon_url: result.icon?.url ?? result.icon?.override,
icon_color: result.icon?.color,
time_created: result.time.created,
time_updated: result.time.updated,
@@ -262,7 +354,7 @@ export namespace Project {
worktree: result.worktree,
vcs: result.vcs ?? null,
name: result.name,
- icon_url: result.icon?.url,
+ icon_url: result.icon?.url ?? result.icon?.override,
icon_color: result.icon?.color,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
@@ -297,24 +389,28 @@ export namespace Project {
if (input.vcs !== "git") return
if (input.icon?.override) return
if (input.icon?.url) return
- const matches = await Glob.scan("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
+ const preferred = await Glob.scan(".opencode/**/favicon.{ico,png,svg,jpg,jpeg,webp,avif,gif,url}", {
cwd: input.worktree,
absolute: true,
include: "file",
})
- const shortest = matches.sort((a, b) => a.length - b.length)[0]
- if (!shortest) return
- const buffer = await Filesystem.readBytes(shortest)
- const base64 = buffer.toString("base64")
- const mime = Filesystem.mimeType(shortest) || "image/png"
- const url = `data:${mime};base64,${base64}`
- await update({
- projectID: input.id,
- icon: {
- url,
- },
+ const discovered = await Glob.scan("**/favicon.{ico,png,svg,jpg,jpeg,webp,avif,gif,url}", {
+ cwd: input.worktree,
+ absolute: true,
+ include: "file",
})
- return
+ const files = [...preferred.toSorted(sortPath), ...discovered.toSorted(sortPath)]
+ for (const file of files) {
+ const url = await iconData(file).catch(() => undefined)
+ if (!url) continue
+ await update({
+ projectID: input.id,
+ icon: {
+ url,
+ },
+ })
+ return
+ }
}
export function setInitialized(id: ProjectID) {
@@ -374,7 +470,7 @@ export namespace Project {
.update(ProjectTable)
.set({
name: input.name,
- icon_url: input.icon?.url,
+ icon_url: input.icon?.url ?? input.icon?.override,
icon_color: input.icon?.color,
commands: input.commands,
time_updated: Date.now(),
diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts
index a71fe0528f0..08873a46b9e 100644
--- a/packages/opencode/test/project/project.test.ts
+++ b/packages/opencode/test/project/project.test.ts
@@ -246,9 +246,54 @@ describe("Project.fromDirectory with worktrees", () => {
.catch(() => {})
}
})
+
+ test("should prefer sandbox .opencode icon over worktree icon", async () => {
+ const p = await loadProject()
+ await using tmp = await tmpdir({ git: true })
+ const child = path.join(tmp.path, "child")
+ const root = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x01, 0x02, 0x03, 0x04])
+ const local = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x09, 0x08, 0x07, 0x06])
+
+ await Filesystem.write(path.join(tmp.path, ".opencode", "icon.png"), root)
+ await Filesystem.write(path.join(child, ".opencode", "icon.png"), local)
+
+ const { project } = await p.fromDirectory(child)
+
+ expect(project.worktree).toBe(tmp.path)
+ expect(project.icon?.url).toContain(local.toString("base64"))
+ expect(project.icon?.url).not.toContain(root.toString("base64"))
+ })
})
describe("Project.discover", () => {
+ test("should prefer icon from .opencode/icon", async () => {
+ const p = await loadProject()
+ const iconData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0xaa, 0xbb, 0xcc])
+ await using tmp = await tmpdir({ git: true })
+ await Filesystem.write(path.join(tmp.path, ".opencode", "icon", "project-icon.png"), iconData)
+ await Bun.write(path.join(tmp.path, "favicon.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47]))
+
+ const { project } = await p.fromDirectory(tmp.path)
+
+ expect(project.icon?.url).toContain(iconData.toString("base64"))
+ expect(project.icon?.override).toContain(iconData.toString("base64"))
+ expect(project.icon?.color).toBeUndefined()
+ })
+
+ test("should prefer favicon under .opencode", async () => {
+ const p = await loadProject()
+ const localData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x11, 0x22, 0x33, 0x44])
+ await using tmp = await tmpdir({ git: true })
+ await Filesystem.write(path.join(tmp.path, ".opencode", "assets", "favicon.png"), localData)
+ await Bun.write(path.join(tmp.path, "favicon.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47]))
+
+ const { project } = await p.fromDirectory(tmp.path)
+
+ expect(project.icon?.url).toContain(localData.toString("base64"))
+ expect(project.icon?.override).toContain(localData.toString("base64"))
+ expect(project.icon?.color).toBeUndefined()
+ })
+
test("should discover favicon.png in root", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
@@ -328,6 +373,23 @@ describe("Project.update", () => {
expect(fromDb?.icon?.color).toBe("#ff0000")
})
+ test("should map icon override to stored url", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const { project } = await Project.fromDirectory(tmp.path)
+
+ const updated = await Project.update({
+ projectID: project.id,
+ icon: { override: "https://example.com/override.png" },
+ })
+
+ expect(updated.icon?.url).toBe("https://example.com/override.png")
+ expect(updated.icon?.override).toBe("https://example.com/override.png")
+
+ const fromDb = Project.get(project.id)
+ expect(fromDb?.icon?.url).toBe("https://example.com/override.png")
+ expect(fromDb?.icon?.override).toBe("https://example.com/override.png")
+ })
+
test("should update commands", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)