From 9fb20cfe18b516a34f7cb76c41790b1bdca5bc56 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:13:50 -0400 Subject: [PATCH 01/26] fix: cache project favicons across web and mobile (#4767) --- apps/mobile/src/components/ProjectFavicon.tsx | 57 ++++++-- .../components/projectFaviconCache.test.ts | 108 +++++++++++++++ .../src/components/projectFaviconCache.ts | 94 +++++++++++++ apps/server/src/assets/AssetAccess.test.ts | 46 ++++++- apps/server/src/assets/AssetAccess.ts | 63 +++++++-- .../src/components/ProjectFavicon.test.tsx | 128 ++++++++++++++++++ apps/web/src/components/ProjectFavicon.tsx | 61 ++++++--- packages/shared/src/projectFavicon.test.ts | 26 +++- packages/shared/src/projectFavicon.ts | 17 +++ 9 files changed, 552 insertions(+), 48 deletions(-) create mode 100644 apps/mobile/src/components/projectFaviconCache.test.ts create mode 100644 apps/mobile/src/components/projectFaviconCache.ts create mode 100644 apps/web/src/components/ProjectFavicon.test.tsx diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 772d5e8cc14c..d52aa05b4466 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,14 +1,21 @@ import { SymbolView } from "./AppSymbol"; import { Image } from "expo-image"; -import { useState } from "react"; +import { useLayoutEffect, useMemo, useState } from "react"; import { View } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; -import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; +import { + getProjectFaviconCacheKey, + isProjectFaviconFallbackUrl, +} from "@t3tools/shared/projectFavicon"; import { useThemeColor } from "../lib/useThemeColor"; import { useAssetUrl } from "../state/assets"; - -/* ─── Favicon cache (matches web pattern) ────────────────────────────── */ -const loadedFaviconUrls = new Set(); +import { + beginProjectFaviconRequest, + createProjectFaviconRequest, + hasLoadedProjectFavicon, + markProjectFaviconFailed, + markProjectFaviconLoaded, +} from "./projectFaviconCache"; /* ─── Component ──────────────────────────────────────────────────────── */ export function ProjectFavicon(props: { @@ -26,10 +33,15 @@ export function ProjectFavicon(props: { : { _tag: "project-favicon", cwd: props.workspaceRoot }, ); const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; + const cacheKey = + renderableFaviconUrl && props.workspaceRoot + ? getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) + : null; return ( createProjectFaviconRequest(props.cacheKey, props.faviconUrl), + [props.cacheKey, props.faviconUrl], + ); + const [activeFaviconRequest, setActiveFaviconRequest] = useState(null); + useLayoutEffect(() => { + if (faviconRequest === null) return; + + const endRequest = beginProjectFaviconRequest(faviconRequest); + setActiveFaviconRequest(faviconRequest); + return endRequest; + }, [faviconRequest]); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - props.faviconUrl && loadedFaviconUrls.has(props.faviconUrl) ? "loaded" : "loading", + hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading", ); - const showImage = props.faviconUrl !== null && status === "loaded"; + const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest; + const showImage = requestIsActive && status === "loaded"; return ( { - if (props.faviconUrl) loadedFaviconUrls.add(props.faviconUrl); + if (!markProjectFaviconLoaded(faviconRequest)) return; setStatus("loaded"); }} - onError={() => setStatus("error")} + onError={() => { + if (!markProjectFaviconFailed(faviconRequest)) return; + setStatus("error"); + }} /> ) : null} diff --git a/apps/mobile/src/components/projectFaviconCache.test.ts b/apps/mobile/src/components/projectFaviconCache.test.ts new file mode 100644 index 000000000000..d0582a8b5f5b --- /dev/null +++ b/apps/mobile/src/components/projectFaviconCache.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + beginProjectFaviconRequest, + createProjectFaviconRequest, + hasLoadedProjectFavicon, + markProjectFaviconFailed, + markProjectFaviconLoaded, +} from "./projectFaviconCache"; + +describe("project favicon cache", () => { + it("ignores callbacks from a superseded URL", () => { + const cacheKey = "environment-1:/workspace:v1-favicon.svg"; + const expiredUrl = "https://environment.example/api/assets/expired/v1-favicon.svg"; + const refreshedUrl = "https://environment.example/api/assets/refreshed/v1-favicon.svg"; + + const expiredRequest = createProjectFaviconRequest(cacheKey, expiredUrl); + const endExpiredRequest = beginProjectFaviconRequest(expiredRequest); + markProjectFaviconLoaded(expiredRequest); + const refreshedRequest = createProjectFaviconRequest(cacheKey, refreshedUrl); + const endRefreshedRequest = beginProjectFaviconRequest(refreshedRequest); + + expect(markProjectFaviconLoaded(expiredRequest)).toBe(false); + expect(markProjectFaviconFailed(expiredRequest)).toBe(false); + expect(hasLoadedProjectFavicon(cacheKey)).toBe(true); + expect(markProjectFaviconFailed(refreshedRequest)).toBe(true); + expect(hasLoadedProjectFavicon(cacheKey)).toBe(false); + + endRefreshedRequest(); + endExpiredRequest(); + }); + + it("evicts the URL that actually failed", () => { + const cacheKey = "environment-1:/workspace:v2-favicon.svg"; + const faviconUrl = "https://environment.example/api/assets/current/v2-favicon.svg"; + const request = createProjectFaviconRequest(cacheKey, faviconUrl); + const endRequest = beginProjectFaviconRequest(request); + + markProjectFaviconLoaded(request); + + expect(markProjectFaviconFailed(request)).toBe(true); + expect(hasLoadedProjectFavicon(cacheKey)).toBe(false); + + endRequest(); + }); + + it("does not supersede a request until the next request begins", () => { + const cacheKey = "environment-1:/workspace:v3-favicon.svg"; + const committedUrl = "https://environment.example/api/assets/current/v3-favicon.svg"; + const abandonedUrl = "https://environment.example/api/assets/abandoned/v3-favicon.svg"; + const committedRequest = createProjectFaviconRequest(cacheKey, committedUrl); + const endCommittedRequest = beginProjectFaviconRequest(committedRequest); + + createProjectFaviconRequest(cacheKey, abandonedUrl); + + expect(markProjectFaviconLoaded(committedRequest)).toBe(true); + expect(hasLoadedProjectFavicon(cacheKey)).toBe(true); + + endCommittedRequest(); + }); + + it("requires a cache key before creating a URL-bearing request", () => { + const firstUrl = "https://environment.example/api/assets/first/favicon.svg"; + const secondUrl = "https://environment.example/api/assets/second/favicon.svg"; + + expect(createProjectFaviconRequest(null, firstUrl)).toBeNull(); + expect(createProjectFaviconRequest(null, secondUrl)).toBeNull(); + }); + + it("restores the remaining active URL when a newer request ends", () => { + const cacheKey = "environment-1:/workspace:v4-favicon.svg"; + const firstRequest = createProjectFaviconRequest( + cacheKey, + "https://environment.example/api/assets/first/v4-favicon.svg", + ); + const secondRequest = createProjectFaviconRequest( + cacheKey, + "https://environment.example/api/assets/second/v4-favicon.svg", + ); + const endFirstRequest = beginProjectFaviconRequest(firstRequest); + const endSecondRequest = beginProjectFaviconRequest(secondRequest); + + expect(markProjectFaviconLoaded(firstRequest)).toBe(false); + endSecondRequest(); + expect(markProjectFaviconLoaded(firstRequest)).toBe(true); + endFirstRequest(); + expect(markProjectFaviconLoaded(firstRequest)).toBe(false); + }); + + it("bounds remembered loaded revisions", () => { + const firstCacheKey = "environment-1:/workspace:revision-0"; + let lastCacheKey = firstCacheKey; + + for (let revision = 0; revision < 300; revision++) { + lastCacheKey = `environment-1:/workspace:revision-${revision}`; + const request = createProjectFaviconRequest( + lastCacheKey, + `https://environment.example/api/assets/revision-${revision}/favicon.svg`, + ); + const endRequest = beginProjectFaviconRequest(request); + markProjectFaviconLoaded(request); + endRequest(); + } + + expect(hasLoadedProjectFavicon(firstCacheKey)).toBe(false); + expect(hasLoadedProjectFavicon(lastCacheKey)).toBe(true); + }); +}); diff --git a/apps/mobile/src/components/projectFaviconCache.ts b/apps/mobile/src/components/projectFaviconCache.ts new file mode 100644 index 000000000000..da77d7613f2d --- /dev/null +++ b/apps/mobile/src/components/projectFaviconCache.ts @@ -0,0 +1,94 @@ +export interface ProjectFaviconRequest { + readonly cacheKey: string; + readonly faviconUrl: string; +} + +interface ActiveFaviconRequests { + readonly urls: Map; + currentUrl: string; +} + +const MAX_LOADED_FAVICONS = 256; +const activeFaviconRequests = new Map(); +const loadedFaviconKeys = new Map(); + +export function createProjectFaviconRequest( + cacheKey: string, + faviconUrl: string, +): ProjectFaviconRequest; +export function createProjectFaviconRequest( + cacheKey: string | null, + faviconUrl: string | null, +): ProjectFaviconRequest | null; +export function createProjectFaviconRequest(cacheKey: string | null, faviconUrl: string | null) { + if (!cacheKey || !faviconUrl) return null; + return { cacheKey, faviconUrl }; +} + +export function beginProjectFaviconRequest(request: ProjectFaviconRequest) { + let activeRequests = activeFaviconRequests.get(request.cacheKey); + if (!activeRequests) { + activeRequests = { currentUrl: request.faviconUrl, urls: new Map() }; + activeFaviconRequests.set(request.cacheKey, activeRequests); + } + + const activeCount = activeRequests.urls.get(request.faviconUrl) ?? 0; + activeRequests.urls.delete(request.faviconUrl); + activeRequests.urls.set(request.faviconUrl, activeCount + 1); + activeRequests.currentUrl = request.faviconUrl; + + let ended = false; + return () => { + if (ended) return; + ended = true; + + const remainingCount = (activeRequests.urls.get(request.faviconUrl) ?? 1) - 1; + if (remainingCount > 0) { + activeRequests.urls.set(request.faviconUrl, remainingCount); + return; + } + + activeRequests.urls.delete(request.faviconUrl); + if (activeRequests.urls.size === 0) { + if (activeFaviconRequests.get(request.cacheKey) === activeRequests) { + activeFaviconRequests.delete(request.cacheKey); + } + return; + } + + if (activeRequests.currentUrl === request.faviconUrl) { + activeRequests.currentUrl = Array.from(activeRequests.urls.keys()).at(-1)!; + } + }; +} + +export function hasLoadedProjectFavicon(cacheKey: string | null) { + return cacheKey !== null && loadedFaviconKeys.has(cacheKey); +} + +function isCurrentProjectFaviconRequest(request: ProjectFaviconRequest) { + return activeFaviconRequests.get(request.cacheKey)?.currentUrl === request.faviconUrl; +} + +function rememberLoadedProjectFavicon(cacheKey: string) { + loadedFaviconKeys.delete(cacheKey); + loadedFaviconKeys.set(cacheKey, true); + + if (loadedFaviconKeys.size > MAX_LOADED_FAVICONS) { + loadedFaviconKeys.delete(loadedFaviconKeys.keys().next().value!); + } +} + +export function markProjectFaviconLoaded(request: ProjectFaviconRequest) { + if (!isCurrentProjectFaviconRequest(request)) return false; + + rememberLoadedProjectFavicon(request.cacheKey); + return true; +} + +export function markProjectFaviconFailed(request: ProjectFaviconRequest) { + if (!isCurrentProjectFaviconRequest(request)) return false; + + loadedFaviconKeys.delete(request.cacheKey); + return true; +} diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 42fd3f900e5a..568dc3739c00 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -2,11 +2,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ThreadId } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as TestClock from "effect/testing/TestClock"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; @@ -214,12 +216,21 @@ describe("AssetAccess", () => { prefix: "t3-asset-favicon-", }); const faviconPath = path.join(root, "favicon.svg"); - yield* fileSystem.writeFileString(faviconPath, ""); + const initialFavicon = "a"; + const updatedFavicon = "b"; + expect(updatedFavicon).toHaveLength(initialFavicon.length); + yield* fileSystem.writeFileString(faviconPath, initialFavicon); const canonicalFaviconPath = yield* fileSystem.realPath(faviconPath); const faviconResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, }); + expect(faviconResult.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/); + expect( + yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }), + ).toEqual(faviconResult); const faviconSuffix = faviconResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); const faviconSeparatorIndex = faviconSuffix.indexOf("/"); expect( @@ -229,6 +240,14 @@ describe("AssetAccess", () => { ), ).toEqual({ kind: "file", path: canonicalFaviconPath }); + yield* fileSystem.writeFileString(faviconPath, updatedFavicon); + const updatedFaviconResult = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }); + expect( + updatedFaviconResult.relativeUrl.slice(updatedFaviconResult.relativeUrl.lastIndexOf("/")), + ).not.toBe(faviconResult.relativeUrl.slice(faviconResult.relativeUrl.lastIndexOf("/"))); + yield* fileSystem.remove(faviconPath); const fallbackResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, @@ -245,6 +264,31 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("buckets project favicon expiry after content hashing", () => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-expiry-", + }); + yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), ""); + + const bucketMs = 30 * 60 * 1000; + yield* TestClock.setTime(bucketMs - 1); + const crossingCrypto = Crypto.make({ + randomBytes: (size) => new Uint8Array(size), + digest: (algorithm, data) => + TestClock.adjust("2 millis").pipe(Effect.andThen(crypto.digest(algorithm, data))), + }); + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }).pipe(Effect.provideService(Crypto.Crypto, crossingCrypto)); + + expect(result.expiresAt).toBe(3 * bucketMs); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("preserves structured project favicon resolution causes", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index b469e0e315b8..c00f7f1a5e3c 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -21,7 +21,9 @@ import { } from "@t3tools/shared/filePreview"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import * as Clock from "effect/Clock"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -44,6 +46,8 @@ export const ASSET_ROUTE_PREFIX = "/api/assets"; const SIGNING_SECRET_NAME = "asset-access-signing-key"; const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; +const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000; +const PROJECT_FAVICON_VERSION_PREFIX = "v"; const PREVIEW_ASSET_EXTENSIONS = new Set([ ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, @@ -169,7 +173,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; - const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; + let expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; let claims: AssetClaims; let fileName: string; @@ -293,18 +297,18 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ), ); const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; - if ( - relativePath && - !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( - Effect.mapError( - (cause) => - new AssetProjectFaviconInspectionError({ - resource: input.resource, - cause, - }), - ), - )) - ) { + const canonicalFaviconPath = relativePath + ? yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconInspectionError({ + resource: input.resource, + cause, + }), + ), + ) + : null; + if (relativePath && !canonicalFaviconPath) { return yield* new AssetProjectFaviconNotFoundError({ resource: input.resource, }); @@ -324,7 +328,31 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i relativePath, expiresAt, }; - fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER; + if (relativePath && canonicalFaviconPath) { + const crypto = yield* Crypto.Crypto; + const faviconBytes = yield* fileSystem.readFile(canonicalFaviconPath).pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconInspectionError({ + resource: input.resource, + cause, + }), + ), + ); + const revision = yield* crypto.digest("SHA-256", faviconBytes).pipe( + Effect.map(Encoding.encodeHex), + Effect.mapError( + (cause) => + new AssetProjectFaviconInspectionError({ + resource: input.resource, + cause, + }), + ), + ); + fileName = `${PROJECT_FAVICON_VERSION_PREFIX}${revision}-${path.basename(relativePath)}`; + } else { + fileName = PROJECT_FAVICON_FALLBACK_MARKER; + } break; } } @@ -339,6 +367,13 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); + if (claims.kind === "project-favicon") { + const issuedAt = yield* Clock.currentTimeMillis; + expiresAt = + (Math.floor(issuedAt / PROJECT_FAVICON_TOKEN_BUCKET_MS) + 2) * + PROJECT_FAVICON_TOKEN_BUCKET_MS; + claims = { ...claims, expiresAt }; + } const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`; return { diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx new file mode 100644 index 000000000000..c2fac8beb7ec --- /dev/null +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -0,0 +1,128 @@ +import type { ComponentType, Dispatch, ReactElement, SetStateAction } from "react"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { EnvironmentId } from "@t3tools/contracts"; + +const testState = vi.hoisted(() => ({ + faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg", +})); + +const hooks = vi.hoisted(() => { + let cursor = 0; + let slots: unknown[] = []; + const nextIndex = () => cursor++; + + return { + beginRender() { + cursor = 0; + }, + reset() { + cursor = 0; + slots = []; + }, + useMemoCache(size: number): unknown[] { + const index = nextIndex(); + if (!slots[index]) { + slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel")); + } + return slots[index] as unknown[]; + }, + useState(initialValue: T | (() => T)): [T, Dispatch>] { + const index = nextIndex(); + if (index >= slots.length) { + slots[index] = + typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; + } + const setValue: Dispatch> = (nextValue) => { + const previous = slots[index] as T; + slots[index] = + typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue; + }; + return [slots[index] as T, setValue]; + }, + }; +}); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useState: hooks.useState, + }; +}); + +vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); +vi.mock("../assets/assetUrls", () => ({ + useAssetUrl: () => testState.faviconUrl, +})); + +import { ProjectFavicon } from "./ProjectFavicon"; + +type ProjectFaviconImageProps = { + readonly cacheKey: string; + readonly src: string; + readonly className?: string | undefined; + readonly fallbackIcon: ComponentType<{ className?: string }>; +}; + +type ImageElement = ReactElement<{ + readonly src: string; + readonly onLoad?: () => void; + readonly onError?: () => void; +}>; + +type ProjectFaviconImageElement = ReactElement<{ + readonly children: [ReactElement | null, ImageElement | null, ImageElement | null]; +}>; + +function resolveImageComponent(): { + readonly Component: (props: ProjectFaviconImageProps) => ProjectFaviconImageElement; + readonly props: ProjectFaviconImageProps; +} { + hooks.beginRender(); + const element = ProjectFavicon({ + environmentId: "environment-test" as EnvironmentId, + cwd: "/workspace-test", + }) as ReactElement; + hooks.reset(); + + return { + Component: element.type as (props: ProjectFaviconImageProps) => ProjectFaviconImageElement, + props: element.props, + }; +} + +function renderImage( + Component: (props: ProjectFaviconImageProps) => ProjectFaviconImageElement, + props: ProjectFaviconImageProps, +): ProjectFaviconImageElement { + hooks.beginRender(); + return Component(props); +} + +describe("ProjectFavicon", () => { + beforeEach(() => { + hooks.reset(); + }); + + it("falls back when the displayed favicon fails without discarding a valid older image early", () => { + const { Component, props } = resolveImageComponent(); + const initialLoadingImage = renderImage(Component, props).props.children[2]; + initialLoadingImage?.props.onLoad?.(); + + const refreshedProps = { + ...props, + src: "https://environment.test/api/assets/token-b/v1-20-favicon.svg", + }; + const refreshing = renderImage(Component, refreshedProps).props.children; + expect(refreshing[1]?.props.src).toBe(props.src); + refreshing[2]?.props.onError?.(); + + const afterRefreshError = renderImage(Component, refreshedProps).props.children; + expect(afterRefreshError[1]?.props.src).toBe(props.src); + afterRefreshError[1]?.props.onError?.(); + + const afterDisplayedError = renderImage(Component, refreshedProps).props.children; + expect(afterDisplayedError[0]).not.toBeNull(); + expect(afterDisplayedError[1]).toBeNull(); + }); +}); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 201241731fae..1df19a640756 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,12 +1,15 @@ import type { EnvironmentId } from "@t3tools/contracts"; -import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; +import { + getProjectFaviconCacheKey, + isProjectFaviconFallbackUrl, +} from "@t3tools/shared/projectFavicon"; import { FolderIcon } from "lucide-react"; import type { ComponentType } from "react"; import { useState } from "react"; import { useAssetUrl } from "../assets/assetUrls"; import { cn } from "~/lib/utils"; -const loadedProjectFaviconSrcs = new Set(); +const loadedProjectFaviconSrcs = new Map(); export function ProjectFavicon(input: { environmentId: EnvironmentId; @@ -24,9 +27,12 @@ export function ProjectFavicon(input: { return ; } + const cacheKey = getProjectFaviconCacheKey(input.environmentId, input.cwd, src); + return ( ; }) { - const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", + const [displayedSrc, setDisplayedSrc] = useState( + () => loadedProjectFaviconSrcs.get(cacheKey) ?? null, ); + const isLoading = displayedSrc !== src; + const handleLoadError = (failedSrc: string) => { + if (loadedProjectFaviconSrcs.get(cacheKey) === failedSrc) { + loadedProjectFaviconSrcs.delete(cacheKey); + } + setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc)); + }; return ( <> - {status !== "loaded" ? ( + {displayedSrc === null ? ( ) : null} - { - loadedProjectFaviconSrcs.add(src); - setStatus("loaded"); - }} - onError={() => setStatus("error")} - /> + {displayedSrc ? ( + handleLoadError(displayedSrc)} + /> + ) : null} + {isLoading ? ( + { + loadedProjectFaviconSrcs.set(cacheKey, src); + setDisplayedSrc(src); + }} + onError={() => handleLoadError(src)} + /> + ) : null} ); } diff --git a/packages/shared/src/projectFavicon.test.ts b/packages/shared/src/projectFavicon.test.ts index 0011b2fc7c99..1df17cc7fe5e 100644 --- a/packages/shared/src/projectFavicon.test.ts +++ b/packages/shared/src/projectFavicon.test.ts @@ -1,8 +1,32 @@ import { describe, expect, it } from "vite-plus/test"; -import { isProjectFaviconFallbackUrl, PROJECT_FAVICON_FALLBACK_MARKER } from "./projectFavicon.ts"; +import { + getProjectFaviconCacheKey, + isProjectFaviconFallbackUrl, + PROJECT_FAVICON_FALLBACK_MARKER, +} from "./projectFavicon.ts"; describe("project favicon", () => { + it("uses the project and versioned filename as the cache identity", () => { + const firstUrl = "https://environment.example/api/assets/first-signed-token/v1-20-favicon.svg"; + const refreshedUrl = + "https://environment.example/api/assets/refreshed-signed-token/v1-20-favicon.svg"; + + expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).toBe( + getProjectFaviconCacheKey("environment-1", "/workspace", refreshedUrl), + ); + expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).not.toBe( + getProjectFaviconCacheKey( + "environment-1", + "/workspace", + "https://environment.example/api/assets/refreshed-signed-token/v2-20-favicon.svg", + ), + ); + expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).not.toBe( + getProjectFaviconCacheKey("environment-2", "/workspace", firstUrl), + ); + }); + it("identifies fallback asset URLs by their dedicated filename", () => { expect( isProjectFaviconFallbackUrl( diff --git a/packages/shared/src/projectFavicon.ts b/packages/shared/src/projectFavicon.ts index 2e46429b6c1d..eebc1a8a1b63 100644 --- a/packages/shared/src/projectFavicon.ts +++ b/packages/shared/src/projectFavicon.ts @@ -1,5 +1,22 @@ export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing"; +export function getProjectFaviconCacheKey( + environmentId: string, + workspaceRoot: string, + url: string, +) { + let revision = url; + + try { + const pathname = new URL(url, "https://t3.invalid").pathname; + revision = pathname.slice(pathname.lastIndexOf("/") + 1); + } catch { + // Keep the full value as a safe fallback for malformed URLs. + } + + return JSON.stringify([environmentId, workspaceRoot, revision]); +} + export function isProjectFaviconFallbackUrl(url: string | null | undefined): boolean { if (!url) return false; From f6385059fd828ecd4aa7ad0b4c19e60b01e259bd Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 30 Jul 2026 11:25:31 -0700 Subject: [PATCH 02/26] perf(ci): cut stale runs and redundant setup (#4802) Co-authored-by: aaditagrawal --- .github/workflows/ci.yml | 32 +++++++++++- .github/workflows/deploy-relay.yml | 9 +++- .github/workflows/mobile-eas-preview.yml | 18 +++++-- .github/workflows/mobile-eas-production.yml | 8 ++- .../workflows/mobile-showcase-screenshots.yml | 20 ++++++- .github/workflows/release.yml | 52 +++++++++++++++++-- 6 files changed, 126 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index debbf312e897..6637dff343af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,10 @@ on: branches: - main +concurrency: + group: ci-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: check: name: Check @@ -20,6 +24,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 @@ -75,6 +84,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 @@ -175,13 +189,20 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=@t3tools/scripts... - name: Install mobile native static analysis tools run: brew bundle install --file apps/mobile/Brewfile @@ -196,13 +217,20 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=@t3tools/scripts... - name: Exercise release-only workflow steps run: node scripts/release-smoke.ts diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 089f14c01642..7e08df1076df 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -41,13 +41,20 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=t3code-relay... - name: Deploy production relay stage id: deploy diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index a79b3122b358..146880a73d97 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -2,7 +2,7 @@ name: Mobile EAS Preview on: pull_request: - types: [opened, reopened, synchronize, labeled, unlabeled] + types: [opened, reopened, synchronize, labeled] jobs: preview: @@ -10,8 +10,14 @@ jobs: # Fork-only and label-gated. Keep this off upstream and avoid private # upstream runners; the token check below skips the body when EAS is not # configured for the fork. - if: github.repository == 'aaditagrawal/t3code' && contains(github.event.pull_request.labels.*.name, '🚀 Mobile Continuous Deployment') + if: | + github.repository == 'aaditagrawal/t3code' && + contains(github.event.pull_request.labels.*.name, '🚀 Mobile Continuous Deployment') && + (github.event.action != 'labeled' || github.event.label.name == '🚀 Mobile Continuous Deployment') runs-on: ubuntu-24.04 + concurrency: + group: mobile-eas-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true permissions: contents: read pull-requests: write @@ -37,6 +43,10 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ if: steps.expo-token.outputs.present == 'true' @@ -44,7 +54,9 @@ jobs: with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=@t3tools/mobile... - name: Expose pnpm if: steps.expo-token.outputs.present == 'true' diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index e4fa135ad32b..26c2c6085ed6 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -57,6 +57,10 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ if: steps.expo-token.outputs.present == 'true' @@ -64,7 +68,9 @@ jobs: with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=@t3tools/mobile... - name: Expose pnpm if: steps.expo-token.outputs.present == 'true' diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index 36dfb61f73f5..0aa9f30a2ff2 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -37,13 +37,21 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=@t3tools/mobile... + - --filter=@t3tools/scripts... - name: Expose pnpm run: | @@ -77,13 +85,21 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=@t3tools/mobile... + - --filter=@t3tools/scripts... - name: Expose pnpm run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0bda335259c2..ae6917995df5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,6 +42,10 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - id: check name: Compare HEAD to last nightly tag @@ -91,6 +95,10 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 @@ -213,6 +221,10 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 @@ -302,14 +314,19 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} - fetch-depth: 0 + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=t3... - name: Build node-pty linux-x64 prebuild shell: bash @@ -394,14 +411,21 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} - fetch-depth: 0 + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=@t3tools/desktop... + - --filter=t3... + - --filter=@t3tools/scripts... - name: Setup Rust uses: dtolnay/rust-toolchain@stable @@ -698,6 +722,10 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 @@ -778,6 +806,10 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 @@ -915,6 +947,10 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 @@ -1034,6 +1070,10 @@ jobs: fetch-depth: 0 token: ${{ steps.app_token.outputs.token }} persist-credentials: true + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - id: app_bot name: Resolve GitHub App bot identity @@ -1104,6 +1144,10 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 From 299a2dcb638de6d3a0b98731bf01fbf415cca3b2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 30 Jul 2026 13:07:10 -0700 Subject: [PATCH 03/26] chore(mobile): bump app version to 1.0.1 Co-Authored-By: Claude Fable 5 Co-authored-by: aaditagrawal --- apps/mobile/app.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index d1c29ffcfc6e..0e4ef6de45bc 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -9,7 +9,7 @@ const repoEnv = loadRepoEnv(); Object.assign(process.env, repoEnv); const APP_VARIANT = resolveAppVariant(repoEnv.APP_VARIANT); -const APP_VERSION = process.env.MOBILE_APP_VERSION?.trim() || "0.1.0"; +const APP_VERSION = process.env.MOBILE_APP_VERSION?.trim() || "1.0.1"; const ANDROID_VERSION_CODE = resolveAndroidVersionCode(process.env.MOBILE_ANDROID_VERSION_CODE); const isIosPersonalTeamBuild = repoEnv.T3CODE_IOS_PERSONAL_TEAM === "1"; From f3467b28974b4eb50bbb268279f8f2624a9db078 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 30 Jul 2026 16:08:05 -0400 Subject: [PATCH 04/26] style(web): make scroll-to-end pill translucent (#5036) --- apps/web/src/components/ChatView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d80d8c7bf5b2..dd63a7fd8431 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5942,7 +5942,7 @@ function ChatViewContent(props: ChatViewProps) { aria-label="Scroll to end" title="Scroll to end" onClick={() => scrollToEnd(true)} - className="pointer-events-auto flex items-center gap-1.5 rounded-full border border-border/60 bg-card px-3 py-1 text-muted-foreground text-xs shadow-sm transition-colors hover:border-border hover:text-foreground hover:cursor-pointer" + className="chat-composer-glass pointer-events-auto flex items-center gap-1.5 rounded-full border border-border/60 px-3 py-1 text-muted-foreground text-xs shadow-sm transition-colors hover:border-border hover:text-foreground hover:cursor-pointer" > Scroll to end From 506127d48575da284548aec4369af69e3febda4b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 30 Jul 2026 13:15:03 -0700 Subject: [PATCH 05/26] fix(ci): drop sparse-checkout from EAS workflows actions/checkout implements sparse-checkout with --filter=blob:none, so the runner repo is a partial clone missing the .repos/ blobs. eas-cli archives the project via `git clone --depth 1 file://`, which upload-pack cannot serve from a partial clone and exits 128 (#4802 broke both EAS build workflows this way). Co-Authored-By: Claude Fable 5 --- .github/workflows/mobile-eas-preview.yml | 8 ++++---- .github/workflows/mobile-eas-production.yml | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index 146880a73d97..d9186c5a5123 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -43,10 +43,10 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false + # No sparse-checkout here: it makes actions/checkout fetch with + # --filter=blob:none, and eas-cli archives the project via + # `git clone --depth 1 file://`, which fails (exit 128) + # when the partial clone can't serve the unfetched blobs. - name: Setup Vite+ if: steps.expo-token.outputs.present == 'true' diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 26c2c6085ed6..3a3dca1c0cef 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -57,10 +57,10 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false + # No sparse-checkout here: it makes actions/checkout fetch with + # --filter=blob:none, and eas-cli archives the project via + # `git clone --depth 1 file://`, which fails (exit 128) + # when the partial clone can't serve the unfetched blobs. - name: Setup Vite+ if: steps.expo-token.outputs.present == 'true' From 4f771648afbdb78bd69aad5522b67802b97ee225 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 31 Jul 2026 00:06:49 +0200 Subject: [PATCH 06/26] fix(desktop): bump Clerk Electron SDK to 0.0.24 and register t3code:// scheme on Linux (#5015) Co-authored-by: Claude Fable 5 --- .../src/app/DesktopAppIdentity.test.ts | 1 - apps/desktop/src/app/DesktopAppIdentity.ts | 46 +++++---- apps/desktop/src/app/DesktopClerk.test.ts | 95 +++++++++++++++++- apps/desktop/src/app/DesktopClerk.ts | 21 +++- apps/desktop/src/app/DesktopLifecycle.test.ts | 1 - apps/desktop/src/electron/ElectronApp.test.ts | 3 - apps/desktop/src/electron/ElectronApp.ts | 2 - .../DesktopTelemetryPublisher.test.ts | 1 - .../src/window/DesktopApplicationMenu.test.ts | 1 - pnpm-lock.yaml | 96 +++++++++---------- pnpm-workspace.yaml | 24 ++--- scripts/build-desktop-artifact.test.ts | 5 + scripts/build-desktop-artifact.ts | 9 ++ 13 files changed, 210 insertions(+), 95 deletions(-) diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 2eb540f05263..6c5385e70a6d 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -54,7 +54,6 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => calls.setAboutPanelOptions.push(options); }), setAppUserModelId: () => Effect.void, - requestSingleInstanceLock: Effect.succeed(true), getAppMetrics: Effect.succeed([]), isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(true), diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 385e694338dd..0be55d633e61 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -45,6 +45,27 @@ const normalizeCommitHash = (value: string): Option.Option => { : Option.none(); }; +export const resolveUserDataPath = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const legacyPath = environment.path.join( + environment.appDataDirectory, + environment.legacyUserDataDirName, + ); + const legacyPathExists = yield* fileSystem.exists(legacyPath).pipe( + Effect.mapError( + (cause) => + new DesktopUserDataPathResolutionError({ + legacyPath, + cause, + }), + ), + ); + return legacyPathExists + ? legacyPath + : environment.path.join(environment.appDataDirectory, environment.userDataDirName); +}).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath")); + export const make = Effect.gen(function* () { const assets = yield* DesktopAssets.DesktopAssets; const electronApp = yield* ElectronApp.ElectronApp; @@ -90,24 +111,11 @@ export const make = Effect.gen(function* () { return commitHash; }); - const resolveUserDataPath = Effect.gen(function* () { - const legacyPath = environment.path.join( - environment.appDataDirectory, - environment.legacyUserDataDirName, - ); - const legacyPathExists = yield* fileSystem.exists(legacyPath).pipe( - Effect.mapError( - (cause) => - new DesktopUserDataPathResolutionError({ - legacyPath, - cause, - }), - ), - ); - return legacyPathExists - ? legacyPath - : environment.path.join(environment.appDataDirectory, environment.userDataDirName); - }).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath")); + const userDataPath = resolveUserDataPath.pipe( + Effect.provide( + yield* Effect.context(), + ), + ); const configure = Effect.gen(function* () { const commitHash = yield* resolveAboutCommitHash; @@ -136,7 +144,7 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.appIdentity.configure")); return DesktopAppIdentity.of({ - resolveUserDataPath, + resolveUserDataPath: userDataPath, configure, }); }); diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 9b5ed56d1f34..2f61ca909aef 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -22,17 +22,38 @@ vi.mock("@clerk/electron/storage", () => ({ storage: storageMock, })); +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -const makeDesktopClerkLayer = (isDevelopment = true) => { +const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => { const environment = DesktopEnvironment.DesktopEnvironment.of({ stateDir: "/tmp/t3-state", isDevelopment, + appDataDirectory: "/tmp/app-data", + userDataDirName: isDevelopment ? "t3code-dev" : "t3code", + legacyUserDataDirName: isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)", + path: { join: (...parts: ReadonlyArray) => parts.join("/") }, } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); + const electronApp = { + setPath: (name: string, value: string) => + Effect.sync(() => { + events.push(`setPath:${name}:${value}`); + }), + } as unknown as ElectronApp.ElectronApp["Service"]; + return DesktopClerk.layer.pipe( - Layer.provide(Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment)), + Layer.provide( + Layer.mergeAll( + Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment), + Layer.succeed(ElectronApp.ElectronApp, electronApp), + FileSystem.layerNoop({ exists: () => Effect.succeed(false) }), + ), + ), ); }; @@ -55,11 +76,15 @@ describe("DesktopClerk", () => { it.effect("acquires and releases the SDK bridge with the layer", () => { const cleanup = vi.fn(); + const events: string[] = []; storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue({ cleanup }); + createClerkBridgeMock.mockImplementation(() => { + events.push("createClerkBridge"); + return { cleanup, isPrimaryInstance: true }; + }); return Effect.gen(function* () { - yield* Effect.scoped(Layer.build(makeDesktopClerkLayer())); + yield* Effect.scoped(Layer.build(makeDesktopClerkLayer(true, events))); assert.deepEqual(createClerkBridgeMock.mock.calls, [ [ @@ -71,6 +96,10 @@ describe("DesktopClerk", () => { ], ]); assert.equal(cleanup.mock.calls.length, 1); + // The bridge acquires Electron's single-instance lock at creation, and + // the lock both lives in and creates the userData directory — so the + // real path must be set before the bridge exists. + assert.deepEqual(events, ["setPath:userData:/tmp/app-data/t3code-dev", "createClerkBridge"]); storageMock.mockClear(); createClerkBridgeMock.mockClear(); }); @@ -124,11 +153,67 @@ describe("DesktopClerk", () => { }); }); + it.effect("registers the second-instance handler in the primary instance", () => { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); + const quit = vi.fn(); + const registeredEvents: string[] = []; + const electronApp = { + quit: Effect.sync(quit), + on: (eventName: string) => + Effect.sync(() => { + registeredEvents.push(eventName); + }), + } as unknown as ElectronApp.ElectronApp["Service"]; + const electronWindow = {} as ElectronWindow.ElectronWindow["Service"]; + + return Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + const exit = yield* Effect.exit(Effect.scoped(clerk.configure)); + + assert.isTrue(Exit.isSuccess(exit)); + assert.equal(quit.mock.calls.length, 0); + assert.deepEqual(registeredEvents, ["second-instance"]); + }).pipe( + Effect.provide(makeDesktopClerkLayer()), + Effect.provideService(ElectronApp.ElectronApp, electronApp), + Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + ); + }); + + it.effect("quits and interrupts startup in a secondary instance", () => { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: false }); + const quit = vi.fn(); + const registeredEvents: string[] = []; + const electronApp = { + quit: Effect.sync(quit), + on: (eventName: string) => + Effect.sync(() => { + registeredEvents.push(eventName); + }), + } as unknown as ElectronApp.ElectronApp["Service"]; + const electronWindow = {} as ElectronWindow.ElectronWindow["Service"]; + + return Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + const exit = yield* Effect.exit(Effect.scoped(clerk.configure)); + + assert.isTrue(Exit.hasInterrupts(exit)); + assert.equal(quit.mock.calls.length, 1); + assert.deepEqual(registeredEvents, []); + }).pipe( + Effect.provide(makeDesktopClerkLayer()), + Effect.provideService(ElectronApp.ElectronApp, electronApp), + Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + ); + }); + it.each([ { isDevelopment: true, scheme: "t3code-dev" }, { isDevelopment: false, scheme: "t3code" }, ])("configures the SDK with the $scheme renderer origin", ({ isDevelopment, scheme }) => { - const bridge = { cleanup: vi.fn() }; + const bridge = { cleanup: vi.fn(), isPrimaryInstance: true }; storageMock.mockReturnValue(storageAdapter); createClerkBridgeMock.mockReturnValue(bridge); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 0e283f8dd0c4..9611dc083d2f 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -11,6 +11,7 @@ import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/rela import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; @@ -84,7 +85,18 @@ export function createDesktopClerkBridge(stateDir: string, isDevelopment: boolea export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; - yield* Effect.acquireRelease( + const electronApp = yield* ElectronApp.ElectronApp; + + // Electron scopes the single-instance lock to the userData directory and + // creates that directory when the lock is acquired. The SDK bridge takes + // the lock at creation, so userData must already point at the real + // directory here — under the default productName-derived path, acquiring + // the lock would create "T3 Code (Alpha)" and make the legacy-install + // detection in resolveUserDataPath match on fresh installs. + const userDataPath = yield* DesktopAppIdentity.resolveUserDataPath; + yield* electronApp.setPath("userData", userDataPath); + + const bridge = yield* Effect.acquireRelease( Effect.try({ try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment), catch: (cause) => @@ -113,7 +125,12 @@ export const make = Effect.gen(function* () { const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); - if (!(yield* electronApp.requestSingleInstanceLock)) { + // The SDK bridge holds Electron's single-instance lock (acquired at + // bridge creation) so OAuth deep-link callbacks on Windows/Linux are + // forwarded to the running app. In a secondary instance the bridge has + // already begun quitting the app; app.quit() is asynchronous, so stop + // bootstrap here before whenReady can fire. + if (!bridge.isPrimaryInstance) { yield* electronApp.quit; return yield* Effect.interrupt; } diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index e5ce72f8e48a..978e000a7f58 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -29,7 +29,6 @@ describe("DesktopLifecycle", () => { setName: () => Effect.void, setAboutPanelOptions: () => Effect.void, setAppUserModelId: () => Effect.void, - requestSingleInstanceLock: Effect.succeed(true), getAppMetrics: Effect.succeed([]), isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(true), diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index 077b343959ca..ac14f56ad1a5 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -14,7 +14,6 @@ const { quitMock, relaunchMock, removeListenerMock, - requestSingleInstanceLockMock, setAboutPanelOptionsMock, setAppUserModelIdMock, setAsDefaultProtocolClientMock, @@ -35,7 +34,6 @@ const { quitMock: vi.fn(), relaunchMock: vi.fn(), removeListenerMock: vi.fn(), - requestSingleInstanceLockMock: vi.fn(() => true), setAboutPanelOptionsMock: vi.fn(), setAppUserModelIdMock: vi.fn(), setAsDefaultProtocolClientMock: vi.fn(() => true), @@ -67,7 +65,6 @@ vi.mock("electron", () => ({ quit: quitMock, relaunch: relaunchMock, removeListener: removeListenerMock, - requestSingleInstanceLock: requestSingleInstanceLockMock, runningUnderARM64Translation: false, setAboutPanelOptions: setAboutPanelOptionsMock, setAsDefaultProtocolClient: setAsDefaultProtocolClientMock, diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 5f8052f902dc..73323617195d 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -56,7 +56,6 @@ export class ElectronApp extends Context.Service< options: Electron.AboutPanelOptionsOptions, ) => Effect.Effect; readonly setAppUserModelId: (id: string) => Effect.Effect; - readonly requestSingleInstanceLock: Effect.Effect; readonly getAppMetrics: Effect.Effect>; readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect; readonly setAsDefaultProtocolClient: ( @@ -153,7 +152,6 @@ export const make = ElectronApp.of({ Effect.sync(() => { Electron.app.setAppUserModelId(id); }), - requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()), getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()), isDefaultProtocolClient: (protocol) => Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)), diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index 475be3da1519..36cdcb50b6ba 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -34,7 +34,6 @@ function makeElectronAppLayer( setName: () => Effect.void, setAboutPanelOptions: () => Effect.void, setAppUserModelId: () => Effect.void, - requestSingleInstanceLock: Effect.succeed(true), getAppMetrics: Effect.sync(() => { onMetricsRead(); return metrics; diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 34fc4447146f..f04a49f82afa 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -39,7 +39,6 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { setName: () => Effect.void, setAboutPanelOptions: () => Effect.void, setAppUserModelId: () => Effect.void, - requestSingleInstanceLock: Effect.succeed(true), getAppMetrics: Effect.succeed([]), isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(true), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b505f9fa8282..770238b2b76b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,19 +35,19 @@ catalogs: version: 0.2.2 overrides: - '@clerk/backend': 3.13.0 - '@clerk/clerk-js': 6.25.7 + '@clerk/backend': 3.14.0 + '@clerk/clerk-js': 6.25.12 '@clerk/clerk-js>@base-org/account': '-' '@clerk/clerk-js>@coinbase/wallet-sdk': '-' '@clerk/clerk-js>@solana/wallet-adapter-base': '-' '@clerk/clerk-js>@solana/wallet-adapter-react': '-' '@clerk/clerk-js>@solana/wallet-standard': '-' '@clerk/clerk-js>@wallet-standard/core': '-' - '@clerk/electron': 0.0.18 + '@clerk/electron': 0.0.24 '@clerk/electron-passkeys': 0.0.3 - '@clerk/expo': 4.0.2 - '@clerk/react': 6.12.7 - '@clerk/shared': 4.25.7 + '@clerk/expo': 4.1.2 + '@clerk/react': 6.12.9 + '@clerk/shared': 4.25.9 '@effect/atom-react': 4.0.0-beta.102 '@effect/platform-bun': 4.0.0-beta.102 '@effect/platform-node': 4.0.0-beta.102 @@ -111,8 +111,8 @@ importers: apps/desktop: dependencies: '@clerk/electron': - specifier: 0.0.18 - version: 0.0.18(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 0.0.24 + version: 0.0.24(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron-passkeys': specifier: 0.0.3 version: 0.0.3 @@ -200,8 +200,8 @@ importers: specifier: ^0.7.1 version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@clerk/expo': - specifier: 4.0.2 - version: 4.0.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + specifier: 4.1.2 + version: 4.1.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) '@effect/atom-react': specifier: 4.0.0-beta.102 version: 4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(react@19.2.3)(scheduler@0.27.0) @@ -532,11 +532,11 @@ importers: specifier: 6.25.7 version: 6.25.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron': - specifier: 0.0.18 - version: 0.0.18(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 0.0.24 + version: 0.0.24(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/react': - specifier: 6.12.7 - version: 6.12.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 6.12.9 + version: 6.12.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -692,8 +692,8 @@ importers: infra/relay: dependencies: '@clerk/backend': - specifier: 3.13.0 - version: 3.13.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 3.14.0 + version: 3.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@effect/sql-pg': specifier: 4.0.0-beta.102 version: 4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) @@ -1722,12 +1722,12 @@ packages: resolution: {integrity: sha512-EYlRokl8szrP9Z25qT5aepMdBjzBvHF9ZEhzIiUBc9guz/T31EqRgvD0QSgZcpE93xiwrr+OkB4nz0BZyF6fSA==} engines: {node: '>= 20.12.0'} - '@clerk/backend@3.13.0': - resolution: {integrity: sha512-kYOgeR48iXYypudpYITE0L2yxi4TeFXU+ISAOULlVgOcurw8qnm1/JEuK6Mr2IFUbRS9hcHk1HVO0Ceo0XmL8Q==} + '@clerk/backend@3.14.0': + resolution: {integrity: sha512-WsphTvDFHDuQilKI7dyVE5qmt7USu8qSrujAq6SqpEHbVFfNBfINDNuzxlF9M2skOTfHFfgiTE8Jjb1egIBGLg==} engines: {node: '>=20.9.0'} - '@clerk/clerk-js@6.25.7': - resolution: {integrity: sha512-GOnbEUzPKAdsdCodZsHhku2YloBBuwLGx5UP0VeezUj73wKLwlSYMlVPuntsJoY7c8+lVrMCY9LQWYVZ7VMLBw==} + '@clerk/clerk-js@6.25.12': + resolution: {integrity: sha512-N91mFKenMF5e+LRQ0NsqFb8bw+icbwnF8MzjzS4cux72xG4Gekh772tiLIetbURe44Srgjw5lj8jkWbt+yH/FA==} engines: {node: '>=20.9.0'} '@clerk/electron-passkeys-darwin-arm64@0.0.3': @@ -1754,8 +1754,8 @@ packages: resolution: {integrity: sha512-OHhIe88qDL+FxyBalXdXNHAS5eEramr6Rerp+6iNkfkjqT8rx4hHNmfpmjg5/T1/am8QfknbOBZkqoXZlCrjPg==} engines: {node: '>=20.9.0'} - '@clerk/electron@0.0.18': - resolution: {integrity: sha512-0lWiITlTxCviglPfPSu+uhERnQyXroiWrSMRpZcEO3FgBt3bnrpfgZUrXW8iaLoUYJrFP2byF7bHJPRwEPVXEw==} + '@clerk/electron@0.0.24': + resolution: {integrity: sha512-xORO51KLzCqmRQ4IVsa+Amp1M5NjG28QnvsaRBq1v4CUEjH8vAXDKCPiPBKAE+HuQB6HUhzYmzUkyTw2FcDonw==} engines: {node: '>=20.9.0'} peerDependencies: '@clerk/electron-passkeys': 0.0.3 @@ -1771,8 +1771,8 @@ packages: react-dom: optional: true - '@clerk/expo@4.0.2': - resolution: {integrity: sha512-afBdV2bLRM55p5L7FoAGV/wCamLxXusjIBPmNo+TwAYabU0oUokMsm+0++xWDA0nxzMt/O08Oj+xAGEtE+Qvxg==} + '@clerk/expo@4.1.2': + resolution: {integrity: sha512-ZyGozbbizcGwEyK0CEXsPHnHnQoaa2CbTsXAqWBygzKEZyFjLjCHwQsQBKj6WgjAgn8mtw2ByIP9qLpItZffgw==} engines: {node: '>=20.9.0'} peerDependencies: '@clerk/expo-google-signin': '>=0.1.0' @@ -1810,15 +1810,15 @@ packages: react-dom: optional: true - '@clerk/react@6.12.7': - resolution: {integrity: sha512-K8CK0tS7My/3RT5sAIJTjYgnH0xU+K4Jdnhi76xQdAY4agD/M07BbrBMWXEE0xUEDst2PqPfX660V1LRLOsxOQ==} + '@clerk/react@6.12.9': + resolution: {integrity: sha512-lQ1UOJhHGDweLCDf4IgT5r3rgkch1Gx21hNqK2/kOohJrJd/XM89fq2F7VK9Kg/Byc14TsfCh64Dm8ej2MyRWw==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 - '@clerk/shared@4.25.7': - resolution: {integrity: sha512-FfBbDeFkxwDuz/2YcVs4DhyZ0VxnqNtdbO0Tn/lkLAiDvFFspWw+PDYqo7LQiBymEQaOQkb094dX21WWnZbmlA==} + '@clerk/shared@4.25.9': + resolution: {integrity: sha512-nw3maGzqPrwmUKjGffOM9Zvw24IyOllq0TkQGzsTfQX312WLu2u5CNvjKntMb6Ok6kXa1Zdc77zmPLn1/EAIFA==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 @@ -11674,18 +11674,18 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@clerk/backend@3.13.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/backend@3.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.25.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.25.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6) standardwebhooks: 1.0.0 tslib: 2.8.1 transitivePeerDependencies: - react - react-dom - '@clerk/clerk-js@6.25.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/clerk-js@6.25.12(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.25.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.25.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.100.14 @@ -11700,9 +11700,9 @@ snapshots: - react - react-dom - '@clerk/clerk-js@6.25.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/clerk-js@6.25.12(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.25.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.25.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.100.14 @@ -11736,11 +11736,11 @@ snapshots: '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.3 '@clerk/electron-passkeys-win32-x64-msvc': 0.0.3 - '@clerk/electron@0.0.18(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/electron@0.0.24(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/clerk-js': 6.25.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@clerk/react': 6.12.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@clerk/shared': 4.25.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/clerk-js': 6.25.12(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/react': 6.12.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.25.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6) electron: 41.5.0 react: 19.2.6 tslib: 2.8.1 @@ -11749,11 +11749,11 @@ snapshots: electron-store: 8.2.0 react-dom: 19.2.6(react@19.2.6) - '@clerk/expo@4.0.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)': + '@clerk/expo@4.1.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)': dependencies: - '@clerk/clerk-js': 6.25.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/react': 6.12.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/shared': 4.25.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/clerk-js': 6.25.12(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/react': 6.12.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.25.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@expo/config-plugins': 56.0.9(typescript@6.0.3) base-64: 1.0.0 expo: 56.0.12(8895228379997a2a064f9644cda56ed0) @@ -11772,21 +11772,21 @@ snapshots: - supports-color - typescript - '@clerk/react@6.12.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/react@6.12.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.25.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.25.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) tslib: 2.8.1 - '@clerk/react@6.12.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/react@6.12.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.25.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.25.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) tslib: 2.8.1 - '@clerk/shared@4.25.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/shared@4.25.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/query-core': 5.100.14 dequal: 2.0.3 @@ -11796,7 +11796,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@clerk/shared@4.25.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/shared@4.25.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@tanstack/query-core': 5.100.14 dequal: 2.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d41e50e8784c..79736c894e15 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -23,13 +23,13 @@ allowBuilds: workerd: false catalog: - "@clerk/backend": 3.13.0 - "@clerk/clerk-js": 6.25.7 - "@clerk/electron": 0.0.18 + "@clerk/backend": 3.14.0 + "@clerk/clerk-js": 6.25.12 + "@clerk/electron": 0.0.24 "@clerk/electron-passkeys": 0.0.3 - "@clerk/expo": 4.0.2 - "@clerk/react": 6.12.7 - "@clerk/shared": 4.25.7 + "@clerk/expo": 4.1.2 + "@clerk/react": 6.12.9 + "@clerk/shared": 4.25.9 "@effect/atom-react": 4.0.0-beta.102 "@effect/openapi-generator": 4.0.0-beta.102 "@effect/platform-bun": 4.0.0-beta.102 @@ -52,12 +52,12 @@ catalog: yaml: ^2.9.0 minimumReleaseAgeExclude: - - "@clerk/backend@3.13.0" - - "@clerk/clerk-js@6.25.7" - - "@clerk/electron@0.0.18" - - "@clerk/expo@4.0.2" - - "@clerk/react@6.12.7" - - "@clerk/shared@4.25.7" + - "@clerk/backend@3.14.0" + - "@clerk/clerk-js@6.25.12" + - "@clerk/electron@0.0.24" + - "@clerk/expo@4.1.2" + - "@clerk/react@6.12.9" + - "@clerk/shared@4.25.9" - "@distilled.cloud/aws@0.30.2" - "@distilled.cloud/axiom@0.30.2" - "@distilled.cloud/cloudflare@0.30.2" diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index b9ab886e7afe..f43fe415bd7f 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -351,6 +351,11 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.deepStrictEqual(mac.asarUnpack, ["node_modules/@github/copilot*/**/*"]); assert.deepStrictEqual(linux.asarUnpack, ["node_modules/@github/copilot*/**/*"]); assert.deepStrictEqual(win.asarUnpack, WINDOWS_ASAR_UNPACK); + // Linux must register the renderer schemes so the generated .desktop + // entry advertises MimeType=x-scheme-handler/t3code; for OAuth deep links. + assert.deepStrictEqual((linux.linux as Record).protocols, [ + { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, + ]); for (const config of [mac, linux, win]) { assert.deepStrictEqual(config.electronLanguages, DESKTOP_ELECTRON_LANGUAGES); assert.deepStrictEqual(config.files, DESKTOP_FILE_EXCLUSIONS); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 5a07d0034f05..81e386255833 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -1613,6 +1613,15 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( executableName: "t3code", icon: "icons", category: "Development", + // electron-builder turns these into MimeType=x-scheme-handler/; + // in the .desktop entry (Exec already gets %U), so browsers can hand + // t3code:// OAuth callbacks to the app. + protocols: [ + { + name: "T3 Code", + schemes: ["t3code", "t3code-dev"], + }, + ], desktop: { entry: { StartupWMClass: "t3code", From a64c05d415aff6d0236e41a8fd9dc4722c336193 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:38:59 +0530 Subject: [PATCH 07/26] perf(server): cache default branch name and origin existence across status refreshes (#5008) --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 45 ++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 91 +++++++++++++++++--- 2 files changed, 123 insertions(+), 13 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index aab69de6a128..24d53cd4846f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -153,6 +153,51 @@ it.effect("uses stable diagnostics for every parsed non-repository command", () }).pipe(Effect.provide(layer)); }); +it.effect("invalidates origin remote cache when a driver mutation adds origin", () => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + + const before = yield* driver.statusDetailsLocal(cwd); + assert.equal(before.hasOriginRemote, false); + + yield* driver.ensureRemote({ cwd, preferredName: "origin", url: remote }); + + const after = yield* driver.statusDetailsLocal(cwd); + assert.equal(after.hasOriginRemote, true); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect("re-reads origin remote status after cache TTL expiry and bypassed invalidation", () => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + + // First call caches hasOriginRemote = false (5-min TTL) + assert.equal((yield* driver.statusDetailsLocal(cwd)).hasOriginRemote, false); + + // Add origin via raw git (bypasses invalidation hook) + yield* git(cwd, ["remote", "add", "origin", remote]); + + // Cache still has the stale false (TTL not yet expired) + const stillCached = yield* driver.statusDetailsLocal(cwd); + assert.equal(stillCached.hasOriginRemote, false); + + // Advance past the 5-minute TTL so the cache entry expires + yield* TestClock.adjust("6 minutes"); + + // After expiry, the next call re-executes and picks up the remote + const afterExpiry = yield* driver.statusDetailsLocal(cwd); + assert.equal(afterExpiry.hasOriginRemote, true); + }).pipe(Effect.provide(TestLayer)), +); + it.effect("coalesces concurrent ref pages into one repository snapshot", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index b5dbe9514d8f..fa3157d960f7 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -63,6 +63,8 @@ const LIST_REFS_SNAPSHOT_CACHE_CAPACITY = 64; const LIST_REFS_SNAPSHOT_CACHE_TTL = Duration.minutes(2); const LIST_REFS_REFRESH_COALESCE_TTL = Duration.seconds(5); const LIST_REFS_REFRESH_FAILURE_COOLDOWN = Duration.seconds(30); +const STATUS_DEFAULT_BRANCH_CACHE_TTL = Duration.minutes(5); +const STATUS_ORIGIN_EXISTS_CACHE_TTL = Duration.minutes(5); const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ GCM_INTERACTIVE: "never", GIT_ASKPASS: "", @@ -1121,6 +1123,63 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return Cache.get(refresh ? repositoryPathsRefreshCache : repositoryPathsCache, cacheKey); }; + const defaultBranchCache = yield* Cache.makeWith( + (gitCommonDir: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fetchCwd = + path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; + return yield* executeGit( + "GitVcsDriver.statusDetails.defaultBranch", + fetchCwd, + ["--git-dir", gitCommonDir, "symbolic-ref", "refs/remotes/origin/HEAD"], + { allowNonZeroExit: true }, + ).pipe( + Effect.map((result) => { + if (result.exitCode !== 0) return null; + return parseDefaultBranchFromRemoteHeadRef(result.stdout, "origin"); + }), + ); + }), + { + capacity: 2_048, + timeToLive: Exit.match({ + onSuccess: () => STATUS_DEFAULT_BRANCH_CACHE_TTL, + onFailure: () => Duration.zero, + }), + }, + ); + const originExistsCache = yield* Cache.makeWith( + (gitCommonDir: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fetchCwd = + path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; + return yield* executeGit( + "GitVcsDriver.statusDetails.originExists", + fetchCwd, + ["--git-dir", gitCommonDir, "remote", "get-url", "origin"], + { allowNonZeroExit: true }, + ).pipe(Effect.map((result) => result.exitCode === 0)); + }), + { + capacity: 2_048, + timeToLive: Exit.match({ + onSuccess: () => STATUS_ORIGIN_EXISTS_CACHE_TTL, + onFailure: () => Duration.zero, + }), + }, + ); + const invalidateStatusStaticCaches = (cwd: string) => + Effect.gen(function* () { + const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( + Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), + ); + const cacheKey = repositoryPaths?.gitCommonDir ?? normalizeRepositoryPathsCacheKey(cwd); + yield* Cache.invalidate(defaultBranchCache, cacheKey); + yield* Cache.invalidate(originExistsCache, cacheKey); + }); + const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) { const repositoryPaths = yield* resolveRepositoryPaths(cwd); if (repositoryPaths !== null) { @@ -1519,7 +1578,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); } - const [numstatStdout, defaultRefResult, hasPrimaryRemote] = yield* Effect.all( + const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( + Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), + ); + const statusCacheKey = repositoryPaths?.gitCommonDir; + const [numstatStdout, defaultBranch, hasPrimaryRemote] = yield* Effect.all( [ executeGitWithStableDiagnostics( "GitVcsDriver.statusDetails.numstat", @@ -1576,21 +1639,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); }), ), - executeGit( - "GitVcsDriver.statusDetails.defaultRef", - cwd, - ["symbolic-ref", "refs/remotes/origin/HEAD"], - { allowNonZeroExit: true }, - ), - originRemoteExists(cwd).pipe(Effect.orElseSucceed(() => false)), + statusCacheKey + ? Cache.get(defaultBranchCache, statusCacheKey).pipe(Effect.orElseSucceed(() => null)) + : resolveDefaultBranchName(cwd, "origin").pipe(Effect.orElseSucceed(() => null)), + statusCacheKey + ? Cache.get(originExistsCache, statusCacheKey).pipe(Effect.orElseSucceed(() => false)) + : originRemoteExists(cwd).pipe(Effect.orElseSucceed(() => false)), ], { concurrency: "unbounded" }, ); const statusStdout = statusResult.stdout; - const defaultBranch = - defaultRefResult.exitCode === 0 - ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "") - : null; let refName: string | null = null; let upstreamRef: string | null = null; @@ -2811,7 +2869,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cwd: string, effect: Effect.Effect, ): Effect.Effect => - effect.pipe(Effect.ensuring(invalidateListRefsSnapshot(cwd).pipe(Effect.ignore))); + effect.pipe( + Effect.ensuring( + Effect.all([ + invalidateListRefsSnapshot(cwd).pipe(Effect.ignore), + invalidateStatusStaticCaches(cwd).pipe(Effect.ignore), + ]), + ), + ); const initRepoWithListRefsInvalidation: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = ( input, ) => From e0a7471ff57889f78ed033e6dc722f6871dee60b Mon Sep 17 00:00:00 2001 From: Arham Amin <132888838+arhxam@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:43:46 +0530 Subject: [PATCH 08/26] feat(shared): support shorthand (major-only) versions in the semver helpers (#5027) Co-authored-by: Claude Opus 4.8 (1M context) --- packages/shared/src/semver.test.ts | 26 +++++++++++++++++++++++++- packages/shared/src/semver.ts | 7 ++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/semver.test.ts b/packages/shared/src/semver.test.ts index 8cbbc150fc96..ed3e1896aaf6 100644 --- a/packages/shared/src/semver.test.ts +++ b/packages/shared/src/semver.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; -import { compareSemverVersions, normalizeSemverVersion, satisfiesSemverRange } from "./semver.ts"; +import { + compareSemverVersions, + normalizeSemverVersion, + parseSemver, + satisfiesSemverRange, +} from "./semver.ts"; describe("semver helpers", () => { it("matches supported range groups", () => { @@ -18,6 +23,25 @@ describe("semver helpers", () => { expect(normalizeSemverVersion("2.1")).toBe("2.1.0"); }); + it("normalizes and parses shorthand major-only versions", () => { + expect(normalizeSemverVersion("20")).toBe("20.0.0"); + expect(normalizeSemverVersion("v18")).toBe("v18.0.0"); + expect(normalizeSemverVersion("20-rc.1")).toBe("20.0.0-rc.1"); + expect(parseSemver("20")).toEqual({ major: 20, minor: 0, patch: 0, prerelease: [] }); + }); + + it("compares shorthand versions numerically instead of lexically", () => { + // Regression: "20" vs "9" previously fell back to string comparison, which + // ordered "20" before "9" ("2" < "9"). + expect(compareSemverVersions("20", "9")).toBeGreaterThan(0); + expect(compareSemverVersions("18", "18.0.0")).toBe(0); + }); + + it("still rejects non-numeric shorthand and keeps empty input empty", () => { + expect(parseSemver("abc")).toBeNull(); + expect(normalizeSemverVersion("")).toBe(""); + }); + it("compares prerelease versions before stable versions", () => { expect(compareSemverVersions("2.1.111-beta.1", "2.1.111")).toBeLessThan(0); }); diff --git a/packages/shared/src/semver.ts b/packages/shared/src/semver.ts index 1a73e33042f1..a765b065fc6d 100644 --- a/packages/shared/src/semver.ts +++ b/packages/shared/src/semver.ts @@ -17,7 +17,12 @@ export function normalizeSemverVersion(version: string): string { } } - if (segments.length === 2) { + // Pad shorthand versions ("20" or "20.1") up to three segments so major-only + // and minor-only inputs parse and compare numerically. This matches + // satisfiesSemverRange, which already treats a missing minor/patch as 0. The + // length > 0 guard keeps empty/garbage input empty (parseSemver still + // rejects it), and inputs with more than three segments are left untouched. + while (segments.length > 0 && segments.length < 3) { segments.push("0"); } From 4ce12bf9738a11d3a2fb4e7fde6b30df1f808e56 Mon Sep 17 00:00:00 2001 From: Elaine Lyons Date: Thu, 30 Jul 2026 18:15:38 -0400 Subject: [PATCH 09/26] fix(mobile): remove unnecessary photo library permission (#4929) --- apps/mobile/app.config.ts | 2 ++ apps/mobile/src/lib/composerImages.ts | 8 -------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 0e4ef6de45bc..4b4eee643e44 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -294,10 +294,12 @@ const config: ExpoConfig = { "expo-camera", { cameraPermission: "Allow T3 Code to access your camera so you can scan pairing QR codes.", + microphonePermission: false, barcodeScannerEnabled: true, recordAudioAndroid: false, }, ], + ["expo-image-picker", { photosPermission: false, microphonePermission: false }], [ "expo-splash-screen", { diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 5c79b5b5eb8a..f559545c04ef 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -65,14 +65,6 @@ export async function pickComposerImages(input: { readonly existingCount: number }; } - const permission = await imagePicker.requestMediaLibraryPermissionsAsync(); - if (!permission.granted) { - return { - images: [], - error: "Allow photo library access to attach images.", - }; - } - const result = await imagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], allowsMultipleSelection: true, From c03692b8f5ebb1f6d0956db7eb47f53c42b733b4 Mon Sep 17 00:00:00 2001 From: Arham Amin <132888838+arhxam@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:46:22 +0530 Subject: [PATCH 10/26] fix(shared): lenient JSON parser deletes commas inside string values (#5025) Co-authored-by: Claude Opus 4.8 (1M context) --- packages/shared/src/schemaJson.test.ts | 9 +++++++++ packages/shared/src/schemaJson.ts | 10 ++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/schemaJson.test.ts b/packages/shared/src/schemaJson.test.ts index c808a9b7c515..4a4d16da0b30 100644 --- a/packages/shared/src/schemaJson.test.ts +++ b/packages/shared/src/schemaJson.test.ts @@ -57,6 +57,15 @@ Done.`), expect(() => decodeLenientJson('{ "enabled": true,, }')).toThrow(); }); + it("preserves commas before brackets inside string values", () => { + // A comma inside a string value that happens to precede `}`/`]` must not + // be stripped as if it were a trailing comma. + expect(decodeLenientJson('{"note":"a,]"}')).toEqual({ note: "a,]" }); + expect(decodeLenientJson('{"list":["x,}"]}')).toEqual({ list: ["x,}"] }); + // Genuine trailing commas are still removed. + expect(decodeLenientJson('{"values":[1, 2,],}')).toEqual({ values: [1, 2] }); + }); + it("formats schema failures with paths without exposing invalid values", () => { const decodeCredential = decodeJsonResult(Schema.Struct({ token: Schema.Number })); const decoded = decodeCredential('{"token":"credential=secret-value"}'); diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index 04d26d9c2293..77b1fa5d548d 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -190,8 +190,14 @@ const parseLenientJsonGetter = SchemaGetter.onSome((input: string) => { (match, stringLiteral: string | undefined) => (stringLiteral ? match : ""), ); - // Strip trailing commas before `}` or `]`. - stripped = stripped.replace(/,(\s*[}\]])/g, "$1"); + // Strip trailing commas before `}` or `]`. The alternation preserves quoted + // strings so a comma inside a string value (e.g. `{"note":"a,]"}`) is not + // mistaken for a trailing comma and removed. + stripped = stripped.replace( + /("(?:[^"\\]|\\.)*")|,(\s*[}\]])/g, + (match, stringLiteral: string | undefined, bracket: string | undefined) => + stringLiteral ? match : (bracket ?? ""), + ); return decodeJsonString(stripped).pipe( Effect.map(Option.some), From 4a91a25bbb2d695409a657828e995d2bd99f05c7 Mon Sep 17 00:00:00 2001 From: Simone Date: Fri, 31 Jul 2026 00:17:38 +0200 Subject: [PATCH 11/26] fix(mobile): default bare IP pairing to HTTP (#4990) --- .agents/skills/test-t3-mobile/SKILL.md | 2 +- .../src/features/connection/pairing.test.ts | 21 +++++++++++++++++++ .../mobile/src/features/connection/pairing.ts | 17 ++++++++++++++- docs/user/remote-access.md | 2 ++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index f3e3dcfd8ce5..98c1c3b20224 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -66,7 +66,7 @@ Use these client origins: - Android Emulator: `http://10.0.2.2:` - Physical device: bind the backend to `0.0.0.0` and use the host's reachable LAN origin -Always enter the complete `http://` origin; the mobile host field otherwise assumes HTTPS. When testing web and mobile together, run `vp run dev --home-dir --host 127.0.0.1` instead and do not launch a second backend over the same base directory. +Enter the complete `http://` origin to make the test transport explicit. Bare IP addresses default to HTTP, while bare hostnames default to HTTPS. When testing web and mobile together, run `vp run dev --home-dir --host 127.0.0.1` instead and do not launch a second backend over the same base directory. ## Start or reuse Metro safely diff --git a/apps/mobile/src/features/connection/pairing.test.ts b/apps/mobile/src/features/connection/pairing.test.ts index 18b6c71a293a..193927684794 100644 --- a/apps/mobile/src/features/connection/pairing.test.ts +++ b/apps/mobile/src/features/connection/pairing.test.ts @@ -1,11 +1,32 @@ import { describe, expect, it } from "vite-plus/test"; import { + buildPairingUrl, extractPairingUrlFromQrPayload, PairingQrPayloadEmptyError, parsePairingUrl, } from "./pairing"; +describe("buildPairingUrl", () => { + it("uses HTTP for a schemeless IP address", () => { + expect(buildPairingUrl("192.168.1.100:3773", "pairing-token")).toBe( + "http://192.168.1.100:3773/#token=pairing-token", + ); + }); + + it("keeps HTTPS as the default for a schemeless hostname", () => { + expect(buildPairingUrl("remote.example.com", "pairing-token")).toBe( + "https://remote.example.com/#token=pairing-token", + ); + }); + + it("preserves an explicit scheme for an IP address", () => { + expect(buildPairingUrl("https://192.168.1.100:3773", "pairing-token")).toBe( + "https://192.168.1.100:3773/#token=pairing-token", + ); + }); +}); + describe("extractPairingUrlFromQrPayload", () => { it("trims raw pairing urls from qr payloads", () => { expect( diff --git a/apps/mobile/src/features/connection/pairing.ts b/apps/mobile/src/features/connection/pairing.ts index 910efa7f2565..569d00cbdd36 100644 --- a/apps/mobile/src/features/connection/pairing.ts +++ b/apps/mobile/src/features/connection/pairing.ts @@ -3,6 +3,21 @@ import * as Schema from "effect/Schema"; const MOBILE_PAIRING_URL_PARAM = "pairingUrl"; +function isIpLiteral(host: string): boolean { + try { + const hostname = new URL(`http://${host}`).hostname.replace(/^\[|\]$/g, ""); + if (hostname.includes(":")) return true; + + const octets = hostname.split("."); + return ( + octets.length === 4 && + octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255) + ); + } catch { + return false; + } +} + export class PairingQrPayloadEmptyError extends Schema.TaggedErrorClass()( "PairingQrPayloadEmptyError", {}, @@ -19,7 +34,7 @@ export function buildPairingUrl(host: string, code: string): string { if (!c) return h; try { - const url = new URL(h.includes("://") ? h : `https://${h}`); + const url = new URL(h.includes("://") ? h : `${isIpLiteral(h) ? "http" : "https"}://${h}`); url.hash = new URLSearchParams([["token", c]]).toString(); return url.toString(); } catch { diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index bec3070745af..d768509269ea 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -44,6 +44,8 @@ available. You can set another endpoint as the default from the expanded endpoin If the copied link points directly at `http://192.168.x.y:3773`, open it from a client that can reach that LAN address. If it points at `https://app.t3.codes/pair?...`, the hosted web app will save the environment and connect directly to the backend URL in the link. +In the mobile app's **Add Environment** form, a numeric IP address without a scheme uses HTTP. Include `https://` explicitly when the backend is served over HTTPS. + ### Tailscale Endpoints When the desktop app can detect Tailscale, it adds Tailnet endpoints to the reachable endpoint list. From 4840802f871eed9728ae271872fa113b2ade1a1f Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 30 Jul 2026 15:17:47 -0700 Subject: [PATCH 12/26] fix(mobile): restore iOS Threads branding (#4862) --- apps/mobile/package.json | 2 +- apps/mobile/src/Stack.tsx | 3 +- .../src/components/CompactBrandTitle.tsx | 121 ++++++++++++++++++ .../src/features/home/HomeRouteScreen.tsx | 9 +- .../threads/sidebar-navigation-shell.tsx | 4 +- .../features/threads/thread-list-items.tsx | 3 +- apps/mobile/src/lib/layoutMetrics.ts | 5 + apps/mobile/src/lib/mobileBranding.test.ts | 14 ++ apps/mobile/src/lib/mobileBranding.ts | 7 + apps/mobile/src/native/StackHeader.tsx | 3 +- .../mobile-app-store-screenshots.md | 2 +- scripts/mobile-showcase.test.ts | 13 +- scripts/mobile-showcase.ts | 12 +- 13 files changed, 174 insertions(+), 24 deletions(-) create mode 100644 apps/mobile/src/components/CompactBrandTitle.tsx create mode 100644 apps/mobile/src/lib/layoutMetrics.ts create mode 100644 apps/mobile/src/lib/mobileBranding.test.ts create mode 100644 apps/mobile/src/lib/mobileBranding.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 9a5e64aa46f7..c12ca979bf2f 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -11,7 +11,7 @@ "start:dev": "APP_VARIANT=development expo start", "start:preview": "APP_VARIANT=preview expo start", "start:prod": "APP_VARIANT=production expo start", - "showcase": "APP_VARIANT=development EXPO_PUBLIC_SHOWCASE=1 expo start --dev-client --scheme t3code-dev --clear", + "showcase": "APP_VARIANT=production EXPO_PUBLIC_SHOWCASE=1 expo start --dev-client --scheme t3code --clear", "screenshots": "node ../../scripts/mobile-showcase.ts", "android": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android", "android:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && REACT_NATIVE_PACKAGER_HOSTNAME=localhost expo run:android", diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 75f171360546..0bb8165f9fb9 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -15,6 +15,7 @@ import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "re import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/AppText"; +import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent"; @@ -398,7 +399,7 @@ export const RootStack = createNativeStackNavigator({ ...GLASS_HEADER_OPTIONS, contentStyle: { backgroundColor: "transparent" }, headerBackVisible: false, - title: "Threads", + ...getCompactBrandHeaderOptions(), }, }), Thread: createNativeStackScreen({ diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx new file mode 100644 index 000000000000..f0710e85d36d --- /dev/null +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -0,0 +1,121 @@ +import Constants from "expo-constants"; +import type { + NativeStackHeaderItem, + NativeStackNavigationOptions, +} from "@react-navigation/native-stack"; +import { Platform, View } from "react-native"; + +import { AppText as Text } from "./AppText"; +import { T3Wordmark } from "./T3Wordmark"; +import { IPAD_HOME_TITLE_OFFSET } from "../lib/layoutMetrics"; +import { resolveMobileStageLabel } from "../lib/mobileBranding"; +import { useThemeColor } from "../lib/useThemeColor"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../native/native-glass"; + +// Native leading items inherit different UIKit margins than title views. +const IOS_NATIVE_LEADING_TITLE_OFFSET = -6; +const IPAD_NATIVE_LEADING_TITLE_OFFSET = 7; + +/** + * Compact brand lockup sized for native navigation bars. + */ +export function CompactBrandTitle( + props: { + readonly nativeLeadingItem?: boolean; + } = {}, +) { + const iconColor = useThemeColor("--color-icon"); + const mutedColor = useThemeColor("--color-foreground-muted"); + const subtleColor = useThemeColor("--color-subtle"); + const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); + const titleOffset = + Platform.OS !== "ios" + ? 0 + : props.nativeLeadingItem + ? Platform.isPad + ? IPAD_NATIVE_LEADING_TITLE_OFFSET + : IOS_NATIVE_LEADING_TITLE_OFFSET + : Platform.isPad + ? IPAD_HOME_TITLE_OFFSET + : 0; + + return ( + + + + Code + + + + {stageLabel} + + + + ); +} + +export function renderCompactBrandTitle() { + return ; +} + +export function renderCompactBrandHeaderItems(): NativeStackHeaderItem[] { + return [ + { + element: , + hidesSharedBackground: true, + type: "custom", + }, + ]; +} + +export function getCompactBrandHeaderOptions( + fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"], +): NativeStackNavigationOptions { + if (Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED) { + return { + headerTitle: "Threads", + headerTitleStyle: { color: "transparent", fontSize: 18, fontWeight: "800" }, + title: "Threads", + unstable_headerLeftItems: renderCompactBrandHeaderItems, + }; + } + + return { + headerTitle: renderCompactBrandTitle, + headerTitleStyle: fallbackTitleStyle, + title: "Threads", + }; +} diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 62f6e324602b..7fcf78f83b40 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -3,6 +3,7 @@ import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; import { useEffect, useMemo, useState } from "react"; +import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects, useThreadShells } from "../../state/entities"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; @@ -93,7 +94,9 @@ export function HomeRouteScreen() { if (layout.usesSplitView) { return ( <> - + [] }} + /> navigation.navigate("NewTaskSheet", { screen: "NewTask" })} > <> - {/* Restore the compact title in case the split branch blanked it. */} - + {/* Restore the compact title after the split branch blanks the detail header. */} + { + it.each([ + ["development", "Dev"], + ["preview", "Nightly"], + ["production", "Alpha"], + [undefined, "Alpha"], + ])("maps %s builds to %s", (appVariant, expected) => { + expect(resolveMobileStageLabel(appVariant)).toBe(expected); + }); +}); diff --git a/apps/mobile/src/lib/mobileBranding.ts b/apps/mobile/src/lib/mobileBranding.ts new file mode 100644 index 000000000000..9fd6020831a1 --- /dev/null +++ b/apps/mobile/src/lib/mobileBranding.ts @@ -0,0 +1,7 @@ +export type MobileStageLabel = "Alpha" | "Dev" | "Nightly"; + +export function resolveMobileStageLabel(appVariant: unknown): MobileStageLabel { + if (appVariant === "development") return "Dev"; + if (appVariant === "preview") return "Nightly"; + return "Alpha"; +} diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index ddfbc64d6c13..a524d5152473 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -370,7 +370,8 @@ function NativeHeaderToolbarRoot(props: { const navigation = useNativeStackNavigation(); const items = useMemo(() => collectToolbarItems(props.children), [props.children]); - useEffect(() => { + // Swap toolbar owners before paint so split and compact headers cannot clear each other. + useLayoutEffect(() => { if (!navigation) { return; } diff --git a/docs/operations/mobile-app-store-screenshots.md b/docs/operations/mobile-app-store-screenshots.md index 27891cb5d638..e0e7a365a668 100644 --- a/docs/operations/mobile-app-store-screenshots.md +++ b/docs/operations/mobile-app-store-screenshots.md @@ -43,7 +43,7 @@ Captures wait for the real environment snapshot to hydrate and for the requested active. Both platforms record readiness in the simulator/emulator app container. A final settle delay allows native terminal and Git review data to finish rendering. -A full capture regenerates the selected native project with Expo's clean development prebuild before +A full capture regenerates the selected native project with Expo's clean production prebuild before building it. Use --skip-build for repeated captures after the first build. The harness uses fixed Metro port `8199`, which separates it from Expo's normal default port but is diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts index f0c5d02f2133..a2b5d697da08 100644 --- a/scripts/mobile-showcase.test.ts +++ b/scripts/mobile-showcase.test.ts @@ -253,22 +253,19 @@ it("selects a reachable LAN IPv4 address", () => { }); it("maps capture scenes to the real application routes", () => { - assert.equal(showcaseSceneUrl("threads", "environment-1"), "t3code-dev://"); - assert.equal( - showcaseSceneUrl("environments", "environment-1"), - "t3code-dev://settings/environments", - ); + assert.equal(showcaseSceneUrl("threads", "environment-1"), "t3code://"); + assert.equal(showcaseSceneUrl("environments", "environment-1"), "t3code://settings/environments"); assert.equal( showcaseSceneUrl("thread", "environment-1"), - "t3code-dev://threads/environment-1/remote-command-center", + "t3code://threads/environment-1/remote-command-center", ); assert.equal( showcaseSceneUrl("terminal", "environment-1"), - "t3code-dev://threads/environment-1/remote-command-center/terminal?terminalId=term-1", + "t3code://threads/environment-1/remote-command-center/terminal?terminalId=term-1", ); assert.equal( showcaseSceneUrl("review", "environment-1"), - "t3code-dev://threads/environment-1/remote-command-center/review", + "t3code://threads/environment-1/remote-command-center/review", ); }); diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index eb298a5fe78e..1058dde37a8c 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -31,14 +31,14 @@ import { const REPO_ROOT = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); const MOBILE_ROOT = NodePath.join(REPO_ROOT, "apps/mobile"); -const ANDROID_PACKAGE = "com.t3tools.t3code.dev"; -const APP_SCHEME = "t3code-dev"; +const ANDROID_PACKAGE = "com.t3tools.t3code"; +const APP_SCHEME = "t3code"; const IOS_READY_FILENAME = "T3ShowcaseReadyScene"; const SERVER_HOST = "0.0.0.0"; const IOS_SIMULATOR_ARCH = NodeProcess.arch === "arm64" ? "arm64" : "x86_64"; const IOS_APP_PATH = NodePath.join( MOBILE_ROOT, - ".showcase/ios-derived-data/Build/Products/Debug-iphonesimulator/T3CodeDev.app", + ".showcase/ios-derived-data/Build/Products/Debug-iphonesimulator/T3Code.app", ); const ANDROID_APK_PATH = NodePath.join( MOBILE_ROOT, @@ -58,7 +58,7 @@ const ANDROID_SDK_ROOT = resolveAndroidSdkRoot(NodeProcess.env); const MOBILE_BUILD_ENV = { ...NodeProcess.env, ANDROID_HOME: ANDROID_SDK_ROOT, - APP_VARIANT: "development", + APP_VARIANT: "production", EXPO_NO_GIT_STATUS: "1", JAVA_HOME: NodeProcess.env.JAVA_HOME ?? @@ -667,9 +667,9 @@ async function buildIos(): Promise { "xcodebuild", [ "-workspace", - NodePath.join(MOBILE_ROOT, "ios/T3CodeDev.xcworkspace"), + NodePath.join(MOBILE_ROOT, "ios/T3Code.xcworkspace"), "-scheme", - "T3CodeDev", + "T3Code", "-configuration", "Debug", "-sdk", From 8e60310e80a87256c1c428a1a092f81ddc3e0a8a Mon Sep 17 00:00:00 2001 From: Max Anderson Date: Thu, 30 Jul 2026 18:19:00 -0400 Subject: [PATCH 13/26] chore: add mobile issue template area (#4895) --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + .github/ISSUE_TEMPLATE/feature_request.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 07438b251c5d..9bc321dac0de 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -30,6 +30,7 @@ body: - apps/web - apps/server - apps/desktop + - apps/mobile - packages/contracts or packages/shared - Build, CI, or release tooling - Docs diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 53aab5166a56..3c9424fb322c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -30,6 +30,7 @@ body: - apps/web - apps/server - apps/desktop + - apps/mobile - packages/contracts or packages/shared - Build, CI, or release tooling - Docs From 0bf48bfc6f3ad0dff3c81f7d45bf76b1a5adf84b Mon Sep 17 00:00:00 2001 From: Simon Doba Date: Fri, 31 Jul 2026 00:19:34 +0200 Subject: [PATCH 14/26] fix(desktop): link release notes from the update downloaded toast (#4771) Co-authored-by: Claude Opus 5 --- apps/web/src/components/Sidebar.tsx | 7 +- .../components/desktopUpdate.logic.test.ts | 18 +++ .../web/src/components/desktopUpdate.logic.ts | 18 +++ .../components/desktopUpdate.toast.test.tsx | 121 ++++++++++++++++++ .../src/components/desktopUpdate.toast.tsx | 55 ++++++++ .../components/sidebar/SidebarUpdatePill.tsx | 7 +- 6 files changed, 216 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/components/desktopUpdate.toast.test.tsx create mode 100644 apps/web/src/components/desktopUpdate.toast.tsx diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 98b5dcf84edb..052e82718a5c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -133,6 +133,7 @@ import { shouldShowArm64IntelBuildWarning, shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; +import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; import { @@ -3509,11 +3510,7 @@ export default function Sidebar() { .downloadUpdate() .then((result) => { if (result.completed) { - toastManager.add({ - type: "success", - title: "Update downloaded", - description: "Restart the app from the update button to install it.", - }); + showDesktopUpdateDownloadedToast(bridge, result.state); } if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result); diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index b07ae99c0580..8d24b34a4336 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -7,6 +7,7 @@ import { getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, getDesktopUpdateInstallConfirmationMessage, + getDesktopUpdateReleaseUrl, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, @@ -158,6 +159,23 @@ describe("getDesktopUpdateActionError", () => { }); describe("desktop update UI helpers", () => { + it("builds the stable release URL for a downloaded version", () => { + expect(getDesktopUpdateReleaseUrl("0.0.30")).toBe( + "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30", + ); + }); + + it("builds the nightly release URL without dropping its version suffix", () => { + expect(getDesktopUpdateReleaseUrl("0.0.30-nightly.20260728.931")).toBe( + "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30-nightly.20260728.931", + ); + }); + + it("omits the release URL when the updater does not report a version", () => { + expect(getDesktopUpdateReleaseUrl(null)).toBeNull(); + expect(getDesktopUpdateReleaseUrl(" ")).toBeNull(); + }); + it("toasts only for actionable updater errors", () => { expect( shouldToastDesktopUpdateActionResult({ diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 11c34777a41f..dc09d7ca8773 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -3,6 +3,24 @@ import { isWindowsPlatform } from "../lib/utils"; export type DesktopUpdateButtonAction = "download" | "install" | "none"; +const DESKTOP_RELEASE_TAG_URL = "https://github.com/pingdotgg/t3code/releases/tag"; + +/** + * The main process fills `downloadedVersion` from the updater's `update-downloaded` + * event, which is dispatched on its own fiber. A download RPC can therefore resolve + * before that write lands, so fall back to the version the download was started for. + */ +export function getDesktopUpdateDownloadedVersion(state: DesktopUpdateState): string | null { + return state.downloadedVersion ?? state.availableVersion; +} + +/** Release notes for an exact downloaded build; nightly suffixes are part of the tag. */ +export function getDesktopUpdateReleaseUrl(version: string | null): string | null { + const normalizedVersion = version?.trim(); + if (!normalizedVersion) return null; + return `${DESKTOP_RELEASE_TAG_URL}/v${encodeURIComponent(normalizedVersion)}`; +} + export function resolveDesktopUpdateButtonAction( state: DesktopUpdateState, ): DesktopUpdateButtonAction { diff --git a/apps/web/src/components/desktopUpdate.toast.test.tsx b/apps/web/src/components/desktopUpdate.toast.test.tsx new file mode 100644 index 000000000000..369a3cdf4316 --- /dev/null +++ b/apps/web/src/components/desktopUpdate.toast.test.tsx @@ -0,0 +1,121 @@ +import { isValidElement, type ReactElement, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { DesktopUpdateState } from "@t3tools/contracts"; + +const testState = vi.hoisted(() => ({ + addToast: vi.fn(), +})); + +vi.mock("./ui/toast", () => ({ + toastManager: { add: testState.addToast }, +})); + +import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast"; + +type ClickableElement = ReactElement<{ readonly onClick?: () => void }>; + +/** Walks the rendered description, invoking function components, to find the link button. */ +function findReleaseNotesLink(node: ReactNode): ClickableElement | null { + if (Array.isArray(node)) { + for (const child of node) { + const found = findReleaseNotesLink(child); + if (found) return found; + } + return null; + } + if (!isValidElement(node)) return null; + const element = node as ReactElement<{ readonly children?: ReactNode }>; + if (element.type === "button") return element as ClickableElement; + if (typeof element.type === "function") { + const render = element.type as (props: unknown) => ReactNode; + return findReleaseNotesLink(render(element.props)); + } + return findReleaseNotesLink(element.props.children); +} + +function getDescription(): ReactNode { + const toast = testState.addToast.mock.calls[0]?.[0] as { description?: ReactNode } | undefined; + return toast?.description ?? null; +} + +function downloadedState(overrides: Partial = {}): DesktopUpdateState { + return { + enabled: true, + status: "downloaded", + channel: "latest", + currentVersion: "0.0.29", + hostArch: "arm64", + appArch: "arm64", + runningUnderArm64Translation: false, + availableVersion: "0.0.30", + downloadedVersion: "0.0.30", + releaseNotes: [], + downloadPercent: 100, + checkedAt: null, + message: null, + errorContext: null, + canRetry: true, + ...overrides, + }; +} + +describe("showDesktopUpdateDownloadedToast", () => { + beforeEach(() => { + testState.addToast.mockReset(); + }); + + it("opens the downloaded version's release notes", async () => { + const openExternal = vi.fn().mockResolvedValue(true); + + showDesktopUpdateDownloadedToast({ openExternal }, downloadedState()); + const link = findReleaseNotesLink(getDescription()); + link?.props.onClick?.(); + await vi.waitFor(() => { + expect(openExternal).toHaveBeenCalledWith( + "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30", + ); + }); + expect(testState.addToast).toHaveBeenCalledTimes(1); + }); + + it("falls back to the version the download was started for", async () => { + const openExternal = vi.fn().mockResolvedValue(true); + + // The `update-downloaded` event can land after the download RPC resolves. + showDesktopUpdateDownloadedToast( + { openExternal }, + downloadedState({ downloadedVersion: null }), + ); + findReleaseNotesLink(getDescription())?.props.onClick?.(); + + await vi.waitFor(() => { + expect(openExternal).toHaveBeenCalledWith( + "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30", + ); + }); + }); + + it("omits the link when the updater reports no version at all", () => { + showDesktopUpdateDownloadedToast( + { openExternal: vi.fn() }, + downloadedState({ availableVersion: null, downloadedVersion: null }), + ); + + expect(findReleaseNotesLink(getDescription())).toBeNull(); + }); + + it.each([ + ["returns false", vi.fn().mockResolvedValue(false)], + ["rejects", vi.fn().mockRejectedValue(new Error("open failed"))], + ])("shows an error when opening release notes %s", async (_description, openExternal) => { + showDesktopUpdateDownloadedToast({ openExternal }, downloadedState()); + findReleaseNotesLink(getDescription())?.props.onClick?.(); + + await vi.waitFor(() => { + expect(testState.addToast).toHaveBeenLastCalledWith({ + type: "error", + title: "Unable to open release notes", + }); + }); + }); +}); diff --git a/apps/web/src/components/desktopUpdate.toast.tsx b/apps/web/src/components/desktopUpdate.toast.tsx new file mode 100644 index 000000000000..004a76a81cd7 --- /dev/null +++ b/apps/web/src/components/desktopUpdate.toast.tsx @@ -0,0 +1,55 @@ +import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; +import { ArrowRightIcon } from "lucide-react"; + +import { + getDesktopUpdateDownloadedVersion, + getDesktopUpdateReleaseUrl, +} from "./desktopUpdate.logic"; +import { toastManager } from "./ui/toast"; + +type DesktopUpdateShell = Pick; + +function ReleaseNotesLink({ + shell, + releaseUrl, +}: { + shell: DesktopUpdateShell; + releaseUrl: string; +}) { + return ( + + ); +} + +export function showDesktopUpdateDownloadedToast( + shell: DesktopUpdateShell, + state: DesktopUpdateState, +): void { + const releaseUrl = getDesktopUpdateReleaseUrl(getDesktopUpdateDownloadedVersion(state)); + toastManager.add({ + type: "success", + title: "Update downloaded", + description: ( + <> + Restart the app from the update button to install it. + {releaseUrl ? : null} + + ), + }); +} diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 0603a9da085e..fb95dc11a4c0 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -14,6 +14,7 @@ import { shouldShowDesktopUpdateButton, shouldToastDesktopUpdateActionResult, } from "../desktopUpdate.logic"; +import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast"; import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { Separator } from "../ui/separator"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -80,11 +81,7 @@ export function SidebarUpdatePill() { .downloadUpdate() .then((result) => { if (result.completed) { - toastManager.add({ - type: "success", - title: "Update downloaded", - description: "Restart the app from the update button to install it.", - }); + showDesktopUpdateDownloadedToast(bridge, result.state); } if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result); From a9bbe0e80c3e761bad87fed91086188c7199edc9 Mon Sep 17 00:00:00 2001 From: Aditya Vardhan Sharma Date: Thu, 30 Jul 2026 23:20:33 +0100 Subject: [PATCH 15/26] fix(mobile): accept Android clipboard images in composer (#4836) --- .../src/native/T3ComposerEditor.native.tsx | 117 +++++++++--------- 1 file changed, 61 insertions(+), 56 deletions(-) diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 84d1c22084f7..e78f90a7db91 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -1,5 +1,6 @@ import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; import { requireNativeView } from "expo"; +import { TextInputWrapper } from "expo-paste-input"; import { useCallback, useEffect, @@ -9,12 +10,13 @@ import { useState, type Ref, } from "react"; -import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native"; +import type { NativeSyntheticEvent, ViewProps } from "react-native"; import { Image, StyleSheet } from "react-native"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; import { MOBILE_TYPOGRAPHY } from "../lib/typography"; +import { useNativePaste } from "../lib/useNativePaste"; import { useFontFamily } from "../lib/useFontFamily"; import { useThemeColor } from "../lib/useThemeColor"; import { @@ -117,6 +119,7 @@ export function ComposerEditor({ const skillBorder = useThemeColor("--color-inline-skill-border"); const skillText = useThemeColor("--color-inline-skill-foreground"); const fileTint = useThemeColor("--color-icon-muted"); + const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); useImperativeHandle( ref, @@ -221,61 +224,63 @@ export function ComposerEditor({ const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {}; const regularFontFamily = useFontFamily("regular"); return ( - } - onComposerChange={(event) => { - const acknowledgedEventCount = acceptNativeEvent( - event.nativeEvent.eventCount, - event.nativeEvent.value, - event.nativeEvent.selection, - ); - if (acknowledgedEventCount === false) return; - onChangeText(event.nativeEvent.value); - onSelectionChange?.(event.nativeEvent.selection); - setMostRecentEventCount(acknowledgedEventCount); - setNativeEventSequence((sequence) => sequence + 1); - }} - onComposerSelectionChange={(event) => { - const acknowledgedEventCount = acceptNativeEvent( - event.nativeEvent.eventCount, - event.nativeEvent.value, - event.nativeEvent.selection, - ); - if (acknowledgedEventCount === false) return; - onSelectionChange?.(event.nativeEvent.selection); - setMostRecentEventCount(acknowledgedEventCount); - setNativeEventSequence((sequence) => sequence + 1); - }} - onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} - onComposerFocus={onFocus} - onComposerBlur={onBlur} - /> + + { + const acknowledgedEventCount = acceptNativeEvent( + event.nativeEvent.eventCount, + event.nativeEvent.value, + event.nativeEvent.selection, + ); + if (acknowledgedEventCount === false) return; + onChangeText(event.nativeEvent.value); + onSelectionChange?.(event.nativeEvent.selection); + setMostRecentEventCount(acknowledgedEventCount); + setNativeEventSequence((sequence) => sequence + 1); + }} + onComposerSelectionChange={(event) => { + const acknowledgedEventCount = acceptNativeEvent( + event.nativeEvent.eventCount, + event.nativeEvent.value, + event.nativeEvent.selection, + ); + if (acknowledgedEventCount === false) return; + onSelectionChange?.(event.nativeEvent.selection); + setMostRecentEventCount(acknowledgedEventCount); + setNativeEventSequence((sequence) => sequence + 1); + }} + onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} + onComposerFocus={onFocus} + onComposerBlur={onBlur} + /> + ); } From f718bf003ea9d6dabd04fa417815bbda1193c09b Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 30 Jul 2026 15:30:46 -0700 Subject: [PATCH 16/26] fix(mobile): show the correct build channel in Android's Threads page header (#4861) --- apps/mobile/src/features/home/HomeHeader.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 12d5eaca4739..a209dbd76239 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,5 +1,6 @@ import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; +import Constants from "expo-constants"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useCallback, useMemo, useRef } from "react"; import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native"; @@ -9,6 +10,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; import { T3Wordmark } from "../../components/T3Wordmark"; +import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; +import { resolveMobileStageLabel } from "../../lib/mobileBranding"; import { useThemeColor } from "../../lib/useThemeColor"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; @@ -64,6 +67,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { const insets = useSafeAreaInsets(); const iconColor = useThemeColor("--color-icon"); const mutedColor = useThemeColor("--color-foreground-muted"); + const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); // Thread List v2 lays the list out in fixed creation order, so the // sort/group filter controls would be silently ignored — hide them and // key the "customized" icon state off the environment filter alone. @@ -195,8 +199,9 @@ function AndroidHomeHeader(props: HomeHeaderProps) { <> @@ -210,7 +215,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { - Alpha + {stageLabel} From df90fae41b6122e46a451fb5cab9c60711cea8cb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 31 Jul 2026 01:11:46 +0200 Subject: [PATCH 17/26] fix(contracts): decode growing config unions forward-compatibly (#5055) Co-authored-by: Claude Fable 5 --- .../client-runtime/src/rpc/session.test.ts | 34 ++++++++++ packages/contracts/src/baseSchemas.ts | 25 +++++++ packages/contracts/src/keybindings.test.ts | 65 +++++++++++++++++++ packages/contracts/src/keybindings.ts | 11 +++- packages/contracts/src/server.test.ts | 26 +++++++- packages/contracts/src/server.ts | 9 ++- 6 files changed, 165 insertions(+), 5 deletions(-) diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 71649ad94ddc..751453f82f69 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -287,6 +287,40 @@ describe("RpcSessionFactory", () => { }), ); + it.effect("reaches ready when a newer server sends unknown config members", () => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + + const shortcut = { + key: "p", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }; + yield* completeInitialConfig(socket, { + ...ENCODED_SERVER_CONFIG, + keybindings: [ + { command: "someFuture.toggle", shortcut }, + { command: "terminal.toggle", shortcut }, + ], + issues: [{ kind: "keybindings.future-issue", message: "From a newer server" }], + availableEditors: ["some-future-editor", "zed"], + }); + yield* Fiber.join(readyFiber); + + const config = yield* session.initialConfig; + expect(config.keybindings).toEqual([{ command: "terminal.toggle", shortcut }]); + expect(config.issues).toEqual([]); + expect(config.availableEditors).toEqual(["zed"]); + }), + ); + it.effect("uses the legacy config RPC for probes when the server lacks the capability", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index a8fa565cef43..9a63f22c9ef2 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -1,4 +1,5 @@ import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; @@ -20,6 +21,30 @@ export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximu export const IsoDateTime = Schema.String; export type IsoDateTime = typeof IsoDateTime.Type; +/** + * Wire codec for server→client arrays whose element unions grow over time + * (new literal members, new struct variants). Decoding drops elements the + * current build cannot decode instead of failing the whole payload — a client + * has to keep decoding configs sent by servers newer than itself, and + * rejecting the payload would take down the connection over data the client + * couldn't act on anyway. Encoding is the plain array encoding. + */ +export const ForwardCompatibleArray = (element: Element) => { + const decodeElement = Schema.decodeUnknownOption(element as never); + return Schema.Array(Schema.Unknown).pipe( + Schema.decodeTo( + Schema.Array(element), + SchemaTransformation.transform, ReadonlyArray>({ + decode: (values) => + values.filter((value) => Option.isSome(decodeElement(value))) as ReadonlyArray< + Element["Encoded"] + >, + encode: (values) => values, + }), + ), + ); +}; + /** * Construct a branded identifier. Enforces non-empty trimmed strings */ diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 8465da79445f..4fef273e5d67 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -20,6 +20,7 @@ const decode = ( >; const decodeResolvedRule = Schema.decodeUnknownEffect(ResolvedKeybindingRule as never); +const encodeResolvedKeybindings = Schema.encodeEffect(ResolvedKeybindingsConfig); it.effect("parses keybinding rules", () => Effect.gen(function* () { @@ -191,6 +192,70 @@ it.effect("parses resolved keybindings arrays", () => }), ); +const shortcut = { + key: "p", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, +}; + +it.effect("drops resolved rules with commands this build does not know", () => + Effect.gen(function* () { + const parsed = yield* decode(ResolvedKeybindingsConfig, [ + { command: "terminal.toggle", shortcut }, + { command: "someFuture.toggle", shortcut }, + { command: "filePicker.toggle", shortcut }, + ]); + assert.deepEqual( + parsed.map((rule) => rule.command), + ["terminal.toggle", "filePicker.toggle"], + ); + }), +); + +it.effect("drops resolved rules with unknown when-node types", () => + Effect.gen(function* () { + const parsed = yield* decode(ResolvedKeybindingsConfig, [ + { + command: "terminal.toggle", + shortcut, + whenAst: { type: "xor", left: 1, right: 2 }, + }, + { command: "terminal.split", shortcut }, + ]); + assert.deepEqual( + parsed.map((rule) => rule.command), + ["terminal.split"], + ); + }), +); + +it.effect("drops malformed resolved rule entries", () => + Effect.gen(function* () { + const parsed = yield* decode(ResolvedKeybindingsConfig, [ + "garbage", + { command: "terminal.toggle", shortcut }, + null, + ]); + assert.deepEqual( + parsed.map((rule) => rule.command), + ["terminal.toggle"], + ); + }), +); + +it.effect("encodes resolved keybindings to the plain wire shape", () => + Effect.gen(function* () { + const rules = [{ command: "terminal.toggle" as const, shortcut }]; + const encoded = yield* encodeResolvedKeybindings(rules); + assert.deepEqual(encoded, rules); + const roundTripped = yield* decode(ResolvedKeybindingsConfig, encoded); + assert.deepEqual(roundTripped, rules); + }), +); + it.effect("drops unknown fields in resolved keybinding rules", () => decodeResolvedRule({ command: "terminal.toggle", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 14a2a9a4b300..88867bd8a9c5 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { TrimmedString } from "./baseSchemas.ts"; +import { ForwardCompatibleArray, TrimmedString } from "./baseSchemas.ts"; export const MAX_KEYBINDING_VALUE_LENGTH = 64; export const MAX_KEYBINDING_WHEN_LENGTH = 256; @@ -155,7 +155,14 @@ export const ResolvedKeybindingRule = Schema.Struct({ }).annotate({ parseOptions: { onExcessProperty: "ignore" } }); export type ResolvedKeybindingRule = typeof ResolvedKeybindingRule.Type; -export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check( +/** + * The command set grows over time, so a client may receive rules it cannot + * represent (a command or `when` node added after that client shipped). + * Decoding drops those rules instead of failing the whole payload — + * rejecting the config would take down the connection over a shortcut the + * client couldn't dispatch anyway. + */ +export const ResolvedKeybindingsConfig = ForwardCompatibleArray(ResolvedKeybindingRule).check( Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), ); export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type; diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index c906f86f4dc0..078e9fcbf333 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -1,9 +1,11 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { ServerProvider } from "./server.ts"; +import { ServerConfig, ServerProvider, ServerUpsertKeybindingResult } from "./server.ts"; const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); +const decodeUpsertKeybindingResult = Schema.decodeUnknownSync(ServerUpsertKeybindingResult); +const decodeAvailableEditors = Schema.decodeUnknownSync(ServerConfig.fields.availableEditors); describe("ServerProvider", () => { it("defaults capability arrays when decoding provider snapshots", () => { @@ -72,3 +74,25 @@ describe("ServerProvider", () => { expect(parsed.continuation?.groupKey).toBe("codex:home:/Users/julius/.codex"); }); }); + +describe("server config forward compatibility", () => { + it("drops config issues with kinds this build does not know", () => { + const parsed = decodeUpsertKeybindingResult({ + keybindings: [], + issues: [ + { kind: "keybindings.invalid-entry", message: "Bad entry", index: 2 }, + { kind: "keybindings.future-issue", message: "From a newer server" }, + ], + }); + + expect(parsed.issues).toEqual([ + { kind: "keybindings.invalid-entry", message: "Bad entry", index: 2 }, + ]); + }); + + it("drops editor ids this build does not know", () => { + const parsed = decodeAvailableEditors(["zed", "some-future-editor", "vscode"]); + + expect(parsed).toEqual(["zed", "vscode"]); + }); +}); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index e083523bbdf7..334116794f87 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; import { ExecutionEnvironmentDescriptor, ServerSelfUpdateMethod } from "./environment.ts"; import { ServerAuthDescriptor } from "./auth.ts"; import { + ForwardCompatibleArray, IsoDateTime, NonNegativeInt, PositiveInt, @@ -38,7 +39,9 @@ export const ServerConfigIssue = Schema.Union([ ]); export type ServerConfigIssue = typeof ServerConfigIssue.Type; -const ServerConfigIssues = Schema.Array(ServerConfigIssue); +// Issue kinds grow over time; older clients must not fail the whole config +// decode over a kind they cannot render. +const ServerConfigIssues = ForwardCompatibleArray(ServerConfigIssue); export const ServerProviderState = Schema.Literals(["ready", "warning", "error", "disabled"]); export type ServerProviderState = typeof ServerProviderState.Type; @@ -417,7 +420,9 @@ export const ServerConfig = Schema.Struct({ keybindings: ResolvedKeybindingsConfig, issues: ServerConfigIssues, providers: ServerProviders, - availableEditors: Schema.Array(EditorId), + // Editor ids grow over time; drop ones this build does not know rather than + // failing the whole config decode. + availableEditors: ForwardCompatibleArray(EditorId), observability: ServerObservability, settings: ServerSettings, /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */ From fb4b010c7b3cf945b439bfd8d2e8f69255561588 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 30 Jul 2026 21:21:39 -0700 Subject: [PATCH 18/26] fix(mobile): server model, worktree, and origin preferences now apply to new tasks (#5064) Co-authored-by: Claude Fable 5 --- .../features/threads/NewTaskDraftScreen.tsx | 22 +++++++-- .../threads/new-task-flow-provider.tsx | 49 ++++++++++++++----- apps/mobile/src/lib/modelOptions.test.ts | 44 ++++++++++++++++- apps/mobile/src/lib/modelOptions.ts | 25 ++++++++++ .../src/state/use-composer-drafts.test.ts | 30 ++++++++++++ apps/mobile/src/state/use-composer-drafts.ts | 13 +++-- 6 files changed, 163 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index f37b5559a4a5..e920fddb6cb2 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -42,7 +42,8 @@ import { restoreComposerDraftSnapshot, type ComposerDraft, } from "../../state/use-composer-drafts"; -import { useProjects } from "../../state/entities"; +import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; +import { resolveSelectableModelSelection } from "../../lib/modelOptions"; import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; @@ -90,6 +91,9 @@ export function NewTaskDraftScreen(props: { const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); const { logicalProjects, selectedProject, setProject } = flow; const { connectedEnvironments } = useRemoteConnectionStatus(); + const selectedEnvironmentServerConfig = useEnvironmentServerConfig( + selectedProject?.environmentId ?? null, + ); const environmentConnected = selectedProject !== null && connectedEnvironments.find( @@ -795,7 +799,14 @@ export function NewTaskDraftScreen(props: { return; } const draft = getComposerDraftSnapshot(draftKey); - const modelSelection = draft.modelSelection ?? flow.selectedModel; + // Snapshot read keeps just-typed selector state; the availability gate + // still applies so a stored selection on a disabled provider falls back + // to the flow's resolved model. + const modelSelection = + resolveSelectableModelSelection( + selectedEnvironmentServerConfig, + draft.modelSelection ?? null, + ) ?? flow.selectedModel; const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode; const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName; const selectedWorktreePath = @@ -847,7 +858,10 @@ export function NewTaskDraftScreen(props: { if (editingPendingTask) { flow.finishEditingPendingTask(); } else { - clearComposerDraftContent(draftKey); + // Drop the workspace selection with the content: the next task should + // re-resolve mode/branch/origin from the server's configured defaults + // instead of resurrecting this task's picks. + clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true }); } navigation.getParent()?.goBack(); return; @@ -905,7 +919,7 @@ export function NewTaskDraftScreen(props: { } flow.finishEditingPendingTask(); } else { - clearComposerDraftContent(draftKey); + clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true }); } navigation.dispatch( StackActions.replace("Thread", { diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 74fe2f4852a8..8d4ce7a7feda 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -22,7 +22,11 @@ import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../ import type { TurnCommandMetadata } from "../../lib/commandMetadata"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; -import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; +import { + buildModelOptions, + groupByProvider, + resolveSelectableModelSelection, +} from "../../lib/modelOptions"; import { groupProjectsByRepository } from "../../lib/repositoryGroups"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { appAtomRegistry } from "../../state/atom-registry"; @@ -347,7 +351,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); const prompt = selectedProjectDraft.text; const attachments = selectedProjectDraft.attachments; - const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local"; + // The server's configured default decides the mode until the user picks one + // explicitly — same resolution web uses for new draft threads. + const defaultWorkspaceMode: WorkspaceMode = + selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local"; + const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; // Keep the user's explicit choice separate from the resolved display value: @@ -361,22 +369,29 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; + // Stored selections (draft and project default) only count while their + // provider is usable on the server; otherwise the server's default model + // wins instead of silently targeting a disabled provider. + const draftModelSelection = resolveSelectableModelSelection( + selectedEnvironmentServerConfig, + selectedProjectDraft.modelSelection ?? null, + ); + const projectDefaultModelSelection = resolveSelectableModelSelection( + selectedEnvironmentServerConfig, + selectedProject?.defaultModelSelection ?? null, + ); const modelOptions = useMemo( () => buildModelOptions( selectedEnvironmentServerConfig, - selectedProjectDraft.modelSelection ?? selectedProject?.defaultModelSelection ?? null, + draftModelSelection ?? projectDefaultModelSelection, ), - [ - selectedEnvironmentServerConfig, - selectedProject?.defaultModelSelection, - selectedProjectDraft.modelSelection, - ], + [selectedEnvironmentServerConfig, draftModelSelection, projectDefaultModelSelection], ); const selectedModel = - selectedProjectDraft.modelSelection ?? - selectedProject?.defaultModelSelection ?? + draftModelSelection ?? + projectDefaultModelSelection ?? modelOptions.find((option) => option.isDefault)?.selection ?? modelOptions[0]?.selection ?? null; @@ -675,12 +690,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { } const draft = getComposerDraftSnapshot(selectedProjectDraftKey); const text = draft.text.trim(); - const draftModelSelection = draft.modelSelection ?? selectedModel; + // Same availability gate the composer display applies: a stored + // selection targeting a disabled provider must not ride into the queue. + const draftModelSelection = + resolveSelectableModelSelection( + selectedEnvironmentServerConfig, + draft.modelSelection ?? null, + ) ?? selectedModel; if (text.length === 0 || !draftModelSelection) { return null; } const workspaceSelection = draft.workspaceSelection; - const mode = workspaceSelection?.mode ?? "local"; + // Fall back to the resolved mode (server default) so queued tasks drain + // with the same mode the composer displayed. + const mode = workspaceSelection?.mode ?? workspaceMode; // When the selection is the stand-in built from the queued snapshot, // persist the original (possibly absent) snapshot values — the // stand-in's placeholder title/workspaceRoot must never be written back @@ -722,10 +745,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { [ editingPendingProject, editingPendingTask, + selectedEnvironmentServerConfig, selectedModel, selectedProject, selectedProjectDraftKey, startFromOrigin, + workspaceMode, ], ); diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 9a71640b45ad..f9e1e25787ae 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts"; -import { buildModelOptions } from "./modelOptions"; +import { buildModelOptions, resolveSelectableModelSelection } from "./modelOptions"; describe("mobile model options", () => { it("normalizes a legacy fallback selection against current capabilities", () => { @@ -49,4 +49,46 @@ describe("mobile model options", () => { expect(option?.capabilities?.optionDescriptors?.[0]?.id).toBe("serviceTier"); expect(option?.selection.options).toEqual([{ id: "serviceTier", value: "default" }]); }); + + it("rejects stored selections whose provider is not usable", () => { + const config = { + providers: [ + { + instanceId: "codex", + driver: "codex", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [], + }, + { + instanceId: "claudeAgent", + driver: "claudeAgent", + enabled: false, + installed: true, + auth: { status: "authenticated" }, + models: [], + }, + ], + } as unknown as ServerConfig; + + const usable = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6-sol", + }; + const disabled = { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-5", + }; + const removed = { + instanceId: ProviderInstanceId.make("codex_personal"), + model: "gpt-5.6-sol", + }; + + expect(resolveSelectableModelSelection(config, usable)).toBe(usable); + expect(resolveSelectableModelSelection(config, disabled)).toBeNull(); + expect(resolveSelectableModelSelection(config, removed)).toBeNull(); + // No config (environment offline) — nothing to validate against. + expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled); + }); }); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index ab859c73b463..b51fa915dea9 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -58,6 +58,31 @@ function normalizeSelectionOptions( }; } +/** + * A stored model selection is only usable when its provider instance is + * currently enabled, installed, and authenticated on the server. Returns the + * selection unchanged when usable, otherwise `null` so callers fall through to + * the server's default model. A missing config (environment offline) cannot be + * validated, so stored selections pass through untouched. + */ +export function resolveSelectableModelSelection( + config: T3ServerConfig | null | undefined, + selection: ModelSelection | null, +): ModelSelection | null { + if (!selection || !config) { + return selection; + } + const provider = config.providers.find( + (candidate) => candidate.instanceId === selection.instanceId, + ); + return provider && + provider.enabled && + provider.installed && + provider.auth.status !== "unauthenticated" + ? selection + : null; +} + export function buildModelOptions( config: T3ServerConfig | null | undefined, fallbackModelSelection: ModelSelection | null, diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index fdabe67bc716..fed97e81e08f 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -119,6 +119,36 @@ describe("mobile composer drafts", () => { }); }); + it("drops the workspace selection when clearing a sent new-task draft", () => { + const draftKey = "new-task:environment-1:project-1"; + const draft: ComposerDraft = { + text: "send this", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + workspaceSelection: { + mode: "worktree", + branch: "main", + worktreePath: null, + startFromOrigin: false, + }, + }; + + expect( + clearComposerDraftContentState({ [draftKey]: draft }, draftKey, { + clearWorkspaceSelection: true, + }), + ).toEqual({ + [draftKey]: { + modelSelection: draft.modelSelection, + text: "", + attachments: [], + }, + }); + }); + it("reads the latest selector state synchronously for send", () => { const draftKey = "environment-1:thread-1"; const selectedDraft: ComposerDraft = { diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index cdef999e0439..24fa547e2728 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -372,14 +372,18 @@ export function updateComposerDraftSettings( export function clearComposerDraftContentState( current: Record, draftKey: string, + options?: { readonly clearWorkspaceSelection?: boolean }, ): Record { const existing = current[draftKey]; if (!existing) { return current; } - const { importedShareIds: _importedShareIds, ...retained } = existing; + const { importedShareIds: _importedShareIds, workspaceSelection, ...retained } = existing; const draft = { ...retained, + ...(options?.clearWorkspaceSelection || workspaceSelection === undefined + ? {} + : { workspaceSelection }), text: "", attachments: [], }; @@ -526,8 +530,11 @@ export async function restoreComposerDraftSnapshot( await persistenceQueue.run(() => writePersistedComposerDrafts(next)); } -export function clearComposerDraftContent(draftKey: string): void { - updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey)); +export function clearComposerDraftContent( + draftKey: string, + options?: { readonly clearWorkspaceSelection?: boolean }, +): void { + updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options)); } export function clearComposerDraft(draftKey: string): void { From 8996a074e9be6db36dc1426b1d4ab4956d77195e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 31 Jul 2026 10:11:12 +0200 Subject: [PATCH 19/26] fix(ci): repair the mobile showcase screenshots workflow (#5057) --- .../workflows/mobile-showcase-screenshots.yml | 2 + scripts/mobile-showcase-environment.ts | 59 +++++++++++++++---- scripts/mobile-showcase.ts | 29 +++++++-- 3 files changed, 72 insertions(+), 18 deletions(-) diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index 0aa9f30a2ff2..3eaaf508e31f 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -52,6 +52,7 @@ jobs: args: - --filter=@t3tools/mobile... - --filter=@t3tools/scripts... + - --filter=t3... - name: Expose pnpm run: | @@ -100,6 +101,7 @@ jobs: args: - --filter=@t3tools/mobile... - --filter=@t3tools/scripts... + - --filter=t3... - name: Expose pnpm run: | diff --git a/scripts/mobile-showcase-environment.ts b/scripts/mobile-showcase-environment.ts index 28ce513cf922..bb6b5f65d067 100644 --- a/scripts/mobile-showcase-environment.ts +++ b/scripts/mobile-showcase-environment.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off globalDate:off - This host-side fixture creates an isolated local T3 environment. +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off globalDate:off - This host-side fixture creates an isolated local T3 environment. import * as NodeChildProcess from "node:child_process"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; @@ -402,6 +402,48 @@ function insertThread( .run(input.id, isWorking ? "running" : "ready", isWorking ? turnId : null, updatedAt); } +const SEEDED_PROJECTION_TABLES = [ + "projection_pending_approvals", + "projection_thread_proposed_plans", + "projection_thread_activities", + "projection_thread_messages", + "projection_thread_sessions", + "projection_turns", + "projection_threads", + "projection_projects", + "projection_state", +] as const; + +function hasSeedableSchema(dbPath: string): boolean { + let database: NodeSqlite.DatabaseSync; + try { + database = new NodeSqlite.DatabaseSync(dbPath, { readOnly: true }); + } catch { + return false; + } + try { + const row = database + .prepare( + `SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name IN (${SEEDED_PROJECTION_TABLES.map(() => "?").join(", ")})`, + ) + .get(...SEEDED_PROJECTION_TABLES) as { count: number }; + return row.count === SEEDED_PROJECTION_TABLES.length; + } catch { + return false; + } finally { + database.close(); + } +} + +async function waitForSeedableSchema(dbPath: string, timeoutMs = 60_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (hasSeedableSchema(dbPath)) return; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`The environment server did not migrate ${dbPath} within ${timeoutMs}ms.`); +} + function seedDatabase( dbPath: string, workspaceRoots: ReadonlyMap, @@ -416,17 +458,7 @@ function seedDatabase( const database = new NodeSqlite.DatabaseSync(dbPath, { timeout: 30_000 }); try { database.exec("BEGIN IMMEDIATE"); - for (const table of [ - "projection_pending_approvals", - "projection_thread_proposed_plans", - "projection_thread_activities", - "projection_thread_messages", - "projection_thread_sessions", - "projection_turns", - "projection_threads", - "projection_projects", - "projection_state", - ]) { + for (const table of SEEDED_PROJECTION_TABLES) { database.exec(`DELETE FROM ${table}`); } const insertProject = database.prepare( @@ -609,6 +641,9 @@ export async function seedShowcaseEnvironment(input: { }); }), ); + // The environment server begins listening before it finishes migrating the + // database, so wait for the schema before deleting from and reseeding it. + await waitForSeedableSchema(dbPath); seedDatabase(dbPath, workspaceRoots, projects, threads, now); const terminalDirectory = NodePath.join(input.baseDir, "userdata", "logs", "terminals"); diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index 1058dde37a8c..6fdcbde9c382 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -503,6 +503,23 @@ async function waitForPort(port: number, label = "Process", timeoutMs = 60_000): throw new Error(`${label} did not begin listening on port ${port} within ${timeoutMs}ms.`); } +async function waitForFileContent( + filePath: string, + label: string, + timeoutMs = 60_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const content = await NodeFSP.readFile(filePath, "utf8").then( + (value) => value.trim(), + () => "", + ); + if (content) return content; + await delay(250); + } + throw new Error(`${label} was not written to ${filePath} within ${timeoutMs}ms.`); +} + async function reserveAvailablePort(): Promise { return await new Promise((resolve, reject) => { const server = NodeNet.createServer(); @@ -1231,12 +1248,12 @@ async function main(): Promise { showcaseServers.push(server); await waitForPort(port, `${environment.label} server`); await seedShowcaseEnvironment({ baseDir, projectIds: environment.projectIds }); - const environmentId = ( - await NodeFSP.readFile(NodePath.join(baseDir, "userdata", "environment-id"), "utf8") - ).trim(); - if (!environmentId) { - throw new Error(`${environment.label} did not persist an environment id.`); - } + // The server begins listening before the ServerEnvironment layer + // persists the environment id, so poll rather than read once. + const environmentId = await waitForFileContent( + NodePath.join(baseDir, "userdata", "environment-id"), + `${environment.label} environment id`, + ); showcaseEnvironments.push({ baseDir, environmentId, label: environment.label, port }); } From 9fb66730509cf74e43db8298422ad61126d31efc Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 31 Jul 2026 01:12:23 -0700 Subject: [PATCH 20/26] fix(ci): capture iPad App Store screenshots in landscape (#5065) --- .../mobile-app-store-screenshots.md | 5 +- scripts/mobile-showcase.config.ts | 7 ++- scripts/mobile-showcase.test.ts | 13 ++-- scripts/mobile-showcase.ts | 62 ++++++++++++++++++- 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/docs/operations/mobile-app-store-screenshots.md b/docs/operations/mobile-app-store-screenshots.md index e0e7a365a668..f271e54098aa 100644 --- a/docs/operations/mobile-app-store-screenshots.md +++ b/docs/operations/mobile-app-store-screenshots.md @@ -61,7 +61,7 @@ The default matrix is: | ----------------------------- | ------------------------- | ----------------- | ----------------------------------------- | | `apple/iphone-6.9/dark/` | iPhone 17 Pro Max | 1320×2868 | App Store Connect iPhone 6.9-inch | | `apple/iphone-6.5/dark/` | disposable iPhone 14 Plus | 1284×2778 | App Store Connect iPhone 6.5-inch | -| `apple/ipad-13/dark/` | iPad Pro 13-inch (M5) | 2064×2752 | App Store Connect iPad 13-inch | +| `apple/ipad-13/dark/` | iPad Pro 13-inch (M5) | 2752×2064 | App Store Connect iPad 13-inch, landscape | | `google-play/phone/dark/` | Pixel AVD at 420 dpi | 1080×1920 | Google Play phone, portrait 9:16 | | `google-play/tablet-7/dark/` | Pixel AVD at 600dp width | 1080×1920 | Google Play 7-inch tablet, portrait 9:16 | | `google-play/tablet-10/dark/` | Pixel AVD at 800dp width | 1440×2560 | Google Play 10-inch tablet, portrait 9:16 | @@ -86,7 +86,8 @@ A light-only run writes the same tree under `light/`; `--appearance both` writes folders. Edit [mobile-showcase.config.ts](../../scripts/mobile-showcase.config.ts) to change simulator or AVD -names, light/dark appearance, scenes, output directory, capture delay, Android ABI, or viewport. +names, light/dark appearance, iOS orientation, scenes, output directory, capture delay, Android ABI, +or viewport. ## Capture in GitHub Actions diff --git a/scripts/mobile-showcase.config.ts b/scripts/mobile-showcase.config.ts index 3237933bec8a..7f1123968385 100644 --- a/scripts/mobile-showcase.config.ts +++ b/scripts/mobile-showcase.config.ts @@ -25,6 +25,8 @@ export interface ShowcaseIosDevice { readonly simulatorDeviceType?: string; /** Appearance used when the CLI does not pass --appearance. */ readonly appearance: ShowcaseAppearance; + /** Orientation applied by the capture harness. Defaults to portrait. */ + readonly orientation?: "portrait" | "landscape"; readonly scenes: ReadonlyArray; readonly storeAsset: ShowcaseStoreAssetSpec; } @@ -122,12 +124,13 @@ const config: ShowcaseConfig = { simulator: "iPad Pro 13-inch (M5)", simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5-16GB", appearance: "dark", + orientation: "landscape", scenes: ["thread", "terminal", "review", "threads", "environments"], storeAsset: { store: "apple", directory: "apple/ipad-13", - width: 2064, - height: 2752, + width: 2752, + height: 2064, minimumUploadCount: 1, maximumUploadCount: 10, }, diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts index a2b5d697da08..1b9431be654d 100644 --- a/scripts/mobile-showcase.test.ts +++ b/scripts/mobile-showcase.test.ts @@ -225,17 +225,18 @@ it("configures every default device with an exact upload-ready store target", () assert.deepStrictEqual( showcaseConfig.devices.map((device) => [ device.id, + device.platform === "ios" ? (device.orientation ?? "portrait") : null, device.storeAsset.directory, device.storeAsset.width, device.storeAsset.height, ]), [ - ["iphone-6.9", "apple/iphone-6.9", 1320, 2868], - ["iphone-6.5", "apple/iphone-6.5", 1284, 2778], - ["ipad-13", "apple/ipad-13", 2064, 2752], - ["pixel", "google-play/phone", 1080, 1920], - ["android-tablet-7", "google-play/tablet-7", 1080, 1920], - ["android-tablet-10", "google-play/tablet-10", 1440, 2560], + ["iphone-6.9", "portrait", "apple/iphone-6.9", 1320, 2868], + ["iphone-6.5", "portrait", "apple/iphone-6.5", 1284, 2778], + ["ipad-13", "landscape", "apple/ipad-13", 2752, 2064], + ["pixel", null, "google-play/phone", 1080, 1920], + ["android-tablet-7", null, "google-play/tablet-7", 1080, 1920], + ["android-tablet-10", null, "google-play/tablet-10", 1440, 2560], ], ); }); diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index 6fdcbde9c382..4ba2b7941cfa 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -87,9 +87,11 @@ export interface ShowcaseCapture { } interface IosCaptureCleanup { + readonly name: string; readonly udid: string; readonly startedByRunner: boolean; readonly createdByRunner: boolean; + readonly restorePortrait: boolean; } interface AndroidCaptureCleanup { @@ -795,6 +797,49 @@ async function normalizeIosSimulator(appearance: ShowcaseAppearance, udid: strin ]); } +async function setIosSimulatorOrientation( + orientation: NonNullable, + simulator: Pick, +): Promise { + await runCommand("open", ["-a", "Simulator", "--args", "-CurrentDeviceUDID", simulator.udid]); + const menuItem = orientation === "landscape" ? "Landscape Right" : "Portrait"; + await runCommand("osascript", [ + "-e", + "on run argv", + "-e", + "set simulatorName to item 1 of argv", + "-e", + 'tell application "Simulator" to activate', + "-e", + 'tell application "System Events" to tell process "Simulator"', + "-e", + "set simulatorWindows to {}", + "-e", + "repeat 40 times", + "-e", + 'set simulatorWindows to menu items of menu "Window" of menu bar item "Window" of menu bar 1 whose name starts with simulatorName', + "-e", + "if (count of simulatorWindows) is greater than 0 then exit repeat", + "-e", + "delay 0.25", + "-e", + "end repeat", + "-e", + 'if (count of simulatorWindows) is not 1 then error "Expected exactly one Simulator window for " & simulatorName', + "-e", + "click item 1 of simulatorWindows", + "-e", + `click menu item "${menuItem}" of menu "Orientation" of menu item "Orientation" of menu "Device" of menu bar item "Device" of menu bar 1`, + "-e", + "end tell", + "-e", + "delay 1", + "-e", + "end run", + simulator.name, + ]); +} + async function iosAppContainer(udid: string): Promise { return ( await commandOutput("xcrun", ["simctl", "get_app_container", udid, ANDROID_PACKAGE, "data"]) @@ -831,7 +876,13 @@ async function captureIos( ): Promise { const { simulator, createdByRunner } = await ensureIosSimulator(capture.device); const startedByRunner = simulator.state !== "Booted"; - registerCleanup({ udid: simulator.udid, startedByRunner, createdByRunner }); + registerCleanup({ + name: simulator.name, + udid: simulator.udid, + startedByRunner, + createdByRunner, + restorePortrait: capture.device.orientation === "landscape", + }); if (!startedByRunner) { // Clear transient SpringBoard state (permission prompts, stale URL-open // confirmations, keyboards) without erasing the developer's simulator. @@ -893,6 +944,9 @@ async function captureIos( "--showcaseScene", firstScene, ]); + if (capture.device.orientation === "landscape") { + await setIosSimulatorOrientation("landscape", simulator); + } }; await NodeFSP.rm(readyPath, { force: true }); await NodeFSP.writeFile(scenePath, firstScene); @@ -923,6 +977,9 @@ async function captureIos( `${scene}.png`, ); await runCommand("xcrun", ["simctl", "io", simulator.udid, "screenshot", destination]); + if (capture.device.orientation === "landscape") { + await runCommand("sips", ["--rotate", "90", destination]); + } await finalizeCapture(destination, capture.device); } } @@ -1339,6 +1396,9 @@ async function main(): Promise { } } for (const cleanup of iosCleanups) { + if (cleanup.restorePortrait) { + await setIosSimulatorOrientation("portrait", cleanup).catch(() => undefined); + } if (cleanup.startedByRunner || cleanup.createdByRunner) { await runCommand("xcrun", ["simctl", "shutdown", cleanup.udid]).catch(() => undefined); } From 02758db662e9fbc7baf19d7c17ae4ecc5589bce0 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Fri, 31 Jul 2026 05:45:00 -0400 Subject: [PATCH 21/26] fix(oxlint-plugin): Resolve the oxlint bin without assuming a pnpm layout (#5066) --- oxlint-plugin-t3code/test/utils.ts | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/oxlint-plugin-t3code/test/utils.ts b/oxlint-plugin-t3code/test/utils.ts index eb91d32d7d46..740da8a4f2ba 100644 --- a/oxlint-plugin-t3code/test/utils.ts +++ b/oxlint-plugin-t3code/test/utils.ts @@ -9,6 +9,17 @@ import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as NodeModule from "node:module"; + +// oxlint is only a transitive dependency (via vite-plus), so its bin placement +// varies by package manager: pnpm hoists it into the virtual store, while +// other layouts expose vite-plus's LSP-only wrapper instead. Resolve the real +// package through vite-plus rather than hardcoding either layout, and do it at +// module scope so a broken install fails once with a resolution error instead +// of as an opaque defect in every test. +const oxlintPackageJsonPath = NodeModule.createRequire( + NodeModule.createRequire(import.meta.url).resolve("vite-plus/package.json"), +).resolve("oxlint/package.json"); class OxlintFixtureFailure extends Data.TaggedError("OxlintFixtureFailure")<{ readonly exitCode: number; @@ -93,14 +104,7 @@ export const createOxlintRuleHarness = ( const configPath = path.join(fixtureDir, ".oxlintrc.json"); const sourcePath = path.join(fixtureDir, options.filename ?? "fixture.ts"); const repoRoot = path.join(import.meta.dirname, "..", ".."); - const oxlintBin = path.join( - repoRoot, - "node_modules", - ".pnpm", - "node_modules", - ".bin", - "oxlint", - ); + const oxlintBin = path.join(path.dirname(oxlintPackageJsonPath), "bin", "oxlint"); const pluginPath = path.join(repoRoot, "oxlint-plugin-t3code", "index.ts"); yield* fs.writeFileString( @@ -112,8 +116,13 @@ export const createOxlintRuleHarness = ( ); yield* fs.writeFileString(sourcePath, source); + // Run through the current Node binary: oxlint's bin is an extensionless + // shebang script, which Windows cannot spawn directly and which would + // otherwise pick up whatever node is first on PATH. const output = yield* spawnAndCollectOutput( - ChildProcess.make(oxlintBin, ["--config", configPath, sourcePath], { cwd: repoRoot }), + ChildProcess.make(process.execPath, [oxlintBin, "--config", configPath, sourcePath], { + cwd: repoRoot, + }), ); if (output.exitCode !== 0) { From 7d993309b9e97476ce4c86a245d01b224edf25f0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 02:27:09 +0000 Subject: [PATCH 22/26] fix: adapt upstream L1 cherry-picks to fork Keep formatter-clean CSS after sync and refresh the lockfile importer entries for the Clerk catalog bump from #5015. Co-authored-by: aaditagrawal --- apps/web/src/index.css | 4 +++- pnpm-lock.yaml | 9 +++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 599455fa9b25..9552f38e8b22 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -55,7 +55,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-new(t3-mobil } } -html[data-mobile-composer-route-transition="true"]::view-transition-group(t3-mobile-draft-headline) { +html[data-mobile-composer-route-transition="true"]::view-transition-group( + t3-mobile-draft-headline + ) { animation-duration: 130ms; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 770238b2b76b..9faf3a131f7b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -529,8 +529,8 @@ importers: specifier: ^1.4.1 version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/clerk-js': - specifier: 6.25.7 - version: 6.25.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 6.25.12 + version: 6.25.12(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron': specifier: 0.0.24 version: 0.0.24(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -6118,6 +6118,7 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} @@ -11688,7 +11689,7 @@ snapshots: '@clerk/shared': 4.25.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 - '@tanstack/query-core': 5.100.14 + '@tanstack/query-core': 5.101.4 '@zxcvbn-ts/core': 3.0.4 '@zxcvbn-ts/language-common': 3.0.4 alien-signals: 2.0.6 @@ -11705,7 +11706,7 @@ snapshots: '@clerk/shared': 4.25.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 - '@tanstack/query-core': 5.100.14 + '@tanstack/query-core': 5.101.4 '@zxcvbn-ts/core': 3.0.4 '@zxcvbn-ts/language-common': 3.0.4 alien-signals: 2.0.6 From a1922f7fe35d68d04f56f8fb461fe81e4d660288 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 03:24:36 +0000 Subject: [PATCH 23/26] fix: format index.css for vp check Collapse the mobile draft headline view-transition-group selector to the single-line form oxfmt 0.57 / vite-plus 0.2.2 expects. Co-authored-by: aaditagrawal --- apps/web/src/index.css | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 9552f38e8b22..599455fa9b25 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -55,9 +55,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-new(t3-mobil } } -html[data-mobile-composer-route-transition="true"]::view-transition-group( - t3-mobile-draft-headline - ) { +html[data-mobile-composer-route-transition="true"]::view-transition-group(t3-mobile-draft-headline) { animation-duration: 130ms; } From 7eaa4a69fd72908f89cbcc6f0dcdc36881a912cf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 03:37:56 +0000 Subject: [PATCH 24/26] fix: address CodeRabbit review on L1 sync Co-authored-by: aaditagrawal --- .github/workflows/mobile-eas-preview.yml | 9 +++++---- docs/operations/mobile-app-store-screenshots.md | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index d9186c5a5123..356931099bbc 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -2,7 +2,11 @@ name: Mobile EAS Preview on: pull_request: - types: [opened, reopened, synchronize, labeled] + types: [opened, reopened, synchronize, labeled, unlabeled] + +concurrency: + group: mobile-eas-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true jobs: preview: @@ -15,9 +19,6 @@ jobs: contains(github.event.pull_request.labels.*.name, '🚀 Mobile Continuous Deployment') && (github.event.action != 'labeled' || github.event.label.name == '🚀 Mobile Continuous Deployment') runs-on: ubuntu-24.04 - concurrency: - group: mobile-eas-preview-${{ github.event.pull_request.number }} - cancel-in-progress: true permissions: contents: read pull-requests: write diff --git a/docs/operations/mobile-app-store-screenshots.md b/docs/operations/mobile-app-store-screenshots.md index f271e54098aa..cc10580a7612 100644 --- a/docs/operations/mobile-app-store-screenshots.md +++ b/docs/operations/mobile-app-store-screenshots.md @@ -128,7 +128,7 @@ By default, let the screenshot runner start Metro on port `8199`. To keep Metro terminal, start it with the same showcase environment and explicit harness port: cd apps/mobile - APP_VARIANT=development EXPO_PUBLIC_SHOWCASE=1 pnpm exec expo start --dev-client --port 8199 + APP_VARIANT=production EXPO_PUBLIC_SHOWCASE=1 pnpm exec expo start --dev-client --port 8199 Then run the capture from the repository root: From fa36608f46bf47faa1986bc6fcbb2a68ba7e6414 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 03:43:29 +0000 Subject: [PATCH 25/26] fix: format index.css for vp check Co-authored-by: aaditagrawal --- apps/web/src/index.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 599455fa9b25..9552f38e8b22 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -55,7 +55,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-new(t3-mobil } } -html[data-mobile-composer-route-transition="true"]::view-transition-group(t3-mobile-draft-headline) { +html[data-mobile-composer-route-transition="true"]::view-transition-group( + t3-mobile-draft-headline + ) { animation-duration: 130ms; } From 7d52eed497c4b46f0eac8467ac11017cacad4a4f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 03:54:44 +0000 Subject: [PATCH 26/26] fix: format index.css with vp 0.2.2 Co-authored-by: aaditagrawal --- apps/web/src/index.css | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 9552f38e8b22..599455fa9b25 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -55,9 +55,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-new(t3-mobil } } -html[data-mobile-composer-route-transition="true"]::view-transition-group( - t3-mobile-draft-headline - ) { +html[data-mobile-composer-route-transition="true"]::view-transition-group(t3-mobile-draft-headline) { animation-duration: 130ms; }