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