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) => {
34233423 model: "gpt-5",
34243424 },
34253425 faviconPath: "brand/icon.svg",
3426 projectIcon: { kind: "emoji", emoji: "๐Ÿš€" },
34263427 });
34273428
34283429 const projectRows = yield* sql<{
34293430 readonly scriptsJson: string;
34303431 readonly defaultModelSelection: string;
34313432 readonly faviconPath: string | null;
3433 readonly projectIcon: string | null;
34323434 }>`
34333435 SELECT
34343436 scripts_json AS "scriptsJson",
34353437 default_model_selection_json AS "defaultModelSelection",
3436 favicon_path AS "faviconPath"
3438 favicon_path AS "faviconPath",
3439 project_icon_json AS "projectIcon"
34373440 FROM projection_projects
34383441 WHERE project_id = 'project-scripts'
34393442 `;
......@@ -3443,6 +3446,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
34433446 '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]',
34443447 defaultModelSelection: '{"instanceId":"codex","model":"gpt-5"}',
34453448 faviconPath: "brand/icon.svg",
3449 projectIcon: '{"kind":"emoji","emoji":"๐Ÿš€"}',
34463450 },
34473451 ]);
34483452 }),
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+4
......@@ -517,6 +517,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
517517 defaultThreadEnvMode: null,
518518 autoPull: false,
519519 faviconPath: event.payload.faviconPath ?? null,
520 projectIcon: event.payload.projectIcon ?? null,
520521 scripts: event.payload.scripts,
521522 createdAt: event.payload.createdAt,
522523 updatedAt: event.payload.updatedAt,
......@@ -547,6 +548,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
547548 ...(event.payload.faviconPath !== undefined
548549 ? { faviconPath: event.payload.faviconPath }
549550 : {}),
551 ...(event.payload.projectIcon !== undefined
552 ? { projectIcon: event.payload.projectIcon }
553 : {}),
550554 ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}),
551555 updatedAt: event.payload.updatedAt,
552556 });
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2
......@@ -280,6 +280,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
280280 },
281281 autoPull: false,
282282 faviconPath: null,
283 projectIcon: null,
283284 scripts: [
284285 {
285286 id: "script-1",
......@@ -408,6 +409,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
408409 },
409410 autoPull: false,
410411 faviconPath: null,
412 projectIcon: null,
411413 scripts: [
412414 {
413415 id: "script-1",
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+9
......@@ -12,6 +12,7 @@ import {
1212 OrchestrationThread,
1313 OrchestrationThreadDetailSnapshot,
1414 ProjectScript,
15 ProjectIconOverride,
1516 TurnId,
1617 type OrchestrationCheckpointSummary,
1718 type OrchestrationLatestTurn,
......@@ -84,6 +85,7 @@ const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(
8485 Struct.assign({
8586 defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),
8687 autoPull: Schema.Number,
88 projectIcon: Schema.NullOr(Schema.fromJsonString(ProjectIconOverride)),
8789 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
8890 }),
8991);
......@@ -350,6 +352,7 @@ function mapProjectShellRow(
350352 defaultThreadEnvMode: row.defaultThreadEnvMode,
351353 autoPull: row.autoPull === 1,
352354 faviconPath: row.faviconPath ?? null,
355 projectIcon: row.projectIcon ?? null,
353356 scripts: row.scripts,
354357 createdAt: row.createdAt,
355358 updatedAt: row.updatedAt,
......@@ -443,6 +446,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
443446 default_thread_env_mode AS "defaultThreadEnvMode",
444447 auto_pull AS "autoPull",
445448 favicon_path AS "faviconPath",
449 project_icon_json AS "projectIcon",
446450 scripts_json AS "scripts",
447451 created_at AS "createdAt",
448452 updated_at AS "updatedAt",
......@@ -919,6 +923,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
919923 default_thread_env_mode AS "defaultThreadEnvMode",
920924 auto_pull AS "autoPull",
921925 favicon_path AS "faviconPath",
926 project_icon_json AS "projectIcon",
922927 scripts_json AS "scripts",
923928 created_at AS "createdAt",
924929 updated_at AS "updatedAt",
......@@ -944,6 +949,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
944949 default_thread_env_mode AS "defaultThreadEnvMode",
945950 auto_pull AS "autoPull",
946951 favicon_path AS "faviconPath",
952 project_icon_json AS "projectIcon",
947953 scripts_json AS "scripts",
948954 created_at AS "createdAt",
949955 updated_at AS "updatedAt",
......@@ -1885,6 +1891,7 @@ pending_approval_requests AS (
18851891 defaultThreadEnvMode: row.defaultThreadEnvMode,
18861892 autoPull: row.autoPull === 1,
18871893 faviconPath: row.faviconPath ?? null,
1894 projectIcon: row.projectIcon ?? null,
18881895 scripts: row.scripts,
18891896 createdAt: row.createdAt,
18901897 updatedAt: row.updatedAt,
......@@ -2021,6 +2028,7 @@ pending_approval_requests AS (
20212028 defaultThreadEnvMode: row.defaultThreadEnvMode,
20222029 autoPull: row.autoPull === 1,
20232030 faviconPath: row.faviconPath ?? null,
2031 projectIcon: row.projectIcon ?? null,
20242032 scripts: row.scripts,
20252033 createdAt: row.createdAt,
20262034 updatedAt: row.updatedAt,
......@@ -2537,6 +2545,7 @@ pending_approval_requests AS (
25372545 defaultThreadEnvMode: option.value.defaultThreadEnvMode,
25382546 autoPull: option.value.autoPull === 1,
25392547 faviconPath: option.value.faviconPath ?? null,
2548 projectIcon: option.value.projectIcon ?? null,
25402549 scripts: option.value.scripts,
25412550 createdAt: option.value.createdAt,
25422551 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) => {
9494 }),
9595 );
9696
97 it.effect("propagates a project favicon path in project.meta.update", () =>
97 it.effect("propagates project icon metadata in project.meta.update", () =>
9898 Effect.gen(function* () {
9999 const now = "2026-01-01T00:00:00.000Z";
100100 const readModel = yield* projectEvent(createEmptyReadModel(now), {
......@@ -125,6 +125,7 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => {
125125 commandId: CommandId.make("cmd-project-update-favicon"),
126126 projectId: asProjectId("project-favicon"),
127127 faviconPath: "brand/icon.svg",
128 projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" },
128129 },
129130 readModel,
130131 });
......@@ -132,6 +133,11 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => {
132133 const event = Array.isArray(result) ? result[0] : result;
133134 expect(event.type).toBe("project.meta-updated");
134135 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 });
135141 }),
136142 );
137143
apps/server/src/orchestration/decider.ts+2
......@@ -224,6 +224,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
224224 // explicit project default.
225225 defaultModelSelection: null,
226226 faviconPath: null,
227 projectIcon: null,
227228 scripts: [],
228229 createdAt: command.createdAt,
229230 updatedAt: command.createdAt,
......@@ -266,6 +267,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
266267 : {}),
267268 ...(command.autoPull !== undefined ? { autoPull: command.autoPull } : {}),
268269 ...(command.faviconPath !== undefined ? { faviconPath: command.faviconPath } : {}),
270 ...(command.projectIcon !== undefined ? { projectIcon: command.projectIcon } : {}),
269271 ...(command.scripts !== undefined ? { scripts: command.scripts } : {}),
270272 updatedAt: occurredAt,
271273 },
apps/server/src/orchestration/projector.ts+4
......@@ -217,6 +217,7 @@ export function projectEvent(
217217 defaultThreadEnvMode: null,
218218 autoPull: false,
219219 faviconPath: payload.faviconPath ?? null,
220 projectIcon: payload.projectIcon ?? null,
220221 scripts: payload.scripts,
221222 createdAt: payload.createdAt,
222223 updatedAt: payload.updatedAt,
......@@ -256,6 +257,9 @@ export function projectEvent(
256257 ...(payload.faviconPath !== undefined
257258 ? { faviconPath: payload.faviconPath }
258259 : {}),
260 ...(payload.projectIcon !== undefined
261 ? { projectIcon: payload.projectIcon }
262 : {}),
259263 ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}),
260264 updatedAt: payload.updatedAt,
261265 }
apps/server/src/persistence/Layers/ProjectionProjects.ts+7-1
......@@ -6,7 +6,7 @@ import * as Option from "effect/Option";
66import * as Schema from "effect/Schema";
77import * as Struct from "effect/Struct";
88
9import { ModelSelection, ProjectScript } from "@t3tools/contracts";
9import { ModelSelection, ProjectIconOverride, ProjectScript } from "@t3tools/contracts";
1010import { toPersistenceSqlError } from "../Errors.ts";
1111import {
1212 DeleteProjectionProjectInput,
......@@ -20,6 +20,7 @@ const ProjectionProjectDbRow = ProjectionProject.mapFields(
2020 Struct.assign({
2121 defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),
2222 autoPull: Schema.Number,
23 projectIcon: Schema.NullOr(Schema.fromJsonString(ProjectIconOverride)),
2324 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
2425 }),
2526);
......@@ -40,6 +41,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
4041 default_thread_env_mode,
4142 auto_pull,
4243 favicon_path,
44 project_icon_json,
4345 scripts_json,
4446 created_at,
4547 updated_at,
......@@ -53,6 +55,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
5355 ${row.defaultThreadEnvMode},
5456 ${row.autoPull ? 1 : 0},
5557 ${row.faviconPath ?? null},
58 ${row.projectIcon ? JSON.stringify(row.projectIcon) : null},
5659 ${JSON.stringify(row.scripts)},
5760 ${row.createdAt},
5861 ${row.updatedAt},
......@@ -66,6 +69,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
6669 default_thread_env_mode = excluded.default_thread_env_mode,
6770 auto_pull = excluded.auto_pull,
6871 favicon_path = excluded.favicon_path,
72 project_icon_json = excluded.project_icon_json,
6973 scripts_json = excluded.scripts_json,
7074 created_at = excluded.created_at,
7175 updated_at = excluded.updated_at,
......@@ -86,6 +90,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
8690 default_thread_env_mode AS "defaultThreadEnvMode",
8791 auto_pull AS "autoPull",
8892 favicon_path AS "faviconPath",
93 project_icon_json AS "projectIcon",
8994 scripts_json AS "scripts",
9095 created_at AS "createdAt",
9196 updated_at AS "updatedAt",
......@@ -108,6 +113,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
108113 default_thread_env_mode AS "defaultThreadEnvMode",
109114 auto_pull AS "autoPull",
110115 favicon_path AS "faviconPath",
116 project_icon_json AS "projectIcon",
111117 scripts_json AS "scripts",
112118 created_at AS "createdAt",
113119 updated_at AS "updatedAt",
apps/server/src/persistence/Migrations.ts+2
......@@ -58,6 +58,7 @@ import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts";
5858import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.ts";
5959import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts";
6060import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts";
61import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts";
6162
6263/**
6364 * Migration loader with all migrations defined inline.
......@@ -116,6 +117,7 @@ export const migrationEntries = [
116117 [44, "ClearAutomaticProjectModelDefaults", Migration0044],
117118 [45, "ProjectionProjectsAutoPull", Migration0045],
118119 [46, "RepairAutomaticSettlementTimestamps", Migration0046],
120 [47, "ProjectionProjectIcon", Migration0047],
119121] as const;
120122
121123export 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 @@
99import {
1010 IsoDateTime,
1111 ModelSelection,
12 ProjectIconOverride,
1213 ProjectId,
1314 ProjectScript,
1415 ThreadEnvMode,
......@@ -28,6 +29,7 @@ export const ProjectionProject = Schema.Struct({
2829 defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode),
2930 autoPull: Schema.Boolean,
3031 faviconPath: Schema.optional(Schema.NullOr(Schema.String)),
32 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),
3133 scripts: Schema.Array(ProjectScript),
3234 createdAt: IsoDateTime,
3335 updatedAt: IsoDateTime,
apps/web/src/components/ChatView.tsx+1
......@@ -7436,6 +7436,7 @@ function ChatViewContent(props: ChatViewProps) {
74367436 activeProjectName={activeProject?.title}
74377437 activeProjectCwd={activeProject?.workspaceRoot ?? null}
74387438 activeProjectFaviconPath={activeProject?.faviconPath ?? null}
7439 activeProjectIcon={activeProject?.projectIcon ?? null}
74397440 openInCwd={gitCwd}
74407441 activeProjectScripts={activeProject?.scripts}
74417442 preferredScriptId={
apps/web/src/components/CommandPalette.tsx+17-1
......@@ -181,8 +181,10 @@ function projectFavicon(project: Project) {
181181 <ProjectFavicon
182182 environmentId={project.environmentId}
183183 cwd={project.workspaceRoot}
184 projectName={project.title}
184185 faviconPath={project.faviconPath}
185 className={ITEM_ICON_CLASS}
186 projectIcon={project.projectIcon}
187 className="size-4"
186188 />
187189 );
188190}
......@@ -939,6 +941,16 @@ function OpenCommandPaletteDialog(props: {
939941 () => new Map(projects.map((project) => [project.id, project.faviconPath ?? null] as const)),
940942 [projects],
941943 );
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 );
942954 const projectTitleById = useMemo(
943955 () => new Map<ProjectId, string>(projects.map((project) => [project.id, project.title])),
944956 [projects],
......@@ -1170,6 +1182,9 @@ function OpenCommandPaletteDialog(props: {
11701182 environmentId={thread.environmentId}
11711183 projectCwd={projectCwdById.get(thread.projectId) ?? null}
11721184 projectFaviconPath={projectFaviconPathById.get(thread.projectId) ?? null}
1185 projectIcon={
1186 projectIconByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
1187 }
11731188 projectTitle={projectTitle ?? null}
11741189 branch={thread.branch}
11751190 worktreePath={thread.worktreePath}
......@@ -1209,6 +1224,7 @@ function OpenCommandPaletteDialog(props: {
12091224 navigate,
12101225 projectCwdById,
12111226 projectFaviconPathById,
1227 projectIconByKey,
12121228 projectTitleById,
12131229 providerEntryByEnvironmentAndInstanceId,
12141230 threadContentMatchByKey,
apps/web/src/components/LegacySidebar.tsx+9-5
......@@ -2340,11 +2340,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
23402340 }`}
23412341 />
23422342 )}
2343 <ProjectFavicon
2344 environmentId={project.environmentId}
2345 cwd={project.workspaceRoot}
2346 faviconPath={project.faviconPath}
2347 />
2343 <span className="flex shrink-0">
2344 <ProjectFavicon
2345 environmentId={project.environmentId}
2346 cwd={project.workspaceRoot}
2347 projectName={project.displayName}
2348 faviconPath={project.faviconPath}
2349 projectIcon={project.projectIcon}
2350 />
2351 </span>
23482352 <span className="flex min-w-0 flex-1 items-center gap-2">
23492353 <span className="truncate text-sm font-medium text-sidebar-foreground/90">
23502354 {project.displayName}
apps/web/src/components/ProjectFavicon.test.tsx+63
......@@ -1,6 +1,7 @@
11import type { ComponentType, Dispatch, ReactElement, SetStateAction } from "react";
22import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
33import type { EnvironmentId } from "@t3tools/contracts";
4import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
45
56const testState = vi.hoisted(() => ({
67 faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg",
......@@ -52,6 +53,10 @@ vi.mock("react", async (importOriginal) => {
5253});
5354
5455vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache }));
56vi.mock("lucide-react/dynamic", () => ({
57 DynamicIcon: "dynamic-icon",
58 iconNames: ["alarm-clock", "folder-code"],
59}));
5560vi.mock("../assets/assetUrls", () => ({
5661 useAssetUrlState: (_environmentId: unknown, resource: unknown) => {
5762 testState.lastResource = resource;
......@@ -86,6 +91,7 @@ function resolveImageComponent(): {
8691 const element = ProjectFavicon({
8792 environmentId: "environment-test" as EnvironmentId,
8893 cwd: "/workspace-test",
94 projectName: "workspace-test",
8995 }) as ReactElement<ProjectFaviconImageProps>;
9096 hooks.reset();
9197
......@@ -106,6 +112,62 @@ function renderImage(
106112describe("ProjectFavicon", () => {
107113 beforeEach(() => {
108114 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("๐Ÿฆ„");
109171 });
110172
111173 it("falls back when the displayed favicon fails without discarding a valid older image early", () => {
......@@ -134,6 +196,7 @@ describe("ProjectFavicon", () => {
134196 ProjectFavicon({
135197 environmentId: "environment-test" as EnvironmentId,
136198 cwd: "/workspace-test",
199 projectName: "workspace-test",
137200 faviconPath: "brand/icon.svg",
138201 });
139202
apps/web/src/components/ProjectFavicon.tsx+163-9
......@@ -1,29 +1,153 @@
1import type { EnvironmentId } from "@t3tools/contracts";
1import type { EnvironmentId, ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts";
22import {
33 getProjectFaviconCacheKey,
44 isProjectFaviconFallbackUrl,
55} 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";
731import type { ComponentType } from "react";
8import { useState } from "react";
32import { lazy, Suspense, useState } from "react";
933import { useAssetUrlState } from "../assets/assetUrls";
34import { selectProjectIcon, type ProjectIconName } from "../projectIconModel";
35import { projectIconColorClassName } from "../projectIconColors";
1036import { cn } from "~/lib/utils";
1137
1238const 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
1497export function ProjectFavicon(input: {
1598 environmentId: EnvironmentId;
1699 cwd: string;
100 projectName: string;
17101 faviconPath?: string | null | undefined;
102 projectIcon?: ProjectIconOverride | null | undefined;
18103 className?: string | undefined;
19104 fallbackIcon?: ComponentType<{ className?: string }>;
20105}) {
21106 const state = useProjectFaviconAsset(input);
22107 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
25142 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 );
27151 }
28152
29153 const cacheKey = getProjectFaviconCacheKey(input.environmentId, input.cwd, src);
......@@ -35,6 +159,8 @@ export function ProjectFavicon(input: {
35159 src={src}
36160 className={input.className}
37161 fallbackIcon={FallbackIcon}
162 fallbackEmoji={fallbackEmoji}
163 fallbackColorClassName={fallbackColorClassName}
38164 />
39165 );
40166}
......@@ -53,12 +179,31 @@ export function useProjectFaviconAsset(input: {
53179
54180function ProjectFaviconFallback({
55181 className,
182 colorClassName,
56183 icon: Icon,
184 emoji,
57185}: {
58186 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;
60190}) {
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)} />;
62207}
63208
64209function ProjectFaviconImage({
......@@ -66,11 +211,15 @@ function ProjectFaviconImage({
66211 src,
67212 className,
68213 fallbackIcon: FallbackIcon,
214 fallbackEmoji,
215 fallbackColorClassName,
69216}: {
70217 readonly cacheKey: string;
71218 readonly src: string;
72219 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;
74223}) {
75224 const [displayedSrc, setDisplayedSrc] = useState<string | null>(
76225 () => loadedProjectFaviconSrcs.get(cacheKey) ?? null,
......@@ -86,7 +235,12 @@ function ProjectFaviconImage({
86235 return (
87236 <>
88237 {displayedSrc === null ? (
89 <ProjectFaviconFallback className={className} icon={FallbackIcon} />
238 <ProjectFaviconFallback
239 className={className}
240 colorClassName={fallbackColorClassName}
241 icon={FallbackIcon}
242 emoji={fallbackEmoji}
243 />
90244 ) : null}
91245 {displayedSrc ? (
92246 <img
apps/web/src/components/Sidebar.tsx+49-7
......@@ -32,6 +32,7 @@ import {
3232import {
3333 resolveEnvironmentMachineKind,
3434 type EnvironmentMachineKind,
35 type ProjectIconOverride,
3536 type ScopedThreadRef,
3637 type ThreadId,
3738} from "@t3tools/contracts";
......@@ -278,6 +279,7 @@ function SidebarThreadTooltip({
278279 projectTitle,
279280 projectCwd,
280281 projectFaviconPath,
282 projectIcon,
281283 environmentLabel,
282284 environmentMachine,
283285 providerEntry,
......@@ -292,6 +294,7 @@ function SidebarThreadTooltip({
292294 projectTitle: string | null;
293295 projectCwd: string | null;
294296 projectFaviconPath: string | null;
297 projectIcon: ProjectIconOverride | null;
295298 environmentLabel: string | null;
296299 environmentMachine: EnvironmentMachineKind;
297300 providerEntry: ProviderInstanceEntry | null;
......@@ -324,8 +327,10 @@ function SidebarThreadTooltip({
324327 <ProjectFavicon
325328 environmentId={thread.environmentId}
326329 cwd={projectCwd ?? ""}
330 projectName={projectTitle}
327331 faviconPath={projectFaviconPath}
328 className="size-3 shrink-0 stroke-muted-foreground"
332 projectIcon={projectIcon}
333 className="size-3 shrink-0"
329334 />
330335 <div className="min-w-0 truncate text-foreground/75">{projectTitle}</div>
331336 </div>
......@@ -495,6 +500,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
495500 projectTitle: string | null;
496501 projectCwd: string | null;
497502 projectFaviconPath: string | null;
503 projectIcon: ProjectIconOverride | null;
498504 isActive: boolean;
499505 onNavigate: (draftId: DraftId) => void;
500506 onDiscard: (draftId: DraftId) => void;
......@@ -560,7 +566,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
560566 <ProjectFavicon
561567 environmentId={session.environmentId}
562568 cwd={props.projectCwd ?? ""}
569 projectName={props.projectTitle ?? ""}
563570 faviconPath={props.projectFaviconPath}
571 projectIcon={props.projectIcon}
564572 className="size-4 shrink-0"
565573 />
566574 <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: {
605613 projectDisplayNameByKey: ReadonlyMap<string, string>;
606614 projectCwdByKey: ReadonlyMap<string, string>;
607615 projectFaviconPathByKey: ReadonlyMap<string, string | null | undefined>;
616 projectIconByKey: ReadonlyMap<string, ProjectIconOverride | null | undefined>;
608617 scopedProjectKeys: ReadonlySet<string> | null;
609618 routeDraftId: string | null;
610619 onNavigateToDraft: (draftId: DraftId) => void;
......@@ -701,6 +710,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
701710 projectTitle={props.projectDisplayNameByKey.get(projectKey) ?? null}
702711 projectCwd={props.projectCwdByKey.get(projectKey) ?? null}
703712 projectFaviconPath={props.projectFaviconPathByKey.get(projectKey) ?? null}
713 projectIcon={props.projectIconByKey.get(projectKey) ?? null}
704714 isActive={draftId === props.routeDraftId}
705715 onNavigate={props.onNavigateToDraft}
706716 onDiscard={handleDiscard}
......@@ -748,6 +758,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
748758 environmentMachine: EnvironmentMachineKind;
749759 projectCwd: string | null;
750760 projectFaviconPath: string | null;
761 projectIcon: ProjectIconOverride | null;
751762 projectTitle: string | null;
752763 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;
753764 timestampFormat: TimestampFormat;
......@@ -986,6 +997,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
986997 projectTitle={props.projectTitle}
987998 projectCwd={props.projectCwd}
988999 projectFaviconPath={props.projectFaviconPath}
1000 projectIcon={props.projectIcon}
9891001 environmentLabel={props.environmentLabel}
9901002 environmentMachine={props.environmentMachine}
9911003 providerEntry={providerEntry}
......@@ -1293,7 +1305,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
12931305 <ProjectFavicon
12941306 environmentId={thread.environmentId}
12951307 cwd={props.projectCwd ?? ""}
1308 projectName={props.projectTitle ?? thread.title}
12961309 faviconPath={props.projectFaviconPath}
1310 projectIcon={props.projectIcon}
12971311 className="size-4"
12981312 fallbackIcon={MessageSquareIcon}
12991313 />
......@@ -1448,7 +1462,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
14481462 <ProjectFavicon
14491463 environmentId={thread.environmentId}
14501464 cwd={props.projectCwd ?? ""}
1465 projectName={props.projectTitle ?? ""}
14511466 faviconPath={props.projectFaviconPath}
1467 projectIcon={props.projectIcon}
14521468 className="size-4 shrink-0"
14531469 />
14541470 {props.projectTitle ? (
......@@ -1653,6 +1669,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
16531669 thread: SidebarThreadSummary;
16541670 projectCwd: string | null;
16551671 projectFaviconPath: string | null;
1672 projectIcon: ProjectIconOverride | null;
16561673 projectTitle: string | null;
16571674 environmentLabel: string | null;
16581675 environmentMachine: EnvironmentMachineKind;
......@@ -1736,7 +1753,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
17361753 <ProjectFavicon
17371754 environmentId={thread.environmentId}
17381755 cwd={props.projectCwd ?? ""}
1756 projectName={props.projectTitle ?? thread.title}
17391757 faviconPath={props.projectFaviconPath}
1758 projectIcon={props.projectIcon}
17401759 className="size-4 shrink-0"
17411760 fallbackIcon={MessageSquareIcon}
17421761 />
......@@ -1750,6 +1769,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
17501769 projectTitle={props.projectTitle}
17511770 projectCwd={props.projectCwd}
17521771 projectFaviconPath={props.projectFaviconPath}
1772 projectIcon={props.projectIcon}
17531773 environmentLabel={props.environmentLabel}
17541774 environmentMachine={props.environmentMachine}
17551775 providerEntry={providerEntry}
......@@ -1971,6 +1991,13 @@ export default function Sidebar() {
19711991 ),
19721992 [projects],
19731993 );
1994 const projectIconByKey = useMemo(
1995 () =>
1996 new Map(
1997 projects.map((project) => [`${project.environmentId}:${project.id}`, project.projectIcon]),
1998 ),
1999 [projects],
2000 );
19742001 const projectDisplayNameByKey = useMemo(
19752002 () =>
19762003 new Map(
......@@ -3603,12 +3630,16 @@ export default function Sidebar() {
36033630 }
36043631 >
36053632 {scopedProjectGroup ? (
3606 <ProjectFavicon
3607 environmentId={scopedProjectGroup.environmentId}
3608 cwd={scopedProjectGroup.workspaceRoot}
3609 faviconPath={scopedProjectGroup.faviconPath}
3610 className="size-4 shrink-0"
3611 />
3633 <span className="flex shrink-0">
3634 <ProjectFavicon
3635 environmentId={scopedProjectGroup.environmentId}
3636 cwd={scopedProjectGroup.workspaceRoot}
3637 projectName={scopedProjectGroup.displayName}
3638 faviconPath={scopedProjectGroup.faviconPath}
3639 projectIcon={scopedProjectGroup.projectIcon}
3640 className="size-4"
3641 />
3642 </span>
36123643 ) : (
36133644 <FolderIcon className="size-4 shrink-0" />
36143645 )}
......@@ -3661,7 +3692,9 @@ export default function Sidebar() {
36613692 <ProjectFavicon
36623693 environmentId={project.environmentId}
36633694 cwd={project.workspaceRoot}
3695 projectName={project.displayName}
36643696 faviconPath={project.faviconPath}
3697 projectIcon={project.projectIcon}
36653698 className="size-4 shrink-0"
36663699 />
36673700 ) : (
......@@ -3745,6 +3778,10 @@ export default function Sidebar() {
37453778 `${thread.environmentId}:${thread.projectId}`,
37463779 ) ?? null
37473780 }
3781 projectIcon={
3782 projectIconByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
3783 null
3784 }
37483785 projectTitle={
37493786 projectDisplayNameByKey.get(
37503787 `${thread.environmentId}:${thread.projectId}`,
......@@ -3863,6 +3900,10 @@ export default function Sidebar() {
38633900 `${thread.environmentId}:${thread.projectId}`,
38643901 ) ?? null
38653902 }
3903 projectIcon={
3904 projectIconByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
3905 null
3906 }
38663907 projectTitle={
38673908 projectDisplayNameByKey.get(
38683909 `${thread.environmentId}:${thread.projectId}`,
......@@ -3906,6 +3947,7 @@ export default function Sidebar() {
39063947 projectDisplayNameByKey={projectDisplayNameByKey}
39073948 projectCwdByKey={projectCwdByKey}
39083949 projectFaviconPathByKey={projectFaviconPathByKey}
3950 projectIconByKey={projectIconByKey}
39093951 scopedProjectKeys={scopedProjectKeys}
39103952 routeDraftId={routeDraftIdForRows}
39113953 onNavigateToDraft={navigateToDraft}
apps/web/src/components/ThreadCommandSubtitle.tsx+3
......@@ -38,6 +38,7 @@ export function ThreadCommandSubtitle(props: {
3838 environmentId: EnvironmentId;
3939 projectCwd: string | null;
4040 projectFaviconPath?: string | null;
41 projectIcon?: import("@t3tools/contracts").ProjectIconOverride | null;
4142 projectTitle: string | null;
4243 branch: string | null;
4344 worktreePath: string | null;
......@@ -72,7 +73,9 @@ export function ThreadCommandSubtitle(props: {
7273 <ProjectFavicon
7374 environmentId={props.environmentId}
7475 cwd={props.projectCwd}
76 projectName={projectLabel}
7577 faviconPath={props.projectFaviconPath}
78 projectIcon={props.projectIcon}
7679 className="size-3 shrink-0"
7780 />
7881 ) : null}
apps/web/src/components/chat/ChatHeader.tsx+4
......@@ -56,6 +56,7 @@ interface ChatHeaderProps {
5656 activeProjectName: string | undefined;
5757 activeProjectCwd: string | null;
5858 activeProjectFaviconPath: string | null;
59 activeProjectIcon: import("@t3tools/contracts").ProjectIconOverride | null;
5960 openInCwd: string | null;
6061 activeProjectScripts: ReadonlyArray<ProjectScript> | undefined;
6162 preferredScriptId: string | null;
......@@ -126,6 +127,7 @@ export const ChatHeader = memo(function ChatHeader({
126127 activeProjectName,
127128 activeProjectCwd,
128129 activeProjectFaviconPath,
130 activeProjectIcon,
129131 openInCwd,
130132 activeProjectScripts,
131133 preferredScriptId,
......@@ -325,7 +327,9 @@ export const ChatHeader = memo(function ChatHeader({
325327 <ProjectFavicon
326328 environmentId={activeThreadEnvironmentId}
327329 cwd={activeProjectCwd ?? ""}
330 projectName={activeProjectName}
328331 faviconPath={activeProjectFaviconPath}
332 projectIcon={activeProjectIcon}
329333 className="size-3.5"
330334 />
331335 <span className="max-w-40 truncate">{activeProjectName}</span>
apps/web/src/components/pullRequest/PullRequestListFilters.tsx+14-1
......@@ -2,6 +2,7 @@ import type {
22 EnvironmentId,
33 ProjectId,
44 PullRequestInvolvement,
5 ProjectIconOverride,
56 PullRequestListFilters,
67 PullRequestListState,
78 SourceControlProviderKind,
......@@ -58,6 +59,8 @@ export interface PullRequestFilterOption<Value extends string> {
5859 readonly favicon?: {
5960 readonly environmentId: EnvironmentId;
6061 readonly cwd: string;
62 readonly faviconPath?: string | null;
63 readonly projectIcon?: ProjectIconOverride | null;
6164 };
6265 /** Why it cannot be chosen, carried onto the item as its title. */
6366 readonly unavailable?: string | undefined;
......@@ -72,6 +75,9 @@ export function PullRequestFilterOptionIcon<Value extends string>({
7275 <ProjectFavicon
7376 environmentId={option.favicon.environmentId}
7477 cwd={option.favicon.cwd}
78 projectName={option.label}
79 faviconPath={option.favicon.faviconPath}
80 projectIcon={option.favicon.projectIcon}
7581 fallbackIcon={FolderGit2Icon}
7682 className="size-3.5"
7783 />
......@@ -450,6 +456,8 @@ export function PullRequestFiltersMenu({
450456 readonly environmentId: EnvironmentId;
451457 readonly title: string;
452458 readonly workspaceRoot: string;
459 readonly faviconPath?: string | null;
460 readonly projectIcon?: ProjectIconOverride | null;
453461 }>;
454462 projectId: ProjectId | undefined;
455463 /**
......@@ -505,7 +513,12 @@ export function PullRequestFiltersMenu({
505513 value: pullRequestProjectKey(project),
506514 label: project.title,
507515 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 },
509522 ...(unavailable.has(pullRequestProjectKey(project))
510523 ? { unavailable: unavailable.get(pullRequestProjectKey(project)) }
511524 : {}),
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 {
1515import type {
1616 ContextMenuItem,
1717 ModelSelection,
18 ProjectIconOverride,
1819 ProviderDriverKind,
1920 SidebarProjectGroupingMode,
2021 T3ProjectFileScript,
......@@ -27,6 +28,8 @@ import { useCanGoBack, useNavigate } from "@tanstack/react-router";
2728import * as Cause from "effect/Cause";
2829import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react";
2930import {
31 lazy,
32 Suspense,
3033 useCallback,
3134 useEffect,
3235 useMemo,
......@@ -118,6 +121,12 @@ import {
118121} from "./ProjectFaviconPickerDialog";
119122import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic";
120123
124const ProjectIconPickerDialog = lazy(() =>
125 import("./ProjectIconPickerDialog").then((module) => ({
126 default: module.ProjectIconPickerDialog,
127 })),
128);
129
121130export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
122131 repository: "Group by repository",
123132 repository_path: "Group by repository path",
......@@ -335,6 +344,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
335344 });
336345
337346 const faviconPath = representative.faviconPath ?? null;
347 const projectIcon = representative.projectIcon ?? null;
338348 const pickProjectFavicon =
339349 typeof window !== "undefined" &&
340350 group.memberProjects.every(
......@@ -375,6 +385,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
375385 defaultThreadEnvMode: ThreadEnvMode | null;
376386 autoPull: boolean;
377387 faviconPath: string | null;
388 projectIcon: ProjectIconOverride | null;
378389 }>,
379390 failureTitle: string,
380391 ): Promise<AtomCommandResult<void, unknown>> => {
......@@ -474,17 +485,18 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
474485 [updateAllMembers],
475486 );
476487
477 // ----- favicon -----
488 // ----- project icon -----
478489 const [faviconPickerOpen, setFaviconPickerOpen] = useState(false);
490 const [iconPickerOpen, setIconPickerOpen] = useState(false);
479491 const [isSavingFavicon, setIsSavingFavicon] = useState(false);
480492 const savingFaviconRef = useRef(false);
481 const setFaviconPath = useCallback(
482 async (faviconPath: string | null) => {
493 const setProjectIcon = useCallback(
494 async (input: { faviconPath: string | null; projectIcon: ProjectIconOverride | null }) => {
483495 if (savingFaviconRef.current) return;
484496 savingFaviconRef.current = true;
485497 setIsSavingFavicon(true);
486498 try {
487 await updateAllMembers({ faviconPath }, "Failed to update project icon");
499 await updateAllMembers(input, "Failed to update project icon");
488500 } finally {
489501 savingFaviconRef.current = false;
490502 setIsSavingFavicon(false);
......@@ -824,13 +836,19 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
824836 />
825837 <SettingsRow
826838 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 }
828846 resetAction={
829 faviconPath !== null ? (
847 faviconPath !== null || projectIcon !== null ? (
830848 <SettingResetButton
831849 label="project icon"
832850 disabled={isSavingFavicon}
833 onClick={() => void setFaviconPath(null)}
851 onClick={() => void setProjectIcon({ faviconPath: null, projectIcon: null })}
834852 />
835853 ) : null
836854 }
......@@ -839,13 +857,25 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
839857 <ProjectFavicon
840858 environmentId={representative.environmentId}
841859 cwd={representative.workspaceRoot}
860 projectName={group.displayName}
842861 faviconPath={faviconPath}
862 projectIcon={projectIcon}
843863 className="size-6"
844864 />
845865 <Button
846866 size="sm"
847867 variant="outline"
848868 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"
849879 aria-label="Choose a project icon file"
850880 disabled={isSavingFavicon}
851881 onClick={() => setFaviconPickerOpen(true)}
......@@ -1239,10 +1269,20 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
12391269 {...(pickProjectFavicon
12401270 ? { onPickExternal: () => pickProjectFavicon(representative.workspaceRoot) }
12411271 : {})}
1242 onSelect={(path) => void setFaviconPath(path)}
1272 onSelect={(path) => void setProjectIcon({ faviconPath: path, projectIcon: null })}
12431273 open={faviconPickerOpen}
12441274 projectName={group.displayName}
12451275 />
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}
12461286 </>
12471287 );
12481288}
apps/web/src/components/settings/SettingsPanels.tsx+3
......@@ -2822,6 +2822,7 @@ export function ArchivedThreadsPanel() {
28222822 name: project.title,
28232823 cwd: project.workspaceRoot,
28242824 faviconPath: project.faviconPath,
2825 projectIcon: project.projectIcon,
28252826 },
28262827 ] as const,
28272828 ),
......@@ -2947,7 +2948,9 @@ export function ArchivedThreadsPanel() {
29472948 <ProjectFavicon
29482949 environmentId={project.environmentId}
29492950 cwd={project.cwd}
2951 projectName={project.name}
29502952 faviconPath={project.faviconPath}
2953 projectIcon={project.projectIcon}
29512954 />
29522955 }
29532956 >
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() {
362362 ? `${project.title} · ${environmentLabels.get(project.environmentId) ?? project.environmentId}`
363363 : project.title,
364364 workspaceRoot: project.workspaceRoot,
365 faviconPath: project.faviconPath ?? null,
366 projectIcon: project.projectIcon ?? null,
365367 }))
366368 .toSorted((left, right) => left.title.localeCompare(right.title));
367369 }, [environmentLabels, projects]);
docs/user/project-settings.md+9-4
......@@ -1,14 +1,19 @@
11# Customize a project icon
22
33T3 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
891. Open **Settings** and select **Projects**.
9102. Select the project.
103. Under **Appearance**, select **Choose a project file**.
114. Search for an image file and select it.
113. Next to **Project icon**, select **Choose icon**.
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
1318T3 Code supports SVG, PNG, ICO, JPEG, GIF, AVIF, and WebP files. The selected path applies to
1419each 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", () =>
11051105 }),
11061106);
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
11081138it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects svg", () => {
11091139 assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/png"), true);
11101140 assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true);
packages/contracts/src/orchestration.ts+47
......@@ -282,6 +282,48 @@ export const ProjectFaviconPath = TrimmedNonEmptyString.check(
282282);
283283export 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
285327export const OrchestrationProject = Schema.Struct({
286328 id: ProjectId,
287329 title: TrimmedNonEmptyString,
......@@ -296,6 +338,7 @@ export const OrchestrationProject = Schema.Struct({
296338 autoPull: Schema.optional(Schema.Boolean),
297339 // Optional on the wire so cached snapshots from older servers still decode.
298340 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
341 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),
299342 scripts: Schema.Array(ProjectScript),
300343 createdAt: IsoDateTime,
301344 updatedAt: IsoDateTime,
......@@ -508,6 +551,7 @@ export const OrchestrationProjectShell = Schema.Struct({
508551 autoPull: Schema.optional(Schema.Boolean),
509552 // Optional on the wire so cached snapshots from older servers still decode.
510553 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
554 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),
511555 scripts: Schema.Array(ProjectScript),
512556 createdAt: IsoDateTime,
513557 updatedAt: IsoDateTime,
......@@ -729,6 +773,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({
729773 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
730774 autoPull: Schema.optional(Schema.Boolean),
731775 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
776 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),
732777 scripts: Schema.optional(Schema.Array(ProjectScript)),
733778});
734779
......@@ -1185,6 +1230,7 @@ export const ProjectCreatedPayload = Schema.Struct({
11851230 defaultModelSelection: Schema.NullOr(ModelSelection),
11861231 // Optional so persisted events from older servers still decode.
11871232 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
1233 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),
11881234 scripts: Schema.Array(ProjectScript),
11891235 createdAt: IsoDateTime,
11901236 updatedAt: IsoDateTime,
......@@ -1199,6 +1245,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({
11991245 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
12001246 autoPull: Schema.optional(Schema.Boolean),
12011247 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
1248 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),
12021249 scripts: Schema.optional(Schema.Array(ProjectScript)),
12031250 updatedAt: IsoDateTime,
12041251});