| author | |
| committer | |
| log | f6c04c552c203350705f9ab1e47773ea736af245 |
| tree | 09d83afb8d7bb7c6aae6b31b58695036cc5a14b2 |
| parent | 6a5a18cb1bc9ea878afa1146950439b5d4744110 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 3423 | model: "gpt-5", |
| 3424 | 3424 | }, |
| 3425 | 3425 | faviconPath: "brand/icon.svg", |
| 3426 | projectIcon: { kind: "emoji", emoji: "๐" }, | |
| 3426 | 3427 | }); |
| 3427 | 3428 | |
| 3428 | 3429 | const projectRows = yield* sql<{ |
| 3429 | 3430 | readonly scriptsJson: string; |
| 3430 | 3431 | readonly defaultModelSelection: string; |
| 3431 | 3432 | readonly faviconPath: string | null; |
| 3433 | readonly projectIcon: string | null; | |
| 3432 | 3434 | }>` |
| 3433 | 3435 | SELECT |
| 3434 | 3436 | scripts_json AS "scriptsJson", |
| 3435 | 3437 | default_model_selection_json AS "defaultModelSelection", |
| 3436 | favicon_path AS "faviconPath" | |
| 3438 | favicon_path AS "faviconPath", | |
| 3439 | project_icon_json AS "projectIcon" | |
| 3437 | 3440 | FROM projection_projects |
| 3438 | 3441 | WHERE project_id = 'project-scripts' |
| 3439 | 3442 | `; |
| ... | ... | @@ -3443,6 +3446,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { |
| 3443 | 3446 | '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', |
| 3444 | 3447 | defaultModelSelection: '{"instanceId":"codex","model":"gpt-5"}', |
| 3445 | 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 | 517 | defaultThreadEnvMode: null, |
| 518 | 518 | autoPull: false, |
| 519 | 519 | faviconPath: event.payload.faviconPath ?? null, |
| 520 | projectIcon: event.payload.projectIcon ?? null, | |
| 520 | 521 | scripts: event.payload.scripts, |
| 521 | 522 | createdAt: event.payload.createdAt, |
| 522 | 523 | updatedAt: event.payload.updatedAt, |
| ... | ... | @@ -547,6 +548,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 547 | 548 | ...(event.payload.faviconPath !== undefined |
| 548 | 549 | ? { faviconPath: event.payload.faviconPath } |
| 549 | 550 | : {}), |
| 551 | ...(event.payload.projectIcon !== undefined | |
| 552 | ? { projectIcon: event.payload.projectIcon } | |
| 553 | : {}), | |
| 550 | 554 | ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}), |
| 551 | 555 | updatedAt: event.payload.updatedAt, |
| 552 | 556 | }); |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2| ... | ... | @@ -280,6 +280,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 280 | 280 | }, |
| 281 | 281 | autoPull: false, |
| 282 | 282 | faviconPath: null, |
| 283 | projectIcon: null, | |
| 283 | 284 | scripts: [ |
| 284 | 285 | { |
| 285 | 286 | id: "script-1", |
| ... | ... | @@ -408,6 +409,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 408 | 409 | }, |
| 409 | 410 | autoPull: false, |
| 410 | 411 | faviconPath: null, |
| 412 | projectIcon: null, | |
| 411 | 413 | scripts: [ |
| 412 | 414 | { |
| 413 | 415 | id: "script-1", |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+9| ... | ... | @@ -12,6 +12,7 @@ import { |
| 12 | 12 | OrchestrationThread, |
| 13 | 13 | OrchestrationThreadDetailSnapshot, |
| 14 | 14 | ProjectScript, |
| 15 | ProjectIconOverride, | |
| 15 | 16 | TurnId, |
| 16 | 17 | type OrchestrationCheckpointSummary, |
| 17 | 18 | type OrchestrationLatestTurn, |
| ... | ... | @@ -84,6 +85,7 @@ const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( |
| 84 | 85 | Struct.assign({ |
| 85 | 86 | defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), |
| 86 | 87 | autoPull: Schema.Number, |
| 88 | projectIcon: Schema.NullOr(Schema.fromJsonString(ProjectIconOverride)), | |
| 87 | 89 | scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), |
| 88 | 90 | }), |
| 89 | 91 | ); |
| ... | ... | @@ -350,6 +352,7 @@ function mapProjectShellRow( |
| 350 | 352 | defaultThreadEnvMode: row.defaultThreadEnvMode, |
| 351 | 353 | autoPull: row.autoPull === 1, |
| 352 | 354 | faviconPath: row.faviconPath ?? null, |
| 355 | projectIcon: row.projectIcon ?? null, | |
| 353 | 356 | scripts: row.scripts, |
| 354 | 357 | createdAt: row.createdAt, |
| 355 | 358 | updatedAt: row.updatedAt, |
| ... | ... | @@ -443,6 +446,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 443 | 446 | default_thread_env_mode AS "defaultThreadEnvMode", |
| 444 | 447 | auto_pull AS "autoPull", |
| 445 | 448 | favicon_path AS "faviconPath", |
| 449 | project_icon_json AS "projectIcon", | |
| 446 | 450 | scripts_json AS "scripts", |
| 447 | 451 | created_at AS "createdAt", |
| 448 | 452 | updated_at AS "updatedAt", |
| ... | ... | @@ -919,6 +923,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 919 | 923 | default_thread_env_mode AS "defaultThreadEnvMode", |
| 920 | 924 | auto_pull AS "autoPull", |
| 921 | 925 | favicon_path AS "faviconPath", |
| 926 | project_icon_json AS "projectIcon", | |
| 922 | 927 | scripts_json AS "scripts", |
| 923 | 928 | created_at AS "createdAt", |
| 924 | 929 | updated_at AS "updatedAt", |
| ... | ... | @@ -944,6 +949,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 944 | 949 | default_thread_env_mode AS "defaultThreadEnvMode", |
| 945 | 950 | auto_pull AS "autoPull", |
| 946 | 951 | favicon_path AS "faviconPath", |
| 952 | project_icon_json AS "projectIcon", | |
| 947 | 953 | scripts_json AS "scripts", |
| 948 | 954 | created_at AS "createdAt", |
| 949 | 955 | updated_at AS "updatedAt", |
| ... | ... | @@ -1885,6 +1891,7 @@ pending_approval_requests AS ( |
| 1885 | 1891 | defaultThreadEnvMode: row.defaultThreadEnvMode, |
| 1886 | 1892 | autoPull: row.autoPull === 1, |
| 1887 | 1893 | faviconPath: row.faviconPath ?? null, |
| 1894 | projectIcon: row.projectIcon ?? null, | |
| 1888 | 1895 | scripts: row.scripts, |
| 1889 | 1896 | createdAt: row.createdAt, |
| 1890 | 1897 | updatedAt: row.updatedAt, |
| ... | ... | @@ -2021,6 +2028,7 @@ pending_approval_requests AS ( |
| 2021 | 2028 | defaultThreadEnvMode: row.defaultThreadEnvMode, |
| 2022 | 2029 | autoPull: row.autoPull === 1, |
| 2023 | 2030 | faviconPath: row.faviconPath ?? null, |
| 2031 | projectIcon: row.projectIcon ?? null, | |
| 2024 | 2032 | scripts: row.scripts, |
| 2025 | 2033 | createdAt: row.createdAt, |
| 2026 | 2034 | updatedAt: row.updatedAt, |
| ... | ... | @@ -2537,6 +2545,7 @@ pending_approval_requests AS ( |
| 2537 | 2545 | defaultThreadEnvMode: option.value.defaultThreadEnvMode, |
| 2538 | 2546 | autoPull: option.value.autoPull === 1, |
| 2539 | 2547 | faviconPath: option.value.faviconPath ?? null, |
| 2548 | projectIcon: option.value.projectIcon ?? null, | |
| 2540 | 2549 | scripts: option.value.scripts, |
| 2541 | 2550 | createdAt: option.value.createdAt, |
| 2542 | 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 | 94 | }), |
| 95 | 95 | ); |
| 96 | 96 | |
| 97 | it.effect("propagates a project favicon path in project.meta.update", () => | |
| 97 | it.effect("propagates project icon metadata in project.meta.update", () => | |
| 98 | 98 | Effect.gen(function* () { |
| 99 | 99 | const now = "2026-01-01T00:00:00.000Z"; |
| 100 | 100 | const readModel = yield* projectEvent(createEmptyReadModel(now), { |
| ... | ... | @@ -125,6 +125,7 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { |
| 125 | 125 | commandId: CommandId.make("cmd-project-update-favicon"), |
| 126 | 126 | projectId: asProjectId("project-favicon"), |
| 127 | 127 | faviconPath: "brand/icon.svg", |
| 128 | projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" }, | |
| 128 | 129 | }, |
| 129 | 130 | readModel, |
| 130 | 131 | }); |
| ... | ... | @@ -132,6 +133,11 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { |
| 132 | 133 | const event = Array.isArray(result) ? result[0] : result; |
| 133 | 134 | expect(event.type).toBe("project.meta-updated"); |
| 134 | 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 | ); |
| 137 | 143 |
apps/server/src/orchestration/decider.ts+2| ... | ... | @@ -224,6 +224,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 224 | 224 | // explicit project default. |
| 225 | 225 | defaultModelSelection: null, |
| 226 | 226 | faviconPath: null, |
| 227 | projectIcon: null, | |
| 227 | 228 | scripts: [], |
| 228 | 229 | createdAt: command.createdAt, |
| 229 | 230 | updatedAt: command.createdAt, |
| ... | ... | @@ -266,6 +267,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 266 | 267 | : {}), |
| 267 | 268 | ...(command.autoPull !== undefined ? { autoPull: command.autoPull } : {}), |
| 268 | 269 | ...(command.faviconPath !== undefined ? { faviconPath: command.faviconPath } : {}), |
| 270 | ...(command.projectIcon !== undefined ? { projectIcon: command.projectIcon } : {}), | |
| 269 | 271 | ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), |
| 270 | 272 | updatedAt: occurredAt, |
| 271 | 273 | }, |
apps/server/src/orchestration/projector.ts+4| ... | ... | @@ -217,6 +217,7 @@ export function projectEvent( |
| 217 | 217 | defaultThreadEnvMode: null, |
| 218 | 218 | autoPull: false, |
| 219 | 219 | faviconPath: payload.faviconPath ?? null, |
| 220 | projectIcon: payload.projectIcon ?? null, | |
| 220 | 221 | scripts: payload.scripts, |
| 221 | 222 | createdAt: payload.createdAt, |
| 222 | 223 | updatedAt: payload.updatedAt, |
| ... | ... | @@ -256,6 +257,9 @@ export function projectEvent( |
| 256 | 257 | ...(payload.faviconPath !== undefined |
| 257 | 258 | ? { faviconPath: payload.faviconPath } |
| 258 | 259 | : {}), |
| 260 | ...(payload.projectIcon !== undefined | |
| 261 | ? { projectIcon: payload.projectIcon } | |
| 262 | : {}), | |
| 259 | 263 | ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), |
| 260 | 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 | 6 | import * as Schema from "effect/Schema"; |
| 7 | 7 | import * as Struct from "effect/Struct"; |
| 8 | 8 | |
| 9 | import { ModelSelection, ProjectScript } from "@t3tools/contracts"; | |
| 9 | import { ModelSelection, ProjectIconOverride, ProjectScript } from "@t3tools/contracts"; | |
| 10 | 10 | import { toPersistenceSqlError } from "../Errors.ts"; |
| 11 | 11 | import { |
| 12 | 12 | DeleteProjectionProjectInput, |
| ... | ... | @@ -20,6 +20,7 @@ const ProjectionProjectDbRow = ProjectionProject.mapFields( |
| 20 | 20 | Struct.assign({ |
| 21 | 21 | defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), |
| 22 | 22 | autoPull: Schema.Number, |
| 23 | projectIcon: Schema.NullOr(Schema.fromJsonString(ProjectIconOverride)), | |
| 23 | 24 | scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), |
| 24 | 25 | }), |
| 25 | 26 | ); |
| ... | ... | @@ -40,6 +41,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { |
| 40 | 41 | default_thread_env_mode, |
| 41 | 42 | auto_pull, |
| 42 | 43 | favicon_path, |
| 44 | project_icon_json, | |
| 43 | 45 | scripts_json, |
| 44 | 46 | created_at, |
| 45 | 47 | updated_at, |
| ... | ... | @@ -53,6 +55,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { |
| 53 | 55 | ${row.defaultThreadEnvMode}, |
| 54 | 56 | ${row.autoPull ? 1 : 0}, |
| 55 | 57 | ${row.faviconPath ?? null}, |
| 58 | ${row.projectIcon ? JSON.stringify(row.projectIcon) : null}, | |
| 56 | 59 | ${JSON.stringify(row.scripts)}, |
| 57 | 60 | ${row.createdAt}, |
| 58 | 61 | ${row.updatedAt}, |
| ... | ... | @@ -66,6 +69,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { |
| 66 | 69 | default_thread_env_mode = excluded.default_thread_env_mode, |
| 67 | 70 | auto_pull = excluded.auto_pull, |
| 68 | 71 | favicon_path = excluded.favicon_path, |
| 72 | project_icon_json = excluded.project_icon_json, | |
| 69 | 73 | scripts_json = excluded.scripts_json, |
| 70 | 74 | created_at = excluded.created_at, |
| 71 | 75 | updated_at = excluded.updated_at, |
| ... | ... | @@ -86,6 +90,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { |
| 86 | 90 | default_thread_env_mode AS "defaultThreadEnvMode", |
| 87 | 91 | auto_pull AS "autoPull", |
| 88 | 92 | favicon_path AS "faviconPath", |
| 93 | project_icon_json AS "projectIcon", | |
| 89 | 94 | scripts_json AS "scripts", |
| 90 | 95 | created_at AS "createdAt", |
| 91 | 96 | updated_at AS "updatedAt", |
| ... | ... | @@ -108,6 +113,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { |
| 108 | 113 | default_thread_env_mode AS "defaultThreadEnvMode", |
| 109 | 114 | auto_pull AS "autoPull", |
| 110 | 115 | favicon_path AS "faviconPath", |
| 116 | project_icon_json AS "projectIcon", | |
| 111 | 117 | scripts_json AS "scripts", |
| 112 | 118 | created_at AS "createdAt", |
| 113 | 119 | updated_at AS "updatedAt", |
apps/server/src/persistence/Migrations.ts+2| ... | ... | @@ -58,6 +58,7 @@ import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts"; |
| 58 | 58 | import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.ts"; |
| 59 | 59 | import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts"; |
| 60 | 60 | import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts"; |
| 61 | import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts"; | |
| 61 | 62 | |
| 62 | 63 | /** |
| 63 | 64 | * Migration loader with all migrations defined inline. |
| ... | ... | @@ -116,6 +117,7 @@ export const migrationEntries = [ |
| 116 | 117 | [44, "ClearAutomaticProjectModelDefaults", Migration0044], |
| 117 | 118 | [45, "ProjectionProjectsAutoPull", Migration0045], |
| 118 | 119 | [46, "RepairAutomaticSettlementTimestamps", Migration0046], |
| 120 | [47, "ProjectionProjectIcon", Migration0047], | |
| 119 | 121 | ] as const; |
| 120 | 122 | |
| 121 | 123 | export 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 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 5 | ||
| 6 | import { runMigrations } from "../Migrations.ts"; | |
| 7 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; | |
| 8 | ||
| 9 | const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); | |
| 10 | ||
| 11 | layer("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 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | ||
| 4 | export 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 | 9 | import { |
| 10 | 10 | IsoDateTime, |
| 11 | 11 | ModelSelection, |
| 12 | ProjectIconOverride, | |
| 12 | 13 | ProjectId, |
| 13 | 14 | ProjectScript, |
| 14 | 15 | ThreadEnvMode, |
| ... | ... | @@ -28,6 +29,7 @@ export const ProjectionProject = Schema.Struct({ |
| 28 | 29 | defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), |
| 29 | 30 | autoPull: Schema.Boolean, |
| 30 | 31 | faviconPath: Schema.optional(Schema.NullOr(Schema.String)), |
| 32 | projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), | |
| 31 | 33 | scripts: Schema.Array(ProjectScript), |
| 32 | 34 | createdAt: IsoDateTime, |
| 33 | 35 | updatedAt: IsoDateTime, |
apps/web/src/components/ChatView.tsx+1| ... | ... | @@ -7436,6 +7436,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 7436 | 7436 | activeProjectName={activeProject?.title} |
| 7437 | 7437 | activeProjectCwd={activeProject?.workspaceRoot ?? null} |
| 7438 | 7438 | activeProjectFaviconPath={activeProject?.faviconPath ?? null} |
| 7439 | activeProjectIcon={activeProject?.projectIcon ?? null} | |
| 7439 | 7440 | openInCwd={gitCwd} |
| 7440 | 7441 | activeProjectScripts={activeProject?.scripts} |
| 7441 | 7442 | preferredScriptId={ |
apps/web/src/components/CommandPalette.tsx+17-1| ... | ... | @@ -181,8 +181,10 @@ function projectFavicon(project: Project) { |
| 181 | 181 | <ProjectFavicon |
| 182 | 182 | environmentId={project.environmentId} |
| 183 | 183 | cwd={project.workspaceRoot} |
| 184 | projectName={project.title} | |
| 184 | 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 | 941 | () => new Map(projects.map((project) => [project.id, project.faviconPath ?? null] as const)), |
| 940 | 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 | 954 | const projectTitleById = useMemo( |
| 943 | 955 | () => new Map<ProjectId, string>(projects.map((project) => [project.id, project.title])), |
| 944 | 956 | [projects], |
| ... | ... | @@ -1170,6 +1182,9 @@ function OpenCommandPaletteDialog(props: { |
| 1170 | 1182 | environmentId={thread.environmentId} |
| 1171 | 1183 | projectCwd={projectCwdById.get(thread.projectId) ?? null} |
| 1172 | 1184 | projectFaviconPath={projectFaviconPathById.get(thread.projectId) ?? null} |
| 1185 | projectIcon={ | |
| 1186 | projectIconByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null | |
| 1187 | } | |
| 1173 | 1188 | projectTitle={projectTitle ?? null} |
| 1174 | 1189 | branch={thread.branch} |
| 1175 | 1190 | worktreePath={thread.worktreePath} |
| ... | ... | @@ -1209,6 +1224,7 @@ function OpenCommandPaletteDialog(props: { |
| 1209 | 1224 | navigate, |
| 1210 | 1225 | projectCwdById, |
| 1211 | 1226 | projectFaviconPathById, |
| 1227 | projectIconByKey, | |
| 1212 | 1228 | projectTitleById, |
| 1213 | 1229 | providerEntryByEnvironmentAndInstanceId, |
| 1214 | 1230 | threadContentMatchByKey, |
apps/web/src/components/LegacySidebar.tsx+9-5| ... | ... | @@ -2340,11 +2340,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2340 | 2340 | }`} |
| 2341 | 2341 | /> |
| 2342 | 2342 | )} |
| 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> | |
| 2348 | 2352 | <span className="flex min-w-0 flex-1 items-center gap-2"> |
| 2349 | 2353 | <span className="truncate text-sm font-medium text-sidebar-foreground/90"> |
| 2350 | 2354 | {project.displayName} |
apps/web/src/components/ProjectFavicon.test.tsx+63| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import type { ComponentType, Dispatch, ReactElement, SetStateAction } from "react"; |
| 2 | 2 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | 3 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 4 | import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; | |
| 4 | 5 | |
| 5 | 6 | const testState = vi.hoisted(() => ({ |
| 6 | 7 | faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg", |
| ... | ... | @@ -52,6 +53,10 @@ vi.mock("react", async (importOriginal) => { |
| 52 | 53 | }); |
| 53 | 54 | |
| 54 | 55 | vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); |
| 56 | vi.mock("lucide-react/dynamic", () => ({ | |
| 57 | DynamicIcon: "dynamic-icon", | |
| 58 | iconNames: ["alarm-clock", "folder-code"], | |
| 59 | })); | |
| 55 | 60 | vi.mock("../assets/assetUrls", () => ({ |
| 56 | 61 | useAssetUrlState: (_environmentId: unknown, resource: unknown) => { |
| 57 | 62 | testState.lastResource = resource; |
| ... | ... | @@ -86,6 +91,7 @@ function resolveImageComponent(): { |
| 86 | 91 | const element = ProjectFavicon({ |
| 87 | 92 | environmentId: "environment-test" as EnvironmentId, |
| 88 | 93 | cwd: "/workspace-test", |
| 94 | projectName: "workspace-test", | |
| 89 | 95 | }) as ReactElement<ProjectFaviconImageProps>; |
| 90 | 96 | hooks.reset(); |
| 91 | 97 | |
| ... | ... | @@ -106,6 +112,62 @@ function renderImage( |
| 106 | 112 | describe("ProjectFavicon", () => { |
| 107 | 113 | beforeEach(() => { |
| 108 | 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 | }); |
| 110 | 172 | |
| 111 | 173 | it("falls back when the displayed favicon fails without discarding a valid older image early", () => { |
| ... | ... | @@ -134,6 +196,7 @@ describe("ProjectFavicon", () => { |
| 134 | 196 | ProjectFavicon({ |
| 135 | 197 | environmentId: "environment-test" as EnvironmentId, |
| 136 | 198 | cwd: "/workspace-test", |
| 199 | projectName: "workspace-test", | |
| 137 | 200 | faviconPath: "brand/icon.svg", |
| 138 | 201 | }); |
| 139 | 202 |
apps/web/src/components/ProjectFavicon.tsx+163-9| ... | ... | @@ -1,29 +1,153 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 1 | import type { EnvironmentId, ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; | |
| 2 | 2 | import { |
| 3 | 3 | getProjectFaviconCacheKey, |
| 4 | 4 | isProjectFaviconFallbackUrl, |
| 5 | 5 | } from "@t3tools/shared/projectFavicon"; |
| 6 | import { FolderIcon } from "lucide-react"; | |
| 6 | import { | |
| 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"; | |
| 30 | import type { IconName } from "lucide-react/dynamic"; | |
| 7 | 31 | import type { ComponentType } from "react"; |
| 8 | import { useState } from "react"; | |
| 32 | import { lazy, Suspense, useState } from "react"; | |
| 9 | 33 | import { useAssetUrlState } from "../assets/assetUrls"; |
| 34 | import { selectProjectIcon, type ProjectIconName } from "../projectIconModel"; | |
| 35 | import { projectIconColorClassName } from "../projectIconColors"; | |
| 10 | 36 | import { cn } from "~/lib/utils"; |
| 11 | 37 | |
| 12 | 38 | const loadedProjectFaviconSrcs = new Map<string, string>(); |
| 39 | const DynamicIcon = lazy(() => | |
| 40 | import("lucide-react/dynamic").then((module) => ({ default: module.DynamicIcon })), | |
| 41 | ); | |
| 42 | ||
| 43 | function DynamicProjectIconFallback() { | |
| 44 | return <FolderCodeIcon className="size-full text-[inherit]" />; | |
| 45 | } | |
| 46 | ||
| 47 | const 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 | ||
| 72 | const 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 | }; | |
| 13 | 96 | |
| 14 | 97 | export function ProjectFavicon(input: { |
| 15 | 98 | environmentId: EnvironmentId; |
| 16 | 99 | cwd: string; |
| 100 | projectName: string; | |
| 17 | 101 | faviconPath?: string | null | undefined; |
| 102 | projectIcon?: ProjectIconOverride | null | undefined; | |
| 18 | 103 | className?: string | undefined; |
| 19 | 104 | fallbackIcon?: ComponentType<{ className?: string }>; |
| 20 | 105 | }) { |
| 21 | 106 | const state = useProjectFaviconAsset(input); |
| 22 | 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; | |
| 24 | 141 | |
| 25 | 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 | } |
| 28 | 152 | |
| 29 | 153 | const cacheKey = getProjectFaviconCacheKey(input.environmentId, input.cwd, src); |
| ... | ... | @@ -35,6 +159,8 @@ export function ProjectFavicon(input: { |
| 35 | 159 | src={src} |
| 36 | 160 | className={input.className} |
| 37 | 161 | fallbackIcon={FallbackIcon} |
| 162 | fallbackEmoji={fallbackEmoji} | |
| 163 | fallbackColorClassName={fallbackColorClassName} | |
| 38 | 164 | /> |
| 39 | 165 | ); |
| 40 | 166 | } |
| ... | ... | @@ -53,12 +179,31 @@ export function useProjectFaviconAsset(input: { |
| 53 | 179 | |
| 54 | 180 | function ProjectFaviconFallback({ |
| 55 | 181 | className, |
| 182 | colorClassName, | |
| 56 | 183 | icon: Icon, |
| 184 | emoji, | |
| 57 | 185 | }: { |
| 58 | 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 | } |
| 63 | 208 | |
| 64 | 209 | function ProjectFaviconImage({ |
| ... | ... | @@ -66,11 +211,15 @@ function ProjectFaviconImage({ |
| 66 | 211 | src, |
| 67 | 212 | className, |
| 68 | 213 | fallbackIcon: FallbackIcon, |
| 214 | fallbackEmoji, | |
| 215 | fallbackColorClassName, | |
| 69 | 216 | }: { |
| 70 | 217 | readonly cacheKey: string; |
| 71 | 218 | readonly src: string; |
| 72 | 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 | 224 | const [displayedSrc, setDisplayedSrc] = useState<string | null>( |
| 76 | 225 | () => loadedProjectFaviconSrcs.get(cacheKey) ?? null, |
| ... | ... | @@ -86,7 +235,12 @@ function ProjectFaviconImage({ |
| 86 | 235 | return ( |
| 87 | 236 | <> |
| 88 | 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 | 244 | ) : null} |
| 91 | 245 | {displayedSrc ? ( |
| 92 | 246 | <img |
apps/web/src/components/Sidebar.tsx+49-7| ... | ... | @@ -32,6 +32,7 @@ import { |
| 32 | 32 | import { |
| 33 | 33 | resolveEnvironmentMachineKind, |
| 34 | 34 | type EnvironmentMachineKind, |
| 35 | type ProjectIconOverride, | |
| 35 | 36 | type ScopedThreadRef, |
| 36 | 37 | type ThreadId, |
| 37 | 38 | } from "@t3tools/contracts"; |
| ... | ... | @@ -278,6 +279,7 @@ function SidebarThreadTooltip({ |
| 278 | 279 | projectTitle, |
| 279 | 280 | projectCwd, |
| 280 | 281 | projectFaviconPath, |
| 282 | projectIcon, | |
| 281 | 283 | environmentLabel, |
| 282 | 284 | environmentMachine, |
| 283 | 285 | providerEntry, |
| ... | ... | @@ -292,6 +294,7 @@ function SidebarThreadTooltip({ |
| 292 | 294 | projectTitle: string | null; |
| 293 | 295 | projectCwd: string | null; |
| 294 | 296 | projectFaviconPath: string | null; |
| 297 | projectIcon: ProjectIconOverride | null; | |
| 295 | 298 | environmentLabel: string | null; |
| 296 | 299 | environmentMachine: EnvironmentMachineKind; |
| 297 | 300 | providerEntry: ProviderInstanceEntry | null; |
| ... | ... | @@ -324,8 +327,10 @@ function SidebarThreadTooltip({ |
| 324 | 327 | <ProjectFavicon |
| 325 | 328 | environmentId={thread.environmentId} |
| 326 | 329 | cwd={projectCwd ?? ""} |
| 330 | projectName={projectTitle} | |
| 327 | 331 | faviconPath={projectFaviconPath} |
| 328 | className="size-3 shrink-0 stroke-muted-foreground" | |
| 332 | projectIcon={projectIcon} | |
| 333 | className="size-3 shrink-0" | |
| 329 | 334 | /> |
| 330 | 335 | <div className="min-w-0 truncate text-foreground/75">{projectTitle}</div> |
| 331 | 336 | </div> |
| ... | ... | @@ -495,6 +500,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { |
| 495 | 500 | projectTitle: string | null; |
| 496 | 501 | projectCwd: string | null; |
| 497 | 502 | projectFaviconPath: string | null; |
| 503 | projectIcon: ProjectIconOverride | null; | |
| 498 | 504 | isActive: boolean; |
| 499 | 505 | onNavigate: (draftId: DraftId) => void; |
| 500 | 506 | onDiscard: (draftId: DraftId) => void; |
| ... | ... | @@ -560,7 +566,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { |
| 560 | 566 | <ProjectFavicon |
| 561 | 567 | environmentId={session.environmentId} |
| 562 | 568 | cwd={props.projectCwd ?? ""} |
| 569 | projectName={props.projectTitle ?? ""} | |
| 563 | 570 | faviconPath={props.projectFaviconPath} |
| 571 | projectIcon={props.projectIcon} | |
| 564 | 572 | className="size-4 shrink-0" |
| 565 | 573 | /> |
| 566 | 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 | 613 | projectDisplayNameByKey: ReadonlyMap<string, string>; |
| 606 | 614 | projectCwdByKey: ReadonlyMap<string, string>; |
| 607 | 615 | projectFaviconPathByKey: ReadonlyMap<string, string | null | undefined>; |
| 616 | projectIconByKey: ReadonlyMap<string, ProjectIconOverride | null | undefined>; | |
| 608 | 617 | scopedProjectKeys: ReadonlySet<string> | null; |
| 609 | 618 | routeDraftId: string | null; |
| 610 | 619 | onNavigateToDraft: (draftId: DraftId) => void; |
| ... | ... | @@ -701,6 +710,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { |
| 701 | 710 | projectTitle={props.projectDisplayNameByKey.get(projectKey) ?? null} |
| 702 | 711 | projectCwd={props.projectCwdByKey.get(projectKey) ?? null} |
| 703 | 712 | projectFaviconPath={props.projectFaviconPathByKey.get(projectKey) ?? null} |
| 713 | projectIcon={props.projectIconByKey.get(projectKey) ?? null} | |
| 704 | 714 | isActive={draftId === props.routeDraftId} |
| 705 | 715 | onNavigate={props.onNavigateToDraft} |
| 706 | 716 | onDiscard={handleDiscard} |
| ... | ... | @@ -748,6 +758,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 748 | 758 | environmentMachine: EnvironmentMachineKind; |
| 749 | 759 | projectCwd: string | null; |
| 750 | 760 | projectFaviconPath: string | null; |
| 761 | projectIcon: ProjectIconOverride | null; | |
| 751 | 762 | projectTitle: string | null; |
| 752 | 763 | providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>; |
| 753 | 764 | timestampFormat: TimestampFormat; |
| ... | ... | @@ -986,6 +997,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 986 | 997 | projectTitle={props.projectTitle} |
| 987 | 998 | projectCwd={props.projectCwd} |
| 988 | 999 | projectFaviconPath={props.projectFaviconPath} |
| 1000 | projectIcon={props.projectIcon} | |
| 989 | 1001 | environmentLabel={props.environmentLabel} |
| 990 | 1002 | environmentMachine={props.environmentMachine} |
| 991 | 1003 | providerEntry={providerEntry} |
| ... | ... | @@ -1293,7 +1305,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1293 | 1305 | <ProjectFavicon |
| 1294 | 1306 | environmentId={thread.environmentId} |
| 1295 | 1307 | cwd={props.projectCwd ?? ""} |
| 1308 | projectName={props.projectTitle ?? thread.title} | |
| 1296 | 1309 | faviconPath={props.projectFaviconPath} |
| 1310 | projectIcon={props.projectIcon} | |
| 1297 | 1311 | className="size-4" |
| 1298 | 1312 | fallbackIcon={MessageSquareIcon} |
| 1299 | 1313 | /> |
| ... | ... | @@ -1448,7 +1462,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1448 | 1462 | <ProjectFavicon |
| 1449 | 1463 | environmentId={thread.environmentId} |
| 1450 | 1464 | cwd={props.projectCwd ?? ""} |
| 1465 | projectName={props.projectTitle ?? ""} | |
| 1451 | 1466 | faviconPath={props.projectFaviconPath} |
| 1467 | projectIcon={props.projectIcon} | |
| 1452 | 1468 | className="size-4 shrink-0" |
| 1453 | 1469 | /> |
| 1454 | 1470 | {props.projectTitle ? ( |
| ... | ... | @@ -1653,6 +1669,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 1653 | 1669 | thread: SidebarThreadSummary; |
| 1654 | 1670 | projectCwd: string | null; |
| 1655 | 1671 | projectFaviconPath: string | null; |
| 1672 | projectIcon: ProjectIconOverride | null; | |
| 1656 | 1673 | projectTitle: string | null; |
| 1657 | 1674 | environmentLabel: string | null; |
| 1658 | 1675 | environmentMachine: EnvironmentMachineKind; |
| ... | ... | @@ -1736,7 +1753,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 1736 | 1753 | <ProjectFavicon |
| 1737 | 1754 | environmentId={thread.environmentId} |
| 1738 | 1755 | cwd={props.projectCwd ?? ""} |
| 1756 | projectName={props.projectTitle ?? thread.title} | |
| 1739 | 1757 | faviconPath={props.projectFaviconPath} |
| 1758 | projectIcon={props.projectIcon} | |
| 1740 | 1759 | className="size-4 shrink-0" |
| 1741 | 1760 | fallbackIcon={MessageSquareIcon} |
| 1742 | 1761 | /> |
| ... | ... | @@ -1750,6 +1769,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 1750 | 1769 | projectTitle={props.projectTitle} |
| 1751 | 1770 | projectCwd={props.projectCwd} |
| 1752 | 1771 | projectFaviconPath={props.projectFaviconPath} |
| 1772 | projectIcon={props.projectIcon} | |
| 1753 | 1773 | environmentLabel={props.environmentLabel} |
| 1754 | 1774 | environmentMachine={props.environmentMachine} |
| 1755 | 1775 | providerEntry={providerEntry} |
| ... | ... | @@ -1971,6 +1991,13 @@ export default function Sidebar() { |
| 1971 | 1991 | ), |
| 1972 | 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 | 2001 | const projectDisplayNameByKey = useMemo( |
| 1975 | 2002 | () => |
| 1976 | 2003 | new Map( |
| ... | ... | @@ -3603,12 +3630,16 @@ export default function Sidebar() { |
| 3603 | 3630 | } |
| 3604 | 3631 | > |
| 3605 | 3632 | {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> | |
| 3612 | 3643 | ) : ( |
| 3613 | 3644 | <FolderIcon className="size-4 shrink-0" /> |
| 3614 | 3645 | )} |
| ... | ... | @@ -3661,7 +3692,9 @@ export default function Sidebar() { |
| 3661 | 3692 | <ProjectFavicon |
| 3662 | 3693 | environmentId={project.environmentId} |
| 3663 | 3694 | cwd={project.workspaceRoot} |
| 3695 | projectName={project.displayName} | |
| 3664 | 3696 | faviconPath={project.faviconPath} |
| 3697 | projectIcon={project.projectIcon} | |
| 3665 | 3698 | className="size-4 shrink-0" |
| 3666 | 3699 | /> |
| 3667 | 3700 | ) : ( |
| ... | ... | @@ -3745,6 +3778,10 @@ export default function Sidebar() { |
| 3745 | 3778 | `${thread.environmentId}:${thread.projectId}`, |
| 3746 | 3779 | ) ?? null |
| 3747 | 3780 | } |
| 3781 | projectIcon={ | |
| 3782 | projectIconByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? | |
| 3783 | null | |
| 3784 | } | |
| 3748 | 3785 | projectTitle={ |
| 3749 | 3786 | projectDisplayNameByKey.get( |
| 3750 | 3787 | `${thread.environmentId}:${thread.projectId}`, |
| ... | ... | @@ -3863,6 +3900,10 @@ export default function Sidebar() { |
| 3863 | 3900 | `${thread.environmentId}:${thread.projectId}`, |
| 3864 | 3901 | ) ?? null |
| 3865 | 3902 | } |
| 3903 | projectIcon={ | |
| 3904 | projectIconByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? | |
| 3905 | null | |
| 3906 | } | |
| 3866 | 3907 | projectTitle={ |
| 3867 | 3908 | projectDisplayNameByKey.get( |
| 3868 | 3909 | `${thread.environmentId}:${thread.projectId}`, |
| ... | ... | @@ -3906,6 +3947,7 @@ export default function Sidebar() { |
| 3906 | 3947 | projectDisplayNameByKey={projectDisplayNameByKey} |
| 3907 | 3948 | projectCwdByKey={projectCwdByKey} |
| 3908 | 3949 | projectFaviconPathByKey={projectFaviconPathByKey} |
| 3950 | projectIconByKey={projectIconByKey} | |
| 3909 | 3951 | scopedProjectKeys={scopedProjectKeys} |
| 3910 | 3952 | routeDraftId={routeDraftIdForRows} |
| 3911 | 3953 | onNavigateToDraft={navigateToDraft} |
apps/web/src/components/ThreadCommandSubtitle.tsx+3| ... | ... | @@ -38,6 +38,7 @@ export function ThreadCommandSubtitle(props: { |
| 38 | 38 | environmentId: EnvironmentId; |
| 39 | 39 | projectCwd: string | null; |
| 40 | 40 | projectFaviconPath?: string | null; |
| 41 | projectIcon?: import("@t3tools/contracts").ProjectIconOverride | null; | |
| 41 | 42 | projectTitle: string | null; |
| 42 | 43 | branch: string | null; |
| 43 | 44 | worktreePath: string | null; |
| ... | ... | @@ -72,7 +73,9 @@ export function ThreadCommandSubtitle(props: { |
| 72 | 73 | <ProjectFavicon |
| 73 | 74 | environmentId={props.environmentId} |
| 74 | 75 | cwd={props.projectCwd} |
| 76 | projectName={projectLabel} | |
| 75 | 77 | faviconPath={props.projectFaviconPath} |
| 78 | projectIcon={props.projectIcon} | |
| 76 | 79 | className="size-3 shrink-0" |
| 77 | 80 | /> |
| 78 | 81 | ) : null} |
apps/web/src/components/chat/ChatHeader.tsx+4| ... | ... | @@ -56,6 +56,7 @@ interface ChatHeaderProps { |
| 56 | 56 | activeProjectName: string | undefined; |
| 57 | 57 | activeProjectCwd: string | null; |
| 58 | 58 | activeProjectFaviconPath: string | null; |
| 59 | activeProjectIcon: import("@t3tools/contracts").ProjectIconOverride | null; | |
| 59 | 60 | openInCwd: string | null; |
| 60 | 61 | activeProjectScripts: ReadonlyArray<ProjectScript> | undefined; |
| 61 | 62 | preferredScriptId: string | null; |
| ... | ... | @@ -126,6 +127,7 @@ export const ChatHeader = memo(function ChatHeader({ |
| 126 | 127 | activeProjectName, |
| 127 | 128 | activeProjectCwd, |
| 128 | 129 | activeProjectFaviconPath, |
| 130 | activeProjectIcon, | |
| 129 | 131 | openInCwd, |
| 130 | 132 | activeProjectScripts, |
| 131 | 133 | preferredScriptId, |
| ... | ... | @@ -325,7 +327,9 @@ export const ChatHeader = memo(function ChatHeader({ |
| 325 | 327 | <ProjectFavicon |
| 326 | 328 | environmentId={activeThreadEnvironmentId} |
| 327 | 329 | cwd={activeProjectCwd ?? ""} |
| 330 | projectName={activeProjectName} | |
| 328 | 331 | faviconPath={activeProjectFaviconPath} |
| 332 | projectIcon={activeProjectIcon} | |
| 329 | 333 | className="size-3.5" |
| 330 | 334 | /> |
| 331 | 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 | 2 | EnvironmentId, |
| 3 | 3 | ProjectId, |
| 4 | 4 | PullRequestInvolvement, |
| 5 | ProjectIconOverride, | |
| 5 | 6 | PullRequestListFilters, |
| 6 | 7 | PullRequestListState, |
| 7 | 8 | SourceControlProviderKind, |
| ... | ... | @@ -58,6 +59,8 @@ export interface PullRequestFilterOption<Value extends string> { |
| 58 | 59 | readonly favicon?: { |
| 59 | 60 | readonly environmentId: EnvironmentId; |
| 60 | 61 | readonly cwd: string; |
| 62 | readonly faviconPath?: string | null; | |
| 63 | readonly projectIcon?: ProjectIconOverride | null; | |
| 61 | 64 | }; |
| 62 | 65 | /** Why it cannot be chosen, carried onto the item as its title. */ |
| 63 | 66 | readonly unavailable?: string | undefined; |
| ... | ... | @@ -72,6 +75,9 @@ export function PullRequestFilterOptionIcon<Value extends string>({ |
| 72 | 75 | <ProjectFavicon |
| 73 | 76 | environmentId={option.favicon.environmentId} |
| 74 | 77 | cwd={option.favicon.cwd} |
| 78 | projectName={option.label} | |
| 79 | faviconPath={option.favicon.faviconPath} | |
| 80 | projectIcon={option.favicon.projectIcon} | |
| 75 | 81 | fallbackIcon={FolderGit2Icon} |
| 76 | 82 | className="size-3.5" |
| 77 | 83 | /> |
| ... | ... | @@ -450,6 +456,8 @@ export function PullRequestFiltersMenu({ |
| 450 | 456 | readonly environmentId: EnvironmentId; |
| 451 | 457 | readonly title: string; |
| 452 | 458 | readonly workspaceRoot: string; |
| 459 | readonly faviconPath?: string | null; | |
| 460 | readonly projectIcon?: ProjectIconOverride | null; | |
| 453 | 461 | }>; |
| 454 | 462 | projectId: ProjectId | undefined; |
| 455 | 463 | /** |
| ... | ... | @@ -505,7 +513,12 @@ export function PullRequestFiltersMenu({ |
| 505 | 513 | value: pullRequestProjectKey(project), |
| 506 | 514 | label: project.title, |
| 507 | 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 | 522 | ...(unavailable.has(pullRequestProjectKey(project)) |
| 510 | 523 | ? { unavailable: unavailable.get(pullRequestProjectKey(project)) } |
| 511 | 524 | : {}), |
apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx created+51| ... | ... | @@ -0,0 +1,51 @@ |
| 1 | import type { ReactNode } from "react"; | |
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | |
| 3 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 4 | ||
| 5 | vi.mock("../ui/button", () => ({ | |
| 6 | Button: ({ children }: { readonly children?: ReactNode }) => <button>{children}</button>, | |
| 7 | })); | |
| 8 | ||
| 9 | vi.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 | ||
| 22 | vi.mock("../ui/input", () => ({ Input: () => <input /> })); | |
| 23 | vi.mock("../ui/scroll-area", () => ({ | |
| 24 | ScrollArea: ({ children }: { readonly children?: ReactNode }) => <div>{children}</div>, | |
| 25 | })); | |
| 26 | vi.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 | ||
| 39 | import { ProjectIconPickerDialog } from "./ProjectIconPickerDialog"; | |
| 40 | ||
| 41 | describe("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 @@ |
| 1 | import type { ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; | |
| 2 | import { DynamicIcon, type IconName } from "lucide-react/dynamic"; | |
| 3 | import { useEffect, useMemo, useRef, useState } from "react"; | |
| 4 | import { | |
| 5 | filterProjectIconNames, | |
| 6 | firstEmoji, | |
| 7 | PROJECT_EMOJIS, | |
| 8 | PROJECT_ICON_COLORS, | |
| 9 | projectIconColorClassName, | |
| 10 | } from "../../projectIconOptions"; | |
| 11 | import { cn } from "~/lib/utils"; | |
| 12 | import { Button } from "../ui/button"; | |
| 13 | import { | |
| 14 | Dialog, | |
| 15 | DialogDescription, | |
| 16 | DialogFooter, | |
| 17 | DialogHeader, | |
| 18 | DialogPanel, | |
| 19 | DialogPopup, | |
| 20 | DialogTitle, | |
| 21 | } from "../ui/dialog"; | |
| 22 | import { Input } from "../ui/input"; | |
| 23 | import { ScrollArea } from "../ui/scroll-area"; | |
| 24 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; | |
| 25 | ||
| 26 | const DEFAULT_ICON: IconName = "folder-code"; | |
| 27 | const DEFAULT_COLOR: ProjectIconColor = "blue"; | |
| 28 | ||
| 29 | function iconLabel(name: string): string { | |
| 30 | return name | |
| 31 | .split("-") | |
| 32 | .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) | |
| 33 | .join(" "); | |
| 34 | } | |
| 35 | ||
| 36 | export 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 | 15 | import type { |
| 16 | 16 | ContextMenuItem, |
| 17 | 17 | ModelSelection, |
| 18 | ProjectIconOverride, | |
| 18 | 19 | ProviderDriverKind, |
| 19 | 20 | SidebarProjectGroupingMode, |
| 20 | 21 | T3ProjectFileScript, |
| ... | ... | @@ -27,6 +28,8 @@ import { useCanGoBack, useNavigate } from "@tanstack/react-router"; |
| 27 | 28 | import * as Cause from "effect/Cause"; |
| 28 | 29 | import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react"; |
| 29 | 30 | import { |
| 31 | lazy, | |
| 32 | Suspense, | |
| 30 | 33 | useCallback, |
| 31 | 34 | useEffect, |
| 32 | 35 | useMemo, |
| ... | ... | @@ -118,6 +121,12 @@ import { |
| 118 | 121 | } from "./ProjectFaviconPickerDialog"; |
| 119 | 122 | import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; |
| 120 | 123 | |
| 124 | const ProjectIconPickerDialog = lazy(() => | |
| 125 | import("./ProjectIconPickerDialog").then((module) => ({ | |
| 126 | default: module.ProjectIconPickerDialog, | |
| 127 | })), | |
| 128 | ); | |
| 129 | ||
| 121 | 130 | export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = { |
| 122 | 131 | repository: "Group by repository", |
| 123 | 132 | repository_path: "Group by repository path", |
| ... | ... | @@ -335,6 +344,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 335 | 344 | }); |
| 336 | 345 | |
| 337 | 346 | const faviconPath = representative.faviconPath ?? null; |
| 347 | const projectIcon = representative.projectIcon ?? null; | |
| 338 | 348 | const pickProjectFavicon = |
| 339 | 349 | typeof window !== "undefined" && |
| 340 | 350 | group.memberProjects.every( |
| ... | ... | @@ -375,6 +385,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 375 | 385 | defaultThreadEnvMode: ThreadEnvMode | null; |
| 376 | 386 | autoPull: boolean; |
| 377 | 387 | faviconPath: string | null; |
| 388 | projectIcon: ProjectIconOverride | null; | |
| 378 | 389 | }>, |
| 379 | 390 | failureTitle: string, |
| 380 | 391 | ): Promise<AtomCommandResult<void, unknown>> => { |
| ... | ... | @@ -474,17 +485,18 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 474 | 485 | [updateAllMembers], |
| 475 | 486 | ); |
| 476 | 487 | |
| 477 | // ----- favicon ----- | |
| 488 | // ----- project icon ----- | |
| 478 | 489 | const [faviconPickerOpen, setFaviconPickerOpen] = useState(false); |
| 490 | const [iconPickerOpen, setIconPickerOpen] = useState(false); | |
| 479 | 491 | const [isSavingFavicon, setIsSavingFavicon] = useState(false); |
| 480 | 492 | 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 }) => { | |
| 483 | 495 | if (savingFaviconRef.current) return; |
| 484 | 496 | savingFaviconRef.current = true; |
| 485 | 497 | setIsSavingFavicon(true); |
| 486 | 498 | try { |
| 487 | await updateAllMembers({ faviconPath }, "Failed to update project icon"); | |
| 499 | await updateAllMembers(input, "Failed to update project icon"); | |
| 488 | 500 | } finally { |
| 489 | 501 | savingFaviconRef.current = false; |
| 490 | 502 | setIsSavingFavicon(false); |
| ... | ... | @@ -824,13 +836,19 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 824 | 836 | /> |
| 825 | 837 | <SettingsRow |
| 826 | 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 | 846 | resetAction={ |
| 829 | faviconPath !== null ? ( | |
| 847 | faviconPath !== null || projectIcon !== null ? ( | |
| 830 | 848 | <SettingResetButton |
| 831 | 849 | label="project icon" |
| 832 | 850 | disabled={isSavingFavicon} |
| 833 | onClick={() => void setFaviconPath(null)} | |
| 851 | onClick={() => void setProjectIcon({ faviconPath: null, projectIcon: null })} | |
| 834 | 852 | /> |
| 835 | 853 | ) : null |
| 836 | 854 | } |
| ... | ... | @@ -839,13 +857,25 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 839 | 857 | <ProjectFavicon |
| 840 | 858 | environmentId={representative.environmentId} |
| 841 | 859 | cwd={representative.workspaceRoot} |
| 860 | projectName={group.displayName} | |
| 842 | 861 | faviconPath={faviconPath} |
| 862 | projectIcon={projectIcon} | |
| 843 | 863 | className="size-6" |
| 844 | 864 | /> |
| 845 | 865 | <Button |
| 846 | 866 | size="sm" |
| 847 | 867 | variant="outline" |
| 848 | 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 | 879 | aria-label="Choose a project icon file" |
| 850 | 880 | disabled={isSavingFavicon} |
| 851 | 881 | onClick={() => setFaviconPickerOpen(true)} |
| ... | ... | @@ -1239,10 +1269,20 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 1239 | 1269 | {...(pickProjectFavicon |
| 1240 | 1270 | ? { onPickExternal: () => pickProjectFavicon(representative.workspaceRoot) } |
| 1241 | 1271 | : {})} |
| 1242 | onSelect={(path) => void setFaviconPath(path)} | |
| 1272 | onSelect={(path) => void setProjectIcon({ faviconPath: path, projectIcon: null })} | |
| 1243 | 1273 | open={faviconPickerOpen} |
| 1244 | 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 | 2822 | name: project.title, |
| 2823 | 2823 | cwd: project.workspaceRoot, |
| 2824 | 2824 | faviconPath: project.faviconPath, |
| 2825 | projectIcon: project.projectIcon, | |
| 2825 | 2826 | }, |
| 2826 | 2827 | ] as const, |
| 2827 | 2828 | ), |
| ... | ... | @@ -2947,7 +2948,9 @@ export function ArchivedThreadsPanel() { |
| 2947 | 2948 | <ProjectFavicon |
| 2948 | 2949 | environmentId={project.environmentId} |
| 2949 | 2950 | cwd={project.cwd} |
| 2951 | projectName={project.name} | |
| 2950 | 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 @@ |
| 1 | import type { ProjectIconColor } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export 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 | ||
| 119 | const PROJECT_ICON_COLOR_CLASSES = Object.fromEntries( | |
| 120 | PROJECT_ICON_COLORS.map(({ value, className }) => [value, className]), | |
| 121 | ) as Record<ProjectIconColor, string>; | |
| 122 | ||
| 123 | export 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import { PROJECT_ICON_NAMES, selectProjectIcon } from "./projectIconModel"; | |
| 3 | ||
| 4 | describe("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 @@ |
| 1 | export 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 | ||
| 26 | export type ProjectIconName = (typeof PROJECT_ICON_NAMES)[number]; | |
| 27 | ||
| 28 | export type ProjectIconSelection = | |
| 29 | | { readonly kind: "lucide"; readonly icon: ProjectIconName } | |
| 30 | | { readonly kind: "emoji"; readonly icon: ProjectIconName; readonly emoji: string }; | |
| 31 | ||
| 32 | interface ProjectIconClass { | |
| 33 | readonly icon: ProjectIconName; | |
| 34 | readonly terms: ReadonlyArray<string>; | |
| 35 | } | |
| 36 | ||
| 37 | const 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 | ||
| 126 | const GENERIC_PROJECT_ICONS: ReadonlyArray<ProjectIconName> = [ | |
| 127 | "code", | |
| 128 | "braces", | |
| 129 | "circuit", | |
| 130 | "folder-code", | |
| 131 | "layers", | |
| 132 | ]; | |
| 133 | ||
| 134 | const 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 | ||
| 159 | const projectIconCache = new Map<string, ProjectIconSelection>(); | |
| 160 | ||
| 161 | function 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 | ||
| 169 | function 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 | ||
| 175 | function 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 | ||
| 184 | export 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import { | |
| 3 | filterProjectIconNames, | |
| 4 | firstEmoji, | |
| 5 | projectIconColorClassName, | |
| 6 | } from "./projectIconOptions"; | |
| 7 | ||
| 8 | describe("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 @@ |
| 1 | import { iconNames, type IconName } from "lucide-react/dynamic"; | |
| 2 | export { PROJECT_ICON_COLORS, projectIconColorClassName } from "./projectIconColors"; | |
| 3 | ||
| 4 | export 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 | ||
| 31 | export 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 | ||
| 64 | export 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 | ||
| 70 | export 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 | 362 | ? `${project.title} · ${environmentLabels.get(project.environmentId) ?? project.environmentId}` |
| 363 | 363 | : project.title, |
| 364 | 364 | workspaceRoot: project.workspaceRoot, |
| 365 | faviconPath: project.faviconPath ?? null, | |
| 366 | projectIcon: project.projectIcon ?? null, | |
| 365 | 367 | })) |
| 366 | 368 | .toSorted((left, right) => left.title.localeCompare(right.title)); |
| 367 | 369 | }, [environmentLabels, projects]); |
docs/user/project-settings.md+9-4| ... | ... | @@ -1,14 +1,19 @@ |
| 1 | 1 | # Customize a project icon |
| 2 | 2 | |
| 3 | 3 | T3 Code selects a project icon automatically. It checks `t3.json`, common favicon and app icon |
| 4 | paths, and icon links in project HTML files. | |
| 4 | paths, and icon links in project HTML files. If it does not find an image, it chooses a built-in | |
| 5 | emoji from the project name. | |
| 5 | 6 | |
| 6 | To choose a different icon: | |
| 7 | To choose a different icon or emoji: | |
| 7 | 8 | |
| 8 | 9 | 1. Open **Settings** and select **Projects**. |
| 9 | 10 | 2. Select the project. |
| 10 | 3. Under **Appearance**, select **Choose a project file**. | |
| 11 | 4. Search for an image file and select it. | |
| 11 | 3. Next to **Project icon**, select **Choose icon**. | |
| 12 | 4. Search the full Lucide icon set and choose a color, or switch to **Emoji** and choose or paste | |
| 13 | an emoji. | |
| 14 | ||
| 15 | To use an image from the project instead, select **Choose file**, search for an image, and select | |
| 16 | it. | |
| 12 | 17 | |
| 13 | 18 | T3 Code supports SVG, PNG, ICO, JPEG, GIF, AVIF, and WebP files. The selected path applies to |
| 14 | 19 | each 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 | 1105 | }), |
| 1106 | 1106 | ); |
| 1107 | 1107 | |
| 1108 | it.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 | ||
| 1108 | 1138 | it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects svg", () => { |
| 1109 | 1139 | assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/png"), true); |
| 1110 | 1140 | assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true); |
packages/contracts/src/orchestration.ts+47| ... | ... | @@ -282,6 +282,48 @@ export const ProjectFaviconPath = TrimmedNonEmptyString.check( |
| 282 | 282 | ); |
| 283 | 283 | export type ProjectFaviconPath = typeof ProjectFaviconPath.Type; |
| 284 | 284 | |
| 285 | export 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 | ]); | |
| 305 | export type ProjectIconColor = typeof ProjectIconColor.Type; | |
| 306 | ||
| 307 | const ProjectLucideIconName = TrimmedNonEmptyString.check( | |
| 308 | Schema.isMaxLength(64), | |
| 309 | Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), | |
| 310 | ); | |
| 311 | ||
| 312 | const ProjectEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32)); | |
| 313 | ||
| 314 | export 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 | ]); | |
| 325 | export type ProjectIconOverride = typeof ProjectIconOverride.Type; | |
| 326 | ||
| 285 | 327 | export const OrchestrationProject = Schema.Struct({ |
| 286 | 328 | id: ProjectId, |
| 287 | 329 | title: TrimmedNonEmptyString, |
| ... | ... | @@ -296,6 +338,7 @@ export const OrchestrationProject = Schema.Struct({ |
| 296 | 338 | autoPull: Schema.optional(Schema.Boolean), |
| 297 | 339 | // Optional on the wire so cached snapshots from older servers still decode. |
| 298 | 340 | faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), |
| 341 | projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), | |
| 299 | 342 | scripts: Schema.Array(ProjectScript), |
| 300 | 343 | createdAt: IsoDateTime, |
| 301 | 344 | updatedAt: IsoDateTime, |
| ... | ... | @@ -508,6 +551,7 @@ export const OrchestrationProjectShell = Schema.Struct({ |
| 508 | 551 | autoPull: Schema.optional(Schema.Boolean), |
| 509 | 552 | // Optional on the wire so cached snapshots from older servers still decode. |
| 510 | 553 | faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), |
| 554 | projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), | |
| 511 | 555 | scripts: Schema.Array(ProjectScript), |
| 512 | 556 | createdAt: IsoDateTime, |
| 513 | 557 | updatedAt: IsoDateTime, |
| ... | ... | @@ -729,6 +773,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({ |
| 729 | 773 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), |
| 730 | 774 | autoPull: Schema.optional(Schema.Boolean), |
| 731 | 775 | faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), |
| 776 | projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), | |
| 732 | 777 | scripts: Schema.optional(Schema.Array(ProjectScript)), |
| 733 | 778 | }); |
| 734 | 779 | |
| ... | ... | @@ -1185,6 +1230,7 @@ export const ProjectCreatedPayload = Schema.Struct({ |
| 1185 | 1230 | defaultModelSelection: Schema.NullOr(ModelSelection), |
| 1186 | 1231 | // Optional so persisted events from older servers still decode. |
| 1187 | 1232 | faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), |
| 1233 | projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), | |
| 1188 | 1234 | scripts: Schema.Array(ProjectScript), |
| 1189 | 1235 | createdAt: IsoDateTime, |
| 1190 | 1236 | updatedAt: IsoDateTime, |
| ... | ... | @@ -1199,6 +1245,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ |
| 1199 | 1245 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), |
| 1200 | 1246 | autoPull: Schema.optional(Schema.Boolean), |
| 1201 | 1247 | faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), |
| 1248 | projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), | |
| 1202 | 1249 | scripts: Schema.optional(Schema.Array(ProjectScript)), |
| 1203 | 1250 | updatedAt: IsoDateTime, |
| 1204 | 1251 | }); |