| author | |
| committer | |
| log | 7a368fe7c45a45c284f1b064e440a2d696452b07 |
| tree | 6a14a28817be756049caf69aff4dd46bed74766c |
| parent | b12c92f695a6b12116fb2cda40d610bdbe2a9566 |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 215 insertions(+), 98 deletions(-)
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+7-2| ... | ... | @@ -4371,14 +4371,19 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { |
| 4371 | 4371 | type: "project.meta.update", |
| 4372 | 4372 | commandId: CommandId.make("cmd-monogram-save"), |
| 4373 | 4373 | projectId, |
| 4374 | projectIcon: { kind: "lucide", name: "folder-code", color: "violet", monogram: "T3" }, | |
| 4374 | projectIcon: { kind: "monogram", text: "T3", color: "violet" }, | |
| 4375 | 4375 | }); |
| 4376 | 4376 | const saved = yield* sql<{ |
| 4377 | 4377 | readonly icon: string | null; |
| 4378 | 4378 | }>`SELECT project_icon_json AS icon FROM projection_projects WHERE project_id = ${projectId}`; |
| 4379 | 4379 | assert.deepEqual(saved, [ |
| 4380 | { icon: '{"kind":"lucide","name":"folder-code","color":"violet","monogram":"T3"}' }, | |
| 4380 | { icon: '{"kind":"lucide","name":"folder-code","color":"violet","monogramText":"T3"}' }, | |
| 4381 | 4381 | ]); |
| 4382 | const persisted = yield* sql<{ readonly icon: string }>` | |
| 4383 | SELECT json_extract(payload_json, '$.projectIcon') AS icon FROM orchestration_events | |
| 4384 | WHERE command_id = ${CommandId.make("cmd-monogram-save")} | |
| 4385 | `; | |
| 4386 | assert.deepEqual(persisted, saved); | |
| 4382 | 4387 | yield* engine.dispatch({ |
| 4383 | 4388 | type: "project.meta.update", |
| 4384 | 4389 | commandId: CommandId.make("cmd-monogram-clear"), |
apps/server/src/orchestration/decider.projectScripts.test.ts+29| ... | ... | @@ -248,6 +248,35 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { |
| 248 | 248 | name: "alarm-clock", |
| 249 | 249 | color: "violet", |
| 250 | 250 | }); |
| 251 | ||
| 252 | for (const text of ["T3", "e\u0301", "किखि", "क्ष्म", "\u1100\u1161\u11a8"]) { | |
| 253 | const monogram = { kind: "monogram", text, color: "violet" } as const; | |
| 254 | const result = yield* decideOrchestrationCommand({ | |
| 255 | command: { | |
| 256 | type: "project.meta.update", | |
| 257 | commandId: CommandId.make("cmd-monogram"), | |
| 258 | projectId: asProjectId("project-favicon"), | |
| 259 | projectIcon: monogram, | |
| 260 | }, | |
| 261 | readModel, | |
| 262 | }); | |
| 263 | const updated = Array.isArray(result) ? result[0] : result; | |
| 264 | expect(updated.payload).toMatchObject({ projectIcon: monogram }); | |
| 265 | } | |
| 266 | for (const text of ["ABC", "किखिगि"]) { | |
| 267 | const failure = yield* Effect.flip( | |
| 268 | decideOrchestrationCommand({ | |
| 269 | command: { | |
| 270 | type: "project.meta.update", | |
| 271 | commandId: CommandId.make("cmd-monogram-invalid"), | |
| 272 | projectId: asProjectId("project-favicon"), | |
| 273 | projectIcon: { kind: "monogram", text, color: "violet" }, | |
| 274 | }, | |
| 275 | readModel, | |
| 276 | }), | |
| 277 | ); | |
| 278 | expect(failure).toMatchObject({ _tag: "OrchestrationCommandInvariantError" }); | |
| 279 | } | |
| 251 | 280 | }), |
| 252 | 281 | ); |
| 253 | 282 |
apps/server/src/orchestration/decider.ts+11| ... | ... | @@ -47,6 +47,8 @@ import { |
| 47 | 47 | import { projectEvent } from "./projector.ts"; |
| 48 | 48 | import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts"; |
| 49 | 49 | |
| 50 | const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); | |
| 51 | ||
| 50 | 52 | const isScriptRunCommand = Schema.is(SCRIPT_RUN_COMMAND_PATTERN); |
| 51 | 53 | |
| 52 | 54 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| ... | ... | @@ -264,6 +266,15 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 264 | 266 | command, |
| 265 | 267 | projectId: command.projectId, |
| 266 | 268 | }); |
| 269 | if ( | |
| 270 | command.projectIcon?.kind === "monogram" && | |
| 271 | Array.from(monogramSegmenter.segment(command.projectIcon.text)).length > 2 | |
| 272 | ) { | |
| 273 | return yield* new OrchestrationCommandInvariantError({ | |
| 274 | commandType: command.type, | |
| 275 | detail: "Project monograms must contain at most two characters.", | |
| 276 | }); | |
| 277 | } | |
| 267 | 278 | if (command.scripts !== undefined) { |
| 268 | 279 | // Persisted IDs predate shortcut validation. Let users edit or remove them |
| 269 | 280 | // without allowing another invalid ID to enter the project. |
apps/server/src/persistence/Layers/OrchestrationEventStore.ts+6-1| ... | ... | @@ -9,6 +9,7 @@ import { |
| 9 | 9 | OrchestrationEventMetadata, |
| 10 | 10 | OrchestrationEventType, |
| 11 | 11 | ProjectId, |
| 12 | ProjectIconOverride, | |
| 12 | 13 | ThreadId, |
| 13 | 14 | } from "@t3tools/contracts"; |
| 14 | 15 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| ... | ... | @@ -29,6 +30,7 @@ import { |
| 29 | 30 | type OrchestrationEventStoreShape, |
| 30 | 31 | } from "../Services/OrchestrationEventStore.ts"; |
| 31 | 32 | |
| 33 | const encodeProjectIcon = Schema.encodeSync(ProjectIconOverride); | |
| 32 | 34 | const decodeEvent = Schema.decodeUnknownEffect(OrchestrationEvent); |
| 33 | 35 | const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); |
| 34 | 36 | const EventMetadataFromJsonString = Schema.fromJsonString(OrchestrationEventMetadata); |
| ... | ... | @@ -263,7 +265,10 @@ const makeEventStore = Effect.gen(function* () { |
| 263 | 265 | actorKind: inferActorKind(event), |
| 264 | 266 | occurredAt: event.occurredAt, |
| 265 | 267 | commandId: event.commandId, |
| 266 | payloadJson: event.payload, | |
| 268 | payloadJson: | |
| 269 | "projectIcon" in event.payload && event.payload.projectIcon | |
| 270 | ? { ...event.payload, projectIcon: encodeProjectIcon(event.payload.projectIcon) } | |
| 271 | : event.payload, | |
| 267 | 272 | metadataJson: event.metadata, |
| 268 | 273 | }).pipe( |
| 269 | 274 | Effect.mapError( |
apps/web/src/components/ProjectFavicon.tsx+2-2| ... | ... | @@ -43,10 +43,10 @@ export function ProjectFavicon(input: { |
| 43 | 43 | faviconPath: project.faviconPath, |
| 44 | 44 | }), |
| 45 | 45 | ); |
| 46 | if (project.projectIcon?.kind === "lucide" && project.projectIcon.monogram) { | |
| 46 | if (project.projectIcon?.kind === "monogram") { | |
| 47 | 47 | return ( |
| 48 | 48 | <ProjectMonogram |
| 49 | text={project.projectIcon.monogram} | |
| 49 | text={project.projectIcon.text} | |
| 50 | 50 | color={project.projectIcon.color} |
| 51 | 51 | className={input.className} |
| 52 | 52 | /> |
apps/web/src/components/settings/ProjectIconPickerDialog.tsx+5-11| ... | ... | @@ -54,9 +54,7 @@ export function ProjectIconPickerDialog({ |
| 54 | 54 | readonly onSelect: (icon: ProjectIconOverride) => void; |
| 55 | 55 | }) { |
| 56 | 56 | const automatic = deriveProjectIdentity(projectName); |
| 57 | const [mode, setMode] = useState<ProjectIconOverride["kind"] | "monogram">( | |
| 58 | current?.kind === "lucide" && current.monogram ? "monogram" : (current?.kind ?? "lucide"), | |
| 59 | ); | |
| 57 | const [mode, setMode] = useState<ProjectIconOverride["kind"]>(current?.kind ?? "lucide"); | |
| 60 | 58 | const [iconName, setIconName] = useState<IconName>( |
| 61 | 59 | current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON, |
| 62 | 60 | ); |
| ... | ... | @@ -64,7 +62,7 @@ export function ProjectIconPickerDialog({ |
| 64 | 62 | current && current.kind !== "emoji" ? current.color : automatic.color, |
| 65 | 63 | ); |
| 66 | 64 | const [letters, setLetters] = useState( |
| 67 | current?.kind === "lucide" && current.monogram ? current.monogram : automatic.monogram, | |
| 65 | current?.kind === "monogram" ? current.text : automatic.monogram, | |
| 68 | 66 | ); |
| 69 | 67 | const [emoji, setEmoji] = useState(current?.kind === "emoji" ? current.emoji : "💻"); |
| 70 | 68 | const [query, setQuery] = useState(""); |
| ... | ... | @@ -73,14 +71,10 @@ export function ProjectIconPickerDialog({ |
| 73 | 71 | |
| 74 | 72 | useEffect(() => { |
| 75 | 73 | if (open && !previousOpenRef.current) { |
| 76 | setMode( | |
| 77 | current?.kind === "lucide" && current.monogram ? "monogram" : (current?.kind ?? "lucide"), | |
| 78 | ); | |
| 74 | setMode(current?.kind ?? "lucide"); | |
| 79 | 75 | setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON); |
| 80 | 76 | setColor(current && current.kind !== "emoji" ? current.color : automatic.color); |
| 81 | setLetters( | |
| 82 | current?.kind === "lucide" && current.monogram ? current.monogram : automatic.monogram, | |
| 83 | ); | |
| 77 | setLetters(current?.kind === "monogram" ? current.text : automatic.monogram); | |
| 84 | 78 | setEmoji(current?.kind === "emoji" ? current.emoji : "💻"); |
| 85 | 79 | setQuery(""); |
| 86 | 80 | setCustomEmoji(""); |
| ... | ... | @@ -96,7 +90,7 @@ export function ProjectIconPickerDialog({ |
| 96 | 90 | if (mode === "monogram" && !validMonogram) return; |
| 97 | 91 | onSelect( |
| 98 | 92 | mode === "monogram" |
| 99 | ? { kind: "lucide", name: DEFAULT_ICON, monogram, color } | |
| 93 | ? { kind: "monogram", text: monogram, color } | |
| 100 | 94 | : mode === "lucide" |
| 101 | 95 | ? { kind: "lucide", name: iconName, color } |
| 102 | 96 | : { kind: "emoji", emoji }, |
apps/web/src/components/settings/ProjectSettingsPanel.tsx+6-4| ... | ... | @@ -435,10 +435,12 @@ function ProjectDetail({ |
| 435 | 435 | title="Project icon" |
| 436 | 436 | description={ |
| 437 | 437 | projectIcon?.kind === "lucide" |
| 438 | ? `${projectIcon.monogram ?? projectIcon.name} · ${projectIcon.color}` | |
| 439 | : projectIcon?.kind === "emoji" | |
| 440 | ? projectIcon.emoji | |
| 441 | : (faviconPath ?? "Automatic") | |
| 438 | ? `${projectIcon.name} · ${projectIcon.color}` | |
| 439 | : projectIcon?.kind === "monogram" | |
| 440 | ? `${projectIcon.text} · ${projectIcon.color}` | |
| 441 | : projectIcon?.kind === "emoji" | |
| 442 | ? projectIcon.emoji | |
| 443 | : (faviconPath ?? "Automatic") | |
| 442 | 444 | } |
| 443 | 445 | resetAction={ |
| 444 | 446 | group.memberProjects.some( |
packages/contracts/src/orchestration.test.ts+99-42| ... | ... | @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; |
| 5 | 5 | import { CommandId, ProjectId, ThreadId } from "./baseSchemas.ts"; |
| 6 | 6 | |
| 7 | 7 | import { |
| 8 | ProjectIconOverride, | |
| 8 | 9 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 9 | 10 | DEFAULT_RUNTIME_MODE, |
| 10 | 11 | type ChatImageAttachment, |
| ... | ... | @@ -17,9 +18,8 @@ import { |
| 17 | 18 | OrchestrationGetTurnDiffInput, |
| 18 | 19 | OrchestrationLatestTurn, |
| 19 | 20 | ProjectCreatedPayload, |
| 20 | OrchestrationProjectShell, | |
| 21 | ProjectIconColor, | |
| 22 | 21 | ProjectMetaUpdatedPayload, |
| 22 | OrchestrationProjectShell, | |
| 23 | 23 | OrchestrationProposedPlan, |
| 24 | 24 | OrchestrationSession, |
| 25 | 25 | OrchestrationThread, |
| ... | ... | @@ -42,18 +42,6 @@ import { ProviderInstanceId } from "./providerInstance.ts"; |
| 42 | 42 | const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); |
| 43 | 43 | const decodeFullThreadDiffInput = Schema.decodeUnknownEffect(OrchestrationGetFullThreadDiffInput); |
| 44 | 44 | const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); |
| 45 | // The icon shape understood by clients released before monograms. | |
| 46 | const legacyProjectIcon = Schema.Union([ | |
| 47 | Schema.Struct({ kind: Schema.Literal("lucide"), name: Schema.String, color: ProjectIconColor }), | |
| 48 | Schema.Struct({ kind: Schema.Literal("emoji"), emoji: Schema.String }), | |
| 49 | ]); | |
| 50 | const decodeLegacyProjectShell = Schema.decodeUnknownEffect( | |
| 51 | Schema.Struct({ | |
| 52 | ...OrchestrationProjectShell.fields, | |
| 53 | projectIcon: Schema.optional(Schema.NullOr(legacyProjectIcon)), | |
| 54 | }), | |
| 55 | ); | |
| 56 | const encodeProjectShell = Schema.encodeEffect(OrchestrationProjectShell); | |
| 57 | 45 | const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand); |
| 58 | 46 | const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload); |
| 59 | 47 | const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload); |
| ... | ... | @@ -1508,31 +1496,13 @@ it.effect("project icon overrides accept Lucide icons, colors, and emoji", () => |
| 1508 | 1496 | }), |
| 1509 | 1497 | ); |
| 1510 | 1498 | |
| 1511 | it.effect("older clients decode monogram projects as their fallback icon", () => | |
| 1512 | Effect.gen(function* () { | |
| 1513 | const encoded = yield* encodeProjectShell({ | |
| 1514 | id: ProjectId.make("project-monogram"), | |
| 1515 | title: "Monogram", | |
| 1516 | workspaceRoot: "/tmp/monogram", | |
| 1517 | defaultModelSelection: null, | |
| 1518 | scripts: [], | |
| 1519 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 1520 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 1521 | projectIcon: { kind: "lucide", name: "folder-code", color: "violet", monogram: "T3" }, | |
| 1522 | }); | |
| 1523 | const decoded = yield* decodeLegacyProjectShell(encoded); | |
| 1524 | assert.deepEqual(decoded.projectIcon, { kind: "lucide", name: "folder-code", color: "violet" }); | |
| 1525 | }), | |
| 1526 | ); | |
| 1527 | ||
| 1528 | 1499 | it.effect("project monograms validate text and palette colors", () => |
| 1529 | 1500 | Effect.gen(function* () { |
| 1530 | 1501 | for (const text of ["A", "T3", "É", "文書", "कि", "किखि", "e\u0301"]) { |
| 1531 | 1502 | const projectIcon = { |
| 1532 | kind: "lucide", | |
| 1533 | name: "folder-code", | |
| 1503 | kind: "monogram", | |
| 1534 | 1504 | color: "violet", |
| 1535 | monogram: text, | |
| 1505 | text, | |
| 1536 | 1506 | } as const; |
| 1537 | 1507 | const command = yield* decodeOrchestrationCommand({ |
| 1538 | 1508 | type: "project.meta.update", |
| ... | ... | @@ -1542,16 +1512,14 @@ it.effect("project monograms validate text and palette colors", () => |
| 1542 | 1512 | }); |
| 1543 | 1513 | assert.strictEqual(command.type, "project.meta.update"); |
| 1544 | 1514 | if (command.type === "project.meta.update") |
| 1545 | assert.deepEqual(command.projectIcon, projectIcon); | |
| 1515 | assert.deepEqual(command.projectIcon, { kind: "monogram", text, color: "violet" }); | |
| 1546 | 1516 | } |
| 1547 | 1517 | for (const projectIcon of [ |
| 1548 | { kind: "lucide", name: "folder-code", monogram: "", color: "blue" }, | |
| 1549 | { kind: "lucide", name: "folder-code", monogram: "ABC", color: "blue" }, | |
| 1550 | { kind: "lucide", name: "folder-code", monogram: "किखिगि", color: "blue" }, | |
| 1551 | { kind: "lucide", name: "folder-code", monogram: "\u0301", color: "blue" }, | |
| 1552 | { kind: "lucide", name: "folder-code", monogram: "A B", color: "blue" }, | |
| 1553 | { kind: "lucide", name: "folder-code", monogram: "🚀", color: "blue" }, | |
| 1554 | { kind: "lucide", name: "folder-code", monogram: "T3", color: "ultraviolet" }, | |
| 1518 | { kind: "monogram", text: "", color: "blue" }, | |
| 1519 | { kind: "monogram", text: "\u0301", color: "blue" }, | |
| 1520 | { kind: "monogram", text: "A B", color: "blue" }, | |
| 1521 | { kind: "monogram", text: "🚀", color: "blue" }, | |
| 1522 | { kind: "monogram", text: "T3", color: "ultraviolet" }, | |
| 1555 | 1523 | ]) { |
| 1556 | 1524 | const result = yield* Effect.exit( |
| 1557 | 1525 | decodeOrchestrationCommand({ |
| ... | ... | @@ -1586,3 +1554,92 @@ it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects |
| 1586 | 1554 | assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true); |
| 1587 | 1555 | assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/svg+xml"), false); |
| 1588 | 1556 | }); |
| 1557 | ||
| 1558 | const decodeProjectIcon = Schema.decodeUnknownEffect(ProjectIconOverride); | |
| 1559 | const encodeProjectIcon = Schema.encodeEffect(ProjectIconOverride); | |
| 1560 | ||
| 1561 | // Pre-monogram clients reject unknown variants; nightly clients additionally validate monogram. | |
| 1562 | const decodeOldIcon = Schema.decodeUnknownEffect( | |
| 1563 | Schema.Union([ | |
| 1564 | Schema.Struct({ kind: Schema.Literal("lucide"), name: Schema.String, color: Schema.String }), | |
| 1565 | Schema.Struct({ kind: Schema.Literal("emoji"), emoji: Schema.String }), | |
| 1566 | ]), | |
| 1567 | ); | |
| 1568 | const decodeNightlyIcon = Schema.decodeUnknownEffect( | |
| 1569 | Schema.Union([ | |
| 1570 | Schema.Struct({ | |
| 1571 | kind: Schema.Literal("lucide"), | |
| 1572 | name: Schema.String, | |
| 1573 | color: Schema.String, | |
| 1574 | // Fail if this field is ever sent; old validators must never see the new text. | |
| 1575 | monogram: Schema.optional(Schema.Never), | |
| 1576 | }), | |
| 1577 | Schema.Struct({ kind: Schema.Literal("emoji"), emoji: Schema.String }), | |
| 1578 | ]), | |
| 1579 | ); | |
| 1580 | ||
| 1581 | it.effect("sends monograms as fallback icons that old and nightly clients can decode", () => | |
| 1582 | Effect.gen(function* () { | |
| 1583 | const fallback = { kind: "lucide", name: "folder-code", color: "violet" } as const; | |
| 1584 | for (const text of ["T3", "क्ष्म", "e\u0301"]) { | |
| 1585 | const monogram = { kind: "monogram", text, color: "violet" } as const; | |
| 1586 | const wire = yield* encodeProjectIcon(monogram); | |
| 1587 | assert.deepEqual(wire, { ...fallback, monogramText: text }); | |
| 1588 | assert.deepEqual(yield* decodeOldIcon(wire), fallback); | |
| 1589 | assert.deepEqual(yield* decodeNightlyIcon(wire), fallback); | |
| 1590 | assert.deepEqual(yield* decodeProjectIcon(wire), monogram); | |
| 1591 | assert.deepEqual(yield* decodeProjectIcon(monogram), monogram); | |
| 1592 | assert.deepEqual(yield* decodeProjectIcon({ ...fallback, monogram: text }), monogram); | |
| 1593 | } | |
| 1594 | for (const icon of [ | |
| 1595 | { kind: "lucide", name: "alarm-clock", color: "blue" }, | |
| 1596 | { kind: "emoji", emoji: "🚀" }, | |
| 1597 | ] as const) { | |
| 1598 | assert.deepEqual(yield* decodeProjectIcon(icon), icon); | |
| 1599 | assert.deepEqual(yield* encodeProjectIcon(icon), icon); | |
| 1600 | } | |
| 1601 | }), | |
| 1602 | ); | |
| 1603 | ||
| 1604 | const encodeProjectShell = Schema.encodeEffect(OrchestrationProjectShell); | |
| 1605 | const encodeClientCommand = Schema.encodeEffect(ClientOrchestrationCommand); | |
| 1606 | const decodeLegacyShell = Schema.decodeUnknownEffect( | |
| 1607 | Schema.Struct({ | |
| 1608 | ...OrchestrationProjectShell.fields, | |
| 1609 | projectIcon: Schema.optional( | |
| 1610 | Schema.NullOr( | |
| 1611 | Schema.Struct({ | |
| 1612 | kind: Schema.Literal("lucide"), | |
| 1613 | name: Schema.String, | |
| 1614 | color: Schema.String, | |
| 1615 | }), | |
| 1616 | ), | |
| 1617 | ), | |
| 1618 | }), | |
| 1619 | ); | |
| 1620 | ||
| 1621 | it.effect("encodes compatible icons inside snapshots and client commands", () => | |
| 1622 | Effect.gen(function* () { | |
| 1623 | const projectIcon = { kind: "monogram", text: "क्ष्म", color: "violet" } as const; | |
| 1624 | const shell = yield* encodeProjectShell({ | |
| 1625 | id: ProjectId.make("monogram"), | |
| 1626 | title: "Monogram", | |
| 1627 | workspaceRoot: "/tmp/monogram", | |
| 1628 | defaultModelSelection: null, | |
| 1629 | scripts: [], | |
| 1630 | projectIcon, | |
| 1631 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 1632 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 1633 | }); | |
| 1634 | const fallback = { kind: "lucide", name: "folder-code", color: "violet" } as const; | |
| 1635 | assert.deepEqual((yield* decodeLegacyShell(shell)).projectIcon, fallback); | |
| 1636 | const command = yield* encodeClientCommand({ | |
| 1637 | type: "project.meta.update", | |
| 1638 | projectId: ProjectId.make("monogram"), | |
| 1639 | commandId: CommandId.make("monogram"), | |
| 1640 | projectIcon, | |
| 1641 | }); | |
| 1642 | if (command.type !== "project.meta.update") throw new Error("Unexpected command"); | |
| 1643 | assert.deepEqual(yield* decodeNightlyIcon(command.projectIcon), fallback); | |
| 1644 | }), | |
| 1645 | ); |
packages/contracts/src/orchestration.ts+50-36| ... | ... | @@ -459,48 +459,62 @@ const ProjectLucideIconName = TrimmedNonEmptyString.check( |
| 459 | 459 | |
| 460 | 460 | const ProjectEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32)); |
| 461 | 461 | |
| 462 | // Hermes (the Android/iOS JS runtime) has no Intl.Segmenter, and this module | |
| 463 | // loads at app startup, so `new Intl.Segmenter()` crashes mobile on launch. | |
| 464 | // Count grapheme clusters with a small approximation instead: a code point | |
| 465 | // continues the current cluster when it is a combining mark, joiner, | |
| 466 | // variation selector, or emoji modifier, or follows a ZWJ. This must stay | |
| 467 | // runtime-independent (not `typeof Intl.Segmenter` feature detection) so the | |
| 468 | // shared contract validates identically on server, web, and mobile. | |
| 469 | const GRAPHEME_CONTINUATION = /[\p{M}\u200c\u200d\ufe0f\u{1F3FB}-\u{1F3FF}]/u; | |
| 470 | ||
| 471 | const countGraphemes = (text: string): number => { | |
| 472 | let clusters = 0; | |
| 473 | let prevJoiner = false; | |
| 474 | for (const char of text) { | |
| 475 | if (clusters > 0 && (prevJoiner || GRAPHEME_CONTINUATION.test(char))) { | |
| 476 | prevJoiner = char === "\u200d"; | |
| 477 | continue; | |
| 478 | } | |
| 479 | clusters += 1; | |
| 480 | prevJoiner = char === "\u200d"; | |
| 481 | } | |
| 482 | return clusters; | |
| 483 | }; | |
| 484 | ||
| 462 | // Grapheme-count validation belongs to the server command boundary, not snapshot decoding. | |
| 485 | 463 | export const ProjectMonogramText = TrimmedNonEmptyString.check( |
| 486 | 464 | Schema.isMaxLength(32), |
| 487 | 465 | Schema.isPattern(/^[\p{L}\p{N}][\p{L}\p{N}\p{M}\u200c\u200d]*$/u), |
| 488 | Schema.makeFilter((text) => countGraphemes(text) <= 2), | |
| 489 | 466 | ); |
| 490 | 467 | |
| 468 | const ProjectLucideIcon = Schema.Struct({ | |
| 469 | kind: Schema.Literal("lucide"), | |
| 470 | name: ProjectLucideIconName, | |
| 471 | color: ProjectIconColor, | |
| 472 | }); | |
| 473 | const ProjectEmojiIcon = Schema.Struct({ | |
| 474 | kind: Schema.Literal("emoji"), | |
| 475 | emoji: ProjectEmoji, | |
| 476 | }); | |
| 477 | const ProjectMonogramIcon = Schema.Struct({ | |
| 478 | kind: Schema.Literal("monogram"), | |
| 479 | text: ProjectMonogramText, | |
| 480 | color: ProjectIconColor, | |
| 481 | }); | |
| 482 | const ProjectIcon = Schema.Union([ProjectLucideIcon, ProjectEmojiIcon, ProjectMonogramIcon]); | |
| 483 | const ProjectLucideIconWire = Schema.Struct({ | |
| 484 | ...ProjectLucideIcon.fields, | |
| 485 | monogramText: Schema.optional(ProjectMonogramText), | |
| 486 | monogram: Schema.optional(ProjectMonogramText), | |
| 487 | }); | |
| 488 | ||
| 489 | // Older peers only know lucide/emoji. Keep monograms out of their validated | |
| 490 | // `monogram` field too: old grapheme counters can reject otherwise valid text. | |
| 491 | 491 | export const ProjectIconOverride = Schema.Union([ |
| 492 | Schema.Struct({ | |
| 493 | kind: Schema.Literal("lucide"), | |
| 494 | name: ProjectLucideIconName, | |
| 495 | color: ProjectIconColor, | |
| 496 | // Older clients ignore this field and render the named Lucide icon instead. | |
| 497 | monogram: Schema.optional(ProjectMonogramText), | |
| 498 | }), | |
| 499 | Schema.Struct({ | |
| 500 | kind: Schema.Literal("emoji"), | |
| 501 | emoji: ProjectEmoji, | |
| 502 | }), | |
| 503 | ]); | |
| 492 | ProjectLucideIconWire, | |
| 493 | ProjectEmojiIcon, | |
| 494 | ProjectMonogramIcon, | |
| 495 | ]).pipe( | |
| 496 | Schema.decodeTo( | |
| 497 | ProjectIcon, | |
| 498 | SchemaTransformation.transform({ | |
| 499 | decode: (icon): typeof ProjectIcon.Type => { | |
| 500 | if (icon.kind !== "lucide") return icon; | |
| 501 | const text = icon.monogramText ?? icon.monogram; | |
| 502 | return text === undefined | |
| 503 | ? { kind: "lucide", name: icon.name, color: icon.color } | |
| 504 | : { kind: "monogram", text, color: icon.color }; | |
| 505 | }, | |
| 506 | encode: (icon) => | |
| 507 | icon.kind === "monogram" | |
| 508 | ? { | |
| 509 | kind: "lucide" as const, | |
| 510 | name: "folder-code", | |
| 511 | color: icon.color, | |
| 512 | monogramText: icon.text, | |
| 513 | } | |
| 514 | : icon, | |
| 515 | }), | |
| 516 | ), | |
| 517 | ); | |
| 504 | 518 | export type ProjectIconOverride = typeof ProjectIconOverride.Type; |
| 505 | 519 | |
| 506 | 520 | export const OrchestrationProject = Schema.Struct({ |