diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 21c95142929f..90f7470a8c5c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -4353,6 +4353,45 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { }), ); + it.effect("persists and clears a project monogram", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const sql = yield* SqlClient.SqlClient; + const projectId = ProjectId.make("project-monogram"); + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-monogram-create"), + projectId, + title: "Monogram", + workspaceRoot: "/tmp/project-monogram", + defaultModelSelection: null, + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* engine.dispatch({ + type: "project.meta.update", + commandId: CommandId.make("cmd-monogram-save"), + projectId, + projectIcon: { kind: "lucide", name: "folder-code", color: "violet", monogram: "T3" }, + }); + const saved = yield* sql<{ + readonly icon: string | null; + }>`SELECT project_icon_json AS icon FROM projection_projects WHERE project_id = ${projectId}`; + assert.deepEqual(saved, [ + { icon: '{"kind":"lucide","name":"folder-code","color":"violet","monogram":"T3"}' }, + ]); + yield* engine.dispatch({ + type: "project.meta.update", + commandId: CommandId.make("cmd-monogram-clear"), + projectId, + projectIcon: null, + }); + const cleared = yield* sql<{ + readonly icon: string | null; + }>`SELECT project_icon_json AS icon FROM projection_projects WHERE project_id = ${projectId}`; + assert.deepEqual(cleared, [{ icon: null }]); + }), + ); + it.effect("re-creating a deleted thread id starts from an empty projection", () => Effect.gen(function* () { const engine = yield* OrchestrationEngineService; diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 17006889fb9c..edf9f23e23a7 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -11,6 +11,7 @@ import { useAtomValue } from "@effect/atom-react"; import { projectFaviconUrlAtom } from "../state/assets"; import { deriveProjectIdentity } from "../projectIdentity"; import { projectIconColorClassName } from "../projectIconColors"; +import { ProjectMonogram } from "./ProjectMonogram"; import { cn } from "~/lib/utils"; const DynamicIcon = lazy(() => @@ -42,6 +43,15 @@ export function ProjectFavicon(input: { faviconPath: project.faviconPath, }), ); + if (project.projectIcon?.kind === "lucide" && project.projectIcon.monogram) { + return ( + + ); + } if (project.projectIcon?.kind === "emoji") { return ( 0) { const identity = deriveProjectIdentity(projectName); - // Wrapped like the emoji and Lucide branches so the monogram sits where an - // favicon would. Menu items, buttons and the like pull every bare svg - // in with [&_svg]:-mx-0.5 to trim the padding stroke icons carry, and this - // tile has no such padding. return ( - + ); } diff --git a/apps/web/src/components/ProjectMonogram.tsx b/apps/web/src/components/ProjectMonogram.tsx new file mode 100644 index 000000000000..04a44e166ea2 --- /dev/null +++ b/apps/web/src/components/ProjectMonogram.tsx @@ -0,0 +1,52 @@ +import type { ProjectIconColor } from "@t3tools/contracts"; +import { projectIconColorClassName } from "../projectIconColors"; +import { cn } from "~/lib/utils"; + +const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + +export function ProjectMonogram({ + text, + color, + className, +}: { + readonly text: string; + readonly color: ProjectIconColor; + readonly className?: string | undefined; +}) { + // Wrapped like the emoji and Lucide branches so the monogram sits where an + // favicon would. Menu items, buttons and the like pull every bare svg + // in with [&_svg]:-mx-0.5 to trim the padding stroke icons carry, and this + // tile has no such padding. + return ( + + ); +} diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx index 2280395ecf40..e1c08dfbabb4 100644 --- a/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx +++ b/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx @@ -41,7 +41,13 @@ import { ProjectIconPickerDialog } from "./ProjectIconPickerDialog"; describe("ProjectIconPickerDialog", () => { it("shows icons first and selects them for an automatic project", () => { const markup = renderToStaticMarkup( - {}} onSelect={() => {}} />, + {}} + onSelect={() => {}} + />, ); expect(markup).toContain('data-current="lucide"'); diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.tsx index 7fce7a4fbb5b..8bcdcc617ef7 100644 --- a/apps/web/src/components/settings/ProjectIconPickerDialog.tsx +++ b/apps/web/src/components/settings/ProjectIconPickerDialog.tsx @@ -1,4 +1,11 @@ -import type { ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { deriveProjectIdentity } from "../../projectIdentity"; +import { ProjectMonogram } from "../ProjectMonogram"; +import { + ProjectMonogramText, + type ProjectIconColor, + type ProjectIconOverride, +} from "@t3tools/contracts"; import { DynamicIcon, type IconName } from "lucide-react/dynamic"; import { useEffect, useMemo, useRef, useState } from "react"; import { @@ -24,7 +31,7 @@ import { ScrollArea } from "../ui/scroll-area"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; const DEFAULT_ICON: IconName = "folder-code"; -const DEFAULT_COLOR: ProjectIconColor = "blue"; +const isMonogramText = Schema.is(ProjectMonogramText); function iconLabel(name: string): string { return name @@ -35,23 +42,29 @@ function iconLabel(name: string): string { export function ProjectIconPickerDialog({ current, + projectName, open, onOpenChange, onSelect, }: { readonly current: ProjectIconOverride | null; + readonly projectName: string; readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly onSelect: (icon: ProjectIconOverride) => void; }) { - const [mode, setMode] = useState<"lucide" | "emoji">( - current?.kind === "emoji" ? "emoji" : "lucide", + const automatic = deriveProjectIdentity(projectName); + const [mode, setMode] = useState( + current?.kind === "lucide" && current.monogram ? "monogram" : (current?.kind ?? "lucide"), ); const [iconName, setIconName] = useState( current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON, ); const [color, setColor] = useState( - current?.kind === "lucide" ? current.color : DEFAULT_COLOR, + current && current.kind !== "emoji" ? current.color : automatic.color, + ); + const [letters, setLetters] = useState( + current?.kind === "lucide" && current.monogram ? current.monogram : automatic.monogram, ); const [emoji, setEmoji] = useState(current?.kind === "emoji" ? current.emoji : "💻"); const [query, setQuery] = useState(""); @@ -60,21 +73,33 @@ export function ProjectIconPickerDialog({ useEffect(() => { if (open && !previousOpenRef.current) { - setMode(current?.kind === "emoji" ? "emoji" : "lucide"); + setMode( + current?.kind === "lucide" && current.monogram ? "monogram" : (current?.kind ?? "lucide"), + ); setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON); - setColor(current?.kind === "lucide" ? current.color : DEFAULT_COLOR); + setColor(current && current.kind !== "emoji" ? current.color : automatic.color); + setLetters( + current?.kind === "lucide" && current.monogram ? current.monogram : automatic.monogram, + ); setEmoji(current?.kind === "emoji" ? current.emoji : "💻"); setQuery(""); setCustomEmoji(""); } previousOpenRef.current = open; - }, [current, open]); + }, [current, open, automatic.color, automatic.monogram]); const icons = useMemo(() => filterProjectIconNames(query), [query]); const selectedColorClassName = projectIconColorClassName(color); + const monogram = letters.normalize("NFKC").trim().toUpperCase(); + const validMonogram = isMonogramText(monogram); const save = () => { + if (mode === "monogram" && !validMonogram) return; onSelect( - mode === "lucide" ? { kind: "lucide", name: iconName, color } : { kind: "emoji", emoji }, + mode === "monogram" + ? { kind: "lucide", name: DEFAULT_ICON, monogram, color } + : mode === "lucide" + ? { kind: "lucide", name: iconName, color } + : { kind: "emoji", emoji }, ); onOpenChange(false); }; @@ -84,7 +109,7 @@ export function ProjectIconPickerDialog({ Choose project icon - Pick any Lucide icon and color, or use an emoji. + Choose an icon, emoji, or monogram. { const value = next[0]; - if (value === "lucide" || value === "emoji") setMode(value); + if (value === "lucide" || value === "emoji" || value === "monogram") setMode(value); }} > Icons Emoji + Monogram + {mode !== "emoji" ? ( +
+
Color
+
+ {PROJECT_ICON_COLORS.map((option) => ( + + ))} +
+
+ ) : null} + {mode === "lucide" ? ( <> -
-
Color
-
- {PROJECT_ICON_COLORS.map((option) => ( - - ))} -
-
No icons found.

) : null} + ) : mode === "monogram" ? ( +
+ +
+ + setLetters(event.currentTarget.value)} + aria-describedby="project-monogram-hint" + aria-invalid={!validMonogram} + autoComplete="off" + /> +

