authorgravatar for saphid@gmail.comAlex Southwell <saphid@gmail.com> 2026-09-03 15:17:38+10:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 01:17:38-04:00
logf6c04c552c203350705f9ab1e47773ea736af245
tree09d83afb8d7bb7c6aae6b31b58695036cc5a14b2
parent6a5a18cb1bc9ea878afa1146950439b5d4744110
signature Signed by PGP key B5690EEEBB952194

feat(web): add customizable project icons (#9137)

Co-authored-by: maria <maria@kuuro.net> Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

34 files changed, 1294 insertions(+), 38 deletions(-)

apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+5-1
...@@ -3423,17 +3423,20 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {...@@ -3423,17 +3423,20 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
3423 model: "gpt-5",3423 model: "gpt-5",
3424 },3424 },
3425 faviconPath: "brand/icon.svg",3425 faviconPath: "brand/icon.svg",
3426 projectIcon: { kind: "emoji", emoji: "๐Ÿš€" },
3426 });3427 });
34273428
3428 const projectRows = yield* sql<{3429 const projectRows = yield* sql<{
3429 readonly scriptsJson: string;3430 readonly scriptsJson: string;
3430 readonly defaultModelSelection: string;3431 readonly defaultModelSelection: string;
3431 readonly faviconPath: string | null;3432 readonly faviconPath: string | null;
3433 readonly projectIcon: string | null;
3432 }>`3434 }>`
3433 SELECT3435 SELECT
3434 scripts_json AS "scriptsJson",3436 scripts_json AS "scriptsJson",
3435 default_model_selection_json AS "defaultModelSelection",3437 default_model_selection_json AS "defaultModelSelection",
3436 favicon_path AS "faviconPath"3438 favicon_path AS "faviconPath",
3439 project_icon_json AS "projectIcon"
3437 FROM projection_projects3440 FROM projection_projects
3438 WHERE project_id = 'project-scripts'3441 WHERE project_id = 'project-scripts'
3439 `;3442 `;
...@@ -3443,6 +3446,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {...@@ -3443,6 +3446,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
3443 '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]',3446 '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]',
3444 defaultModelSelection: '{"instanceId":"codex","model":"gpt-5"}',3447 defaultModelSelection: '{"instanceId":"codex","model":"gpt-5"}',
3445 faviconPath: "brand/icon.svg",3448 faviconPath: "brand/icon.svg",
3449 projectIcon: '{"kind":"emoji","emoji":"๐Ÿš€"}',
3446 },3450 },
3447 ]);3451 ]);
3448 }),3452 }),
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+4
...@@ -517,6 +517,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -517,6 +517,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
517 defaultThreadEnvMode: null,517 defaultThreadEnvMode: null,
518 autoPull: false,518 autoPull: false,
519 faviconPath: event.payload.faviconPath ?? null,519 faviconPath: event.payload.faviconPath ?? null,
520 projectIcon: event.payload.projectIcon ?? null,
520 scripts: event.payload.scripts,521 scripts: event.payload.scripts,
521 createdAt: event.payload.createdAt,522 createdAt: event.payload.createdAt,
522 updatedAt: event.payload.updatedAt,523 updatedAt: event.payload.updatedAt,
...@@ -547,6 +548,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -547,6 +548,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
547 ...(event.payload.faviconPath !== undefined548 ...(event.payload.faviconPath !== undefined
548 ? { faviconPath: event.payload.faviconPath }549 ? { faviconPath: event.payload.faviconPath }
549 : {}),550 : {}),
551 ...(event.payload.projectIcon !== undefined
552 ? { projectIcon: event.payload.projectIcon }
553 : {}),
550 ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}),554 ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}),
551 updatedAt: event.payload.updatedAt,555 updatedAt: event.payload.updatedAt,
552 });556 });
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2
...@@ -280,6 +280,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -280,6 +280,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
280 },280 },
281 autoPull: false,281 autoPull: false,
282 faviconPath: null,282 faviconPath: null,
283 projectIcon: null,
283 scripts: [284 scripts: [
284 {285 {
285 id: "script-1",286 id: "script-1",
...@@ -408,6 +409,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -408,6 +409,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
408 },409 },
409 autoPull: false,410 autoPull: false,
410 faviconPath: null,411 faviconPath: null,
412 projectIcon: null,
411 scripts: [413 scripts: [
412 {414 {
413 id: "script-1",415 id: "script-1",
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+9
...@@ -12,6 +12,7 @@ import {...@@ -12,6 +12,7 @@ import {
12 OrchestrationThread,12 OrchestrationThread,
13 OrchestrationThreadDetailSnapshot,13 OrchestrationThreadDetailSnapshot,
14 ProjectScript,14 ProjectScript,
15 ProjectIconOverride,
15 TurnId,16 TurnId,
16 type OrchestrationCheckpointSummary,17 type OrchestrationCheckpointSummary,
17 type OrchestrationLatestTurn,18 type OrchestrationLatestTurn,
...@@ -84,6 +85,7 @@ const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(...@@ -84,6 +85,7 @@ const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(
84 Struct.assign({85 Struct.assign({
85 defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),86 defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),
86 autoPull: Schema.Number,87 autoPull: Schema.Number,
88 projectIcon: Schema.NullOr(Schema.fromJsonString(ProjectIconOverride)),
87 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),89 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
88 }),90 }),
89);91);
...@@ -350,6 +352,7 @@ function mapProjectShellRow(...@@ -350,6 +352,7 @@ function mapProjectShellRow(
350 defaultThreadEnvMode: row.defaultThreadEnvMode,352 defaultThreadEnvMode: row.defaultThreadEnvMode,
351 autoPull: row.autoPull === 1,353 autoPull: row.autoPull === 1,
352 faviconPath: row.faviconPath ?? null,354 faviconPath: row.faviconPath ?? null,
355 projectIcon: row.projectIcon ?? null,
353 scripts: row.scripts,356 scripts: row.scripts,
354 createdAt: row.createdAt,357 createdAt: row.createdAt,
355 updatedAt: row.updatedAt,358 updatedAt: row.updatedAt,
...@@ -443,6 +446,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -443,6 +446,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
443 default_thread_env_mode AS "defaultThreadEnvMode",446 default_thread_env_mode AS "defaultThreadEnvMode",
444 auto_pull AS "autoPull",447 auto_pull AS "autoPull",
445 favicon_path AS "faviconPath",448 favicon_path AS "faviconPath",
449 project_icon_json AS "projectIcon",
446 scripts_json AS "scripts",450 scripts_json AS "scripts",
447 created_at AS "createdAt",451 created_at AS "createdAt",
448 updated_at AS "updatedAt",452 updated_at AS "updatedAt",
...@@ -919,6 +923,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -919,6 +923,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
919 default_thread_env_mode AS "defaultThreadEnvMode",923 default_thread_env_mode AS "defaultThreadEnvMode",
920 auto_pull AS "autoPull",924 auto_pull AS "autoPull",
921 favicon_path AS "faviconPath",925 favicon_path AS "faviconPath",
926 project_icon_json AS "projectIcon",
922 scripts_json AS "scripts",927 scripts_json AS "scripts",
923 created_at AS "createdAt",928 created_at AS "createdAt",
924 updated_at AS "updatedAt",929 updated_at AS "updatedAt",
...@@ -944,6 +949,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -944,6 +949,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
944 default_thread_env_mode AS "defaultThreadEnvMode",949 default_thread_env_mode AS "defaultThreadEnvMode",
945 auto_pull AS "autoPull",950 auto_pull AS "autoPull",
946 favicon_path AS "faviconPath",951 favicon_path AS "faviconPath",
952 project_icon_json AS "projectIcon",
947 scripts_json AS "scripts",953 scripts_json AS "scripts",
948 created_at AS "createdAt",954 created_at AS "createdAt",
949 updated_at AS "updatedAt",955 updated_at AS "updatedAt",
...@@ -1885,6 +1891,7 @@ pending_approval_requests AS (...@@ -1885,6 +1891,7 @@ pending_approval_requests AS (
1885 defaultThreadEnvMode: row.defaultThreadEnvMode,1891 defaultThreadEnvMode: row.defaultThreadEnvMode,
1886 autoPull: row.autoPull === 1,1892 autoPull: row.autoPull === 1,
1887 faviconPath: row.faviconPath ?? null,1893 faviconPath: row.faviconPath ?? null,
1894 projectIcon: row.projectIcon ?? null,
1888 scripts: row.scripts,1895 scripts: row.scripts,
1889 createdAt: row.createdAt,1896 createdAt: row.createdAt,
1890 updatedAt: row.updatedAt,1897 updatedAt: row.updatedAt,
...@@ -2021,6 +2028,7 @@ pending_approval_requests AS (...@@ -2021,6 +2028,7 @@ pending_approval_requests AS (
2021 defaultThreadEnvMode: row.defaultThreadEnvMode,2028 defaultThreadEnvMode: row.defaultThreadEnvMode,
2022 autoPull: row.autoPull === 1,2029 autoPull: row.autoPull === 1,
2023 faviconPath: row.faviconPath ?? null,2030 faviconPath: row.faviconPath ?? null,
2031 projectIcon: row.projectIcon ?? null,
2024 scripts: row.scripts,2032 scripts: row.scripts,
2025 createdAt: row.createdAt,2033 createdAt: row.createdAt,
2026 updatedAt: row.updatedAt,2034 updatedAt: row.updatedAt,
...@@ -2537,6 +2545,7 @@ pending_approval_requests AS (...@@ -2537,6 +2545,7 @@ pending_approval_requests AS (
2537 defaultThreadEnvMode: option.value.defaultThreadEnvMode,2545 defaultThreadEnvMode: option.value.defaultThreadEnvMode,
2538 autoPull: option.value.autoPull === 1,2546 autoPull: option.value.autoPull === 1,
2539 faviconPath: option.value.faviconPath ?? null,2547 faviconPath: option.value.faviconPath ?? null,
2548 projectIcon: option.value.projectIcon ?? null,
2540 scripts: option.value.scripts,2549 scripts: option.value.scripts,
2541 createdAt: option.value.createdAt,2550 createdAt: option.value.createdAt,
2542 updatedAt: option.value.updatedAt,2551 updatedAt: option.value.updatedAt,
apps/server/src/orchestration/decider.projectScripts.test.ts+7-1
...@@ -94,7 +94,7 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => {...@@ -94,7 +94,7 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => {
94 }),94 }),
95 );95 );
9696
97 it.effect("propagates a project favicon path in project.meta.update", () =>97 it.effect("propagates project icon metadata in project.meta.update", () =>
98 Effect.gen(function* () {98 Effect.gen(function* () {
99 const now = "2026-01-01T00:00:00.000Z";99 const now = "2026-01-01T00:00:00.000Z";
100 const readModel = yield* projectEvent(createEmptyReadModel(now), {100 const readModel = yield* projectEvent(createEmptyReadModel(now), {
...@@ -125,6 +125,7 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => {...@@ -125,6 +125,7 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => {
125 commandId: CommandId.make("cmd-project-update-favicon"),125 commandId: CommandId.make("cmd-project-update-favicon"),
126 projectId: asProjectId("project-favicon"),126 projectId: asProjectId("project-favicon"),
127 faviconPath: "brand/icon.svg",127 faviconPath: "brand/icon.svg",
128 projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" },
128 },129 },
129 readModel,130 readModel,
130 });131 });
...@@ -132,6 +133,11 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => {...@@ -132,6 +133,11 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => {
132 const event = Array.isArray(result) ? result[0] : result;133 const event = Array.isArray(result) ? result[0] : result;
133 expect(event.type).toBe("project.meta-updated");134 expect(event.type).toBe("project.meta-updated");
134 expect((event.payload as { faviconPath?: string }).faviconPath).toBe("brand/icon.svg");135 expect((event.payload as { faviconPath?: string }).faviconPath).toBe("brand/icon.svg");
136 expect((event.payload as { projectIcon?: unknown }).projectIcon).toEqual({
137 kind: "lucide",
138 name: "alarm-clock",
139 color: "violet",
140 });
135 }),141 }),
136 );142 );
137143
apps/server/src/orchestration/decider.ts+2
...@@ -224,6 +224,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -224,6 +224,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
224 // explicit project default.224 // explicit project default.
225 defaultModelSelection: null,225 defaultModelSelection: null,
226 faviconPath: null,226 faviconPath: null,
227 projectIcon: null,
227 scripts: [],228 scripts: [],
228 createdAt: command.createdAt,229 createdAt: command.createdAt,
229 updatedAt: command.createdAt,230 updatedAt: command.createdAt,
...@@ -266,6 +267,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -266,6 +267,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
266 : {}),267 : {}),
267 ...(command.autoPull !== undefined ? { autoPull: command.autoPull } : {}),268 ...(command.autoPull !== undefined ? { autoPull: command.autoPull } : {}),
268 ...(command.faviconPath !== undefined ? { faviconPath: command.faviconPath } : {}),269 ...(command.faviconPath !== undefined ? { faviconPath: command.faviconPath } : {}),
270 ...(command.projectIcon !== undefined ? { projectIcon: command.projectIcon } : {}),
269 ...(command.scripts !== undefined ? { scripts: command.scripts } : {}),271 ...(command.scripts !== undefined ? { scripts: command.scripts } : {}),
270 updatedAt: occurredAt,272 updatedAt: occurredAt,
271 },273 },
apps/server/src/orchestration/projector.ts+4
...@@ -217,6 +217,7 @@ export function projectEvent(...@@ -217,6 +217,7 @@ export function projectEvent(
217 defaultThreadEnvMode: null,217 defaultThreadEnvMode: null,
218 autoPull: false,218 autoPull: false,
219 faviconPath: payload.faviconPath ?? null,219 faviconPath: payload.faviconPath ?? null,
220 projectIcon: payload.projectIcon ?? null,
220 scripts: payload.scripts,221 scripts: payload.scripts,
221 createdAt: payload.createdAt,222 createdAt: payload.createdAt,
222 updatedAt: payload.updatedAt,223 updatedAt: payload.updatedAt,
...@@ -256,6 +257,9 @@ export function projectEvent(...@@ -256,6 +257,9 @@ export function projectEvent(
256 ...(payload.faviconPath !== undefined257 ...(payload.faviconPath !== undefined
257 ? { faviconPath: payload.faviconPath }258 ? { faviconPath: payload.faviconPath }
258 : {}),259 : {}),
260 ...(payload.projectIcon !== undefined
261 ? { projectIcon: payload.projectIcon }
262 : {}),
259 ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}),263 ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}),
260 updatedAt: payload.updatedAt,264 updatedAt: payload.updatedAt,
261 }265 }
apps/server/src/persistence/Layers/ProjectionProjects.ts+7-1
...@@ -6,7 +6,7 @@ import * as Option from "effect/Option";...@@ -6,7 +6,7 @@ import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
7import * as Struct from "effect/Struct";7import * as Struct from "effect/Struct";
88
9import { ModelSelection, ProjectScript } from "@t3tools/contracts";9import { ModelSelection, ProjectIconOverride, ProjectScript } from "@t3tools/contracts";
10import { toPersistenceSqlError } from "../Errors.ts";10import { toPersistenceSqlError } from "../Errors.ts";
11import {11import {
12 DeleteProjectionProjectInput,12 DeleteProjectionProjectInput,
...@@ -20,6 +20,7 @@ const ProjectionProjectDbRow = ProjectionProject.mapFields(...@@ -20,6 +20,7 @@ const ProjectionProjectDbRow = ProjectionProject.mapFields(
20 Struct.assign({20 Struct.assign({
21 defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),21 defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),
22 autoPull: Schema.Number,22 autoPull: Schema.Number,
23 projectIcon: Schema.NullOr(Schema.fromJsonString(ProjectIconOverride)),
23 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),24 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
24 }),25 }),
25);26);
...@@ -40,6 +41,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {...@@ -40,6 +41,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
40 default_thread_env_mode,41 default_thread_env_mode,
41 auto_pull,42 auto_pull,
42 favicon_path,43 favicon_path,
44 project_icon_json,
43 scripts_json,45 scripts_json,
44 created_at,46 created_at,
45 updated_at,47 updated_at,
...@@ -53,6 +55,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {...@@ -53,6 +55,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
53 ${row.defaultThreadEnvMode},55 ${row.defaultThreadEnvMode},
54 ${row.autoPull ? 1 : 0},56 ${row.autoPull ? 1 : 0},
55 ${row.faviconPath ?? null},57 ${row.faviconPath ?? null},
58 ${row.projectIcon ? JSON.stringify(row.projectIcon) : null},
56 ${JSON.stringify(row.scripts)},59 ${JSON.stringify(row.scripts)},
57 ${row.createdAt},60 ${row.createdAt},
58 ${row.updatedAt},61 ${row.updatedAt},
...@@ -66,6 +69,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {...@@ -66,6 +69,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
66 default_thread_env_mode = excluded.default_thread_env_mode,69 default_thread_env_mode = excluded.default_thread_env_mode,
67 auto_pull = excluded.auto_pull,70 auto_pull = excluded.auto_pull,
68 favicon_path = excluded.favicon_path,71 favicon_path = excluded.favicon_path,
72 project_icon_json = excluded.project_icon_json,
69 scripts_json = excluded.scripts_json,73 scripts_json = excluded.scripts_json,
70 created_at = excluded.created_at,74 created_at = excluded.created_at,
71 updated_at = excluded.updated_at,75 updated_at = excluded.updated_at,
...@@ -86,6 +90,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {...@@ -86,6 +90,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
86 default_thread_env_mode AS "defaultThreadEnvMode",90 default_thread_env_mode AS "defaultThreadEnvMode",
87 auto_pull AS "autoPull",91 auto_pull AS "autoPull",
88 favicon_path AS "faviconPath",92 favicon_path AS "faviconPath",
93 project_icon_json AS "projectIcon",
89 scripts_json AS "scripts",94 scripts_json AS "scripts",
90 created_at AS "createdAt",95 created_at AS "createdAt",
91 updated_at AS "updatedAt",96 updated_at AS "updatedAt",
...@@ -108,6 +113,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {...@@ -108,6 +113,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
108 default_thread_env_mode AS "defaultThreadEnvMode",113 default_thread_env_mode AS "defaultThreadEnvMode",
109 auto_pull AS "autoPull",114 auto_pull AS "autoPull",
110 favicon_path AS "faviconPath",115 favicon_path AS "faviconPath",
116 project_icon_json AS "projectIcon",
111 scripts_json AS "scripts",117 scripts_json AS "scripts",
112 created_at AS "createdAt",118 created_at AS "createdAt",
113 updated_at AS "updatedAt",119 updated_at AS "updatedAt",
apps/server/src/persistence/Migrations.ts+2
...@@ -58,6 +58,7 @@ import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts";...@@ -58,6 +58,7 @@ import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts";
58import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.ts";58import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.ts";
59import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts";59import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts";
60import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts";60import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts";
61import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts";
6162
62/**63/**
63 * Migration loader with all migrations defined inline.64 * Migration loader with all migrations defined inline.
...@@ -116,6 +117,7 @@ export const migrationEntries = [...@@ -116,6 +117,7 @@ export const migrationEntries = [
116 [44, "ClearAutomaticProjectModelDefaults", Migration0044],117 [44, "ClearAutomaticProjectModelDefaults", Migration0044],
117 [45, "ProjectionProjectsAutoPull", Migration0045],118 [45, "ProjectionProjectsAutoPull", Migration0045],
118 [46, "RepairAutomaticSettlementTimestamps", Migration0046],119 [46, "RepairAutomaticSettlementTimestamps", Migration0046],
120 [47, "ProjectionProjectIcon", Migration0047],
119] as const;121] as const;
120122
121export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);123export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/047_ProjectionProjectIcon.test.ts created+28
...@@ -0,0 +1,28 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";
5
6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
8
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
10
11layer("047_ProjectionProjectIcon", (it) => {
12 it.effect("adds the nullable project icon JSON to project projections", () =>
13 Effect.gen(function* () {
14 const sql = yield* SqlClient.SqlClient;
15
16 yield* runMigrations({ toMigrationInclusive: 46 });
17 yield* runMigrations({ toMigrationInclusive: 47 });
18
19 const columns = yield* sql<{ readonly name: string; readonly notnull: number }>`
20 PRAGMA table_info(projection_projects)
21 `;
22 const projectIcon = columns.find((column) => column.name === "project_icon_json");
23
24 assert.equal(projectIcon?.name, "project_icon_json");
25 assert.equal(projectIcon?.notnull, 0);
26 }),
27 );
28});
apps/server/src/persistence/Migrations/047_ProjectionProjectIcon.ts created+16
...@@ -0,0 +1,16 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6 const columns = yield* sql<{ readonly name: string }>`
7 PRAGMA table_info(projection_projects)
8 `;
9
10 if (!columns.some((column) => column.name === "project_icon_json")) {
11 yield* sql`
12 ALTER TABLE projection_projects
13 ADD COLUMN project_icon_json TEXT
14 `;
15 }
16});
apps/server/src/persistence/Services/ProjectionProjects.ts+2
...@@ -9,6 +9,7 @@...@@ -9,6 +9,7 @@
9import {9import {
10 IsoDateTime,10 IsoDateTime,
11 ModelSelection,11 ModelSelection,
12 ProjectIconOverride,
12 ProjectId,13 ProjectId,
13 ProjectScript,14 ProjectScript,
14 ThreadEnvMode,15 ThreadEnvMode,
...@@ -28,6 +29,7 @@ export const ProjectionProject = Schema.Struct({...@@ -28,6 +29,7 @@ export const ProjectionProject = Schema.Struct({
28 defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode),29 defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode),
29 autoPull: Schema.Boolean,30 autoPull: Schema.Boolean,
30 faviconPath: Schema.optional(Schema.NullOr(Schema.String)),31 faviconPath: Schema.optional(Schema.NullOr(Schema.String)),
32 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),
31 scripts: Schema.Array(ProjectScript),33 scripts: Schema.Array(ProjectScript),
32 createdAt: IsoDateTime,34 createdAt: IsoDateTime,
33 updatedAt: IsoDateTime,35 updatedAt: IsoDateTime,
apps/web/src/components/ChatView.tsx+1
...@@ -7436,6 +7436,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7436,6 +7436,7 @@ function ChatViewContent(props: ChatViewProps) {
7436 activeProjectName={activeProject?.title}7436 activeProjectName={activeProject?.title}
7437 activeProjectCwd={activeProject?.workspaceRoot ?? null}7437 activeProjectCwd={activeProject?.workspaceRoot ?? null}
7438 activeProjectFaviconPath={activeProject?.faviconPath ?? null}7438 activeProjectFaviconPath={activeProject?.faviconPath ?? null}
7439 activeProjectIcon={activeProject?.projectIcon ?? null}
7439 openInCwd={gitCwd}7440 openInCwd={gitCwd}
7440 activeProjectScripts={activeProject?.scripts}7441 activeProjectScripts={activeProject?.scripts}
7441 preferredScriptId={7442 preferredScriptId={
apps/web/src/components/CommandPalette.tsx+17-1
...@@ -181,8 +181,10 @@ function projectFavicon(project: Project) {...@@ -181,8 +181,10 @@ function projectFavicon(project: Project) {
181 <ProjectFavicon181 <ProjectFavicon
182 environmentId={project.environmentId}182 environmentId={project.environmentId}
183 cwd={project.workspaceRoot}183 cwd={project.workspaceRoot}
184 projectName={project.title}
184 faviconPath={project.faviconPath}185 faviconPath={project.faviconPath}
185 className={ITEM_ICON_CLASS}186 projectIcon={project.projectIcon}
187 className="size-4"
186 />188 />
187 );189 );
188}190}
...@@ -939,6 +941,16 @@ function OpenCommandPaletteDialog(props: {...@@ -939,6 +941,16 @@ function OpenCommandPaletteDialog(props: {
939 () => new Map(projects.map((project) => [project.id, project.faviconPath ?? null] as const)),941 () => new Map(projects.map((project) => [project.id, project.faviconPath ?? null] as const)),
940 [projects],942 [projects],
941 );943 );
944 const projectIconByKey = useMemo(
945 () =>
946 new Map(
947 projects.map(
948 (project) =>
949 [`${project.environmentId}:${project.id}`, project.projectIcon ?? null] as const,
950 ),
951 ),
952 [projects],
953 );
942 const projectTitleById = useMemo(954 const projectTitleById = useMemo(
943 () => new Map<ProjectId, string>(projects.map((project) => [project.id, project.title])),955 () => new Map<ProjectId, string>(projects.map((project) => [project.id, project.title])),
944 [projects],956 [projects],
...@@ -1170,6 +1182,9 @@ function OpenCommandPaletteDialog(props: {...@@ -1170,6 +1182,9 @@ function OpenCommandPaletteDialog(props: {
1170 environmentId={thread.environmentId}1182 environmentId={thread.environmentId}
1171 projectCwd={projectCwdById.get(thread.projectId) ?? null}1183 projectCwd={projectCwdById.get(thread.projectId) ?? null}
1172 projectFaviconPath={projectFaviconPathById.get(thread.projectId) ?? null}1184 projectFaviconPath={projectFaviconPathById.get(thread.projectId) ?? null}
1185 projectIcon={
1186 projectIconByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
1187 }
1173 projectTitle={projectTitle ?? null}1188 projectTitle={projectTitle ?? null}
1174 branch={thread.branch}1189 branch={thread.branch}
1175 worktreePath={thread.worktreePath}1190 worktreePath={thread.worktreePath}
...@@ -1209,6 +1224,7 @@ function OpenCommandPaletteDialog(props: {...@@ -1209,6 +1224,7 @@ function OpenCommandPaletteDialog(props: {
1209 navigate,1224 navigate,
1210 projectCwdById,1225 projectCwdById,
1211 projectFaviconPathById,1226 projectFaviconPathById,
1227 projectIconByKey,
1212 projectTitleById,1228 projectTitleById,
1213 providerEntryByEnvironmentAndInstanceId,1229 providerEntryByEnvironmentAndInstanceId,
1214 threadContentMatchByKey,1230 threadContentMatchByKey,
apps/web/src/components/LegacySidebar.tsx+9-5
...@@ -2340,11 +2340,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2340,11 +2340,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2340 }`}2340 }`}
2341 />2341 />
2342 )}2342 )}
2343 <ProjectFavicon2343 <span className="flex shrink-0">
2344 environmentId={project.environmentId}2344 <ProjectFavicon
2345 cwd={project.workspaceRoot}2345 environmentId={project.environmentId}
2346 faviconPath={project.faviconPath}2346 cwd={project.workspaceRoot}
2347 />2347 projectName={project.displayName}
2348 faviconPath={project.faviconPath}
2349 projectIcon={project.projectIcon}
2350 />
2351 </span>
2348 <span className="flex min-w-0 flex-1 items-center gap-2">2352 <span className="flex min-w-0 flex-1 items-center gap-2">
2349 <span className="truncate text-sm font-medium text-sidebar-foreground/90">2353 <span className="truncate text-sm font-medium text-sidebar-foreground/90">
2350 {project.displayName}2354 {project.displayName}
apps/web/src/components/ProjectFavicon.test.tsx+63
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import type { ComponentType, Dispatch, ReactElement, SetStateAction } from "react";1import type { ComponentType, Dispatch, ReactElement, SetStateAction } from "react";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3import type { EnvironmentId } from "@t3tools/contracts";3import type { EnvironmentId } from "@t3tools/contracts";
4import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
45
5const testState = vi.hoisted(() => ({6const testState = vi.hoisted(() => ({
6 faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg",7 faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg",
...@@ -52,6 +53,10 @@ vi.mock("react", async (importOriginal) => {...@@ -52,6 +53,10 @@ vi.mock("react", async (importOriginal) => {
52});53});
5354
54vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache }));55vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache }));
56vi.mock("lucide-react/dynamic", () => ({
57 DynamicIcon: "dynamic-icon",
58 iconNames: ["alarm-clock", "folder-code"],
59}));
55vi.mock("../assets/assetUrls", () => ({60vi.mock("../assets/assetUrls", () => ({
56 useAssetUrlState: (_environmentId: unknown, resource: unknown) => {61 useAssetUrlState: (_environmentId: unknown, resource: unknown) => {
57 testState.lastResource = resource;62 testState.lastResource = resource;
...@@ -86,6 +91,7 @@ function resolveImageComponent(): {...@@ -86,6 +91,7 @@ function resolveImageComponent(): {
86 const element = ProjectFavicon({91 const element = ProjectFavicon({
87 environmentId: "environment-test" as EnvironmentId,92 environmentId: "environment-test" as EnvironmentId,
88 cwd: "/workspace-test",93 cwd: "/workspace-test",
94 projectName: "workspace-test",
89 }) as ReactElement<ProjectFaviconImageProps>;95 }) as ReactElement<ProjectFaviconImageProps>;
90 hooks.reset();96 hooks.reset();
9197
...@@ -106,6 +112,62 @@ function renderImage(...@@ -106,6 +112,62 @@ function renderImage(
106describe("ProjectFavicon", () => {112describe("ProjectFavicon", () => {
107 beforeEach(() => {113 beforeEach(() => {
108 hooks.reset();114 hooks.reset();
115 testState.faviconUrl = "https://environment.test/api/assets/token-a/v1-20-favicon.svg";
116 });
117
118 it("shows a project-name emoji when no favicon exists", () => {
119 testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`;
120
121 const element = ProjectFavicon({
122 environmentId: "environment-test" as EnvironmentId,
123 cwd: "/workspace/analytics-db",
124 projectName: "analytics-db",
125 }) as ReactElement<{ readonly emoji?: string }>;
126
127 expect(element.props.emoji).toBe("๐Ÿ—„๏ธ");
128 });
129
130 it("chooses a deterministic semantic emoji", () => {
131 testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`;
132
133 const element = ProjectFavicon({
134 environmentId: "environment-test" as EnvironmentId,
135 cwd: "/workspace/agent-runtime",
136 projectName: "agent-runtime",
137 }) as ReactElement<{ readonly emoji?: string }>;
138
139 expect(element.props.emoji).toBe("๐Ÿค–");
140 });
141
142 it("renders a saved Lucide icon and color ahead of an uploaded favicon", () => {
143 const element = ProjectFavicon({
144 environmentId: "environment-test" as EnvironmentId,
145 cwd: "/workspace/test",
146 projectName: "test",
147 faviconPath: "brand/icon.svg",
148 projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" },
149 }) as ReactElement<{
150 readonly children: ReactElement<{
151 readonly children: ReactElement<{ readonly name: string; readonly className: string }>;
152 }>;
153 readonly className: string;
154 }>;
155
156 expect(element.props.children.props.children.props.name).toBe("alarm-clock");
157 expect(element.props.className).toContain("text-violet-600");
158 expect(element.props.children.props.children.props.className).toContain("text-violet-600");
159 });
160
161 it("renders a saved emoji ahead of an uploaded favicon", () => {
162 const element = ProjectFavicon({
163 environmentId: "environment-test" as EnvironmentId,
164 cwd: "/workspace/test",
165 projectName: "test",
166 faviconPath: "brand/icon.svg",
167 projectIcon: { kind: "emoji", emoji: "๐Ÿฆ„" },
168 }) as ReactElement<{ readonly emoji: string }>;
169
170 expect(element.props.emoji).toBe("๐Ÿฆ„");
109 });171 });
110172
111 it("falls back when the displayed favicon fails without discarding a valid older image early", () => {173 it("falls back when the displayed favicon fails without discarding a valid older image early", () => {
...@@ -134,6 +196,7 @@ describe("ProjectFavicon", () => {...@@ -134,6 +196,7 @@ describe("ProjectFavicon", () => {
134 ProjectFavicon({196 ProjectFavicon({
135 environmentId: "environment-test" as EnvironmentId,197 environmentId: "environment-test" as EnvironmentId,
136 cwd: "/workspace-test",198 cwd: "/workspace-test",
199 projectName: "workspace-test",
137 faviconPath: "brand/icon.svg",200 faviconPath: "brand/icon.svg",
138 });201 });
139202
apps/web/src/components/ProjectFavicon.tsx+163-9
...@@ -1,29 +1,153 @@...@@ -1,29 +1,153 @@
1import type { EnvironmentId } from "@t3tools/contracts";1import type { EnvironmentId, ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts";
2import {2import {
3 getProjectFaviconCacheKey,3 getProjectFaviconCacheKey,
4 isProjectFaviconFallbackUrl,4 isProjectFaviconFallbackUrl,
5} from "@t3tools/shared/projectFavicon";5} from "@t3tools/shared/projectFavicon";
6import { FolderIcon } from "lucide-react";6import {
7 BotIcon,
8 BookOpenIcon,
9 BracesIcon,
10 CircuitBoardIcon,
11 CloudCogIcon,
12 Code2Icon,
13 DatabaseIcon,
14 FlaskConicalIcon,
15 FolderCodeIcon,
16 Gamepad2Icon,
17 Globe2Icon,
18 ImageIcon,
19 Layers3Icon,
20 MonitorIcon,
21 MusicIcon,
22 PackageIcon,
23 ServerIcon,
24 ShieldCheckIcon,
25 ShoppingBagIcon,
26 SmartphoneIcon,
27 TerminalIcon,
28 VideoIcon,
29} from "lucide-react";
30import type { IconName } from "lucide-react/dynamic";
7import type { ComponentType } from "react";31import type { ComponentType } from "react";
8import { useState } from "react";32import { lazy, Suspense, useState } from "react";
9import { useAssetUrlState } from "../assets/assetUrls";33import { useAssetUrlState } from "../assets/assetUrls";
34import { selectProjectIcon, type ProjectIconName } from "../projectIconModel";
35import { projectIconColorClassName } from "../projectIconColors";
10import { cn } from "~/lib/utils";36import { cn } from "~/lib/utils";
1137
12const loadedProjectFaviconSrcs = new Map<string, string>();38const loadedProjectFaviconSrcs = new Map<string, string>();
39const DynamicIcon = lazy(() =>
40 import("lucide-react/dynamic").then((module) => ({ default: module.DynamicIcon })),
41);
42
43function DynamicProjectIconFallback() {
44 return <FolderCodeIcon className="size-full text-[inherit]" />;
45}
46
47const PROJECT_ICONS: Record<ProjectIconName, ComponentType<{ className?: string }>> = {
48 ai: BotIcon,
49 book: BookOpenIcon,
50 braces: BracesIcon,
51 circuit: CircuitBoardIcon,
52 cloud: CloudCogIcon,
53 code: Code2Icon,
54 database: DatabaseIcon,
55 desktop: MonitorIcon,
56 "folder-code": FolderCodeIcon,
57 game: Gamepad2Icon,
58 image: ImageIcon,
59 layers: Layers3Icon,
60 mobile: SmartphoneIcon,
61 music: MusicIcon,
62 package: PackageIcon,
63 security: ShieldCheckIcon,
64 server: ServerIcon,
65 shopping: ShoppingBagIcon,
66 terminal: TerminalIcon,
67 test: FlaskConicalIcon,
68 video: VideoIcon,
69 web: Globe2Icon,
70};
71
72const PROJECT_ICON_COLOR_BY_NAME: Record<ProjectIconName, ProjectIconColor> = {
73 ai: "violet",
74 book: "amber",
75 braces: "purple",
76 circuit: "teal",
77 cloud: "sky",
78 code: "blue",
79 database: "cyan",
80 desktop: "indigo",
81 "folder-code": "orange",
82 game: "emerald",
83 image: "pink",
84 layers: "fuchsia",
85 mobile: "lime",
86 music: "fuchsia",
87 package: "orange",
88 security: "teal",
89 server: "blue",
90 shopping: "rose",
91 terminal: "green",
92 test: "yellow",
93 video: "red",
94 web: "sky",
95};
1396
14export function ProjectFavicon(input: {97export function ProjectFavicon(input: {
15 environmentId: EnvironmentId;98 environmentId: EnvironmentId;
16 cwd: string;99 cwd: string;
100 projectName: string;
17 faviconPath?: string | null | undefined;101 faviconPath?: string | null | undefined;
102 projectIcon?: ProjectIconOverride | null | undefined;
18 className?: string | undefined;103 className?: string | undefined;
19 fallbackIcon?: ComponentType<{ className?: string }>;104 fallbackIcon?: ComponentType<{ className?: string }>;
20}) {105}) {
21 const state = useProjectFaviconAsset(input);106 const state = useProjectFaviconAsset(input);
22 const src = state._tag === "Success" ? state.url : null;107 const src = state._tag === "Success" ? state.url : null;
23 const FallbackIcon = input.fallbackIcon ?? FolderIcon;108 if (input.projectIcon?.kind === "emoji") {
109 return <ProjectFaviconFallback className={input.className} emoji={input.projectIcon.emoji} />;
110 }
111 if (input.projectIcon?.kind === "lucide") {
112 const colorClassName = projectIconColorClassName(input.projectIcon.color);
113 const iconClassName = cn(
114 "inline-flex size-3.5 shrink-0 items-center justify-center",
115 colorClassName,
116 input.className,
117 );
118 return (
119 <span aria-hidden="true" className={iconClassName}>
120 <Suspense fallback={<DynamicProjectIconFallback />}>
121 <DynamicIcon
122 name={input.projectIcon.name as IconName}
123 className={cn("size-full", colorClassName)}
124 fallback={DynamicProjectIconFallback}
125 />
126 </Suspense>
127 </span>
128 );
129 }
130 const automaticIconName = input.fallbackIcon
131 ? null
132 : selectProjectIcon(input.projectName, input.cwd);
133 const FallbackIcon =
134 input.fallbackIcon ??
135 (automaticIconName?.kind === "lucide" ? PROJECT_ICONS[automaticIconName.icon] : undefined);
136 const fallbackEmoji = automaticIconName?.kind === "emoji" ? automaticIconName.emoji : undefined;
137 const fallbackColorClassName =
138 automaticIconName?.kind === "lucide"
139 ? projectIconColorClassName(PROJECT_ICON_COLOR_BY_NAME[automaticIconName.icon])
140 : undefined;
24141
25 if (!src || isProjectFaviconFallbackUrl(src)) {142 if (!src || isProjectFaviconFallbackUrl(src)) {
26 return <ProjectFaviconFallback className={input.className} icon={FallbackIcon} />;143 return (
144 <ProjectFaviconFallback
145 className={input.className}
146 colorClassName={fallbackColorClassName}
147 icon={FallbackIcon}
148 emoji={fallbackEmoji}
149 />
150 );
27 }151 }
28152
29 const cacheKey = getProjectFaviconCacheKey(input.environmentId, input.cwd, src);153 const cacheKey = getProjectFaviconCacheKey(input.environmentId, input.cwd, src);
...@@ -35,6 +159,8 @@ export function ProjectFavicon(input: {...@@ -35,6 +159,8 @@ export function ProjectFavicon(input: {
35 src={src}159 src={src}
36 className={input.className}160 className={input.className}
37 fallbackIcon={FallbackIcon}161 fallbackIcon={FallbackIcon}
162 fallbackEmoji={fallbackEmoji}
163 fallbackColorClassName={fallbackColorClassName}
38 />164 />
39 );165 );
40}166}
...@@ -53,12 +179,31 @@ export function useProjectFaviconAsset(input: {...@@ -53,12 +179,31 @@ export function useProjectFaviconAsset(input: {
53179
54function ProjectFaviconFallback({180function ProjectFaviconFallback({
55 className,181 className,
182 colorClassName,
56 icon: Icon,183 icon: Icon,
184 emoji,
57}: {185}: {
58 readonly className?: string | undefined;186 readonly className?: string | undefined;
59 readonly icon: ComponentType<{ className?: string }>;187 readonly colorClassName?: string | undefined;
188 readonly icon?: ComponentType<{ className?: string }> | undefined;
189 readonly emoji?: string | undefined;
60}) {190}) {
61 return <Icon className={cn("size-3.5 shrink-0 text-icon-muted", className)} />;191 if (emoji) {
192 return (
193 <span
194 aria-hidden="true"
195 className={cn(
196 "inline-flex size-3.5 shrink-0 items-center justify-center leading-none [container-type:size]",
197 className,
198 )}
199 >
200 <span className="text-[length:80cqh] leading-none">{emoji}</span>
201 </span>
202 );
203 }
204
205 if (!Icon) return null;
206 return <Icon className={cn("size-3.5 shrink-0 text-icon-muted", colorClassName, className)} />;
62}207}
63208
64function ProjectFaviconImage({209function ProjectFaviconImage({
...@@ -66,11 +211,15 @@ function ProjectFaviconImage({...@@ -66,11 +211,15 @@ function ProjectFaviconImage({
66 src,211 src,
67 className,212 className,
68 fallbackIcon: FallbackIcon,213 fallbackIcon: FallbackIcon,
214 fallbackEmoji,
215 fallbackColorClassName,
69}: {216}: {
70 readonly cacheKey: string;217 readonly cacheKey: string;
71 readonly src: string;218 readonly src: string;
72 readonly className?: string | undefined;219 readonly className?: string | undefined;
73 readonly fallbackIcon: ComponentType<{ className?: string }>;220 readonly fallbackIcon?: ComponentType<{ className?: string }> | undefined;
221 readonly fallbackEmoji?: string | undefined;
222 readonly fallbackColorClassName?: string | undefined;
74}) {223}) {
75 const [displayedSrc, setDisplayedSrc] = useState<string | null>(224 const [displayedSrc, setDisplayedSrc] = useState<string | null>(
76 () => loadedProjectFaviconSrcs.get(cacheKey) ?? null,225 () => loadedProjectFaviconSrcs.get(cacheKey) ?? null,
...@@ -86,7 +235,12 @@ function ProjectFaviconImage({...@@ -86,7 +235,12 @@ function ProjectFaviconImage({
86 return (235 return (
87 <>236 <>
88 {displayedSrc === null ? (237 {displayedSrc === null ? (
89 <ProjectFaviconFallback className={className} icon={FallbackIcon} />238 <ProjectFaviconFallback
239 className={className}
240 colorClassName={fallbackColorClassName}
241 icon={FallbackIcon}
242 emoji={fallbackEmoji}
243 />
90 ) : null}244 ) : null}
91 {displayedSrc ? (245 {displayedSrc ? (
92 <img246 <img
apps/web/src/components/Sidebar.tsx+49-7
...@@ -32,6 +32,7 @@ import {...@@ -32,6 +32,7 @@ import {
32import {32import {
33 resolveEnvironmentMachineKind,33 resolveEnvironmentMachineKind,
34 type EnvironmentMachineKind,34 type EnvironmentMachineKind,
35 type ProjectIconOverride,
35 type ScopedThreadRef,36 type ScopedThreadRef,
36 type ThreadId,37 type ThreadId,
37} from "@t3tools/contracts";38} from "@t3tools/contracts";
...@@ -278,6 +279,7 @@ function SidebarThreadTooltip({...@@ -278,6 +279,7 @@ function SidebarThreadTooltip({
278 projectTitle,279 projectTitle,
279 projectCwd,280 projectCwd,
280 projectFaviconPath,281 projectFaviconPath,
282 projectIcon,
281 environmentLabel,283 environmentLabel,
282 environmentMachine,284 environmentMachine,
283 providerEntry,285 providerEntry,
...@@ -292,6 +294,7 @@ function SidebarThreadTooltip({...@@ -292,6 +294,7 @@ function SidebarThreadTooltip({
292 projectTitle: string | null;294 projectTitle: string | null;
293 projectCwd: string | null;295 projectCwd: string | null;
294 projectFaviconPath: string | null;296 projectFaviconPath: string | null;
297 projectIcon: ProjectIconOverride | null;
295 environmentLabel: string | null;298 environmentLabel: string | null;
296 environmentMachine: EnvironmentMachineKind;299 environmentMachine: EnvironmentMachineKind;
297 providerEntry: ProviderInstanceEntry | null;300 providerEntry: ProviderInstanceEntry | null;
...@@ -324,8 +327,10 @@ function SidebarThreadTooltip({...@@ -324,8 +327,10 @@ function SidebarThreadTooltip({
324 <ProjectFavicon327 <ProjectFavicon
325 environmentId={thread.environmentId}328 environmentId={thread.environmentId}
326 cwd={projectCwd ?? ""}329 cwd={projectCwd ?? ""}
330 projectName={projectTitle}
327 faviconPath={projectFaviconPath}331 faviconPath={projectFaviconPath}
328 className="size-3 shrink-0 stroke-muted-foreground"332 projectIcon={projectIcon}
333 className="size-3 shrink-0"
329 />334 />
330 <div className="min-w-0 truncate text-foreground/75">{projectTitle}</div>335 <div className="min-w-0 truncate text-foreground/75">{projectTitle}</div>
331 </div>336 </div>
...@@ -495,6 +500,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {...@@ -495,6 +500,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
495 projectTitle: string | null;500 projectTitle: string | null;
496 projectCwd: string | null;501 projectCwd: string | null;
497 projectFaviconPath: string | null;502 projectFaviconPath: string | null;
503 projectIcon: ProjectIconOverride | null;
498 isActive: boolean;504 isActive: boolean;
499 onNavigate: (draftId: DraftId) => void;505 onNavigate: (draftId: DraftId) => void;
500 onDiscard: (draftId: DraftId) => void;506 onDiscard: (draftId: DraftId) => void;
...@@ -560,7 +566,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {...@@ -560,7 +566,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
560 <ProjectFavicon566 <ProjectFavicon
561 environmentId={session.environmentId}567 environmentId={session.environmentId}
562 cwd={props.projectCwd ?? ""}568 cwd={props.projectCwd ?? ""}
569 projectName={props.projectTitle ?? ""}
563 faviconPath={props.projectFaviconPath}570 faviconPath={props.projectFaviconPath}
571 projectIcon={props.projectIcon}
564 className="size-4 shrink-0"572 className="size-4 shrink-0"
565 />573 />
566 <span className="min-w-0 flex-1 truncate text-xs font-medium text-secondary-label">574 <span className="min-w-0 flex-1 truncate text-xs font-medium text-secondary-label">
...@@ -605,6 +613,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {...@@ -605,6 +613,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
605 projectDisplayNameByKey: ReadonlyMap<string, string>;613 projectDisplayNameByKey: ReadonlyMap<string, string>;
606 projectCwdByKey: ReadonlyMap<string, string>;614 projectCwdByKey: ReadonlyMap<string, string>;
607 projectFaviconPathByKey: ReadonlyMap<string, string | null | undefined>;615 projectFaviconPathByKey: ReadonlyMap<string, string | null | undefined>;
616 projectIconByKey: ReadonlyMap<string, ProjectIconOverride | null | undefined>;
608 scopedProjectKeys: ReadonlySet<string> | null;617 scopedProjectKeys: ReadonlySet<string> | null;
609 routeDraftId: string | null;618 routeDraftId: string | null;
610 onNavigateToDraft: (draftId: DraftId) => void;619 onNavigateToDraft: (draftId: DraftId) => void;
...@@ -701,6 +710,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {...@@ -701,6 +710,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
701 projectTitle={props.projectDisplayNameByKey.get(projectKey) ?? null}710 projectTitle={props.projectDisplayNameByKey.get(projectKey) ?? null}
702 projectCwd={props.projectCwdByKey.get(projectKey) ?? null}711 projectCwd={props.projectCwdByKey.get(projectKey) ?? null}
703 projectFaviconPath={props.projectFaviconPathByKey.get(projectKey) ?? null}712 projectFaviconPath={props.projectFaviconPathByKey.get(projectKey) ?? null}
713 projectIcon={props.projectIconByKey.get(projectKey) ?? null}
704 isActive={draftId === props.routeDraftId}714 isActive={draftId === props.routeDraftId}
705 onNavigate={props.onNavigateToDraft}715 onNavigate={props.onNavigateToDraft}
706 onDiscard={handleDiscard}716 onDiscard={handleDiscard}
...@@ -748,6 +758,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -748,6 +758,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
748 environmentMachine: EnvironmentMachineKind;758 environmentMachine: EnvironmentMachineKind;
749 projectCwd: string | null;759 projectCwd: string | null;
750 projectFaviconPath: string | null;760 projectFaviconPath: string | null;
761 projectIcon: ProjectIconOverride | null;
751 projectTitle: string | null;762 projectTitle: string | null;
752 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;763 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;
753 timestampFormat: TimestampFormat;764 timestampFormat: TimestampFormat;
...@@ -986,6 +997,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -986,6 +997,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
986 projectTitle={props.projectTitle}997 projectTitle={props.projectTitle}
987 projectCwd={props.projectCwd}998 projectCwd={props.projectCwd}
988 projectFaviconPath={props.projectFaviconPath}999 projectFaviconPath={props.projectFaviconPath}
1000 projectIcon={props.projectIcon}
989 environmentLabel={props.environmentLabel}1001 environmentLabel={props.environmentLabel}
990 environmentMachine={props.environmentMachine}1002 environmentMachine={props.environmentMachine}
991 providerEntry={providerEntry}1003 providerEntry={providerEntry}
...@@ -1293,7 +1305,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1293,7 +1305,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1293 <ProjectFavicon1305 <ProjectFavicon
1294 environmentId={thread.environmentId}1306 environmentId={thread.environmentId}
1295 cwd={props.projectCwd ?? ""}1307 cwd={props.projectCwd ?? ""}
1308 projectName={props.projectTitle ?? thread.title}
1296 faviconPath={props.projectFaviconPath}1309 faviconPath={props.projectFaviconPath}
1310 projectIcon={props.projectIcon}
1297 className="size-4"1311 className="size-4"
1298 fallbackIcon={MessageSquareIcon}1312 fallbackIcon={MessageSquareIcon}
1299 />1313 />
...@@ -1448,7 +1462,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1448,7 +1462,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1448 <ProjectFavicon1462 <ProjectFavicon
1449 environmentId={thread.environmentId}1463 environmentId={thread.environmentId}
1450 cwd={props.projectCwd ?? ""}1464 cwd={props.projectCwd ?? ""}
1465 projectName={props.projectTitle ?? ""}
1451 faviconPath={props.projectFaviconPath}1466 faviconPath={props.projectFaviconPath}
1467 projectIcon={props.projectIcon}
1452 className="size-4 shrink-0"1468 className="size-4 shrink-0"
1453 />1469 />
1454 {props.projectTitle ? (1470 {props.projectTitle ? (
...@@ -1653,6 +1669,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {...@@ -1653,6 +1669,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
1653 thread: SidebarThreadSummary;1669 thread: SidebarThreadSummary;
1654 projectCwd: string | null;1670 projectCwd: string | null;
1655 projectFaviconPath: string | null;1671 projectFaviconPath: string | null;
1672 projectIcon: ProjectIconOverride | null;
1656 projectTitle: string | null;1673 projectTitle: string | null;
1657 environmentLabel: string | null;1674 environmentLabel: string | null;
1658 environmentMachine: EnvironmentMachineKind;1675 environmentMachine: EnvironmentMachineKind;
...@@ -1736,7 +1753,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {...@@ -1736,7 +1753,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
1736 <ProjectFavicon1753 <ProjectFavicon
1737 environmentId={thread.environmentId}1754 environmentId={thread.environmentId}
1738 cwd={props.projectCwd ?? ""}1755 cwd={props.projectCwd ?? ""}
1756 projectName={props.projectTitle ?? thread.title}
1739 faviconPath={props.projectFaviconPath}1757 faviconPath={props.projectFaviconPath}
1758 projectIcon={props.projectIcon}
1740 className="size-4 shrink-0"1759 className="size-4 shrink-0"
1741 fallbackIcon={MessageSquareIcon}1760 fallbackIcon={MessageSquareIcon}
1742 />1761 />
...@@ -1750,6 +1769,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {...@@ -1750,6 +1769,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
1750 projectTitle={props.projectTitle}1769 projectTitle={props.projectTitle}
1751 projectCwd={props.projectCwd}1770 projectCwd={props.projectCwd}
1752 projectFaviconPath={props.projectFaviconPath}1771 projectFaviconPath={props.projectFaviconPath}
1772 projectIcon={props.projectIcon}
1753 environmentLabel={props.environmentLabel}1773 environmentLabel={props.environmentLabel}
1754 environmentMachine={props.environmentMachine}1774 environmentMachine={props.environmentMachine}
1755 providerEntry={providerEntry}1775 providerEntry={providerEntry}
...@@ -1971,6 +1991,13 @@ export default function Sidebar() {...@@ -1971,6 +1991,13 @@ export default function Sidebar() {
1971 ),1991 ),
1972 [projects],1992 [projects],
1973 );1993 );
1994 const projectIconByKey = useMemo(
1995 () =>
1996 new Map(
1997 projects.map((project) => [`${project.environmentId}:${project.id}`, project.projectIcon]),
1998 ),
1999 [projects],
2000 );
1974 const projectDisplayNameByKey = useMemo(2001 const projectDisplayNameByKey = useMemo(
1975 () =>2002 () =>
1976 new Map(2003 new Map(
...@@ -3603,12 +3630,16 @@ export default function Sidebar() {...@@ -3603,12 +3630,16 @@ export default function Sidebar() {
3603 }3630 }
3604 >3631 >
3605 {scopedProjectGroup ? (3632 {scopedProjectGroup ? (
3606 <ProjectFavicon3633 <span className="flex shrink-0">
3607 environmentId={scopedProjectGroup.environmentId}3634 <ProjectFavicon
3608 cwd={scopedProjectGroup.workspaceRoot}3635 environmentId={scopedProjectGroup.environmentId}
3609 faviconPath={scopedProjectGroup.faviconPath}3636 cwd={scopedProjectGroup.workspaceRoot}
3610 className="size-4 shrink-0"3637 projectName={scopedProjectGroup.displayName}
3611 />3638 faviconPath={scopedProjectGroup.faviconPath}
3639 projectIcon={scopedProjectGroup.projectIcon}
3640 className="size-4"
3641 />
3642 </span>
3612 ) : (3643 ) : (
3613 <FolderIcon className="size-4 shrink-0" />3644 <FolderIcon className="size-4 shrink-0" />
3614 )}3645 )}
...@@ -3661,7 +3692,9 @@ export default function Sidebar() {...@@ -3661,7 +3692,9 @@ export default function Sidebar() {
3661 <ProjectFavicon3692 <ProjectFavicon
3662 environmentId={project.environmentId}3693 environmentId={project.environmentId}
3663 cwd={project.workspaceRoot}3694 cwd={project.workspaceRoot}
3695 projectName={project.displayName}
3664 faviconPath={project.faviconPath}3696 faviconPath={project.faviconPath}
3697 projectIcon={project.projectIcon}
3665 className="size-4 shrink-0"3698 className="size-4 shrink-0"
3666 />3699 />
3667 ) : (3700 ) : (
...@@ -3745,6 +3778,10 @@ export default function Sidebar() {...@@ -3745,6 +3778,10 @@ export default function Sidebar() {
3745 `${thread.environmentId}:${thread.projectId}`,3778 `${thread.environmentId}:${thread.projectId}`,
3746 ) ?? null3779 ) ?? null
3747 }3780 }
3781 projectIcon={
3782 projectIconByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
3783 null
3784 }
3748 projectTitle={3785 projectTitle={
3749 projectDisplayNameByKey.get(3786 projectDisplayNameByKey.get(
3750 `${thread.environmentId}:${thread.projectId}`,3787 `${thread.environmentId}:${thread.projectId}`,
...@@ -3863,6 +3900,10 @@ export default function Sidebar() {...@@ -3863,6 +3900,10 @@ export default function Sidebar() {
3863 `${thread.environmentId}:${thread.projectId}`,3900 `${thread.environmentId}:${thread.projectId}`,
3864 ) ?? null3901 ) ?? null
3865 }3902 }
3903 projectIcon={
3904 projectIconByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
3905 null
3906 }
3866 projectTitle={3907 projectTitle={
3867 projectDisplayNameByKey.get(3908 projectDisplayNameByKey.get(
3868 `${thread.environmentId}:${thread.projectId}`,3909 `${thread.environmentId}:${thread.projectId}`,
...@@ -3906,6 +3947,7 @@ export default function Sidebar() {...@@ -3906,6 +3947,7 @@ export default function Sidebar() {
3906 projectDisplayNameByKey={projectDisplayNameByKey}3947 projectDisplayNameByKey={projectDisplayNameByKey}
3907 projectCwdByKey={projectCwdByKey}3948 projectCwdByKey={projectCwdByKey}
3908 projectFaviconPathByKey={projectFaviconPathByKey}3949 projectFaviconPathByKey={projectFaviconPathByKey}
3950 projectIconByKey={projectIconByKey}
3909 scopedProjectKeys={scopedProjectKeys}3951 scopedProjectKeys={scopedProjectKeys}
3910 routeDraftId={routeDraftIdForRows}3952 routeDraftId={routeDraftIdForRows}
3911 onNavigateToDraft={navigateToDraft}3953 onNavigateToDraft={navigateToDraft}
apps/web/src/components/ThreadCommandSubtitle.tsx+3
...@@ -38,6 +38,7 @@ export function ThreadCommandSubtitle(props: {...@@ -38,6 +38,7 @@ export function ThreadCommandSubtitle(props: {
38 environmentId: EnvironmentId;38 environmentId: EnvironmentId;
39 projectCwd: string | null;39 projectCwd: string | null;
40 projectFaviconPath?: string | null;40 projectFaviconPath?: string | null;
41 projectIcon?: import("@t3tools/contracts").ProjectIconOverride | null;
41 projectTitle: string | null;42 projectTitle: string | null;
42 branch: string | null;43 branch: string | null;
43 worktreePath: string | null;44 worktreePath: string | null;
...@@ -72,7 +73,9 @@ export function ThreadCommandSubtitle(props: {...@@ -72,7 +73,9 @@ export function ThreadCommandSubtitle(props: {
72 <ProjectFavicon73 <ProjectFavicon
73 environmentId={props.environmentId}74 environmentId={props.environmentId}
74 cwd={props.projectCwd}75 cwd={props.projectCwd}
76 projectName={projectLabel}
75 faviconPath={props.projectFaviconPath}77 faviconPath={props.projectFaviconPath}
78 projectIcon={props.projectIcon}
76 className="size-3 shrink-0"79 className="size-3 shrink-0"
77 />80 />
78 ) : null}81 ) : null}
apps/web/src/components/chat/ChatHeader.tsx+4
...@@ -56,6 +56,7 @@ interface ChatHeaderProps {...@@ -56,6 +56,7 @@ interface ChatHeaderProps {
56 activeProjectName: string | undefined;56 activeProjectName: string | undefined;
57 activeProjectCwd: string | null;57 activeProjectCwd: string | null;
58 activeProjectFaviconPath: string | null;58 activeProjectFaviconPath: string | null;
59 activeProjectIcon: import("@t3tools/contracts").ProjectIconOverride | null;
59 openInCwd: string | null;60 openInCwd: string | null;
60 activeProjectScripts: ReadonlyArray<ProjectScript> | undefined;61 activeProjectScripts: ReadonlyArray<ProjectScript> | undefined;
61 preferredScriptId: string | null;62 preferredScriptId: string | null;
...@@ -126,6 +127,7 @@ export const ChatHeader = memo(function ChatHeader({...@@ -126,6 +127,7 @@ export const ChatHeader = memo(function ChatHeader({
126 activeProjectName,127 activeProjectName,
127 activeProjectCwd,128 activeProjectCwd,
128 activeProjectFaviconPath,129 activeProjectFaviconPath,
130 activeProjectIcon,
129 openInCwd,131 openInCwd,
130 activeProjectScripts,132 activeProjectScripts,
131 preferredScriptId,133 preferredScriptId,
...@@ -325,7 +327,9 @@ export const ChatHeader = memo(function ChatHeader({...@@ -325,7 +327,9 @@ export const ChatHeader = memo(function ChatHeader({
325 <ProjectFavicon327 <ProjectFavicon
326 environmentId={activeThreadEnvironmentId}328 environmentId={activeThreadEnvironmentId}
327 cwd={activeProjectCwd ?? ""}329 cwd={activeProjectCwd ?? ""}
330 projectName={activeProjectName}
328 faviconPath={activeProjectFaviconPath}331 faviconPath={activeProjectFaviconPath}
332 projectIcon={activeProjectIcon}
329 className="size-3.5"333 className="size-3.5"
330 />334 />
331 <span className="max-w-40 truncate">{activeProjectName}</span>335 <span className="max-w-40 truncate">{activeProjectName}</span>
apps/web/src/components/pullRequest/PullRequestListFilters.tsx+14-1
...@@ -2,6 +2,7 @@ import type {...@@ -2,6 +2,7 @@ import type {
2 EnvironmentId,2 EnvironmentId,
3 ProjectId,3 ProjectId,
4 PullRequestInvolvement,4 PullRequestInvolvement,
5 ProjectIconOverride,
5 PullRequestListFilters,6 PullRequestListFilters,
6 PullRequestListState,7 PullRequestListState,
7 SourceControlProviderKind,8 SourceControlProviderKind,
...@@ -58,6 +59,8 @@ export interface PullRequestFilterOption<Value extends string> {...@@ -58,6 +59,8 @@ export interface PullRequestFilterOption<Value extends string> {
58 readonly favicon?: {59 readonly favicon?: {
59 readonly environmentId: EnvironmentId;60 readonly environmentId: EnvironmentId;
60 readonly cwd: string;61 readonly cwd: string;
62 readonly faviconPath?: string | null;
63 readonly projectIcon?: ProjectIconOverride | null;
61 };64 };
62 /** Why it cannot be chosen, carried onto the item as its title. */65 /** Why it cannot be chosen, carried onto the item as its title. */
63 readonly unavailable?: string | undefined;66 readonly unavailable?: string | undefined;
...@@ -72,6 +75,9 @@ export function PullRequestFilterOptionIcon<Value extends string>({...@@ -72,6 +75,9 @@ export function PullRequestFilterOptionIcon<Value extends string>({
72 <ProjectFavicon75 <ProjectFavicon
73 environmentId={option.favicon.environmentId}76 environmentId={option.favicon.environmentId}
74 cwd={option.favicon.cwd}77 cwd={option.favicon.cwd}
78 projectName={option.label}
79 faviconPath={option.favicon.faviconPath}
80 projectIcon={option.favicon.projectIcon}
75 fallbackIcon={FolderGit2Icon}81 fallbackIcon={FolderGit2Icon}
76 className="size-3.5"82 className="size-3.5"
77 />83 />
...@@ -450,6 +456,8 @@ export function PullRequestFiltersMenu({...@@ -450,6 +456,8 @@ export function PullRequestFiltersMenu({
450 readonly environmentId: EnvironmentId;456 readonly environmentId: EnvironmentId;
451 readonly title: string;457 readonly title: string;
452 readonly workspaceRoot: string;458 readonly workspaceRoot: string;
459 readonly faviconPath?: string | null;
460 readonly projectIcon?: ProjectIconOverride | null;
453 }>;461 }>;
454 projectId: ProjectId | undefined;462 projectId: ProjectId | undefined;
455 /**463 /**
...@@ -505,7 +513,12 @@ export function PullRequestFiltersMenu({...@@ -505,7 +513,12 @@ export function PullRequestFiltersMenu({
505 value: pullRequestProjectKey(project),513 value: pullRequestProjectKey(project),
506 label: project.title,514 label: project.title,
507 Icon: FolderGit2Icon,515 Icon: FolderGit2Icon,
508 favicon: { environmentId: project.environmentId, cwd: project.workspaceRoot },516 favicon: {
517 environmentId: project.environmentId,
518 cwd: project.workspaceRoot,
519 faviconPath: project.faviconPath ?? null,
520 projectIcon: project.projectIcon ?? null,
521 },
509 ...(unavailable.has(pullRequestProjectKey(project))522 ...(unavailable.has(pullRequestProjectKey(project))
510 ? { unavailable: unavailable.get(pullRequestProjectKey(project)) }523 ? { unavailable: unavailable.get(pullRequestProjectKey(project)) }
511 : {}),524 : {}),
apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx created+51
...@@ -0,0 +1,51 @@
1import type { ReactNode } from "react";
2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it, vi } from "vite-plus/test";
4
5vi.mock("../ui/button", () => ({
6 Button: ({ children }: { readonly children?: ReactNode }) => <button>{children}</button>,
7}));
8
9vi.mock("../ui/dialog", () => {
10 const Container = ({ children }: { readonly children?: ReactNode }) => <div>{children}</div>;
11 return {
12 Dialog: Container,
13 DialogDescription: Container,
14 DialogFooter: Container,
15 DialogHeader: Container,
16 DialogPanel: Container,
17 DialogPopup: Container,
18 DialogTitle: Container,
19 };
20});
21
22vi.mock("../ui/input", () => ({ Input: () => <input /> }));
23vi.mock("../ui/scroll-area", () => ({
24 ScrollArea: ({ children }: { readonly children?: ReactNode }) => <div>{children}</div>,
25}));
26vi.mock("../ui/toggle-group", () => ({
27 Toggle: ({ children, value }: { readonly children?: ReactNode; readonly value: string }) => (
28 <button data-value={value}>{children}</button>
29 ),
30 ToggleGroup: ({
31 children,
32 value,
33 }: {
34 readonly children?: ReactNode;
35 readonly value: readonly string[];
36 }) => <div data-current={value.join(",")}>{children}</div>,
37}));
38
39import { ProjectIconPickerDialog } from "./ProjectIconPickerDialog";
40
41describe("ProjectIconPickerDialog", () => {
42 it("shows emoji first and selects it for an automatic project", () => {
43 const markup = renderToStaticMarkup(
44 <ProjectIconPickerDialog current={null} open onOpenChange={() => {}} onSelect={() => {}} />,
45 );
46
47 expect(markup).toContain('data-current="emoji"');
48 expect(markup.indexOf(">Emoji<")).toBeLessThan(markup.indexOf(">Icons<"));
49 expect(markup).toContain("Or paste any emoji");
50 });
51});
apps/web/src/components/settings/ProjectIconPickerDialog.tsx created+205
...@@ -0,0 +1,205 @@
1import type { ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts";
2import { DynamicIcon, type IconName } from "lucide-react/dynamic";
3import { useEffect, useMemo, useRef, useState } from "react";
4import {
5 filterProjectIconNames,
6 firstEmoji,
7 PROJECT_EMOJIS,
8 PROJECT_ICON_COLORS,
9 projectIconColorClassName,
10} from "../../projectIconOptions";
11import { cn } from "~/lib/utils";
12import { Button } from "../ui/button";
13import {
14 Dialog,
15 DialogDescription,
16 DialogFooter,
17 DialogHeader,
18 DialogPanel,
19 DialogPopup,
20 DialogTitle,
21} from "../ui/dialog";
22import { Input } from "../ui/input";
23import { ScrollArea } from "../ui/scroll-area";
24import { Toggle, ToggleGroup } from "../ui/toggle-group";
25
26const DEFAULT_ICON: IconName = "folder-code";
27const DEFAULT_COLOR: ProjectIconColor = "blue";
28
29function iconLabel(name: string): string {
30 return name
31 .split("-")
32 .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
33 .join(" ");
34}
35
36export function ProjectIconPickerDialog({
37 current,
38 open,
39 onOpenChange,
40 onSelect,
41}: {
42 readonly current: ProjectIconOverride | null;
43 readonly open: boolean;
44 readonly onOpenChange: (open: boolean) => void;
45 readonly onSelect: (icon: ProjectIconOverride) => void;
46}) {
47 const [mode, setMode] = useState<"lucide" | "emoji">(
48 current?.kind === "lucide" ? "lucide" : "emoji",
49 );
50 const [iconName, setIconName] = useState<IconName>(
51 current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON,
52 );
53 const [color, setColor] = useState<ProjectIconColor>(
54 current?.kind === "lucide" ? current.color : DEFAULT_COLOR,
55 );
56 const [emoji, setEmoji] = useState(current?.kind === "emoji" ? current.emoji : "๐Ÿ’ป");
57 const [query, setQuery] = useState("");
58 const [customEmoji, setCustomEmoji] = useState("");
59 const previousOpenRef = useRef(false);
60
61 useEffect(() => {
62 if (open && !previousOpenRef.current) {
63 setMode(current?.kind === "lucide" ? "lucide" : "emoji");
64 setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON);
65 setColor(current?.kind === "lucide" ? current.color : DEFAULT_COLOR);
66 setEmoji(current?.kind === "emoji" ? current.emoji : "๐Ÿ’ป");
67 setQuery("");
68 setCustomEmoji("");
69 }
70 previousOpenRef.current = open;
71 }, [current, open]);
72
73 const icons = useMemo(() => filterProjectIconNames(query), [query]);
74 const selectedColorClassName = projectIconColorClassName(color);
75 const save = () => {
76 onSelect(
77 mode === "lucide" ? { kind: "lucide", name: iconName, color } : { kind: "emoji", emoji },
78 );
79 onOpenChange(false);
80 };
81
82 return (
83 <Dialog open={open} onOpenChange={onOpenChange}>
84 <DialogPopup className="w-full sm:w-[32rem]">
85 <DialogHeader>
86 <DialogTitle>Choose project icon</DialogTitle>
87 <DialogDescription>Pick an emoji, or choose any Lucide icon and color.</DialogDescription>
88 </DialogHeader>
89 <DialogPanel className="flex min-h-0 flex-col gap-4">
90 <ToggleGroup
91 aria-label="Icon type"
92 variant="segmented"
93 value={[mode]}
94 onValueChange={(next) => {
95 const value = next[0];
96 if (value === "lucide" || value === "emoji") setMode(value);
97 }}
98 >
99 <Toggle value="emoji">Emoji</Toggle>
100 <Toggle value="lucide">Icons</Toggle>
101 </ToggleGroup>
102
103 {mode === "lucide" ? (
104 <>
105 <div>
106 <div className="mb-2 text-xs font-medium text-muted-foreground">Color</div>
107 <div className="flex flex-wrap gap-1.5" role="group" aria-label="Icon color">
108 {PROJECT_ICON_COLORS.map((option) => (
109 <button
110 key={option.value}
111 type="button"
112 aria-label={option.label}
113 aria-pressed={color === option.value}
114 className={cn(
115 "flex size-6 items-center justify-center rounded-full border border-transparent outline-none focus-visible:ring-2 focus-visible:ring-ring",
116 color === option.value && "border-foreground/64",
117 )}
118 onClick={() => setColor(option.value)}
119 >
120 <span className={cn("size-4 rounded-full", option.swatchClassName)} />
121 </button>
122 ))}
123 </div>
124 </div>
125 <Input
126 type="search"
127 value={query}
128 aria-label="Search Lucide icons"
129 placeholder="Search all Lucide icons"
130 onChange={(event) => setQuery(event.currentTarget.value)}
131 />
132 <ScrollArea scrollFade className="max-h-64">
133 <div className="grid grid-cols-8 gap-1 p-0.5 sm:grid-cols-10">
134 {icons.map((name) => (
135 <button
136 key={name}
137 type="button"
138 aria-label={iconLabel(name)}
139 aria-pressed={iconName === name}
140 className={cn(
141 "flex aspect-square items-center justify-center rounded-md border border-transparent outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring",
142 iconName === name && "border-border bg-accent",
143 selectedColorClassName,
144 )}
145 onClick={() => setIconName(name)}
146 >
147 <DynamicIcon name={name} className="size-5" />
148 </button>
149 ))}
150 </div>
151 </ScrollArea>
152 {icons.length === 0 ? (
153 <p className="py-8 text-center text-sm text-muted-foreground">No icons found.</p>
154 ) : null}
155 </>
156 ) : (
157 <>
158 <ScrollArea scrollFade className="max-h-64">
159 <div className="grid grid-cols-8 gap-1 p-0.5 sm:grid-cols-10">
160 {PROJECT_EMOJIS.map((option) => (
161 <button
162 key={option.emoji}
163 type="button"
164 aria-label={option.label}
165 aria-pressed={emoji === option.emoji}
166 className={cn(
167 "flex aspect-square items-center justify-center rounded-md border border-transparent text-xl outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring",
168 emoji === option.emoji && "border-border bg-accent",
169 )}
170 onClick={() => setEmoji(option.emoji)}
171 >
172 {option.emoji}
173 </button>
174 ))}
175 </div>
176 </ScrollArea>
177 <div>
178 <div className="mb-2 text-xs font-medium text-muted-foreground">
179 Or paste any emoji
180 </div>
181 <Input
182 value={customEmoji}
183 aria-label="Custom emoji"
184 placeholder="Paste an emoji"
185 onChange={(event) => {
186 const value = event.currentTarget.value;
187 setCustomEmoji(value);
188 const nextEmoji = firstEmoji(value);
189 if (nextEmoji) setEmoji(nextEmoji);
190 }}
191 />
192 </div>
193 </>
194 )}
195 </DialogPanel>
196 <DialogFooter>
197 <Button variant="outline" onClick={() => onOpenChange(false)}>
198 Cancel
199 </Button>
200 <Button onClick={save}>Save icon</Button>
201 </DialogFooter>
202 </DialogPopup>
203 </Dialog>
204 );
205}
apps/web/src/components/settings/ProjectSettingsPanel.tsx+48-8
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15import type {15import type {
16 ContextMenuItem,16 ContextMenuItem,
17 ModelSelection,17 ModelSelection,
18 ProjectIconOverride,
18 ProviderDriverKind,19 ProviderDriverKind,
19 SidebarProjectGroupingMode,20 SidebarProjectGroupingMode,
20 T3ProjectFileScript,21 T3ProjectFileScript,
...@@ -27,6 +28,8 @@ import { useCanGoBack, useNavigate } from "@tanstack/react-router";...@@ -27,6 +28,8 @@ import { useCanGoBack, useNavigate } from "@tanstack/react-router";
27import * as Cause from "effect/Cause";28import * as Cause from "effect/Cause";
28import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react";29import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react";
29import {30import {
31 lazy,
32 Suspense,
30 useCallback,33 useCallback,
31 useEffect,34 useEffect,
32 useMemo,35 useMemo,
...@@ -118,6 +121,12 @@ import {...@@ -118,6 +121,12 @@ import {
118} from "./ProjectFaviconPickerDialog";121} from "./ProjectFaviconPickerDialog";
119import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic";122import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic";
120123
124const ProjectIconPickerDialog = lazy(() =>
125 import("./ProjectIconPickerDialog").then((module) => ({
126 default: module.ProjectIconPickerDialog,
127 })),
128);
129
121export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {130export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
122 repository: "Group by repository",131 repository: "Group by repository",
123 repository_path: "Group by repository path",132 repository_path: "Group by repository path",
...@@ -335,6 +344,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -335,6 +344,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
335 });344 });
336345
337 const faviconPath = representative.faviconPath ?? null;346 const faviconPath = representative.faviconPath ?? null;
347 const projectIcon = representative.projectIcon ?? null;
338 const pickProjectFavicon =348 const pickProjectFavicon =
339 typeof window !== "undefined" &&349 typeof window !== "undefined" &&
340 group.memberProjects.every(350 group.memberProjects.every(
...@@ -375,6 +385,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -375,6 +385,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
375 defaultThreadEnvMode: ThreadEnvMode | null;385 defaultThreadEnvMode: ThreadEnvMode | null;
376 autoPull: boolean;386 autoPull: boolean;
377 faviconPath: string | null;387 faviconPath: string | null;
388 projectIcon: ProjectIconOverride | null;
378 }>,389 }>,
379 failureTitle: string,390 failureTitle: string,
380 ): Promise<AtomCommandResult<void, unknown>> => {391 ): Promise<AtomCommandResult<void, unknown>> => {
...@@ -474,17 +485,18 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -474,17 +485,18 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
474 [updateAllMembers],485 [updateAllMembers],
475 );486 );
476487
477 // ----- favicon -----488 // ----- project icon -----
478 const [faviconPickerOpen, setFaviconPickerOpen] = useState(false);489 const [faviconPickerOpen, setFaviconPickerOpen] = useState(false);
490 const [iconPickerOpen, setIconPickerOpen] = useState(false);
479 const [isSavingFavicon, setIsSavingFavicon] = useState(false);491 const [isSavingFavicon, setIsSavingFavicon] = useState(false);
480 const savingFaviconRef = useRef(false);492 const savingFaviconRef = useRef(false);
481 const setFaviconPath = useCallback(493 const setProjectIcon = useCallback(
482 async (faviconPath: string | null) => {494 async (input: { faviconPath: string | null; projectIcon: ProjectIconOverride | null }) => {
483 if (savingFaviconRef.current) return;495 if (savingFaviconRef.current) return;
484 savingFaviconRef.current = true;496 savingFaviconRef.current = true;
485 setIsSavingFavicon(true);497 setIsSavingFavicon(true);
486 try {498 try {
487 await updateAllMembers({ faviconPath }, "Failed to update project icon");499 await updateAllMembers(input, "Failed to update project icon");
488 } finally {500 } finally {
489 savingFaviconRef.current = false;501 savingFaviconRef.current = false;
490 setIsSavingFavicon(false);502 setIsSavingFavicon(false);
...@@ -824,13 +836,19 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -824,13 +836,19 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
824 />836 />
825 <SettingsRow837 <SettingsRow
826 title="Project icon"838 title="Project icon"
827 description={faviconPath ?? "Automatic"}839 description={
840 projectIcon?.kind === "lucide"
841 ? `${projectIcon.name} · ${projectIcon.color}`
842 : projectIcon?.kind === "emoji"
843 ? projectIcon.emoji
844 : (faviconPath ?? "Automatic")
845 }
828 resetAction={846 resetAction={
829 faviconPath !== null ? (847 faviconPath !== null || projectIcon !== null ? (
830 <SettingResetButton848 <SettingResetButton
831 label="project icon"849 label="project icon"
832 disabled={isSavingFavicon}850 disabled={isSavingFavicon}
833 onClick={() => void setFaviconPath(null)}851 onClick={() => void setProjectIcon({ faviconPath: null, projectIcon: null })}
834 />852 />
835 ) : null853 ) : null
836 }854 }
...@@ -839,13 +857,25 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -839,13 +857,25 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
839 <ProjectFavicon857 <ProjectFavicon
840 environmentId={representative.environmentId}858 environmentId={representative.environmentId}
841 cwd={representative.workspaceRoot}859 cwd={representative.workspaceRoot}
860 projectName={group.displayName}
842 faviconPath={faviconPath}861 faviconPath={faviconPath}
862 projectIcon={projectIcon}
843 className="size-6"863 className="size-6"
844 />864 />
845 <Button865 <Button
846 size="sm"866 size="sm"
847 variant="outline"867 variant="outline"
848 type="button"868 type="button"
869 aria-label="Choose a project icon"
870 disabled={isSavingFavicon}
871 onClick={() => setIconPickerOpen(true)}
872 >
873 Choose icon
874 </Button>
875 <Button
876 size="xs"
877 variant="outline"
878 type="button"
849 aria-label="Choose a project icon file"879 aria-label="Choose a project icon file"
850 disabled={isSavingFavicon}880 disabled={isSavingFavicon}
851 onClick={() => setFaviconPickerOpen(true)}881 onClick={() => setFaviconPickerOpen(true)}
...@@ -1239,10 +1269,20 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -1239,10 +1269,20 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
1239 {...(pickProjectFavicon1269 {...(pickProjectFavicon
1240 ? { onPickExternal: () => pickProjectFavicon(representative.workspaceRoot) }1270 ? { onPickExternal: () => pickProjectFavicon(representative.workspaceRoot) }
1241 : {})}1271 : {})}
1242 onSelect={(path) => void setFaviconPath(path)}1272 onSelect={(path) => void setProjectIcon({ faviconPath: path, projectIcon: null })}
1243 open={faviconPickerOpen}1273 open={faviconPickerOpen}
1244 projectName={group.displayName}1274 projectName={group.displayName}
1245 />1275 />
1276 {iconPickerOpen ? (
1277 <Suspense fallback={null}>
1278 <ProjectIconPickerDialog
1279 current={projectIcon}
1280 open
1281 onOpenChange={setIconPickerOpen}
1282 onSelect={(icon) => void setProjectIcon({ faviconPath: null, projectIcon: icon })}
1283 />
1284 </Suspense>
1285 ) : null}
1246 </>1286 </>
1247 );1287 );
1248}1288}
apps/web/src/components/settings/SettingsPanels.tsx+3
...@@ -2822,6 +2822,7 @@ export function ArchivedThreadsPanel() {...@@ -2822,6 +2822,7 @@ export function ArchivedThreadsPanel() {
2822 name: project.title,2822 name: project.title,
2823 cwd: project.workspaceRoot,2823 cwd: project.workspaceRoot,
2824 faviconPath: project.faviconPath,2824 faviconPath: project.faviconPath,
2825 projectIcon: project.projectIcon,
2825 },2826 },
2826 ] as const,2827 ] as const,
2827 ),2828 ),
...@@ -2947,7 +2948,9 @@ export function ArchivedThreadsPanel() {...@@ -2947,7 +2948,9 @@ export function ArchivedThreadsPanel() {
2947 <ProjectFavicon2948 <ProjectFavicon
2948 environmentId={project.environmentId}2949 environmentId={project.environmentId}
2949 cwd={project.cwd}2950 cwd={project.cwd}
2951 projectName={project.name}
2950 faviconPath={project.faviconPath}2952 faviconPath={project.faviconPath}
2953 projectIcon={project.projectIcon}
2951 />2954 />
2952 }2955 }
2953 >2956 >
apps/web/src/projectIconColors.ts created+125
...@@ -0,0 +1,125 @@
1import type { ProjectIconColor } from "@t3tools/contracts";
2
3export const PROJECT_ICON_COLORS: ReadonlyArray<{
4 readonly value: ProjectIconColor;
5 readonly label: string;
6 readonly className: string;
7 readonly swatchClassName: string;
8}> = [
9 {
10 value: "gray",
11 label: "Gray",
12 className: "text-gray-600 dark:text-gray-400",
13 swatchClassName: "bg-gray-500",
14 },
15 {
16 value: "red",
17 label: "Red",
18 className: "text-red-600 dark:text-red-400",
19 swatchClassName: "bg-red-500",
20 },
21 {
22 value: "orange",
23 label: "Orange",
24 className: "text-orange-600 dark:text-orange-400",
25 swatchClassName: "bg-orange-500",
26 },
27 {
28 value: "amber",
29 label: "Amber",
30 className: "text-amber-600 dark:text-amber-400",
31 swatchClassName: "bg-amber-500",
32 },
33 {
34 value: "yellow",
35 label: "Yellow",
36 className: "text-yellow-600 dark:text-yellow-400",
37 swatchClassName: "bg-yellow-500",
38 },
39 {
40 value: "lime",
41 label: "Lime",
42 className: "text-lime-600 dark:text-lime-400",
43 swatchClassName: "bg-lime-500",
44 },
45 {
46 value: "green",
47 label: "Green",
48 className: "text-green-600 dark:text-green-400",
49 swatchClassName: "bg-green-500",
50 },
51 {
52 value: "emerald",
53 label: "Emerald",
54 className: "text-emerald-600 dark:text-emerald-400",
55 swatchClassName: "bg-emerald-500",
56 },
57 {
58 value: "teal",
59 label: "Teal",
60 className: "text-teal-600 dark:text-teal-400",
61 swatchClassName: "bg-teal-500",
62 },
63 {
64 value: "cyan",
65 label: "Cyan",
66 className: "text-cyan-600 dark:text-cyan-400",
67 swatchClassName: "bg-cyan-500",
68 },
69 {
70 value: "sky",
71 label: "Sky",
72 className: "text-sky-600 dark:text-sky-400",
73 swatchClassName: "bg-sky-500",
74 },
75 {
76 value: "blue",
77 label: "Blue",
78 className: "text-blue-600 dark:text-blue-400",
79 swatchClassName: "bg-blue-500",
80 },
81 {
82 value: "indigo",
83 label: "Indigo",
84 className: "text-indigo-600 dark:text-indigo-400",
85 swatchClassName: "bg-indigo-500",
86 },
87 {
88 value: "violet",
89 label: "Violet",
90 className: "text-violet-600 dark:text-violet-400",
91 swatchClassName: "bg-violet-500",
92 },
93 {
94 value: "purple",
95 label: "Purple",
96 className: "text-purple-600 dark:text-purple-400",
97 swatchClassName: "bg-purple-500",
98 },
99 {
100 value: "fuchsia",
101 label: "Fuchsia",
102 className: "text-fuchsia-600 dark:text-fuchsia-400",
103 swatchClassName: "bg-fuchsia-500",
104 },
105 {
106 value: "pink",
107 label: "Pink",
108 className: "text-pink-600 dark:text-pink-400",
109 swatchClassName: "bg-pink-500",
110 },
111 {
112 value: "rose",
113 label: "Rose",
114 className: "text-rose-600 dark:text-rose-400",
115 swatchClassName: "bg-rose-500",
116 },
117];
118
119const PROJECT_ICON_COLOR_CLASSES = Object.fromEntries(
120 PROJECT_ICON_COLORS.map(({ value, className }) => [value, className]),
121) as Record<ProjectIconColor, string>;
122
123export function projectIconColorClassName(color: ProjectIconColor): string {
124 return PROJECT_ICON_COLOR_CLASSES[color];
125}
apps/web/src/projectIconModel.test.ts created+37
...@@ -0,0 +1,37 @@
1import { describe, expect, it } from "vite-plus/test";
2import { PROJECT_ICON_NAMES, selectProjectIcon } from "./projectIconModel";
3
4describe("selectProjectIcon", () => {
5 it.each([
6 ["customer-api", "server"],
7 ["AnalyticsDatabase", "database"],
8 ["ios-client", "mobile"],
9 ["terraform-infra", "cloud"],
10 ["developer-docs", "book"],
11 ["shop-frontend", "shopping"],
12 ["agent-runtime", "ai"],
13 ["video-studio", "video"],
14 ] as const)("classifies %s as %s", (projectName, expectedIcon) => {
15 expect(selectProjectIcon(projectName, `/workspace/${projectName}`).icon).toBe(expectedIcon);
16 });
17
18 it("uses the workspace directory when the project name is blank", () => {
19 expect(selectProjectIcon("", "C:\\work\\mobile-app").icon).toBe("mobile");
20 });
21
22 it("uses emoji for automatic project icons", () => {
23 expect(selectProjectIcon("agent-runtime", "/workspace/agent-runtime")).toEqual({
24 kind: "emoji",
25 icon: "ai",
26 emoji: "๐Ÿค–",
27 });
28 });
29
30 it("gives unknown names a stable generic icon", () => {
31 const icon = selectProjectIcon("mercury", "/workspace/mercury");
32
33 expect(icon.kind).toBe("emoji");
34 expect(PROJECT_ICON_NAMES).toContain(icon.icon);
35 expect(selectProjectIcon("mercury", "/elsewhere/mercury")).toEqual(icon);
36 });
37});
apps/web/src/projectIconModel.ts created+221
...@@ -0,0 +1,221 @@
1export const PROJECT_ICON_NAMES = [
2 "ai",
3 "book",
4 "cloud",
5 "database",
6 "desktop",
7 "game",
8 "image",
9 "mobile",
10 "music",
11 "package",
12 "security",
13 "server",
14 "shopping",
15 "terminal",
16 "test",
17 "video",
18 "web",
19 "code",
20 "braces",
21 "circuit",
22 "folder-code",
23 "layers",
24] as const;
25
26export type ProjectIconName = (typeof PROJECT_ICON_NAMES)[number];
27
28export type ProjectIconSelection =
29 | { readonly kind: "lucide"; readonly icon: ProjectIconName }
30 | { readonly kind: "emoji"; readonly icon: ProjectIconName; readonly emoji: string };
31
32interface ProjectIconClass {
33 readonly icon: ProjectIconName;
34 readonly terms: ReadonlyArray<string>;
35}
36
37const PROJECT_ICON_MODEL: ReadonlyArray<ProjectIconClass> = [
38 { icon: "ai", terms: ["ai", "agent", "bot", "gpt", "llm", "ml", "model", "neural"] },
39 {
40 icon: "mobile",
41 terms: ["android", "expo", "ios", "mobile", "native", "reactnative", "swift"],
42 },
43 {
44 icon: "desktop",
45 terms: ["desktop", "electron", "linux", "mac", "macos", "tauri", "windows"],
46 },
47 {
48 icon: "book",
49 terms: ["book", "docs", "documentation", "guide", "handbook", "manual", "wiki"],
50 },
51 {
52 icon: "security",
53 terms: ["auth", "identity", "oauth", "security", "sso", "vault"],
54 },
55 {
56 icon: "database",
57 terms: [
58 "analytics",
59 "data",
60 "database",
61 "db",
62 "mongo",
63 "mysql",
64 "postgres",
65 "redis",
66 "sql",
67 "storage",
68 ],
69 },
70 {
71 icon: "cloud",
72 terms: [
73 "aws",
74 "azure",
75 "cloud",
76 "deploy",
77 "devops",
78 "docker",
79 "gcp",
80 "infra",
81 "kubernetes",
82 "terraform",
83 ],
84 },
85 {
86 icon: "server",
87 terms: ["api", "backend", "gateway", "server", "service", "worker"],
88 },
89 {
90 icon: "terminal",
91 terms: ["automation", "bash", "cli", "command", "script", "shell", "terminal"],
92 },
93 {
94 icon: "package",
95 terms: ["component", "kit", "lib", "library", "package", "plugin", "sdk", "toolkit"],
96 },
97 {
98 icon: "test",
99 terms: ["benchmark", "e2e", "fixture", "spec", "test", "testing"],
100 },
101 {
102 icon: "shopping",
103 terms: ["cart", "commerce", "market", "shop", "store"],
104 },
105 { icon: "game", terms: ["game", "gaming", "play"] },
106 { icon: "music", terms: ["audio", "music", "podcast", "radio", "sound"] },
107 { icon: "video", terms: ["film", "movie", "stream", "video"] },
108 { icon: "image", terms: ["camera", "gallery", "image", "photo", "picture"] },
109 {
110 icon: "web",
111 terms: [
112 "browser",
113 "frontend",
114 "nextjs",
115 "react",
116 "site",
117 "svelte",
118 "ui",
119 "vue",
120 "web",
121 "website",
122 ],
123 },
124];
125
126const GENERIC_PROJECT_ICONS: ReadonlyArray<ProjectIconName> = [
127 "code",
128 "braces",
129 "circuit",
130 "folder-code",
131 "layers",
132];
133
134const PROJECT_ICON_EMOJIS: Record<ProjectIconName, string> = {
135 ai: "๐Ÿค–",
136 book: "๐Ÿ“š",
137 braces: "๐Ÿงฉ",
138 circuit: "โšก",
139 cloud: "โ˜๏ธ",
140 code: "๐Ÿ’ป",
141 database: "๐Ÿ—„๏ธ",
142 desktop: "๐Ÿ–ฅ๏ธ",
143 "folder-code": "๐Ÿ› ๏ธ",
144 game: "๐ŸŽฎ",
145 image: "๐Ÿ–ผ๏ธ",
146 layers: "โœจ",
147 mobile: "๐Ÿ“ฑ",
148 music: "๐ŸŽต",
149 package: "๐Ÿ“ฆ",
150 security: "๐Ÿ”’",
151 server: "โš™๏ธ",
152 shopping: "๐Ÿ›๏ธ",
153 terminal: "โŒจ๏ธ",
154 test: "๐Ÿงช",
155 video: "๐ŸŽฌ",
156 web: "๐ŸŒ",
157};
158
159const projectIconCache = new Map<string, ProjectIconSelection>();
160
161function projectNameTokens(value: string): ReadonlyArray<string> {
162 return value
163 .replace(/([a-z\d])([A-Z])/g, "$1 $2")
164 .toLowerCase()
165 .split(/[^a-z\d]+/)
166 .filter(Boolean);
167}
168
169function termScore(token: string, term: string): number {
170 if (token === term) return 3;
171 if (term.length >= 4 && (token.startsWith(term) || token.endsWith(term))) return 1;
172 return 0;
173}
174
175function stableIndex(value: string, length: number): number {
176 let hash = 2_166_136_261;
177 for (let index = 0; index < value.length; index += 1) {
178 hash ^= value.charCodeAt(index);
179 hash = Math.imul(hash, 16_777_619);
180 }
181 return (hash >>> 0) % length;
182}
183
184export function selectProjectIcon(
185 projectName: string,
186 workspaceRoot: string,
187): ProjectIconSelection {
188 const name = projectName.trim() || workspaceRoot.split(/[\\/]+/).findLast(Boolean) || "project";
189 const cacheKey = name.toLowerCase();
190 const cachedIcon = projectIconCache.get(cacheKey);
191 if (cachedIcon) return cachedIcon;
192
193 const tokens = projectNameTokens(name);
194 let bestIcon: ProjectIconName | null = null;
195 let bestScore = 0;
196
197 for (const projectClass of PROJECT_ICON_MODEL) {
198 let score = 0;
199 for (const token of tokens) {
200 let tokenScore = 0;
201 for (const term of projectClass.terms) {
202 tokenScore = Math.max(tokenScore, termScore(token, term));
203 }
204 score += tokenScore;
205 }
206 if (score > bestScore) {
207 bestIcon = projectClass.icon;
208 bestScore = score;
209 }
210 }
211
212 const iconName =
213 bestIcon ?? GENERIC_PROJECT_ICONS[stableIndex(cacheKey, GENERIC_PROJECT_ICONS.length)]!;
214 const icon: ProjectIconSelection = {
215 kind: "emoji",
216 icon: iconName,
217 emoji: PROJECT_ICON_EMOJIS[iconName],
218 };
219 projectIconCache.set(cacheKey, icon);
220 return icon;
221}
apps/web/src/projectIconOptions.test.ts created+24
...@@ -0,0 +1,24 @@
1import { describe, expect, it } from "vite-plus/test";
2import {
3 filterProjectIconNames,
4 firstEmoji,
5 projectIconColorClassName,
6} from "./projectIconOptions";
7
8describe("projectIconOptions", () => {
9 it("searches across the full Lucide set", () => {
10 expect(filterProjectIconNames("alarm clock")).toContain("alarm-clock");
11 expect(filterProjectIconNames("alarm \tclock")).toContain("alarm-clock");
12 });
13
14 it("extracts one complete emoji grapheme", () => {
15 expect(firstEmoji(" ๐Ÿ‘ฉ๐Ÿฝ‍๐Ÿ’ป hello")).toBe("๐Ÿ‘ฉ๐Ÿฝ‍๐Ÿ’ป");
16 expect(firstEmoji("๐Ÿ‡บ๐Ÿ‡ธ project")).toBe("๐Ÿ‡บ๐Ÿ‡ธ");
17 expect(firstEmoji("1๏ธโƒฃ project")).toBe("1๏ธโƒฃ");
18 expect(firstEmoji("plain text")).toBeNull();
19 });
20
21 it("maps persisted colors to theme-aware classes", () => {
22 expect(projectIconColorClassName("violet")).toBe("text-violet-600 dark:text-violet-400");
23 });
24});
apps/web/src/projectIconOptions.ts created+81
...@@ -0,0 +1,81 @@
1import { iconNames, type IconName } from "lucide-react/dynamic";
2export { PROJECT_ICON_COLORS, projectIconColorClassName } from "./projectIconColors";
3
4export const POPULAR_PROJECT_ICONS = [
5 "folder-code",
6 "code-2",
7 "terminal",
8 "globe-2",
9 "server",
10 "database",
11 "bot",
12 "sparkles",
13 "smartphone",
14 "monitor",
15 "cloud-cog",
16 "package",
17 "book-open",
18 "flask-conical",
19 "shield-check",
20 "rocket",
21 "gamepad-2",
22 "music",
23 "image",
24 "shopping-bag",
25 "git-branch",
26 "workflow",
27 "wrench",
28 "layers-3",
29] as const satisfies ReadonlyArray<IconName>;
30
31export const PROJECT_EMOJIS: ReadonlyArray<{ readonly emoji: string; readonly label: string }> = [
32 { emoji: "๐Ÿ’ป", label: "Computer" },
33 { emoji: "๐Ÿ› ๏ธ", label: "Tools" },
34 { emoji: "๐Ÿš€", label: "Rocket" },
35 { emoji: "๐Ÿค–", label: "Robot" },
36 { emoji: "โœจ", label: "Sparkles" },
37 { emoji: "โšก", label: "Lightning" },
38 { emoji: "๐ŸŒ", label: "Web" },
39 { emoji: "๐Ÿ“ฑ", label: "Mobile" },
40 { emoji: "๐Ÿ–ฅ๏ธ", label: "Desktop" },
41 { emoji: "โŒจ๏ธ", label: "Keyboard" },
42 { emoji: "โš™๏ธ", label: "Gear" },
43 { emoji: "๐Ÿ—„๏ธ", label: "Database" },
44 { emoji: "โ˜๏ธ", label: "Cloud" },
45 { emoji: "๐Ÿ“ฆ", label: "Package" },
46 { emoji: "๐Ÿ“š", label: "Books" },
47 { emoji: "๐Ÿงช", label: "Test tube" },
48 { emoji: "๐Ÿ”’", label: "Lock" },
49 { emoji: "๐ŸŽฎ", label: "Game" },
50 { emoji: "๐ŸŽต", label: "Music" },
51 { emoji: "๐ŸŽฌ", label: "Movie" },
52 { emoji: "๐Ÿ–ผ๏ธ", label: "Picture" },
53 { emoji: "๐Ÿ›๏ธ", label: "Shopping" },
54 { emoji: "๐Ÿ”ฅ", label: "Fire" },
55 { emoji: "๐Ÿ’ก", label: "Idea" },
56 { emoji: "๐Ÿงฉ", label: "Puzzle" },
57 { emoji: "๐Ÿ“Š", label: "Chart" },
58 { emoji: "๐Ÿง ", label: "Brain" },
59 { emoji: "๐Ÿฆ„", label: "Unicorn" },
60 { emoji: "๐Ÿ™", label: "Octopus" },
61 { emoji: "๐ŸŒฑ", label: "Seedling" },
62];
63
64export function filterProjectIconNames(query: string): ReadonlyArray<IconName> {
65 const normalized = query.trim().toLowerCase().replaceAll(/\s+/g, "-");
66 if (!normalized) return POPULAR_PROJECT_ICONS;
67 return iconNames.filter((name) => name.includes(normalized)).slice(0, 60);
68}
69
70export function firstEmoji(value: string): string | null {
71 const trimmed = value.trim();
72 if (!trimmed) return null;
73 const segments = new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(trimmed);
74 const segment = segments[Symbol.iterator]().next().value?.segment;
75 const isFlag = /^\p{Regional_Indicator}{2}$/u.test(segment ?? "");
76 const isKeycap = /^[#*0-9]\uFE0F?\u20E3$/u.test(segment ?? "");
77 if (!segment || (!/\p{Extended_Pictographic}/u.test(segment) && !isFlag && !isKeycap)) {
78 return null;
79 }
80 return segment;
81}
apps/web/src/routes/_chat.pull-requests.tsx+2
...@@ -362,6 +362,8 @@ function PullRequestsRouteView() {...@@ -362,6 +362,8 @@ function PullRequestsRouteView() {
362 ? `${project.title} · ${environmentLabels.get(project.environmentId) ?? project.environmentId}`362 ? `${project.title} · ${environmentLabels.get(project.environmentId) ?? project.environmentId}`
363 : project.title,363 : project.title,
364 workspaceRoot: project.workspaceRoot,364 workspaceRoot: project.workspaceRoot,
365 faviconPath: project.faviconPath ?? null,
366 projectIcon: project.projectIcon ?? null,
365 }))367 }))
366 .toSorted((left, right) => left.title.localeCompare(right.title));368 .toSorted((left, right) => left.title.localeCompare(right.title));
367 }, [environmentLabels, projects]);369 }, [environmentLabels, projects]);
docs/user/project-settings.md+9-4
...@@ -1,14 +1,19 @@...@@ -1,14 +1,19 @@
1# Customize a project icon1# Customize a project icon
22
3T3 Code selects a project icon automatically. It checks `t3.json`, common favicon and app icon3T3 Code selects a project icon automatically. It checks `t3.json`, common favicon and app icon
4paths, and icon links in project HTML files.4paths, and icon links in project HTML files. If it does not find an image, it chooses a built-in
5emoji from the project name.
56
6To choose a different icon:7To choose a different icon or emoji:
78
81. Open **Settings** and select **Projects**.91. Open **Settings** and select **Projects**.
92. Select the project.102. Select the project.
103. Under **Appearance**, select **Choose a project file**.113. Next to **Project icon**, select **Choose icon**.
114. Search for an image file and select it.124. Search the full Lucide icon set and choose a color, or switch to **Emoji** and choose or paste
13 an emoji.
14
15To use an image from the project instead, select **Choose file**, search for an image, and select
16it.
1217
13T3 Code supports SVG, PNG, ICO, JPEG, GIF, AVIF, and WebP files. The selected path applies to18T3 Code supports SVG, PNG, ICO, JPEG, GIF, AVIF, and WebP files. The selected path applies to
14each checkout in the project group and appears on your connected clients.19each checkout in the project group and appears on your connected clients.
packages/contracts/src/orchestration.test.ts+30
...@@ -1105,6 +1105,36 @@ it.effect("project favicon overrides accept only supported image files", () =>...@@ -1105,6 +1105,36 @@ it.effect("project favicon overrides accept only supported image files", () =>
1105 }),1105 }),
1106);1106);
11071107
1108it.effect("project icon overrides accept Lucide icons, colors, and emoji", () =>
1109 Effect.gen(function* () {
1110 const lucide = yield* decodeOrchestrationCommand({
1111 type: "project.meta.update",
1112 commandId: "cmd-project-lucide-icon",
1113 projectId: "project-1",
1114 projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" },
1115 });
1116 assert.strictEqual(lucide.type, "project.meta.update");
1117
1118 const emoji = yield* decodeOrchestrationCommand({
1119 type: "project.meta.update",
1120 commandId: "cmd-project-emoji-icon",
1121 projectId: "project-1",
1122 projectIcon: { kind: "emoji", emoji: "๐Ÿ‘ฉ๐Ÿฝ‍๐Ÿ’ป" },
1123 });
1124 assert.strictEqual(emoji.type, "project.meta.update");
1125
1126 const invalid = yield* Effect.exit(
1127 decodeOrchestrationCommand({
1128 type: "project.meta.update",
1129 commandId: "cmd-project-invalid-icon",
1130 projectId: "project-1",
1131 projectIcon: { kind: "lucide", name: "Alarm Clock", color: "ultraviolet" },
1132 }),
1133 );
1134 assert.strictEqual(invalid._tag, "Failure");
1135 }),
1136);
1137
1108it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects svg", () => {1138it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects svg", () => {
1109 assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/png"), true);1139 assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/png"), true);
1110 assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true);1140 assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true);
packages/contracts/src/orchestration.ts+47
...@@ -282,6 +282,48 @@ export const ProjectFaviconPath = TrimmedNonEmptyString.check(...@@ -282,6 +282,48 @@ export const ProjectFaviconPath = TrimmedNonEmptyString.check(
282);282);
283export type ProjectFaviconPath = typeof ProjectFaviconPath.Type;283export type ProjectFaviconPath = typeof ProjectFaviconPath.Type;
284284
285export const ProjectIconColor = Schema.Literals([
286 "gray",
287 "red",
288 "orange",
289 "amber",
290 "yellow",
291 "lime",
292 "green",
293 "emerald",
294 "teal",
295 "cyan",
296 "sky",
297 "blue",
298 "indigo",
299 "violet",
300 "purple",
301 "fuchsia",
302 "pink",
303 "rose",
304]);
305export type ProjectIconColor = typeof ProjectIconColor.Type;
306
307const ProjectLucideIconName = TrimmedNonEmptyString.check(
308 Schema.isMaxLength(64),
309 Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
310);
311
312const ProjectEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32));
313
314export const ProjectIconOverride = Schema.Union([
315 Schema.Struct({
316 kind: Schema.Literal("lucide"),
317 name: ProjectLucideIconName,
318 color: ProjectIconColor,
319 }),
320 Schema.Struct({
321 kind: Schema.Literal("emoji"),
322 emoji: ProjectEmoji,
323 }),
324]);
325export type ProjectIconOverride = typeof ProjectIconOverride.Type;
326
285export const OrchestrationProject = Schema.Struct({327export const OrchestrationProject = Schema.Struct({
286 id: ProjectId,328 id: ProjectId,
287 title: TrimmedNonEmptyString,329 title: TrimmedNonEmptyString,
...@@ -296,6 +338,7 @@ export const OrchestrationProject = Schema.Struct({...@@ -296,6 +338,7 @@ export const OrchestrationProject = Schema.Struct({
296 autoPull: Schema.optional(Schema.Boolean),338 autoPull: Schema.optional(Schema.Boolean),
297 // Optional on the wire so cached snapshots from older servers still decode.339 // Optional on the wire so cached snapshots from older servers still decode.
298 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),340 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
341 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),
299 scripts: Schema.Array(ProjectScript),342 scripts: Schema.Array(ProjectScript),
300 createdAt: IsoDateTime,343 createdAt: IsoDateTime,
301 updatedAt: IsoDateTime,344 updatedAt: IsoDateTime,
...@@ -508,6 +551,7 @@ export const OrchestrationProjectShell = Schema.Struct({...@@ -508,6 +551,7 @@ export const OrchestrationProjectShell = Schema.Struct({
508 autoPull: Schema.optional(Schema.Boolean),551 autoPull: Schema.optional(Schema.Boolean),
509 // Optional on the wire so cached snapshots from older servers still decode.552 // Optional on the wire so cached snapshots from older servers still decode.
510 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),553 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
554 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),
511 scripts: Schema.Array(ProjectScript),555 scripts: Schema.Array(ProjectScript),
512 createdAt: IsoDateTime,556 createdAt: IsoDateTime,
513 updatedAt: IsoDateTime,557 updatedAt: IsoDateTime,
...@@ -729,6 +773,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({...@@ -729,6 +773,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({
729 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),773 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
730 autoPull: Schema.optional(Schema.Boolean),774 autoPull: Schema.optional(Schema.Boolean),
731 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),775 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
776 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),
732 scripts: Schema.optional(Schema.Array(ProjectScript)),777 scripts: Schema.optional(Schema.Array(ProjectScript)),
733});778});
734779
...@@ -1185,6 +1230,7 @@ export const ProjectCreatedPayload = Schema.Struct({...@@ -1185,6 +1230,7 @@ export const ProjectCreatedPayload = Schema.Struct({
1185 defaultModelSelection: Schema.NullOr(ModelSelection),1230 defaultModelSelection: Schema.NullOr(ModelSelection),
1186 // Optional so persisted events from older servers still decode.1231 // Optional so persisted events from older servers still decode.
1187 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),1232 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
1233 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),
1188 scripts: Schema.Array(ProjectScript),1234 scripts: Schema.Array(ProjectScript),
1189 createdAt: IsoDateTime,1235 createdAt: IsoDateTime,
1190 updatedAt: IsoDateTime,1236 updatedAt: IsoDateTime,
...@@ -1199,6 +1245,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({...@@ -1199,6 +1245,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({
1199 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),1245 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
1200 autoPull: Schema.optional(Schema.Boolean),1246 autoPull: Schema.optional(Schema.Boolean),
1201 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),1247 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
1248 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),
1202 scripts: Schema.optional(Schema.Array(ProjectScript)),1249 scripts: Schema.optional(Schema.Array(ProjectScript)),
1203 updatedAt: IsoDateTime,1250 updatedAt: IsoDateTime,
1204});1251});