| 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,14 +4371,19 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { |
| 4371 | type: "project.meta.update", | 4371 | type: "project.meta.update", |
| 4372 | commandId: CommandId.make("cmd-monogram-save"), | 4372 | commandId: CommandId.make("cmd-monogram-save"), |
| 4373 | projectId, | 4373 | projectId, |
| 4374 | projectIcon: { kind: "lucide", name: "folder-code", color: "violet", monogram: "T3" }, | 4374 | projectIcon: { kind: "monogram", text: "T3", color: "violet" }, |
| 4375 | }); | 4375 | }); |
| 4376 | const saved = yield* sql<{ | 4376 | const saved = yield* sql<{ |
| 4377 | readonly icon: string | null; | 4377 | readonly icon: string | null; |
| 4378 | }>`SELECT project_icon_json AS icon FROM projection_projects WHERE project_id = ${projectId}`; | 4378 | }>`SELECT project_icon_json AS icon FROM projection_projects WHERE project_id = ${projectId}`; |
| 4379 | assert.deepEqual(saved, [ | 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 | yield* engine.dispatch({ | 4387 | yield* engine.dispatch({ |
| 4383 | type: "project.meta.update", | 4388 | type: "project.meta.update", |
| 4384 | commandId: CommandId.make("cmd-monogram-clear"), | 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,6 +248,35 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { |
| 248 | name: "alarm-clock", | 248 | name: "alarm-clock", |
| 249 | color: "violet", | 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,6 +47,8 @@ import { |
| 47 | import { projectEvent } from "./projector.ts"; | 47 | import { projectEvent } from "./projector.ts"; |
| 48 | import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts"; | 48 | import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts"; |
| 49 | 49 | ||
| 50 | const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); | ||
| 51 | |||
| 50 | const isScriptRunCommand = Schema.is(SCRIPT_RUN_COMMAND_PATTERN); | 52 | const isScriptRunCommand = Schema.is(SCRIPT_RUN_COMMAND_PATTERN); |
| 51 | 53 | ||
| 52 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); | 54 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| ... | @@ -264,6 +266,15 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -264,6 +266,15 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 264 | command, | 266 | command, |
| 265 | projectId: command.projectId, | 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 | if (command.scripts !== undefined) { | 278 | if (command.scripts !== undefined) { |
| 268 | // Persisted IDs predate shortcut validation. Let users edit or remove them | 279 | // Persisted IDs predate shortcut validation. Let users edit or remove them |
| 269 | // without allowing another invalid ID to enter the project. | 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,6 +9,7 @@ import { |
| 9 | OrchestrationEventMetadata, | 9 | OrchestrationEventMetadata, |
| 10 | OrchestrationEventType, | 10 | OrchestrationEventType, |
| 11 | ProjectId, | 11 | ProjectId, |
| 12 | ProjectIconOverride, | ||
| 12 | ThreadId, | 13 | ThreadId, |
| 13 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| 14 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 15 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| ... | @@ -29,6 +30,7 @@ import { | ... | @@ -29,6 +30,7 @@ import { |
| 29 | type OrchestrationEventStoreShape, | 30 | type OrchestrationEventStoreShape, |
| 30 | } from "../Services/OrchestrationEventStore.ts"; | 31 | } from "../Services/OrchestrationEventStore.ts"; |
| 31 | 32 | ||
| 33 | const encodeProjectIcon = Schema.encodeSync(ProjectIconOverride); | ||
| 32 | const decodeEvent = Schema.decodeUnknownEffect(OrchestrationEvent); | 34 | const decodeEvent = Schema.decodeUnknownEffect(OrchestrationEvent); |
| 33 | const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); | 35 | const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); |
| 34 | const EventMetadataFromJsonString = Schema.fromJsonString(OrchestrationEventMetadata); | 36 | const EventMetadataFromJsonString = Schema.fromJsonString(OrchestrationEventMetadata); |
| ... | @@ -263,7 +265,10 @@ const makeEventStore = Effect.gen(function* () { | ... | @@ -263,7 +265,10 @@ const makeEventStore = Effect.gen(function* () { |
| 263 | actorKind: inferActorKind(event), | 265 | actorKind: inferActorKind(event), |
| 264 | occurredAt: event.occurredAt, | 266 | occurredAt: event.occurredAt, |
| 265 | commandId: event.commandId, | 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 | metadataJson: event.metadata, | 272 | metadataJson: event.metadata, |
| 268 | }).pipe( | 273 | }).pipe( |
| 269 | Effect.mapError( | 274 | Effect.mapError( |
apps/web/src/components/ProjectFavicon.tsx+2-2| ... | @@ -43,10 +43,10 @@ export function ProjectFavicon(input: { | ... | @@ -43,10 +43,10 @@ export function ProjectFavicon(input: { |
| 43 | faviconPath: project.faviconPath, | 43 | faviconPath: project.faviconPath, |
| 44 | }), | 44 | }), |
| 45 | ); | 45 | ); |
| 46 | if (project.projectIcon?.kind === "lucide" && project.projectIcon.monogram) { | 46 | if (project.projectIcon?.kind === "monogram") { |
| 47 | return ( | 47 | return ( |
| 48 | <ProjectMonogram | 48 | <ProjectMonogram |
| 49 | text={project.projectIcon.monogram} | 49 | text={project.projectIcon.text} |
| 50 | color={project.projectIcon.color} | 50 | color={project.projectIcon.color} |
| 51 | className={input.className} | 51 | className={input.className} |
| 52 | /> | 52 | /> |
apps/web/src/components/settings/ProjectIconPickerDialog.tsx+5-11| ... | @@ -54,9 +54,7 @@ export function ProjectIconPickerDialog({ | ... | @@ -54,9 +54,7 @@ export function ProjectIconPickerDialog({ |
| 54 | readonly onSelect: (icon: ProjectIconOverride) => void; | 54 | readonly onSelect: (icon: ProjectIconOverride) => void; |
| 55 | }) { | 55 | }) { |
| 56 | const automatic = deriveProjectIdentity(projectName); | 56 | const automatic = deriveProjectIdentity(projectName); |
| 57 | const [mode, setMode] = useState<ProjectIconOverride["kind"] | "monogram">( | 57 | const [mode, setMode] = useState<ProjectIconOverride["kind"]>(current?.kind ?? "lucide"); |
| 58 | current?.kind === "lucide" && current.monogram ? "monogram" : (current?.kind ?? "lucide"), | ||
| 59 | ); | ||
| 60 | const [iconName, setIconName] = useState<IconName>( | 58 | const [iconName, setIconName] = useState<IconName>( |
| 61 | current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON, | 59 | current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON, |
| 62 | ); | 60 | ); |
| ... | @@ -64,7 +62,7 @@ export function ProjectIconPickerDialog({ | ... | @@ -64,7 +62,7 @@ export function ProjectIconPickerDialog({ |
| 64 | current && current.kind !== "emoji" ? current.color : automatic.color, | 62 | current && current.kind !== "emoji" ? current.color : automatic.color, |
| 65 | ); | 63 | ); |
| 66 | const [letters, setLetters] = useState( | 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 | const [emoji, setEmoji] = useState(current?.kind === "emoji" ? current.emoji : "💻"); | 67 | const [emoji, setEmoji] = useState(current?.kind === "emoji" ? current.emoji : "💻"); |
| 70 | const [query, setQuery] = useState(""); | 68 | const [query, setQuery] = useState(""); |
| ... | @@ -73,14 +71,10 @@ export function ProjectIconPickerDialog({ | ... | @@ -73,14 +71,10 @@ export function ProjectIconPickerDialog({ |
| 73 | 71 | ||
| 74 | useEffect(() => { | 72 | useEffect(() => { |
| 75 | if (open && !previousOpenRef.current) { | 73 | if (open && !previousOpenRef.current) { |
| 76 | setMode( | 74 | setMode(current?.kind ?? "lucide"); |
| 77 | current?.kind === "lucide" && current.monogram ? "monogram" : (current?.kind ?? "lucide"), | ||
| 78 | ); | ||
| 79 | setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON); | 75 | setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON); |
| 80 | setColor(current && current.kind !== "emoji" ? current.color : automatic.color); | 76 | setColor(current && current.kind !== "emoji" ? current.color : automatic.color); |
| 81 | setLetters( | 77 | setLetters(current?.kind === "monogram" ? current.text : automatic.monogram); |
| 82 | current?.kind === "lucide" && current.monogram ? current.monogram : automatic.monogram, | ||
| 83 | ); | ||
| 84 | setEmoji(current?.kind === "emoji" ? current.emoji : "💻"); | 78 | setEmoji(current?.kind === "emoji" ? current.emoji : "💻"); |
| 85 | setQuery(""); | 79 | setQuery(""); |
| 86 | setCustomEmoji(""); | 80 | setCustomEmoji(""); |
| ... | @@ -96,7 +90,7 @@ export function ProjectIconPickerDialog({ | ... | @@ -96,7 +90,7 @@ export function ProjectIconPickerDialog({ |
| 96 | if (mode === "monogram" && !validMonogram) return; | 90 | if (mode === "monogram" && !validMonogram) return; |
| 97 | onSelect( | 91 | onSelect( |
| 98 | mode === "monogram" | 92 | mode === "monogram" |
| 99 | ? { kind: "lucide", name: DEFAULT_ICON, monogram, color } | 93 | ? { kind: "monogram", text: monogram, color } |
| 100 | : mode === "lucide" | 94 | : mode === "lucide" |
| 101 | ? { kind: "lucide", name: iconName, color } | 95 | ? { kind: "lucide", name: iconName, color } |
| 102 | : { kind: "emoji", emoji }, | 96 | : { kind: "emoji", emoji }, |
apps/web/src/components/settings/ProjectSettingsPanel.tsx+6-4| ... | @@ -435,10 +435,12 @@ function ProjectDetail({ | ... | @@ -435,10 +435,12 @@ function ProjectDetail({ |
| 435 | title="Project icon" | 435 | title="Project icon" |
| 436 | description={ | 436 | description={ |
| 437 | projectIcon?.kind === "lucide" | 437 | projectIcon?.kind === "lucide" |
| 438 | ? `${projectIcon.monogram ?? projectIcon.name} · ${projectIcon.color}` | 438 | ? `${projectIcon.name} · ${projectIcon.color}` |
| 439 | : projectIcon?.kind === "emoji" | 439 | : projectIcon?.kind === "monogram" |
| 440 | ? projectIcon.emoji | 440 | ? `${projectIcon.text} · ${projectIcon.color}` |
| 441 | : (faviconPath ?? "Automatic") | 441 | : projectIcon?.kind === "emoji" |
| 442 | ? projectIcon.emoji | ||
| 443 | : (faviconPath ?? "Automatic") | ||
| 442 | } | 444 | } |
| 443 | resetAction={ | 445 | resetAction={ |
| 444 | group.memberProjects.some( | 446 | group.memberProjects.some( |
packages/contracts/src/orchestration.test.ts+99-42| ... | @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; |
| 5 | import { CommandId, ProjectId, ThreadId } from "./baseSchemas.ts"; | 5 | import { CommandId, ProjectId, ThreadId } from "./baseSchemas.ts"; |
| 6 | 6 | ||
| 7 | import { | 7 | import { |
| 8 | ProjectIconOverride, | ||
| 8 | DEFAULT_PROVIDER_INTERACTION_MODE, | 9 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 9 | DEFAULT_RUNTIME_MODE, | 10 | DEFAULT_RUNTIME_MODE, |
| 10 | type ChatImageAttachment, | 11 | type ChatImageAttachment, |
| ... | @@ -17,9 +18,8 @@ import { | ... | @@ -17,9 +18,8 @@ import { |
| 17 | OrchestrationGetTurnDiffInput, | 18 | OrchestrationGetTurnDiffInput, |
| 18 | OrchestrationLatestTurn, | 19 | OrchestrationLatestTurn, |
| 19 | ProjectCreatedPayload, | 20 | ProjectCreatedPayload, |
| 20 | OrchestrationProjectShell, | ||
| 21 | ProjectIconColor, | ||
| 22 | ProjectMetaUpdatedPayload, | 21 | ProjectMetaUpdatedPayload, |
| 22 | OrchestrationProjectShell, | ||
| 23 | OrchestrationProposedPlan, | 23 | OrchestrationProposedPlan, |
| 24 | OrchestrationSession, | 24 | OrchestrationSession, |
| 25 | OrchestrationThread, | 25 | OrchestrationThread, |
| ... | @@ -42,18 +42,6 @@ import { ProviderInstanceId } from "./providerInstance.ts"; | ... | @@ -42,18 +42,6 @@ import { ProviderInstanceId } from "./providerInstance.ts"; |
| 42 | const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); | 42 | const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); |
| 43 | const decodeFullThreadDiffInput = Schema.decodeUnknownEffect(OrchestrationGetFullThreadDiffInput); | 43 | const decodeFullThreadDiffInput = Schema.decodeUnknownEffect(OrchestrationGetFullThreadDiffInput); |
| 44 | const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); | 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 | const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand); | 45 | const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand); |
| 58 | const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload); | 46 | const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload); |
| 59 | const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload); | 47 | const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload); |
| ... | @@ -1508,31 +1496,13 @@ it.effect("project icon overrides accept Lucide icons, colors, and emoji", () => | ... | @@ -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 | it.effect("project monograms validate text and palette colors", () => | 1499 | it.effect("project monograms validate text and palette colors", () => |
| 1529 | Effect.gen(function* () { | 1500 | Effect.gen(function* () { |
| 1530 | for (const text of ["A", "T3", "É", "文書", "कि", "किखि", "e\u0301"]) { | 1501 | for (const text of ["A", "T3", "É", "文書", "कि", "किखि", "e\u0301"]) { |
| 1531 | const projectIcon = { | 1502 | const projectIcon = { |
| 1532 | kind: "lucide", | 1503 | kind: "monogram", |
| 1533 | name: "folder-code", | ||
| 1534 | color: "violet", | 1504 | color: "violet", |
| 1535 | monogram: text, | 1505 | text, |
| 1536 | } as const; | 1506 | } as const; |
| 1537 | const command = yield* decodeOrchestrationCommand({ | 1507 | const command = yield* decodeOrchestrationCommand({ |
| 1538 | type: "project.meta.update", | 1508 | type: "project.meta.update", |
| ... | @@ -1542,16 +1512,14 @@ it.effect("project monograms validate text and palette colors", () => | ... | @@ -1542,16 +1512,14 @@ it.effect("project monograms validate text and palette colors", () => |
| 1542 | }); | 1512 | }); |
| 1543 | assert.strictEqual(command.type, "project.meta.update"); | 1513 | assert.strictEqual(command.type, "project.meta.update"); |
| 1544 | if (command.type === "project.meta.update") | 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 | for (const projectIcon of [ | 1517 | for (const projectIcon of [ |
| 1548 | { kind: "lucide", name: "folder-code", monogram: "", color: "blue" }, | 1518 | { kind: "monogram", text: "", color: "blue" }, |
| 1549 | { kind: "lucide", name: "folder-code", monogram: "ABC", color: "blue" }, | 1519 | { kind: "monogram", text: "\u0301", color: "blue" }, |
| 1550 | { kind: "lucide", name: "folder-code", monogram: "किखिगि", color: "blue" }, | 1520 | { kind: "monogram", text: "A B", color: "blue" }, |
| 1551 | { kind: "lucide", name: "folder-code", monogram: "\u0301", color: "blue" }, | 1521 | { kind: "monogram", text: "🚀", color: "blue" }, |
| 1552 | { kind: "lucide", name: "folder-code", monogram: "A B", color: "blue" }, | 1522 | { kind: "monogram", text: "T3", color: "ultraviolet" }, |
| 1553 | { kind: "lucide", name: "folder-code", monogram: "🚀", color: "blue" }, | ||
| 1554 | { kind: "lucide", name: "folder-code", monogram: "T3", color: "ultraviolet" }, | ||
| 1555 | ]) { | 1523 | ]) { |
| 1556 | const result = yield* Effect.exit( | 1524 | const result = yield* Effect.exit( |
| 1557 | decodeOrchestrationCommand({ | 1525 | decodeOrchestrationCommand({ |
| ... | @@ -1586,3 +1554,92 @@ it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects | ... | @@ -1586,3 +1554,92 @@ it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects |
| 1586 | assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true); | 1554 | assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true); |
| 1587 | assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/svg+xml"), false); | 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,48 +459,62 @@ const ProjectLucideIconName = TrimmedNonEmptyString.check( |
| 459 | 459 | ||
| 460 | const ProjectEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32)); | 460 | const ProjectEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32)); |
| 461 | 461 | ||
| 462 | // Hermes (the Android/iOS JS runtime) has no Intl.Segmenter, and this module | 462 | // Grapheme-count validation belongs to the server command boundary, not snapshot decoding. |
| 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 | |||
| 485 | export const ProjectMonogramText = TrimmedNonEmptyString.check( | 463 | export const ProjectMonogramText = TrimmedNonEmptyString.check( |
| 486 | Schema.isMaxLength(32), | 464 | Schema.isMaxLength(32), |
| 487 | Schema.isPattern(/^[\p{L}\p{N}][\p{L}\p{N}\p{M}\u200c\u200d]*$/u), | 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 | export const ProjectIconOverride = Schema.Union([ | 491 | export const ProjectIconOverride = Schema.Union([ |
| 492 | Schema.Struct({ | 492 | ProjectLucideIconWire, |
| 493 | kind: Schema.Literal("lucide"), | 493 | ProjectEmojiIcon, |
| 494 | name: ProjectLucideIconName, | 494 | ProjectMonogramIcon, |
| 495 | color: ProjectIconColor, | 495 | ]).pipe( |
| 496 | // Older clients ignore this field and render the named Lucide icon instead. | 496 | Schema.decodeTo( |
| 497 | monogram: Schema.optional(ProjectMonogramText), | 497 | ProjectIcon, |
| 498 | }), | 498 | SchemaTransformation.transform({ |
| 499 | Schema.Struct({ | 499 | decode: (icon): typeof ProjectIcon.Type => { |
| 500 | kind: Schema.Literal("emoji"), | 500 | if (icon.kind !== "lucide") return icon; |
| 501 | emoji: ProjectEmoji, | 501 | const text = icon.monogramText ?? icon.monogram; |
| 502 | }), | 502 | return text === undefined |
| 503 | ]); | 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 | export type ProjectIconOverride = typeof ProjectIconOverride.Type; | 518 | export type ProjectIconOverride = typeof ProjectIconOverride.Type; |
| 505 | 519 | ||
| 506 | export const OrchestrationProject = Schema.Struct({ | 520 | export const OrchestrationProject = Schema.Struct({ |