+ One or two letters or numbers. +

+
+
) : ( <> @@ -197,7 +250,9 @@ export function ProjectIconPickerDialog({ - +
diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 1a0f31d77836..3e4cfd22a937 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -435,7 +435,7 @@ function ProjectDetail({ title="Project icon" description={ projectIcon?.kind === "lucide" - ? `${projectIcon.name} · ${projectIcon.color}` + ? `${projectIcon.monogram ?? projectIcon.name} · ${projectIcon.color}` : projectIcon?.kind === "emoji" ? projectIcon.emoji : (faviconPath ?? "Automatic") @@ -530,6 +530,7 @@ function ProjectDetail({ void setProjectIcon({ faviconPath: null, projectIcon: icon })} diff --git a/apps/web/src/projectIdentity.test.ts b/apps/web/src/projectIdentity.test.ts index 942e76fc91b8..250fce32810a 100644 --- a/apps/web/src/projectIdentity.test.ts +++ b/apps/web/src/projectIdentity.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; +import { PROJECT_ICON_COLORS } from "./projectIconColors"; import { deriveProjectIdentity } from "./projectIdentity"; describe("deriveProjectIdentity", () => { @@ -18,14 +19,20 @@ describe("deriveProjectIdentity", () => { const canonical = deriveProjectIdentity("Nebula"); const equivalent = deriveProjectIdentity(" NEBULA "); - expect(equivalent.background).toBe(canonical.background); - expect(equivalent.highlight).toBe(canonical.highlight); + expect(equivalent.color).toBe(canonical.color); + }); + + it("uses only colors available in the icon picker", () => { + const palette = PROJECT_ICON_COLORS.map(({ value }) => value); + for (const name of ["Jobs", "Scripts and Extractors", "T3", "文書", "", "---"]) { + expect(palette).toContain(deriveProjectIdentity(name).color); + } }); it("generates different hues for different project names", () => { const colors = new Set( ["Nebula", "M7 Forge", "Silver Orchard", "Blue Harbor", "Copper Finch", "Juniper Vale"].map( - (projectName) => deriveProjectIdentity(projectName).background, + (projectName) => deriveProjectIdentity(projectName).color, ), ); diff --git a/apps/web/src/projectIdentity.ts b/apps/web/src/projectIdentity.ts index 661a07553a65..840856aa403d 100644 --- a/apps/web/src/projectIdentity.ts +++ b/apps/web/src/projectIdentity.ts @@ -1,8 +1,10 @@ +import { PROJECT_ICON_COLORS } from "./projectIconColors"; +import type { ProjectIconColor } from "@t3tools/contracts"; + /** Visual identity tokens for a generated project badge. */ export interface ProjectIdentity { readonly monogram: string; - readonly background: string; - readonly highlight: string; + readonly color: ProjectIconColor; } function normalizeProjectName(projectName: string): string { @@ -23,21 +25,19 @@ function projectMonogram(projectName: string): string { return Array.from(`${first}${second}`.toUpperCase()).slice(0, 2).join(""); } -function projectHue(projectName: string): number { +function projectColor(projectName: string): ProjectIconColor { const seed = normalizeProjectName(projectName).toLocaleLowerCase("en-US") || "project"; - let hue = 0; + let index = 0; for (const glyph of seed) { - hue = (hue * 31 + (glyph.codePointAt(0) ?? 0)) % 360; + index = (index * 31 + (glyph.codePointAt(0) ?? 0)) % PROJECT_ICON_COLORS.length; } - return hue; + return PROJECT_ICON_COLORS[index]?.value ?? "blue"; } /** Derives the stable monogram and generated colors used when a project has no icon. */ export function deriveProjectIdentity(projectName: string): ProjectIdentity { - const hue = projectHue(projectName); return { monogram: projectMonogram(projectName), - background: `hsl(${hue} 48% 36%)`, - highlight: `hsl(${(hue + 24) % 360} 58% 48%)`, + color: projectColor(projectName), }; } diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 2985c6c011c9..372e92b9a10a 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -43,12 +43,14 @@ Browser access changes apply when an agent session next starts. ## Project icons -Select the project and open Project to choose an icon, emoji, or image. The choice applies to +Select the project and open Project to choose an icon, emoji, monogram, or image. The choice applies to every checkout in the project group and appears on connected clients. Choose **Automatic** to let T3 Code detect an icon again. -When no image is found, web and desktop show a two-character monogram with colors -derived from the saved project name. For example, `Nebula` becomes `NA`, +Choose **Monogram** in the icon picker to set one or two letters or numbers and a color. + +When no image is found, web and desktop show a two-character monogram with a color +from the icon palette, derived from the saved project name. For example, `Nebula` becomes `NA`, `Silver Orchard` becomes `SO`, and `M7 Forge` becomes `M7`. ## Keep the default branch current diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index b8c8358813d4..5228e296d68f 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -17,6 +17,8 @@ import { OrchestrationGetTurnDiffInput, OrchestrationLatestTurn, ProjectCreatedPayload, + OrchestrationProjectShell, + ProjectIconColor, ProjectMetaUpdatedPayload, OrchestrationProposedPlan, OrchestrationSession, @@ -40,6 +42,18 @@ import { ProviderInstanceId } from "./providerInstance.ts"; const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); const decodeFullThreadDiffInput = Schema.decodeUnknownEffect(OrchestrationGetFullThreadDiffInput); const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); +// The icon shape understood by clients released before monograms. +const legacyProjectIcon = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("lucide"), name: Schema.String, color: ProjectIconColor }), + Schema.Struct({ kind: Schema.Literal("emoji"), emoji: Schema.String }), +]); +const decodeLegacyProjectShell = Schema.decodeUnknownEffect( + Schema.Struct({ + ...OrchestrationProjectShell.fields, + projectIcon: Schema.optional(Schema.NullOr(legacyProjectIcon)), + }), +); +const encodeProjectShell = Schema.encodeEffect(OrchestrationProjectShell); const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand); const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload); const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload); @@ -1494,6 +1508,64 @@ it.effect("project icon overrides accept Lucide icons, colors, and emoji", () => }), ); +it.effect("older clients decode monogram projects as their fallback icon", () => + Effect.gen(function* () { + const encoded = yield* encodeProjectShell({ + id: ProjectId.make("project-monogram"), + title: "Monogram", + workspaceRoot: "/tmp/monogram", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + projectIcon: { kind: "lucide", name: "folder-code", color: "violet", monogram: "T3" }, + }); + const decoded = yield* decodeLegacyProjectShell(encoded); + assert.deepEqual(decoded.projectIcon, { kind: "lucide", name: "folder-code", color: "violet" }); + }), +); + +it.effect("project monograms validate text and palette colors", () => + Effect.gen(function* () { + for (const text of ["A", "T3", "É", "文書", "कि", "किखि", "e\u0301"]) { + const projectIcon = { + kind: "lucide", + name: "folder-code", + color: "violet", + monogram: text, + } as const; + const command = yield* decodeOrchestrationCommand({ + type: "project.meta.update", + commandId: "cmd-monogram", + projectId: "project-1", + projectIcon, + }); + assert.strictEqual(command.type, "project.meta.update"); + if (command.type === "project.meta.update") + assert.deepEqual(command.projectIcon, projectIcon); + } + for (const projectIcon of [ + { kind: "lucide", name: "folder-code", monogram: "", color: "blue" }, + { kind: "lucide", name: "folder-code", monogram: "ABC", color: "blue" }, + { kind: "lucide", name: "folder-code", monogram: "किखिगि", color: "blue" }, + { kind: "lucide", name: "folder-code", monogram: "\u0301", color: "blue" }, + { kind: "lucide", name: "folder-code", monogram: "A B", color: "blue" }, + { kind: "lucide", name: "folder-code", monogram: "🚀", color: "blue" }, + { kind: "lucide", name: "folder-code", monogram: "T3", color: "ultraviolet" }, + ]) { + const result = yield* Effect.exit( + decodeOrchestrationCommand({ + type: "project.meta.update", + commandId: "cmd-monogram-invalid", + projectId: "project-1", + projectIcon, + }), + ); + assert.strictEqual(result._tag, "Failure"); + } + }), +); + it.effect("rejects thread history imports without messages", () => Effect.gen(function* () { const result = yield* Effect.exit( diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 1bb11ba0a673..6f2dbf733293 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -459,11 +459,20 @@ const ProjectLucideIconName = TrimmedNonEmptyString.check( const ProjectEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32)); +const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); +export const ProjectMonogramText = TrimmedNonEmptyString.check( + Schema.isMaxLength(32), + Schema.isPattern(/^[\p{L}\p{N}][\p{L}\p{N}\p{M}\u200c\u200d]*$/u), + Schema.makeFilter((text) => Array.from(monogramSegmenter.segment(text)).length <= 2), +); + export const ProjectIconOverride = Schema.Union([ Schema.Struct({ kind: Schema.Literal("lucide"), name: ProjectLucideIconName, color: ProjectIconColor, + // Older clients ignore this field and render the named Lucide icon instead. + monogram: Schema.optional(ProjectMonogramText), }), Schema.Struct({ kind: Schema.Literal("emoji"),