authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-01 12:34:52-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-01 12:34:52-07:00
log6ea01f8d24419019588f2be589681929d8e26b0c
tree5dbf96b592533f8483a036b35176f3446bc621b6
parenta97a4a9d189f145afe79c3a6f8533bbf9a6b40b1
signature Signed by PGP key B5690EEEBB952194

fix: cloned projects show their favicon instead of a monogram (#14635)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

9 files changed, 230 insertions(+), 13 deletions(-)

apps/mobile/src/state/assets.ts+2
...@@ -18,6 +18,7 @@ import { environmentCatalog } from "../connection/catalog";...@@ -18,6 +18,7 @@ import { environmentCatalog } from "../connection/catalog";
18import { connectionAtomRuntime } from "../connection/runtime";18import { connectionAtomRuntime } from "../connection/runtime";
19import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache";19import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache";
20import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state";20import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state";
21import { environmentProjectCloneListAtom } from "./projectClones";
21import { environmentSession, usePreparedConnection } from "./session";22import { environmentSession, usePreparedConnection } from "./session";
22import { useAtomQueryRunner } from "./use-atom-query-runner";23import { useAtomQueryRunner } from "./use-atom-query-runner";
2324
...@@ -29,6 +30,7 @@ export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({...@@ -29,6 +30,7 @@ export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({
29 imageCache: projectFaviconDatabaseCache,30 imageCache: projectFaviconDatabaseCache,
30 createUrl: assetEnvironment.createUrl,31 createUrl: assetEnvironment.createUrl,
31 preparedConnection: environmentSession.preparedConnectionValueAtom,32 preparedConnection: environmentSession.preparedConnectionValueAtom,
33 projectClones: environmentProjectCloneListAtom,
32});34});
3335
34const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe(36const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe(
apps/mobile/src/state/projectClones.ts+8
...@@ -36,6 +36,14 @@ const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId)...@@ -36,6 +36,14 @@ const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId)
36 }).pipe(Atom.withLabel(`mobile-project-clones:${environmentId}`)),36 }).pipe(Atom.withLabel(`mobile-project-clones:${environmentId}`)),
37);37);
3838
39/** The environment's tracked clones, empty until the stream's first list. */
40export const environmentProjectCloneListAtom = Atom.family((environmentId: EnvironmentId) =>
41 Atom.make((get): ReadonlyArray<ProjectCloneSnapshot> => {
42 const clones = get(environmentProjectClonesAtom(environmentId));
43 return clones === "pending" ? EMPTY_CLONES : clones;
44 }).pipe(Atom.withLabel(`mobile-project-clone-list:${environmentId}`)),
45);
46
39const projectCloneAtom = Atom.family((key: string) => {47const projectCloneAtom = Atom.family((key: string) => {
40 const ref = parseScopedProjectKey(key);48 const ref = parseScopedProjectKey(key);
41 return Atom.make((get): ProjectCloneState => {49 return Atom.make((get): ProjectCloneState => {
apps/server/src/assets/AssetAccess.ts+16-11
...@@ -419,6 +419,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -419,6 +419,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
419 readonly resource: AssetResource;419 readonly resource: AssetResource;
420 readonly workspaceRoot?: string;420 readonly workspaceRoot?: string;
421 readonly projectFaviconPath?: string;421 readonly projectFaviconPath?: string;
422 /** The project's clone has not landed, so its icon is reported missing without a lookup. */
423 readonly projectCheckoutPending?: boolean;
422}) {424}) {
423 const fileSystem = yield* FileSystem.FileSystem;425 const fileSystem = yield* FileSystem.FileSystem;
424 const path = yield* Path.Path;426 const path = yield* Path.Path;
...@@ -573,17 +575,20 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -573,17 +575,20 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
573 ),575 ),
574 );576 );
575 const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;577 const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
576 const faviconPath = yield* faviconResolver578 // A lookup in a half-cloned checkout would cache a miss that outlives the clone.
577 .resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined)579 const faviconPath = input.projectCheckoutPending
578 .pipe(580 ? null
579 Effect.mapError(581 : yield* faviconResolver
580 (cause) =>582 .resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined)
581 new AssetProjectFaviconResolutionError({583 .pipe(
582 resource: input.resource,584 Effect.mapError(
583 cause,585 (cause) =>
584 }),586 new AssetProjectFaviconResolutionError({
585 ),587 resource: input.resource,
586 );588 cause,
589 }),
590 ),
591 );
587 const isExternalOverride =592 const isExternalOverride =
588 faviconPath !== null &&593 faviconPath !== null &&
589 input.projectFaviconPath !== undefined &&594 input.projectFaviconPath !== undefined &&
apps/server/src/server.test.ts+81
...@@ -8153,6 +8153,87 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -8153,6 +8153,87 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
8153 }).pipe(Effect.provide(NodeHttpServer.layerTest)),8153 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
8154 );8154 );
81558155
8156 it.effect("finds a cloned project's icon once the clone lands", () =>
8157 Effect.gen(function* () {
8158 const fs = yield* FileSystem.FileSystem;
8159 const path = yield* Path.Path;
8160 const parentDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-clone-favicon-" });
8161 const destinationPath = path.join(parentDir, "app");
8162 const projectId = ProjectId.make("project-clone-favicon");
8163 const cloneGate = yield* Deferred.make<void>();
8164 const metaUpdateDispatched = yield* Deferred.make<void>();
8165
8166 yield* buildAppUnderTest({
8167 layers: {
8168 orchestrationEngine: {
8169 dispatch: (command) =>
8170 (command.type === "project.meta.update"
8171 ? Deferred.succeed(metaUpdateDispatched, undefined)
8172 : Effect.void
8173 ).pipe(Effect.as({ sequence: 1 })),
8174 },
8175 projectionSnapshotQuery: {
8176 getActiveProjectByWorkspaceRoot: (workspaceRoot) =>
8177 Effect.succeed(
8178 workspaceRoot === destinationPath
8179 ? Option.some({
8180 ...makeDefaultOrchestrationReadModel().projects[0]!,
8181 id: projectId,
8182 workspaceRoot,
8183 })
8184 : Option.none(),
8185 ),
8186 },
8187 sourceControlRepositoryService: {
8188 prepareClone: (input) =>
8189 Effect.succeed({
8190 destinationPath: input.destinationPath,
8191 remoteUrl: input.remoteUrl ?? "",
8192 cloneUrl: input.remoteUrl ?? "",
8193 repository: null,
8194 }),
8195 cloneRepository: (input) =>
8196 Deferred.await(cloneGate).pipe(
8197 Effect.andThen(
8198 fs.writeFileString(path.join(input.destinationPath, "favicon.svg"), "<svg/>"),
8199 ),
8200 Effect.orDie,
8201 Effect.as({
8202 cwd: input.destinationPath,
8203 remoteUrl: input.remoteUrl ?? "",
8204 repository: null,
8205 }),
8206 ),
8207 },
8208 },
8209 });
8210
8211 const wsUrl = yield* getWsServerUrl("/ws");
8212 yield* Effect.scoped(
8213 withWsRpcClient(wsUrl, (client) =>
8214 Effect.gen(function* () {
8215 yield* client[WS_METHODS.projectCloneStart]({
8216 projectId,
8217 title: "app",
8218 createdAt: "2026-01-01T00:00:00.000Z",
8219 remoteUrl: "git@github.com:octocat/app.git",
8220 destinationPath,
8221 });
8222 const resource = { _tag: "project-favicon" as const, cwd: destinationPath };
8223 const duringClone = yield* client[WS_METHODS.assetsCreateUrl]({ resource });
8224 assert.isTrue(duringClone.relativeUrl.endsWith("/project-favicon-missing"));
8225
8226 yield* Deferred.succeed(cloneGate, undefined);
8227 yield* Deferred.await(metaUpdateDispatched);
8228 // The lookup during the clone must not leave a cached miss behind.
8229 const afterClone = yield* client[WS_METHODS.assetsCreateUrl]({ resource });
8230 assert.equal(afterClone.sourcePath, "favicon.svg");
8231 }),
8232 ),
8233 );
8234 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
8235 );
8236
8156 it.effect("records thread analytics only after a client command succeeds", () =>8237 it.effect("records thread analytics only after a client command succeeds", () =>
8157 Effect.gen(function* () {8238 Effect.gen(function* () {
8158 const effects: string[] = [];8239 const effects: string[] = [];
apps/server/src/ws.ts+7
...@@ -3508,11 +3508,18 @@ const makeWsRpcLayer = (...@@ -3508,11 +3508,18 @@ const makeWsRpcLayer = (
3508 resource: input.resource,3508 resource: input.resource,
3509 });3509 });
3510 }3510 }
3511 // A cloned project exists before its files do. Clients ask again
3512 // when the clone lands (see createProjectFaviconUrlAtomFamily).
3513 const clone = yield* projectCloneTracker.get(project.value.id);
3511 return yield* issueAssetUrl({3514 return yield* issueAssetUrl({
3512 resource: input.resource,3515 resource: input.resource,
3513 ...(project.value.faviconPath3516 ...(project.value.faviconPath
3514 ? { projectFaviconPath: project.value.faviconPath }3517 ? { projectFaviconPath: project.value.faviconPath }
3515 : {}),3518 : {}),
3519 projectCheckoutPending:
3520 clone !== null &&
3521 clone.phase !== "done" &&
3522 clone.destinationPath === project.value.workspaceRoot,
3516 });3523 });
3517 }3524 }
3518 const thread = yield* projectionSnapshotQuery3525 const thread = yield* projectionSnapshotQuery
apps/web/src/state/assets.ts+2
...@@ -8,6 +8,7 @@ import { connectionAtomRuntime } from "../connection/runtime";...@@ -8,6 +8,7 @@ import { connectionAtomRuntime } from "../connection/runtime";
8import { projectFaviconCache } from "../assets/projectFaviconCache";8import { projectFaviconCache } from "../assets/projectFaviconCache";
9import { isElectron } from "../env";9import { isElectron } from "../env";
10import { primaryEnvironmentIdAtom } from "./primaryEnvironment";10import { primaryEnvironmentIdAtom } from "./primaryEnvironment";
11import { environmentProjectClonesAtom } from "./projectClones";
11import { environmentSession } from "./session";12import { environmentSession } from "./session";
1213
13const localMediaEnvironment = Atom.make((get) => {14const localMediaEnvironment = Atom.make((get) => {
...@@ -31,4 +32,5 @@ export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({...@@ -31,4 +32,5 @@ export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({
31 imageCache: projectFaviconCache,32 imageCache: projectFaviconCache,
32 createUrl: assetEnvironment.createUrl,33 createUrl: assetEnvironment.createUrl,
33 preparedConnection: environmentSession.preparedConnectionValueAtom,34 preparedConnection: environmentSession.preparedConnectionValueAtom,
35 projectClones: environmentProjectClonesAtom,
34});36});
apps/web/src/state/projectClones.ts+1-1
...@@ -16,7 +16,7 @@ const EMPTY_CLONE_ATOM = Atom.make<ProjectCloneSnapshot | null>(null).pipe(...@@ -16,7 +16,7 @@ const EMPTY_CLONE_ATOM = Atom.make<ProjectCloneSnapshot | null>(null).pipe(
16 * Latest clone list an environment has streamed; empty until the subscription16 * Latest clone list an environment has streamed; empty until the subscription
17 * delivers, and never subscribed on servers that predate clone tracking.17 * delivers, and never subscribed on servers that predate clone tracking.
18 */18 */
19const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) =>19export const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) =>
20 Atom.make((get): ReadonlyArray<ProjectCloneSnapshot> => {20 Atom.make((get): ReadonlyArray<ProjectCloneSnapshot> => {
21 const supported =21 const supported =
22 get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities22 get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
packages/client-runtime/src/state/assets.test.ts+92
...@@ -6,6 +6,8 @@ import {...@@ -6,6 +6,8 @@ import {
6 AssetWorkspaceContextNotFoundError,6 AssetWorkspaceContextNotFoundError,
7 EnvironmentAuthorizationError,7 EnvironmentAuthorizationError,
8 EnvironmentId,8 EnvironmentId,
9 type ProjectCloneSnapshot,
10 ProjectId,
9 ThreadId,11 ThreadId,
10 WS_METHODS,12 WS_METHODS,
11} from "@t3tools/contracts";13} from "@t3tools/contracts";
...@@ -409,4 +411,94 @@ describe("project favicon URL cache", () => {...@@ -409,4 +411,94 @@ describe("project favicon URL cache", () => {
409 registry.dispose();411 registry.dispose();
410 }412 }
411 });413 });
414
415 const cloning: ProjectCloneSnapshot = {
416 projectId: ProjectId.make("project-cloning"),
417 remoteUrl: "git@github.com:octocat/app.git",
418 destinationPath: "/workspace",
419 repository: null,
420 phase: "running",
421 stage: "receiving",
422 percent: 10,
423 detail: null,
424 error: null,
425 startedAt: "2026-01-01T00:00:00.000Z",
426 endedAt: null,
427 sequence: 1,
428 };
429
430 function mountClonedProjectFavicon(initialClones: ReadonlyArray<ProjectCloneSnapshot>) {
431 const registry = AtomRegistry.make();
432 // Stands in for the server, which reports the icon missing until the clone lands.
433 const server = { lookups: 0, landed: false };
434 const result = Atom.make(() => {
435 server.lookups += 1;
436 return AsyncResult.success({
437 expiresAt: 4_000_000_000_000,
438 relativeUrl: server.landed
439 ? "/api/assets/token-b/v1-icon.svg"
440 : "/api/assets/token-a/project-favicon-missing",
441 });
442 });
443 const clones = Atom.make(initialClones);
444 const connection = Atom.make(Option.some({ httpBaseUrl: "https://remote.test" }));
445 const favicon = createProjectFaviconUrlAtomFamily({
446 createUrl: () => result,
447 preparedConnection: () => connection,
448 projectClones: () => clones,
449 })({ environmentId: EnvironmentId.make("remote"), cwd: "/workspace" });
450 const unmount = registry.mount(favicon);
451 return {
452 registry,
453 server,
454 clones,
455 favicon,
456 dispose: () => {
457 unmount();
458 registry.dispose();
459 },
460 };
461 }
462
463 it("asks for a cloned project's icon again once its clone lands", () => {
464 const { registry, server, clones, favicon, dispose } = mountClonedProjectFavicon([cloning]);
465 try {
466 expect(registry.get(favicon)).toBe(
467 "https://remote.test/api/assets/token-a/project-favicon-missing",
468 );
469 // Progress, and another folder's clone landing, do not ask again.
470 registry.set(clones, [
471 { ...cloning, percent: 80, sequence: 2 },
472 {
473 ...cloning,
474 projectId: ProjectId.make("project-other"),
475 destinationPath: "/other",
476 phase: "done",
477 sequence: 3,
478 },
479 ]);
480 expect(server.lookups).toBe(1);
481
482 server.landed = true;
483 registry.set(clones, [{ ...cloning, phase: "done", sequence: 4 }]);
484 expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-b/v1-icon.svg");
485 expect(server.lookups).toBe(2);
486 } finally {
487 dispose();
488 }
489 });
490
491 it("asks again when the first clone list it sees already says done", () => {
492 const { registry, server, clones, favicon, dispose } = mountClonedProjectFavicon([]);
493 try {
494 expect(registry.get(favicon)).toBe(
495 "https://remote.test/api/assets/token-a/project-favicon-missing",
496 );
497 server.landed = true;
498 registry.set(clones, [{ ...cloning, phase: "done", sequence: 2 }]);
499 expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-b/v1-icon.svg");
500 } finally {
501 dispose();
502 }
503 });
412});504});
packages/client-runtime/src/state/assets.ts+21-1
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 type AssetImageDimensions,4 type AssetImageDimensions,
5 AssetResource,5 AssetResource,
6 EnvironmentId,6 EnvironmentId,
7 type ProjectCloneSnapshot,
7 WS_METHODS,8 WS_METHODS,
8} from "@t3tools/contracts";9} from "@t3tools/contracts";
9import { mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";10import { mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
...@@ -181,14 +182,33 @@ export function createProjectFaviconUrlAtomFamily(input: {...@@ -181,14 +182,33 @@ export function createProjectFaviconUrlAtomFamily(input: {
181 readonly preparedConnection: (182 readonly preparedConnection: (
182 environmentId: EnvironmentId,183 environmentId: EnvironmentId,
183 ) => Atom.Atom<Option.Option<{ readonly httpBaseUrl: string }>>;184 ) => Atom.Atom<Option.Option<{ readonly httpBaseUrl: string }>>;
185 /** The environment's tracked clones, empty when it reports none. */
186 readonly projectClones?: (
187 environmentId: EnvironmentId,
188 ) => Atom.Atom<ReadonlyArray<ProjectCloneSnapshot>>;
184}) {189}) {
185 const decodeKey = Schema.decodeUnknownSync(190 const decodeKey = Schema.decodeUnknownSync(
186 Schema.Tuple([EnvironmentId, Schema.String, Schema.NullOr(Schema.String)]),191 Schema.Tuple([EnvironmentId, Schema.String, Schema.NullOr(Schema.String)]),
187 );192 );
193 const projectClones = input.projectClones;
188 const family = Atom.family((key: string) => {194 const family = Atom.family((key: string) => {
189 const [environmentId, cwd, path] = decodeKey(JSON.parse(key));195 const [environmentId, cwd, path] = decodeKey(JSON.parse(key));
190 const resource = { _tag: "project-favicon" as const, cwd, ...(path ? { path } : {}) };196 const resource = { _tag: "project-favicon" as const, cwd, ...(path ? { path } : {}) };
191 const request = input.createUrl({ environmentId, input: { resource } });197 const query = input.createUrl({ environmentId, input: { resource } });
198 // A cloned project exists before its files do, and the server reports its
199 // icon missing until the clone lands. Ask again whenever the clone's phase
200 // changes: the first list a client sees may already say done.
201 const request = projectClones
202 ? query.pipe(
203 Atom.makeRefreshOnSignal(
204 Atom.make(
205 (get) =>
206 get(projectClones(environmentId)).find((clone) => clone.destinationPath === cwd)
207 ?.phase ?? null,
208 ),
209 ),
210 )
211 : query;
192 const resolvedUrl = Atom.make((get): string | null => {212 const resolvedUrl = Atom.make((get): string | null => {
193 const result = get(request);213 const result = get(request);
194 const connection = get(input.preparedConnection(environmentId));214 const connection = get(input.preparedConnection(environmentId));