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