| author | |
| committer | |
| log | 5c03ced3be8444567db4b50a474619bc5e63db98 |
| tree | 0dec8dbf1c1a4f2dc037652c8627a61d9e253e4f |
| parent | ca3f9738c39c3502dd2258aae872f971080969ef |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>6 files changed, 101 insertions(+), 103 deletions(-)
apps/server/src/orchestration-v2/ProjectStore.ts+3-3| ... | @@ -3,7 +3,7 @@ import { | ... | @@ -3,7 +3,7 @@ import { |
| 3 | IsoDateTime, | 3 | IsoDateTime, |
| 4 | ModelSelection, | 4 | ModelSelection, |
| 5 | type OrchestrationProjectShell, | 5 | type OrchestrationProjectShell, |
| 6 | ProjectIconOverride, | 6 | StoredProjectIcon, |
| 7 | ProjectId, | 7 | ProjectId, |
| 8 | ProjectScript, | 8 | ProjectScript, |
| 9 | ThreadEnvMode, | 9 | ThreadEnvMode, |
| ... | @@ -37,7 +37,7 @@ export const ProjectRow = Schema.Struct({ | ... | @@ -37,7 +37,7 @@ export const ProjectRow = Schema.Struct({ |
| 37 | defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), | 37 | defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), |
| 38 | autoPull: Schema.Boolean, | 38 | autoPull: Schema.Boolean, |
| 39 | faviconPath: Schema.NullOr(Schema.String), | 39 | faviconPath: Schema.NullOr(Schema.String), |
| 40 | projectIcon: Schema.NullOr(ProjectIconOverride), | 40 | projectIcon: Schema.NullOr(StoredProjectIcon), |
| 41 | scripts: Schema.Array(ProjectScript), | 41 | scripts: Schema.Array(ProjectScript), |
| 42 | createdAt: IsoDateTime, | 42 | createdAt: IsoDateTime, |
| 43 | updatedAt: IsoDateTime, | 43 | updatedAt: IsoDateTime, |
| ... | @@ -49,7 +49,7 @@ const ProjectDbRow = Schema.Struct({ | ... | @@ -49,7 +49,7 @@ const ProjectDbRow = Schema.Struct({ |
| 49 | ...ProjectRow.fields, | 49 | ...ProjectRow.fields, |
| 50 | defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), | 50 | defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), |
| 51 | autoPull: Schema.BooleanFromBit, | 51 | autoPull: Schema.BooleanFromBit, |
| 52 | projectIcon: Schema.NullOr(Schema.fromJsonString(ProjectIconOverride)), | 52 | projectIcon: Schema.NullOr(Schema.fromJsonString(StoredProjectIcon)), |
| 53 | scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), | 53 | scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), |
| 54 | }); | 54 | }); |
| 55 | 55 |
apps/server/src/persistence/Layers/OrchestrationEventStore.ts+2-2| ... | @@ -9,7 +9,7 @@ import { | ... | @@ -9,7 +9,7 @@ import { |
| 9 | OrchestrationV2DomainEventJson, | 9 | OrchestrationV2DomainEventJson, |
| 10 | OrchestrationV2StoredEvent, | 10 | OrchestrationV2StoredEvent, |
| 11 | ProjectId, | 11 | ProjectId, |
| 12 | ProjectIconOverride, | 12 | StoredProjectIcon, |
| 13 | ThreadId, | 13 | ThreadId, |
| 14 | type OrchestrationV2DomainEvent, | 14 | type OrchestrationV2DomainEvent, |
| 15 | } from "@t3tools/contracts"; | 15 | } from "@t3tools/contracts"; |
| ... | @@ -31,7 +31,7 @@ import { | ... | @@ -31,7 +31,7 @@ import { |
| 31 | } from "../Errors.ts"; | 31 | } from "../Errors.ts"; |
| 32 | import * as OrchestrationEventStore from "../Services/OrchestrationEventStore.ts"; | 32 | import * as OrchestrationEventStore from "../Services/OrchestrationEventStore.ts"; |
| 33 | 33 | ||
| 34 | const encodeProjectIcon = Schema.encodeSync(ProjectIconOverride); | 34 | const encodeProjectIcon = Schema.encodeSync(StoredProjectIcon); |
| 35 | const decodeProjectEvent = Schema.decodeUnknownEffect(ApplicationProjectEvent); | 35 | const decodeProjectEvent = Schema.decodeUnknownEffect(ApplicationProjectEvent); |
| 36 | const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); | 36 | const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); |
| 37 | const EventMetadataFromJsonString = Schema.fromJsonString(ApplicationEventMetadata); | 37 | const EventMetadataFromJsonString = Schema.fromJsonString(ApplicationEventMetadata); |
packages/contracts/src/applicationEvent.ts+3-3| ... | @@ -14,7 +14,7 @@ import { | ... | @@ -14,7 +14,7 @@ import { |
| 14 | import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; | 14 | import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; |
| 15 | import { ModelSelection } from "./modelSelection.ts"; | 15 | import { ModelSelection } from "./modelSelection.ts"; |
| 16 | import type { OrchestrationV2StoredEvent } from "./orchestrationV2.ts"; | 16 | import type { OrchestrationV2StoredEvent } from "./orchestrationV2.ts"; |
| 17 | import { ProjectIconOverride, ProjectScript } from "./project.ts"; | 17 | import { ProjectScript, StoredProjectIcon } from "./project.ts"; |
| 18 | 18 | ||
| 19 | /** | 19 | /** |
| 20 | * Which client dispatched the command that produced this event (#7774). | 20 | * Which client dispatched the command that produced this event (#7774). |
| ... | @@ -51,7 +51,7 @@ export const ApplicationProjectCreatedPayload = Schema.Struct({ | ... | @@ -51,7 +51,7 @@ export const ApplicationProjectCreatedPayload = Schema.Struct({ |
| 51 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), | 51 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), |
| 52 | // Optional so persisted events from older servers still decode. | 52 | // Optional so persisted events from older servers still decode. |
| 53 | faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | 53 | faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 54 | projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), | 54 | projectIcon: Schema.optional(Schema.NullOr(StoredProjectIcon)), |
| 55 | scripts: Schema.Array(ProjectScript), | 55 | scripts: Schema.Array(ProjectScript), |
| 56 | createdAt: IsoDateTime, | 56 | createdAt: IsoDateTime, |
| 57 | updatedAt: IsoDateTime, | 57 | updatedAt: IsoDateTime, |
| ... | @@ -68,7 +68,7 @@ export const ApplicationProjectMetaUpdatedPayload = Schema.Struct({ | ... | @@ -68,7 +68,7 @@ export const ApplicationProjectMetaUpdatedPayload = Schema.Struct({ |
| 68 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), | 68 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), |
| 69 | autoPull: Schema.optional(Schema.Boolean), | 69 | autoPull: Schema.optional(Schema.Boolean), |
| 70 | faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | 70 | faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 71 | projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), | 71 | projectIcon: Schema.optional(Schema.NullOr(StoredProjectIcon)), |
| 72 | scripts: Schema.optional(Schema.Array(ProjectScript)), | 72 | scripts: Schema.optional(Schema.Array(ProjectScript)), |
| 73 | updatedAt: IsoDateTime, | 73 | updatedAt: IsoDateTime, |
| 74 | }); | 74 | }); |
packages/contracts/src/orchestrationProject.ts+2-2| ... | @@ -3,7 +3,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -3,7 +3,7 @@ import * as Schema from "effect/Schema"; |
| 3 | import { IsoDateTime, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | 3 | import { IsoDateTime, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 4 | import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; | 4 | import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; |
| 5 | import { ModelSelection } from "./modelSelection.ts"; | 5 | import { ModelSelection } from "./modelSelection.ts"; |
| 6 | import { ProjectIconOverride, ProjectScript } from "./project.ts"; | 6 | import { ProjectScript, ReceivedProjectIcon } from "./project.ts"; |
| 7 | 7 | ||
| 8 | /** Project summary shared by the V2 shell and application project APIs. */ | 8 | /** Project summary shared by the V2 shell and application project APIs. */ |
| 9 | export const OrchestrationProjectShell = Schema.Struct({ | 9 | export const OrchestrationProjectShell = Schema.Struct({ |
| ... | @@ -20,7 +20,7 @@ export const OrchestrationProjectShell = Schema.Struct({ | ... | @@ -20,7 +20,7 @@ export const OrchestrationProjectShell = Schema.Struct({ |
| 20 | autoPull: Schema.optional(Schema.Boolean), | 20 | autoPull: Schema.optional(Schema.Boolean), |
| 21 | // Optional on the wire so cached snapshots from older servers still decode. | 21 | // Optional on the wire so cached snapshots from older servers still decode. |
| 22 | faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | 22 | faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 23 | projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), | 23 | projectIcon: Schema.optional(Schema.NullOr(ReceivedProjectIcon)), |
| 24 | scripts: Schema.Array(ProjectScript), | 24 | scripts: Schema.Array(ProjectScript), |
| 25 | createdAt: IsoDateTime, | 25 | createdAt: IsoDateTime, |
| 26 | updatedAt: IsoDateTime, | 26 | updatedAt: IsoDateTime, |
packages/contracts/src/project.test.ts+34-62| ... | @@ -8,7 +8,8 @@ import { OrchestrationProjectShell } from "./orchestrationProject.ts"; | ... | @@ -8,7 +8,8 @@ import { OrchestrationProjectShell } from "./orchestrationProject.ts"; |
| 8 | 8 | ||
| 9 | import { | 9 | import { |
| 10 | ProjectFaviconPath, | 10 | ProjectFaviconPath, |
| 11 | ProjectIconOverride, | 11 | ReceivedProjectIcon, |
| 12 | StoredProjectIcon, | ||
| 12 | ProjectReadFileError, | 13 | ProjectReadFileError, |
| 13 | ProjectCreatePayload, | 14 | ProjectCreatePayload, |
| 14 | ProjectUpdatePayload, | 15 | ProjectUpdatePayload, |
| ... | @@ -204,85 +205,56 @@ effectIt.effect("project monograms validate text and palette colors", () => | ... | @@ -204,85 +205,56 @@ effectIt.effect("project monograms validate text and palette colors", () => |
| 204 | }), | 205 | }), |
| 205 | ); | 206 | ); |
| 206 | 207 | ||
| 207 | const decodeProjectIcon = Schema.decodeUnknownEffect(ProjectIconOverride); | 208 | const decodeStoredIcon = Schema.decodeUnknownEffect(StoredProjectIcon); |
| 208 | const encodeProjectIcon = Schema.encodeEffect(ProjectIconOverride); | 209 | const encodeStoredIcon = Schema.encodeEffect(StoredProjectIcon); |
| 210 | const decodeReceivedIcon = Schema.decodeUnknownEffect(ReceivedProjectIcon); | ||
| 211 | const encodeReceivedIcon = Schema.encodeEffect(ReceivedProjectIcon); | ||
| 212 | const decodeProjectShell = Schema.decodeUnknownEffect(OrchestrationProjectShell); | ||
| 209 | 213 | ||
| 210 | // Pre-monogram clients reject unknown variants; nightly clients additionally validate monogram. | 214 | effectIt.effect("sends and stores icons in their plain shape", () => |
| 211 | const decodeOldIcon = Schema.decodeUnknownEffect( | ||
| 212 | Schema.Union([ | ||
| 213 | Schema.Struct({ kind: Schema.Literal("lucide"), name: Schema.String, color: Schema.String }), | ||
| 214 | Schema.Struct({ kind: Schema.Literal("emoji"), emoji: Schema.String }), | ||
| 215 | ]), | ||
| 216 | ); | ||
| 217 | const decodeNightlyIcon = Schema.decodeUnknownEffect( | ||
| 218 | Schema.Union([ | ||
| 219 | Schema.Struct({ | ||
| 220 | kind: Schema.Literal("lucide"), | ||
| 221 | name: Schema.String, | ||
| 222 | color: Schema.String, | ||
| 223 | // Fail if this field is ever sent; old validators must never see the new text. | ||
| 224 | monogram: Schema.optional(Schema.Never), | ||
| 225 | }), | ||
| 226 | Schema.Struct({ kind: Schema.Literal("emoji"), emoji: Schema.String }), | ||
| 227 | ]), | ||
| 228 | ); | ||
| 229 | |||
| 230 | effectIt.effect("sends monograms as fallback icons that old and nightly clients can decode", () => | ||
| 231 | Effect.gen(function* () { | 215 | Effect.gen(function* () { |
| 232 | const fallback = { kind: "lucide", name: "folder-code", color: "violet" } as const; | ||
| 233 | for (const text of ["T3", "क्ष्म", "e\u0301"]) { | ||
| 234 | const monogram = { kind: "monogram", text, color: "violet" } as const; | ||
| 235 | const wire = yield* encodeProjectIcon(monogram); | ||
| 236 | assert.deepEqual(wire, { ...fallback, monogramText: text }); | ||
| 237 | assert.deepEqual(yield* decodeOldIcon(wire), fallback); | ||
| 238 | assert.deepEqual(yield* decodeNightlyIcon(wire), fallback); | ||
| 239 | assert.deepEqual(yield* decodeProjectIcon(wire), monogram); | ||
| 240 | assert.deepEqual(yield* decodeProjectIcon(monogram), monogram); | ||
| 241 | assert.deepEqual(yield* decodeProjectIcon({ ...fallback, monogram: text }), monogram); | ||
| 242 | } | ||
| 243 | for (const icon of [ | 216 | for (const icon of [ |
| 217 | { kind: "monogram", text: "क्ष्म", color: "violet" }, | ||
| 244 | { kind: "lucide", name: "alarm-clock", color: "blue" }, | 218 | { kind: "lucide", name: "alarm-clock", color: "blue" }, |
| 245 | { kind: "emoji", emoji: "🚀" }, | 219 | { kind: "emoji", emoji: "🚀" }, |
| 246 | ] as const) { | 220 | ] as const) { |
| 247 | assert.deepEqual(yield* decodeProjectIcon(icon), icon); | 221 | assert.deepEqual(yield* encodeReceivedIcon(icon), icon); |
| 248 | assert.deepEqual(yield* encodeProjectIcon(icon), icon); | 222 | assert.deepEqual(yield* encodeStoredIcon(icon), icon); |
| 223 | assert.deepEqual(yield* decodeReceivedIcon(icon), icon); | ||
| 249 | } | 224 | } |
| 250 | }), | 225 | }), |
| 251 | ); | 226 | ); |
| 252 | 227 | ||
| 253 | const encodeProjectShell = Schema.encodeEffect(OrchestrationProjectShell); | 228 | effectIt.effect("reads monograms stored in the pre-v2 fallback shape", () => |
| 254 | const encodeProjectUpdate = Schema.encodeEffect(ProjectUpdatePayload); | 229 | Effect.gen(function* () { |
| 255 | const decodeLegacyShell = Schema.decodeUnknownEffect( | 230 | const monogram = { kind: "monogram", text: "T3", color: "violet" } as const; |
| 256 | Schema.Struct({ | 231 | const fallback = { kind: "lucide", name: "folder-code", color: "violet" } as const; |
| 257 | ...OrchestrationProjectShell.fields, | 232 | for (const legacy of [ |
| 258 | projectIcon: Schema.optional( | 233 | { ...fallback, monogramText: "T3" }, |
| 259 | Schema.NullOr( | 234 | { ...fallback, monogram: "T3" }, |
| 260 | Schema.Struct({ | 235 | ]) { |
| 261 | kind: Schema.Literal("lucide"), | 236 | assert.deepEqual(yield* decodeStoredIcon(legacy), monogram); |
| 262 | name: Schema.String, | 237 | assert.deepEqual(yield* decodeReceivedIcon(legacy), monogram); |
| 263 | color: Schema.String, | 238 | } |
| 264 | }), | ||
| 265 | ), | ||
| 266 | ), | ||
| 267 | }), | 239 | }), |
| 268 | ); | 240 | ); |
| 269 | 241 | ||
| 270 | effectIt.effect("encodes compatible icons inside snapshots and project updates", () => | 242 | effectIt.effect("an icon kind from a newer server shows the default icon", () => |
| 271 | Effect.gen(function* () { | 243 | Effect.gen(function* () { |
| 272 | const projectIcon = { kind: "monogram", text: "क्ष्म", color: "violet" } as const; | 244 | assert.isNull(yield* decodeReceivedIcon({ kind: "image", url: "https://example.com/a.png" })); |
| 273 | const shell = yield* encodeProjectShell({ | 245 | const shell = yield* decodeProjectShell({ |
| 274 | id: ProjectId.make("monogram"), | 246 | id: "project-1", |
| 275 | title: "Monogram", | 247 | title: "Project", |
| 276 | workspaceRoot: "/tmp/monogram", | 248 | workspaceRoot: "/tmp/project", |
| 277 | defaultModelSelection: null, | 249 | defaultModelSelection: null, |
| 278 | scripts: [], | 250 | scripts: [], |
| 279 | projectIcon, | 251 | projectIcon: { kind: "image", url: "https://example.com/a.png" }, |
| 280 | createdAt: "2026-01-01T00:00:00.000Z", | 252 | createdAt: "2026-01-01T00:00:00.000Z", |
| 281 | updatedAt: "2026-01-01T00:00:00.000Z", | 253 | updatedAt: "2026-01-01T00:00:00.000Z", |
| 282 | }); | 254 | }); |
| 283 | const fallback = { kind: "lucide", name: "folder-code", color: "violet" } as const; | 255 | assert.isNull(shell.projectIcon); |
| 284 | assert.deepEqual((yield* decodeLegacyShell(shell)).projectIcon, fallback); | 256 | // A known kind with a broken payload still fails. |
| 285 | const update = yield* encodeProjectUpdate({ projectIcon }); | 257 | const broken = yield* Effect.exit(decodeReceivedIcon({ kind: "emoji" })); |
| 286 | assert.deepEqual(yield* decodeNightlyIcon(update.projectIcon), fallback); | 258 | assert.strictEqual(broken._tag, "Failure"); |
| 287 | }), | 259 | }), |
| 288 | ); | 260 | ); |
packages/contracts/src/project.ts+57-31| ... | @@ -4,12 +4,15 @@ import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; | ... | @@ -4,12 +4,15 @@ import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; |
| 4 | import { ModelSelection } from "./modelSelection.ts"; | 4 | import { ModelSelection } from "./modelSelection.ts"; |
| 5 | import { | 5 | import { |
| 6 | CommandId, | 6 | CommandId, |
| 7 | ForwardCompatibleUnion, | ||
| 8 | isUnknownUnionMember, | ||
| 7 | IsoDateTime, | 9 | IsoDateTime, |
| 8 | NonNegativeInt, | 10 | NonNegativeInt, |
| 9 | PositiveInt, | 11 | PositiveInt, |
| 10 | ProjectId, | 12 | ProjectId, |
| 11 | TrimmedNonEmptyString, | 13 | TrimmedNonEmptyString, |
| 12 | TrimmedString, | 14 | TrimmedString, |
| 15 | type UnknownUnionMember, | ||
| 13 | } from "./baseSchemas.ts"; | 16 | } from "./baseSchemas.ts"; |
| 14 | 17 | ||
| 15 | const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; | 18 | const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; |
| ... | @@ -89,13 +92,6 @@ const ProjectMonogramIcon = Schema.Struct({ | ... | @@ -89,13 +92,6 @@ const ProjectMonogramIcon = Schema.Struct({ |
| 89 | text: ProjectMonogramText, | 92 | text: ProjectMonogramText, |
| 90 | color: ProjectIconColor, | 93 | color: ProjectIconColor, |
| 91 | }); | 94 | }); |
| 92 | const ProjectIcon = Schema.Union([ProjectLucideIcon, ProjectEmojiIcon, ProjectMonogramIcon]); | ||
| 93 | const ProjectLucideIconWire = Schema.Struct({ | ||
| 94 | ...ProjectLucideIcon.fields, | ||
| 95 | monogramText: Schema.optional(ProjectMonogramText), | ||
| 96 | monogram: Schema.optional(ProjectMonogramText), | ||
| 97 | }); | ||
| 98 | |||
| 99 | /** A workspace-relative image a project may use as its favicon. */ | 95 | /** A workspace-relative image a project may use as its favicon. */ |
| 100 | export const ProjectFaviconPath = TrimmedNonEmptyString.check( | 96 | export const ProjectFaviconPath = TrimmedNonEmptyString.check( |
| 101 | Schema.isMaxLength(1024), | 97 | Schema.isMaxLength(1024), |
| ... | @@ -103,36 +99,66 @@ export const ProjectFaviconPath = TrimmedNonEmptyString.check( | ... | @@ -103,36 +99,66 @@ export const ProjectFaviconPath = TrimmedNonEmptyString.check( |
| 103 | ); | 99 | ); |
| 104 | export type ProjectFaviconPath = typeof ProjectFaviconPath.Type; | 100 | export type ProjectFaviconPath = typeof ProjectFaviconPath.Type; |
| 105 | 101 | ||
| 106 | // Older peers only know lucide/emoji. Keep monograms out of their validated | 102 | /** A project's chosen icon, as clients set it. */ |
| 107 | // `monogram` field too: old grapheme counters can reject otherwise valid text. | ||
| 108 | export const ProjectIconOverride = Schema.Union([ | 103 | export const ProjectIconOverride = Schema.Union([ |
| 109 | ProjectLucideIconWire, | 104 | ProjectLucideIcon, |
| 110 | ProjectEmojiIcon, | 105 | ProjectEmojiIcon, |
| 111 | ProjectMonogramIcon, | 106 | ProjectMonogramIcon, |
| 112 | ]).pipe( | 107 | ]); |
| 108 | export type ProjectIconOverride = typeof ProjectIconOverride.Type; | ||
| 109 | |||
| 110 | /** | ||
| 111 | * Before v2, servers sent a monogram as a lucide icon carrying its text, so | ||
| 112 | * older clients showed a folder. Icons stored then, and v2 servers released | ||
| 113 | * before this change, still use that shape; it is read here, never written. | ||
| 114 | */ | ||
| 115 | const ProjectLucideIconWithLegacyMonogram = Schema.Struct({ | ||
| 116 | ...ProjectLucideIcon.fields, | ||
| 117 | monogramText: Schema.optional(ProjectMonogramText), | ||
| 118 | monogram: Schema.optional(ProjectMonogramText), | ||
| 119 | }); | ||
| 120 | const projectIconMembers = [ | ||
| 121 | ProjectLucideIconWithLegacyMonogram, | ||
| 122 | ProjectEmojiIcon, | ||
| 123 | ProjectMonogramIcon, | ||
| 124 | ] as const; | ||
| 125 | type ProjectIconMember = (typeof projectIconMembers)[number]["Type"]; | ||
| 126 | |||
| 127 | const fromLegacyMonogram = (icon: ProjectIconMember): ProjectIconOverride => { | ||
| 128 | if (icon.kind !== "lucide") return icon; | ||
| 129 | const text = icon.monogramText ?? icon.monogram; | ||
| 130 | return text === undefined | ||
| 131 | ? { kind: "lucide", name: icon.name, color: icon.color } | ||
| 132 | : { kind: "monogram", text, color: icon.color }; | ||
| 133 | }; | ||
| 134 | |||
| 135 | /** An icon the server stores; it reads legacy monograms and writes the plain shape. */ | ||
| 136 | export const StoredProjectIcon = Schema.Union(projectIconMembers).pipe( | ||
| 113 | Schema.decodeTo( | 137 | Schema.decodeTo( |
| 114 | ProjectIcon, | 138 | Schema.toType(ProjectIconOverride), |
| 115 | SchemaTransformation.transform({ | 139 | SchemaTransformation.transform<ProjectIconOverride, ProjectIconMember>({ |
| 116 | decode: (icon): typeof ProjectIcon.Type => { | 140 | decode: fromLegacyMonogram, |
| 117 | if (icon.kind !== "lucide") return icon; | 141 | encode: (icon) => icon, |
| 118 | const text = icon.monogramText ?? icon.monogram; | 142 | }), |
| 119 | return text === undefined | 143 | ), |
| 120 | ? { kind: "lucide", name: icon.name, color: icon.color } | 144 | ); |
| 121 | : { kind: "monogram", text, color: icon.color }; | 145 | |
| 122 | }, | 146 | /** |
| 123 | encode: (icon) => | 147 | * An icon as clients receive it. A kind from a newer server decodes as no |
| 124 | icon.kind === "monogram" | 148 | * override, so the client shows the project's default icon. |
| 125 | ? { | 149 | */ |
| 126 | kind: "lucide" as const, | 150 | export const ReceivedProjectIcon = ForwardCompatibleUnion(projectIconMembers, "kind").pipe( |
| 127 | name: "folder-code", | 151 | Schema.decodeTo( |
| 128 | color: icon.color, | 152 | Schema.NullOr(Schema.toType(ProjectIconOverride)), |
| 129 | monogramText: icon.text, | 153 | SchemaTransformation.transform< |
| 130 | } | 154 | ProjectIconOverride | null, |
| 131 | : icon, | 155 | ProjectIconMember | UnknownUnionMember<"kind"> |
| 156 | >({ | ||
| 157 | decode: (icon) => (isUnknownUnionMember(icon) ? null : fromLegacyMonogram(icon)), | ||
| 158 | encode: (icon) => icon as ProjectIconMember, | ||
| 132 | }), | 159 | }), |
| 133 | ), | 160 | ), |
| 134 | ); | 161 | ); |
| 135 | export type ProjectIconOverride = typeof ProjectIconOverride.Type; | ||
| 136 | 162 | ||
| 137 | export const Project = Schema.Struct({ | 163 | export const Project = Schema.Struct({ |
| 138 | id: ProjectId, | 164 | id: ProjectId, |
| ... | @@ -140,7 +166,7 @@ export const Project = Schema.Struct({ | ... | @@ -140,7 +166,7 @@ export const Project = Schema.Struct({ |
| 140 | workspaceRoot: TrimmedNonEmptyString, | 166 | workspaceRoot: TrimmedNonEmptyString, |
| 141 | repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), | 167 | repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), |
| 142 | faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | 168 | faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 143 | projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), | 169 | projectIcon: Schema.optional(Schema.NullOr(ReceivedProjectIcon)), |
| 144 | defaultModelSelection: Schema.NullOr(ModelSelection), | 170 | defaultModelSelection: Schema.NullOr(ModelSelection), |
| 145 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), | 171 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), |
| 146 | // Opt-in because background sync performs network I/O and may move the checkout. | 172 | // Opt-in because background sync performs network I/O and may move the checkout. |