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....@@ -68,6 +68,7 @@ Full docs live in [docs/](./docs). There's no docs site yet.
68- [Install and first run](./docs/user/install.md)68- [Install and first run](./docs/user/install.md)
69- [Permission modes](./docs/user/permission-modes.md)69- [Permission modes](./docs/user/permission-modes.md)
70- [Keyboard shortcuts](./docs/user/keybindings.md)70- [Keyboard shortcuts](./docs/user/keybindings.md)
71- [Customize a project icon](./docs/user/project-settings.md)
71- [Remote access from a phone or another machine](./docs/user/remote-access.md)72- [Remote access from a phone or another machine](./docs/user/remote-access.md)
72- [Keeping app and server in sync](./docs/user/updating.md)73- [Keeping app and server in sync](./docs/user/updating.md)
73- [Source control integrations](./docs/user/source-control.md)74- [Source control integrations](./docs/user/source-control.md)
apps/mobile/src/components/ProjectFavicon.tsx+6-1
...@@ -24,13 +24,18 @@ export function ProjectFavicon(props: {...@@ -24,13 +24,18 @@ export function ProjectFavicon(props: {
24 readonly size?: number;24 readonly size?: number;
25 readonly projectTitle: string;25 readonly projectTitle: string;
26 readonly workspaceRoot?: string | null;26 readonly workspaceRoot?: string | null;
27 readonly faviconPath?: string | null;
27}) {28}) {
28 const size = props.size ?? 42;29 const size = props.size ?? 42;
29 const faviconUrl = useAssetUrl(30 const faviconUrl = useAssetUrl(
30 props.environmentId,31 props.environmentId,
31 props.workspaceRoot === null || props.workspaceRoot === undefined32 props.workspaceRoot === null || props.workspaceRoot === undefined
32 ? null33 ? null
33 : { _tag: "project-favicon", cwd: props.workspaceRoot },34 : {
35 _tag: "project-favicon",
36 cwd: props.workspaceRoot,
37 ...(props.faviconPath ? { path: props.faviconPath } : {}),
38 },
34 );39 );
35 const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl;40 const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl;
36 const cacheKey =41 const cacheKey =
apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx+1
...@@ -368,6 +368,7 @@ function ProjectGroupLabel(props: {...@@ -368,6 +368,7 @@ function ProjectGroupLabel(props: {
368 <View className="flex-row items-center gap-2.5 px-1 pb-2">368 <View className="flex-row items-center gap-2.5 px-1 pb-2">
369 <ProjectFavicon369 <ProjectFavicon
370 environmentId={props.project.environmentId}370 environmentId={props.project.environmentId}
371 faviconPath={props.project.faviconPath}
371 projectTitle={props.project.title}372 projectTitle={props.project.title}
372 size={18}373 size={18}
373 workspaceRoot={props.project.workspaceRoot}374 workspaceRoot={props.project.workspaceRoot}
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+2
...@@ -296,6 +296,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -296,6 +296,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
296 <View className="h-7 w-7 items-center justify-center">296 <View className="h-7 w-7 items-center justify-center">
297 <ProjectFavicon297 <ProjectFavicon
298 environmentId={scope.representative.environmentId}298 environmentId={scope.representative.environmentId}
299 faviconPath={scope.representative.faviconPath}
299 size={20}300 size={20}
300 projectTitle={scope.title}301 projectTitle={scope.title}
301 workspaceRoot={scope.representative.workspaceRoot}302 workspaceRoot={scope.representative.workspaceRoot}
...@@ -330,6 +331,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -330,6 +331,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
330 >331 >
331 <ProjectFavicon332 <ProjectFavicon
332 environmentId={project.environmentId}333 environmentId={project.environmentId}
334 faviconPath={project.faviconPath}
333 size={18}335 size={18}
334 projectTitle={project.title}336 projectTitle={project.title}
335 workspaceRoot={project.workspaceRoot}337 workspaceRoot={project.workspaceRoot}
apps/mobile/src/features/threads/thread-list-items.tsx+1
...@@ -134,6 +134,7 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props:...@@ -134,6 +134,7 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props:
134 >134 >
135 <ProjectFavicon135 <ProjectFavicon
136 environmentId={props.project.environmentId}136 environmentId={props.project.environmentId}
137 faviconPath={props.project.faviconPath}
137 open={!props.collapsed}138 open={!props.collapsed}
138 size={compact ? 22 : 18}139 size={compact ? 22 : 18}
139 projectTitle={props.project.title}140 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...@@ -230,6 +230,7 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
230 {props.project ? (230 {props.project ? (
231 <ProjectFavicon231 <ProjectFavicon
232 environmentId={pendingTask.message.environmentId}232 environmentId={pendingTask.message.environmentId}
233 faviconPath={props.project.faviconPath}
233 size={15}234 size={15}
234 projectTitle={projectTitle}235 projectTitle={projectTitle}
235 workspaceRoot={props.project.workspaceRoot}236 workspaceRoot={props.project.workspaceRoot}
...@@ -630,6 +631,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -630,6 +631,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
630 {props.project ? (631 {props.project ? (
631 <ProjectFavicon632 <ProjectFavicon
632 environmentId={thread.environmentId}633 environmentId={thread.environmentId}
634 faviconPath={props.project.faviconPath}
633 size={15}635 size={15}
634 projectTitle={props.projectTitle ?? props.project.title}636 projectTitle={props.projectTitle ?? props.project.title}
635 workspaceRoot={props.project.workspaceRoot}637 workspaceRoot={props.project.workspaceRoot}
...@@ -818,6 +820,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -818,6 +820,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
818 <View className="opacity-40">820 <View className="opacity-40">
819 <ProjectFavicon821 <ProjectFavicon
820 environmentId={thread.environmentId}822 environmentId={thread.environmentId}
823 faviconPath={props.project.faviconPath}
821 size={15}824 size={15}
822 projectTitle={props.projectTitle ?? props.project.title}825 projectTitle={props.projectTitle ?? props.project.title}
823 workspaceRoot={props.project.workspaceRoot}826 workspaceRoot={props.project.workspaceRoot}
apps/server/src/assets/AssetAccess.test.ts+83-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { ThreadId } from "@t3tools/contracts";2import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts";
3import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";3import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
4import { describe, expect, it } from "@effect/vitest";4import { describe, expect, it } from "@effect/vitest";
5import * as Crypto from "effect/Crypto";5import * as Crypto from "effect/Crypto";
...@@ -225,6 +225,7 @@ describe("AssetAccess", () => {...@@ -225,6 +225,7 @@ describe("AssetAccess", () => {
225 const faviconResult = yield* issueAssetUrl({225 const faviconResult = yield* issueAssetUrl({
226 resource: { _tag: "project-favicon", cwd: root },226 resource: { _tag: "project-favicon", cwd: root },
227 });227 });
228 expect(faviconResult.sourcePath).toBe("favicon.svg");
228 expect(faviconResult.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/);229 expect(faviconResult.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/);
229 expect(230 expect(
230 yield* issueAssetUrl({231 yield* issueAssetUrl({
...@@ -253,6 +254,7 @@ describe("AssetAccess", () => {...@@ -253,6 +254,7 @@ describe("AssetAccess", () => {
253 resource: { _tag: "project-favicon", cwd: root },254 resource: { _tag: "project-favicon", cwd: root },
254 });255 });
255 expect(fallbackResult.relativeUrl.endsWith(`/${PROJECT_FAVICON_FALLBACK_MARKER}`)).toBe(true);256 expect(fallbackResult.relativeUrl.endsWith(`/${PROJECT_FAVICON_FALLBACK_MARKER}`)).toBe(true);
257 expect(fallbackResult.sourcePath).toBeUndefined();
256 const fallbackSuffix = fallbackResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);258 const fallbackSuffix = fallbackResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
257 const fallbackSeparatorIndex = fallbackSuffix.indexOf("/");259 const fallbackSeparatorIndex = fallbackSuffix.indexOf("/");
258 expect(260 expect(
...@@ -264,6 +266,86 @@ describe("AssetAccess", () => {...@@ -264,6 +266,86 @@ describe("AssetAccess", () => {
264 }).pipe(Effect.provide(testLayer)),266 }).pipe(Effect.provide(testLayer)),
265 );267 );
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
267 it.effect("buckets project favicon expiry after content hashing", () =>349 it.effect("buckets project favicon expiry after content hashing", () =>
268 Effect.gen(function* () {350 Effect.gen(function* () {
269 const crypto = yield* Crypto.Crypto;351 const crypto = yield* Crypto.Crypto;
apps/server/src/assets/AssetAccess.ts+18-9
...@@ -169,6 +169,7 @@ const resolveCanonicalWorkspaceFileForRequest = (input: {...@@ -169,6 +169,7 @@ const resolveCanonicalWorkspaceFileForRequest = (input: {
169export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: {169export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: {
170 readonly resource: AssetResource;170 readonly resource: AssetResource;
171 readonly workspaceRoot?: string;171 readonly workspaceRoot?: string;
172 readonly projectFaviconPath?: string;
172}) {173}) {
173 const fileSystem = yield* FileSystem.FileSystem;174 const fileSystem = yield* FileSystem.FileSystem;
174 const path = yield* Path.Path;175 const path = yield* Path.Path;
...@@ -176,6 +177,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -176,6 +177,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
176 let expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS;177 let expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS;
177 let claims: AssetClaims;178 let claims: AssetClaims;
178 let fileName: string;179 let fileName: string;
180 let sourcePath: string | undefined;
179181
180 switch (input.resource._tag) {182 switch (input.resource._tag) {
181 case "workspace-file": {183 case "workspace-file": {
...@@ -287,16 +289,22 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -287,16 +289,22 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
287 ),289 ),
288 );290 );
289 const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;291 const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
290 const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot).pipe(292 const faviconPath = yield* faviconResolver
291 Effect.mapError(293 .resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined)
292 (cause) =>294 .pipe(
293 new AssetProjectFaviconResolutionError({295 Effect.mapError(
294 resource: input.resource,296 (cause) =>
295 cause,297 new AssetProjectFaviconResolutionError({
296 }),298 resource: input.resource,
297 ),299 cause,
298 );300 }),
301 ),
302 );
299 const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null;303 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;
300 const canonicalFaviconPath = relativePath308 const canonicalFaviconPath = relativePath
301 ? yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe(309 ? yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe(
302 Effect.mapError(310 Effect.mapError(
...@@ -379,6 +387,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -379,6 +387,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
379 return {387 return {
380 relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`,388 relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`,
381 expiresAt,389 expiresAt,
390 ...(sourcePath !== undefined ? { sourcePath } : {}),
382 };391 };
383});392});
384393
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+5-1
...@@ -2764,15 +2764,18 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {...@@ -2764,15 +2764,18 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
2764 instanceId: ProviderInstanceId.make("codex"),2764 instanceId: ProviderInstanceId.make("codex"),
2765 model: "gpt-5",2765 model: "gpt-5",
2766 },2766 },
2767 faviconPath: "brand/icon.svg",
2767 });2768 });
27682769
2769 const projectRows = yield* sql<{2770 const projectRows = yield* sql<{
2770 readonly scriptsJson: string;2771 readonly scriptsJson: string;
2771 readonly defaultModelSelection: string;2772 readonly defaultModelSelection: string;
2773 readonly faviconPath: string | null;
2772 }>`2774 }>`
2773 SELECT2775 SELECT
2774 scripts_json AS "scriptsJson",2776 scripts_json AS "scriptsJson",
2775 default_model_selection_json AS "defaultModelSelection"2777 default_model_selection_json AS "defaultModelSelection",
2778 favicon_path AS "faviconPath"
2776 FROM projection_projects2779 FROM projection_projects
2777 WHERE project_id = 'project-scripts'2780 WHERE project_id = 'project-scripts'
2778 `;2781 `;
...@@ -2781,6 +2784,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {...@@ -2781,6 +2784,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
2781 scriptsJson:2784 scriptsJson:
2782 '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]',2785 '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]',
2783 defaultModelSelection: '{"instanceId":"codex","model":"gpt-5"}',2786 defaultModelSelection: '{"instanceId":"codex","model":"gpt-5"}',
2787 faviconPath: "brand/icon.svg",
2784 },2788 },
2785 ]);2789 ]);
2786 }),2790 }),
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+4
...@@ -496,6 +496,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -496,6 +496,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
496 workspaceRoot: event.payload.workspaceRoot,496 workspaceRoot: event.payload.workspaceRoot,
497 defaultModelSelection: event.payload.defaultModelSelection,497 defaultModelSelection: event.payload.defaultModelSelection,
498 defaultThreadEnvMode: null,498 defaultThreadEnvMode: null,
499 faviconPath: event.payload.faviconPath ?? null,
499 scripts: event.payload.scripts,500 scripts: event.payload.scripts,
500 createdAt: event.payload.createdAt,501 createdAt: event.payload.createdAt,
501 updatedAt: event.payload.updatedAt,502 updatedAt: event.payload.updatedAt,
...@@ -522,6 +523,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -522,6 +523,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
522 ...(event.payload.defaultThreadEnvMode !== undefined523 ...(event.payload.defaultThreadEnvMode !== undefined
523 ? { defaultThreadEnvMode: event.payload.defaultThreadEnvMode }524 ? { defaultThreadEnvMode: event.payload.defaultThreadEnvMode }
524 : {}),525 : {}),
526 ...(event.payload.faviconPath !== undefined
527 ? { faviconPath: event.payload.faviconPath }
528 : {}),
525 ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}),529 ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}),
526 updatedAt: event.payload.updatedAt,530 updatedAt: event.payload.updatedAt,
527 });531 });
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2
...@@ -275,6 +275,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -275,6 +275,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
275 instanceId: ProviderInstanceId.make("codex"),275 instanceId: ProviderInstanceId.make("codex"),
276 model: "gpt-5-codex",276 model: "gpt-5-codex",
277 },277 },
278 faviconPath: null,
278 scripts: [279 scripts: [
279 {280 {
280 id: "script-1",281 id: "script-1",
...@@ -394,6 +395,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -394,6 +395,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
394 instanceId: ProviderInstanceId.make("codex"),395 instanceId: ProviderInstanceId.make("codex"),
395 model: "gpt-5-codex",396 model: "gpt-5-codex",
396 },397 },
398 faviconPath: null,
397 scripts: [399 scripts: [
398 {400 {
399 id: "script-1",401 id: "script-1",
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+7
...@@ -317,6 +317,7 @@ function mapProjectShellRow(...@@ -317,6 +317,7 @@ function mapProjectShellRow(
317 repositoryIdentity,317 repositoryIdentity,
318 defaultModelSelection: row.defaultModelSelection,318 defaultModelSelection: row.defaultModelSelection,
319 defaultThreadEnvMode: row.defaultThreadEnvMode,319 defaultThreadEnvMode: row.defaultThreadEnvMode,
320 faviconPath: row.faviconPath ?? null,
320 scripts: row.scripts,321 scripts: row.scripts,
321 createdAt: row.createdAt,322 createdAt: row.createdAt,
322 updatedAt: row.updatedAt,323 updatedAt: row.updatedAt,
...@@ -393,6 +394,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -393,6 +394,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
393 workspace_root AS "workspaceRoot",394 workspace_root AS "workspaceRoot",
394 default_model_selection_json AS "defaultModelSelection",395 default_model_selection_json AS "defaultModelSelection",
395 default_thread_env_mode AS "defaultThreadEnvMode",396 default_thread_env_mode AS "defaultThreadEnvMode",
397 favicon_path AS "faviconPath",
396 scripts_json AS "scripts",398 scripts_json AS "scripts",
397 created_at AS "createdAt",399 created_at AS "createdAt",
398 updated_at AS "updatedAt",400 updated_at AS "updatedAt",
...@@ -847,6 +849,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -847,6 +849,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
847 workspace_root AS "workspaceRoot",849 workspace_root AS "workspaceRoot",
848 default_model_selection_json AS "defaultModelSelection",850 default_model_selection_json AS "defaultModelSelection",
849 default_thread_env_mode AS "defaultThreadEnvMode",851 default_thread_env_mode AS "defaultThreadEnvMode",
852 favicon_path AS "faviconPath",
850 scripts_json AS "scripts",853 scripts_json AS "scripts",
851 created_at AS "createdAt",854 created_at AS "createdAt",
852 updated_at AS "updatedAt",855 updated_at AS "updatedAt",
...@@ -870,6 +873,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -870,6 +873,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
870 workspace_root AS "workspaceRoot",873 workspace_root AS "workspaceRoot",
871 default_model_selection_json AS "defaultModelSelection",874 default_model_selection_json AS "defaultModelSelection",
872 default_thread_env_mode AS "defaultThreadEnvMode",875 default_thread_env_mode AS "defaultThreadEnvMode",
876 favicon_path AS "faviconPath",
873 scripts_json AS "scripts",877 scripts_json AS "scripts",
874 created_at AS "createdAt",878 created_at AS "createdAt",
875 updated_at AS "updatedAt",879 updated_at AS "updatedAt",
...@@ -1547,6 +1551,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1547,6 +1551,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1547 repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null,1551 repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null,
1548 defaultModelSelection: row.defaultModelSelection,1552 defaultModelSelection: row.defaultModelSelection,
1549 defaultThreadEnvMode: row.defaultThreadEnvMode,1553 defaultThreadEnvMode: row.defaultThreadEnvMode,
1554 faviconPath: row.faviconPath ?? null,
1550 scripts: row.scripts,1555 scripts: row.scripts,
1551 createdAt: row.createdAt,1556 createdAt: row.createdAt,
1552 updatedAt: row.updatedAt,1557 updatedAt: row.updatedAt,
...@@ -1677,6 +1682,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1677,6 +1682,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1677 workspaceRoot: row.workspaceRoot,1682 workspaceRoot: row.workspaceRoot,
1678 defaultModelSelection: row.defaultModelSelection,1683 defaultModelSelection: row.defaultModelSelection,
1679 defaultThreadEnvMode: row.defaultThreadEnvMode,1684 defaultThreadEnvMode: row.defaultThreadEnvMode,
1685 faviconPath: row.faviconPath ?? null,
1680 scripts: row.scripts,1686 scripts: row.scripts,
1681 createdAt: row.createdAt,1687 createdAt: row.createdAt,
1682 updatedAt: row.updatedAt,1688 updatedAt: row.updatedAt,
...@@ -2169,6 +2175,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2169,6 +2175,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2169 repositoryIdentity,2175 repositoryIdentity,
2170 defaultModelSelection: option.value.defaultModelSelection,2176 defaultModelSelection: option.value.defaultModelSelection,
2171 defaultThreadEnvMode: option.value.defaultThreadEnvMode,2177 defaultThreadEnvMode: option.value.defaultThreadEnvMode,
2178 faviconPath: option.value.faviconPath ?? null,
2172 scripts: option.value.scripts,2179 scripts: option.value.scripts,
2173 createdAt: option.value.createdAt,2180 createdAt: option.value.createdAt,
2174 updatedAt: option.value.updatedAt,2181 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) => {...@@ -94,6 +94,47 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => {
94 }),94 }),
95 );95 );
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
97 it.effect("rejects project.create for an active workspace root that already exists", () =>138 it.effect("rejects project.create for an active workspace root that already exists", () =>
98 Effect.gen(function* () {139 Effect.gen(function* () {
99 const now = "2026-01-01T00:00:00.000Z";140 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"...@@ -250,6 +250,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
250 title: command.title,250 title: command.title,
251 workspaceRoot: command.workspaceRoot,251 workspaceRoot: command.workspaceRoot,
252 defaultModelSelection: command.defaultModelSelection ?? null,252 defaultModelSelection: command.defaultModelSelection ?? null,
253 faviconPath: null,
253 scripts: [],254 scripts: [],
254 createdAt: command.createdAt,255 createdAt: command.createdAt,
255 updatedAt: command.createdAt,256 updatedAt: command.createdAt,
...@@ -290,6 +291,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -290,6 +291,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
290 ...(command.defaultThreadEnvMode !== undefined291 ...(command.defaultThreadEnvMode !== undefined
291 ? { defaultThreadEnvMode: command.defaultThreadEnvMode }292 ? { defaultThreadEnvMode: command.defaultThreadEnvMode }
292 : {}),293 : {}),
294 ...(command.faviconPath !== undefined ? { faviconPath: command.faviconPath } : {}),
293 ...(command.scripts !== undefined ? { scripts: command.scripts } : {}),295 ...(command.scripts !== undefined ? { scripts: command.scripts } : {}),
294 updatedAt: occurredAt,296 updatedAt: occurredAt,
295 },297 },
apps/server/src/orchestration/projector.ts+4
...@@ -215,6 +215,7 @@ export function projectEvent(...@@ -215,6 +215,7 @@ export function projectEvent(
215 workspaceRoot: payload.workspaceRoot,215 workspaceRoot: payload.workspaceRoot,
216 defaultModelSelection: payload.defaultModelSelection,216 defaultModelSelection: payload.defaultModelSelection,
217 defaultThreadEnvMode: null,217 defaultThreadEnvMode: null,
218 faviconPath: payload.faviconPath ?? null,
218 scripts: payload.scripts,219 scripts: payload.scripts,
219 createdAt: payload.createdAt,220 createdAt: payload.createdAt,
220 updatedAt: payload.updatedAt,221 updatedAt: payload.updatedAt,
...@@ -250,6 +251,9 @@ export function projectEvent(...@@ -250,6 +251,9 @@ export function projectEvent(
250 ...(payload.defaultThreadEnvMode !== undefined251 ...(payload.defaultThreadEnvMode !== undefined
251 ? { defaultThreadEnvMode: payload.defaultThreadEnvMode }252 ? { defaultThreadEnvMode: payload.defaultThreadEnvMode }
252 : {}),253 : {}),
254 ...(payload.faviconPath !== undefined
255 ? { faviconPath: payload.faviconPath }
256 : {}),
253 ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}),257 ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}),
254 updatedAt: payload.updatedAt,258 updatedAt: payload.updatedAt,
255 }259 }
apps/server/src/persistence/Layers/ProjectionProjects.ts+5
...@@ -36,6 +36,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {...@@ -36,6 +36,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
36 workspace_root,36 workspace_root,
37 default_model_selection_json,37 default_model_selection_json,
38 default_thread_env_mode,38 default_thread_env_mode,
39 favicon_path,
39 scripts_json,40 scripts_json,
40 created_at,41 created_at,
41 updated_at,42 updated_at,
...@@ -47,6 +48,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {...@@ -47,6 +48,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
47 ${row.workspaceRoot},48 ${row.workspaceRoot},
48 ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null},49 ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null},
49 ${row.defaultThreadEnvMode},50 ${row.defaultThreadEnvMode},
51 ${row.faviconPath ?? null},
50 ${JSON.stringify(row.scripts)},52 ${JSON.stringify(row.scripts)},
51 ${row.createdAt},53 ${row.createdAt},
52 ${row.updatedAt},54 ${row.updatedAt},
...@@ -58,6 +60,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {...@@ -58,6 +60,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
58 workspace_root = excluded.workspace_root,60 workspace_root = excluded.workspace_root,
59 default_model_selection_json = excluded.default_model_selection_json,61 default_model_selection_json = excluded.default_model_selection_json,
60 default_thread_env_mode = excluded.default_thread_env_mode,62 default_thread_env_mode = excluded.default_thread_env_mode,
63 favicon_path = excluded.favicon_path,
61 scripts_json = excluded.scripts_json,64 scripts_json = excluded.scripts_json,
62 created_at = excluded.created_at,65 created_at = excluded.created_at,
63 updated_at = excluded.updated_at,66 updated_at = excluded.updated_at,
...@@ -76,6 +79,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {...@@ -76,6 +79,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
76 workspace_root AS "workspaceRoot",79 workspace_root AS "workspaceRoot",
77 default_model_selection_json AS "defaultModelSelection",80 default_model_selection_json AS "defaultModelSelection",
78 default_thread_env_mode AS "defaultThreadEnvMode",81 default_thread_env_mode AS "defaultThreadEnvMode",
82 favicon_path AS "faviconPath",
79 scripts_json AS "scripts",83 scripts_json AS "scripts",
80 created_at AS "createdAt",84 created_at AS "createdAt",
81 updated_at AS "updatedAt",85 updated_at AS "updatedAt",
...@@ -96,6 +100,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {...@@ -96,6 +100,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
96 workspace_root AS "workspaceRoot",100 workspace_root AS "workspaceRoot",
97 default_model_selection_json AS "defaultModelSelection",101 default_model_selection_json AS "defaultModelSelection",
98 default_thread_env_mode AS "defaultThreadEnvMode",102 default_thread_env_mode AS "defaultThreadEnvMode",
103 favicon_path AS "faviconPath",
99 scripts_json AS "scripts",104 scripts_json AS "scripts",
100 created_at AS "createdAt",105 created_at AS "createdAt",
101 updated_at AS "updatedAt",106 updated_at AS "updatedAt",
apps/server/src/persistence/Migrations.ts+2
...@@ -52,6 +52,7 @@ import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts";...@@ -52,6 +52,7 @@ import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts";
52import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts";52import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts";
53import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts";53import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts";
54import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts";54import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts";
55import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts";
5556
56/**57/**
57 * Migration loader with all migrations defined inline.58 * Migration loader with all migrations defined inline.
...@@ -103,6 +104,7 @@ export const migrationEntries = [...@@ -103,6 +104,7 @@ export const migrationEntries = [
103 [37, "ProjectionTurnsKeysetIndex", Migration0037],104 [37, "ProjectionTurnsKeysetIndex", Migration0037],
104 [38, "ProjectionThreadsPinOrderKey", Migration0038],105 [38, "ProjectionThreadsPinOrderKey", Migration0038],
105 [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039],106 [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039],
107 [40, "ProjectionProjectFaviconPath", Migration0040],
106] as const;108] as const;
107109
108export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);110export 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({...@@ -26,6 +26,7 @@ export const ProjectionProject = Schema.Struct({
26 workspaceRoot: Schema.String,26 workspaceRoot: Schema.String,
27 defaultModelSelection: Schema.NullOr(ModelSelection),27 defaultModelSelection: Schema.NullOr(ModelSelection),
28 defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode),28 defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode),
29 faviconPath: Schema.optional(Schema.NullOr(Schema.String)),
29 scripts: Schema.Array(ProjectScript),30 scripts: Schema.Array(ProjectScript),
30 createdAt: IsoDateTime,31 createdAt: IsoDateTime,
31 updatedAt: IsoDateTime,32 updatedAt: IsoDateTime,
apps/server/src/project/ProjectFaviconResolver.test.ts+27
...@@ -77,6 +77,33 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {...@@ -77,6 +77,33 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
77 }),77 }),
78 );78 );
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
80 it.effect("falls back to well-known files when the t3.json iconPath does not exist", () =>107 it.effect("falls back to well-known files when the t3.json iconPath does not exist", () =>
81 Effect.gen(function* () {108 Effect.gen(function* () {
82 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;109 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
apps/server/src/project/ProjectFaviconResolver.ts+11-1
...@@ -91,6 +91,7 @@ export class ProjectFaviconResolver extends Context.Service<...@@ -91,6 +91,7 @@ export class ProjectFaviconResolver extends Context.Service<
91 */91 */
92 readonly resolvePath: (92 readonly resolvePath: (
93 cwd: string,93 cwd: string,
94 faviconPath?: string,
94 ) => Effect.Effect<string | null, ProjectFaviconResolutionError>;95 ) => Effect.Effect<string | null, ProjectFaviconResolutionError>;
95 }96 }
96>()("t3/project/ProjectFaviconResolver") {}97>()("t3/project/ProjectFaviconResolver") {}
...@@ -168,7 +169,7 @@ export const make = Effect.gen(function* () {...@@ -168,7 +169,7 @@ export const make = Effect.gen(function* () {
168169
169 const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn(170 const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn(
170 "ProjectFaviconResolver.resolvePath",171 "ProjectFaviconResolver.resolvePath",
171 )(function* (cwd) {172 )(function* (cwd, faviconPath) {
172 const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe(173 const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe(
173 Effect.mapError(174 Effect.mapError(
174 (cause) =>175 (cause) =>
...@@ -179,6 +180,15 @@ export const make = Effect.gen(function* () {...@@ -179,6 +180,15 @@ export const make = Effect.gen(function* () {
179 }),180 }),
180 ),181 ),
181 );182 );
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
182 // A t3.json iconPath takes precedence over the well-known locations.192 // A t3.json iconPath takes precedence over the well-known locations.
183 const projectFile = yield* projectFileLoader.load(projectCwd);193 const projectFile = yield* projectFileLoader.load(projectCwd);
184 if (Option.isSome(projectFile) && projectFile.value.iconPath !== undefined) {194 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* () {...@@ -245,7 +245,7 @@ export const make = Effect.gen(function* () {
245 });245 });
246 return yield* Effect.gen(function* () {246 return yield* Effect.gen(function* () {
247 const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;247 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);
249 }).pipe(249 }).pipe(
250 Effect.provide(250 Effect.provide(
251 workspaceSearchIndexes.get(251 workspaceSearchIndexes.get(
apps/server/src/workspace/WorkspaceSearchIndex.test.ts+55-1
...@@ -1,4 +1,10 @@...@@ -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";
2import { afterEach, expect, it } from "@effect/vitest";8import { afterEach, expect, it } from "@effect/vitest";
3import * as Cause from "effect/Cause";9import * as Cause from "effect/Cause";
4import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
...@@ -11,6 +17,54 @@ afterEach(() => {...@@ -11,6 +17,54 @@ afterEach(() => {
11 vi.restoreAllMocks();17 vi.restoreAllMocks();
12});18});
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
14it.effect("preserves unexpected FileFinder creation failures", () =>68it.effect("preserves unexpected FileFinder creation failures", () =>
15 Effect.gen(function* () {69 Effect.gen(function* () {
16 const cause = new Error("native initialization failed");70 const cause = new Error("native initialization failed");
apps/server/src/workspace/WorkspaceSearchIndex.ts+17-12
...@@ -23,6 +23,7 @@ import type {...@@ -23,6 +23,7 @@ import type {
23 ProjectSearchContentsResult,23 ProjectSearchContentsResult,
24 ProjectSearchEntriesResult,24 ProjectSearchEntriesResult,
25} from "@t3tools/contracts";25} from "@t3tools/contracts";
26import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
2627
27const WORKSPACE_INDEX_MAX_ENTRIES = 25_000;28const WORKSPACE_INDEX_MAX_ENTRIES = 25_000;
28const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2;29const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2;
...@@ -111,6 +112,7 @@ export class WorkspaceSearchIndex extends Context.Service<...@@ -111,6 +112,7 @@ export class WorkspaceSearchIndex extends Context.Service<
111 query: string,112 query: string,
112 limit: number,113 limit: number,
113 kind?: ProjectEntryKind,114 kind?: ProjectEntryKind,
115 imageOnly?: boolean,
114 ) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceSearchIndexSearchFailed>;116 ) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceSearchIndexSearchFailed>;
115 readonly searchContents: (117 readonly searchContents: (
116 input: Omit<ProjectSearchContentsInput, "cwd">,118 input: Omit<ProjectSearchContentsInput, "cwd">,
...@@ -157,15 +159,18 @@ function toDirectoryEntry(item: DirItem): ProjectEntry | null {...@@ -157,15 +159,18 @@ function toDirectoryEntry(item: DirItem): ProjectEntry | null {
157 return normalizedPath ? { path: normalizedPath, kind: "directory" } : null;159 return normalizedPath ? { path: normalizedPath, kind: "directory" } : null;
158}160}
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 });
161 return {171 return {
162 entries: result.items172 entries: entries.slice(0, limit),
163 .flatMap((item) => {173 truncated: entries.length > limit || result.totalMatched > result.items.length,
164 const entry = toFileEntry(item);
165 return entry ? [entry] : [];
166 })
167 .slice(0, limit),
168 truncated: result.totalMatched > limit,
169 };174 };
170}175}
171176
...@@ -445,13 +450,13 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (...@@ -445,13 +450,13 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (
445450
446 const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn(451 const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn(
447 "WorkspaceSearchIndex.search",452 "WorkspaceSearchIndex.search",
448 )(function* (query, limit, kind) {453 )(function* (query, limit, kind, imageOnly) {
449 const pageSize = Math.max(1, limit + 1);454 const pageSize = imageOnly ? WORKSPACE_INDEX_PAGE_SIZE : Math.max(1, limit + 1);
450 if (kind === "file") {455 if (kind === "file" || imageOnly) {
451 const result = yield* runSearch(query, pageSize, "fileSearch", () =>456 const result = yield* runSearch(query, pageSize, "fileSearch", () =>
452 finder.fileSearch(query, { pageSize }),457 finder.fileSearch(query, { pageSize }),
453 );458 );
454 return mapFileSearchResult(result, limit);459 return mapFileSearchResult(result, limit, imageOnly);
455 }460 }
456 if (kind === "directory") {461 if (kind === "directory") {
457 const result = yield* runSearch(query, pageSize, "directorySearch", () =>462 const result = yield* runSearch(query, pageSize, "directorySearch", () =>
apps/server/src/ws.ts+25-1
...@@ -1763,9 +1763,33 @@ const makeWsRpcLayer = (...@@ -1763,9 +1763,33 @@ const makeWsRpcLayer = (
1763 observeRpcEffect(1763 observeRpcEffect(
1764 WS_METHODS.assetsCreateUrl,1764 WS_METHODS.assetsCreateUrl,
1765 Effect.gen(function* () {1765 Effect.gen(function* () {
1766 if (input.resource._tag !== "workspace-file") {1766 if (input.resource._tag === "attachment") {
1767 return yield* issueAssetUrl({ resource: input.resource });1767 return yield* issueAssetUrl({ resource: input.resource });
1768 }1768 }
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 }
1769 const thread = yield* projectionSnapshotQuery1793 const thread = yield* projectionSnapshotQuery
1770 .getThreadShellById(input.resource.threadId)1794 .getThreadShellById(input.resource.threadId)
1771 .pipe(1795 .pipe(
apps/web/src/assets/assetUrls.ts+8-2
...@@ -12,7 +12,7 @@ export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";...@@ -12,7 +12,7 @@ export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
12export type AssetUrlState =12export type AssetUrlState =
13 | { readonly _tag: "Loading" }13 | { readonly _tag: "Loading" }
14 | { readonly _tag: "Failure" }14 | { readonly _tag: "Failure" }
15 | { readonly _tag: "Success"; readonly url: string };15 | { readonly _tag: "Success"; readonly url: string; readonly sourcePath?: string };
1616
17export function useAssetUrlState(17export function useAssetUrlState(
18 environmentId: EnvironmentId,18 environmentId: EnvironmentId,
...@@ -32,7 +32,13 @@ export function useAssetUrlState(...@@ -32,7 +32,13 @@ export function useAssetUrlState(
32 return { _tag: "Loading" };32 return { _tag: "Loading" };
33 }33 }
34 const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl);34 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 };
36}42}
3743
38export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null {44export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null {
apps/web/src/components/ChatView.tsx+1
...@@ -6048,6 +6048,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6048,6 +6048,7 @@ function ChatViewContent(props: ChatViewProps) {
6048 changeRequestState={activeThreadPr?.state ?? null}6048 changeRequestState={activeThreadPr?.state ?? null}
6049 activeProjectName={activeProject?.title}6049 activeProjectName={activeProject?.title}
6050 activeProjectCwd={activeProject?.workspaceRoot ?? null}6050 activeProjectCwd={activeProject?.workspaceRoot ?? null}
6051 activeProjectFaviconPath={activeProject?.faviconPath ?? null}
6051 openInCwd={gitCwd}6052 openInCwd={gitCwd}
6052 activeProjectScripts={activeProject?.scripts}6053 activeProjectScripts={activeProject?.scripts}
6053 preferredScriptId={6054 preferredScriptId={
apps/web/src/components/CommandPalette.tsx+1
...@@ -150,6 +150,7 @@ function projectFavicon(project: Project) {...@@ -150,6 +150,7 @@ function projectFavicon(project: Project) {
150 <ProjectFavicon150 <ProjectFavicon
151 environmentId={project.environmentId}151 environmentId={project.environmentId}
152 cwd={project.workspaceRoot}152 cwd={project.workspaceRoot}
153 faviconPath={project.faviconPath}
153 className={ITEM_ICON_CLASS}154 className={ITEM_ICON_CLASS}
154 />155 />
155 );156 );
apps/web/src/components/LegacySidebar.tsx+5-1
...@@ -2253,7 +2253,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2253,7 +2253,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2253 }`}2253 }`}
2254 />2254 />
2255 )}2255 )}
2256 <ProjectFavicon environmentId={project.environmentId} cwd={project.workspaceRoot} />2256 <ProjectFavicon
2257 environmentId={project.environmentId}
2258 cwd={project.workspaceRoot}
2259 faviconPath={project.faviconPath}
2260 />
2257 <span className="flex min-w-0 flex-1 items-center gap-2">2261 <span className="flex min-w-0 flex-1 items-center gap-2">
2258 <span className="truncate text-sm font-medium text-sidebar-foreground/90">2262 <span className="truncate text-sm font-medium text-sidebar-foreground/90">
2259 {project.displayName}2263 {project.displayName}
apps/web/src/components/ProjectFavicon.test.tsx+19-1
...@@ -4,6 +4,7 @@ import type { EnvironmentId } from "@t3tools/contracts";...@@ -4,6 +4,7 @@ import type { EnvironmentId } from "@t3tools/contracts";
44
5const testState = vi.hoisted(() => ({5const testState = vi.hoisted(() => ({
6 faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg",6 faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg",
7 lastResource: null as unknown,
7}));8}));
89
9const hooks = vi.hoisted(() => {10const hooks = vi.hoisted(() => {
...@@ -52,7 +53,10 @@ vi.mock("react", async (importOriginal) => {...@@ -52,7 +53,10 @@ vi.mock("react", async (importOriginal) => {
5253
53vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache }));54vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache }));
54vi.mock("../assets/assetUrls", () => ({55vi.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 },
56}));60}));
5761
58import { ProjectFavicon } from "./ProjectFavicon";62import { ProjectFavicon } from "./ProjectFavicon";
...@@ -125,4 +129,18 @@ describe("ProjectFavicon", () => {...@@ -125,4 +129,18 @@ describe("ProjectFavicon", () => {
125 expect(afterDisplayedError[0]).not.toBeNull();129 expect(afterDisplayedError[0]).not.toBeNull();
126 expect(afterDisplayedError[1]).toBeNull();130 expect(afterDisplayedError[1]).toBeNull();
127 });131 });
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 });
128});146});
apps/web/src/components/ProjectFavicon.tsx+16-5
...@@ -6,7 +6,7 @@ import {...@@ -6,7 +6,7 @@ import {
6import { FolderIcon } from "lucide-react";6import { FolderIcon } from "lucide-react";
7import type { ComponentType } from "react";7import type { ComponentType } from "react";
8import { useState } from "react";8import { useState } from "react";
9import { useAssetUrl } from "../assets/assetUrls";9import { useAssetUrlState } from "../assets/assetUrls";
10import { cn } from "~/lib/utils";10import { cn } from "~/lib/utils";
1111
12const loadedProjectFaviconSrcs = new Map<string, string>();12const loadedProjectFaviconSrcs = new Map<string, string>();
...@@ -14,13 +14,12 @@ const loadedProjectFaviconSrcs = new Map<string, string>();...@@ -14,13 +14,12 @@ const loadedProjectFaviconSrcs = new Map<string, string>();
14export function ProjectFavicon(input: {14export function ProjectFavicon(input: {
15 environmentId: EnvironmentId;15 environmentId: EnvironmentId;
16 cwd: string;16 cwd: string;
17 faviconPath?: string | null | undefined;
17 className?: string | undefined;18 className?: string | undefined;
18 fallbackIcon?: ComponentType<{ className?: string }>;19 fallbackIcon?: ComponentType<{ className?: string }>;
19}) {20}) {
20 const src = useAssetUrl(input.environmentId, {21 const state = useProjectFaviconAsset(input);
21 _tag: "project-favicon",22 const src = state._tag === "Success" ? state.url : null;
22 cwd: input.cwd,
23 });
24 const FallbackIcon = input.fallbackIcon ?? FolderIcon;23 const FallbackIcon = input.fallbackIcon ?? FolderIcon;
2524
26 if (!src || isProjectFaviconFallbackUrl(src)) {25 if (!src || isProjectFaviconFallbackUrl(src)) {
...@@ -40,6 +39,18 @@ export function ProjectFavicon(input: {...@@ -40,6 +39,18 @@ export function ProjectFavicon(input: {
40 );39 );
41}40}
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
43function ProjectFaviconFallback({54function ProjectFaviconFallback({
44 className,55 className,
45 icon: Icon,56 icon: Icon,
apps/web/src/components/Sidebar.tsx+34
...@@ -236,6 +236,7 @@ function SidebarThreadTooltip({...@@ -236,6 +236,7 @@ function SidebarThreadTooltip({
236 thread,236 thread,
237 projectTitle,237 projectTitle,
238 projectCwd,238 projectCwd,
239 projectFaviconPath,
239 environmentLabel,240 environmentLabel,
240 driverKind,241 driverKind,
241 modelInstanceId,242 modelInstanceId,
...@@ -247,6 +248,7 @@ function SidebarThreadTooltip({...@@ -247,6 +248,7 @@ function SidebarThreadTooltip({
247 thread: SidebarThreadSummary;248 thread: SidebarThreadSummary;
248 projectTitle: string | null;249 projectTitle: string | null;
249 projectCwd: string | null;250 projectCwd: string | null;
251 projectFaviconPath: string | null;
250 environmentLabel: string | null;252 environmentLabel: string | null;
251 driverKind: ProviderInstanceEntry["driverKind"] | null;253 driverKind: ProviderInstanceEntry["driverKind"] | null;
252 modelInstanceId: string;254 modelInstanceId: string;
...@@ -276,6 +278,7 @@ function SidebarThreadTooltip({...@@ -276,6 +278,7 @@ function SidebarThreadTooltip({
276 <ProjectFavicon278 <ProjectFavicon
277 environmentId={thread.environmentId}279 environmentId={thread.environmentId}
278 cwd={projectCwd ?? ""}280 cwd={projectCwd ?? ""}
281 faviconPath={projectFaviconPath}
279 className="size-3 shrink-0 stroke-muted-foreground"282 className="size-3 shrink-0 stroke-muted-foreground"
280 />283 />
281 <div className="min-w-0 truncate text-foreground/75">{projectTitle}</div>284 <div className="min-w-0 truncate text-foreground/75">{projectTitle}</div>
...@@ -423,6 +426,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {...@@ -423,6 +426,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
423 composer: ComposerThreadDraftState;426 composer: ComposerThreadDraftState;
424 projectTitle: string | null;427 projectTitle: string | null;
425 projectCwd: string | null;428 projectCwd: string | null;
429 projectFaviconPath: string | null;
426 isActive: boolean;430 isActive: boolean;
427 onNavigate: (draftId: DraftId) => void;431 onNavigate: (draftId: DraftId) => void;
428 onDiscard: (draftId: DraftId) => void;432 onDiscard: (draftId: DraftId) => void;
...@@ -487,6 +491,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {...@@ -487,6 +491,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
487 <ProjectFavicon491 <ProjectFavicon
488 environmentId={session.environmentId}492 environmentId={session.environmentId}
489 cwd={props.projectCwd ?? ""}493 cwd={props.projectCwd ?? ""}
494 faviconPath={props.projectFaviconPath}
490 className="size-4 shrink-0"495 className="size-4 shrink-0"
491 />496 />
492 <span className="min-w-0 flex-1 truncate text-xs font-medium text-secondary-label">497 <span className="min-w-0 flex-1 truncate text-xs font-medium text-secondary-label">
...@@ -524,6 +529,7 @@ interface SidebarDraftRowData {...@@ -524,6 +529,7 @@ interface SidebarDraftRowData {
524const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {529const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
525 projectDisplayNameByKey: ReadonlyMap<string, string>;530 projectDisplayNameByKey: ReadonlyMap<string, string>;
526 projectCwdByKey: ReadonlyMap<string, string>;531 projectCwdByKey: ReadonlyMap<string, string>;
532 projectFaviconPathByKey: ReadonlyMap<string, string | null | undefined>;
527 scopedProjectKeys: ReadonlySet<string> | null;533 scopedProjectKeys: ReadonlySet<string> | null;
528 routeDraftId: string | null;534 routeDraftId: string | null;
529 onNavigateToDraft: (draftId: DraftId) => void;535 onNavigateToDraft: (draftId: DraftId) => void;
...@@ -618,6 +624,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {...@@ -618,6 +624,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
618 composer={composer}624 composer={composer}
619 projectTitle={props.projectDisplayNameByKey.get(projectKey) ?? null}625 projectTitle={props.projectDisplayNameByKey.get(projectKey) ?? null}
620 projectCwd={props.projectCwdByKey.get(projectKey) ?? null}626 projectCwd={props.projectCwdByKey.get(projectKey) ?? null}
627 projectFaviconPath={props.projectFaviconPathByKey.get(projectKey) ?? null}
621 isActive={draftId === props.routeDraftId}628 isActive={draftId === props.routeDraftId}
622 onNavigate={props.onNavigateToDraft}629 onNavigate={props.onNavigateToDraft}
623 onDiscard={handleDiscard}630 onDiscard={handleDiscard}
...@@ -667,6 +674,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -667,6 +674,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
667 currentEnvironmentId: string | null;674 currentEnvironmentId: string | null;
668 environmentLabel: string | null;675 environmentLabel: string | null;
669 projectCwd: string | null;676 projectCwd: string | null;
677 projectFaviconPath: string | null;
670 projectTitle: string | null;678 projectTitle: string | null;
671 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;679 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;
672 timestampFormat: TimestampFormat;680 timestampFormat: TimestampFormat;
...@@ -857,6 +865,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -857,6 +865,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
857 thread={thread}865 thread={thread}
858 projectTitle={props.projectTitle}866 projectTitle={props.projectTitle}
859 projectCwd={props.projectCwd}867 projectCwd={props.projectCwd}
868 projectFaviconPath={props.projectFaviconPath}
860 environmentLabel={props.environmentLabel}869 environmentLabel={props.environmentLabel}
861 driverKind={driverKind}870 driverKind={driverKind}
862 modelInstanceId={modelInstanceId}871 modelInstanceId={modelInstanceId}
...@@ -1122,6 +1131,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1122,6 +1131,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1122 <ProjectFavicon1131 <ProjectFavicon
1123 environmentId={thread.environmentId}1132 environmentId={thread.environmentId}
1124 cwd={props.projectCwd ?? ""}1133 cwd={props.projectCwd ?? ""}
1134 faviconPath={props.projectFaviconPath}
1125 className="size-4"1135 className="size-4"
1126 fallbackIcon={MessageSquareIcon}1136 fallbackIcon={MessageSquareIcon}
1127 />1137 />
...@@ -1261,6 +1271,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1261,6 +1271,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1261 <ProjectFavicon1271 <ProjectFavicon
1262 environmentId={thread.environmentId}1272 environmentId={thread.environmentId}
1263 cwd={props.projectCwd ?? ""}1273 cwd={props.projectCwd ?? ""}
1274 faviconPath={props.projectFaviconPath}
1264 className="size-4 shrink-0"1275 className="size-4 shrink-0"
1265 />1276 />
1266 {props.projectTitle ? (1277 {props.projectTitle ? (
...@@ -1454,6 +1465,7 @@ function latestTurnDiff(...@@ -1454,6 +1465,7 @@ function latestTurnDiff(
1454const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {1465const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
1455 thread: SidebarThreadSummary;1466 thread: SidebarThreadSummary;
1456 projectCwd: string | null;1467 projectCwd: string | null;
1468 projectFaviconPath: string | null;
1457 projectTitle: string | null;1469 projectTitle: string | null;
1458 environmentLabel: string | null;1470 environmentLabel: string | null;
1459 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;1471 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;
...@@ -1526,6 +1538,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {...@@ -1526,6 +1538,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
1526 <ProjectFavicon1538 <ProjectFavicon
1527 environmentId={thread.environmentId}1539 environmentId={thread.environmentId}
1528 cwd={props.projectCwd ?? ""}1540 cwd={props.projectCwd ?? ""}
1541 faviconPath={props.projectFaviconPath}
1529 className="size-4 shrink-0"1542 className="size-4 shrink-0"
1530 fallbackIcon={MessageSquareIcon}1543 fallbackIcon={MessageSquareIcon}
1531 />1544 />
...@@ -1538,6 +1551,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {...@@ -1538,6 +1551,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
1538 thread={thread}1551 thread={thread}
1539 projectTitle={props.projectTitle}1552 projectTitle={props.projectTitle}
1540 projectCwd={props.projectCwd}1553 projectCwd={props.projectCwd}
1554 projectFaviconPath={props.projectFaviconPath}
1541 environmentLabel={props.environmentLabel}1555 environmentLabel={props.environmentLabel}
1542 driverKind={driverKind}1556 driverKind={driverKind}
1543 modelInstanceId={modelInstanceId}1557 modelInstanceId={modelInstanceId}
...@@ -1714,6 +1728,13 @@ export default function Sidebar() {...@@ -1714,6 +1728,13 @@ export default function Sidebar() {
1714 ),1728 ),
1715 [projects],1729 [projects],
1716 );1730 );
1731 const projectFaviconPathByKey = useMemo(
1732 () =>
1733 new Map(
1734 projects.map((project) => [`${project.environmentId}:${project.id}`, project.faviconPath]),
1735 ),
1736 [projects],
1737 );
1717 const projectDisplayNameByKey = useMemo(1738 const projectDisplayNameByKey = useMemo(
1718 () =>1739 () =>
1719 new Map(1740 new Map(
...@@ -3230,6 +3251,7 @@ export default function Sidebar() {...@@ -3230,6 +3251,7 @@ export default function Sidebar() {
3230 <ProjectFavicon3251 <ProjectFavicon
3231 environmentId={scopedProjectGroup.environmentId}3252 environmentId={scopedProjectGroup.environmentId}
3232 cwd={scopedProjectGroup.workspaceRoot}3253 cwd={scopedProjectGroup.workspaceRoot}
3254 faviconPath={scopedProjectGroup.faviconPath}
3233 className="size-4 shrink-0"3255 className="size-4 shrink-0"
3234 />3256 />
3235 ) : (3257 ) : (
...@@ -3267,6 +3289,7 @@ export default function Sidebar() {...@@ -3267,6 +3289,7 @@ export default function Sidebar() {
3267 <ProjectFavicon3289 <ProjectFavicon
3268 environmentId={project.environmentId}3290 environmentId={project.environmentId}
3269 cwd={project.workspaceRoot}3291 cwd={project.workspaceRoot}
3292 faviconPath={project.faviconPath}
3270 className="size-4 shrink-0"3293 className="size-4 shrink-0"
3271 />3294 />
3272 <span className="min-w-0 truncate text-sm">{project.displayName}</span>3295 <span className="min-w-0 truncate text-sm">{project.displayName}</span>
...@@ -3339,6 +3362,11 @@ export default function Sidebar() {...@@ -3339,6 +3362,11 @@ export default function Sidebar() {
3339 projectCwd={3362 projectCwd={
3340 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null3363 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
3341 }3364 }
3365 projectFaviconPath={
3366 projectFaviconPathByKey.get(
3367 `${thread.environmentId}:${thread.projectId}`,
3368 ) ?? null
3369 }
3342 projectTitle={3370 projectTitle={
3343 projectDisplayNameByKey.get(3371 projectDisplayNameByKey.get(
3344 `${thread.environmentId}:${thread.projectId}`,3372 `${thread.environmentId}:${thread.projectId}`,
...@@ -3442,6 +3470,11 @@ export default function Sidebar() {...@@ -3442,6 +3470,11 @@ export default function Sidebar() {
3442 projectCwd={3470 projectCwd={
3443 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null3471 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
3444 }3472 }
3473 projectFaviconPath={
3474 projectFaviconPathByKey.get(
3475 `${thread.environmentId}:${thread.projectId}`,
3476 ) ?? null
3477 }
3445 projectTitle={3478 projectTitle={
3446 projectDisplayNameByKey.get(3479 projectDisplayNameByKey.get(
3447 `${thread.environmentId}:${thread.projectId}`,3480 `${thread.environmentId}:${thread.projectId}`,
...@@ -3480,6 +3513,7 @@ export default function Sidebar() {...@@ -3480,6 +3513,7 @@ export default function Sidebar() {
3480 key="draft-sessions"3513 key="draft-sessions"
3481 projectDisplayNameByKey={projectDisplayNameByKey}3514 projectDisplayNameByKey={projectDisplayNameByKey}
3482 projectCwdByKey={projectCwdByKey}3515 projectCwdByKey={projectCwdByKey}
3516 projectFaviconPathByKey={projectFaviconPathByKey}
3483 scopedProjectKeys={scopedProjectKeys}3517 scopedProjectKeys={scopedProjectKeys}
3484 routeDraftId={routeDraftIdForRows}3518 routeDraftId={routeDraftIdForRows}
3485 onNavigateToDraft={navigateToDraft}3519 onNavigateToDraft={navigateToDraft}
apps/web/src/components/chat/ChatHeader.tsx+3
...@@ -49,6 +49,7 @@ interface ChatHeaderProps {...@@ -49,6 +49,7 @@ interface ChatHeaderProps {
49 changeRequestState: ChangeRequestStateLike | null;49 changeRequestState: ChangeRequestStateLike | null;
50 activeProjectName: string | undefined;50 activeProjectName: string | undefined;
51 activeProjectCwd: string | null;51 activeProjectCwd: string | null;
52 activeProjectFaviconPath: string | null;
52 openInCwd: string | null;53 openInCwd: string | null;
53 activeProjectScripts: ReadonlyArray<ProjectScript> | undefined;54 activeProjectScripts: ReadonlyArray<ProjectScript> | undefined;
54 preferredScriptId: string | null;55 preferredScriptId: string | null;
...@@ -101,6 +102,7 @@ export const ChatHeader = memo(function ChatHeader({...@@ -101,6 +102,7 @@ export const ChatHeader = memo(function ChatHeader({
101 changeRequestState,102 changeRequestState,
102 activeProjectName,103 activeProjectName,
103 activeProjectCwd,104 activeProjectCwd,
105 activeProjectFaviconPath,
104 openInCwd,106 openInCwd,
105 activeProjectScripts,107 activeProjectScripts,
106 preferredScriptId,108 preferredScriptId,
...@@ -229,6 +231,7 @@ export const ChatHeader = memo(function ChatHeader({...@@ -229,6 +231,7 @@ export const ChatHeader = memo(function ChatHeader({
229 <ProjectFavicon231 <ProjectFavicon
230 environmentId={activeThreadEnvironmentId}232 environmentId={activeThreadEnvironmentId}
231 cwd={activeProjectCwd ?? ""}233 cwd={activeProjectCwd ?? ""}
234 faviconPath={activeProjectFaviconPath}
232 className="size-3.5"235 className="size-3.5"
233 />236 />
234 <span className="max-w-40 truncate text-sm font-medium">{activeProjectName}</span>237 <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(...@@ -150,10 +150,19 @@ export function useProjectFilePickerQuery(
150 cwd: string,150 cwd: string,
151 query: string,151 query: string,
152 limit: number,152 limit: number,
153 options?: { readonly imageOnly?: boolean },
153) {154) {
154 const search = useProjectPathSearch({ environmentId, cwd, query, kind: "file" }, limit, {155 const search = useProjectPathSearch(
155 allowEmptyQuery: true,156 {
156 });157 environmentId,
158 cwd,
159 query,
160 kind: "file",
161 ...(options?.imageOnly ? { imageOnly: true } : {}),
162 },
163 limit,
164 { allowEmptyQuery: true },
165 );
157166
158 return {167 return {
159 entries: search.isPending ? [] : search.entries,168 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 {...@@ -86,6 +86,7 @@ import {
86 SettingsRow,86 SettingsRow,
87 SettingsSection,87 SettingsSection,
88} from "./settingsLayout";88} from "./settingsLayout";
89import { ProjectFaviconPickerDialog } from "./ProjectFaviconPickerDialog";
8990
90export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {91export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
91 repository: "Group by repository",92 repository: "Group by repository",
...@@ -221,6 +222,7 @@ export function ProjectSettingsPanel({...@@ -221,6 +222,7 @@ export function ProjectSettingsPanel({
221 <ProjectFavicon222 <ProjectFavicon
222 environmentId={group.environmentId}223 environmentId={group.environmentId}
223 cwd={group.workspaceRoot}224 cwd={group.workspaceRoot}
225 faviconPath={group.faviconPath}
224 className="size-4 shrink-0"226 className="size-4 shrink-0"
225 />227 />
226 <span className="min-w-0 flex-1 truncate">{group.displayName}</span>228 <span className="min-w-0 flex-1 truncate">{group.displayName}</span>
...@@ -295,6 +297,7 @@ function ProjectDetail({...@@ -295,6 +297,7 @@ function ProjectDetail({
295 group.memberProjects.find(297 group.memberProjects.find(
296 (member) => member.environmentId === group.environmentId && member.id === group.id,298 (member) => member.environmentId === group.environmentId && member.id === group.id,
297 ) ?? group.memberProjects[0]!;299 ) ?? group.memberProjects[0]!;
300 const faviconPath = representative.faviconPath ?? null;
298301
299 const threadCountByMember = useMemo(() => {302 const threadCountByMember = useMemo(() => {
300 const counts = new Map<string, number>();303 const counts = new Map<string, number>();
...@@ -328,6 +331,7 @@ function ProjectDetail({...@@ -328,6 +331,7 @@ function ProjectDetail({
328 input: Partial<{331 input: Partial<{
329 defaultModelSelection: ModelSelection | null;332 defaultModelSelection: ModelSelection | null;
330 defaultThreadEnvMode: ThreadEnvMode | null;333 defaultThreadEnvMode: ThreadEnvMode | null;
334 faviconPath: string | null;
331 scripts: ReadonlyArray<ReturnType<typeof buildProjectScript>>;335 scripts: ReadonlyArray<ReturnType<typeof buildProjectScript>>;
332 }>,336 }>,
333 failureTitle: string,337 failureTitle: string,
...@@ -391,6 +395,25 @@ function ProjectDetail({...@@ -391,6 +395,25 @@ function ProjectDetail({
391 [updateAllMembers],395 [updateAllMembers],
392 );396 );
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
394 // ----- scripts -----417 // ----- scripts -----
395 const scripts = representative.scripts;418 const scripts = representative.scripts;
396 const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null);419 const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null);
...@@ -692,6 +715,7 @@ function ProjectDetail({...@@ -692,6 +715,7 @@ function ProjectDetail({
692 <ProjectFavicon715 <ProjectFavicon
693 environmentId={group.environmentId}716 environmentId={group.environmentId}
694 cwd={group.workspaceRoot}717 cwd={group.workspaceRoot}
718 faviconPath={group.faviconPath}
695 className="size-8 shrink-0"719 className="size-8 shrink-0"
696 />720 />
697 <div className="min-w-0 flex-1">721 <div className="min-w-0 flex-1">
...@@ -723,6 +747,43 @@ function ProjectDetail({...@@ -723,6 +747,43 @@ function ProjectDetail({
723 </Select>747 </Select>
724 </div>748 </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
726 <SettingsSection title="Model">787 <SettingsSection title="Model">
727 <SettingsRow788 <SettingsRow
728 id="project-default-model"789 id="project-default-model"
...@@ -1068,6 +1129,15 @@ function ProjectDetail({...@@ -1068,6 +1129,15 @@ function ProjectDetail({
1068 onDelete={deleteScript}1129 onDelete={deleteScript}
1069 onClose={() => setEditorRequest(null)}1130 onClose={() => setEditorRequest(null)}
1070 />1131 />
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 />
1071 </SettingsPageContainer>1141 </SettingsPageContainer>
1072 );1142 );
1073}1143}
apps/web/src/components/settings/SettingsPanels.tsx+8-1
...@@ -2258,6 +2258,7 @@ export function ArchivedThreadsPanel() {...@@ -2258,6 +2258,7 @@ export function ArchivedThreadsPanel() {
2258 environmentId,2258 environmentId,
2259 name: project.title,2259 name: project.title,
2260 cwd: project.workspaceRoot,2260 cwd: project.workspaceRoot,
2261 faviconPath: project.faviconPath,
2261 },2262 },
2262 ] as const,2263 ] as const,
2263 ),2264 ),
...@@ -2379,7 +2380,13 @@ export function ArchivedThreadsPanel() {...@@ -2379,7 +2380,13 @@ export function ArchivedThreadsPanel() {
2379 key={project.id}2380 key={project.id}
2380 id={index === 0 ? searchableSetting("archive").id : undefined}2381 id={index === 0 ? searchableSetting("archive").id : undefined}
2381 title={project.name}2382 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 }
2383 >2390 >
2384 {projectThreads.map((thread) => (2391 {projectThreads.map((thread) => (
2385 <SettingsRow2392 <SettingsRow
apps/web/src/components/settings/settingsLayout.tsx+10-1
...@@ -199,7 +199,15 @@ export function SettingsRow({...@@ -199,7 +199,15 @@ export function SettingsRow({
199 );199 );
200}200}
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}) {
203 return (211 return (
204 <Tooltip>212 <Tooltip>
205 <TooltipTrigger213 <TooltipTrigger
...@@ -208,6 +216,7 @@ export function SettingResetButton({ label, onClick }: { label: string; onClick:...@@ -208,6 +216,7 @@ export function SettingResetButton({ label, onClick }: { label: string; onClick:
208 size="icon-xs"216 size="icon-xs"
209 variant="ghost"217 variant="ghost"
210 aria-label={`Reset ${label} to default`}218 aria-label={`Reset ${label} to default`}
219 disabled={disabled}
211 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"220 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"
212 onClick={(event) => {221 onClick={(event) => {
213 event.stopPropagation();222 event.stopPropagation();
apps/web/src/state/queries.test.ts+2-1
...@@ -10,7 +10,7 @@ describe("areProjectPathSearchTargetsEqual", () => {...@@ -10,7 +10,7 @@ describe("areProjectPathSearchTargetsEqual", () => {
10 query: "index",10 query: "index",
11 };11 };
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", () => {
14 expect(areProjectPathSearchTargetsEqual(target, target)).toBe(true);14 expect(areProjectPathSearchTargetsEqual(target, target)).toBe(true);
15 expect(15 expect(
16 areProjectPathSearchTargetsEqual(target, {16 areProjectPathSearchTargetsEqual(target, {
...@@ -21,5 +21,6 @@ describe("areProjectPathSearchTargetsEqual", () => {...@@ -21,5 +21,6 @@ describe("areProjectPathSearchTargetsEqual", () => {
21 expect(areProjectPathSearchTargetsEqual(target, { ...target, cwd: "/project-b" })).toBe(false);21 expect(areProjectPathSearchTargetsEqual(target, { ...target, cwd: "/project-b" })).toBe(false);
22 expect(areProjectPathSearchTargetsEqual(target, { ...target, query: "readme" })).toBe(false);22 expect(areProjectPathSearchTargetsEqual(target, { ...target, query: "readme" })).toBe(false);
23 expect(areProjectPathSearchTargetsEqual(target, { ...target, kind: "file" })).toBe(false);23 expect(areProjectPathSearchTargetsEqual(target, { ...target, kind: "file" })).toBe(false);
24 expect(areProjectPathSearchTargetsEqual(target, { ...target, imageOnly: true })).toBe(false);
24 });25 });
25});26});
apps/web/src/state/queries.ts+6-2
...@@ -236,6 +236,7 @@ export function usePaginatedBranches(target: VcsRefTarget) {...@@ -236,6 +236,7 @@ export function usePaginatedBranches(target: VcsRefTarget) {
236236
237type ProjectPathSearchTarget = ComposerPathSearchTarget & {237type ProjectPathSearchTarget = ComposerPathSearchTarget & {
238 readonly kind?: ProjectEntryKind | undefined;238 readonly kind?: ProjectEntryKind | undefined;
239 readonly imageOnly?: boolean | undefined;
239};240};
240241
241export function areProjectPathSearchTargetsEqual(242export function areProjectPathSearchTargetsEqual(
...@@ -246,7 +247,8 @@ export function areProjectPathSearchTargetsEqual(...@@ -246,7 +247,8 @@ export function areProjectPathSearchTargetsEqual(
246 left.environmentId === right.environmentId &&247 left.environmentId === right.environmentId &&
247 left.cwd === right.cwd &&248 left.cwd === right.cwd &&
248 left.query === right.query &&249 left.query === right.query &&
249 left.kind === right.kind250 left.kind === right.kind &&
251 left.imageOnly === right.imageOnly
250 );252 );
251}253}
252254
...@@ -262,8 +264,9 @@ export function useProjectPathSearch(...@@ -262,8 +264,9 @@ export function useProjectPathSearch(
262 cwd: target.cwd,264 cwd: target.cwd,
263 query: target.query == null ? null : target.query.trim(),265 query: target.query == null ? null : target.query.trim(),
264 kind: target.kind,266 kind: target.kind,
267 imageOnly: target.imageOnly,
265 }),268 }),
266 [target.cwd, target.environmentId, target.kind, target.query],269 [target.cwd, target.environmentId, target.imageOnly, target.kind, target.query],
267 );270 );
268 const debouncedTarget = useDebouncedValue(normalizedTarget, PROJECT_PATH_SEARCH_DEBOUNCE_MS);271 const debouncedTarget = useDebouncedValue(normalizedTarget, PROJECT_PATH_SEARCH_DEBOUNCE_MS);
269 const result = useEnvironmentQuery(272 const result = useEnvironmentQuery(
...@@ -278,6 +281,7 @@ export function useProjectPathSearch(...@@ -278,6 +281,7 @@ export function useProjectPathSearch(
278 query: debouncedTarget.query,281 query: debouncedTarget.query,
279 limit,282 limit,
280 ...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}),283 ...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}),
284 ...(debouncedTarget.imageOnly ? { imageOnly: true } : {}),
281 },285 },
282 })286 })
283 : null,287 : null,
docs/README.md+1
...@@ -6,6 +6,7 @@...@@ -6,6 +6,7 @@
6- [Permission modes](./user/permission-modes.md)6- [Permission modes](./user/permission-modes.md)
7- [Keyboard shortcuts](./user/keybindings.md)7- [Keyboard shortcuts](./user/keybindings.md)
8- [Organizing threads](./user/thread-sidebar.md)8- [Organizing threads](./user/thread-sidebar.md)
9- [Customize a project icon](./user/project-settings.md)
9- [Remote access](./user/remote-access.md)10- [Remote access](./user/remote-access.md)
10- [Keeping app and server in sync](./user/updating.md)11- [Keeping app and server in sync](./user/updating.md)
11- [Source control integrations](./user/source-control.md)12- [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", () => {...@@ -72,6 +72,18 @@ describe("createAssetEnvironmentAtoms", () => {
72 },72 },
73 }),73 }),
74 ).not.toBe(assets.createUrl(originalTarget));74 ).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));
75 expect(87 expect(
76 assets.createUrl({88 assets.createUrl({
77 environmentId: EnvironmentId.make("environment-2"),89 environmentId: EnvironmentId.make("environment-2"),
packages/contracts/src/assets.ts+7
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
22
3import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";3import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
4import { ProjectFaviconPath } from "./orchestration.ts";
45
5const ASSET_PATH_MAX_LENGTH = 1024;6const ASSET_PATH_MAX_LENGTH = 1024;
67
...@@ -14,6 +15,9 @@ export const AssetResource = Schema.Union([...@@ -14,6 +15,9 @@ export const AssetResource = Schema.Union([
14 }),15 }),
15 Schema.TaggedStruct("project-favicon", {16 Schema.TaggedStruct("project-favicon", {
16 cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),17 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),
17 }),21 }),
18]);22]);
19export type AssetResource = typeof AssetResource.Type;23export type AssetResource = typeof AssetResource.Type;
...@@ -26,6 +30,9 @@ export type AssetCreateUrlInput = typeof AssetCreateUrlInput.Type;...@@ -26,6 +30,9 @@ export type AssetCreateUrlInput = typeof AssetCreateUrlInput.Type;
26export const AssetCreateUrlResult = Schema.Struct({30export const AssetCreateUrlResult = Schema.Struct({
27 relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)),31 relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)),
28 expiresAt: Schema.Number,32 expiresAt: Schema.Number,
33 sourcePath: Schema.optional(
34 TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
35 ),
29});36});
30export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type;37export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type;
3138
packages/contracts/src/orchestration.test.ts+22
...@@ -913,3 +913,25 @@ it.effect("ModelSelection rejects malformed instance ids", () =>...@@ -913,3 +913,25 @@ it.effect("ModelSelection rejects malformed instance ids", () =>
913 assert.strictEqual(result._tag, "Failure");913 assert.strictEqual(result._tag, "Failure");
914 }),914 }),
915);915);
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({...@@ -211,6 +211,12 @@ export const ProjectScript = Schema.Struct({
211});211});
212export type ProjectScript = typeof ProjectScript.Type;212export 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
214export const OrchestrationProject = Schema.Struct({220export const OrchestrationProject = Schema.Struct({
215 id: ProjectId,221 id: ProjectId,
216 title: TrimmedNonEmptyString,222 title: TrimmedNonEmptyString,
...@@ -220,6 +226,8 @@ export const OrchestrationProject = Schema.Struct({...@@ -220,6 +226,8 @@ export const OrchestrationProject = Schema.Struct({
220 // Per-project override for where new threads start. Null/absent means226 // Per-project override for where new threads start. Null/absent means
221 // "no override": clients fall back to t3.json, then the global setting.227 // "no override": clients fall back to t3.json, then the global setting.
222 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),228 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)),
223 scripts: Schema.Array(ProjectScript),231 scripts: Schema.Array(ProjectScript),
224 createdAt: IsoDateTime,232 createdAt: IsoDateTime,
225 updatedAt: IsoDateTime,233 updatedAt: IsoDateTime,
...@@ -415,6 +423,8 @@ export const OrchestrationProjectShell = Schema.Struct({...@@ -415,6 +423,8 @@ export const OrchestrationProjectShell = Schema.Struct({
415 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),423 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
416 defaultModelSelection: Schema.NullOr(ModelSelection),424 defaultModelSelection: Schema.NullOr(ModelSelection),
417 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),425 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)),
418 scripts: Schema.Array(ProjectScript),428 scripts: Schema.Array(ProjectScript),
419 createdAt: IsoDateTime,429 createdAt: IsoDateTime,
420 updatedAt: IsoDateTime,430 updatedAt: IsoDateTime,
...@@ -629,6 +639,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({...@@ -629,6 +639,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({
629 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),639 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
630 // Absent = leave unchanged; null = clear the override.640 // Absent = leave unchanged; null = clear the override.
631 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),641 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
642 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
632 scripts: Schema.optional(Schema.Array(ProjectScript)),643 scripts: Schema.optional(Schema.Array(ProjectScript)),
633});644});
634645
...@@ -1073,6 +1084,8 @@ export const ProjectCreatedPayload = Schema.Struct({...@@ -1073,6 +1084,8 @@ export const ProjectCreatedPayload = Schema.Struct({
1073 workspaceRoot: TrimmedNonEmptyString,1084 workspaceRoot: TrimmedNonEmptyString,
1074 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),1085 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
1075 defaultModelSelection: Schema.NullOr(ModelSelection),1086 defaultModelSelection: Schema.NullOr(ModelSelection),
1087 // Optional so persisted events from older servers still decode.
1088 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
1076 scripts: Schema.Array(ProjectScript),1089 scripts: Schema.Array(ProjectScript),
1077 createdAt: IsoDateTime,1090 createdAt: IsoDateTime,
1078 updatedAt: IsoDateTime,1091 updatedAt: IsoDateTime,
...@@ -1085,6 +1098,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({...@@ -1085,6 +1098,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({
1085 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),1098 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
1086 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),1099 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
1087 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),1100 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
1101 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
1088 scripts: Schema.optional(Schema.Array(ProjectScript)),1102 scripts: Schema.optional(Schema.Array(ProjectScript)),
1089 updatedAt: IsoDateTime,1103 updatedAt: IsoDateTime,
1090});1104});
packages/contracts/src/project.ts+1
...@@ -21,6 +21,7 @@ export const ProjectSearchEntriesInput = Schema.Struct({...@@ -21,6 +21,7 @@ export const ProjectSearchEntriesInput = Schema.Struct({
21 query: TrimmedString.check(Schema.isMaxLength(256)),21 query: TrimmedString.check(Schema.isMaxLength(256)),
22 limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT)),22 limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT)),
23 kind: Schema.optional(ProjectEntryKind),23 kind: Schema.optional(ProjectEntryKind),
24 imageOnly: Schema.optional(Schema.Boolean),
24});25});
25export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type;26export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type;
2627