From 6ea01f8d24419019588f2be589681929d8e26b0c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 1 Oct 2026 12:34:52 -0700 Subject: [PATCH] fix: cloned projects show their favicon instead of a monogram (#14635) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/mobile/src/state/assets.ts | 2 + apps/mobile/src/state/projectClones.ts | 8 ++ apps/server/src/assets/AssetAccess.ts | 27 +++--- apps/server/src/server.test.ts | 81 ++++++++++++++++ apps/server/src/ws.ts | 7 ++ apps/web/src/state/assets.ts | 2 + apps/web/src/state/projectClones.ts | 2 +- .../client-runtime/src/state/assets.test.ts | 92 +++++++++++++++++++ packages/client-runtime/src/state/assets.ts | 22 ++++- 9 files changed, 230 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index b2fb759c56806c0f93605c4ebecdf473d64f10f2..d6fefe2b8f86ada7e6befd1686b094317b87bc0b 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -18,6 +18,7 @@ import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache"; import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; +import { environmentProjectCloneListAtom } from "./projectClones"; import { environmentSession, usePreparedConnection } from "./session"; import { useAtomQueryRunner } from "./use-atom-query-runner"; @@ -29,6 +30,7 @@ export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ imageCache: projectFaviconDatabaseCache, createUrl: assetEnvironment.createUrl, preparedConnection: environmentSession.preparedConnectionValueAtom, + projectClones: environmentProjectCloneListAtom, }); const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial(false)).pipe( diff --git a/apps/mobile/src/state/projectClones.ts b/apps/mobile/src/state/projectClones.ts index 9a86411e099d7dcb1f9713b329cdd888ff039226..018fbddfcf5a2414d5cbc0c509901ad2d03fac5a 100644 --- a/apps/mobile/src/state/projectClones.ts +++ b/apps/mobile/src/state/projectClones.ts @@ -36,6 +36,14 @@ const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) }).pipe(Atom.withLabel(`mobile-project-clones:${environmentId}`)), ); +/** The environment's tracked clones, empty until the stream's first list. */ +export const environmentProjectCloneListAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get): ReadonlyArray => { + const clones = get(environmentProjectClonesAtom(environmentId)); + return clones === "pending" ? EMPTY_CLONES : clones; + }).pipe(Atom.withLabel(`mobile-project-clone-list:${environmentId}`)), +); + const projectCloneAtom = Atom.family((key: string) => { const ref = parseScopedProjectKey(key); return Atom.make((get): ProjectCloneState => { diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 8dbd1c7559667c018ebc846a6fa6d2614561208e..25a2748cd9e502c9df070bff5de42356f22b2710 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -419,6 +419,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i readonly resource: AssetResource; readonly workspaceRoot?: string; readonly projectFaviconPath?: string; + /** The project's clone has not landed, so its icon is reported missing without a lookup. */ + readonly projectCheckoutPending?: boolean; }) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -573,17 +575,20 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ), ); const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const faviconPath = yield* faviconResolver - .resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined) - .pipe( - Effect.mapError( - (cause) => - new AssetProjectFaviconResolutionError({ - resource: input.resource, - cause, - }), - ), - ); + // A lookup in a half-cloned checkout would cache a miss that outlives the clone. + const faviconPath = input.projectCheckoutPending + ? null + : yield* faviconResolver + .resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined) + .pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconResolutionError({ + resource: input.resource, + cause, + }), + ), + ); const isExternalOverride = faviconPath !== null && input.projectFaviconPath !== undefined && diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index fd505400c2e50fec2a374bf8b668bdf2f89bd4f9..221941aa78334b2803e702fd256d1b424a3d1545 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -8153,6 +8153,87 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("finds a cloned project's icon once the clone lands", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parentDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-clone-favicon-" }); + const destinationPath = path.join(parentDir, "app"); + const projectId = ProjectId.make("project-clone-favicon"); + const cloneGate = yield* Deferred.make(); + const metaUpdateDispatched = yield* Deferred.make(); + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + (command.type === "project.meta.update" + ? Deferred.succeed(metaUpdateDispatched, undefined) + : Effect.void + ).pipe(Effect.as({ sequence: 1 })), + }, + projectionSnapshotQuery: { + getActiveProjectByWorkspaceRoot: (workspaceRoot) => + Effect.succeed( + workspaceRoot === destinationPath + ? Option.some({ + ...makeDefaultOrchestrationReadModel().projects[0]!, + id: projectId, + workspaceRoot, + }) + : Option.none(), + ), + }, + sourceControlRepositoryService: { + prepareClone: (input) => + Effect.succeed({ + destinationPath: input.destinationPath, + remoteUrl: input.remoteUrl ?? "", + cloneUrl: input.remoteUrl ?? "", + repository: null, + }), + cloneRepository: (input) => + Deferred.await(cloneGate).pipe( + Effect.andThen( + fs.writeFileString(path.join(input.destinationPath, "favicon.svg"), ""), + ), + Effect.orDie, + Effect.as({ + cwd: input.destinationPath, + remoteUrl: input.remoteUrl ?? "", + repository: null, + }), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + yield* client[WS_METHODS.projectCloneStart]({ + projectId, + title: "app", + createdAt: "2026-01-01T00:00:00.000Z", + remoteUrl: "git@github.com:octocat/app.git", + destinationPath, + }); + const resource = { _tag: "project-favicon" as const, cwd: destinationPath }; + const duringClone = yield* client[WS_METHODS.assetsCreateUrl]({ resource }); + assert.isTrue(duringClone.relativeUrl.endsWith("/project-favicon-missing")); + + yield* Deferred.succeed(cloneGate, undefined); + yield* Deferred.await(metaUpdateDispatched); + // The lookup during the clone must not leave a cached miss behind. + const afterClone = yield* client[WS_METHODS.assetsCreateUrl]({ resource }); + assert.equal(afterClone.sourcePath, "favicon.svg"); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("records thread analytics only after a client command succeeds", () => Effect.gen(function* () { const effects: string[] = []; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f256dfaa4aa0dd3016a96eacdddd94d64396e84c..bcc19be00da5fc618eb6adf3912618b151230d2a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3508,11 +3508,18 @@ const makeWsRpcLayer = ( resource: input.resource, }); } + // A cloned project exists before its files do. Clients ask again + // when the clone lands (see createProjectFaviconUrlAtomFamily). + const clone = yield* projectCloneTracker.get(project.value.id); return yield* issueAssetUrl({ resource: input.resource, ...(project.value.faviconPath ? { projectFaviconPath: project.value.faviconPath } : {}), + projectCheckoutPending: + clone !== null && + clone.phase !== "done" && + clone.destinationPath === project.value.workspaceRoot, }); } const thread = yield* projectionSnapshotQuery diff --git a/apps/web/src/state/assets.ts b/apps/web/src/state/assets.ts index 672e2d88672df23bcfd1b16afa1af9582dc893e4..fe2acba3f9ac63c6517fb9861cc50c4e11385ba9 100644 --- a/apps/web/src/state/assets.ts +++ b/apps/web/src/state/assets.ts @@ -8,6 +8,7 @@ import { connectionAtomRuntime } from "../connection/runtime"; import { projectFaviconCache } from "../assets/projectFaviconCache"; import { isElectron } from "../env"; import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; +import { environmentProjectClonesAtom } from "./projectClones"; import { environmentSession } from "./session"; const localMediaEnvironment = Atom.make((get) => { @@ -31,4 +32,5 @@ export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ imageCache: projectFaviconCache, createUrl: assetEnvironment.createUrl, preparedConnection: environmentSession.preparedConnectionValueAtom, + projectClones: environmentProjectClonesAtom, }); diff --git a/apps/web/src/state/projectClones.ts b/apps/web/src/state/projectClones.ts index b5645167c1ccb504071b0020d926876327860b4e..7cc95c8ba00e047974f0b52d73e46a59727a42de 100644 --- a/apps/web/src/state/projectClones.ts +++ b/apps/web/src/state/projectClones.ts @@ -16,7 +16,7 @@ const EMPTY_CLONE_ATOM = Atom.make(null).pipe( * Latest clone list an environment has streamed; empty until the subscription * delivers, and never subscribed on servers that predate clone tracking. */ -const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) => +export const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get): ReadonlyArray => { const supported = get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index bd266f2616754cda32629f493d98b58ee045a439..e3190079afc1759790b30f18f98dd73b4f21c09b 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -6,6 +6,8 @@ import { AssetWorkspaceContextNotFoundError, EnvironmentAuthorizationError, EnvironmentId, + type ProjectCloneSnapshot, + ProjectId, ThreadId, WS_METHODS, } from "@t3tools/contracts"; @@ -409,4 +411,94 @@ describe("project favicon URL cache", () => { registry.dispose(); } }); + + const cloning: ProjectCloneSnapshot = { + projectId: ProjectId.make("project-cloning"), + remoteUrl: "git@github.com:octocat/app.git", + destinationPath: "/workspace", + repository: null, + phase: "running", + stage: "receiving", + percent: 10, + detail: null, + error: null, + startedAt: "2026-01-01T00:00:00.000Z", + endedAt: null, + sequence: 1, + }; + + function mountClonedProjectFavicon(initialClones: ReadonlyArray) { + const registry = AtomRegistry.make(); + // Stands in for the server, which reports the icon missing until the clone lands. + const server = { lookups: 0, landed: false }; + const result = Atom.make(() => { + server.lookups += 1; + return AsyncResult.success({ + expiresAt: 4_000_000_000_000, + relativeUrl: server.landed + ? "/api/assets/token-b/v1-icon.svg" + : "/api/assets/token-a/project-favicon-missing", + }); + }); + const clones = Atom.make(initialClones); + const connection = Atom.make(Option.some({ httpBaseUrl: "https://remote.test" })); + const favicon = createProjectFaviconUrlAtomFamily({ + createUrl: () => result, + preparedConnection: () => connection, + projectClones: () => clones, + })({ environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }); + const unmount = registry.mount(favicon); + return { + registry, + server, + clones, + favicon, + dispose: () => { + unmount(); + registry.dispose(); + }, + }; + } + + it("asks for a cloned project's icon again once its clone lands", () => { + const { registry, server, clones, favicon, dispose } = mountClonedProjectFavicon([cloning]); + try { + expect(registry.get(favicon)).toBe( + "https://remote.test/api/assets/token-a/project-favicon-missing", + ); + // Progress, and another folder's clone landing, do not ask again. + registry.set(clones, [ + { ...cloning, percent: 80, sequence: 2 }, + { + ...cloning, + projectId: ProjectId.make("project-other"), + destinationPath: "/other", + phase: "done", + sequence: 3, + }, + ]); + expect(server.lookups).toBe(1); + + server.landed = true; + registry.set(clones, [{ ...cloning, phase: "done", sequence: 4 }]); + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-b/v1-icon.svg"); + expect(server.lookups).toBe(2); + } finally { + dispose(); + } + }); + + it("asks again when the first clone list it sees already says done", () => { + const { registry, server, clones, favicon, dispose } = mountClonedProjectFavicon([]); + try { + expect(registry.get(favicon)).toBe( + "https://remote.test/api/assets/token-a/project-favicon-missing", + ); + server.landed = true; + registry.set(clones, [{ ...cloning, phase: "done", sequence: 2 }]); + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-b/v1-icon.svg"); + } finally { + dispose(); + } + }); }); diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index 0729d1bfeaa514fb3cb8e420fb4973165abafcc6..670246610248a7277e7265c9836dd5270af51b09 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -4,6 +4,7 @@ import { type AssetImageDimensions, AssetResource, EnvironmentId, + type ProjectCloneSnapshot, WS_METHODS, } from "@t3tools/contracts"; import { mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; @@ -181,14 +182,33 @@ export function createProjectFaviconUrlAtomFamily(input: { readonly preparedConnection: ( environmentId: EnvironmentId, ) => Atom.Atom>; + /** The environment's tracked clones, empty when it reports none. */ + readonly projectClones?: ( + environmentId: EnvironmentId, + ) => Atom.Atom>; }) { const decodeKey = Schema.decodeUnknownSync( Schema.Tuple([EnvironmentId, Schema.String, Schema.NullOr(Schema.String)]), ); + const projectClones = input.projectClones; const family = Atom.family((key: string) => { const [environmentId, cwd, path] = decodeKey(JSON.parse(key)); const resource = { _tag: "project-favicon" as const, cwd, ...(path ? { path } : {}) }; - const request = input.createUrl({ environmentId, input: { resource } }); + const query = input.createUrl({ environmentId, input: { resource } }); + // A cloned project exists before its files do, and the server reports its + // icon missing until the clone lands. Ask again whenever the clone's phase + // changes: the first list a client sees may already say done. + const request = projectClones + ? query.pipe( + Atom.makeRefreshOnSignal( + Atom.make( + (get) => + get(projectClones(environmentId)).find((clone) => clone.destinationPath === cwd) + ?.phase ?? null, + ), + ), + ) + : query; const resolvedUrl = Atom.make((get): string | null => { const result = get(request); const connection = get(input.preparedConnection(environmentId)); -- 2.54.0