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";
1818import { connectionAtomRuntime } from "../connection/runtime";
1919import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache";
2020import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state";
21import { environmentProjectCloneListAtom } from "./projectClones";
2122import { environmentSession, usePreparedConnection } from "./session";
2223import { useAtomQueryRunner } from "./use-atom-query-runner";
2324
......@@ -29,6 +30,7 @@ export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({
2930 imageCache: projectFaviconDatabaseCache,
3031 createUrl: assetEnvironment.createUrl,
3132 preparedConnection: environmentSession.preparedConnectionValueAtom,
33 projectClones: environmentProjectCloneListAtom,
3234});
3335
3436const 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)
3636 }).pipe(Atom.withLabel(`mobile-project-clones:${environmentId}`)),
3737);
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
3947const projectCloneAtom = Atom.family((key: string) => {
4048 const ref = parseScopedProjectKey(key);
4149 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
419419 readonly resource: AssetResource;
420420 readonly workspaceRoot?: string;
421421 readonly projectFaviconPath?: string;
422 /** The project's clone has not landed, so its icon is reported missing without a lookup. */
423 readonly projectCheckoutPending?: boolean;
422424}) {
423425 const fileSystem = yield* FileSystem.FileSystem;
424426 const path = yield* Path.Path;
......@@ -573,17 +575,20 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
573575 ),
574576 );
575577 const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
576 const faviconPath = yield* faviconResolver
577 .resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined)
578 .pipe(
579 Effect.mapError(
580 (cause) =>
581 new AssetProjectFaviconResolutionError({
582 resource: input.resource,
583 cause,
584 }),
585 ),
586 );
578 // A lookup in a half-cloned checkout would cache a miss that outlives the clone.
579 const faviconPath = input.projectCheckoutPending
580 ? null
581 : yield* faviconResolver
582 .resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined)
583 .pipe(
584 Effect.mapError(
585 (cause) =>
586 new AssetProjectFaviconResolutionError({
587 resource: input.resource,
588 cause,
589 }),
590 ),
591 );
587592 const isExternalOverride =
588593 faviconPath !== null &&
589594 input.projectFaviconPath !== undefined &&
apps/server/src/server.test.ts+81
......@@ -8153,6 +8153,87 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
81538153 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
81548154 );
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
81568237 it.effect("records thread analytics only after a client command succeeds", () =>
81578238 Effect.gen(function* () {
81588239 const effects: string[] = [];
apps/server/src/ws.ts+7
......@@ -3508,11 +3508,18 @@ const makeWsRpcLayer = (
35083508 resource: input.resource,
35093509 });
35103510 }
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);
35113514 return yield* issueAssetUrl({
35123515 resource: input.resource,
35133516 ...(project.value.faviconPath
35143517 ? { projectFaviconPath: project.value.faviconPath }
35153518 : {}),
3519 projectCheckoutPending:
3520 clone !== null &&
3521 clone.phase !== "done" &&
3522 clone.destinationPath === project.value.workspaceRoot,
35163523 });
35173524 }
35183525 const thread = yield* projectionSnapshotQuery
apps/web/src/state/assets.ts+2
......@@ -8,6 +8,7 @@ import { connectionAtomRuntime } from "../connection/runtime";
88import { projectFaviconCache } from "../assets/projectFaviconCache";
99import { isElectron } from "../env";
1010import { primaryEnvironmentIdAtom } from "./primaryEnvironment";
11import { environmentProjectClonesAtom } from "./projectClones";
1112import { environmentSession } from "./session";
1213
1314const localMediaEnvironment = Atom.make((get) => {
......@@ -31,4 +32,5 @@ export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({
3132 imageCache: projectFaviconCache,
3233 createUrl: assetEnvironment.createUrl,
3334 preparedConnection: environmentSession.preparedConnectionValueAtom,
35 projectClones: environmentProjectClonesAtom,
3436});
apps/web/src/state/projectClones.ts+1-1
......@@ -16,7 +16,7 @@ const EMPTY_CLONE_ATOM = Atom.make<ProjectCloneSnapshot | null>(null).pipe(
1616 * Latest clone list an environment has streamed; empty until the subscription
1717 * delivers, and never subscribed on servers that predate clone tracking.
1818 */
19const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) =>
19export const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) =>
2020 Atom.make((get): ReadonlyArray<ProjectCloneSnapshot> => {
2121 const supported =
2222 get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
packages/client-runtime/src/state/assets.test.ts+92
......@@ -6,6 +6,8 @@ import {
66 AssetWorkspaceContextNotFoundError,
77 EnvironmentAuthorizationError,
88 EnvironmentId,
9 type ProjectCloneSnapshot,
10 ProjectId,
911 ThreadId,
1012 WS_METHODS,
1113} from "@t3tools/contracts";
......@@ -409,4 +411,94 @@ describe("project favicon URL cache", () => {
409411 registry.dispose();
410412 }
411413 });
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 });
412504});
packages/client-runtime/src/state/assets.ts+21-1
......@@ -4,6 +4,7 @@ import {
44 type AssetImageDimensions,
55 AssetResource,
66 EnvironmentId,
7 type ProjectCloneSnapshot,
78 WS_METHODS,
89} from "@t3tools/contracts";
910import { mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
......@@ -181,14 +182,33 @@ export function createProjectFaviconUrlAtomFamily(input: {
181182 readonly preparedConnection: (
182183 environmentId: EnvironmentId,
183184 ) => 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>>;
184189}) {
185190 const decodeKey = Schema.decodeUnknownSync(
186191 Schema.Tuple([EnvironmentId, Schema.String, Schema.NullOr(Schema.String)]),
187192 );
193 const projectClones = input.projectClones;
188194 const family = Atom.family((key: string) => {
189195 const [environmentId, cwd, path] = decodeKey(JSON.parse(key));
190196 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;
192212 const resolvedUrl = Atom.make((get): string | null => {
193213 const result = get(request);
194214 const connection = get(input.preparedConnection(environmentId));