authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-09 01:13:16-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-08 22:13:16-07:00
log076e9048dc57ab09b3692def050e12d690a1b027
tree302930c07267a15454a6a459449a150b1dcf25ce
parent05eb051184ac4d486795ac6f8be29129b8b8845f
signature Signed by PGP key B5690EEEBB952194

feat(web): project icons can be chosen manually (#5775)


48 files changed, 740 insertions(+), 45 deletions(-)

README.md+1
......@@ -68,6 +68,7 @@ Full docs live in [docs/](./docs). There's no docs site yet.
6868- [Install and first run](./docs/user/install.md)
6969- [Permission modes](./docs/user/permission-modes.md)
7070- [Keyboard shortcuts](./docs/user/keybindings.md)
71- [Customize a project icon](./docs/user/project-settings.md)
7172- [Remote access from a phone or another machine](./docs/user/remote-access.md)
7273- [Keeping app and server in sync](./docs/user/updating.md)
7374- [Source control integrations](./docs/user/source-control.md)
apps/mobile/src/components/ProjectFavicon.tsx+6-1
......@@ -24,13 +24,18 @@ export function ProjectFavicon(props: {
2424 readonly size?: number;
2525 readonly projectTitle: string;
2626 readonly workspaceRoot?: string | null;
27 readonly faviconPath?: string | null;
2728}) {
2829 const size = props.size ?? 42;
2930 const faviconUrl = useAssetUrl(
3031 props.environmentId,
3132 props.workspaceRoot === null || props.workspaceRoot === undefined
3233 ? null
33 : { _tag: "project-favicon", cwd: props.workspaceRoot },
34 : {
35 _tag: "project-favicon",
36 cwd: props.workspaceRoot,
37 ...(props.faviconPath ? { path: props.faviconPath } : {}),
38 },
3439 );
3540 const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl;
3641 const cacheKey =
apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx+1
......@@ -368,6 +368,7 @@ function ProjectGroupLabel(props: {
368368 <View className="flex-row items-center gap-2.5 px-1 pb-2">
369369 <ProjectFavicon
370370 environmentId={props.project.environmentId}
371 faviconPath={props.project.faviconPath}
371372 projectTitle={props.project.title}
372373 size={18}
373374 workspaceRoot={props.project.workspaceRoot}
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+2
......@@ -296,6 +296,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
296296 <View className="h-7 w-7 items-center justify-center">
297297 <ProjectFavicon
298298 environmentId={scope.representative.environmentId}
299 faviconPath={scope.representative.faviconPath}
299300 size={20}
300301 projectTitle={scope.title}
301302 workspaceRoot={scope.representative.workspaceRoot}
......@@ -330,6 +331,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
330331 >
331332 <ProjectFavicon
332333 environmentId={project.environmentId}
334 faviconPath={project.faviconPath}
333335 size={18}
334336 projectTitle={project.title}
335337 workspaceRoot={project.workspaceRoot}
apps/mobile/src/features/threads/thread-list-items.tsx+1
......@@ -134,6 +134,7 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props:
134134 >
135135 <ProjectFavicon
136136 environmentId={props.project.environmentId}
137 faviconPath={props.project.faviconPath}
137138 open={!props.collapsed}
138139 size={compact ? 22 : 18}
139140 projectTitle={props.project.title}
apps/mobile/src/features/threads/thread-list-v2-items.tsx+3
......@@ -230,6 +230,7 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
230230 {props.project ? (
231231 <ProjectFavicon
232232 environmentId={pendingTask.message.environmentId}
233 faviconPath={props.project.faviconPath}
233234 size={15}
234235 projectTitle={projectTitle}
235236 workspaceRoot={props.project.workspaceRoot}
......@@ -630,6 +631,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
630631 {props.project ? (
631632 <ProjectFavicon
632633 environmentId={thread.environmentId}
634 faviconPath={props.project.faviconPath}
633635 size={15}
634636 projectTitle={props.projectTitle ?? props.project.title}
635637 workspaceRoot={props.project.workspaceRoot}
......@@ -818,6 +820,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
818820 <View className="opacity-40">
819821 <ProjectFavicon
820822 environmentId={thread.environmentId}
823 faviconPath={props.project.faviconPath}
821824 size={15}
822825 projectTitle={props.projectTitle ?? props.project.title}
823826 workspaceRoot={props.project.workspaceRoot}
apps/server/src/assets/AssetAccess.test.ts+83-1
......@@ -1,5 +1,5 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
2import { ThreadId } from "@t3tools/contracts";
2import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts";
33import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
44import { describe, expect, it } from "@effect/vitest";
55import * as Crypto from "effect/Crypto";
......@@ -225,6 +225,7 @@ describe("AssetAccess", () => {
225225 const faviconResult = yield* issueAssetUrl({
226226 resource: { _tag: "project-favicon", cwd: root },
227227 });
228 expect(faviconResult.sourcePath).toBe("favicon.svg");
228229 expect(faviconResult.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/);
229230 expect(
230231 yield* issueAssetUrl({
......@@ -253,6 +254,7 @@ describe("AssetAccess", () => {
253254 resource: { _tag: "project-favicon", cwd: root },
254255 });
255256 expect(fallbackResult.relativeUrl.endsWith(`/${PROJECT_FAVICON_FALLBACK_MARKER}`)).toBe(true);
257 expect(fallbackResult.sourcePath).toBeUndefined();
256258 const fallbackSuffix = fallbackResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
257259 const fallbackSeparatorIndex = fallbackSuffix.indexOf("/");
258260 expect(
......@@ -264,6 +266,86 @@ describe("AssetAccess", () => {
264266 }).pipe(Effect.provide(testLayer)),
265267 );
266268
269 it.effect("issues project favicon capabilities for a saved override", () =>
270 Effect.gen(function* () {
271 const fileSystem = yield* FileSystem.FileSystem;
272 const path = yield* Path.Path;
273 const root = yield* fileSystem.makeTempDirectoryScoped({
274 prefix: "t3-asset-favicon-override-",
275 });
276 yield* fileSystem.makeDirectory(path.join(root, "brand"));
277 yield* fileSystem.writeFileString(path.join(root, "brand", "custom.svg"), "<svg />");
278 yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), "<svg>auto</svg>");
279
280 const result = yield* issueAssetUrl({
281 resource: { _tag: "project-favicon", cwd: root },
282 projectFaviconPath: "brand/custom.svg",
283 });
284
285 expect(result.sourcePath).toBe("brand/custom.svg");
286 expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-custom\.svg$/);
287 }).pipe(Effect.provide(testLayer)),
288 );
289
290 it.effect("ignores a client favicon path hint", () =>
291 Effect.gen(function* () {
292 const fileSystem = yield* FileSystem.FileSystem;
293 const path = yield* Path.Path;
294 const root = yield* fileSystem.makeTempDirectoryScoped({
295 prefix: "t3-asset-favicon-hint-",
296 });
297 yield* fileSystem.makeDirectory(path.join(root, "brand"));
298 yield* fileSystem.writeFileString(path.join(root, "brand", "hint.svg"), "<svg>hint</svg>");
299 yield* fileSystem.writeFileString(path.join(root, "brand", "saved.svg"), "<svg>saved</svg>");
300
301 const result = yield* issueAssetUrl({
302 resource: { _tag: "project-favicon", cwd: root, path: "brand/hint.svg" },
303 projectFaviconPath: "brand/saved.svg",
304 });
305
306 expect(result.sourcePath).toBe("brand/saved.svg");
307 expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-saved\.svg$/);
308 }).pipe(Effect.provide(testLayer)),
309 );
310
311 it.effect("keeps automatic favicon resolution separate from a saved override", () =>
312 Effect.gen(function* () {
313 const fileSystem = yield* FileSystem.FileSystem;
314 const path = yield* Path.Path;
315 const root = yield* fileSystem.makeTempDirectoryScoped({
316 prefix: "t3-asset-favicon-automatic-",
317 });
318 yield* fileSystem.makeDirectory(path.join(root, "brand"));
319 yield* fileSystem.writeFileString(path.join(root, "brand", "saved.svg"), "<svg>saved</svg>");
320 yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), "<svg>automatic</svg>");
321
322 const result = yield* issueAssetUrl({
323 resource: { _tag: "project-favicon", cwd: root },
324 });
325
326 expect(result.sourcePath).toBe("favicon.svg");
327 expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/);
328 }).pipe(Effect.provide(testLayer)),
329 );
330
331 it.effect("rejects a resolved project favicon with a non-image extension", () =>
332 Effect.gen(function* () {
333 const fileSystem = yield* FileSystem.FileSystem;
334 const path = yield* Path.Path;
335 const root = yield* fileSystem.makeTempDirectoryScoped({
336 prefix: "t3-asset-favicon-type-",
337 });
338 yield* fileSystem.writeFileString(path.join(root, "secret.txt"), "not an image");
339
340 const error = yield* issueAssetUrl({
341 resource: { _tag: "project-favicon", cwd: root },
342 projectFaviconPath: "secret.txt",
343 }).pipe(Effect.flip);
344
345 expect(error).toBeInstanceOf(AssetPreviewTypeValidationError);
346 }).pipe(Effect.provide(testLayer)),
347 );
348
267349 it.effect("buckets project favicon expiry after content hashing", () =>
268350 Effect.gen(function* () {
269351 const crypto = yield* Crypto.Crypto;
apps/server/src/assets/AssetAccess.ts+18-9
......@@ -169,6 +169,7 @@ const resolveCanonicalWorkspaceFileForRequest = (input: {
169169export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: {
170170 readonly resource: AssetResource;
171171 readonly workspaceRoot?: string;
172 readonly projectFaviconPath?: string;
172173}) {
173174 const fileSystem = yield* FileSystem.FileSystem;
174175 const path = yield* Path.Path;
......@@ -176,6 +177,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
176177 let expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS;
177178 let claims: AssetClaims;
178179 let fileName: string;
180 let sourcePath: string | undefined;
179181
180182 switch (input.resource._tag) {
181183 case "workspace-file": {
......@@ -287,16 +289,22 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
287289 ),
288290 );
289291 const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
290 const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot).pipe(
291 Effect.mapError(
292 (cause) =>
293 new AssetProjectFaviconResolutionError({
294 resource: input.resource,
295 cause,
296 }),
297 ),
298 );
292 const faviconPath = yield* faviconResolver
293 .resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined)
294 .pipe(
295 Effect.mapError(
296 (cause) =>
297 new AssetProjectFaviconResolutionError({
298 resource: input.resource,
299 cause,
300 }),
301 ),
302 );
299303 const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null;
304 if (relativePath && !isWorkspaceImagePreviewPath(relativePath)) {
305 return yield* new AssetPreviewTypeValidationError({ resource: input.resource });
306 }
307 sourcePath = relativePath ?? undefined;
300308 const canonicalFaviconPath = relativePath
301309 ? yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe(
302310 Effect.mapError(
......@@ -379,6 +387,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
379387 return {
380388 relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`,
381389 expiresAt,
390 ...(sourcePath !== undefined ? { sourcePath } : {}),
382391 };
383392});
384393
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+5-1
......@@ -2764,15 +2764,18 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
27642764 instanceId: ProviderInstanceId.make("codex"),
27652765 model: "gpt-5",
27662766 },
2767 faviconPath: "brand/icon.svg",
27672768 });
27682769
27692770 const projectRows = yield* sql<{
27702771 readonly scriptsJson: string;
27712772 readonly defaultModelSelection: string;
2773 readonly faviconPath: string | null;
27722774 }>`
27732775 SELECT
27742776 scripts_json AS "scriptsJson",
2775 default_model_selection_json AS "defaultModelSelection"
2777 default_model_selection_json AS "defaultModelSelection",
2778 favicon_path AS "faviconPath"
27762779 FROM projection_projects
27772780 WHERE project_id = 'project-scripts'
27782781 `;
......@@ -2781,6 +2784,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
27812784 scriptsJson:
27822785 '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]',
27832786 defaultModelSelection: '{"instanceId":"codex","model":"gpt-5"}',
2787 faviconPath: "brand/icon.svg",
27842788 },
27852789 ]);
27862790 }),
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+4
......@@ -496,6 +496,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
496496 workspaceRoot: event.payload.workspaceRoot,
497497 defaultModelSelection: event.payload.defaultModelSelection,
498498 defaultThreadEnvMode: null,
499 faviconPath: event.payload.faviconPath ?? null,
499500 scripts: event.payload.scripts,
500501 createdAt: event.payload.createdAt,
501502 updatedAt: event.payload.updatedAt,
......@@ -522,6 +523,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
522523 ...(event.payload.defaultThreadEnvMode !== undefined
523524 ? { defaultThreadEnvMode: event.payload.defaultThreadEnvMode }
524525 : {}),
526 ...(event.payload.faviconPath !== undefined
527 ? { faviconPath: event.payload.faviconPath }
528 : {}),
525529 ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}),
526530 updatedAt: event.payload.updatedAt,
527531 });
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2
......@@ -275,6 +275,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
275275 instanceId: ProviderInstanceId.make("codex"),
276276 model: "gpt-5-codex",
277277 },
278 faviconPath: null,
278279 scripts: [
279280 {
280281 id: "script-1",
......@@ -394,6 +395,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
394395 instanceId: ProviderInstanceId.make("codex"),
395396 model: "gpt-5-codex",
396397 },
398 faviconPath: null,
397399 scripts: [
398400 {
399401 id: "script-1",
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+7
......@@ -317,6 +317,7 @@ function mapProjectShellRow(
317317 repositoryIdentity,
318318 defaultModelSelection: row.defaultModelSelection,
319319 defaultThreadEnvMode: row.defaultThreadEnvMode,
320 faviconPath: row.faviconPath ?? null,
320321 scripts: row.scripts,
321322 createdAt: row.createdAt,
322323 updatedAt: row.updatedAt,
......@@ -393,6 +394,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
393394 workspace_root AS "workspaceRoot",
394395 default_model_selection_json AS "defaultModelSelection",
395396 default_thread_env_mode AS "defaultThreadEnvMode",
397 favicon_path AS "faviconPath",
396398 scripts_json AS "scripts",
397399 created_at AS "createdAt",
398400 updated_at AS "updatedAt",
......@@ -847,6 +849,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
847849 workspace_root AS "workspaceRoot",
848850 default_model_selection_json AS "defaultModelSelection",
849851 default_thread_env_mode AS "defaultThreadEnvMode",
852 favicon_path AS "faviconPath",
850853 scripts_json AS "scripts",
851854 created_at AS "createdAt",
852855 updated_at AS "updatedAt",
......@@ -870,6 +873,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
870873 workspace_root AS "workspaceRoot",
871874 default_model_selection_json AS "defaultModelSelection",
872875 default_thread_env_mode AS "defaultThreadEnvMode",
876 favicon_path AS "faviconPath",
873877 scripts_json AS "scripts",
874878 created_at AS "createdAt",
875879 updated_at AS "updatedAt",
......@@ -1547,6 +1551,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
15471551 repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null,
15481552 defaultModelSelection: row.defaultModelSelection,
15491553 defaultThreadEnvMode: row.defaultThreadEnvMode,
1554 faviconPath: row.faviconPath ?? null,
15501555 scripts: row.scripts,
15511556 createdAt: row.createdAt,
15521557 updatedAt: row.updatedAt,
......@@ -1677,6 +1682,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
16771682 workspaceRoot: row.workspaceRoot,
16781683 defaultModelSelection: row.defaultModelSelection,
16791684 defaultThreadEnvMode: row.defaultThreadEnvMode,
1685 faviconPath: row.faviconPath ?? null,
16801686 scripts: row.scripts,
16811687 createdAt: row.createdAt,
16821688 updatedAt: row.updatedAt,
......@@ -2169,6 +2175,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
21692175 repositoryIdentity,
21702176 defaultModelSelection: option.value.defaultModelSelection,
21712177 defaultThreadEnvMode: option.value.defaultThreadEnvMode,
2178 faviconPath: option.value.faviconPath ?? null,
21722179 scripts: option.value.scripts,
21732180 createdAt: option.value.createdAt,
21742181 updatedAt: option.value.updatedAt,
apps/server/src/orchestration/decider.projectScripts.test.ts+41
......@@ -94,6 +94,47 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => {
9494 }),
9595 );
9696
97 it.effect("propagates a project favicon path in project.meta.update", () =>
98 Effect.gen(function* () {
99 const now = "2026-01-01T00:00:00.000Z";
100 const readModel = yield* projectEvent(createEmptyReadModel(now), {
101 sequence: 1,
102 eventId: asEventId("evt-project-create-favicon"),
103 aggregateKind: "project",
104 aggregateId: asProjectId("project-favicon"),
105 type: "project.created",
106 occurredAt: now,
107 commandId: CommandId.make("cmd-project-create-favicon"),
108 causationEventId: null,
109 correlationId: CommandId.make("cmd-project-create-favicon"),
110 metadata: {},
111 payload: {
112 projectId: asProjectId("project-favicon"),
113 title: "Favicon",
114 workspaceRoot: "/tmp/favicon",
115 defaultModelSelection: null,
116 scripts: [],
117 createdAt: now,
118 updatedAt: now,
119 },
120 });
121
122 const result = yield* decideOrchestrationCommand({
123 command: {
124 type: "project.meta.update",
125 commandId: CommandId.make("cmd-project-update-favicon"),
126 projectId: asProjectId("project-favicon"),
127 faviconPath: "brand/icon.svg",
128 },
129 readModel,
130 });
131
132 const event = Array.isArray(result) ? result[0] : result;
133 expect(event.type).toBe("project.meta-updated");
134 expect((event.payload as { faviconPath?: string }).faviconPath).toBe("brand/icon.svg");
135 }),
136 );
137
97138 it.effect("rejects project.create for an active workspace root that already exists", () =>
98139 Effect.gen(function* () {
99140 const now = "2026-01-01T00:00:00.000Z";
apps/server/src/orchestration/decider.ts+2
......@@ -250,6 +250,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
250250 title: command.title,
251251 workspaceRoot: command.workspaceRoot,
252252 defaultModelSelection: command.defaultModelSelection ?? null,
253 faviconPath: null,
253254 scripts: [],
254255 createdAt: command.createdAt,
255256 updatedAt: command.createdAt,
......@@ -290,6 +291,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
290291 ...(command.defaultThreadEnvMode !== undefined
291292 ? { defaultThreadEnvMode: command.defaultThreadEnvMode }
292293 : {}),
294 ...(command.faviconPath !== undefined ? { faviconPath: command.faviconPath } : {}),
293295 ...(command.scripts !== undefined ? { scripts: command.scripts } : {}),
294296 updatedAt: occurredAt,
295297 },
apps/server/src/orchestration/projector.ts+4
......@@ -215,6 +215,7 @@ export function projectEvent(
215215 workspaceRoot: payload.workspaceRoot,
216216 defaultModelSelection: payload.defaultModelSelection,
217217 defaultThreadEnvMode: null,
218 faviconPath: payload.faviconPath ?? null,
218219 scripts: payload.scripts,
219220 createdAt: payload.createdAt,
220221 updatedAt: payload.updatedAt,
......@@ -250,6 +251,9 @@ export function projectEvent(
250251 ...(payload.defaultThreadEnvMode !== undefined
251252 ? { defaultThreadEnvMode: payload.defaultThreadEnvMode }
252253 : {}),
254 ...(payload.faviconPath !== undefined
255 ? { faviconPath: payload.faviconPath }
256 : {}),
253257 ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}),
254258 updatedAt: payload.updatedAt,
255259 }
apps/server/src/persistence/Layers/ProjectionProjects.ts+5
......@@ -36,6 +36,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
3636 workspace_root,
3737 default_model_selection_json,
3838 default_thread_env_mode,
39 favicon_path,
3940 scripts_json,
4041 created_at,
4142 updated_at,
......@@ -47,6 +48,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
4748 ${row.workspaceRoot},
4849 ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null},
4950 ${row.defaultThreadEnvMode},
51 ${row.faviconPath ?? null},
5052 ${JSON.stringify(row.scripts)},
5153 ${row.createdAt},
5254 ${row.updatedAt},
......@@ -58,6 +60,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
5860 workspace_root = excluded.workspace_root,
5961 default_model_selection_json = excluded.default_model_selection_json,
6062 default_thread_env_mode = excluded.default_thread_env_mode,
63 favicon_path = excluded.favicon_path,
6164 scripts_json = excluded.scripts_json,
6265 created_at = excluded.created_at,
6366 updated_at = excluded.updated_at,
......@@ -76,6 +79,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
7679 workspace_root AS "workspaceRoot",
7780 default_model_selection_json AS "defaultModelSelection",
7881 default_thread_env_mode AS "defaultThreadEnvMode",
82 favicon_path AS "faviconPath",
7983 scripts_json AS "scripts",
8084 created_at AS "createdAt",
8185 updated_at AS "updatedAt",
......@@ -96,6 +100,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
96100 workspace_root AS "workspaceRoot",
97101 default_model_selection_json AS "defaultModelSelection",
98102 default_thread_env_mode AS "defaultThreadEnvMode",
103 favicon_path AS "faviconPath",
99104 scripts_json AS "scripts",
100105 created_at AS "createdAt",
101106 updated_at AS "updatedAt",
apps/server/src/persistence/Migrations.ts+2
......@@ -52,6 +52,7 @@ import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts";
5252import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts";
5353import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts";
5454import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts";
55import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts";
5556
5657/**
5758 * Migration loader with all migrations defined inline.
......@@ -103,6 +104,7 @@ export const migrationEntries = [
103104 [37, "ProjectionTurnsKeysetIndex", Migration0037],
104105 [38, "ProjectionThreadsPinOrderKey", Migration0038],
105106 [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039],
107 [40, "ProjectionProjectFaviconPath", Migration0040],
106108] as const;
107109
108110export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts created+28
......@@ -0,0 +1,28 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";
5
6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "../NodeSqliteClient.ts";
8
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
10
11layer("040_ProjectionProjectFaviconPath", (it) => {
12 it.effect("adds the nullable favicon path to project projections", () =>
13 Effect.gen(function* () {
14 const sql = yield* SqlClient.SqlClient;
15
16 yield* runMigrations({ toMigrationInclusive: 39 });
17 yield* runMigrations({ toMigrationInclusive: 40 });
18
19 const columns = yield* sql<{ readonly name: string; readonly notnull: number }>`
20 PRAGMA table_info(projection_projects)
21 `;
22 const faviconPath = columns.find((column) => column.name === "favicon_path");
23
24 assert.equal(faviconPath?.name, "favicon_path");
25 assert.equal(faviconPath?.notnull, 0);
26 }),
27 );
28});
apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.ts created+16
......@@ -0,0 +1,16 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6 const columns = yield* sql<{ readonly name: string }>`
7 PRAGMA table_info(projection_projects)
8 `;
9
10 if (!columns.some((column) => column.name === "favicon_path")) {
11 yield* sql`
12 ALTER TABLE projection_projects
13 ADD COLUMN favicon_path TEXT
14 `;
15 }
16});
apps/server/src/persistence/Services/ProjectionProjects.ts+1
......@@ -26,6 +26,7 @@ export const ProjectionProject = Schema.Struct({
2626 workspaceRoot: Schema.String,
2727 defaultModelSelection: Schema.NullOr(ModelSelection),
2828 defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode),
29 faviconPath: Schema.optional(Schema.NullOr(Schema.String)),
2930 scripts: Schema.Array(ProjectScript),
3031 createdAt: IsoDateTime,
3132 updatedAt: IsoDateTime,
apps/server/src/project/ProjectFaviconResolver.test.ts+27
......@@ -77,6 +77,33 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
7777 }),
7878 );
7979
80 it.effect("uses a saved project favicon override", () =>
81 Effect.gen(function* () {
82 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
83 const cwd = yield* makeTempDir;
84 yield* writeTextFile(cwd, "brand/custom.svg", "<svg>custom</svg>");
85 yield* writeTextFile(cwd, "favicon.svg", "<svg>automatic</svg>");
86
87 const resolved = yield* resolver.resolvePath(cwd, "brand/custom.svg");
88
89 expect(resolved).not.toBeNull();
90 expect(resolved).toContain("brand/custom.svg");
91 }),
92 );
93
94 it.effect("falls back when a saved override is missing from a checkout", () =>
95 Effect.gen(function* () {
96 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
97 const cwd = yield* makeTempDir;
98 yield* writeTextFile(cwd, "favicon.svg", "<svg>automatic</svg>");
99
100 const resolved = yield* resolver.resolvePath(cwd, "brand/missing.svg");
101
102 expect(resolved).not.toBeNull();
103 expect(resolved).toContain("favicon.svg");
104 }),
105 );
106
80107 it.effect("falls back to well-known files when the t3.json iconPath does not exist", () =>
81108 Effect.gen(function* () {
82109 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
apps/server/src/project/ProjectFaviconResolver.ts+11-1
......@@ -91,6 +91,7 @@ export class ProjectFaviconResolver extends Context.Service<
9191 */
9292 readonly resolvePath: (
9393 cwd: string,
94 faviconPath?: string,
9495 ) => Effect.Effect<string | null, ProjectFaviconResolutionError>;
9596 }
9697>()("t3/project/ProjectFaviconResolver") {}
......@@ -168,7 +169,7 @@ export const make = Effect.gen(function* () {
168169
169170 const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn(
170171 "ProjectFaviconResolver.resolvePath",
171 )(function* (cwd) {
172 )(function* (cwd, faviconPath) {
172173 const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe(
173174 Effect.mapError(
174175 (cause) =>
......@@ -179,6 +180,15 @@ export const make = Effect.gen(function* () {
179180 }),
180181 ),
181182 );
183 // A grouped project's saved path can be absent from one checkout. Use it
184 // where it exists and retain automatic discovery for the other checkouts.
185 if (faviconPath !== undefined) {
186 const existing = yield* findExistingFile(projectCwd, [faviconPath]);
187 if (existing) {
188 return existing;
189 }
190 }
191
182192 // A t3.json iconPath takes precedence over the well-known locations.
183193 const projectFile = yield* projectFileLoader.load(projectCwd);
184194 if (Option.isSome(projectFile) && projectFile.value.iconPath !== undefined) {
apps/server/src/workspace/WorkspaceEntries.ts+1-1
......@@ -245,7 +245,7 @@ export const make = Effect.gen(function* () {
245245 });
246246 return yield* Effect.gen(function* () {
247247 const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
248 return yield* searchIndex.search(normalizedQuery, input.limit, input.kind);
248 return yield* searchIndex.search(normalizedQuery, input.limit, input.kind, input.imageOnly);
249249 }).pipe(
250250 Effect.provide(
251251 workspaceSearchIndexes.get(
apps/server/src/workspace/WorkspaceSearchIndex.test.ts+55-1
......@@ -1,4 +1,10 @@
1import { FileFinder, type GrepCursor, type GrepOptions, type GrepResult } from "@ff-labs/fff-node";
1import {
2 FileFinder,
3 type FileItem,
4 type GrepCursor,
5 type GrepOptions,
6 type GrepResult,
7} from "@ff-labs/fff-node";
28import { afterEach, expect, it } from "@effect/vitest";
39import * as Cause from "effect/Cause";
410import * as Effect from "effect/Effect";
......@@ -11,6 +17,54 @@ afterEach(() => {
1117 vi.restoreAllMocks();
1218});
1319
20function fileItem(relativePath: string): FileItem {
21 return {
22 relativePath,
23 fileName: relativePath.slice(relativePath.lastIndexOf("/") + 1),
24 size: 1,
25 modified: 0,
26 accessFrecencyScore: 0,
27 modificationFrecencyScore: 0,
28 totalFrecencyScore: 0,
29 gitStatus: "clean",
30 };
31}
32
33it.effect("filters image searches before applying the result limit", () =>
34 Effect.scoped(
35 Effect.gen(function* () {
36 const items = [
37 ...Array.from({ length: 200 }, (_, index) => fileItem(`src/file-${index}.ts`)),
38 fileItem("public/icon.svg"),
39 ];
40 const fileSearch = vi.fn(() => ({
41 ok: true as const,
42 value: {
43 items,
44 scores: [],
45 totalMatched: items.length,
46 totalFiles: items.length,
47 },
48 }));
49 const finder = {
50 destroy: vi.fn(),
51 waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
52 fileSearch,
53 } as unknown as FileFinder;
54 vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });
55
56 const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project");
57 const resultWithoutKind = yield* searchIndex.search("", 200, undefined, true);
58 const resultWithDirectoryKind = yield* searchIndex.search("", 200, "directory", true);
59
60 expect(resultWithoutKind.entries).toEqual([{ kind: "file", path: "public/icon.svg" }]);
61 expect(resultWithDirectoryKind.entries).toEqual([{ kind: "file", path: "public/icon.svg" }]);
62 expect(fileSearch).toHaveBeenCalledTimes(2);
63 expect(fileSearch).toHaveBeenCalledWith("", { pageSize: 25_002 });
64 }),
65 ),
66);
67
1468it.effect("preserves unexpected FileFinder creation failures", () =>
1569 Effect.gen(function* () {
1670 const cause = new Error("native initialization failed");
apps/server/src/workspace/WorkspaceSearchIndex.ts+17-12
......@@ -23,6 +23,7 @@ import type {
2323 ProjectSearchContentsResult,
2424 ProjectSearchEntriesResult,
2525} from "@t3tools/contracts";
26import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
2627
2728const WORKSPACE_INDEX_MAX_ENTRIES = 25_000;
2829const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2;
......@@ -111,6 +112,7 @@ export class WorkspaceSearchIndex extends Context.Service<
111112 query: string,
112113 limit: number,
113114 kind?: ProjectEntryKind,
115 imageOnly?: boolean,
114116 ) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceSearchIndexSearchFailed>;
115117 readonly searchContents: (
116118 input: Omit<ProjectSearchContentsInput, "cwd">,
......@@ -157,15 +159,18 @@ function toDirectoryEntry(item: DirItem): ProjectEntry | null {
157159 return normalizedPath ? { path: normalizedPath, kind: "directory" } : null;
158160}
159161
160function mapFileSearchResult(result: SearchResult, limit: number): ProjectSearchEntriesResult {
162function mapFileSearchResult(
163 result: SearchResult,
164 limit: number,
165 imageOnly = false,
166): ProjectSearchEntriesResult {
167 const entries = result.items.flatMap((item) => {
168 const entry = toFileEntry(item);
169 return entry && (!imageOnly || isWorkspaceImagePreviewPath(entry.path)) ? [entry] : [];
170 });
161171 return {
162 entries: result.items
163 .flatMap((item) => {
164 const entry = toFileEntry(item);
165 return entry ? [entry] : [];
166 })
167 .slice(0, limit),
168 truncated: result.totalMatched > limit,
172 entries: entries.slice(0, limit),
173 truncated: entries.length > limit || result.totalMatched > result.items.length,
169174 };
170175}
171176
......@@ -445,13 +450,13 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (
445450
446451 const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn(
447452 "WorkspaceSearchIndex.search",
448 )(function* (query, limit, kind) {
449 const pageSize = Math.max(1, limit + 1);
450 if (kind === "file") {
453 )(function* (query, limit, kind, imageOnly) {
454 const pageSize = imageOnly ? WORKSPACE_INDEX_PAGE_SIZE : Math.max(1, limit + 1);
455 if (kind === "file" || imageOnly) {
451456 const result = yield* runSearch(query, pageSize, "fileSearch", () =>
452457 finder.fileSearch(query, { pageSize }),
453458 );
454 return mapFileSearchResult(result, limit);
459 return mapFileSearchResult(result, limit, imageOnly);
455460 }
456461 if (kind === "directory") {
457462 const result = yield* runSearch(query, pageSize, "directorySearch", () =>
apps/server/src/ws.ts+25-1
......@@ -1763,9 +1763,33 @@ const makeWsRpcLayer = (
17631763 observeRpcEffect(
17641764 WS_METHODS.assetsCreateUrl,
17651765 Effect.gen(function* () {
1766 if (input.resource._tag !== "workspace-file") {
1766 if (input.resource._tag === "attachment") {
17671767 return yield* issueAssetUrl({ resource: input.resource });
17681768 }
1769 if (input.resource._tag === "project-favicon") {
1770 const project = yield* projectionSnapshotQuery
1771 .getActiveProjectByWorkspaceRoot(input.resource.cwd)
1772 .pipe(
1773 Effect.mapError(
1774 (cause) =>
1775 new AssetWorkspaceContextResolutionError({
1776 resource: input.resource,
1777 cause,
1778 }),
1779 ),
1780 );
1781 if (Option.isNone(project)) {
1782 return yield* new AssetWorkspaceContextNotFoundError({
1783 resource: input.resource,
1784 });
1785 }
1786 return yield* issueAssetUrl({
1787 resource: input.resource,
1788 ...(project.value.faviconPath
1789 ? { projectFaviconPath: project.value.faviconPath }
1790 : {}),
1791 });
1792 }
17691793 const thread = yield* projectionSnapshotQuery
17701794 .getThreadShellById(input.resource.threadId)
17711795 .pipe(
apps/web/src/assets/assetUrls.ts+8-2
......@@ -12,7 +12,7 @@ export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
1212export type AssetUrlState =
1313 | { readonly _tag: "Loading" }
1414 | { readonly _tag: "Failure" }
15 | { readonly _tag: "Success"; readonly url: string };
15 | { readonly _tag: "Success"; readonly url: string; readonly sourcePath?: string };
1616
1717export function useAssetUrlState(
1818 environmentId: EnvironmentId,
......@@ -32,7 +32,13 @@ export function useAssetUrlState(
3232 return { _tag: "Loading" };
3333 }
3434 const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl);
35 return url === null ? { _tag: "Failure" } : { _tag: "Success", url };
35 return url === null
36 ? { _tag: "Failure" }
37 : {
38 _tag: "Success",
39 url,
40 ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}),
41 };
3642}
3743
3844export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null {
apps/web/src/components/ChatView.tsx+1
......@@ -6048,6 +6048,7 @@ function ChatViewContent(props: ChatViewProps) {
60486048 changeRequestState={activeThreadPr?.state ?? null}
60496049 activeProjectName={activeProject?.title}
60506050 activeProjectCwd={activeProject?.workspaceRoot ?? null}
6051 activeProjectFaviconPath={activeProject?.faviconPath ?? null}
60516052 openInCwd={gitCwd}
60526053 activeProjectScripts={activeProject?.scripts}
60536054 preferredScriptId={
apps/web/src/components/CommandPalette.tsx+1
......@@ -150,6 +150,7 @@ function projectFavicon(project: Project) {
150150 <ProjectFavicon
151151 environmentId={project.environmentId}
152152 cwd={project.workspaceRoot}
153 faviconPath={project.faviconPath}
153154 className={ITEM_ICON_CLASS}
154155 />
155156 );
apps/web/src/components/LegacySidebar.tsx+5-1
......@@ -2253,7 +2253,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
22532253 }`}
22542254 />
22552255 )}
2256 <ProjectFavicon environmentId={project.environmentId} cwd={project.workspaceRoot} />
2256 <ProjectFavicon
2257 environmentId={project.environmentId}
2258 cwd={project.workspaceRoot}
2259 faviconPath={project.faviconPath}
2260 />
22572261 <span className="flex min-w-0 flex-1 items-center gap-2">
22582262 <span className="truncate text-sm font-medium text-sidebar-foreground/90">
22592263 {project.displayName}
apps/web/src/components/ProjectFavicon.test.tsx+19-1
......@@ -4,6 +4,7 @@ import type { EnvironmentId } from "@t3tools/contracts";
44
55const testState = vi.hoisted(() => ({
66 faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg",
7 lastResource: null as unknown,
78}));
89
910const hooks = vi.hoisted(() => {
......@@ -52,7 +53,10 @@ vi.mock("react", async (importOriginal) => {
5253
5354vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache }));
5455vi.mock("../assets/assetUrls", () => ({
55 useAssetUrl: () => testState.faviconUrl,
56 useAssetUrlState: (_environmentId: unknown, resource: unknown) => {
57 testState.lastResource = resource;
58 return { _tag: "Success", url: testState.faviconUrl };
59 },
5660}));
5761
5862import { ProjectFavicon } from "./ProjectFavicon";
......@@ -125,4 +129,18 @@ describe("ProjectFavicon", () => {
125129 expect(afterDisplayedError[0]).not.toBeNull();
126130 expect(afterDisplayedError[1]).toBeNull();
127131 });
132
133 it("requests a saved favicon path when one is set", () => {
134 ProjectFavicon({
135 environmentId: "environment-test" as EnvironmentId,
136 cwd: "/workspace-test",
137 faviconPath: "brand/icon.svg",
138 });
139
140 expect(testState.lastResource).toEqual({
141 _tag: "project-favicon",
142 cwd: "/workspace-test",
143 path: "brand/icon.svg",
144 });
145 });
128146});
apps/web/src/components/ProjectFavicon.tsx+16-5
......@@ -6,7 +6,7 @@ import {
66import { FolderIcon } from "lucide-react";
77import type { ComponentType } from "react";
88import { useState } from "react";
9import { useAssetUrl } from "../assets/assetUrls";
9import { useAssetUrlState } from "../assets/assetUrls";
1010import { cn } from "~/lib/utils";
1111
1212const loadedProjectFaviconSrcs = new Map<string, string>();
......@@ -14,13 +14,12 @@ const loadedProjectFaviconSrcs = new Map<string, string>();
1414export function ProjectFavicon(input: {
1515 environmentId: EnvironmentId;
1616 cwd: string;
17 faviconPath?: string | null | undefined;
1718 className?: string | undefined;
1819 fallbackIcon?: ComponentType<{ className?: string }>;
1920}) {
20 const src = useAssetUrl(input.environmentId, {
21 _tag: "project-favicon",
22 cwd: input.cwd,
23 });
21 const state = useProjectFaviconAsset(input);
22 const src = state._tag === "Success" ? state.url : null;
2423 const FallbackIcon = input.fallbackIcon ?? FolderIcon;
2524
2625 if (!src || isProjectFaviconFallbackUrl(src)) {
......@@ -40,6 +39,18 @@ export function ProjectFavicon(input: {
4039 );
4140}
4241
42export function useProjectFaviconAsset(input: {
43 readonly environmentId: EnvironmentId;
44 readonly cwd: string;
45 readonly faviconPath?: string | null | undefined;
46}) {
47 return useAssetUrlState(input.environmentId, {
48 _tag: "project-favicon",
49 cwd: input.cwd,
50 ...(input.faviconPath ? { path: input.faviconPath } : {}),
51 });
52}
53
4354function ProjectFaviconFallback({
4455 className,
4556 icon: Icon,
apps/web/src/components/Sidebar.tsx+34
......@@ -236,6 +236,7 @@ function SidebarThreadTooltip({
236236 thread,
237237 projectTitle,
238238 projectCwd,
239 projectFaviconPath,
239240 environmentLabel,
240241 driverKind,
241242 modelInstanceId,
......@@ -247,6 +248,7 @@ function SidebarThreadTooltip({
247248 thread: SidebarThreadSummary;
248249 projectTitle: string | null;
249250 projectCwd: string | null;
251 projectFaviconPath: string | null;
250252 environmentLabel: string | null;
251253 driverKind: ProviderInstanceEntry["driverKind"] | null;
252254 modelInstanceId: string;
......@@ -276,6 +278,7 @@ function SidebarThreadTooltip({
276278 <ProjectFavicon
277279 environmentId={thread.environmentId}
278280 cwd={projectCwd ?? ""}
281 faviconPath={projectFaviconPath}
279282 className="size-3 shrink-0 stroke-muted-foreground"
280283 />
281284 <div className="min-w-0 truncate text-foreground/75">{projectTitle}</div>
......@@ -423,6 +426,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
423426 composer: ComposerThreadDraftState;
424427 projectTitle: string | null;
425428 projectCwd: string | null;
429 projectFaviconPath: string | null;
426430 isActive: boolean;
427431 onNavigate: (draftId: DraftId) => void;
428432 onDiscard: (draftId: DraftId) => void;
......@@ -487,6 +491,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
487491 <ProjectFavicon
488492 environmentId={session.environmentId}
489493 cwd={props.projectCwd ?? ""}
494 faviconPath={props.projectFaviconPath}
490495 className="size-4 shrink-0"
491496 />
492497 <span className="min-w-0 flex-1 truncate text-xs font-medium text-secondary-label">
......@@ -524,6 +529,7 @@ interface SidebarDraftRowData {
524529const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
525530 projectDisplayNameByKey: ReadonlyMap<string, string>;
526531 projectCwdByKey: ReadonlyMap<string, string>;
532 projectFaviconPathByKey: ReadonlyMap<string, string | null | undefined>;
527533 scopedProjectKeys: ReadonlySet<string> | null;
528534 routeDraftId: string | null;
529535 onNavigateToDraft: (draftId: DraftId) => void;
......@@ -618,6 +624,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
618624 composer={composer}
619625 projectTitle={props.projectDisplayNameByKey.get(projectKey) ?? null}
620626 projectCwd={props.projectCwdByKey.get(projectKey) ?? null}
627 projectFaviconPath={props.projectFaviconPathByKey.get(projectKey) ?? null}
621628 isActive={draftId === props.routeDraftId}
622629 onNavigate={props.onNavigateToDraft}
623630 onDiscard={handleDiscard}
......@@ -667,6 +674,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
667674 currentEnvironmentId: string | null;
668675 environmentLabel: string | null;
669676 projectCwd: string | null;
677 projectFaviconPath: string | null;
670678 projectTitle: string | null;
671679 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;
672680 timestampFormat: TimestampFormat;
......@@ -857,6 +865,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
857865 thread={thread}
858866 projectTitle={props.projectTitle}
859867 projectCwd={props.projectCwd}
868 projectFaviconPath={props.projectFaviconPath}
860869 environmentLabel={props.environmentLabel}
861870 driverKind={driverKind}
862871 modelInstanceId={modelInstanceId}
......@@ -1122,6 +1131,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
11221131 <ProjectFavicon
11231132 environmentId={thread.environmentId}
11241133 cwd={props.projectCwd ?? ""}
1134 faviconPath={props.projectFaviconPath}
11251135 className="size-4"
11261136 fallbackIcon={MessageSquareIcon}
11271137 />
......@@ -1261,6 +1271,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
12611271 <ProjectFavicon
12621272 environmentId={thread.environmentId}
12631273 cwd={props.projectCwd ?? ""}
1274 faviconPath={props.projectFaviconPath}
12641275 className="size-4 shrink-0"
12651276 />
12661277 {props.projectTitle ? (
......@@ -1454,6 +1465,7 @@ function latestTurnDiff(
14541465const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
14551466 thread: SidebarThreadSummary;
14561467 projectCwd: string | null;
1468 projectFaviconPath: string | null;
14571469 projectTitle: string | null;
14581470 environmentLabel: string | null;
14591471 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;
......@@ -1526,6 +1538,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
15261538 <ProjectFavicon
15271539 environmentId={thread.environmentId}
15281540 cwd={props.projectCwd ?? ""}
1541 faviconPath={props.projectFaviconPath}
15291542 className="size-4 shrink-0"
15301543 fallbackIcon={MessageSquareIcon}
15311544 />
......@@ -1538,6 +1551,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
15381551 thread={thread}
15391552 projectTitle={props.projectTitle}
15401553 projectCwd={props.projectCwd}
1554 projectFaviconPath={props.projectFaviconPath}
15411555 environmentLabel={props.environmentLabel}
15421556 driverKind={driverKind}
15431557 modelInstanceId={modelInstanceId}
......@@ -1714,6 +1728,13 @@ export default function Sidebar() {
17141728 ),
17151729 [projects],
17161730 );
1731 const projectFaviconPathByKey = useMemo(
1732 () =>
1733 new Map(
1734 projects.map((project) => [`${project.environmentId}:${project.id}`, project.faviconPath]),
1735 ),
1736 [projects],
1737 );
17171738 const projectDisplayNameByKey = useMemo(
17181739 () =>
17191740 new Map(
......@@ -3230,6 +3251,7 @@ export default function Sidebar() {
32303251 <ProjectFavicon
32313252 environmentId={scopedProjectGroup.environmentId}
32323253 cwd={scopedProjectGroup.workspaceRoot}
3254 faviconPath={scopedProjectGroup.faviconPath}
32333255 className="size-4 shrink-0"
32343256 />
32353257 ) : (
......@@ -3267,6 +3289,7 @@ export default function Sidebar() {
32673289 <ProjectFavicon
32683290 environmentId={project.environmentId}
32693291 cwd={project.workspaceRoot}
3292 faviconPath={project.faviconPath}
32703293 className="size-4 shrink-0"
32713294 />
32723295 <span className="min-w-0 truncate text-sm">{project.displayName}</span>
......@@ -3339,6 +3362,11 @@ export default function Sidebar() {
33393362 projectCwd={
33403363 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
33413364 }
3365 projectFaviconPath={
3366 projectFaviconPathByKey.get(
3367 `${thread.environmentId}:${thread.projectId}`,
3368 ) ?? null
3369 }
33423370 projectTitle={
33433371 projectDisplayNameByKey.get(
33443372 `${thread.environmentId}:${thread.projectId}`,
......@@ -3442,6 +3470,11 @@ export default function Sidebar() {
34423470 projectCwd={
34433471 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
34443472 }
3473 projectFaviconPath={
3474 projectFaviconPathByKey.get(
3475 `${thread.environmentId}:${thread.projectId}`,
3476 ) ?? null
3477 }
34453478 projectTitle={
34463479 projectDisplayNameByKey.get(
34473480 `${thread.environmentId}:${thread.projectId}`,
......@@ -3480,6 +3513,7 @@ export default function Sidebar() {
34803513 key="draft-sessions"
34813514 projectDisplayNameByKey={projectDisplayNameByKey}
34823515 projectCwdByKey={projectCwdByKey}
3516 projectFaviconPathByKey={projectFaviconPathByKey}
34833517 scopedProjectKeys={scopedProjectKeys}
34843518 routeDraftId={routeDraftIdForRows}
34853519 onNavigateToDraft={navigateToDraft}
apps/web/src/components/chat/ChatHeader.tsx+3
......@@ -49,6 +49,7 @@ interface ChatHeaderProps {
4949 changeRequestState: ChangeRequestStateLike | null;
5050 activeProjectName: string | undefined;
5151 activeProjectCwd: string | null;
52 activeProjectFaviconPath: string | null;
5253 openInCwd: string | null;
5354 activeProjectScripts: ReadonlyArray<ProjectScript> | undefined;
5455 preferredScriptId: string | null;
......@@ -101,6 +102,7 @@ export const ChatHeader = memo(function ChatHeader({
101102 changeRequestState,
102103 activeProjectName,
103104 activeProjectCwd,
105 activeProjectFaviconPath,
104106 openInCwd,
105107 activeProjectScripts,
106108 preferredScriptId,
......@@ -229,6 +231,7 @@ export const ChatHeader = memo(function ChatHeader({
229231 <ProjectFavicon
230232 environmentId={activeThreadEnvironmentId}
231233 cwd={activeProjectCwd ?? ""}
234 faviconPath={activeProjectFaviconPath}
232235 className="size-3.5"
233236 />
234237 <span className="max-w-40 truncate text-sm font-medium">{activeProjectName}</span>
apps/web/src/components/files/projectFilesQueryState.ts+12-3
......@@ -150,10 +150,19 @@ export function useProjectFilePickerQuery(
150150 cwd: string,
151151 query: string,
152152 limit: number,
153 options?: { readonly imageOnly?: boolean },
153154) {
154 const search = useProjectPathSearch({ environmentId, cwd, query, kind: "file" }, limit, {
155 allowEmptyQuery: true,
156 });
155 const search = useProjectPathSearch(
156 {
157 environmentId,
158 cwd,
159 query,
160 kind: "file",
161 ...(options?.imageOnly ? { imageOnly: true } : {}),
162 },
163 limit,
164 { allowEmptyQuery: true },
165 );
157166
158167 return {
159168 entries: search.isPending ? [] : search.entries,
apps/web/src/components/settings/ProjectFaviconPickerDialog.tsx created+104
......@@ -0,0 +1,104 @@
1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId } from "@t3tools/contracts";
3import { useMemo, useState } from "react";
4
5import { primaryServerKeybindingsAtom } from "~/state/server";
6import { useTheme } from "~/hooks/useTheme";
7import { CommandPaletteContent } from "../CommandPaletteContent";
8import type { CommandPaletteActionItem } from "../CommandPalette.logic";
9import { CommandPaletteResults } from "../CommandPaletteResults";
10import { PierreEntryIcon } from "../chat/PierreEntryIcon";
11import {
12 getProjectFilePickerMatches,
13 PROJECT_FILE_PICKER_RESULT_LIMIT,
14} from "../files/ProjectFilePicker.logic";
15import { useProjectFilePickerQuery } from "../files/projectFilesQueryState";
16import { CommandDialog, CommandDialogPopup } from "../ui/command";
17
18function emptyMessage(query: string, error: string | null, isPending: boolean): string {
19 if (error) return error;
20 if (isPending) return query.trim() ? "Searching project files…" : "Indexing project files…";
21 return query.trim() ? "No matching image files." : "No image files found.";
22}
23
24export function ProjectFaviconPickerDialog(props: {
25 readonly cwd: string;
26 readonly environmentId: EnvironmentId;
27 readonly onOpenChange: (open: boolean) => void;
28 readonly onSelect: (path: string) => void;
29 readonly open: boolean;
30 readonly projectName: string;
31}) {
32 const [query, setQuery] = useState("");
33 const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null);
34 const result = useProjectFilePickerQuery(
35 props.environmentId,
36 props.cwd,
37 query,
38 PROJECT_FILE_PICKER_RESULT_LIMIT,
39 { imageOnly: true },
40 );
41 const { resolvedTheme } = useTheme();
42 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
43 const items = useMemo<CommandPaletteActionItem[]>(
44 () =>
45 getProjectFilePickerMatches(result.entries, result.matchedQuery).map((match) => ({
46 kind: "action",
47 value: `project-favicon:${match.path}`,
48 searchTerms: [match.name, match.path],
49 title: match.name,
50 description: match.path,
51 icon: <PierreEntryIcon pathValue={match.path} kind="file" theme={resolvedTheme} />,
52 run: async () => props.onSelect(match.path),
53 })),
54 [props.onSelect, resolvedTheme, result.entries, result.matchedQuery],
55 );
56
57 return (
58 <CommandDialog open={props.open} onOpenChange={props.onOpenChange}>
59 {props.open ? (
60 <CommandDialogPopup
61 aria-label="Choose project icon"
62 className="overflow-hidden p-0"
63 onBackdropPointerDown={() => props.onOpenChange(false)}
64 >
65 <CommandPaletteContent
66 aria-label="Choose project icon"
67 autoHighlight="always"
68 escapeLabel="Close"
69 footerActionLabel="Select icon"
70 inputProps={{ placeholder: "Search image files…" }}
71 mode="none"
72 onItemHighlighted={(value) => {
73 setHighlightedItemValue(typeof value === "string" ? value : null);
74 }}
75 onValueChange={(value) => {
76 setHighlightedItemValue(null);
77 setQuery(value);
78 }}
79 panelClassName="max-h-[min(34rem,76vh)]"
80 testId="project-favicon-picker"
81 value={query}
82 >
83 <CommandPaletteResults
84 groups={
85 items.length > 0
86 ? [{ value: "project-favicon-files", label: props.projectName, items }]
87 : []
88 }
89 highlightedItemValue={highlightedItemValue}
90 isActionsOnly={false}
91 keybindings={keybindings}
92 onExecuteItem={(item) => {
93 if (item.kind !== "action") return;
94 props.onOpenChange(false);
95 void item.run();
96 }}
97 emptyStateMessage={emptyMessage(query, result.error, result.isPending)}
98 />
99 </CommandPaletteContent>
100 </CommandDialogPopup>
101 ) : null}
102 </CommandDialog>
103 );
104}
apps/web/src/components/settings/ProjectSettingsPanel.tsx+70
......@@ -86,6 +86,7 @@ import {
8686 SettingsRow,
8787 SettingsSection,
8888} from "./settingsLayout";
89import { ProjectFaviconPickerDialog } from "./ProjectFaviconPickerDialog";
8990
9091export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
9192 repository: "Group by repository",
......@@ -221,6 +222,7 @@ export function ProjectSettingsPanel({
221222 <ProjectFavicon
222223 environmentId={group.environmentId}
223224 cwd={group.workspaceRoot}
225 faviconPath={group.faviconPath}
224226 className="size-4 shrink-0"
225227 />
226228 <span className="min-w-0 flex-1 truncate">{group.displayName}</span>
......@@ -295,6 +297,7 @@ function ProjectDetail({
295297 group.memberProjects.find(
296298 (member) => member.environmentId === group.environmentId && member.id === group.id,
297299 ) ?? group.memberProjects[0]!;
300 const faviconPath = representative.faviconPath ?? null;
298301
299302 const threadCountByMember = useMemo(() => {
300303 const counts = new Map<string, number>();
......@@ -328,6 +331,7 @@ function ProjectDetail({
328331 input: Partial<{
329332 defaultModelSelection: ModelSelection | null;
330333 defaultThreadEnvMode: ThreadEnvMode | null;
334 faviconPath: string | null;
331335 scripts: ReadonlyArray<ReturnType<typeof buildProjectScript>>;
332336 }>,
333337 failureTitle: string,
......@@ -391,6 +395,25 @@ function ProjectDetail({
391395 [updateAllMembers],
392396 );
393397
398 // ----- favicon -----
399 const [faviconPickerOpen, setFaviconPickerOpen] = useState(false);
400 const [isSavingFavicon, setIsSavingFavicon] = useState(false);
401 const savingFaviconRef = useRef(false);
402 const setFaviconPath = useCallback(
403 async (faviconPath: string | null) => {
404 if (savingFaviconRef.current) return;
405 savingFaviconRef.current = true;
406 setIsSavingFavicon(true);
407 try {
408 await updateAllMembers({ faviconPath }, "Failed to update project icon");
409 } finally {
410 savingFaviconRef.current = false;
411 setIsSavingFavicon(false);
412 }
413 },
414 [updateAllMembers],
415 );
416
394417 // ----- scripts -----
395418 const scripts = representative.scripts;
396419 const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null);
......@@ -692,6 +715,7 @@ function ProjectDetail({
692715 <ProjectFavicon
693716 environmentId={group.environmentId}
694717 cwd={group.workspaceRoot}
718 faviconPath={group.faviconPath}
695719 className="size-8 shrink-0"
696720 />
697721 <div className="min-w-0 flex-1">
......@@ -723,6 +747,43 @@ function ProjectDetail({
723747 </Select>
724748 </div>
725749
750 <SettingsSection title="Appearance">
751 <SettingsRow
752 id="project-favicon"
753 title="Project icon"
754 description={faviconPath ?? "Automatic"}
755 resetAction={
756 faviconPath !== null ? (
757 <SettingResetButton
758 label="project icon"
759 disabled={isSavingFavicon}
760 onClick={() => void setFaviconPath(null)}
761 />
762 ) : null
763 }
764 control={
765 <div className="flex items-center gap-2">
766 <ProjectFavicon
767 environmentId={representative.environmentId}
768 cwd={representative.workspaceRoot}
769 faviconPath={faviconPath}
770 className="size-6"
771 />
772 <Button
773 size="xs"
774 variant="outline"
775 type="button"
776 aria-label="Choose a project icon file"
777 disabled={isSavingFavicon}
778 onClick={() => setFaviconPickerOpen(true)}
779 >
780 Choose file
781 </Button>
782 </div>
783 }
784 />
785 </SettingsSection>
786
726787 <SettingsSection title="Model">
727788 <SettingsRow
728789 id="project-default-model"
......@@ -1068,6 +1129,15 @@ function ProjectDetail({
10681129 onDelete={deleteScript}
10691130 onClose={() => setEditorRequest(null)}
10701131 />
1132 <ProjectFaviconPickerDialog
1133 key={`${representative.environmentId}:${representative.workspaceRoot}:${faviconPickerOpen}`}
1134 cwd={representative.workspaceRoot}
1135 environmentId={representative.environmentId}
1136 onOpenChange={setFaviconPickerOpen}
1137 onSelect={(path) => void setFaviconPath(path)}
1138 open={faviconPickerOpen}
1139 projectName={group.displayName}
1140 />
10711141 </SettingsPageContainer>
10721142 );
10731143}
apps/web/src/components/settings/SettingsPanels.tsx+8-1
......@@ -2258,6 +2258,7 @@ export function ArchivedThreadsPanel() {
22582258 environmentId,
22592259 name: project.title,
22602260 cwd: project.workspaceRoot,
2261 faviconPath: project.faviconPath,
22612262 },
22622263 ] as const,
22632264 ),
......@@ -2379,7 +2380,13 @@ export function ArchivedThreadsPanel() {
23792380 key={project.id}
23802381 id={index === 0 ? searchableSetting("archive").id : undefined}
23812382 title={project.name}
2382 icon={<ProjectFavicon environmentId={project.environmentId} cwd={project.cwd} />}
2383 icon={
2384 <ProjectFavicon
2385 environmentId={project.environmentId}
2386 cwd={project.cwd}
2387 faviconPath={project.faviconPath}
2388 />
2389 }
23832390 >
23842391 {projectThreads.map((thread) => (
23852392 <SettingsRow
apps/web/src/components/settings/settingsLayout.tsx+10-1
......@@ -199,7 +199,15 @@ export function SettingsRow({
199199 );
200200}
201201
202export function SettingResetButton({ label, onClick }: { label: string; onClick: () => void }) {
202export function SettingResetButton({
203 label,
204 disabled = false,
205 onClick,
206}: {
207 label: string;
208 disabled?: boolean;
209 onClick: () => void;
210}) {
203211 return (
204212 <Tooltip>
205213 <TooltipTrigger
......@@ -208,6 +216,7 @@ export function SettingResetButton({ label, onClick }: { label: string; onClick:
208216 size="icon-xs"
209217 variant="ghost"
210218 aria-label={`Reset ${label} to default`}
219 disabled={disabled}
211220 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"
212221 onClick={(event) => {
213222 event.stopPropagation();
apps/web/src/state/queries.test.ts+2-1
......@@ -10,7 +10,7 @@ describe("areProjectPathSearchTargetsEqual", () => {
1010 query: "index",
1111 };
1212
13 it("requires the environment, workspace, query, and entry kind to match", () => {
13 it("requires the environment, workspace, query, entry kind, and image filter to match", () => {
1414 expect(areProjectPathSearchTargetsEqual(target, target)).toBe(true);
1515 expect(
1616 areProjectPathSearchTargetsEqual(target, {
......@@ -21,5 +21,6 @@ describe("areProjectPathSearchTargetsEqual", () => {
2121 expect(areProjectPathSearchTargetsEqual(target, { ...target, cwd: "/project-b" })).toBe(false);
2222 expect(areProjectPathSearchTargetsEqual(target, { ...target, query: "readme" })).toBe(false);
2323 expect(areProjectPathSearchTargetsEqual(target, { ...target, kind: "file" })).toBe(false);
24 expect(areProjectPathSearchTargetsEqual(target, { ...target, imageOnly: true })).toBe(false);
2425 });
2526});
apps/web/src/state/queries.ts+6-2
......@@ -236,6 +236,7 @@ export function usePaginatedBranches(target: VcsRefTarget) {
236236
237237type ProjectPathSearchTarget = ComposerPathSearchTarget & {
238238 readonly kind?: ProjectEntryKind | undefined;
239 readonly imageOnly?: boolean | undefined;
239240};
240241
241242export function areProjectPathSearchTargetsEqual(
......@@ -246,7 +247,8 @@ export function areProjectPathSearchTargetsEqual(
246247 left.environmentId === right.environmentId &&
247248 left.cwd === right.cwd &&
248249 left.query === right.query &&
249 left.kind === right.kind
250 left.kind === right.kind &&
251 left.imageOnly === right.imageOnly
250252 );
251253}
252254
......@@ -262,8 +264,9 @@ export function useProjectPathSearch(
262264 cwd: target.cwd,
263265 query: target.query == null ? null : target.query.trim(),
264266 kind: target.kind,
267 imageOnly: target.imageOnly,
265268 }),
266 [target.cwd, target.environmentId, target.kind, target.query],
269 [target.cwd, target.environmentId, target.imageOnly, target.kind, target.query],
267270 );
268271 const debouncedTarget = useDebouncedValue(normalizedTarget, PROJECT_PATH_SEARCH_DEBOUNCE_MS);
269272 const result = useEnvironmentQuery(
......@@ -278,6 +281,7 @@ export function useProjectPathSearch(
278281 query: debouncedTarget.query,
279282 limit,
280283 ...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}),
284 ...(debouncedTarget.imageOnly ? { imageOnly: true } : {}),
281285 },
282286 })
283287 : null,
docs/README.md+1
......@@ -6,6 +6,7 @@
66- [Permission modes](./user/permission-modes.md)
77- [Keyboard shortcuts](./user/keybindings.md)
88- [Organizing threads](./user/thread-sidebar.md)
9- [Customize a project icon](./user/project-settings.md)
910- [Remote access](./user/remote-access.md)
1011- [Keeping app and server in sync](./user/updating.md)
1112- [Source control integrations](./user/source-control.md)
docs/user/project-settings.md created+16
......@@ -0,0 +1,16 @@
1# Customize a project icon
2
3T3 Code selects a project icon automatically. It checks `t3.json`, common favicon and app icon
4paths, and icon links in project HTML files.
5
6To choose a different icon:
7
81. Open **Settings** and select **Projects**.
92. Select the project.
103. Under **Appearance**, select **Choose a project file**.
114. Search for an image file and select it.
12
13T3 Code supports SVG, PNG, ICO, JPEG, GIF, AVIF, and WebP files. The selected path applies to
14each checkout in the project group and appears on your connected clients.
15
16To use automatic detection again, select **Automatic**.
packages/client-runtime/src/state/assets.test.ts+12
......@@ -72,6 +72,18 @@ describe("createAssetEnvironmentAtoms", () => {
7272 },
7373 }),
7474 ).not.toBe(assets.createUrl(originalTarget));
75 expect(
76 assets.createUrl({
77 environmentId,
78 input: {
79 resource: {
80 _tag: "project-favicon",
81 cwd: "/repo/original",
82 path: "brand/icon.svg",
83 },
84 },
85 }),
86 ).not.toBe(assets.createUrl(originalTarget));
7587 expect(
7688 assets.createUrl({
7789 environmentId: EnvironmentId.make("environment-2"),
packages/contracts/src/assets.ts+7
......@@ -1,6 +1,7 @@
11import * as Schema from "effect/Schema";
22
33import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
4import { ProjectFaviconPath } from "./orchestration.ts";
45
56const ASSET_PATH_MAX_LENGTH = 1024;
67
......@@ -14,6 +15,9 @@ export const AssetResource = Schema.Union([
1415 }),
1516 Schema.TaggedStruct("project-favicon", {
1617 cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
18 // A cache-key hint only. The server reads the authoritative path from the
19 // project projection before it issues the signed URL.
20 path: Schema.optional(ProjectFaviconPath),
1721 }),
1822]);
1923export type AssetResource = typeof AssetResource.Type;
......@@ -26,6 +30,9 @@ export type AssetCreateUrlInput = typeof AssetCreateUrlInput.Type;
2630export const AssetCreateUrlResult = Schema.Struct({
2731 relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)),
2832 expiresAt: Schema.Number,
33 sourcePath: Schema.optional(
34 TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
35 ),
2936});
3037export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type;
3138
packages/contracts/src/orchestration.test.ts+22
......@@ -913,3 +913,25 @@ it.effect("ModelSelection rejects malformed instance ids", () =>
913913 assert.strictEqual(result._tag, "Failure");
914914 }),
915915);
916
917it.effect("project favicon overrides accept only supported image files", () =>
918 Effect.gen(function* () {
919 const valid = yield* decodeOrchestrationCommand({
920 type: "project.meta.update",
921 commandId: "cmd-project-favicon",
922 projectId: "project-1",
923 faviconPath: "brand/icon.svg",
924 });
925 assert.strictEqual(valid.type, "project.meta.update");
926
927 const invalid = yield* Effect.exit(
928 decodeOrchestrationCommand({
929 type: "project.meta.update",
930 commandId: "cmd-project-secret",
931 projectId: "project-1",
932 faviconPath: ".env",
933 }),
934 );
935 assert.strictEqual(invalid._tag, "Failure");
936 }),
937);
packages/contracts/src/orchestration.ts+14
......@@ -211,6 +211,12 @@ export const ProjectScript = Schema.Struct({
211211});
212212export type ProjectScript = typeof ProjectScript.Type;
213213
214export const ProjectFaviconPath = TrimmedNonEmptyString.check(
215 Schema.isMaxLength(1024),
216 Schema.isPattern(/\.(?:avif|gif|ico|jpe?g|png|svg|webp)$/i),
217);
218export type ProjectFaviconPath = typeof ProjectFaviconPath.Type;
219
214220export const OrchestrationProject = Schema.Struct({
215221 id: ProjectId,
216222 title: TrimmedNonEmptyString,
......@@ -220,6 +226,8 @@ export const OrchestrationProject = Schema.Struct({
220226 // Per-project override for where new threads start. Null/absent means
221227 // "no override": clients fall back to t3.json, then the global setting.
222228 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
229 // Optional on the wire so cached snapshots from older servers still decode.
230 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
223231 scripts: Schema.Array(ProjectScript),
224232 createdAt: IsoDateTime,
225233 updatedAt: IsoDateTime,
......@@ -415,6 +423,8 @@ export const OrchestrationProjectShell = Schema.Struct({
415423 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
416424 defaultModelSelection: Schema.NullOr(ModelSelection),
417425 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
426 // Optional on the wire so cached snapshots from older servers still decode.
427 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
418428 scripts: Schema.Array(ProjectScript),
419429 createdAt: IsoDateTime,
420430 updatedAt: IsoDateTime,
......@@ -629,6 +639,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({
629639 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
630640 // Absent = leave unchanged; null = clear the override.
631641 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
642 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
632643 scripts: Schema.optional(Schema.Array(ProjectScript)),
633644});
634645
......@@ -1073,6 +1084,8 @@ export const ProjectCreatedPayload = Schema.Struct({
10731084 workspaceRoot: TrimmedNonEmptyString,
10741085 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
10751086 defaultModelSelection: Schema.NullOr(ModelSelection),
1087 // Optional so persisted events from older servers still decode.
1088 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
10761089 scripts: Schema.Array(ProjectScript),
10771090 createdAt: IsoDateTime,
10781091 updatedAt: IsoDateTime,
......@@ -1085,6 +1098,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({
10851098 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
10861099 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
10871100 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
1101 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
10881102 scripts: Schema.optional(Schema.Array(ProjectScript)),
10891103 updatedAt: IsoDateTime,
10901104});
packages/contracts/src/project.ts+1
......@@ -21,6 +21,7 @@ export const ProjectSearchEntriesInput = Schema.Struct({
2121 query: TrimmedString.check(Schema.isMaxLength(256)),
2222 limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT)),
2323 kind: Schema.optional(ProjectEntryKind),
24 imageOnly: Schema.optional(Schema.Boolean),
2425});
2526export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type;
2627