diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index b2fb759c5680..d6fefe2b8f86 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 9a86411e099d..018fbddfcf5a 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 8dbd1c755966..25a2748cd9e5 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 fd505400c2e5..221941aa7833 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 f256dfaa4aa0..bcc19be00da5 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 672e2d88672d..fe2acba3f9ac 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 b5645167c1cc..7cc95c8ba00e 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 bd266f261675..e3190079afc1 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 0729d1bfeaa5..670246610248 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));