Skip to content

feat(app): discover project icons and names automatically - #56698

Open
kitlangton wants to merge 3 commits into
betafrom
project-icons
Open

kitlangton wants to merge 3 commits into
betafrom
project-icons

Conversation

@kitlangton

@kitlangton kitlangton commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Why

V1 found project icons automatically. That was lost when packages/opencode was removed for V2: project.icon_url is still read, but nothing writes it, so every project in the desktop sidebar is a gray single-letter tile. Names are often wrong too. A project whose first clone lives in an oddly named folder keeps that folder's name: an old voice-control clone makes Hex show "V", and a main worktree folder named rewrite makes team-board show "R". The built-in OpenCode icon is also broken, because it compares against the V1 root-commit project ID.

This builds on @Brendonovich's #45218, which added icon candidate discovery and a picker in project settings. It was never reviewed and was closed as stale. His commit is kept here with his authorship, ported to the current beta.

What Changes

Automatic icon. The server finds a project's icon in the background, stores it with the project, and lists it in /api/project. Discovery takes the first match in this order:

Rank Source Examples
0 icon in the project's opencode.json(c) or .opencode/opencode.json(c) (path or https:/data: URL) { "icon": "./brand/mark.png" }
1 App icons largest PNG in *.appiconset, the front layer of an Icon Composer *.icon (from icon.json), app-icon.*
2 Desktop build icons build/icon.{svg,png}, src-tauri/icons/*, resources/icon.*, icons/icon.*
3 Web icons favicon*.{svg,png,ico}, apple-touch-icon*.png, Next app/icon.*
4 Logos logo.* / icon.* at the root or in assets/, public/, static/, images/, docs/, .github/…

Within a rank, an SVG wins, then a larger square raster, then the shallower path. Discovery is bounded:

  • It lists directories at most 4 levels deep and reads at most 500 directories.
  • It skips dot directories (except .github) plus dependency, build-output and fixture trees such as node_modules, dist, target, Pods, test, fixtures and artifacts. build/ is never listed, but build/icon.* is checked.
  • It ignores files over 1 MB.

On my real repos, discovery took 1–35 ms.

Your choice always wins. On the client, an uploaded or picked override comes first, then a chosen color tile, then the discovered icon. Discovery writes only its own column and never touches the override or the color.

Picker. Project settings shows a "Detected icons" row (from #45218). It has an "Automatic" choice first, followed by up to 12 ranked candidates. Picking a candidate saves it as the override. Picking Automatic clears the override and the color.

Name from the git remote. Projects get a repository field taken from the origin remote. Clients display name, then repository, then the folder name:

origin repository
git@github.com:acme/team-board.git team-board
https://github.com/acme/hex hex
ssh://git@gitlab.com/group/sub/tool.git/ tool
file:///tmp/repo.git or no remote (none; the folder name is used)

Fallback tile. A project with no icon and no chosen color gets a stable color derived from its ID instead of gray. It uses the existing avatar color variants, excluding gray. With no color chosen, the color picker no longer shows gray as selected.

OpenCode icon. The OpenCode project is now matched by the V2 IDs for github.com/anomalyco/opencode and github.com/sst/opencode. The V1 ID is kept. A user's override now beats the built-in OpenCode icon.

Icon Delivery

/api/project only adds fields; the existing response shape is unchanged:

{
  "id": "…",
  "canonical": "/src/team-board/rewrite",
  "repository": "team-board",            // new
  "icon": {
    "override": "data:…",                // unchanged (user upload / picked candidate)
    "color": "blue",                     // unchanged
    "discovered": {                      // new
      "url": "/api/project/<id>/icon?v=<content-hash>",
      "path": "public/favicon.svg"
    }
  }
}

GET /api/project/:projectID/icon serves the discovered file's bytes with its content type and ETag: "<hash>".

  • A matching ?v= gets Cache-Control: private, max-age=31536000, immutable. Any other request gets private, no-cache. If-None-Match returns 304.
  • The route needs the same authentication as the rest of the API. Desktop fetches it through the client and shows it from an object URL. Native clients can fetch it relative to the server URL and cache it by URL.
  • If icon in the project config is an absolute URL, discovered.url is that URL.
sequenceDiagram
  participant C as Client
  participant S as Server
  participant P as Project service
  C->>S: open a location
  S->>P: resolve(directory)
  P-->>P: fork discovery (at most once per 10 min per project)
  P->>P: store best candidate + content hash
  P-->>C: project.updated (icon.discovered)
  C->>S: GET /api/project/:id/icon?v=hash
  S-->>C: bytes, ETag, immutable cache
Loading

Refresh. Discovery runs:

  • in the background when a project is resolved, at most once every 10 minutes per project;
  • for every unarchived project 5 seconds after startup, then hourly;
  • when the icon route finds the stored file changed or missing.

It never blocks a request. Changes go out through the existing ephemeral project.updated event, and no new durable events are added. A migration adds two nullable columns: project.repository and project.icon_discovered.

Demo

Headless Chromium through the app's Playwright mock harness. The fixture projects are fictional: an Xcode app (orbit), an Electron app (lantern), a web app whose clone sits in old-clone (meadow), a plain repo with no icon (quill), and a repo whose main worktree folder is rewrite (tidepool).

Before (beta):

After:

Project settings picker:

Scope

This PR covers server discovery, the wire fields, the icon route, and the desktop. iOS will read repository and icon.discovered separately. Two things are left for follow-ups: compositing Icon Composer layers over their fill, and reading inline <link rel="icon"> from index.html. A git@host:path remote without a user (host:path) still has no repository name, because the project-ID parser rejects it and changing that would change existing project IDs.

Verification

cd packages/core && bun test test/project-icon.test.ts test/project.test.ts   # 38 pass
cd packages/server && bun test test/project-icon.test.ts                      # 1 pass (real server: list → icon bytes, ETag, 304, cache headers)
cd packages/client && bun test test/promise.test.ts                           # 41 pass
cd packages/ui && bun test src/data-display/project-avatar                    # 4 pass
cd packages/app && bun test src/runtime/server/image.test.ts                  # 41 pass
bun typecheck   # in core, server, protocol, schema, client, ui, app
bun run lint:changed origin/beta
cd packages/app && bunx playwright test e2e/regression/settings.spec.ts e2e/regression/settings-search.spec.ts e2e/regression/home.spec.ts:222 e2e/regression/multi-server.spec.ts:223   # 59 pass (mock server now answers /api/project/icons)
  • Core: tests cover ranking fixtures (Xcode appiconset, Icon Composer front layer, Tauri sizes, Electron build/icon.png, Next app/icon.png, public/favicon.svg vs .ico, logos), the config override (path, URL, path outside the project), the 1 MB cap, the depth limit, skipped dependency and fixture trees, remote-name parsing, and user choices surviving discovery and reset.
  • UI: tests cover source precedence, the V2 OpenCode ID, the derived color and the name fallback.
  • Lint: lint:changed reports 5 warnings, all on lines that already existed (typeof checks and an unannotated assertion in touched files). None are from this change.
  • Real repos: ran discovery read-only against local repos.
  • CI: all 17 checks pass.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants