From 78cd98bd1171da1ea2e1f9c2adab02b0802e7657 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 19:26:44 -0700 Subject: [PATCH 1/9] feat(web): project icons can be chosen manually --- README.md | 1 + apps/mobile/src/components/ProjectFavicon.tsx | 7 +- .../archive/ArchivedThreadsScreen.tsx | 1 + .../features/threads/NewTaskRouteScreen.tsx | 2 + .../features/threads/thread-list-items.tsx | 1 + .../features/threads/thread-list-v2-items.tsx | 3 + apps/server/src/assets/AssetAccess.test.ts | 22 +++ apps/server/src/assets/AssetAccess.ts | 23 +-- .../Layers/ProjectionPipeline.test.ts | 6 +- .../Layers/ProjectionPipeline.ts | 4 + .../Layers/ProjectionSnapshotQuery.ts | 7 + .../decider.projectScripts.test.ts | 41 +++++ apps/server/src/orchestration/decider.ts | 2 + apps/server/src/orchestration/projector.ts | 4 + .../persistence/Layers/ProjectionProjects.ts | 5 + apps/server/src/persistence/Migrations.ts | 2 + .../040_ProjectionProjectFaviconPath.test.ts | 28 ++++ .../040_ProjectionProjectFaviconPath.ts | 16 ++ .../Services/ProjectionProjects.ts | 1 + .../project/ProjectFaviconResolver.test.ts | 26 ++++ .../src/project/ProjectFaviconResolver.ts | 9 +- apps/web/src/assets/assetUrls.ts | 10 +- apps/web/src/components/ChatView.tsx | 1 + apps/web/src/components/CommandPalette.tsx | 1 + apps/web/src/components/LegacySidebar.tsx | 6 +- .../src/components/ProjectFavicon.test.tsx | 20 ++- apps/web/src/components/ProjectFavicon.tsx | 21 ++- apps/web/src/components/Sidebar.tsx | 29 ++++ apps/web/src/components/chat/ChatHeader.tsx | 3 + .../settings/ProjectFaviconPickerDialog.tsx | 106 +++++++++++++ .../settings/ProjectSettingsPanel.tsx | 140 +++++++++++++++++- .../components/settings/SettingsPanels.tsx | 9 +- docs/README.md | 1 + docs/user/project-settings.md | 16 ++ .../client-runtime/src/state/assets.test.ts | 12 ++ packages/contracts/src/assets.ts | 4 + packages/contracts/src/orchestration.ts | 11 ++ 37 files changed, 577 insertions(+), 24 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts create mode 100644 apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.ts create mode 100644 apps/web/src/components/settings/ProjectFaviconPickerDialog.tsx create mode 100644 docs/user/project-settings.md diff --git a/README.md b/README.md index 1e9b0517945..c2349e72860 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ Full docs live in [docs/](./docs). There's no docs site yet. - [Install and first run](./docs/user/install.md) - [Permission modes](./docs/user/permission-modes.md) - [Keyboard shortcuts](./docs/user/keybindings.md) +- [Customize a project icon](./docs/user/project-settings.md) - [Remote access from a phone or another machine](./docs/user/remote-access.md) - [Keeping app and server in sync](./docs/user/updating.md) - [Source control integrations](./docs/user/source-control.md) diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index d52aa05b446..c4297f24b09 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -24,13 +24,18 @@ export function ProjectFavicon(props: { readonly size?: number; readonly projectTitle: string; readonly workspaceRoot?: string | null; + readonly faviconPath?: string | null; }) { const size = props.size ?? 42; const faviconUrl = useAssetUrl( props.environmentId, props.workspaceRoot === null || props.workspaceRoot === undefined ? null - : { _tag: "project-favicon", cwd: props.workspaceRoot }, + : { + _tag: "project-favicon", + cwd: props.workspaceRoot, + ...(props.faviconPath ? { path: props.faviconPath } : {}), + }, ); const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; const cacheKey = diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 01440007bc6..801862086b9 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -368,6 +368,7 @@ function ProjectGroupLabel(props: { { const faviconResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, }); + expect(faviconResult.sourcePath).toBe("favicon.svg"); expect(faviconResult.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/); expect( yield* issueAssetUrl({ @@ -253,6 +254,7 @@ describe("AssetAccess", () => { resource: { _tag: "project-favicon", cwd: root }, }); expect(fallbackResult.relativeUrl.endsWith(`/${PROJECT_FAVICON_FALLBACK_MARKER}`)).toBe(true); + expect(fallbackResult.sourcePath).toBeUndefined(); const fallbackSuffix = fallbackResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); const fallbackSeparatorIndex = fallbackSuffix.indexOf("/"); expect( @@ -264,6 +266,26 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("issues project favicon capabilities for a saved override", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-override-", + }); + yield* fileSystem.makeDirectory(path.join(root, "brand")); + yield* fileSystem.writeFileString(path.join(root, "brand", "custom.svg"), ""); + yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), "auto"); + + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root, path: "brand/custom.svg" }, + }); + + expect(result.sourcePath).toBe("brand/custom.svg"); + expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-custom\.svg$/); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("buckets project favicon expiry after content hashing", () => Effect.gen(function* () { const crypto = yield* Crypto.Crypto; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index c00f7f1a5e3..8405dfed730 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -176,6 +176,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i let expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; let claims: AssetClaims; let fileName: string; + let sourcePath: string | undefined; switch (input.resource._tag) { case "workspace-file": { @@ -287,16 +288,19 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ), ); const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot).pipe( - Effect.mapError( - (cause) => - new AssetProjectFaviconResolutionError({ - resource: input.resource, - cause, - }), - ), - ); + const faviconPath = yield* faviconResolver + .resolvePath(workspaceRoot, input.resource.path) + .pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconResolutionError({ + resource: input.resource, + cause, + }), + ), + ); const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; + sourcePath = relativePath ?? undefined; const canonicalFaviconPath = relativePath ? yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( Effect.mapError( @@ -379,6 +383,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i return { relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`, expiresAt, + ...(sourcePath !== undefined ? { sourcePath } : {}), }; }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 8e65295b1ba..e3b18d74a9a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -2764,15 +2764,18 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5", }, + faviconPath: "brand/icon.svg", }); const projectRows = yield* sql<{ readonly scriptsJson: string; readonly defaultModelSelection: string; + readonly faviconPath: string | null; }>` SELECT scripts_json AS "scriptsJson", - default_model_selection_json AS "defaultModelSelection" + default_model_selection_json AS "defaultModelSelection", + favicon_path AS "faviconPath" FROM projection_projects WHERE project_id = 'project-scripts' `; @@ -2781,6 +2784,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { scriptsJson: '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', defaultModelSelection: '{"instanceId":"codex","model":"gpt-5"}', + faviconPath: "brand/icon.svg", }, ]); }), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index ef6a4fc9bd9..e9a625dd91c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -496,6 +496,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti workspaceRoot: event.payload.workspaceRoot, defaultModelSelection: event.payload.defaultModelSelection, defaultThreadEnvMode: null, + faviconPath: event.payload.faviconPath ?? null, scripts: event.payload.scripts, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -522,6 +523,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.defaultThreadEnvMode !== undefined ? { defaultThreadEnvMode: event.payload.defaultThreadEnvMode } : {}), + ...(event.payload.faviconPath !== undefined + ? { faviconPath: event.payload.faviconPath } + : {}), ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}), updatedAt: event.payload.updatedAt, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 18ac95bccb9..3e77f9cf875 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -317,6 +317,7 @@ function mapProjectShellRow( repositoryIdentity, defaultModelSelection: row.defaultModelSelection, defaultThreadEnvMode: row.defaultThreadEnvMode, + faviconPath: row.faviconPath ?? null, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -393,6 +394,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", default_thread_env_mode AS "defaultThreadEnvMode", + favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -847,6 +849,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", default_thread_env_mode AS "defaultThreadEnvMode", + favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -870,6 +873,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", default_thread_env_mode AS "defaultThreadEnvMode", + favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -1547,6 +1551,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, defaultModelSelection: row.defaultModelSelection, defaultThreadEnvMode: row.defaultThreadEnvMode, + faviconPath: row.faviconPath ?? null, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1677,6 +1682,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspaceRoot: row.workspaceRoot, defaultModelSelection: row.defaultModelSelection, defaultThreadEnvMode: row.defaultThreadEnvMode, + faviconPath: row.faviconPath ?? null, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -2169,6 +2175,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { repositoryIdentity, defaultModelSelection: option.value.defaultModelSelection, defaultThreadEnvMode: option.value.defaultThreadEnvMode, + faviconPath: option.value.faviconPath ?? null, scripts: option.value.scripts, createdAt: option.value.createdAt, updatedAt: option.value.updatedAt, diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index a0c06840733..bf5c509fa16 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -94,6 +94,47 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { }), ); + it.effect("propagates a project favicon path in project.meta.update", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const readModel = yield* projectEvent(createEmptyReadModel(now), { + sequence: 1, + eventId: asEventId("evt-project-create-favicon"), + aggregateKind: "project", + aggregateId: asProjectId("project-favicon"), + type: "project.created", + occurredAt: now, + commandId: CommandId.make("cmd-project-create-favicon"), + causationEventId: null, + correlationId: CommandId.make("cmd-project-create-favicon"), + metadata: {}, + payload: { + projectId: asProjectId("project-favicon"), + title: "Favicon", + workspaceRoot: "/tmp/favicon", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + const result = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-update-favicon"), + projectId: asProjectId("project-favicon"), + faviconPath: "brand/icon.svg", + }, + readModel, + }); + + const event = Array.isArray(result) ? result[0] : result; + expect(event.type).toBe("project.meta-updated"); + expect((event.payload as { faviconPath?: string }).faviconPath).toBe("brand/icon.svg"); + }), + ); + it.effect("rejects project.create for an active workspace root that already exists", () => Effect.gen(function* () { const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index e598a0b1428..a48bb29e154 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -250,6 +250,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" title: command.title, workspaceRoot: command.workspaceRoot, defaultModelSelection: command.defaultModelSelection ?? null, + faviconPath: null, scripts: [], createdAt: command.createdAt, updatedAt: command.createdAt, @@ -290,6 +291,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.defaultThreadEnvMode !== undefined ? { defaultThreadEnvMode: command.defaultThreadEnvMode } : {}), + ...(command.faviconPath !== undefined ? { faviconPath: command.faviconPath } : {}), ...(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 8ba7dbed5db..f486dcb2bcb 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -215,6 +215,7 @@ export function projectEvent( workspaceRoot: payload.workspaceRoot, defaultModelSelection: payload.defaultModelSelection, defaultThreadEnvMode: null, + faviconPath: payload.faviconPath ?? null, scripts: payload.scripts, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -250,6 +251,9 @@ export function projectEvent( ...(payload.defaultThreadEnvMode !== undefined ? { defaultThreadEnvMode: payload.defaultThreadEnvMode } : {}), + ...(payload.faviconPath !== undefined + ? { faviconPath: payload.faviconPath } + : {}), ...(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 c8598bfc7d6..ba133bb24a4 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -36,6 +36,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root, default_model_selection_json, default_thread_env_mode, + favicon_path, scripts_json, created_at, updated_at, @@ -47,6 +48,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.workspaceRoot}, ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, ${row.defaultThreadEnvMode}, + ${row.faviconPath ?? null}, ${JSON.stringify(row.scripts)}, ${row.createdAt}, ${row.updatedAt}, @@ -58,6 +60,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root = excluded.workspace_root, default_model_selection_json = excluded.default_model_selection_json, default_thread_env_mode = excluded.default_thread_env_mode, + favicon_path = excluded.favicon_path, scripts_json = excluded.scripts_json, created_at = excluded.created_at, updated_at = excluded.updated_at, @@ -76,6 +79,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", default_thread_env_mode AS "defaultThreadEnvMode", + favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -96,6 +100,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", default_thread_env_mode AS "defaultThreadEnvMode", + favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 16f8a658cfb..b137cedfbed 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -52,6 +52,7 @@ import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts"; import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; +import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; /** * Migration loader with all migrations defined inline. @@ -103,6 +104,7 @@ export const migrationEntries = [ [37, "ProjectionTurnsKeysetIndex", Migration0037], [38, "ProjectionThreadsPinOrderKey", Migration0038], [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], + [40, "ProjectionProjectFaviconPath", Migration0040], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts b/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts new file mode 100644 index 00000000000..7fd43d9b2ec --- /dev/null +++ b/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts @@ -0,0 +1,28 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("040_ProjectionProjectFaviconPath", (it) => { + it.effect("adds the nullable favicon path to project projections", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 39 }); + yield* runMigrations({ toMigrationInclusive: 40 }); + + const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` + PRAGMA table_info(projection_projects) + `; + const faviconPath = columns.find((column) => column.name === "favicon_path"); + + assert.equal(faviconPath?.name, "favicon_path"); + assert.equal(faviconPath?.notnull, 0); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.ts b/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.ts new file mode 100644 index 00000000000..8424e8d5e72 --- /dev/null +++ b/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (!columns.some((column) => column.name === "favicon_path")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN favicon_path TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 6dbd76fc4ba..339439fdfcb 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -26,6 +26,7 @@ export const ProjectionProject = Schema.Struct({ workspaceRoot: Schema.String, defaultModelSelection: Schema.NullOr(ModelSelection), defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), + faviconPath: Schema.optional(Schema.NullOr(Schema.String)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 75db78844a5..c89ebe8b201 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -77,6 +77,32 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { }), ); + it.effect("uses a saved project favicon override", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "brand/custom.svg", "custom"); + yield* writeTextFile(cwd, "favicon.svg", "automatic"); + + const resolved = yield* resolver.resolvePath(cwd, "brand/custom.svg"); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("brand/custom.svg"); + }), + ); + + it.effect("does not fall back when a saved override is missing", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "favicon.svg", "automatic"); + + const resolved = yield* resolver.resolvePath(cwd, "brand/missing.svg"); + + expect(resolved).toBeNull(); + }), + ); + it.effect("falls back to well-known files when the t3.json iconPath does not exist", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 2c7195de630..bc22e893964 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -91,6 +91,7 @@ export class ProjectFaviconResolver extends Context.Service< */ readonly resolvePath: ( cwd: string, + faviconPath?: string, ) => Effect.Effect; } >()("t3/project/ProjectFaviconResolver") {} @@ -168,7 +169,7 @@ export const make = Effect.gen(function* () { const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( "ProjectFaviconResolver.resolvePath", - )(function* (cwd) { + )(function* (cwd, faviconPath) { const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( Effect.mapError( (cause) => @@ -179,6 +180,12 @@ export const make = Effect.gen(function* () { }), ), ); + // A saved project override is exact. If the file is gone, return no icon + // instead of silently showing a different automatic candidate. + if (faviconPath !== undefined) { + return yield* findExistingFile(projectCwd, [faviconPath]); + } + // A t3.json iconPath takes precedence over the well-known locations. const projectFile = yield* projectFileLoader.load(projectCwd); if (Option.isSome(projectFile) && projectFile.value.iconPath !== undefined) { diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 701af3a79fc..f8c0b5ae75f 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -12,7 +12,7 @@ export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; export type AssetUrlState = | { readonly _tag: "Loading" } | { readonly _tag: "Failure" } - | { readonly _tag: "Success"; readonly url: string }; + | { readonly _tag: "Success"; readonly url: string; readonly sourcePath?: string }; export function useAssetUrlState( environmentId: EnvironmentId, @@ -32,7 +32,13 @@ export function useAssetUrlState( return { _tag: "Loading" }; } const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); - return url === null ? { _tag: "Failure" } : { _tag: "Success", url }; + return url === null + ? { _tag: "Failure" } + : { + _tag: "Success", + url, + ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}), + }; } export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 26a5c41ded5..07f5d3b5fcc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6048,6 +6048,7 @@ function ChatViewContent(props: ChatViewProps) { changeRequestState={activeThreadPr?.state ?? null} activeProjectName={activeProject?.title} activeProjectCwd={activeProject?.workspaceRoot ?? null} + activeProjectFaviconPath={activeProject?.faviconPath ?? null} openInCwd={gitCwd} activeProjectScripts={activeProject?.scripts} preferredScriptId={ diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index b3e845f0007..d8e03a74b3c 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -150,6 +150,7 @@ function projectFavicon(project: Project) { ); diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 2c1f99ffa0f..54fbc12df94 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -2253,7 +2253,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }`} /> )} - + {project.displayName} diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index c2fac8beb7e..bbeeda4bc7f 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -4,6 +4,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; const testState = vi.hoisted(() => ({ faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg", + lastResource: null as unknown, })); const hooks = vi.hoisted(() => { @@ -52,7 +53,10 @@ vi.mock("react", async (importOriginal) => { vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); vi.mock("../assets/assetUrls", () => ({ - useAssetUrl: () => testState.faviconUrl, + useAssetUrlState: (_environmentId: unknown, resource: unknown) => { + testState.lastResource = resource; + return { _tag: "Success", url: testState.faviconUrl }; + }, })); import { ProjectFavicon } from "./ProjectFavicon"; @@ -125,4 +129,18 @@ describe("ProjectFavicon", () => { expect(afterDisplayedError[0]).not.toBeNull(); expect(afterDisplayedError[1]).toBeNull(); }); + + it("requests a saved favicon path when one is set", () => { + ProjectFavicon({ + environmentId: "environment-test" as EnvironmentId, + cwd: "/workspace-test", + faviconPath: "brand/icon.svg", + }); + + expect(testState.lastResource).toEqual({ + _tag: "project-favicon", + cwd: "/workspace-test", + path: "brand/icon.svg", + }); + }); }); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 66216e10cb5..619bbf37001 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -6,7 +6,7 @@ import { import { FolderIcon } from "lucide-react"; import type { ComponentType } from "react"; import { useState } from "react"; -import { useAssetUrl } from "../assets/assetUrls"; +import { useAssetUrlState } from "../assets/assetUrls"; import { cn } from "~/lib/utils"; const loadedProjectFaviconSrcs = new Map(); @@ -14,13 +14,12 @@ const loadedProjectFaviconSrcs = new Map(); export function ProjectFavicon(input: { environmentId: EnvironmentId; cwd: string; + faviconPath?: string | null | undefined; className?: string | undefined; fallbackIcon?: ComponentType<{ className?: string }>; }) { - const src = useAssetUrl(input.environmentId, { - _tag: "project-favicon", - cwd: input.cwd, - }); + const state = useProjectFaviconAsset(input); + const src = state._tag === "Success" ? state.url : null; const FallbackIcon = input.fallbackIcon ?? FolderIcon; if (!src || isProjectFaviconFallbackUrl(src)) { @@ -40,6 +39,18 @@ export function ProjectFavicon(input: { ); } +export function useProjectFaviconAsset(input: { + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly faviconPath?: string | null | undefined; +}) { + return useAssetUrlState(input.environmentId, { + _tag: "project-favicon", + cwd: input.cwd, + ...(input.faviconPath ? { path: input.faviconPath } : {}), + }); +} + function ProjectFaviconFallback({ className, icon: Icon, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 6dbdfd45de7..0ad341e80e9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -236,6 +236,7 @@ function SidebarThreadTooltip({ thread, projectTitle, projectCwd, + projectFaviconPath, environmentLabel, driverKind, modelInstanceId, @@ -247,6 +248,7 @@ function SidebarThreadTooltip({ thread: SidebarThreadSummary; projectTitle: string | null; projectCwd: string | null; + projectFaviconPath: string | null; environmentLabel: string | null; driverKind: ProviderInstanceEntry["driverKind"] | null; modelInstanceId: string; @@ -276,6 +278,7 @@ function SidebarThreadTooltip({
{projectTitle}
@@ -667,6 +670,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { currentEnvironmentId: string | null; environmentLabel: string | null; projectCwd: string | null; + projectFaviconPath: string | null; projectTitle: string | null; providerEntryByInstanceId: ReadonlyMap; timestampFormat: TimestampFormat; @@ -857,6 +861,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { thread={thread} projectTitle={props.projectTitle} projectCwd={props.projectCwd} + projectFaviconPath={props.projectFaviconPath} environmentLabel={props.environmentLabel} driverKind={driverKind} modelInstanceId={modelInstanceId} @@ -1122,6 +1127,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { @@ -1261,6 +1267,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {props.projectTitle ? ( @@ -1454,6 +1461,7 @@ function latestTurnDiff( const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { thread: SidebarThreadSummary; projectCwd: string | null; + projectFaviconPath: string | null; projectTitle: string | null; environmentLabel: string | null; providerEntryByInstanceId: ReadonlyMap; @@ -1526,6 +1534,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { @@ -1538,6 +1547,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { thread={thread} projectTitle={props.projectTitle} projectCwd={props.projectCwd} + projectFaviconPath={props.projectFaviconPath} environmentLabel={props.environmentLabel} driverKind={driverKind} modelInstanceId={modelInstanceId} @@ -1714,6 +1724,13 @@ export default function Sidebar() { ), [projects], ); + const projectFaviconPathByKey = useMemo( + () => + new Map( + projects.map((project) => [`${project.environmentId}:${project.id}`, project.faviconPath]), + ), + [projects], + ); const projectDisplayNameByKey = useMemo( () => new Map( @@ -3230,6 +3247,7 @@ export default function Sidebar() { ) : ( @@ -3267,6 +3285,7 @@ export default function Sidebar() { {project.displayName} @@ -3339,6 +3358,11 @@ export default function Sidebar() { projectCwd={ projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null } + projectFaviconPath={ + projectFaviconPathByKey.get( + `${thread.environmentId}:${thread.projectId}`, + ) ?? null + } projectTitle={ projectDisplayNameByKey.get( `${thread.environmentId}:${thread.projectId}`, @@ -3442,6 +3466,11 @@ export default function Sidebar() { projectCwd={ projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null } + projectFaviconPath={ + projectFaviconPathByKey.get( + `${thread.environmentId}:${thread.projectId}`, + ) ?? null + } projectTitle={ projectDisplayNameByKey.get( `${thread.environmentId}:${thread.projectId}`, diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 68e1743bccb..39fe6daceb2 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -49,6 +49,7 @@ interface ChatHeaderProps { changeRequestState: ChangeRequestStateLike | null; activeProjectName: string | undefined; activeProjectCwd: string | null; + activeProjectFaviconPath: string | null; openInCwd: string | null; activeProjectScripts: ReadonlyArray | undefined; preferredScriptId: string | null; @@ -101,6 +102,7 @@ export const ChatHeader = memo(function ChatHeader({ changeRequestState, activeProjectName, activeProjectCwd, + activeProjectFaviconPath, openInCwd, activeProjectScripts, preferredScriptId, @@ -229,6 +231,7 @@ export const ChatHeader = memo(function ChatHeader({ {activeProjectName} diff --git a/apps/web/src/components/settings/ProjectFaviconPickerDialog.tsx b/apps/web/src/components/settings/ProjectFaviconPickerDialog.tsx new file mode 100644 index 00000000000..26b8bb037d2 --- /dev/null +++ b/apps/web/src/components/settings/ProjectFaviconPickerDialog.tsx @@ -0,0 +1,106 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; +import { useMemo, useState } from "react"; + +import { primaryServerKeybindingsAtom } from "~/state/server"; +import { useTheme } from "~/hooks/useTheme"; +import { CommandPaletteContent } from "../CommandPaletteContent"; +import type { CommandPaletteActionItem } from "../CommandPalette.logic"; +import { CommandPaletteResults } from "../CommandPaletteResults"; +import { PierreEntryIcon } from "../chat/PierreEntryIcon"; +import { + getProjectFilePickerMatches, + PROJECT_FILE_PICKER_RESULT_LIMIT, +} from "../files/ProjectFilePicker.logic"; +import { useProjectFilePickerQuery } from "../files/projectFilesQueryState"; +import { CommandDialog, CommandDialogPopup } from "../ui/command"; + +function emptyMessage(query: string, error: string | null, isPending: boolean): string { + if (error) return error; + if (isPending) return query.trim() ? "Searching project files…" : "Indexing project files…"; + return query.trim() ? "No matching image files." : "No image files found."; +} + +export function ProjectFaviconPickerDialog(props: { + readonly cwd: string; + readonly environmentId: EnvironmentId; + readonly onOpenChange: (open: boolean) => void; + readonly onSelect: (path: string) => void; + readonly open: boolean; + readonly projectName: string; +}) { + const [query, setQuery] = useState(""); + const [highlightedItemValue, setHighlightedItemValue] = useState(null); + const result = useProjectFilePickerQuery( + props.environmentId, + props.cwd, + query, + PROJECT_FILE_PICKER_RESULT_LIMIT, + ); + const { resolvedTheme } = useTheme(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const items = useMemo( + () => + getProjectFilePickerMatches(result.entries, result.matchedQuery) + .filter((match) => isWorkspaceImagePreviewPath(match.path)) + .map((match) => ({ + kind: "action", + value: `project-favicon:${match.path}`, + searchTerms: [match.name, match.path], + title: match.name, + description: match.path, + icon: , + run: async () => props.onSelect(match.path), + })), + [props.onSelect, resolvedTheme, result.entries, result.matchedQuery], + ); + + return ( + + {props.open ? ( + props.onOpenChange(false)} + > + { + setHighlightedItemValue(typeof value === "string" ? value : null); + }} + onValueChange={(value) => { + setHighlightedItemValue(null); + setQuery(value); + }} + panelClassName="max-h-[min(34rem,76vh)]" + testId="project-favicon-picker" + value={query} + > + 0 + ? [{ value: "project-favicon-files", label: props.projectName, items }] + : [] + } + highlightedItemValue={highlightedItemValue} + isActionsOnly={false} + keybindings={keybindings} + onExecuteItem={(item) => { + if (item.kind !== "action") return; + props.onOpenChange(false); + void item.run(); + }} + emptyStateMessage={emptyMessage(query, result.error, result.isPending)} + /> + + + ) : null} + + ); +} diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 2f8593587ca..b984c7332d5 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -23,7 +23,15 @@ import { resolveEnvModeLabel } from "../BranchToolbar.logic"; import { createModelSelection } from "@t3tools/shared/model"; import { useLocation, useNavigate } from "@tanstack/react-router"; import * as Cause from "effect/Cause"; -import { CopyIcon, FolderIcon, PlusIcon, ServerIcon, SettingsIcon, Trash2Icon } from "lucide-react"; +import { + CopyIcon, + FolderIcon, + ImageIcon, + PlusIcon, + ServerIcon, + SettingsIcon, + Trash2Icon, +} from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore } from "../../composerDraftStore"; @@ -67,7 +75,7 @@ import { import { useAtomCommand } from "../../state/use-atom-command"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; -import { ProjectFavicon } from "../ProjectFavicon"; +import { ProjectFavicon, useProjectFaviconAsset } from "../ProjectFavicon"; import { EMPTY_PROJECT_SCRIPT_INPUT, editorRequestForScript, @@ -86,6 +94,7 @@ import { SettingsRow, SettingsSection, } from "./settingsLayout"; +import { ProjectFaviconPickerDialog } from "./ProjectFaviconPickerDialog"; export const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", @@ -221,6 +230,7 @@ export function ProjectSettingsPanel({ {group.displayName} @@ -295,6 +305,7 @@ function ProjectDetail({ group.memberProjects.find( (member) => member.environmentId === group.environmentId && member.id === group.id, ) ?? group.memberProjects[0]!; + const faviconPath = representative.faviconPath ?? null; const threadCountByMember = useMemo(() => { const counts = new Map(); @@ -328,6 +339,7 @@ function ProjectDetail({ input: Partial<{ defaultModelSelection: ModelSelection | null; defaultThreadEnvMode: ThreadEnvMode | null; + faviconPath: string | null; scripts: ReadonlyArray>; }>, failureTitle: string, @@ -391,6 +403,30 @@ function ProjectDetail({ [updateAllMembers], ); + // ----- favicon ----- + const [faviconPickerOpen, setFaviconPickerOpen] = useState(false); + const [isSavingFavicon, setIsSavingFavicon] = useState(false); + const automaticFavicon = useProjectFaviconAsset({ + environmentId: representative.environmentId, + cwd: representative.workspaceRoot, + }); + const selectedFavicon = useProjectFaviconAsset({ + environmentId: representative.environmentId, + cwd: representative.workspaceRoot, + faviconPath, + }); + const setFaviconPath = useCallback( + async (faviconPath: string | null) => { + setIsSavingFavicon(true); + try { + await updateAllMembers({ faviconPath }, "Failed to update project icon"); + } finally { + setIsSavingFavicon(false); + } + }, + [updateAllMembers], + ); + // ----- scripts ----- const scripts = representative.scripts; const [editorRequest, setEditorRequest] = useState(null); @@ -692,6 +728,7 @@ function ProjectDetail({
@@ -723,6 +760,96 @@ function ProjectDetail({
+ + +
+ + +
+
+
+ setEditorRequest(null)} /> + void setFaviconPath(path)} + open={faviconPickerOpen} + projectName={group.displayName} + /> ); } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f9046f44106..c987ef64299 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2258,6 +2258,7 @@ export function ArchivedThreadsPanel() { environmentId, name: project.title, cwd: project.workspaceRoot, + faviconPath: project.faviconPath, }, ] as const, ), @@ -2379,7 +2380,13 @@ export function ArchivedThreadsPanel() { key={project.id} id={index === 0 ? searchableSetting("archive").id : undefined} title={project.name} - icon={} + icon={ + + } > {projectThreads.map((thread) => ( { }, }), ).not.toBe(assets.createUrl(originalTarget)); + expect( + assets.createUrl({ + environmentId, + input: { + resource: { + _tag: "project-favicon", + cwd: "/repo/original", + path: "brand/icon.svg", + }, + }, + }), + ).not.toBe(assets.createUrl(originalTarget)); expect( assets.createUrl({ environmentId: EnvironmentId.make("environment-2"), diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index 0dbe7d9fa25..1d2ffae4c13 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -14,6 +14,7 @@ export const AssetResource = Schema.Union([ }), Schema.TaggedStruct("project-favicon", { cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), + path: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH))), }), ]); export type AssetResource = typeof AssetResource.Type; @@ -26,6 +27,9 @@ export type AssetCreateUrlInput = typeof AssetCreateUrlInput.Type; export const AssetCreateUrlResult = Schema.Struct({ relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), expiresAt: Schema.Number, + sourcePath: Schema.optional( + TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), + ), }); export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 68f36c3cd34..750c696f26c 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -211,6 +211,9 @@ export const ProjectScript = Schema.Struct({ }); export type ProjectScript = typeof ProjectScript.Type; +export const ProjectFaviconPath = TrimmedNonEmptyString.check(Schema.isMaxLength(1024)); +export type ProjectFaviconPath = typeof ProjectFaviconPath.Type; + export const OrchestrationProject = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, @@ -220,6 +223,8 @@ export const OrchestrationProject = Schema.Struct({ // Per-project override for where new threads start. Null/absent means // "no override": clients fall back to t3.json, then the global setting. defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), + // Optional on the wire so cached snapshots from older servers still decode. + faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -415,6 +420,8 @@ export const OrchestrationProjectShell = Schema.Struct({ repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), + // Optional on the wire so cached snapshots from older servers still decode. + faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -629,6 +636,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({ defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), // Absent = leave unchanged; null = clear the override. defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), + faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), scripts: Schema.optional(Schema.Array(ProjectScript)), }); @@ -1073,6 +1081,8 @@ export const ProjectCreatedPayload = Schema.Struct({ workspaceRoot: TrimmedNonEmptyString, repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), + // Optional so persisted events from older servers still decode. + faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -1085,6 +1095,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), + faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), scripts: Schema.optional(Schema.Array(ProjectScript)), updatedAt: IsoDateTime, }); From d10fb690c0cfea011af74e0d160b39cddeced62f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 19:36:49 -0700 Subject: [PATCH 2/9] fix(web): constrain project icon selection --- apps/server/src/assets/AssetAccess.test.ts | 42 ++++++++++++++- apps/server/src/assets/AssetAccess.ts | 6 ++- apps/server/src/workspace/WorkspaceEntries.ts | 2 +- .../workspace/WorkspaceSearchIndex.test.ts | 53 ++++++++++++++++++- .../src/workspace/WorkspaceSearchIndex.ts | 27 ++++++---- apps/server/src/ws.ts | 24 ++++++++- .../files/projectFilesQueryState.ts | 15 ++++-- .../settings/ProjectFaviconPickerDialog.tsx | 22 ++++---- apps/web/src/state/queries.test.ts | 3 +- apps/web/src/state/queries.ts | 8 ++- packages/contracts/src/assets.ts | 5 +- packages/contracts/src/orchestration.test.ts | 22 ++++++++ packages/contracts/src/orchestration.ts | 5 +- packages/contracts/src/project.ts | 1 + 14 files changed, 199 insertions(+), 36 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index a16571cbacc..9a9304c2ccb 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ThreadId } from "@t3tools/contracts"; +import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; @@ -279,6 +279,7 @@ describe("AssetAccess", () => { const result = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root, path: "brand/custom.svg" }, + projectFaviconPath: "brand/custom.svg", }); expect(result.sourcePath).toBe("brand/custom.svg"); @@ -286,6 +287,45 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("ignores a client favicon path hint", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-hint-", + }); + yield* fileSystem.makeDirectory(path.join(root, "brand")); + yield* fileSystem.writeFileString(path.join(root, "brand", "hint.svg"), "hint"); + yield* fileSystem.writeFileString(path.join(root, "brand", "saved.svg"), "saved"); + + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root, path: "brand/hint.svg" }, + projectFaviconPath: "brand/saved.svg", + }); + + expect(result.sourcePath).toBe("brand/saved.svg"); + expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-saved\.svg$/); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects a resolved project favicon with a non-image extension", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-type-", + }); + yield* fileSystem.writeFileString(path.join(root, "secret.txt"), "not an image"); + + const error = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + projectFaviconPath: "secret.txt", + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(AssetPreviewTypeValidationError); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("buckets project favicon expiry after content hashing", () => Effect.gen(function* () { const crypto = yield* Crypto.Crypto; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 8405dfed730..ea07825d8e0 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -169,6 +169,7 @@ const resolveCanonicalWorkspaceFileForRequest = (input: { export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { readonly resource: AssetResource; readonly workspaceRoot?: string; + readonly projectFaviconPath?: string | null; }) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -289,7 +290,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ); const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; const faviconPath = yield* faviconResolver - .resolvePath(workspaceRoot, input.resource.path) + .resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined) .pipe( Effect.mapError( (cause) => @@ -300,6 +301,9 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ), ); const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; + if (relativePath && !isWorkspaceImagePreviewPath(relativePath)) { + return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); + } sourcePath = relativePath ?? undefined; const canonicalFaviconPath = relativePath ? yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index bb2113dac37..28a30481b1b 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -245,7 +245,7 @@ export const make = Effect.gen(function* () { }); return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; - return yield* searchIndex.search(normalizedQuery, input.limit, input.kind); + return yield* searchIndex.search(normalizedQuery, input.limit, input.kind, input.imageOnly); }).pipe( Effect.provide( workspaceSearchIndexes.get( diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 15572837030..6b2236e3b19 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -1,4 +1,10 @@ -import { FileFinder, type GrepCursor, type GrepOptions, type GrepResult } from "@ff-labs/fff-node"; +import { + FileFinder, + type FileItem, + type GrepCursor, + type GrepOptions, + type GrepResult, +} from "@ff-labs/fff-node"; import { afterEach, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; @@ -11,6 +17,51 @@ afterEach(() => { vi.restoreAllMocks(); }); +function fileItem(relativePath: string): FileItem { + return { + relativePath, + fileName: relativePath.slice(relativePath.lastIndexOf("/") + 1), + size: 1, + modified: 0, + accessFrecencyScore: 0, + modificationFrecencyScore: 0, + totalFrecencyScore: 0, + gitStatus: "clean", + }; +} + +it.effect("filters image searches before applying the result limit", () => + Effect.scoped( + Effect.gen(function* () { + const items = [ + ...Array.from({ length: 200 }, (_, index) => fileItem(`src/file-${index}.ts`)), + fileItem("public/icon.svg"), + ]; + const fileSearch = vi.fn(() => ({ + ok: true as const, + value: { + items, + scores: [], + totalMatched: items.length, + totalFiles: items.length, + }, + })); + const finder = { + destroy: vi.fn(), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + fileSearch, + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); + const result = yield* searchIndex.search("", 200, "file", true); + + expect(result.entries).toEqual([{ kind: "file", path: "public/icon.svg" }]); + expect(fileSearch).toHaveBeenCalledWith("", { pageSize: 25_002 }); + }), + ), +); + it.effect("preserves unexpected FileFinder creation failures", () => Effect.gen(function* () { const cause = new Error("native initialization failed"); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 8bf36b7a80a..18727b75374 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -23,6 +23,7 @@ import type { ProjectSearchContentsResult, ProjectSearchEntriesResult, } from "@t3tools/contracts"; +import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; @@ -111,6 +112,7 @@ export class WorkspaceSearchIndex extends Context.Service< query: string, limit: number, kind?: ProjectEntryKind, + imageOnly?: boolean, ) => Effect.Effect; readonly searchContents: ( input: Omit, @@ -157,15 +159,18 @@ function toDirectoryEntry(item: DirItem): ProjectEntry | null { return normalizedPath ? { path: normalizedPath, kind: "directory" } : null; } -function mapFileSearchResult(result: SearchResult, limit: number): ProjectSearchEntriesResult { +function mapFileSearchResult( + result: SearchResult, + limit: number, + imageOnly = false, +): ProjectSearchEntriesResult { + const entries = result.items.flatMap((item) => { + const entry = toFileEntry(item); + return entry && (!imageOnly || isWorkspaceImagePreviewPath(entry.path)) ? [entry] : []; + }); return { - entries: result.items - .flatMap((item) => { - const entry = toFileEntry(item); - return entry ? [entry] : []; - }) - .slice(0, limit), - truncated: result.totalMatched > limit, + entries: entries.slice(0, limit), + truncated: entries.length > limit || result.totalMatched > result.items.length, }; } @@ -445,13 +450,13 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( "WorkspaceSearchIndex.search", - )(function* (query, limit, kind) { - const pageSize = Math.max(1, limit + 1); + )(function* (query, limit, kind, imageOnly) { + const pageSize = imageOnly ? WORKSPACE_INDEX_PAGE_SIZE : Math.max(1, limit + 1); if (kind === "file") { const result = yield* runSearch(query, pageSize, "fileSearch", () => finder.fileSearch(query, { pageSize }), ); - return mapFileSearchResult(result, limit); + return mapFileSearchResult(result, limit, imageOnly); } if (kind === "directory") { const result = yield* runSearch(query, pageSize, "directorySearch", () => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 789f39b6397..4ffc98394af 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1763,9 +1763,31 @@ const makeWsRpcLayer = ( observeRpcEffect( WS_METHODS.assetsCreateUrl, Effect.gen(function* () { - if (input.resource._tag !== "workspace-file") { + if (input.resource._tag === "attachment") { return yield* issueAssetUrl({ resource: input.resource }); } + if (input.resource._tag === "project-favicon") { + const project = yield* projectionSnapshotQuery + .getActiveProjectByWorkspaceRoot(input.resource.cwd) + .pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceContextResolutionError({ + resource: input.resource, + cause, + }), + ), + ); + if (Option.isNone(project)) { + return yield* new AssetWorkspaceContextNotFoundError({ + resource: input.resource, + }); + } + return yield* issueAssetUrl({ + resource: input.resource, + projectFaviconPath: project.value.faviconPath ?? null, + }); + } const thread = yield* projectionSnapshotQuery .getThreadShellById(input.resource.threadId) .pipe( diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index d165c1d1a7a..08203ff6b87 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -150,10 +150,19 @@ export function useProjectFilePickerQuery( cwd: string, query: string, limit: number, + options?: { readonly imageOnly?: boolean }, ) { - const search = useProjectPathSearch({ environmentId, cwd, query, kind: "file" }, limit, { - allowEmptyQuery: true, - }); + const search = useProjectPathSearch( + { + environmentId, + cwd, + query, + kind: "file", + ...(options?.imageOnly ? { imageOnly: true } : {}), + }, + limit, + { allowEmptyQuery: true }, + ); return { entries: search.isPending ? [] : search.entries, diff --git a/apps/web/src/components/settings/ProjectFaviconPickerDialog.tsx b/apps/web/src/components/settings/ProjectFaviconPickerDialog.tsx index 26b8bb037d2..eee3880692b 100644 --- a/apps/web/src/components/settings/ProjectFaviconPickerDialog.tsx +++ b/apps/web/src/components/settings/ProjectFaviconPickerDialog.tsx @@ -1,6 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentId } from "@t3tools/contracts"; -import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { useMemo, useState } from "react"; import { primaryServerKeybindingsAtom } from "~/state/server"; @@ -37,22 +36,21 @@ export function ProjectFaviconPickerDialog(props: { props.cwd, query, PROJECT_FILE_PICKER_RESULT_LIMIT, + { imageOnly: true }, ); const { resolvedTheme } = useTheme(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const items = useMemo( () => - getProjectFilePickerMatches(result.entries, result.matchedQuery) - .filter((match) => isWorkspaceImagePreviewPath(match.path)) - .map((match) => ({ - kind: "action", - value: `project-favicon:${match.path}`, - searchTerms: [match.name, match.path], - title: match.name, - description: match.path, - icon: , - run: async () => props.onSelect(match.path), - })), + getProjectFilePickerMatches(result.entries, result.matchedQuery).map((match) => ({ + kind: "action", + value: `project-favicon:${match.path}`, + searchTerms: [match.name, match.path], + title: match.name, + description: match.path, + icon: , + run: async () => props.onSelect(match.path), + })), [props.onSelect, resolvedTheme, result.entries, result.matchedQuery], ); diff --git a/apps/web/src/state/queries.test.ts b/apps/web/src/state/queries.test.ts index f8887dfb8ef..811f4211ee0 100644 --- a/apps/web/src/state/queries.test.ts +++ b/apps/web/src/state/queries.test.ts @@ -10,7 +10,7 @@ describe("areProjectPathSearchTargetsEqual", () => { query: "index", }; - it("requires the environment, workspace, query, and entry kind to match", () => { + it("requires the environment, workspace, query, entry kind, and image filter to match", () => { expect(areProjectPathSearchTargetsEqual(target, target)).toBe(true); expect( areProjectPathSearchTargetsEqual(target, { @@ -21,5 +21,6 @@ describe("areProjectPathSearchTargetsEqual", () => { expect(areProjectPathSearchTargetsEqual(target, { ...target, cwd: "/project-b" })).toBe(false); expect(areProjectPathSearchTargetsEqual(target, { ...target, query: "readme" })).toBe(false); expect(areProjectPathSearchTargetsEqual(target, { ...target, kind: "file" })).toBe(false); + expect(areProjectPathSearchTargetsEqual(target, { ...target, imageOnly: true })).toBe(false); }); }); diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 2a095b8f584..a9a309b61bf 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -236,6 +236,7 @@ export function usePaginatedBranches(target: VcsRefTarget) { type ProjectPathSearchTarget = ComposerPathSearchTarget & { readonly kind?: ProjectEntryKind | undefined; + readonly imageOnly?: boolean | undefined; }; export function areProjectPathSearchTargetsEqual( @@ -246,7 +247,8 @@ export function areProjectPathSearchTargetsEqual( left.environmentId === right.environmentId && left.cwd === right.cwd && left.query === right.query && - left.kind === right.kind + left.kind === right.kind && + left.imageOnly === right.imageOnly ); } @@ -262,8 +264,9 @@ export function useProjectPathSearch( cwd: target.cwd, query: target.query == null ? null : target.query.trim(), kind: target.kind, + imageOnly: target.imageOnly, }), - [target.cwd, target.environmentId, target.kind, target.query], + [target.cwd, target.environmentId, target.imageOnly, target.kind, target.query], ); const debouncedTarget = useDebouncedValue(normalizedTarget, PROJECT_PATH_SEARCH_DEBOUNCE_MS); const result = useEnvironmentQuery( @@ -278,6 +281,7 @@ export function useProjectPathSearch( query: debouncedTarget.query, limit, ...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}), + ...(debouncedTarget.imageOnly ? { imageOnly: true } : {}), }, }) : null, diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index 1d2ffae4c13..e3922073455 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -1,6 +1,7 @@ import * as Schema from "effect/Schema"; import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProjectFaviconPath } from "./orchestration.ts"; const ASSET_PATH_MAX_LENGTH = 1024; @@ -14,7 +15,9 @@ export const AssetResource = Schema.Union([ }), Schema.TaggedStruct("project-favicon", { cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), - path: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH))), + // A cache-key hint only. The server reads the authoritative path from the + // project projection before it issues the signed URL. + path: Schema.optional(ProjectFaviconPath), }), ]); export type AssetResource = typeof AssetResource.Type; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index ecf7afa0610..eba1b4648b2 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -913,3 +913,25 @@ it.effect("ModelSelection rejects malformed instance ids", () => assert.strictEqual(result._tag, "Failure"); }), ); + +it.effect("project favicon overrides accept only supported image files", () => + Effect.gen(function* () { + const valid = yield* decodeOrchestrationCommand({ + type: "project.meta.update", + commandId: "cmd-project-favicon", + projectId: "project-1", + faviconPath: "brand/icon.svg", + }); + assert.strictEqual(valid.type, "project.meta.update"); + + const invalid = yield* Effect.exit( + decodeOrchestrationCommand({ + type: "project.meta.update", + commandId: "cmd-project-secret", + projectId: "project-1", + faviconPath: ".env", + }), + ); + assert.strictEqual(invalid._tag, "Failure"); + }), +); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 750c696f26c..35fef721efa 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -211,7 +211,10 @@ export const ProjectScript = Schema.Struct({ }); export type ProjectScript = typeof ProjectScript.Type; -export const ProjectFaviconPath = TrimmedNonEmptyString.check(Schema.isMaxLength(1024)); +export const ProjectFaviconPath = TrimmedNonEmptyString.check( + Schema.isMaxLength(1024), + Schema.isPattern(/\.(?:avif|gif|ico|jpe?g|png|svg|webp)$/i), +); export type ProjectFaviconPath = typeof ProjectFaviconPath.Type; export const OrchestrationProject = Schema.Struct({ diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index a1b11df73b2..757c000a065 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -21,6 +21,7 @@ export const ProjectSearchEntriesInput = Schema.Struct({ query: TrimmedString.check(Schema.isMaxLength(256)), limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT)), kind: Schema.optional(ProjectEntryKind), + imageOnly: Schema.optional(Schema.Boolean), }); export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type; From 8e4389157cc708a7fc0870981cd173755168b94e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 19:39:47 -0700 Subject: [PATCH 3/9] fix(server): enforce image-only project searches --- apps/server/src/workspace/WorkspaceSearchIndex.test.ts | 7 +++++-- apps/server/src/workspace/WorkspaceSearchIndex.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 6b2236e3b19..1fdf956447d 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -54,9 +54,12 @@ it.effect("filters image searches before applying the result limit", () => vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); - const result = yield* searchIndex.search("", 200, "file", true); + const resultWithoutKind = yield* searchIndex.search("", 200, undefined, true); + const resultWithDirectoryKind = yield* searchIndex.search("", 200, "directory", true); - expect(result.entries).toEqual([{ kind: "file", path: "public/icon.svg" }]); + expect(resultWithoutKind.entries).toEqual([{ kind: "file", path: "public/icon.svg" }]); + expect(resultWithDirectoryKind.entries).toEqual([{ kind: "file", path: "public/icon.svg" }]); + expect(fileSearch).toHaveBeenCalledTimes(2); expect(fileSearch).toHaveBeenCalledWith("", { pageSize: 25_002 }); }), ), diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 18727b75374..eeb2df342c2 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -452,7 +452,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( "WorkspaceSearchIndex.search", )(function* (query, limit, kind, imageOnly) { const pageSize = imageOnly ? WORKSPACE_INDEX_PAGE_SIZE : Math.max(1, limit + 1); - if (kind === "file") { + if (kind === "file" || imageOnly) { const result = yield* runSearch(query, pageSize, "fileSearch", () => finder.fileSearch(query, { pageSize }), ); From 753ec143333102ef8a45e4b3b27d9256ee720afa Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 19:44:35 -0700 Subject: [PATCH 4/9] test(server): include project icon paths in snapshots --- .../src/orchestration/Layers/ProjectionSnapshotQuery.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index c466819d6bc..be596b36b85 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -275,6 +275,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, + faviconPath: null, scripts: [ { id: "script-1", @@ -394,6 +395,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, + faviconPath: null, scripts: [ { id: "script-1", From e15f60f2321e8a14c73e9b5ad715cc2c7e3e2e17 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 19:45:47 -0700 Subject: [PATCH 5/9] fix(server): keep automatic project icons separate --- apps/server/src/assets/AssetAccess.test.ts | 20 ++++++++++++++++++++ apps/server/src/assets/AssetAccess.ts | 2 +- apps/server/src/ws.ts | 4 +++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 9a9304c2ccb..5c9d81c8828 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -308,6 +308,26 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("keeps automatic favicon resolution separate from a saved override", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-automatic-", + }); + yield* fileSystem.makeDirectory(path.join(root, "brand")); + yield* fileSystem.writeFileString(path.join(root, "brand", "saved.svg"), "saved"); + yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), "automatic"); + + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }); + + expect(result.sourcePath).toBe("favicon.svg"); + expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("rejects a resolved project favicon with a non-image extension", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index ea07825d8e0..7157513b14d 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -169,7 +169,7 @@ const resolveCanonicalWorkspaceFileForRequest = (input: { export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { readonly resource: AssetResource; readonly workspaceRoot?: string; - readonly projectFaviconPath?: string | null; + readonly projectFaviconPath?: string; }) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4ffc98394af..b107a300490 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1785,7 +1785,9 @@ const makeWsRpcLayer = ( } return yield* issueAssetUrl({ resource: input.resource, - projectFaviconPath: project.value.faviconPath ?? null, + ...(input.resource.path !== undefined && project.value.faviconPath + ? { projectFaviconPath: project.value.faviconPath } + : {}), }); } const thread = yield* projectionSnapshotQuery From 7157de1feec8a2405d0f757f6bae48fd613e0b9c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 20:20:19 -0700 Subject: [PATCH 6/9] fix(web): simplify project icon setting --- .../settings/ProjectSettingsPanel.tsx | 122 ++++-------------- 1 file changed, 24 insertions(+), 98 deletions(-) diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index b984c7332d5..6cb49df7eaa 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -23,15 +23,7 @@ import { resolveEnvModeLabel } from "../BranchToolbar.logic"; import { createModelSelection } from "@t3tools/shared/model"; import { useLocation, useNavigate } from "@tanstack/react-router"; import * as Cause from "effect/Cause"; -import { - CopyIcon, - FolderIcon, - ImageIcon, - PlusIcon, - ServerIcon, - SettingsIcon, - Trash2Icon, -} from "lucide-react"; +import { CopyIcon, FolderIcon, PlusIcon, ServerIcon, SettingsIcon, Trash2Icon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore } from "../../composerDraftStore"; @@ -75,7 +67,7 @@ import { import { useAtomCommand } from "../../state/use-atom-command"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; -import { ProjectFavicon, useProjectFaviconAsset } from "../ProjectFavicon"; +import { ProjectFavicon } from "../ProjectFavicon"; import { EMPTY_PROJECT_SCRIPT_INPUT, editorRequestForScript, @@ -406,15 +398,6 @@ function ProjectDetail({ // ----- favicon ----- const [faviconPickerOpen, setFaviconPickerOpen] = useState(false); const [isSavingFavicon, setIsSavingFavicon] = useState(false); - const automaticFavicon = useProjectFaviconAsset({ - environmentId: representative.environmentId, - cwd: representative.workspaceRoot, - }); - const selectedFavicon = useProjectFaviconAsset({ - environmentId: representative.environmentId, - cwd: representative.workspaceRoot, - faviconPath, - }); const setFaviconPath = useCallback( async (faviconPath: string | null) => { setIsSavingFavicon(true); @@ -764,90 +747,33 @@ function ProjectDetail({ -
- - -
-
+ + + } + />
From ea0e629875fb53847cc5200b823812dab7c01b4b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 20:25:03 -0700 Subject: [PATCH 7/9] fix(web): serialize project icon updates --- apps/web/src/components/settings/ProjectSettingsPanel.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 6cb49df7eaa..00401114153 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -398,12 +398,16 @@ function ProjectDetail({ // ----- favicon ----- const [faviconPickerOpen, setFaviconPickerOpen] = useState(false); const [isSavingFavicon, setIsSavingFavicon] = useState(false); + const savingFaviconRef = useRef(false); const setFaviconPath = useCallback( async (faviconPath: string | null) => { + if (savingFaviconRef.current) return; + savingFaviconRef.current = true; setIsSavingFavicon(true); try { await updateAllMembers({ faviconPath }, "Failed to update project icon"); } finally { + savingFaviconRef.current = false; setIsSavingFavicon(false); } }, From 3b7d26eb65c7c46a474b1479079d786b2c8bf063 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 20:30:21 -0700 Subject: [PATCH 8/9] fix(web): keep grouped project icons consistent --- .../server/src/project/ProjectFaviconResolver.test.ts | 5 +++-- apps/server/src/project/ProjectFaviconResolver.ts | 9 ++++++--- .../src/components/settings/ProjectSettingsPanel.tsx | 6 +++++- apps/web/src/components/settings/settingsLayout.tsx | 11 ++++++++++- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index c89ebe8b201..34ad74da621 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -91,7 +91,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { }), ); - it.effect("does not fall back when a saved override is missing", () => + it.effect("falls back when a saved override is missing from a checkout", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; const cwd = yield* makeTempDir; @@ -99,7 +99,8 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { const resolved = yield* resolver.resolvePath(cwd, "brand/missing.svg"); - expect(resolved).toBeNull(); + expect(resolved).not.toBeNull(); + expect(resolved).toContain("favicon.svg"); }), ); diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index bc22e893964..0b447bd4f3e 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -180,10 +180,13 @@ export const make = Effect.gen(function* () { }), ), ); - // A saved project override is exact. If the file is gone, return no icon - // instead of silently showing a different automatic candidate. + // A grouped project's saved path can be absent from one checkout. Use it + // where it exists and retain automatic discovery for the other checkouts. if (faviconPath !== undefined) { - return yield* findExistingFile(projectCwd, [faviconPath]); + const existing = yield* findExistingFile(projectCwd, [faviconPath]); + if (existing) { + return existing; + } } // A t3.json iconPath takes precedence over the well-known locations. diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 00401114153..8c2c9f081be 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -754,7 +754,11 @@ function ProjectDetail({ description={faviconPath ?? "Automatic"} resetAction={ faviconPath !== null ? ( - void setFaviconPath(null)} /> + void setFaviconPath(null)} + /> ) : null } control={ diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index 0bb1a1aa678..85f568019a1 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -199,7 +199,15 @@ export function SettingsRow({ ); } -export function SettingResetButton({ label, onClick }: { label: string; onClick: () => void }) { +export function SettingResetButton({ + label, + disabled = false, + onClick, +}: { + label: string; + disabled?: boolean; + onClick: () => void; +}) { return ( { event.stopPropagation(); From ae1e94ebb2a18a108124d253d23a03d6ffd6a1c5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 21:39:50 -0700 Subject: [PATCH 9/9] fix(web): show saved icons in draft rows --- apps/server/src/assets/AssetAccess.test.ts | 2 +- apps/server/src/ws.ts | 2 +- apps/web/src/components/Sidebar.tsx | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 5c9d81c8828..0a1972c2827 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -278,7 +278,7 @@ describe("AssetAccess", () => { yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), "auto"); const result = yield* issueAssetUrl({ - resource: { _tag: "project-favicon", cwd: root, path: "brand/custom.svg" }, + resource: { _tag: "project-favicon", cwd: root }, projectFaviconPath: "brand/custom.svg", }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index b107a300490..5cbe64cd413 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1785,7 +1785,7 @@ const makeWsRpcLayer = ( } return yield* issueAssetUrl({ resource: input.resource, - ...(input.resource.path !== undefined && project.value.faviconPath + ...(project.value.faviconPath ? { projectFaviconPath: project.value.faviconPath } : {}), }); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0ad341e80e9..aee4a04c0a1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -426,6 +426,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { composer: ComposerThreadDraftState; projectTitle: string | null; projectCwd: string | null; + projectFaviconPath: string | null; isActive: boolean; onNavigate: (draftId: DraftId) => void; onDiscard: (draftId: DraftId) => void; @@ -490,6 +491,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { @@ -527,6 +529,7 @@ interface SidebarDraftRowData { const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { projectDisplayNameByKey: ReadonlyMap; projectCwdByKey: ReadonlyMap; + projectFaviconPathByKey: ReadonlyMap; scopedProjectKeys: ReadonlySet | null; routeDraftId: string | null; onNavigateToDraft: (draftId: DraftId) => void; @@ -621,6 +624,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { composer={composer} projectTitle={props.projectDisplayNameByKey.get(projectKey) ?? null} projectCwd={props.projectCwdByKey.get(projectKey) ?? null} + projectFaviconPath={props.projectFaviconPathByKey.get(projectKey) ?? null} isActive={draftId === props.routeDraftId} onNavigate={props.onNavigateToDraft} onDiscard={handleDiscard} @@ -3509,6 +3513,7 @@ export default function Sidebar() { key="draft-sessions" projectDisplayNameByKey={projectDisplayNameByKey} projectCwdByKey={projectCwdByKey} + projectFaviconPathByKey={projectFaviconPathByKey} scopedProjectKeys={scopedProjectKeys} routeDraftId={routeDraftIdForRows} onNavigateToDraft={navigateToDraft}