| 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,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 === undefined | 32 | props.workspaceRoot === null || props.workspaceRoot === undefined |
| 32 | ? null | 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 | 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 | <ProjectFavicon | 369 | <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 | <ProjectFavicon | 297 | <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 | <ProjectFavicon | 332 | <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 | <ProjectFavicon | 135 | <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 | <ProjectFavicon | 231 | <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 | <ProjectFavicon | 632 | <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 | <ProjectFavicon | 821 | <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 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { ThreadId } from "@t3tools/contracts"; | 2 | import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts"; |
| 3 | import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; | 3 | import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; |
| 4 | import { describe, expect, it } from "@effect/vitest"; | 4 | import { describe, expect, it } from "@effect/vitest"; |
| 5 | import * as Crypto from "effect/Crypto"; | 5 | import * 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 | ); |
| 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 | 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: { |
| 169 | export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { | 169 | export 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; | ||
| 179 | 181 | ||
| 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 = relativePath | 308 | 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 | }); |
| 384 | 393 |
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 | }); |
| 2768 | 2769 | ||
| 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 | SELECT | 2775 | 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_projects | 2779 | 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 !== undefined | 523 | ...(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 | ); |
| 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 | 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 !== undefined | 291 | ...(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 !== undefined | 251 | ...(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"; |
| 52 | import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; | 52 | import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; |
| 53 | import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; | 53 | import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; |
| 54 | import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; | 54 | import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; |
| 55 | import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; | ||
| 55 | 56 | ||
| 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; |
| 107 | 109 | ||
| 108 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); | 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,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 | ); |
| 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 | 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* () { |
| 168 | 169 | ||
| 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 @@ |
| 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 | import { afterEach, expect, it } from "@effect/vitest"; | 8 | import { afterEach, expect, it } from "@effect/vitest"; |
| 3 | import * as Cause from "effect/Cause"; | 9 | import * as Cause from "effect/Cause"; |
| 4 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| ... | @@ -11,6 +17,54 @@ afterEach(() => { | ... | @@ -11,6 +17,54 @@ afterEach(() => { |
| 11 | vi.restoreAllMocks(); | 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 | it.effect("preserves unexpected FileFinder creation failures", () => | 68 | it.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"; |
| 26 | import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; | ||
| 26 | 27 | ||
| 27 | const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; | 28 | const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; |
| 28 | const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; | 29 | const 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 | } |
| 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 | return { | 171 | return { |
| 162 | entries: result.items | 172 | 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 | } |
| 171 | 176 | ||
| ... | @@ -445,13 +450,13 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( | ... | @@ -445,13 +450,13 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( |
| 445 | 450 | ||
| 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* projectionSnapshotQuery | 1793 | 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"; |
| 12 | export type AssetUrlState = | 12 | export 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 }; |
| 16 | 16 | ||
| 17 | export function useAssetUrlState( | 17 | export 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 | } |
| 37 | 43 | ||
| 38 | export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { | 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,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 | <ProjectFavicon | 150 | <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"; |
| 4 | 4 | ||
| 5 | const testState = vi.hoisted(() => ({ | 5 | const 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 | })); |
| 8 | 9 | ||
| 9 | const hooks = vi.hoisted(() => { | 10 | const hooks = vi.hoisted(() => { |
| ... | @@ -52,7 +53,10 @@ vi.mock("react", async (importOriginal) => { | ... | @@ -52,7 +53,10 @@ vi.mock("react", async (importOriginal) => { |
| 52 | 53 | ||
| 53 | vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); | 54 | vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); |
| 54 | vi.mock("../assets/assetUrls", () => ({ | 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 | import { ProjectFavicon } from "./ProjectFavicon"; | 62 | import { 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 { |
| 6 | import { FolderIcon } from "lucide-react"; | 6 | import { FolderIcon } from "lucide-react"; |
| 7 | import type { ComponentType } from "react"; | 7 | import type { ComponentType } from "react"; |
| 8 | import { useState } from "react"; | 8 | import { useState } from "react"; |
| 9 | import { useAssetUrl } from "../assets/assetUrls"; | 9 | import { useAssetUrlState } from "../assets/assetUrls"; |
| 10 | import { cn } from "~/lib/utils"; | 10 | import { cn } from "~/lib/utils"; |
| 11 | 11 | ||
| 12 | const loadedProjectFaviconSrcs = new Map<string, string>(); | 12 | const 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>(); |
| 14 | export function ProjectFavicon(input: { | 14 | export 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; |
| 25 | 24 | ||
| 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 | } |
| 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 | function ProjectFaviconFallback({ | 54 | function 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 | <ProjectFavicon | 278 | <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 | <ProjectFavicon | 491 | <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 { |
| 524 | const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { | 529 | const 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 | <ProjectFavicon | 1131 | <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 | <ProjectFavicon | 1271 | <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( |
| 1454 | const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { | 1465 | const 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 | <ProjectFavicon | 1538 | <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 | <ProjectFavicon | 3251 | <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 | <ProjectFavicon | 3289 | <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}`) ?? null | 3363 | 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}`) ?? null | 3471 | 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 | <ProjectFavicon | 231 | <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 | ); | ||
| 157 | 166 | ||
| 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 @@ | ||
| 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,6 +86,7 @@ import { |
| 86 | SettingsRow, | 86 | SettingsRow, |
| 87 | SettingsSection, | 87 | SettingsSection, |
| 88 | } from "./settingsLayout"; | 88 | } from "./settingsLayout"; |
| 89 | import { ProjectFaviconPickerDialog } from "./ProjectFaviconPickerDialog"; | ||
| 89 | 90 | ||
| 90 | export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = { | 91 | export 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 | <ProjectFavicon | 222 | <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; | ||
| 298 | 301 | ||
| 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 | ); |
| 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 | // ----- 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 | <ProjectFavicon | 715 | <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> |
| 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 | <SettingsSection title="Model"> | 787 | <SettingsSection title="Model"> |
| 727 | <SettingsRow | 788 | <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 | <SettingsRow | 2392 | <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 | } |
| 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 | return ( | 211 | return ( |
| 204 | <Tooltip> | 212 | <Tooltip> |
| 205 | <TooltipTrigger | 213 | <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 | }; |
| 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 | 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) { |
| 236 | 236 | ||
| 237 | type ProjectPathSearchTarget = ComposerPathSearchTarget & { | 237 | type ProjectPathSearchTarget = ComposerPathSearchTarget & { |
| 238 | readonly kind?: ProjectEntryKind | undefined; | 238 | readonly kind?: ProjectEntryKind | undefined; |
| 239 | readonly imageOnly?: boolean | undefined; | ||
| 239 | }; | 240 | }; |
| 240 | 241 | ||
| 241 | export function areProjectPathSearchTargetsEqual( | 242 | export 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.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,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 | |||
| 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,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 @@ |
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | 2 | ||
| 3 | import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | 3 | import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 4 | import { ProjectFaviconPath } from "./orchestration.ts"; | ||
| 4 | 5 | ||
| 5 | const ASSET_PATH_MAX_LENGTH = 1024; | 6 | const ASSET_PATH_MAX_LENGTH = 1024; |
| 6 | 7 | ||
| ... | @@ -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 | ]); |
| 19 | export type AssetResource = typeof AssetResource.Type; | 23 | export 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; |
| 26 | export const AssetCreateUrlResult = Schema.Struct({ | 30 | export 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 | }); |
| 30 | export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; | 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,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 | |||
| 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,6 +211,12 @@ export const ProjectScript = Schema.Struct({ |
| 211 | }); | 211 | }); |
| 212 | export type ProjectScript = typeof ProjectScript.Type; | 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 | export const OrchestrationProject = Schema.Struct({ | 220 | export 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 means | 226 | // 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 | }); |
| 634 | 645 | ||
| ... | @@ -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 | }); |
| 25 | export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type; | 26 | export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type; |
| 26 | 27 |