authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-15 17:06:56-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-15 17:06:56-07:00
log7a368fe7c45a45c284f1b064e440a2d696452b07
tree6a14a28817be756049caf69aff4dd46bed74766c
parentb12c92f695a6b12116fb2cda40d610bdbe2a9566
signature Signed by PGP key B5690EEEBB952194

refactor: give project monograms their own icon variant (#11993)


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) => {
43714371 type: "project.meta.update",
43724372 commandId: CommandId.make("cmd-monogram-save"),
43734373 projectId,
4374 projectIcon: { kind: "lucide", name: "folder-code", color: "violet", monogram: "T3" },
4374 projectIcon: { kind: "monogram", text: "T3", color: "violet" },
43754375 });
43764376 const saved = yield* sql<{
43774377 readonly icon: string | null;
43784378 }>`SELECT project_icon_json AS icon FROM projection_projects WHERE project_id = ${projectId}`;
43794379 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"}' },
43814381 ]);
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);
43824387 yield* engine.dispatch({
43834388 type: "project.meta.update",
43844389 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) => {
248248 name: "alarm-clock",
249249 color: "violet",
250250 });
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 }
251280 }),
252281 );
253282
apps/server/src/orchestration/decider.ts+11
......@@ -47,6 +47,8 @@ import {
4747import { projectEvent } from "./projector.ts";
4848import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts";
4949
50const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
51
5052const isScriptRunCommand = Schema.is(SCRIPT_RUN_COMMAND_PATTERN);
5153
5254const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
......@@ -264,6 +266,15 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
264266 command,
265267 projectId: command.projectId,
266268 });
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 }
267278 if (command.scripts !== undefined) {
268279 // Persisted IDs predate shortcut validation. Let users edit or remove them
269280 // without allowing another invalid ID to enter the project.
apps/server/src/persistence/Layers/OrchestrationEventStore.ts+6-1
......@@ -9,6 +9,7 @@ import {
99 OrchestrationEventMetadata,
1010 OrchestrationEventType,
1111 ProjectId,
12 ProjectIconOverride,
1213 ThreadId,
1314} from "@t3tools/contracts";
1415import * as SqlClient from "effect/unstable/sql/SqlClient";
......@@ -29,6 +30,7 @@ import {
2930 type OrchestrationEventStoreShape,
3031} from "../Services/OrchestrationEventStore.ts";
3132
33const encodeProjectIcon = Schema.encodeSync(ProjectIconOverride);
3234const decodeEvent = Schema.decodeUnknownEffect(OrchestrationEvent);
3335const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown);
3436const EventMetadataFromJsonString = Schema.fromJsonString(OrchestrationEventMetadata);
......@@ -263,7 +265,10 @@ const makeEventStore = Effect.gen(function* () {
263265 actorKind: inferActorKind(event),
264266 occurredAt: event.occurredAt,
265267 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,
267272 metadataJson: event.metadata,
268273 }).pipe(
269274 Effect.mapError(
apps/web/src/components/ProjectFavicon.tsx+2-2
......@@ -43,10 +43,10 @@ export function ProjectFavicon(input: {
4343 faviconPath: project.faviconPath,
4444 }),
4545 );
46 if (project.projectIcon?.kind === "lucide" && project.projectIcon.monogram) {
46 if (project.projectIcon?.kind === "monogram") {
4747 return (
4848 <ProjectMonogram
49 text={project.projectIcon.monogram}
49 text={project.projectIcon.text}
5050 color={project.projectIcon.color}
5151 className={input.className}
5252 />
apps/web/src/components/settings/ProjectIconPickerDialog.tsx+5-11
......@@ -54,9 +54,7 @@ export function ProjectIconPickerDialog({
5454 readonly onSelect: (icon: ProjectIconOverride) => void;
5555}) {
5656 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");
6058 const [iconName, setIconName] = useState<IconName>(
6159 current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON,
6260 );
......@@ -64,7 +62,7 @@ export function ProjectIconPickerDialog({
6462 current && current.kind !== "emoji" ? current.color : automatic.color,
6563 );
6664 const [letters, setLetters] = useState(
67 current?.kind === "lucide" && current.monogram ? current.monogram : automatic.monogram,
65 current?.kind === "monogram" ? current.text : automatic.monogram,
6866 );
6967 const [emoji, setEmoji] = useState(current?.kind === "emoji" ? current.emoji : "💻");
7068 const [query, setQuery] = useState("");
......@@ -73,14 +71,10 @@ export function ProjectIconPickerDialog({
7371
7472 useEffect(() => {
7573 if (open && !previousOpenRef.current) {
76 setMode(
77 current?.kind === "lucide" && current.monogram ? "monogram" : (current?.kind ?? "lucide"),
78 );
74 setMode(current?.kind ?? "lucide");
7975 setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON);
8076 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);
8478 setEmoji(current?.kind === "emoji" ? current.emoji : "💻");
8579 setQuery("");
8680 setCustomEmoji("");
......@@ -96,7 +90,7 @@ export function ProjectIconPickerDialog({
9690 if (mode === "monogram" && !validMonogram) return;
9791 onSelect(
9892 mode === "monogram"
99 ? { kind: "lucide", name: DEFAULT_ICON, monogram, color }
93 ? { kind: "monogram", text: monogram, color }
10094 : mode === "lucide"
10195 ? { kind: "lucide", name: iconName, color }
10296 : { kind: "emoji", emoji },
apps/web/src/components/settings/ProjectSettingsPanel.tsx+6-4
......@@ -435,10 +435,12 @@ function ProjectDetail({
435435 title="Project icon"
436436 description={
437437 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")
442444 }
443445 resetAction={
444446 group.memberProjects.some(
packages/contracts/src/orchestration.test.ts+99-42
......@@ -5,6 +5,7 @@ import * as Schema from "effect/Schema";
55import { CommandId, ProjectId, ThreadId } from "./baseSchemas.ts";
66
77import {
8 ProjectIconOverride,
89 DEFAULT_PROVIDER_INTERACTION_MODE,
910 DEFAULT_RUNTIME_MODE,
1011 type ChatImageAttachment,
......@@ -17,9 +18,8 @@ import {
1718 OrchestrationGetTurnDiffInput,
1819 OrchestrationLatestTurn,
1920 ProjectCreatedPayload,
20 OrchestrationProjectShell,
21 ProjectIconColor,
2221 ProjectMetaUpdatedPayload,
22 OrchestrationProjectShell,
2323 OrchestrationProposedPlan,
2424 OrchestrationSession,
2525 OrchestrationThread,
......@@ -42,18 +42,6 @@ import { ProviderInstanceId } from "./providerInstance.ts";
4242const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);
4343const decodeFullThreadDiffInput = Schema.decodeUnknownEffect(OrchestrationGetFullThreadDiffInput);
4444const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff);
45// The icon shape understood by clients released before monograms.
46const 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]);
50const decodeLegacyProjectShell = Schema.decodeUnknownEffect(
51 Schema.Struct({
52 ...OrchestrationProjectShell.fields,
53 projectIcon: Schema.optional(Schema.NullOr(legacyProjectIcon)),
54 }),
55);
56const encodeProjectShell = Schema.encodeEffect(OrchestrationProjectShell);
5745const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand);
5846const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload);
5947const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload);
......@@ -1508,31 +1496,13 @@ it.effect("project icon overrides accept Lucide icons, colors, and emoji", () =>
15081496 }),
15091497);
15101498
1511it.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
15281499it.effect("project monograms validate text and palette colors", () =>
15291500 Effect.gen(function* () {
15301501 for (const text of ["A", "T3", "É", "文書", "कि", "किखि", "e\u0301"]) {
15311502 const projectIcon = {
1532 kind: "lucide",
1533 name: "folder-code",
1503 kind: "monogram",
15341504 color: "violet",
1535 monogram: text,
1505 text,
15361506 } as const;
15371507 const command = yield* decodeOrchestrationCommand({
15381508 type: "project.meta.update",
......@@ -1542,16 +1512,14 @@ it.effect("project monograms validate text and palette colors", () =>
15421512 });
15431513 assert.strictEqual(command.type, "project.meta.update");
15441514 if (command.type === "project.meta.update")
1545 assert.deepEqual(command.projectIcon, projectIcon);
1515 assert.deepEqual(command.projectIcon, { kind: "monogram", text, color: "violet" });
15461516 }
15471517 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" },
15551523 ]) {
15561524 const result = yield* Effect.exit(
15571525 decodeOrchestrationCommand({
......@@ -1586,3 +1554,92 @@ it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects
15861554 assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true);
15871555 assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/svg+xml"), false);
15881556});
1557
1558const decodeProjectIcon = Schema.decodeUnknownEffect(ProjectIconOverride);
1559const encodeProjectIcon = Schema.encodeEffect(ProjectIconOverride);
1560
1561// Pre-monogram clients reject unknown variants; nightly clients additionally validate monogram.
1562const 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);
1568const 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
1581it.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
1604const encodeProjectShell = Schema.encodeEffect(OrchestrationProjectShell);
1605const encodeClientCommand = Schema.encodeEffect(ClientOrchestrationCommand);
1606const 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
1621it.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(
459459
460460const ProjectEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32));
461461
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.
469const GRAPHEME_CONTINUATION = /[\p{M}\u200c\u200d\ufe0f\u{1F3FB}-\u{1F3FF}]/u;
470
471const 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.
485463export const ProjectMonogramText = TrimmedNonEmptyString.check(
486464 Schema.isMaxLength(32),
487465 Schema.isPattern(/^[\p{L}\p{N}][\p{L}\p{N}\p{M}\u200c\u200d]*$/u),
488 Schema.makeFilter((text) => countGraphemes(text) <= 2),
489466);
490467
468const ProjectLucideIcon = Schema.Struct({
469 kind: Schema.Literal("lucide"),
470 name: ProjectLucideIconName,
471 color: ProjectIconColor,
472});
473const ProjectEmojiIcon = Schema.Struct({
474 kind: Schema.Literal("emoji"),
475 emoji: ProjectEmoji,
476});
477const ProjectMonogramIcon = Schema.Struct({
478 kind: Schema.Literal("monogram"),
479 text: ProjectMonogramText,
480 color: ProjectIconColor,
481});
482const ProjectIcon = Schema.Union([ProjectLucideIcon, ProjectEmojiIcon, ProjectMonogramIcon]);
483const 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.
491491export 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);
504518export type ProjectIconOverride = typeof ProjectIconOverride.Type;
505519
506520export const OrchestrationProject = Schema.Struct({