diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml new file mode 100644 index 00000000000..36dfb61f73f --- /dev/null +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -0,0 +1,134 @@ +name: Mobile Showcase Screenshots + +on: + workflow_dispatch: + inputs: + platform: + description: Device platforms to capture + required: true + default: all + type: choice + options: + - all + - ios + - android + appearance: + description: System appearances to capture + required: true + default: both + type: choice + options: + - both + - dark + - light + +permissions: + contents: read + +env: + NODE_OPTIONS: --max-old-space-size=8192 + +jobs: + ios: + name: iPhone 6.9, iPhone 6.5, and iPad 13 + if: inputs.platform == 'all' || inputs.platform == 'ios' + runs-on: blacksmith-12vcpu-macos-26 + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Expose pnpm + run: | + pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" + vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" + echo "$vp_pnpm_bin" >> "$GITHUB_PATH" + "$vp_pnpm_bin/pnpm" --version + + - name: Capture iOS showcase + run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" + + - name: Validate App Store Connect assets + run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --validate-only + + - name: Upload iOS screenshots + if: always() + uses: actions/upload-artifact@v7 + with: + name: app-store-connect-screenshots + path: artifacts/app-store/screenshots/apple/ + if-no-files-found: warn + retention-days: 14 + + android: + name: Android phone, 7-inch tablet, and 10-inch tablet + if: inputs.platform == 'all' || inputs.platform == 'android' + runs-on: blacksmith-16vcpu-ubuntu-2404 + timeout-minutes: 60 + env: + T3_SHOWCASE_ANDROID_ABI: x86_64 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Expose pnpm + run: | + pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" + vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" + echo "$vp_pnpm_bin" >> "$GITHUB_PATH" + "$vp_pnpm_bin/pnpm" --version + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + + - name: Setup Gradle cache + uses: gradle/actions/setup-gradle@v5 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Capture Android showcase + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 36 + target: google_apis + arch: x86_64 + profile: pixel_7_pro + avd-name: Pixel_10_Pro + cores: 8 + ram-size: 4096M + disable-animations: false + script: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" + + - name: Validate Google Play assets + run: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --validate-only + + - name: Upload Android screenshots + if: always() + uses: actions/upload-artifact@v7 + with: + name: google-play-screenshots + path: artifacts/app-store/screenshots/google-play/ + if-no-files-found: warn + retention-days: 14 diff --git a/.gitignore b/.gitignore index ef6067824f2..8e1669c8115 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ squashfs-root/ .gstack/ dist-electron/ .electron-runtime/ +.showcase/ +apps/mobile/.showcase/ +artifacts/app-store/screenshots/ node_modules/ .alchemy/ *.log diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 081ef903a06..929afeeabe9 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -20,7 +20,7 @@ export const APP_BUNDLE_ID = isDevelopment ? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}` : "com.t3tools.t3code"; const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"]; -const LAUNCHER_VERSION = 12; +const LAUNCHER_VERSION = 14; const defaultIconPath = NodePath.join(desktopDir, "resources", "icon.icns"); const developmentMacIconPngPath = NodePath.join( repoRoot, @@ -220,11 +220,12 @@ function ensureDevelopmentIconIcns(runtimeDir) { } } -function patchMainBundleInfoPlist(appBundlePath, iconPath) { +function patchMainBundleInfoPlist(appBundlePath, iconPath, executableName) { const infoPlistPath = NodePath.join(appBundlePath, "Contents", "Info.plist"); setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME); setPlistString(infoPlistPath, "CFBundleName", APP_DISPLAY_NAME); setPlistString(infoPlistPath, "CFBundleIdentifier", APP_BUNDLE_ID); + setPlistString(infoPlistPath, "CFBundleExecutable", executableName); setPlistString(infoPlistPath, "CFBundleIconFile", "icon.icns"); setPlistJson(infoPlistPath, "CFBundleURLTypes", [ { @@ -277,11 +278,25 @@ function readJson(path) { } } +export function resolveMacLauncherPaths(appBundlePath, displayName = APP_DISPLAY_NAME) { + const executableDir = NodePath.join(appBundlePath, "Contents", "MacOS"); + const launcherExecutableName = `${displayName} Launcher`; + return { + launcherExecutableName, + launcherBinaryPath: NodePath.join(executableDir, launcherExecutableName), + runtimeElectronBinaryPath: NodePath.join(executableDir, "Electron"), + }; +} + function buildMacLauncher(electronBinaryPath) { const sourceAppBundlePath = NodePath.resolve(NodePath.dirname(electronBinaryPath), "../.."); const runtimeDir = NodePath.join(desktopDir, ".electron-runtime"); const targetAppBundlePath = NodePath.join(runtimeDir, `${APP_DISPLAY_NAME}.app`); - const targetBinaryPath = NodePath.join(targetAppBundlePath, "Contents", "MacOS", "Electron"); + const developmentPaths = resolveMacLauncherPaths(targetAppBundlePath); + const runtimeElectronBinaryPath = developmentPaths.runtimeElectronBinaryPath; + const launcherBinaryPath = isDevelopment + ? developmentPaths.launcherBinaryPath + : runtimeElectronBinaryPath; const iconPath = isDevelopment ? ensureDevelopmentIconIcns(runtimeDir) : defaultIconPath; const metadataPath = NodePath.join(runtimeDir, "metadata.json"); @@ -298,7 +313,8 @@ function buildMacLauncher(electronBinaryPath) { const currentMetadata = readJson(metadataPath); if ( - NodeFS.existsSync(targetBinaryPath) && + NodeFS.existsSync(launcherBinaryPath) && + (!isDevelopment || NodeFS.existsSync(runtimeElectronBinaryPath)) && currentMetadata && JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) ) { @@ -306,10 +322,10 @@ function buildMacLauncher(electronBinaryPath) { // The launcher also handles protocol activations outside the dev runner, // so refresh its fallback environment on every launch. Never let a value // captured by an older parent app override the live dev-runner environment. - writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath); + writeDevelopmentLauncherScript(launcherBinaryPath, runtimeElectronBinaryPath); } registerMacLauncherBundle(targetAppBundlePath); - return targetBinaryPath; + return launcherBinaryPath; } NodeFS.rmSync(targetAppBundlePath, { recursive: true, force: true }); @@ -321,15 +337,24 @@ function buildMacLauncher(electronBinaryPath) { recursive: true, verbatimSymlinks: true, }); - patchMainBundleInfoPlist(targetAppBundlePath, iconPath); + patchMainBundleInfoPlist( + targetAppBundlePath, + iconPath, + isDevelopment ? developmentPaths.launcherExecutableName : "Electron", + ); patchHelperBundleInfoPlists(targetAppBundlePath); if (isDevelopment) { - writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath); + // Keep Electron's native executable inside the branded bundle. Launching the + // node_modules copy makes macOS associate the process (and Dock label) with + // Electron.app even though this bundle's Info.plist has the T3 Code name. + // Its conventional executable name also keeps Electron's default-app runtime + // in development mode instead of making app.isPackaged report true. + writeDevelopmentLauncherScript(launcherBinaryPath, runtimeElectronBinaryPath); } NodeFS.writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); registerMacLauncherBundle(targetAppBundlePath); - return targetBinaryPath; + return launcherBinaryPath; } function isLinuxSetuidSandboxConfigured(electronBinaryPath) { diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index 36e9429e444..1c82167ea21 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -1,6 +1,10 @@ import { assert, describe, it } from "vite-plus/test"; -import { makeDevelopmentLauncherScript, resolveElectronBinaryPath } from "./electron-launcher.mjs"; +import { + makeDevelopmentLauncherScript, + resolveElectronBinaryPath, + resolveMacLauncherPaths, +} from "./electron-launcher.mjs"; describe("electron development launcher", () => { it("uses captured values only as fallbacks for a live runner environment", () => { @@ -45,4 +49,33 @@ describe("electron development launcher", () => { ); assert.deepEqual(calls, ["ensure", "require:electron"]); }); + + it("keeps the native Electron executable name inside the branded macOS bundle", () => { + const paths = resolveMacLauncherPaths( + "/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app", + "T3 Code (Dev)", + ); + + assert.equal(paths.launcherExecutableName, "T3 Code (Dev) Launcher"); + assert.equal( + paths.launcherBinaryPath, + "/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app/Contents/MacOS/T3 Code (Dev) Launcher", + ); + assert.equal( + paths.runtimeElectronBinaryPath, + "/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app/Contents/MacOS/Electron", + ); + + const script = makeDevelopmentLauncherScript({ + electronBinaryPath: paths.runtimeElectronBinaryPath, + mainEntryPath: "/repo/apps/desktop/dist-electron/main.cjs", + desktopRoot: "/repo/apps/desktop", + environment: {}, + }); + assert.include( + script, + "exec '/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app/Contents/MacOS/Electron'", + ); + assert.notInclude(script, "node_modules/electron"); + }); }); diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts index 8fcc34f41c2..c2acc9ce120 100644 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -10,6 +10,7 @@ const { autoUpdaterMock } = vi.hoisted(() => ({ autoInstallOnAppQuit: true, channel: "latest", disableDifferentialDownload: false, + fullChangelog: false, checkForUpdates: vi.fn(() => Promise.resolve(null)), downloadUpdate: vi.fn(() => Promise.resolve([])), on: vi.fn(), @@ -33,6 +34,7 @@ describe("ElectronUpdater", () => { autoUpdaterMock.autoInstallOnAppQuit = true; autoUpdaterMock.channel = "latest"; autoUpdaterMock.disableDifferentialDownload = false; + autoUpdaterMock.fullChangelog = false; autoUpdaterMock.checkForUpdates.mockClear(); autoUpdaterMock.checkForUpdates.mockImplementation(() => Promise.resolve(null)); autoUpdaterMock.downloadUpdate.mockClear(); @@ -98,6 +100,18 @@ describe("ElectronUpdater", () => { }).pipe(Effect.provide(ElectronUpdater.layer)), ); + it.effect("sets full changelog mode", () => + Effect.gen(function* () { + const updater = yield* ElectronUpdater.ElectronUpdater; + + yield* updater.setFullChangelog(true); + assert.equal(autoUpdaterMock.fullChangelog, true); + + yield* updater.setFullChangelog(false); + assert.equal(autoUpdaterMock.fullChangelog, false); + }).pipe(Effect.provide(ElectronUpdater.layer)), + ); + it.effect("preserves quit-and-install flags and the execution-time channel", () => Effect.gen(function* () { const cause = new Error("quit and install failed"); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 435fbd00228..4157d29a9df 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -66,6 +66,7 @@ export class ElectronUpdater extends Context.Service< readonly setAllowPrerelease: (value: boolean) => Effect.Effect; readonly allowDowngrade: Effect.Effect; readonly setAllowDowngrade: (value: boolean) => Effect.Effect; + readonly setFullChangelog: (value: boolean) => Effect.Effect; readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect; readonly checkForUpdates: Effect.Effect; readonly downloadUpdate: Effect.Effect; @@ -112,6 +113,11 @@ export const make = ElectronUpdater.of({ autoUpdater.allowDowngrade = value; return Effect.void; }), + setFullChangelog: (value) => + Effect.suspend(() => { + autoUpdater.fullChangelog = value; + return Effect.void; + }), setDisableDifferentialDownload: (value) => Effect.suspend(() => { autoUpdater.disableDifferentialDownload = value; diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index ec4284f5d1a..e478d0c6eff 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -35,6 +35,7 @@ import { getAppBranding, getLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, + getWindowFullscreenState, openExternal, pickFolder, setTheme, @@ -48,6 +49,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* PreviewIpc.installPreviewEventForwarding(); yield* ipc.handleSync(getAppBranding); + yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handle(getLocalEnvironmentBearerToken); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index d28c507fa7f..0d5c79cf55a 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -4,6 +4,8 @@ export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; +export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; +export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index a67b3246fb4..13e6e8d3956 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -1,10 +1,14 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import type * as Electron from "electron"; + import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; -import { getLocalEnvironmentBootstraps } from "./window.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import { getLocalEnvironmentBootstraps, getWindowFullscreenState } from "./window.ts"; const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { executablePath: "wsl.exe", @@ -126,3 +130,19 @@ describe("getLocalEnvironmentBootstraps", () => { }).pipe(Effect.provide(DesktopBackendPool.layerTest([stoppedInstance]))); }); }); + +describe("getWindowFullscreenState", () => { + it.effect("reads the current native window state", () => { + const window = { isFullScreen: () => true } as Electron.BrowserWindow; + + return Effect.gen(function* () { + assert.isTrue(yield* getWindowFullscreenState.handler()); + }).pipe( + Effect.provide( + Layer.mock(ElectronWindow.ElectronWindow)({ + currentMainOrFirst: Effect.succeed(Option.some(window)), + }), + ), + ); + }); +}); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 94357c7a161..a4e98aaabad 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -55,6 +55,16 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({ }), }); +export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.getWindowFullscreenState")(function* () { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const window = yield* electronWindow.currentMainOrFirst; + return Option.isSome(window) && window.value.isFullScreen(); + }), +}); + export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, result: Schema.Array(DesktopEnvironmentBootstrapSchema), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 4b5c0d2f656..9f92cfcf2fa 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -116,6 +116,19 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); }; }, + getWindowFullscreenState: () => + ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true, + onWindowFullscreenStateChange: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, fullscreen: unknown) => { + if (typeof fullscreen !== "boolean") return; + listener(fullscreen); + }; + + ipcRenderer.on(IpcChannels.WINDOW_FULLSCREEN_STATE_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.WINDOW_FULLSCREEN_STATE_CHANNEL, wrappedListener); + }; + }, getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL), setUpdateChannel: (channel) => ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel), diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 696bd755506..5ae92bbee96 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -38,6 +38,7 @@ const flushCallbacks = Effect.yieldNow; function makeHarness(options: UpdatesHarnessOptions = {}) { let checkCount = 0; let allowDowngrade = false; + let fullChangelog = false; const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; const listeners = new Map void>>(); const sentStates: DesktopUpdateState[] = []; @@ -73,6 +74,10 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { Effect.sync(() => { allowDowngrade = value; }), + setFullChangelog: (value) => + Effect.sync(() => { + fullChangelog = value; + }), setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void, checkForUpdates: Effect.sync(() => { checkCount += 1; @@ -186,6 +191,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { layer, checkCount: () => checkCount, feedUrls: () => feedUrls, + fullChangelog: () => fullChangelog, listenerCount: () => Array.from(listeners.values()).reduce( (total, eventListeners) => total + eventListeners.size, @@ -287,6 +293,49 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("enables nightly full changelog release notes and broadcasts summaries", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + yield* updates.setChannel("nightly"); + assert.equal(harness.fullChangelog(), true); + + harness.emit("update-available", { + version: "1.2.4-nightly.20260709.766", + releaseNotes: [ + { + version: "1.2.4-nightly.20260709.766", + note: `

What's Changed

Full Changelog

`, + }, + { + version: "1.2.4-nightly.20260709.765", + note: "- [codex] Upgrade Clerk stack by @juliusmarminge in #3821", + }, + ], + }); + yield* flushCallbacks; + + const state = yield* updates.getState; + assert.equal(state.status, "available"); + assert.deepEqual(state.releaseNotes, [ + { + version: "1.2.4-nightly.20260709.766", + items: ["feat(client): persist offline environment data by @juliusmarminge in #3795"], + }, + { + version: "1.2.4-nightly.20260709.765", + items: ["[codex] Upgrade Clerk stack by @juliusmarminge in #3821"], + }, + ]); + assert.deepEqual(harness.sentStates.at(-1)?.releaseNotes, state.releaseNotes); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("keeps raw updater event failures out of update state", () => { const harness = makeHarness(); const cause = new Error( diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index aabb0830b0f..7357907e178 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -27,6 +27,7 @@ import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; import { createInitialDesktopUpdateState, @@ -49,6 +50,10 @@ type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; const UpdateInfo = Schema.Struct({ version: Schema.String, + // Left unvalidated on purpose: a malformed release-notes payload must never + // fail the decode and block the update state transition. The shape is + // validated defensively in normalizeDesktopUpdateReleaseNotes. + releaseNotes: Schema.optional(Schema.Unknown), }); const DownloadProgressInfo = Schema.Struct({ @@ -330,10 +335,12 @@ export const make = Effect.gen(function* () { yield* electronUpdater.setChannel(channel); yield* electronUpdater.setAllowPrerelease(allowsPrerelease); yield* electronUpdater.setAllowDowngrade(allowsPrerelease); + yield* electronUpdater.setFullChangelog(allowsPrerelease); yield* logUpdaterInfo("using update channel", { channel, allowPrerelease: allowsPrerelease, allowDowngrade: allowsPrerelease, + fullChangelog: allowsPrerelease, }); }); @@ -567,11 +574,15 @@ export const make = Effect.gen(function* () { } const checkedAt = yield* currentIsoTimestamp; + const releaseNotes = normalizeDesktopUpdateReleaseNotes(info.releaseNotes, info.version); yield* setState( - reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt), + reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt, releaseNotes), ); yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); - yield* logUpdaterInfo("update available", { version: info.version }); + yield* logUpdaterInfo("update available", { + version: info.version, + releaseNoteGroups: releaseNotes.length, + }); }), ), Effect.catchCause((cause) => { diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts new file mode 100644 index 00000000000..9d6bbaea6bc --- /dev/null +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; + +describe("normalizeDesktopUpdateReleaseNotes", () => { + it("splits a plain string note into items under the fallback version", () => { + const notes = normalizeDesktopUpdateReleaseNotes( + "## What's changed\n- First fix\n- Second fix", + "1.2.3", + ); + expect(notes).toEqual([{ version: "1.2.3", items: ["First fix", "Second fix"] }]); + }); + + it("keeps per-version groups and drops empty ones", () => { + const notes = normalizeDesktopUpdateReleaseNotes( + [ + { version: "1.2.3", note: "- Newer change" }, + { version: "1.2.2", note: "Full changelog: https://example.com/compare/x...y" }, + { version: "1.2.1", note: "- Older change" }, + ], + "1.2.3", + ); + expect(notes).toEqual([ + { version: "1.2.3", items: ["Newer change"] }, + { version: "1.2.1", items: ["Older change"] }, + ]); + }); + + it("decodes valid HTML entities", () => { + const notes = normalizeDesktopUpdateReleaseNotes("- Fix & polish 😀", "1.0.0"); + expect(notes).toEqual([{ version: "1.0.0", items: ["Fix & polish 😀"] }]); + }); + + it("ignores malformed entries instead of throwing", () => { + const notes = normalizeDesktopUpdateReleaseNotes( + [ + { version: "1.2.3", note: "- Valid change" }, + { version: 42, note: "- Bad version type" }, + { version: "1.2.1", note: { html: "

object note

" } }, + "not an object", + null, + ], + "1.2.3", + ); + expect(notes).toEqual([{ version: "1.2.3", items: ["Valid change"] }]); + }); + + it("returns non-empty groups even when preceded by many boilerplate-only groups", () => { + const boilerplate = Array.from({ length: 7 }, (_, index) => ({ + version: `1.3.${9 - index}`, + note: "Full changelog: https://example.com/compare/x...y", + })); + const notes = normalizeDesktopUpdateReleaseNotes( + [...boilerplate, { version: "1.3.2", note: "- Older but real change" }], + "1.3.9", + ); + expect(notes).toEqual([{ version: "1.3.2", items: ["Older but real change"] }]); + }); + + it("does not throw on out-of-range numeric entities and keeps the literal", () => { + expect(() => + normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"), + ).not.toThrow(); + const notes = normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"); + expect(notes).toEqual([{ version: "1.0.0", items: ["Broken entity �"] }]); + }); +}); diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts new file mode 100644 index 00000000000..69857c92b3f --- /dev/null +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -0,0 +1,125 @@ +import type { DesktopUpdateReleaseNote } from "@t3tools/contracts"; + +interface ElectronReleaseNoteInfo { + readonly version: string; + readonly note: string | null | undefined; +} + +function isElectronReleaseNoteInfo(value: unknown): value is ElectronReleaseNoteInfo { + if (typeof value !== "object" || value === null) return false; + const candidate = value as { readonly version?: unknown; readonly note?: unknown }; + return ( + typeof candidate.version === "string" && + (typeof candidate.note === "string" || candidate.note === null || candidate.note === undefined) + ); +} + +const MAX_RELEASE_NOTE_GROUPS = 6; +const MAX_RELEASE_NOTE_ITEMS_PER_GROUP = 8; +const MAX_RELEASE_NOTE_ITEM_LENGTH = 220; + +const HTML_ENTITY_REPLACEMENTS: Readonly> = { + amp: "&", + apos: "'", + gt: ">", + lt: "<", + nbsp: " ", + quot: '"', +}; + +function decodeCodePoint(codePoint: number, entity: string): string { + // String.fromCodePoint throws RangeError outside the valid Unicode range, and + // Number.isFinite alone lets oversized values (e.g. �) through. + if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { + return `&${entity};`; + } + return String.fromCodePoint(codePoint); +} + +function decodeHtmlEntity(entity: string): string { + const named = HTML_ENTITY_REPLACEMENTS[entity]; + if (named) return named; + if (entity.startsWith("#x")) { + return decodeCodePoint(Number.parseInt(entity.slice(2), 16), entity); + } + if (entity.startsWith("#")) { + return decodeCodePoint(Number.parseInt(entity.slice(1), 10), entity); + } + return `&${entity};`; +} + +function decodeHtmlEntities(input: string): string { + return input.replace(/&([a-zA-Z]+|#\d+|#x[0-9a-fA-F]+);/g, (_, entity: string) => + decodeHtmlEntity(entity), + ); +} + +function stripMarkup(input: string): string { + return decodeHtmlEntities( + input + .replace(//gi, "\n") + .replace(/]*>/gi, "\n- ") + .replace(/<\/(?:p|div|li|h[1-6]|ul|ol|blockquote)>/gi, "\n") + .replace(/<[^>]*>/g, "") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1"), + ); +} + +function truncateReleaseNoteItem(item: string): string { + if (item.length <= MAX_RELEASE_NOTE_ITEM_LENGTH) return item; + return `${item.slice(0, MAX_RELEASE_NOTE_ITEM_LENGTH - 3).trimEnd()}...`; +} + +function isIgnoredReleaseNoteLine(line: string): boolean { + const normalized = line + .toLowerCase() + .replace(/[*_`#]/g, "") + .trim(); + return ( + normalized === "" || + normalized === "what's changed" || + normalized === "whats changed" || + normalized === "full changelog" || + normalized === "new contributors" || + normalized.startsWith("compare: ") || + normalized.includes("/compare/") + ); +} + +function extractReleaseNoteItems(note: string | null | undefined): ReadonlyArray { + if (!note) return []; + + const items: string[] = []; + for (const rawLine of stripMarkup(note).split("\n")) { + const item = rawLine + .trim() + .replace(/^[-*]\s+/, "") + .replace(/^\d+[.)]\s+/, "") + .replace(/\s+/g, " "); + if (isIgnoredReleaseNoteLine(item)) continue; + items.push(truncateReleaseNoteItem(item)); + if (items.length >= MAX_RELEASE_NOTE_ITEMS_PER_GROUP) break; + } + return items; +} + +export function normalizeDesktopUpdateReleaseNotes( + releaseNotes: unknown, + fallbackVersion: string, +): ReadonlyArray { + const rawNotes = + typeof releaseNotes === "string" + ? [{ version: fallbackVersion, note: releaseNotes }] + : Array.isArray(releaseNotes) + ? releaseNotes.filter(isElectronReleaseNoteInfo) + : []; + + return rawNotes + .map((entry) => ({ + version: entry.version, + items: extractReleaseNoteItems(entry.note), + })) + .filter((entry) => entry.items.length > 0) + .slice(0, MAX_RELEASE_NOTE_GROUPS); +} diff --git a/apps/desktop/src/updates/updateMachine.test.ts b/apps/desktop/src/updates/updateMachine.test.ts index 4b2a87c7ce0..040411f76f4 100644 --- a/apps/desktop/src/updates/updateMachine.test.ts +++ b/apps/desktop/src/updates/updateMachine.test.ts @@ -118,6 +118,12 @@ describe("updateMachine", () => { }); it("tracks available, download start, and progress cleanly", () => { + const releaseNotes = [ + { + version: "1.1.0", + items: ["feat: add update release notes"], + }, + ]; const available = reduceDesktopUpdateStateOnUpdateAvailable( { ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), @@ -126,15 +132,33 @@ describe("updateMachine", () => { }, "1.1.0", "2026-03-04T00:00:00.000Z", + releaseNotes, ); const downloading = reduceDesktopUpdateStateOnDownloadStart(available); const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5); expect(available.status).toBe("available"); expect(available.channel).toBe("latest"); + expect(available.releaseNotes).toBe(releaseNotes); + expect(downloading.releaseNotes).toBe(releaseNotes); expect(downloading.status).toBe("downloading"); expect(downloading.downloadPercent).toBe(0); expect(progress.downloadPercent).toBe(55.5); expect(progress.errorContext).toBeNull(); }); + + it("clears release notes when checking again", () => { + const state = reduceDesktopUpdateStateOnCheckStart( + { + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "nightly"), + enabled: true, + status: "available", + availableVersion: "1.1.0-nightly.1", + releaseNotes: [{ version: "1.1.0-nightly.1", items: ["feat: old note"] }], + }, + "2026-03-04T00:00:00.000Z", + ); + + expect(state.releaseNotes).toEqual([]); + }); }); diff --git a/apps/desktop/src/updates/updateMachine.ts b/apps/desktop/src/updates/updateMachine.ts index b5037225774..fef51bbb8ab 100644 --- a/apps/desktop/src/updates/updateMachine.ts +++ b/apps/desktop/src/updates/updateMachine.ts @@ -1,6 +1,7 @@ import type { DesktopRuntimeInfo, DesktopUpdateChannel, + DesktopUpdateReleaseNote, DesktopUpdateState, } from "@t3tools/contracts"; @@ -29,6 +30,7 @@ export function createInitialDesktopUpdateState( runningUnderArm64Translation: runtimeInfo.runningUnderArm64Translation, availableVersion: null, downloadedVersion: null, + releaseNotes: [], downloadPercent: null, checkedAt: null, message: null, @@ -45,6 +47,7 @@ export function reduceDesktopUpdateStateOnCheckStart( ...state, status: "checking", checkedAt, + releaseNotes: [], message: null, downloadPercent: null, errorContext: null, @@ -72,12 +75,14 @@ export function reduceDesktopUpdateStateOnUpdateAvailable( state: DesktopUpdateState, version: string, checkedAt: string, + releaseNotes: ReadonlyArray = [], ): DesktopUpdateState { return { ...state, status: "available", availableVersion: version, downloadedVersion: null, + releaseNotes, downloadPercent: null, checkedAt, message: null, @@ -95,6 +100,7 @@ export function reduceDesktopUpdateStateOnNoUpdate( status: "up-to-date", availableVersion: null, downloadedVersion: null, + releaseNotes: [], downloadPercent: null, checkedAt, message: null, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 280f2109fec..492ef1ad577 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -28,7 +28,7 @@ import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; +import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; import * as PreviewManager from "../preview/Manager.ts"; @@ -46,6 +46,7 @@ const environmentInput = { } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; function makeFakeBrowserWindow() { + const windowListeners = new Map void>(); const webContentsListeners = new Map void>(); const webContents = { copyImageAt: vi.fn(), @@ -66,10 +67,13 @@ function makeFakeBrowserWindow() { close: vi.fn(), focus: vi.fn(), isDestroyed: vi.fn(() => false), + isFullScreen: vi.fn(() => false), isMinimized: vi.fn(() => false), isVisible: vi.fn(() => true), loadURL: vi.fn(() => Promise.resolve()), - on: vi.fn(), + on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { + windowListeners.set(eventName, listener); + }), once: vi.fn(), restore: vi.fn(), setBackgroundColor: vi.fn(), @@ -88,6 +92,7 @@ function makeFakeBrowserWindow() { send: webContents.send, setAutoHideCursor: window.setAutoHideCursor, webContentsListeners, + windowListeners, }; } @@ -338,6 +343,37 @@ describe("DesktopWindow", () => { }), ); + it.effect("publishes native macOS fullscreen changes to the renderer", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const enterFullscreen = fakeWindow.windowListeners.get("enter-full-screen"); + const leaveFullscreen = fakeWindow.windowListeners.get("leave-full-screen"); + if (!enterFullscreen || !leaveFullscreen) { + return yield* Effect.die("fullscreen listeners were not registered"); + } + + enterFullscreen(); + leaveFullscreen(); + assert.deepEqual(fakeWindow.send.mock.calls, [ + [WINDOW_FULLSCREEN_STATE_CHANNEL, true], + [WINDOW_FULLSCREEN_STATE_CHANNEL, false], + ]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("recovers when the development renderer is temporarily unreachable", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index b1dfabe7ab4..c808c77e51c 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -15,7 +15,7 @@ import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; +import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; import * as PreviewManager from "../preview/Manager.ts"; const TITLEBAR_HEIGHT = 40; @@ -365,6 +365,15 @@ export const make = Effect.gen(function* () { window.setTitle(environment.displayName); }); + if (environment.platform === "darwin") { + window.on("enter-full-screen", () => { + window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, true); + }); + window.on("leave-full-screen", () => { + window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, false); + }); + } + let developmentLoadRetryIndex = 0; let developmentLoadRetryFiber: Fiber.Fiber | undefined; const clearDevelopmentLoadRetry = () => { diff --git a/apps/marketing/public/harnesses/cursor_light.svg b/apps/marketing/public/harnesses/cursor_light.svg index e61e0be3bfd..089d4676370 100644 --- a/apps/marketing/public/harnesses/cursor_light.svg +++ b/apps/marketing/public/harnesses/cursor_light.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/apps/marketing/src/components/LegalPage.astro b/apps/marketing/src/components/LegalPage.astro new file mode 100644 index 00000000000..eb824039e98 --- /dev/null +++ b/apps/marketing/src/components/LegalPage.astro @@ -0,0 +1,414 @@ +--- +import Layout from "../layouts/Layout.astro"; + +interface Props { + readonly title: string; + readonly description: string; + readonly heading: string; + readonly lede: string; + readonly effectiveDate: string; + readonly lastUpdated: string; + readonly sections: ReadonlyArray; +} + +const { title, description, heading, lede, effectiveDate, lastUpdated, sections } = Astro.props; +--- + + + + + + + + diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index 5d9fc4e8f3b..9c454c4b78b 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -4,11 +4,13 @@ import { GITHUB_REPOSITORY_URL, MARKETING_STATS } from "../lib/site"; interface Props { title?: string; description?: string; + pageClass?: string; } const { title = "T3 Code", description = "T3 Code — The open-source control plane for coding agents.", + pageClass, } = Astro.props; --- @@ -30,7 +32,7 @@ const { {title} -
+ @@ -279,7 +284,7 @@ const { min-height: 100vh; display: flex; flex-direction: column; - overflow-x: hidden; + overflow-x: clip; } .nav { @@ -402,6 +407,8 @@ const { .footer-links { display: flex; + flex-wrap: wrap; + justify-content: flex-end; gap: 20px; } @@ -421,6 +428,28 @@ const { padding-left: 20px; padding-right: 20px; } + + .legal-document-page .nav { + display: none; + } + + .footer { + padding-bottom: max(32px, env(safe-area-inset-bottom)); + } + + .footer-inner { + align-items: flex-start; + flex-direction: column; + gap: 28px; + } + + .footer-links { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + width: 100%; + gap: 14px 20px; + justify-content: initial; + } } @media (max-width: 420px) { diff --git a/apps/marketing/src/lib/site.ts b/apps/marketing/src/lib/site.ts index 5ff5958c588..92e491a077a 100644 --- a/apps/marketing/src/lib/site.ts +++ b/apps/marketing/src/lib/site.ts @@ -1,6 +1,6 @@ export const GITHUB_REPOSITORY_URL = "https://github.com/pingdotgg/t3code"; export const MARKETING_STATS = { - githubStars: "12k+", + githubStars: "14k+", users: "100,000", } as const; diff --git a/apps/marketing/src/pages/legal.astro b/apps/marketing/src/pages/legal.astro new file mode 100644 index 00000000000..d75413a8da7 --- /dev/null +++ b/apps/marketing/src/pages/legal.astro @@ -0,0 +1,172 @@ +--- +import Layout from "../layouts/Layout.astro"; + +const documents = [ + { + number: "01", + title: "Privacy Policy", + description: "How T3 Code handles information, connected services, and your privacy choices.", + href: "/privacy-policy", + }, + { + number: "02", + title: "Terms of Service", + description: "The terms that govern your use of T3 Code and T3 Tools-operated services.", + href: "/terms-of-service", + }, + { + number: "03", + title: "Security Policy", + description: "Our security practices, responsible disclosure process, and shared responsibilities.", + href: "/security-policy", + }, +] as const; +--- + + + + + + diff --git a/apps/marketing/src/pages/privacy-policy.astro b/apps/marketing/src/pages/privacy-policy.astro new file mode 100644 index 00000000000..b85bc0b12b4 --- /dev/null +++ b/apps/marketing/src/pages/privacy-policy.astro @@ -0,0 +1,445 @@ +--- +import LegalPage from "../components/LegalPage.astro"; + +const sections = [ + ["introduction", "Introduction"], + ["information", "Personal information we collect"], + ["uses", "How we use your personal information"], + ["sharing", "How we share your personal information"], + ["choices", "Your choices regarding your personal information"], + ["security", "Security of your personal information"], + ["international", "International users"], + ["children", "Children"], + ["do-not-track", "Do Not Track"], + ["updates", "Updates to this Privacy Policy"], + ["contact", "Contact us"], +] as const; +--- + + +
+

01

+

Introduction

+

+ This Privacy Policy (the “Policy”) describes how T3 Tools, Inc. (the “Company,” “T3 + Tools,” “we,” or “us”) collects, uses, and shares personal information when you use the T3 + Code desktop and mobile applications, the hosted T3 Code web application located at + {" "}https://app.t3.codes, or the optional T3 Connect + service (together, the “Services”), or visit the T3 Code marketing website located at + {" "}https://t3.codes (the “Site”). This Policy applies to + personal information we collect through the Site and the Services, as well as personal + information you provide to us directly. +

+

+ By using the Site or the Services, you acknowledge the practices described in this + Policy. If you do not agree with this Policy, please do not use the Site or the Services. + This Policy does not govern third-party coding agents, artificial-intelligence providers, + identity providers, or environments that you choose to connect to T3 Code; those third + parties process information under their own terms and privacy policies. +

+
+ +
+

02

+

Personal Information We Collect

+ +

+ We collect personal information about you in different ways depending on how you use + T3 Code and which optional features you enable. +

+ +

Personal Information From Users of T3 Code

+ +

+ When you use T3 Code, we may collect the following categories of personal information: +

+
    +
  • + Account information. If you create or sign in to a T3 account, we + receive general identifiers and profile information from our authentication provider + and the identity provider you select. This may include your name, email address, + profile image, user ID, sign-in method, and authentication credentials or tokens. +
  • +
  • + Environment and device information. If you enable T3 Connect, we + process information needed to connect and secure your environments and devices, + including identifiers, labels you assign, public keys, connection endpoints, + credential hashes, connection status, app and operating-system versions, and + notification preferences. +
  • +
  • + Agent activity and notifications. If you enable notifications or Live + Activities, we process push tokens and limited activity information needed to provide + those features, such as project and thread titles, model name, status, and activity + headline. +
  • +
  • + Information you provide to us. We collect information that you choose + to provide when you request support, send feedback, or otherwise communicate with us. +
  • +
+ +

Personal Information From Users of Our Site Generally

+

+ When you visit the Site or use online portions of the Services, we and our service + providers may automatically log online identifiers and internet-activity information, + including your Internet Protocol (IP) address, device and operating-system type, browser + or app type and version, pages viewed, access times, request timestamps, connection + events, latency, error details, and diagnostic traces. The amount of information we + collect depends on the Services you use and your device and browser settings. +

+

+ We use operational and diagnostic information to provide, secure, maintain, and + troubleshoot the Services, not for targeted advertising. +

+ +

Information We Get From Others

+

+ We may receive personal information from service providers that help us operate the + Services, such as authentication and identity providers, app stores, device-platform + providers, and coding-agent providers you choose to use. We may combine this information + with information we collect through the Site and the Services. +

+ +

Cookies and Local Storage

+

+ The Site and online portions of the Services may use cookies or similar technologies to + operate essential features, maintain sessions, remember preferences, and understand + whether the Services are functioning correctly. T3 Code also stores connection settings, + credentials, preferences, drafts, and cached thread, file, branch, and server metadata on + your device. Credentials and session tokens use platform-provided protected storage where + supported. Information stored only on your device is not collected by T3 Tools unless it + is transmitted as part of a feature you use. +

+ +

Device Permissions

+
    +
  • + Camera. Used when you choose to scan a pairing QR code. Camera frames + are processed on your device. +
  • +
  • + Photos and files. Items you select are accessed only to preview them or + attach them to a request you initiate. +
  • +
  • + Notifications. Used only if you enable agent-status notifications or + Live Activities. +
  • +
  • + Local network. Used to discover or connect to T3 Code environments + accessible from your machine over the network. +
  • +
+
+ +
+

03

+

How We Use Your Personal Information

+

+ Subject to this Policy and applicable terms governing third-party applications and + services, we may use personal information for the following purposes: +

+
    +
  • To establish and verify your identity and authenticate your account;
  • +
  • To link and protect devices and environments and deliver optional notifications;
  • +
  • To process and fulfill requests you make through the Services;
  • +
  • To provide customer service and respond to support requests and feedback;
  • +
  • To communicate with you about the Services and your relationship with us;
  • +
  • To monitor reliability, prevent abuse, and investigate security incidents;
  • +
  • To comply with applicable law and enforce the terms governing the Services; and
  • +
  • + To analyze and improve T3 Code using aggregated, de-identified, or other anonymous + operational information. +
  • +
+ +

Training of AI Models

+

+ We do not use your personal information, source code, prompts, or agent responses to + train artificial-intelligence models. +

+ +

Compliance and Protection

+

We may use personal information to:

+
    +
  • Comply with applicable laws, lawful requests, and legal process;
  • +
  • Protect the rights, privacy, safety, and property of T3 Tools, our users, and others;
  • +
  • Audit our internal processes for legal, contractual, and policy compliance;
  • +
  • Enforce the terms and conditions that govern the Site and the Services; and
  • +
  • + Prevent, identify, investigate, and deter fraudulent, harmful, unauthorized, + unethical, or illegal activity, including cyberattacks and identity theft. +
  • +
+

+ We may also use personal information for other purposes consistent with this Policy or + explained to you when we collect the information. +

+
+ +
+

04

+

How We Share Your Personal Information

+

+ We may disclose the categories of personal information described above to the following + categories of third parties: +

+ +

Providers You Choose

+

+ At your direction, prompts, files, and related content are sent to coding agents and + artificial-intelligence providers configured in your environment. Because you bring and + configure your own coding-agent harness and provider, the applicable third parties vary. + Those providers process information under their own terms and privacy policies. +

+ +

Third-Party Service Providers

+

+ We use third-party service providers to help provide the Services and operate our + business. Depending on the features you use, these providers may include: +

+
    +
  • + Clerk (Clerk, Inc.).
    + We use Clerk to provide account authentication.
    + You can view Clerk's privacy policy here:{" "}https://clerk.com/legal/privacy. +
  • +
  • + Cloudflare (Cloudflare, Inc.).
    + We use Cloudflare for networking, T3 Connect infrastructure, and content delivery.
    + You can view Cloudflare's privacy policy here:{" "}https://www.cloudflare.com/policies/privacy/. +
  • +
  • + PlanetScale (PlanetScale, Inc.).
    + We use PlanetScale to host the database that supports T3 Connect.
    + You can view PlanetScale's privacy policy here:{" "}https://planetscale.com/legal/privacy. +
  • +
  • + Vercel (Vercel Inc.).
    + We use Vercel to host and deploy the T3 Code Site.
    + You can view Vercel's privacy notice here:{" "}https://vercel.com/legal/privacy-notice. +
  • +
  • + Axiom (Axiom, Inc.).
    + We use Axiom for operational diagnostics that help us maintain and troubleshoot the + Services.
    + You can view Axiom's privacy policy here:{" "}https://axiom.co/docs/legal/privacy. +
  • +
  • + Expo (650 Industries, Inc.).
    + We use Expo for application updates and related mobile-app services.
    + You can view Expo's privacy policy here:{" "}https://expo.dev/privacy. +
  • +
  • + Apple (Apple Inc.).
    + We use Apple services for app distribution, optional authentication, and platform + notifications.
    + You can view Apple's privacy policy here:{" "}https://www.apple.com/legal/privacy/. +
  • +
  • + Google (Google LLC).
    + We use Google services for app distribution and optional authentication.
    + You can view Google's privacy policy here:{" "}https://policies.google.com/privacy. +
  • +
+

+ These providers process information for us under their own contractual and privacy + obligations. +

+ +

Affiliates and Professional Advisors

+

+ We may share personal information with our affiliates for purposes consistent with this + Policy and with lawyers, accountants, bankers, and other professional advisors in the + course of the services they provide to us. +

+ +

Corporate Restructuring

+

+ We may share some or all personal information in connection with or during negotiation of + a merger, financing, acquisition, reorganization, bankruptcy, dissolution, sale of + assets, or similar transaction. If another company acquires T3 Tools or its assets, that + company may possess the personal information collected by us and assume the rights and + obligations described in this Policy. +

+ +

Legal and Safety Disclosures

+

+ We may disclose personal information if we believe in good faith that disclosure is + necessary to comply with law or legal process; protect or defend the rights, property, or + safety of T3 Tools, users of the Site or the Services, or others; investigate or prevent + fraud, abuse, or unlawful activity; or enforce this Policy and the terms governing the + Services. We may also share personal information at your direction, with your consent, or + as described to you when the information is collected. +

+ +

No Sale of Personal Information

+

+ We do not sell your personal information or share it for cross-context behavioral or + targeted advertising. +

+ +

Third-Party Websites and Services

+

+ The Site and the Services may contain links to third-party websites or services. When you + follow a third-party link or connect a third-party service, that third party may collect + personal information from you. We do not control and are not responsible for the privacy + practices or content of third parties. +

+
+ +
+

05

+

Your Choices Regarding Your Personal Information

+ +

Account and Connection Choices

+

+ You may use local or direct connections without signing in to a T3 account. You can + disconnect environments, sign out, disable optional notifications and Live Activities, + and clear local caches from Settings. You can grant or revoke camera, photo, local-network, + and notification permissions in your device settings. +

+ +

Cookies and Local Data

+

+ You can use your browser settings to stop accepting or to delete cookies, although some + portions of the Site or the Services may not function correctly as a result. Information + stored only on your device remains there until you clear it, remove the relevant + connection, or uninstall the app. The mobile app provides cache controls under + {" "}Settings → App → Client Storage. +

+ +

Retention and Deletion

+

+ We retain account, environment-link, device-registration, notification, and security + information while your account or the relevant feature is active and for as long as + reasonably necessary to provide the Services, protect them from abuse, comply with law, + and resolve disputes. Operational diagnostic traces are configured to expire after 30 + days. Some records may remain longer in backups or where retention is required for + security, fraud prevention, or legal compliance. +

+

+ To request deletion of your T3 account and associated T3 Connect data, email{" "}privacy@t3.tools{" "}from the email address associated with your account. We may need to verify your + identity. Deleting a T3 account does not delete information held by an environment or + coding-agent provider you control; contact those providers or delete that information + directly. +

+ +

Your Privacy Rights

+

+ Depending on where you live, you may have rights to request access to, correction of, + deletion of, or a portable copy of your personal information, or to restrict or object + to certain processing. You may also have the right to appeal our response or complain to + a data-protection authority. We will honor applicable rights after verifying your + request. +

+
+ +
+

06

+

Security of Your Personal Information

+

+ T3 Tools is committed to protecting the security of your personal information. We use + administrative, technical, and organizational safeguards designed to protect personal + information from unauthorized access, use, or disclosure. These safeguards include + encrypted network transport, scoped and proof-bound access tokens for supported + connection flows, protected credential storage, and redaction of authorization headers + from relay diagnostics. +

+

+ No method of transmission over the Internet or method of electronic storage is completely + secure. While we use reasonable efforts to protect personal information, we cannot + guarantee its absolute security. You are responsible for securing the environments, + coding-agent accounts, credentials, and networks that you connect to T3 Code. +

+
+ +
+

07

+

International Users

+

+ The Site and the Services are provided from the United States and are governed by United + States law. If you use the Site or the Services from outside the United States, your + personal information may be transferred to and processed in the United States and other + countries where we or our service providers operate. Where required, we use lawful + safeguards for international transfers of personal information. +

+
+ +
+

08

+

Children

+

+ The Site and the Services are not intended for children under 13 years of age, and you + must be at least 13 years old to use them. We do not knowingly collect, use, or disclose + personal information from children under 13. If you believe a child under 13 has provided + us with personal information, please contact us so that we can take appropriate action. +

+
+ +
+

09

+

Do Not Track

+

+ We currently do not respond to browser “Do Not Track” signals. T3 Code does not use + personal information for cross-context behavioral or targeted advertising. +

+
+ +
+

10

+

Updates to This Privacy Policy

+

+ We reserve the right to update this Policy from time to time. If we make material changes, + we will post the revised Policy at this URL, update the “Last Updated” and “Effective Date” + above, and provide any additional notice required by law. Except as otherwise indicated, + changes become effective when the revised Policy is posted. +

+
+ +
+

11

+

Contact Us

+

+ If you have questions about this Policy or wish to make a privacy or deletion request, + please contact us at: +

+
+ T3 Tools, Inc.
+ 2261 Market Street #5309
+ San Francisco, CA 94114
+ United States +
+ privacy@t3.tools +
+
diff --git a/apps/marketing/src/pages/security-policy.astro b/apps/marketing/src/pages/security-policy.astro new file mode 100644 index 00000000000..c9b05ec36fd --- /dev/null +++ b/apps/marketing/src/pages/security-policy.astro @@ -0,0 +1,187 @@ +--- +import LegalPage from "../components/LegalPage.astro"; + +const sections = [ + ["introduction", "Introduction"], + ["reporting", "Reporting security issues"], + ["safe-harbor", "Responsible research and safe harbor"], + ["practices", "Our security practices"], + ["responsibilities", "Your responsibilities"], + ["updates", "Updates to this policy"], + ["contact", "Contact us"], +] as const; +--- + + +
+

01

+

Introduction

+

+ This Security Policy describes the vulnerability-reporting process and security practices for + the T3 Code desktop and mobile applications, the hosted application at{" "}https://app.t3.codes, the optional T3 Connect service, and the T3 Code website at{" "}https://t3.codes. +

+

+ T3 Code connects software running on your devices and in environments you control with + coding-agent harnesses and providers you choose. We secure the software and infrastructure + operated by T3 Tools, but we do not control the security of your devices, environments, + repositories, networks, credentials, or third-party providers. Please review the shared + responsibilities below when assessing risk. +

+
+ +
+

02

+

Reporting Security Issues

+

+ If you believe you have found a security vulnerability affecting T3 Code or T3 Tools-operated + infrastructure, email{" "}security@ping.gg. Please do not + disclose the issue publicly until we have had a reasonable opportunity to investigate and + remediate it. +

+

Please include, when available:

+
    +
  • A description of the issue and its potential impact;
  • +
  • The affected application, version, URL, endpoint, or component;
  • +
  • Clear reproduction steps or a minimal proof of concept;
  • +
  • Relevant logs, screenshots, or request and response details with secrets removed; and
  • +
  • Your preferred contact information and whether you want public credit.
  • +
+

+ We aim to acknowledge complete reports within one business day, keep reporters informed of + material progress, and coordinate disclosure after a fix is available. Resolution time varies + with severity, complexity, and dependencies on third parties. +

+
+ +
+

03

+

Responsible Research and Safe Harbor

+

+ We support good-faith security research. If you make a genuine effort to comply with this + policy, avoid harm, respect privacy, and report findings promptly, we will treat your research + as authorized and will not initiate legal action against you for accidental, good-faith + violations of this policy. If a third party initiates legal action concerning compliant + research, we will make our authorization known where appropriate. +

+

To remain within this safe harbor, you must:

+
    +
  • Test only accounts, data, and systems you own or have explicit permission to test;
  • +
  • Stop and report immediately if you encounter personal, confidential, or production data;
  • +
  • Access only the minimum information needed to demonstrate the issue;
  • +
  • Avoid disrupting availability, degrading performance, or damaging or deleting data;
  • +
  • Not use social engineering, phishing, physical attacks, or denial-of-service testing;
  • +
  • Not test third-party services or infrastructure outside T3 Tools’ control; and
  • +
  • Give us reasonable time to address the issue before public disclosure.
  • +
+

+ This safe harbor does not authorize violations of law or activity outside the scope of this + policy. Vulnerability rewards are not guaranteed and, if offered, are determined by T3 Tools + in its discretion. +

+
+ +
+

04

+

Our Security Practices

+

+ We use administrative, technical, and organizational safeguards designed for the nature of T3 + Code and the information processed by T3 Tools-operated services. These practices include: +

+
    +
  • + Data minimization. T3 Code is designed so coding-session content can remain + between your clients, your environment, and the harnesses and providers you configure. We + process information through T3 Tools-operated infrastructure only when needed for a feature + you enable. +
  • +
  • + Encrypted transport. T3 Tools-operated network services use encrypted + transport. Supported connection flows use scoped credentials and security controls designed + to reduce unauthorized reuse. +
  • +
  • + Credential protection. T3 Code uses platform-provided protected storage for + credentials and session tokens where supported. We design operational logging to avoid + collecting secrets that are not needed to operate or troubleshoot the Services. +
  • +
  • + Access controls. Access to production systems and operational data is limited + according to job responsibilities and protected using authentication and authorization + controls. +
  • +
  • + Maintenance and monitoring. We monitor T3 Tools-operated services for + reliability and security events, review dependencies, and deploy updates and mitigations as + appropriate. +
  • +
  • + Established providers. We use specialized providers for services such as + authentication, hosting, and application distribution and evaluate the controls relevant to + their role. +
  • +
+

+ No system is completely secure. These practices reduce risk but do not guarantee that the Site + or Services will be free from vulnerabilities or unauthorized access. +

+
+ +
+

05

+

Your Responsibilities

+

+ Because you control much of the T3 Code execution path, you play an important role in securing + it. You are responsible for: +

+
    +
  • Keeping T3 Code, your operating systems, and connected tools up to date;
  • +
  • Securing your devices, repositories, environments, networks, and backups;
  • +
  • Protecting account, provider, repository, and environment credentials;
  • +
  • Using multi-factor authentication where your identity and coding-agent providers support it;
  • +
  • Granting providers and integrations only the permissions they need;
  • +
  • Reviewing commands, source changes, and other agent output before applying or executing it;
  • +
  • Removing lost or unused devices and revoking credentials you believe may be compromised; and
  • +
  • Following the security policies of your selected harnesses, providers, and infrastructure.
  • +
+

+ Do not send passwords, API keys, access tokens, or private keys in a vulnerability report. + Revoke any secret that may have been exposed before sharing sanitized evidence with us. +

+
+ +
+

06

+

Updates to This Policy

+

+ We may update this Security Policy as T3 Code, our infrastructure, and security practices + evolve. We will post revisions at this URL and update the dates above. Material changes to the + vulnerability-reporting process or safe-harbor terms will apply prospectively. +

+
+ +
+

07

+

Contact Us

+

+ Report security issues or ask security-related questions at{" "}security@ping.gg. +

+

+ For general legal questions, contact{" "}legal@t3.tools. + For privacy requests, contact{" "}privacy@t3.tools. +

+
+
diff --git a/apps/marketing/src/pages/terms-of-service.astro b/apps/marketing/src/pages/terms-of-service.astro new file mode 100644 index 00000000000..e6de5f2c751 --- /dev/null +++ b/apps/marketing/src/pages/terms-of-service.astro @@ -0,0 +1,396 @@ +--- +import LegalPage from "../components/LegalPage.astro"; + +const sections = [ + ["introduction", "Introduction"], + ["accounts", "Accounts"], + ["content", "Your content and providers"], + ["rights", "T3 Code and proprietary rights"], + ["acceptable-use", "Acceptable use"], + ["availability", "Availability and changes"], + ["fees", "Fees"], + ["privacy", "Privacy"], + ["termination", "Suspension and termination"], + ["beta", "Beta services"], + ["disclaimers", "Disclaimers"], + ["liability", "Limitation of liability"], + ["indemnification", "Indemnification"], + ["copyright", "Copyright complaints"], + ["third-parties", "Third-party services"], + ["feedback", "Feedback"], + ["disputes", "Dispute resolution"], + ["miscellaneous", "Miscellaneous"], + ["changes", "Changes to these Terms"], + ["contact", "Contact us"], +] as const; +--- + + +
+

01

+

Introduction

+

+ These Terms of Service (the “Terms”) are a binding agreement between you and T3 Tools, Inc. + (“T3 Tools,” “we,” “us,” or “our”). They govern your access to and use of the T3 Code desktop + and mobile applications, the hosted T3 Code application at{" "}https://app.t3.codes, and the optional T3 Connect service (together, the “Services”), as well as the T3 Code + marketing website at{" "}https://t3.codes (the “Site”). +

+

+ By accessing or using the Site or Services, you agree to these Terms. If you use the Services + for an organization, you represent that you have authority to bind that organization, and + “you” includes the organization. If you do not agree to these Terms, do not use the Site or + Services. +

+

+ You must be at least 13 years old to use the Site or Services. If the law where you live + requires a greater age to enter into these Terms, you must meet that requirement or have a + parent or legal guardian agree on your behalf. +

+
+ +
+

02

+

Accounts

+

+ You can use some T3 Code features without creating a T3 account. An account may be required + for account-backed features, including T3 Connect. You agree to provide accurate account + information, keep your credentials secure, and promptly notify us if you suspect unauthorized + access. You are responsible for activity performed through your account and connected devices, + except to the extent caused by our failure to use reasonable security measures. +

+

+ You may not share credentials in a way that compromises the Services, impersonate another + person, or create accounts through unauthorized automated means. We may require reasonable + verification before restoring access or acting on an account request. +

+
+ +
+

03

+

Your Content and Providers

+

+ T3 Code can help you send prompts, source files, images, attachments, review comments, and + instructions to coding-agent harnesses and providers you choose, and display their responses, + command output, file changes, source code, and diffs (collectively, “User Content”). You retain + all rights you have in your User Content. +

+

+ You bring and configure your own coding-agent harness and provider. Your User Content is + ordinarily processed in the environment you control and by the providers you select, not by + T3 Tools. When you enable a T3 Tools-operated feature that must transmit or process User + Content, you grant us a limited, non-exclusive license to host, transmit, reproduce, and + process that content only as reasonably necessary to provide, secure, and troubleshoot that + feature. This license ends when the content is no longer needed for those purposes, subject to + reasonable backup, security, and legal-retention requirements. +

+

+ You represent that you have the rights and permissions needed to use and submit User Content + and to direct its processing by your selected harnesses and providers. You are responsible for + reviewing those providers’ terms, privacy practices, data controls, and output before using + them with confidential, proprietary, regulated, or personal information. +

+
+ +
+

04

+

T3 Code and Proprietary Rights

+

+ T3 Tools and its licensors own the Site, the Services, and their branding, designs, hosted + infrastructure, documentation, and other materials, excluding User Content and third-party + materials. These Terms do not transfer ownership of either party’s intellectual property. +

+

+ Portions of T3 Code are available as open-source software. Your use, copying, modification, and + distribution of that source code are governed by the license included with the applicable + repository or component. These Terms govern your use of the hosted Services and do not limit + rights granted to you by an applicable open-source license. +

+

+ Subject to these Terms, we grant you a limited, non-exclusive, non-transferable, revocable right + to access and use the Services for lawful personal or internal business purposes. You may not + use our trademarks, service marks, or trade dress without written permission. +

+
+ +
+

05

+

Acceptable Use

+

You may not use the Site or Services to:

+
    +
  • Violate applicable law or another person’s rights;
  • +
  • Upload, transmit, or generate content you do not have the right to use;
  • +
  • Distribute malware, exploit code, or harmful content except in authorized security work;
  • +
  • Probe, attack, disrupt, or gain unauthorized access to the Services or another system;
  • +
  • Bypass rate limits, access controls, security measures, or usage restrictions;
  • +
  • Interfere with other users or place an unreasonable load on shared infrastructure;
  • +
  • Use the Services to facilitate fraud, harassment, abuse, or deceptive conduct; or
  • +
  • Misrepresent that output or activity from a third-party agent was produced or endorsed by us.
  • +
+

+ Authorized security research must follow our{" "}Security Policy. + We may investigate suspected violations and take proportionate action to protect users and the + Services. +

+
+ +
+

06

+

Availability and Changes

+

+ We may add, change, suspend, or discontinue features, integrations, or supported platforms. We + aim to provide reasonable notice when a change materially reduces an account-backed feature, + but urgent security, legal, or reliability changes may take effect immediately. The Services + may be unavailable because of maintenance, provider outages, network conditions, or events + outside our control. +

+

+ T3 Code depends on software, environments, networks, and coding-agent providers that you + configure or that third parties operate. We do not control their availability, compatibility, + pricing, or behavior. +

+
+ +
+

07

+

Fees

+

+ T3 Code is currently offered without a T3 Tools subscription fee unless we clearly state + otherwise for a feature. You remain responsible for charges from your coding-agent providers, + cloud infrastructure, network operators, app stores, and other third parties. If we introduce + a paid feature, we will present its price and applicable payment terms before you purchase it. +

+
+ +
+

08

+

Privacy

+

+ Our{" "}Privacy Policy explains how T3 Tools collects, uses, and + shares personal information when you use the Site and Services. Third-party environments, + harnesses, identity providers, and coding-agent providers process information under their own + terms and privacy policies. +

+
+ +
+

09

+

Suspension and Termination

+

+ You may stop using the Services at any time. You may disconnect environments and devices from + within T3 Code and may request deletion of an account as described in our Privacy Policy. +

+

+ We may suspend or terminate access to account-backed or hosted features if we reasonably + believe you materially violated these Terms, created a security or legal risk, failed to pay an + applicable fee, or used the Services in a way that could harm other users or shared + infrastructure. When practicable, we will give notice and an opportunity to cure. We may act + immediately where needed to prevent harm or comply with law. +

+

+ Provisions that by their nature should survive termination—including ownership, disclaimers, + limitations of liability, indemnification, and dispute terms—will survive. +

+
+ +
+

10

+

Beta Services

+

+ T3 Code is an early-stage product, and some or all features may be identified as alpha, beta, + preview, experimental, or pre-release (“Beta Services”). Beta Services may be incomplete, + change without notice, contain errors, or lose data. Do not rely on Beta Services as the sole + copy of important work, and review proposed commands and file changes before applying them. +

+
+ +
+

11

+

Disclaimers

+ +

+ Coding agents and artificial-intelligence systems can produce incorrect, insecure, incomplete, + or harmful output. You are responsible for reviewing output, commands, patches, and other + actions before relying on or executing them. We do not warrant that the Services or third-party + output will be uninterrupted, secure, accurate, error-free, or suitable for your purpose. +

+

+ Some jurisdictions do not allow certain warranty exclusions, so some of the foregoing may not + apply to you. +

+
+ +
+

12

+

Limitation of Liability

+ +

+ To the maximum extent permitted by law, our aggregate liability for all claims arising from or + relating to the Site, Services, or these Terms will not exceed the greater of $100 or the amount + you paid directly to T3 Tools for the Services during the 12 months before the event giving rise + to the claim. These limitations apply regardless of the legal theory and even if a remedy fails + of its essential purpose. They do not limit liability that cannot lawfully be limited. +

+
+ +
+

13

+

Indemnification

+

+ To the extent permitted by law, you will defend, indemnify, and hold harmless T3 Tools and its + affiliates, officers, employees, and agents from third-party claims, damages, losses, and + reasonable legal fees arising from your User Content, your use of the Site or Services in + violation of these Terms or law, or your infringement of another person’s rights. We will + promptly notify you of a covered claim and reasonably cooperate in its defense. You may not + settle a claim in a way that admits fault by or imposes obligations on us without our written + consent. +

+
+ + + +
+

15

+

Third-Party Services

+

+ The Services may connect to or display content from third-party coding agents, model providers, + identity providers, repositories, app stores, websites, and infrastructure. Your use of those + services is governed by their terms. We do not endorse or control third-party services and are + not responsible for their content, security, availability, output, or data practices. +

+

+ Third-party and open-source software included with T3 Code is governed by its applicable + license notices. Where those licenses conflict with these Terms for that software, the + applicable open-source license controls. +

+
+ +
+

16

+

Feedback

+

+ If you send us ideas, suggestions, or other feedback about T3 Code, you grant T3 Tools a + perpetual, irrevocable, worldwide, royalty-free right to use and commercialize that feedback + without restriction or compensation. This does not transfer ownership of your User Content. +

+
+ +
+

17

+

Dispute Resolution

+ +

Informal Resolution

+

+ Before starting arbitration or a court proceeding, the party raising a dispute must send a + written notice describing the dispute and requested relief. Notices to us must be sent to{" "}legal@t3.tools. The parties will try in good faith to resolve the dispute for at least 30 days. +

+

Binding Arbitration

+

+ Except for disputes eligible for small-claims court and requests for injunctive relief to + protect intellectual property or prevent unauthorized access, unresolved disputes will be + resolved by binding individual arbitration administered by JAMS under its applicable consumer + or comprehensive rules. The Federal Arbitration Act governs this agreement. Arbitration may + take place remotely unless the arbitrator determines that an in-person hearing is necessary. +

+

No Class Actions

+

+ Disputes must be brought only on an individual basis. Neither party may participate in a class, + collective, consolidated, or representative action or arbitration to the extent permitted by + law. +

+

Opt Out

+

+ You may opt out of this arbitration agreement by emailing{" "}legal@t3.tools within 30 days after you first accept these Terms. Include your name, the email associated + with your account if any, and a clear statement that you opt out of arbitration. +

+

+ If the arbitration agreement does not apply, exclusive jurisdiction and venue will lie in the + state and federal courts located in San Francisco County, California, and each party consents + to those courts. +

+
+ +
+

18

+

Miscellaneous

+

+ California law governs these Terms without regard to conflict-of-law principles, except that + the Federal Arbitration Act governs the arbitration provisions. These Terms and documents + incorporated by reference are the entire agreement between you and us regarding the Site and + Services. If a provision is unenforceable, it will be modified to the minimum extent necessary + or severed, and the remaining provisions will continue in effect. +

+

+ Our failure to enforce a provision is not a waiver. You may not assign these Terms without our + written consent. We may assign them in connection with a merger, acquisition, reorganization, + or sale of assets. We are not liable for delays or failures caused by events beyond our + reasonable control. Headings are for convenience only, and “including” means “including without + limitation.” +

+
+ +
+

19

+

Changes to These Terms

+

+ We may update these Terms from time to time. We will post updated Terms at this URL and update + the dates above. If a change materially affects your rights, we will provide additional notice + when reasonably practicable or as required by law. Changes apply prospectively from their + effective date. Your continued use of the Site or Services after that date means you accept the + updated Terms. +

+
+ +
+

20

+

Contact Us

+

If you have questions about these Terms, contact us at:

+
+ T3 Tools, Inc.
+ 2261 Market Street #5309
+ San Francisco, CA 94114
+ United States +
+ legal@t3.tools +
+
diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 552216bf353..0eb865cb79b 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -35,8 +35,8 @@ vp run ios:dev ``` If your Xcode account only has a Personal Team, use a bundle identifier you control and opt into the -reduced-capability local build. Personal Team builds omit the widget extension, push entitlement, and -native Sign in with Apple entitlement; builds without this opt-in are unchanged. +reduced-capability local build. Personal Team builds omit the widget and share extensions, push +entitlement, and native Sign in with Apple entitlement; builds without this opt-in are unchanged. ```bash T3CODE_IOS_PERSONAL_TEAM=1 \ diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index ef80e4b8212..4a0c761f2c6 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -1,5 +1,6 @@ import type { ExpoConfig } from "expo/config"; +import { BRAND_ASSET_PATHS } from "../../scripts/lib/brand-assets.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; type AppVariant = "development" | "preview" | "production"; @@ -13,6 +14,8 @@ const isIosPersonalTeamBuild = repoEnv.T3CODE_IOS_PERSONAL_TEAM === "1"; const personalTeamBundleIdentifier = repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID?.trim(); const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/; +const fromRepoRoot = (relativePath: string) => `../../${relativePath}`; + if ( isIosPersonalTeamBuild && (!personalTeamBundleIdentifier || @@ -23,46 +26,65 @@ if ( ); } -const VARIANT_CONFIG: Record< - AppVariant, - { - readonly appName: string; - readonly scheme: string; - readonly iosIcon: string; - readonly splashIcon: string; - readonly iosBundleIdentifier: string; - readonly androidPackage: string; - readonly relyingParty?: string; - } -> = { +const DEVELOPMENT_ASSETS = { + appIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng), + iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIconComposerProject), + splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng), + androidAdaptiveForeground: fromRepoRoot(BRAND_ASSET_PATHS.developmentUniversalIconPng), + androidAdaptiveBackgroundColor: "#00639B", + androidMonochromeIcon: "./assets/android-icon-mark.png", + androidNotificationIcon: "./assets/android-notification-icon.png", + androidNotificationColor: "#00639B", +} as const; + +const PREVIEW_ASSETS = { + appIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng), + iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIconComposerProject), + splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng), + androidAdaptiveForeground: fromRepoRoot(BRAND_ASSET_PATHS.nightlyLinuxIconPng), + androidAdaptiveBackgroundColor: "#111533", + androidMonochromeIcon: "./assets/android-icon-mark.png", + androidNotificationIcon: "./assets/android-notification-icon.png", + androidNotificationColor: "#7565C7", +} as const; + +const RELEASE_ASSETS = { + appIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng), + iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIconComposerProject), + splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng), + androidAdaptiveForeground: "./assets/android-icon-mark.png", + androidAdaptiveBackgroundColor: "#000000", + androidMonochromeIcon: "./assets/android-icon-mark.png", + androidNotificationIcon: "./assets/android-notification-icon.png", + androidNotificationColor: "#FFFFFF", +} as const; + +const VARIANT_CONFIG = { development: { appName: "T3 Code Dev", scheme: "t3code-dev", - iosIcon: "./assets/icon-composer-dev.icon", - splashIcon: "./assets/splash-icon-dev.png", iosBundleIdentifier: "com.t3tools.t3code.dev", androidPackage: "com.t3tools.t3code.dev", relyingParty: "clerk.t3.codes", + assets: DEVELOPMENT_ASSETS, }, preview: { appName: "T3 Code Preview", scheme: "t3code-preview", - iosIcon: "./assets/icon-composer-prod.icon", - splashIcon: "./assets/splash-icon-prod.png", iosBundleIdentifier: "com.t3tools.t3code.preview", androidPackage: "com.t3tools.t3code.preview", relyingParty: "clerk.t3.codes", + assets: PREVIEW_ASSETS, }, production: { appName: "T3 Code", scheme: "t3code", - iosIcon: "./assets/icon-composer-prod.icon", - splashIcon: "./assets/splash-icon-prod.png", iosBundleIdentifier: "com.t3tools.t3code", androidPackage: "com.t3tools.t3code", relyingParty: "clerk.t3.codes", + assets: RELEASE_ASSETS, }, -}; +} as const; function resolveAppVariant(value: string | undefined): AppVariant { switch (value) { @@ -76,6 +98,9 @@ function resolveAppVariant(value: string | undefined): AppVariant { } const variant = VARIANT_CONFIG[APP_VARIANT]; +const iosBundleIdentifier = isIosPersonalTeamBuild + ? personalTeamBundleIdentifier! + : variant.iosBundleIdentifier; const dmSansFonts = { regular: "@expo-google-fonts/dm-sans/400Regular/DMSans_400Regular.ttf", @@ -86,8 +111,8 @@ const dmSansFonts = { const widgetsPlugin: NonNullable[number] = [ "expo-widgets", { - bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`, - groupIdentifier: `group.${variant.iosBundleIdentifier}`, + bundleIdentifier: `${iosBundleIdentifier}.widgets`, + groupIdentifier: `group.${iosBundleIdentifier}`, enablePushNotifications: true, // Agent activity can update many times an hour; without the // frequent-updates entitlement iOS throttles the update budget sooner. @@ -103,6 +128,30 @@ const widgetsPlugin: NonNullable[number] = [ }, ]; +const sharingPlugin: NonNullable[number] = [ + "expo-sharing", + { + ios: { + // Personal Teams cannot sign App Groups or extension targets. Keep the + // reduced-capability local build usable while release builds expose the + // real system share target. + enabled: !isIosPersonalTeamBuild, + extensionBundleIdentifier: `${iosBundleIdentifier}.sharing`, + appGroupId: `group.${iosBundleIdentifier}`, + activationRule: { + supportsText: true, + supportsWebUrlWithMaxCount: 1, + supportsImageWithMaxCount: 8, + }, + }, + android: { + enabled: true, + singleShareMimeTypes: ["text/plain", "image/*"], + multipleShareMimeTypes: ["image/*"], + }, + }, +]; + // These aliases match the fonts' PostScript names on iOS. Register the same // names on Android so React Native and the native composer use one set of // family names without waiting for runtime font loading. @@ -121,7 +170,7 @@ const config: ExpoConfig = { policy: process.env.MOBILE_VERSION_POLICY ?? "fingerprint", }, orientation: "portrait", - icon: "./assets/icon.png", + icon: variant.assets.appIcon, userInterfaceStyle: "automatic", updates: { enabled: true, @@ -130,9 +179,9 @@ const config: ExpoConfig = { fallbackToCacheTimeout: 0, }, ios: { - icon: variant.iosIcon, + icon: variant.assets.iosIcon, supportsTablet: true, - bundleIdentifier: variant.iosBundleIdentifier, + bundleIdentifier: iosBundleIdentifier, // Pin code signing to the T3 Tools team so non-interactive `expo run:ios` // does not fall back to a personal team (which cannot sign app groups, // Sign in with Apple, or push notification entitlements). @@ -151,13 +200,12 @@ const config: ExpoConfig = { }, }, android: { - icon: "./assets/icon.png", + icon: variant.assets.appIcon, package: variant.androidPackage, adaptiveIcon: { - backgroundColor: "#E6F4FE", - foregroundImage: "./assets/android-icon-foreground.png", - backgroundImage: "./assets/android-icon-background.png", - monochromeImage: "./assets/android-icon-monochrome.png", + backgroundColor: variant.assets.androidAdaptiveBackgroundColor, + foregroundImage: variant.assets.androidAdaptiveForeground, + monochromeImage: variant.assets.androidMonochromeIcon, }, // Opts into OnBackInvokedCallback-based back dispatch (Android 13+). // JS back handling survives it via react-native's Android 16 shim plus @@ -165,7 +213,7 @@ const config: ExpoConfig = { predictiveBackGestureEnabled: true, }, web: { - favicon: "./assets/favicon.png", + favicon: variant.assets.appIcon, }, plugins: [ "expo-asset", @@ -195,6 +243,17 @@ const config: ExpoConfig = { ], "expo-secure-store", "expo-sqlite", + ...(isIosPersonalTeamBuild + ? [sharingPlugin] + : ["./plugins/withShareExtensionDisplayName.cjs", sharingPlugin]), + [ + "expo-notifications", + { + icon: variant.assets.androidNotificationIcon, + color: variant.assets.androidNotificationColor, + mode: APP_VARIANT === "development" ? "development" : "production", + }, + ], // appleSignIn must be gated here: withoutIosPersonalTeamCapabilities.cjs runs before // plugins earlier in this array, so it cannot strip the entitlement Clerk would add. ["@clerk/expo", { theme: "./clerk-theme.json", appleSignIn: !isIosPersonalTeamBuild }], @@ -206,8 +265,8 @@ const config: ExpoConfig = { // the shortcut items set in src/features/shortcuts. androidIcons: { shortcut_icon: { - foregroundImage: "./assets/android-icon-foreground.png", - backgroundColor: "#E6F4FE", + foregroundImage: variant.assets.androidAdaptiveForeground, + backgroundColor: variant.assets.androidAdaptiveBackgroundColor, }, }, }, @@ -217,17 +276,18 @@ const config: ExpoConfig = { { cameraPermission: "Allow T3 Code to access your camera so you can scan pairing QR codes.", barcodeScannerEnabled: true, + recordAudioAndroid: false, }, ], [ "expo-splash-screen", { - image: variant.splashIcon, + image: variant.assets.splashIcon, resizeMode: "contain", backgroundColor: "#ffffff", imageWidth: 220, dark: { - image: variant.splashIcon, + image: variant.assets.splashIcon, backgroundColor: "#0a0a0a", }, }, diff --git a/apps/mobile/assets/android-icon-background.png b/apps/mobile/assets/android-icon-background.png deleted file mode 100644 index b33d7978d72..00000000000 Binary files a/apps/mobile/assets/android-icon-background.png and /dev/null differ diff --git a/apps/mobile/assets/android-icon-foreground.png b/apps/mobile/assets/android-icon-foreground.png deleted file mode 100644 index b33d7978d72..00000000000 Binary files a/apps/mobile/assets/android-icon-foreground.png and /dev/null differ diff --git a/apps/mobile/assets/android-icon-mark.png b/apps/mobile/assets/android-icon-mark.png new file mode 100644 index 00000000000..3300f22fd5a Binary files /dev/null and b/apps/mobile/assets/android-icon-mark.png differ diff --git a/apps/mobile/assets/android-icon-monochrome.png b/apps/mobile/assets/android-icon-monochrome.png deleted file mode 100644 index b33d7978d72..00000000000 Binary files a/apps/mobile/assets/android-icon-monochrome.png and /dev/null differ diff --git a/apps/mobile/assets/android-notification-icon.png b/apps/mobile/assets/android-notification-icon.png new file mode 100644 index 00000000000..74de6633cbb Binary files /dev/null and b/apps/mobile/assets/android-notification-icon.png differ diff --git a/apps/mobile/assets/favicon.png b/apps/mobile/assets/favicon.png deleted file mode 100644 index e0e1b9659b8..00000000000 Binary files a/apps/mobile/assets/favicon.png and /dev/null differ diff --git a/apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg b/apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg deleted file mode 100644 index d98c41a4e3e..00000000000 Binary files a/apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg and /dev/null differ diff --git a/apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png b/apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png deleted file mode 100644 index de5a82d8c49..00000000000 Binary files a/apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png and /dev/null differ diff --git a/apps/mobile/assets/icon-composer-prod.icon/Assets/T3.svg b/apps/mobile/assets/icon-composer-prod.icon/Assets/T3.svg deleted file mode 100644 index b12706fdfc2..00000000000 --- a/apps/mobile/assets/icon-composer-prod.icon/Assets/T3.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/mobile/assets/icon.png b/apps/mobile/assets/icon.png deleted file mode 100644 index b33d6f337b0..00000000000 Binary files a/apps/mobile/assets/icon.png and /dev/null differ diff --git a/apps/mobile/assets/splash-icon-dev.png b/apps/mobile/assets/splash-icon-dev.png deleted file mode 100644 index b33d6f337b0..00000000000 Binary files a/apps/mobile/assets/splash-icon-dev.png and /dev/null differ diff --git a/apps/mobile/assets/splash-icon-prod.png b/apps/mobile/assets/splash-icon-prod.png deleted file mode 100644 index 2ff311ce786..00000000000 Binary files a/apps/mobile/assets/splash-icon-prod.png and /dev/null differ diff --git a/apps/mobile/index.ts b/apps/mobile/index.ts index 979dc75d5f1..9b887f58a28 100644 --- a/apps/mobile/index.ts +++ b/apps/mobile/index.ts @@ -1,5 +1,6 @@ import { registerRootComponent } from "expo"; import "react-native-gesture-handler"; +import { LogBox } from "react-native"; import { featureFlags } from "react-native-screens"; import App from "./src/App"; @@ -8,4 +9,8 @@ import App from "./src/App"; // native stack is rendered inside a non-fitToContents formSheet. featureFlags.experiment.synchronousScreenUpdatesEnabled = true; +if (process.env.EXPO_PUBLIC_SHOWCASE === "1") { + LogBox.ignoreAllLogs(); +} + registerRootComponent(App); diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt index 4d5f02aaa9c..b15a1cd4428 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt @@ -7,6 +7,32 @@ class T3NativeControlsModule : Module() { override fun definition() = ModuleDefinition { Name("T3NativeControls") + Function("getShowcasePairingUrl") { + appContext.currentActivity?.intent?.getStringExtra("showcasePairingUrl") + } + + Function("getShowcaseScene") { + val storedScene = appContext.reactContext + ?.filesDir + ?.resolve("t3-showcase-scene") + ?.takeIf { it.isFile } + ?.readText() + ?.trim() + ?.takeIf { it.isNotEmpty() } + storedScene ?: appContext.currentActivity?.intent?.getStringExtra("showcaseScene") + } + + Function("prepareShowcaseCapture") { + // Android app data is cleared by the host runner before launch. + } + + Function("markShowcaseReady") { scene: String -> + appContext.reactContext + ?.filesDir + ?.resolve("t3-showcase-ready") + ?.writeText(scene) + } + View(T3HeaderButtonView::class) { Prop("label") { view: T3HeaderButtonView, label: String -> view.setLabel(label) diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 33e1dc9086c..7781b164a2c 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -1,9 +1,48 @@ import ExpoModulesCore +import Security public final class T3NativeControlsModule: Module { public func definition() -> ModuleDefinition { Name("T3NativeControls") + Function("getShowcasePairingUrl") { + let arguments = ProcessInfo.processInfo.arguments + guard + let flagIndex = arguments.firstIndex(of: "--showcasePairingUrl"), + arguments.indices.contains(flagIndex + 1) + else { + return nil as String? + } + return arguments[flagIndex + 1] + } + + Function("getShowcaseScene") { () -> String? in + let scenePath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseScene" + if let storedScene = try? String(contentsOfFile: scenePath, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines), !storedScene.isEmpty { + return storedScene + } + let arguments = ProcessInfo.processInfo.arguments + guard + let flagIndex = arguments.firstIndex(of: "--showcaseScene"), + arguments.indices.contains(flagIndex + 1) + else { + return nil as String? + } + return arguments[flagIndex + 1] + } + + Function("prepareShowcaseCapture") { + for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword] { + SecItemDelete([kSecClass as String: itemClass] as CFDictionary) + } + } + + Function("markShowcaseReady") { (scene: String) in + let readyPath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseReadyScene" + try? scene.write(toFile: readyPath, atomically: true, encoding: .utf8) + } + View(T3HeaderButtonView.self) { Prop("label") { (view: T3HeaderButtonView, label: String) in view.setLabel(label) diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt new file mode 100644 index 00000000000..6782e6894d9 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt @@ -0,0 +1,315 @@ +package expo.modules.t3reviewdiff + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.graphics.Shader +import android.graphics.Typeface +import kotlin.math.max +import kotlin.math.min + +internal class ReviewDiffCanvasDrawing(context: Context) { + private val density = context.resources.displayMetrics.density + var theme: DiffTheme = DiffTheme.fallback("light") + + val backgroundPaint = Paint() + val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG) + val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + hinting = Paint.HINTING_ON + isSubpixelText = false + clearShadowLayer() + } + val uiPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + hinting = Paint.HINTING_ON + isSubpixelText = false + clearShadowLayer() + } + + private val italicCodeTypeface by lazy { + Typeface.create(ReviewDiffTypefaces.regular, Typeface.ITALIC) + } + private val boldItalicCodeTypeface by lazy { + Typeface.create(ReviewDiffTypefaces.bold, Typeface.ITALIC) + } + + init { + textPaint.typeface = ReviewDiffTypefaces.regular + uiPaint.typeface = Typeface.DEFAULT_BOLD + } + + fun fileHeaderChevronRect(top: Int, bottom: Int, style: DiffStyle): RectF { + val centerY = (top + bottom) / 2f + val left = style.fileHeaderHorizontalPaddingPx + return RectF(left, centerY - 10f * density, left + 20f * density, centerY + 10f * density) + } + + fun fileHeaderIconRect(top: Int, bottom: Int, style: DiffStyle): RectF { + val chevron = fileHeaderChevronRect(top, bottom, style) + return RectF( + chevron.right + 8f * density, + chevron.top, + chevron.right + 28f * density, + chevron.bottom, + ) + } + + fun fileHeaderCheckboxRect(top: Int, bottom: Int, width: Int, style: DiffStyle): RectF { + val centerY = (top + bottom) / 2f + val right = width - style.fileHeaderHorizontalPaddingPx + return RectF( + right - 20f * density, + centerY - 10f * density, + right, + centerY + 10f * density, + ) + } + + fun drawDisclosureChevron( + canvas: Canvas, + rect: RectF, + color: Int, + collapsed: Boolean + ) { + borderPaint.style = Paint.Style.STROKE + borderPaint.color = color + borderPaint.strokeWidth = 2f * density + borderPaint.strokeCap = Paint.Cap.ROUND + borderPaint.strokeJoin = Paint.Join.ROUND + val path = Path() + if (collapsed) { + path.moveTo(rect.left + rect.width() * 0.4f, rect.top + rect.height() * 0.28f) + path.lineTo(rect.left + rect.width() * 0.6f, rect.centerY()) + path.lineTo(rect.left + rect.width() * 0.4f, rect.bottom - rect.height() * 0.28f) + } else { + path.moveTo(rect.left + rect.width() * 0.28f, rect.top + rect.height() * 0.42f) + path.lineTo(rect.centerX(), rect.top + rect.height() * 0.62f) + path.lineTo(rect.right - rect.width() * 0.28f, rect.top + rect.height() * 0.42f) + } + canvas.drawPath(path, borderPaint) + borderPaint.style = Paint.Style.FILL + } + + fun drawFileIcon(canvas: Canvas, rect: RectF, changeType: String) { + val color = when (changeType) { + "new" -> theme.addText + "deleted" -> theme.deleteText + else -> theme.hunkText + } + borderPaint.style = Paint.Style.STROKE + borderPaint.color = color + borderPaint.strokeWidth = 2f * density + canvas.drawRoundRect(rect, 6f * density, 6f * density, borderPaint) + + if (changeType == "rename-pure" || changeType == "rename-changed" || changeType == "renamed") { + drawRenameChevronIcon(canvas, rect, color) + } else { + backgroundPaint.color = color + canvas.drawCircle(rect.centerX(), rect.centerY(), 3f * density, backgroundPaint) + } + borderPaint.style = Paint.Style.FILL + } + + fun drawViewedCheckbox(canvas: Canvas, rect: RectF, checked: Boolean) { + if (checked) { + backgroundPaint.color = theme.hunkText + canvas.drawRoundRect(rect, 6f * density, 6f * density, backgroundPaint) + } + borderPaint.style = Paint.Style.STROKE + borderPaint.color = if (checked) theme.hunkText else theme.mutedText + borderPaint.strokeWidth = 1.8f * density + canvas.drawRoundRect(rect, 6f * density, 6f * density, borderPaint) + if (checked) { + borderPaint.color = theme.background + borderPaint.strokeWidth = 2f * density + borderPaint.strokeCap = Paint.Cap.ROUND + borderPaint.strokeJoin = Paint.Join.ROUND + val path = Path().apply { + moveTo(rect.left + rect.width() * 0.28f, rect.centerY()) + lineTo(rect.left + rect.width() * 0.44f, rect.bottom - rect.height() * 0.3f) + lineTo(rect.right - rect.width() * 0.25f, rect.top + rect.height() * 0.3f) + } + canvas.drawPath(path, borderPaint) + } + borderPaint.style = Paint.Style.FILL + } + + fun drawNoticeIcon(canvas: Canvas, rect: RectF, color: Int) { + borderPaint.style = Paint.Style.STROKE + borderPaint.color = color + borderPaint.strokeWidth = 1.7f * density + borderPaint.strokeCap = Paint.Cap.ROUND + canvas.drawOval(rect, borderPaint) + canvas.drawLine( + rect.centerX(), + rect.top + rect.height() * 0.3f, + rect.centerX(), + rect.top + rect.height() * 0.58f, + borderPaint, + ) + borderPaint.style = Paint.Style.FILL + canvas.drawCircle(rect.centerX(), rect.bottom - rect.height() * 0.24f, density, borderPaint) + } + + fun configureUiPaint(paint: Paint, color: Int, size: Float, weight: String) { + paint.typeface = if (isBoldFontWeight(weight)) Typeface.DEFAULT_BOLD else Typeface.DEFAULT + configureTextPaint(paint, color, size) + } + + fun configureMonospacePaint(color: Int, size: Float, weight: String) { + textPaint.typeface = ReviewDiffTypefaces.forWeight(weight) + configureTextPaint(textPaint, color, size) + } + + fun configureCodePaint(color: Int, fontStyle: Int, style: DiffStyle) { + val tokenBold = fontStyle and 2 != 0 + val italic = fontStyle and 1 != 0 + textPaint.typeface = when { + tokenBold && italic -> boldItalicCodeTypeface + tokenBold -> ReviewDiffTypefaces.bold + italic -> italicCodeTypeface + else -> ReviewDiffTypefaces.forWeight(style.codeFontWeight) + } + configureTextPaint(textPaint, color, style.codeFontSizePx) + textPaint.isUnderlineText = fontStyle and 4 != 0 + } + + fun lineNumberColor(change: String): Int = when (change) { + "add" -> theme.addText + "delete" -> theme.deleteText + else -> theme.mutedText + } + + fun drawDeleteStripes( + canvas: Canvas, + top: Int, + bottom: Int, + width: Float, + color: Int + ) { + backgroundPaint.color = color + var y = top.toFloat() + while (y < bottom) { + canvas.drawRect(0f, y, width, min(bottom.toFloat(), y + density), backgroundPaint) + y += 2f * density + } + } + + fun drawWordDiffRanges( + canvas: Canvas, + row: DiffRow, + codeX: Float, + top: Int, + bottom: Int + ) { + if (row.wordDiffRanges.isEmpty() || (row.change != "add" && row.change != "delete")) return + val color = if (row.change == "add") theme.addBar else theme.deleteBar + backgroundPaint.color = withAlpha(color, 71) + val characterWidth = textPaint.measureText("M") + val fontHeight = textPaint.fontMetrics.run { descent - ascent } + val highlightHeight = max(4f * density, min(bottom - top - 4f * density, fontHeight)) + val highlightTop = (top + bottom - highlightHeight) / 2f + row.wordDiffRanges.forEach { range -> + val left = codeX + range.start * characterWidth + val right = max(left + 2f * density, codeX + range.end * characterWidth) + canvas.drawRoundRect( + RectF(left, highlightTop, right, highlightTop + highlightHeight), + 3f * density, + 3f * density, + backgroundPaint, + ) + } + } + + fun drawFileHeaderPathScrollFades( + canvas: Canvas, + pathRect: RectF, + horizontalOffset: Int, + maxOffset: Int + ) { + if (maxOffset <= 0 || pathRect.width() <= 0f) return + val fadeWidth = min(28f * density, pathRect.width() / 3f) + if (horizontalOffset > 0) { + drawHorizontalFade( + canvas, + RectF(pathRect.left, pathRect.top, pathRect.left + fadeWidth, pathRect.bottom), + fadesToRight = false, + ) + } + if (horizontalOffset < maxOffset) { + drawHorizontalFade( + canvas, + RectF(pathRect.right - fadeWidth, pathRect.top, pathRect.right, pathRect.bottom), + fadesToRight = true, + ) + } + } + + private fun drawRenameChevronIcon(canvas: Canvas, rect: RectF, color: Int) { + borderPaint.style = Paint.Style.STROKE + borderPaint.color = color + borderPaint.strokeWidth = 1.8f * density + borderPaint.strokeCap = Paint.Cap.ROUND + borderPaint.strokeJoin = Paint.Join.ROUND + val chevronWidth = 3.6f * density + val chevronHeight = 8f * density + val gap = 2.4f * density + val startX = rect.centerX() - (chevronWidth * 2f + gap) / 2f + val path = Path() + for (x in listOf(startX, startX + chevronWidth + gap)) { + path.moveTo(x, rect.centerY() - chevronHeight / 2f) + path.lineTo(x + chevronWidth, rect.centerY()) + path.lineTo(x, rect.centerY() + chevronHeight / 2f) + } + canvas.drawPath(path, borderPaint) + } + + private fun configureTextPaint(paint: Paint, color: Int, size: Float) { + paint.textSize = size + paint.color = color + paint.style = Paint.Style.FILL + paint.textAlign = Paint.Align.LEFT + paint.isUnderlineText = false + paint.isFakeBoldText = false + paint.clearShadowLayer() + } + + private fun drawHorizontalFade(canvas: Canvas, rect: RectF, fadesToRight: Boolean) { + val opaque = theme.headerBackground + val transparent = Color.argb(0, Color.red(opaque), Color.green(opaque), Color.blue(opaque)) + backgroundPaint.shader = LinearGradient( + rect.left, + rect.centerY(), + rect.right, + rect.centerY(), + if (fadesToRight) transparent else opaque, + if (fadesToRight) opaque else transparent, + Shader.TileMode.CLAMP, + ) + canvas.drawRect(rect, backgroundPaint) + backgroundPaint.shader = null + } + + private fun isBoldFontWeight(weight: String): Boolean = when (weight.lowercase()) { + "medium", "semibold", "semi-bold", "bold", "heavy", "black" -> true + else -> false + } + + private fun withAlpha(color: Int, alpha: Int): Int = + Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)) +} + +private object ReviewDiffTypefaces { + val regular: Typeface = Typeface.create("monospace", Typeface.NORMAL) + private val medium: Typeface = Typeface.create("monospace-medium", Typeface.NORMAL) + val bold: Typeface = Typeface.create("monospace", Typeface.BOLD) + + fun forWeight(weight: String): Typeface = when (weight.lowercase()) { + "medium", "semibold", "semi-bold" -> medium + "bold", "heavy", "black" -> bold + else -> regular + } +} diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt index 6f960f818a0..97e9f696db9 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt @@ -4,7 +4,7 @@ import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint -import android.graphics.Typeface +import android.graphics.RectF import android.view.GestureDetector import android.view.MotionEvent import android.view.VelocityTracker @@ -19,6 +19,7 @@ import org.json.JSONArray import org.json.JSONObject import java.util.concurrent.Executors import kotlin.math.abs +import kotlin.math.ceil import kotlin.math.max import kotlin.math.min @@ -49,12 +50,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont private val verticalScroller = OverScroller(context) private val horizontalScroller = OverScroller(context) private var dragAxis: DragAxis? = null + private var horizontalDragTarget: HorizontalPanTarget? = null private var lastTouchX = 0f private var lastTouchY = 0f private var velocityTracker: VelocityTracker? = null init { - canvasView.onRowTap = { row, gesture -> handleRowTap(row, gesture) } + canvasView.onRowTap = { row, gesture, target -> handleRowTap(row, gesture, target) } canvasView.onVisibleRowsChanged = { first, last -> onDebug( mapOf( @@ -86,12 +88,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont lastVisibleFileId = null pendingInitialScroll = true canvasView.setVerticalOffset(0) - canvasView.setHorizontalOffset(0) + canvasView.resetHorizontalOffsets() applyPendingInitialScroll() } fun setCollapsedFileIdsJson(value: String) { collapsedFileIds = parseStringSet(value) + canvasView.collapsedFileIds = collapsedFileIds rebuildVisibleRows() } @@ -107,6 +110,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont fun setCollapsedCommentIdsJson(value: String) { collapsedCommentIds = parseStringSet(value) + canvasView.collapsedCommentIds = collapsedCommentIds rebuildVisibleRows() } @@ -202,6 +206,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont verticalScroller.forceFinished(true) horizontalScroller.forceFinished(true) dragAxis = null + horizontalDragTarget = canvasView.horizontalPanTarget(event.y) lastTouchX = event.x lastTouchY = event.y parent?.requestDisallowInterceptTouchEvent(true) @@ -212,7 +217,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont val deltaX = event.x - lastTouchX val deltaY = event.y - lastTouchY if (max(abs(deltaX), abs(deltaY)) > touchSlop) { - dragAxis = if (abs(deltaY) >= abs(deltaX)) DragAxis.VERTICAL else DragAxis.HORIZONTAL + dragAxis = if (abs(deltaY) >= abs(deltaX)) { + DragAxis.VERTICAL + } else { + horizontalDragTarget + ?.takeIf { canvasView.maxHorizontalOffset(it) > 0 } + ?.let { DragAxis.HORIZONTAL } + } } } return dragAxis != null @@ -238,6 +249,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont ) { velocityTracker?.recycle() velocityTracker = null + horizontalDragTarget = null parent?.requestDisallowInterceptTouchEvent(false) } return handled @@ -253,7 +265,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont if (axis == DragAxis.VERTICAL) { canvasView.scrollByVertical(deltaY) } else { - canvasView.scrollByHorizontal(deltaX) + horizontalDragTarget?.let { canvasView.scrollByHorizontal(deltaX, it) } } lastTouchX = event.x lastTouchY = event.y @@ -276,7 +288,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont ) postInvalidateOnAnimation() } - } else { + } else if (horizontalDragTarget?.kind == HorizontalPanKind.CODE) { val velocity = -(velocityTracker?.xVelocity ?: 0f).toInt() if (abs(velocity) >= minimumFlingVelocity) { horizontalScroller.fling( @@ -294,6 +306,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont } } dragAxis = null + horizontalDragTarget = null velocityTracker?.recycle() velocityTracker = null parent?.requestDisallowInterceptTouchEvent(false) @@ -325,11 +338,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont currentFileCollapsed = collapsedFileIds.contains(row.resolvedFileId) filtered.add(row) } else if (!currentFileCollapsed) { - if (row.kind != "comment" || !collapsedCommentIds.contains(row.id)) { - filtered.add(row) - } else { - filtered.add(row.copy(commentText = "Comment collapsed")) - } + filtered.add(row) } } visibleRows = filtered @@ -339,10 +348,11 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont applyPendingInitialScroll() } - private fun handleRowTap(row: DiffRow, gesture: String) { + private fun handleRowTap(row: DiffRow, gesture: String, target: RowTapTarget) { when (row.kind) { "file" -> { - if (gesture == "longPress") { + if (gesture != "tap") return + if (target == RowTapTarget.VIEWED_CHECKBOX) { onToggleViewedFile(mapOf("fileId" to row.resolvedFileId)) } else { onToggleFile(mapOf("fileId" to row.resolvedFileId)) @@ -408,7 +418,22 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont } } -private data class DiffRow( +private enum class RowTapTarget { + ROW, + VIEWED_CHECKBOX +} + +private enum class HorizontalPanKind { + CODE, + FILE_HEADER_PATH +} + +private data class HorizontalPanTarget( + val fileId: String, + val kind: HorizontalPanKind +) + +internal data class DiffRow( val kind: String, val id: String, val fileId: String, @@ -422,6 +447,7 @@ private data class DiffRow( val change: String, val oldLineNumber: Int?, val newLineNumber: Int?, + val wordDiffRanges: List, val commentText: String, val commentRangeLabel: String, val commentSectionTitle: String @@ -429,13 +455,18 @@ private data class DiffRow( val resolvedFileId: String get() = fileId.ifEmpty { id } } +internal data class DiffWordDiffRange( + val start: Int, + val end: Int +) + private data class DiffToken( val content: String, val color: Int?, val fontStyle: Int ) -private data class DiffTheme( +internal data class DiffTheme( val background: Int, val text: Int, val mutedText: Int, @@ -511,14 +542,25 @@ private data class DiffTheme( } } -private data class DiffStyle( +internal data class DiffStyle( val rowHeightPx: Float, val gutterWidthPx: Float, val codePaddingPx: Float, val changeBarWidthPx: Float, val fileHeaderHeightPx: Float, + val fileHeaderHorizontalPaddingPx: Float, val codeFontSizePx: Float, - val lineNumberFontSizePx: Float + val codeFontWeight: String, + val lineNumberFontSizePx: Float, + val lineNumberFontWeight: String, + val hunkFontSizePx: Float, + val hunkFontWeight: String, + val fileHeaderFontSizePx: Float, + val fileHeaderFontWeight: String, + val fileHeaderMetaFontSizePx: Float, + val fileHeaderMetaFontWeight: String, + val fileHeaderSubtextFontSizePx: Float, + val fileHeaderSubtextFontWeight: String ) { companion object { fun defaults(density: Float): DiffStyle = DiffStyle( @@ -527,8 +569,19 @@ private data class DiffStyle( codePaddingPx = 10f * density, changeBarWidthPx = 3f * density, fileHeaderHeightPx = 44f * density, + fileHeaderHorizontalPaddingPx = 10f * density, codeFontSizePx = 12f * density, + codeFontWeight = "regular", lineNumberFontSizePx = 10f * density, + lineNumberFontWeight = "regular", + hunkFontSizePx = 11f * density, + hunkFontWeight = "medium", + fileHeaderFontSizePx = 11f * density, + fileHeaderFontWeight = "semibold", + fileHeaderMetaFontSizePx = 10f * density, + fileHeaderMetaFontWeight = "semibold", + fileHeaderSubtextFontSizePx = 11f * density, + fileHeaderSubtextFontWeight = "medium", ) fun fromJson(value: String, fallback: DiffStyle, density: Float): DiffStyle = try { @@ -539,11 +592,50 @@ private data class DiffStyle( codePaddingPx = json.floatDp("codePadding", fallback.codePaddingPx, density), changeBarWidthPx = json.floatDp("changeBarWidth", fallback.changeBarWidthPx, density), fileHeaderHeightPx = json.floatDp("fileHeaderHeight", fallback.fileHeaderHeightPx, density), + fileHeaderHorizontalPaddingPx = json.floatDp( + "fileHeaderHorizontalPadding", + fallback.fileHeaderHorizontalPaddingPx, + density, + ), codeFontSizePx = json.floatSp("codeFontSize", fallback.codeFontSizePx, density), + codeFontWeight = json.optString("codeFontWeight", fallback.codeFontWeight), lineNumberFontSizePx = json.floatSp( "lineNumberFontSize", fallback.lineNumberFontSizePx, - density + density, + ), + lineNumberFontWeight = json.optString( + "lineNumberFontWeight", + fallback.lineNumberFontWeight, + ), + hunkFontSizePx = json.floatSp("hunkFontSize", fallback.hunkFontSizePx, density), + hunkFontWeight = json.optString("hunkFontWeight", fallback.hunkFontWeight), + fileHeaderFontSizePx = json.floatSp( + "fileHeaderFontSize", + fallback.fileHeaderFontSizePx, + density, + ), + fileHeaderFontWeight = json.optString( + "fileHeaderFontWeight", + fallback.fileHeaderFontWeight, + ), + fileHeaderMetaFontSizePx = json.floatSp( + "fileHeaderMetaFontSize", + fallback.fileHeaderMetaFontSizePx, + density, + ), + fileHeaderMetaFontWeight = json.optString( + "fileHeaderMetaFontWeight", + fallback.fileHeaderMetaFontWeight, + ), + fileHeaderSubtextFontSizePx = json.floatSp( + "fileHeaderSubtextFontSize", + fallback.fileHeaderSubtextFontSizePx, + density, + ), + fileHeaderSubtextFontWeight = json.optString( + "fileHeaderSubtextFontWeight", + fallback.fileHeaderSubtextFontWeight, ), ) } catch (_: Exception) { @@ -554,35 +646,54 @@ private data class DiffStyle( private class DiffCanvasView(context: Context) : View(context) { private val density = resources.displayMetrics.density - private val backgroundPaint = Paint() - private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG) - private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE } - private val boldTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) - } + private val drawing = ReviewDiffCanvasDrawing(context) + private val backgroundPaint = drawing.backgroundPaint + private val borderPaint = drawing.borderPaint + private val textPaint = drawing.textPaint + private val boldTextPaint = drawing.uiPaint private val gestureDetector = GestureDetector( context, object : GestureDetector.SimpleOnGestureListener() { override fun onDown(event: MotionEvent): Boolean = true override fun onSingleTapUp(event: MotionEvent): Boolean { - rowAt(event.y)?.let { onRowTap?.invoke(it, "tap") } + rowHitAt(event.y)?.let { hit -> + val target = if ( + hit.row.kind == "file" && + drawing.fileHeaderCheckboxRect( + hit.top, + hit.bottom, + width, + style + ).contains(event.x, event.y) + ) { + RowTapTarget.VIEWED_CHECKBOX + } else { + RowTapTarget.ROW + } + onRowTap?.invoke(hit.row, "tap", target) + } return true } override fun onLongPress(event: MotionEvent) { - rowAt(event.y)?.let { onRowTap?.invoke(it, "longPress") } + rowHitAt(event.y)?.row + ?.takeIf { it.kind == "line" } + ?.let { onRowTap?.invoke(it, "longPress", RowTapTarget.ROW) } } }, ) private var rowOffsets = intArrayOf(0) private var verticalOffset = 0 private var horizontalOffset = 0 + private val headerPathOffsetsByFileId = mutableMapOf() private var lastVisibleRange: Pair? = null - var rows: List = emptyList() set(value) { field = value + headerPathOffsetsByFileId.keys.retainAll( + value.asSequence().filter { it.kind == "file" }.map { it.resolvedFileId }.toSet(), + ) rebuildOffsets() } var tokensByRowId: Map> = emptyMap() @@ -595,6 +706,16 @@ private class DiffCanvasView(context: Context) : View(context) { field = value invalidate() } + var collapsedFileIds: Set = emptySet() + set(value) { + field = value + invalidate() + } + var collapsedCommentIds: Set = emptySet() + set(value) { + field = value + rebuildOffsets() + } var selectedRowIds: Set = emptySet() set(value) { field = value @@ -603,6 +724,7 @@ private class DiffCanvasView(context: Context) : View(context) { var theme: DiffTheme = DiffTheme.fallback("light") set(value) { field = value + drawing.theme = value invalidate() } var style: DiffStyle = DiffStyle.defaults(density) @@ -614,9 +736,10 @@ private class DiffCanvasView(context: Context) : View(context) { set(value) { field = max(value, suggestedMinimumWidth) setHorizontalOffset(horizontalOffset) + clampHeaderPathOffsets() invalidate() } - var onRowTap: ((DiffRow, String) -> Unit)? = null + var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { @@ -630,6 +753,7 @@ private class DiffCanvasView(context: Context) : View(context) { super.onSizeChanged(width, height, oldWidth, oldHeight) setVerticalOffset(verticalOffset) setHorizontalOffset(horizontalOffset) + clampHeaderPathOffsets() } override fun onDraw(canvas: Canvas) { @@ -681,27 +805,63 @@ private class DiffCanvasView(context: Context) : View(context) { invalidate() } - fun scrollByHorizontal(delta: Int) { - setHorizontalOffset(horizontalOffset + delta) + fun scrollByHorizontal(delta: Int, target: HorizontalPanTarget) { + if (target.kind == HorizontalPanKind.FILE_HEADER_PATH) { + setHeaderPathOffset( + target.fileId, + (headerPathOffsetsByFileId[target.fileId] ?: 0) + delta, + ) + } else { + setHorizontalOffset(horizontalOffset + delta) + } } fun horizontalOffset(): Int = horizontalOffset fun maxHorizontalOffset(): Int = max(0, contentWidthPx - width) + fun maxHorizontalOffset(target: HorizontalPanTarget): Int = + if (target.kind == HorizontalPanKind.FILE_HEADER_PATH) { + maxHeaderPathOffset(target.fileId) + } else { + maxHorizontalOffset() + } + + fun horizontalPanTarget(y: Float): HorizontalPanTarget? { + val row = rowHitAt(y)?.row ?: return null + return HorizontalPanTarget( + fileId = row.resolvedFileId, + kind = if (row.kind == "file") HorizontalPanKind.FILE_HEADER_PATH else HorizontalPanKind.CODE, + ) + } + + fun resetHorizontalOffsets() { + setHorizontalOffset(0) + if (headerPathOffsetsByFileId.isNotEmpty()) { + headerPathOffsetsByFileId.clear() + invalidate() + } + } + private fun rebuildOffsets() { rowOffsets = IntArray(rows.size + 1) rows.forEachIndexed { index, row -> rowOffsets[index + 1] = rowOffsets[index] + rowHeight(row) } setVerticalOffset(verticalOffset) + clampHeaderPathOffsets() requestLayout() invalidate() } private fun rowHeight(row: DiffRow): Int = when (row.kind) { "file" -> style.fileHeaderHeightPx.toInt() - "comment" -> max((style.rowHeightPx * 3.2f).toInt(), (56 * density).toInt()) + "notice" -> max((style.rowHeightPx * 2f).toInt(), (44 * density).toInt()) + "comment" -> if (collapsedCommentIds.contains(row.id)) { + (44 * density).toInt() + } else { + (124 * density).toInt() + } else -> style.rowHeightPx.toInt() }.coerceAtLeast(1) @@ -721,13 +881,20 @@ private class DiffCanvasView(context: Context) : View(context) { return low.coerceIn(0, rows.lastIndex) } - private fun rowAt(y: Float): DiffRow? { + private fun rowHitAt(y: Float): RowHit? { stickyFileHeader(firstVisibleRow())?.let { sticky -> if (y >= max(0, sticky.top).toFloat() && y < sticky.bottom.toFloat()) { - return rows.getOrNull(sticky.index) + return rows.getOrNull(sticky.index)?.let { RowHit(it, sticky.top, sticky.bottom) } } } - return rows.getOrNull(rowIndexAt(verticalOffset + y.toInt())) + val index = rowIndexAt(verticalOffset + y.toInt()) + return rows.getOrNull(index)?.let { + RowHit( + row = it, + top = rowOffsets[index] - verticalOffset, + bottom = rowOffsets[index + 1] - verticalOffset, + ) + } } private fun firstVisibleRow(): Int = rowIndexAt(verticalOffset) @@ -754,23 +921,140 @@ private class DiffCanvasView(context: Context) : View(context) { private fun drawFileRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { fill(canvas, theme.headerBackground, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) - val baseline = centeredBaseline(top, bottom, boldTextPaint.apply { textSize = 13f * density }) - boldTextPaint.color = theme.text - val marker = if (viewedFileIds.contains(row.resolvedFileId)) "[x] " else "[ ] " - textPaint.color = theme.mutedText - textPaint.textSize = 11f * density - val stats = "+${row.additions} -${row.deletions}" - val statsX = max(12f * density, width - textPaint.measureText(stats) - 16f * density) - val title = ellipsize(marker + row.filePath, boldTextPaint, statsX - 28f * density) - canvas.drawText(title, 12f * density, baseline, boldTextPaint) - canvas.drawText(stats, statsX, baseline, textPaint) + + val chevronRect = drawing.fileHeaderChevronRect(top, bottom, style) + val iconRect = drawing.fileHeaderIconRect(top, bottom, style) + val checkboxRect = drawing.fileHeaderCheckboxRect(top, bottom, width, style) + drawing.drawDisclosureChevron( + canvas, + chevronRect, + theme.mutedText, + collapsed = collapsedFileIds.contains(row.resolvedFileId), + ) + drawing.drawFileIcon(canvas, iconRect, row.changeType) + drawing.drawViewedCheckbox( + canvas, + checkboxRect, + viewedFileIds.contains(row.resolvedFileId), + ) + + drawing.configureUiPaint( + paint = boldTextPaint, + color = theme.deleteText, + size = style.fileHeaderMetaFontSizePx, + weight = style.fileHeaderMetaFontWeight, + ) + val deleteText = "-${row.deletions}" + val deleteWidth = boldTextPaint.measureText(deleteText) + val addText = "+${row.additions}" + val addWidth = boldTextPaint.measureText(addText) + val countGap = 4f * density + val countsX = checkboxRect.left - 10f * density - deleteWidth - countGap - addWidth + val baseline = centeredBaseline(top, bottom, boldTextPaint) + canvas.drawText(deleteText, countsX, baseline, boldTextPaint) + boldTextPaint.color = theme.addText + canvas.drawText(addText, countsX + deleteWidth + countGap, baseline, boldTextPaint) + + val pathLayout = fileHeaderPathLayout(row, top, bottom, iconRect, countsX) + val maxPathOffset = maxHeaderPathOffset(pathLayout) + val pathOffset = (headerPathOffsetsByFileId[row.resolvedFileId] ?: 0) + .coerceIn(0, maxPathOffset) + canvas.save() + canvas.clipRect(pathLayout.rect) + canvas.drawText( + pathLayout.displayPath, + pathLayout.rect.left - pathOffset, + centeredBaseline(top, bottom, boldTextPaint), + boldTextPaint, + ) + canvas.restore() + drawing.drawFileHeaderPathScrollFades( + canvas, + pathLayout.rect, + pathOffset, + maxPathOffset, + ) drawBottomBorder(canvas, bottom) } + private fun fileHeaderPathLayout( + row: DiffRow, + top: Int, + bottom: Int, + iconRect: RectF, + countsX: Float + ): FileHeaderPathLayout { + drawing.configureUiPaint( + paint = boldTextPaint, + color = theme.text, + size = style.fileHeaderFontSizePx, + weight = style.fileHeaderFontWeight, + ) + val pathX = iconRect.right + 10f * density + val pathWidth = max(24f * density, countsX - pathX - 12f * density) + val centerY = (top + bottom) / 2f + val displayPath = if ( + !row.previousPath.isNullOrEmpty() && row.previousPath != row.filePath + ) { + "${row.previousPath} -> ${row.filePath}" + } else { + row.filePath + } + return FileHeaderPathLayout( + displayPath = displayPath, + rect = RectF(pathX, centerY - 10f * density, pathX + pathWidth, centerY + 10f * density), + ) + } + + private fun maxHeaderPathOffset(fileId: String): Int { + val row = rows.firstOrNull { it.kind == "file" && it.resolvedFileId == fileId } ?: return 0 + val top = 0 + val bottom = rowHeight(row) + val iconRect = drawing.fileHeaderIconRect(top, bottom, style) + val checkboxRect = drawing.fileHeaderCheckboxRect(top, bottom, width, style) + drawing.configureUiPaint( + paint = boldTextPaint, + color = theme.deleteText, + size = style.fileHeaderMetaFontSizePx, + weight = style.fileHeaderMetaFontWeight, + ) + val countsX = checkboxRect.left - 10f * density - + boldTextPaint.measureText("-${row.deletions}") - 4f * density - + boldTextPaint.measureText("+${row.additions}") + return maxHeaderPathOffset(fileHeaderPathLayout(row, top, bottom, iconRect, countsX)) + } + + private fun maxHeaderPathOffset(layout: FileHeaderPathLayout): Int = max( + 0, + ceil(boldTextPaint.measureText(layout.displayPath) - layout.rect.width()).toInt(), + ) + + private fun setHeaderPathOffset(fileId: String, value: Int) { + val nextOffset = value.coerceIn(0, maxHeaderPathOffset(fileId)) + if ((headerPathOffsetsByFileId[fileId] ?: 0) == nextOffset) return + headerPathOffsetsByFileId[fileId] = nextOffset + invalidate() + } + + private fun clampHeaderPathOffsets() { + headerPathOffsetsByFileId.keys.toList().forEach { fileId -> + val nextOffset = (headerPathOffsetsByFileId[fileId] ?: 0) + .coerceIn(0, maxHeaderPathOffset(fileId)) + if (nextOffset == 0) { + headerPathOffsetsByFileId.remove(fileId) + } else { + headerPathOffsetsByFileId[fileId] = nextOffset + } + } + } + private fun drawHunkRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { fill(canvas, theme.hunkBackground, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) - textPaint.color = theme.hunkText - textPaint.textSize = style.codeFontSizePx + drawing.configureMonospacePaint( + color = theme.hunkText, + size = style.hunkFontSizePx, + weight = style.hunkFontWeight, + ) drawScrollableCode(canvas, top, bottom) { codeX -> canvas.drawText( row.text.ifEmpty { row.content }, @@ -782,36 +1066,82 @@ private class DiffCanvasView(context: Context) : View(context) { } private fun drawNoticeRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { - textPaint.color = theme.mutedText - textPaint.textSize = style.codeFontSizePx - drawScrollableCode(canvas, top, bottom) { codeX -> - canvas.drawText(row.text, codeX, centeredBaseline(top, bottom, textPaint), textPaint) - } + fill(canvas, theme.background, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) + val iconSize = 16f * density + val iconRect = RectF( + style.fileHeaderHorizontalPaddingPx + 2f * density, + (top + bottom - iconSize) / 2f, + style.fileHeaderHorizontalPaddingPx + 2f * density + iconSize, + (top + bottom + iconSize) / 2f, + ) + drawing.drawNoticeIcon(canvas, iconRect, theme.mutedText) + drawing.configureUiPaint( + paint = textPaint, + color = theme.mutedText, + size = style.fileHeaderSubtextFontSizePx, + weight = style.fileHeaderSubtextFontWeight, + ) + val textX = iconRect.right + 10f * density + canvas.drawText( + ellipsize(row.text, textPaint, width - textX - style.fileHeaderHorizontalPaddingPx), + textX, + centeredBaseline(top, bottom, textPaint), + textPaint, + ) + drawBottomBorder(canvas, bottom) } private fun drawCommentRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { - fill( - canvas, - theme.headerBackground, - style.gutterWidthPx, - top.toFloat(), - width.toFloat(), - bottom.toFloat() + fill(canvas, theme.background, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) + val cardRect = RectF( + 8f * density, + top + 5f * density, + width - 8f * density, + bottom - 5f * density, ) - boldTextPaint.color = theme.text - boldTextPaint.textSize = 12f * density - drawScrollableCode(canvas, top, bottom) { codeX -> + backgroundPaint.color = theme.headerBackground + canvas.drawRoundRect(cardRect, 10f * density, 10f * density, backgroundPaint) + borderPaint.style = Paint.Style.STROKE + borderPaint.color = withAlpha(theme.border, 217) + borderPaint.strokeWidth = density + canvas.drawRoundRect(cardRect, 10f * density, 10f * density, borderPaint) + borderPaint.style = Paint.Style.FILL + + val collapsed = collapsedCommentIds.contains(row.id) + val chevronRect = RectF( + cardRect.left + 10f * density, + cardRect.top + 11f * density, + cardRect.left + 26f * density, + cardRect.top + 27f * density, + ) + drawing.drawDisclosureChevron(canvas, chevronRect, theme.mutedText, collapsed) + drawing.configureUiPaint( + paint = textPaint, + color = theme.mutedText, + size = style.fileHeaderSubtextFontSizePx, + weight = style.fileHeaderSubtextFontWeight, + ) + val title = "Comment on ${row.commentRangeLabel.ifEmpty { "line" }}" + canvas.drawText( + ellipsize(title, textPaint, cardRect.right - chevronRect.right - 20f * density), + chevronRect.right + 10f * density, + cardRect.top + 22f * density, + textPaint, + ) + if (!collapsed) { + textPaint.color = theme.text + val bodyX = cardRect.left + 18f * density canvas.drawText( - row.commentSectionTitle.ifEmpty { row.commentRangeLabel.ifEmpty { "Comment" } }, - codeX, - top + 20f * density, - boldTextPaint, + ellipsize( + row.commentText.ifEmpty { "Comment" }, + textPaint, + cardRect.right - bodyX - 18f * density + ), + bodyX, + cardRect.top + 58f * density, + textPaint, ) - textPaint.color = theme.mutedText - textPaint.textSize = 12f * density - canvas.drawText(row.commentText, codeX, top + 42f * density, textPaint) } - drawBottomBorder(canvas, bottom) } @Suppress("CyclomaticComplexMethod") @@ -822,65 +1152,79 @@ private class DiffCanvasView(context: Context) : View(context) { else -> theme.background } fill(canvas, background, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) + if (style.changeBarWidthPx > 0) { + when (row.change) { + "add" -> fill( + canvas, + theme.addBar, + 0f, + top.toFloat(), + style.changeBarWidthPx, + bottom.toFloat() + ) + "delete" -> drawing.drawDeleteStripes( + canvas, + top, + bottom, + style.changeBarWidthPx, + theme.deleteBar, + ) + } + } val selected = selectedRowIds.contains(row.id) if (selected) { fill( canvas, - withAlpha(theme.hunkText, if (theme.background == Color.WHITE) 54 else 76), + withAlpha(theme.hunkText, 56), 0f, top.toFloat(), width.toFloat(), bottom.toFloat(), ) - } - val barColor = when (row.change) { - "add" -> theme.addBar - "delete" -> theme.deleteBar - else -> Color.TRANSPARENT - } - if (barColor != Color.TRANSPARENT && style.changeBarWidthPx > 0) { - fill(canvas, barColor, 0f, top.toFloat(), style.changeBarWidthPx, bottom.toFloat()) + fill( + canvas, + withAlpha(theme.hunkText, 242), + 0f, + top.toFloat(), + style.changeBarWidthPx, + bottom.toFloat() + ) } val tokens = tokensByRowId[row.id] drawScrollableCode(canvas, top, bottom) { codeX -> + drawing.configureCodePaint(theme.text, 0, style) + drawing.drawWordDiffRanges(canvas, row, codeX, top, bottom) if (tokens.isNullOrEmpty()) { - textPaint.textSize = style.codeFontSizePx - textPaint.color = when (row.change) { - "add" -> theme.addText - "delete" -> theme.deleteText - else -> theme.text - } canvas.drawText(row.content, codeX, centeredBaseline(top, bottom, textPaint), textPaint) } else { var x = codeX tokens.forEach { token -> - textPaint.textSize = style.codeFontSizePx - textPaint.color = token.color ?: theme.text - textPaint.typeface = when { - token.fontStyle and 1 != 0 -> Typeface.create(Typeface.MONOSPACE, Typeface.ITALIC) - token.fontStyle and 2 != 0 -> Typeface.create(Typeface.MONOSPACE, Typeface.BOLD) - else -> Typeface.MONOSPACE - } + drawing.configureCodePaint(token.color ?: theme.text, token.fontStyle, style) canvas.drawText(token.content, x, centeredBaseline(top, bottom, textPaint), textPaint) x += textPaint.measureText(token.content) } - textPaint.typeface = Typeface.MONOSPACE } } - textPaint.textSize = style.lineNumberFontSizePx - textPaint.color = if (selected) theme.text else theme.mutedText - val oldNumber = row.oldLineNumber?.toString().orEmpty() - val newNumber = row.newLineNumber?.toString().orEmpty() - val baseline = centeredBaseline(top, bottom, textPaint) - canvas.drawText(oldNumber, style.changeBarWidthPx + 6f * density, baseline, textPaint) + drawLineNumber(canvas, row, top, bottom) + } + + private fun drawLineNumber(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { + val lineNumber = row.newLineNumber ?: row.oldLineNumber ?: return + drawing.configureMonospacePaint( + color = drawing.lineNumberColor(row.change), + size = style.lineNumberFontSizePx, + weight = style.lineNumberFontWeight, + ) + textPaint.textAlign = Paint.Align.RIGHT canvas.drawText( - newNumber, - style.changeBarWidthPx + style.gutterWidthPx / 2f, - baseline, - textPaint + lineNumber.toString(), + style.changeBarWidthPx + style.gutterWidthPx - style.codePaddingPx, + centeredBaseline(top, bottom, textPaint), + textPaint, ) + textPaint.textAlign = Paint.Align.LEFT } private fun drawScrollableCode( @@ -972,6 +1316,17 @@ private class DiffCanvasView(context: Context) : View(context) { val top: Int, val bottom: Int ) + + private data class RowHit( + val row: DiffRow, + val top: Int, + val bottom: Int + ) + + private data class FileHeaderPathLayout( + val displayPath: String, + val rect: RectF + ) } private fun withAlpha(color: Int, alpha: Int): Int = @@ -995,6 +1350,7 @@ private fun parseRows(value: String): List = try { change = row.optString("change", "context"), oldLineNumber = row.optNullableInt("oldLineNumber"), newLineNumber = row.optNullableInt("newLineNumber"), + wordDiffRanges = row.optJSONArray("wordDiffRanges")?.let(::parseWordDiffRanges).orEmpty(), commentText = row.optString("commentText"), commentRangeLabel = row.optString("commentRangeLabel"), commentSectionTitle = row.optString("commentSectionTitle"), @@ -1004,6 +1360,17 @@ private fun parseRows(value: String): List = try { emptyList() } +private fun parseWordDiffRanges(value: JSONArray): List = buildList { + for (index in 0 until value.length()) { + val range = value.optJSONObject(index) ?: continue + val start = range.optInt("start", -1) + val end = range.optInt("end", -1) + if (start >= 0 && end > start) { + add(DiffWordDiffRange(start = start, end = end)) + } + } +} + private fun parseTokensObject(value: String): Map> = try { parseTokensObject(JSONObject(value)) } catch (_: Exception) { @@ -1020,7 +1387,7 @@ private fun parseTokensObject(value: JSONObject): Map> { val token = array.getJSONObject(index) DiffToken( content = token.optString("content"), - color = token.optNullableString("color")?.let { parseColor(it, Color.TRANSPARENT) }, + color = token.optNullableString("color")?.let(::parseColorOrNull), fontStyle = token.optInt("fontStyle"), ) } @@ -1043,6 +1410,12 @@ private fun parseColor(value: String, fallback: Int): Int = try { fallback } +private fun parseColorOrNull(value: String): Int? = try { + Color.parseColor(value) +} catch (_: Exception) { + null +} + private fun JSONObject.optNullableString(key: String): String? = if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() } diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt index 44840eb570e..1631c7fe68a 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt @@ -30,6 +30,10 @@ class T3TerminalModule : Module() { view.focusRequest = focusRequest } + Prop("autoFocus") { view: T3TerminalView, autoFocus: Boolean -> + view.autoFocus = autoFocus + } + Prop("appearanceScheme") { view: T3TerminalView, appearanceScheme: String -> view.appearanceScheme = appearanceScheme } diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index fcc6092dbee..88de793a8f7 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -76,6 +76,17 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex } } + var autoFocus: Boolean = true + set(value) { + field = value + if (value) { + requestKeyboardFocus() + } else { + inputView.clearFocus() + hideKeyboard() + } + } + var backgroundColorHex: String = "#24292E" set(value) { field = value @@ -368,6 +379,13 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex inputMethodManager?.showSoftInput(inputView, InputMethodManager.SHOW_IMPLICIT) } + private fun hideKeyboard() { + val inputMethodManager = context.getSystemService( + Context.INPUT_METHOD_SERVICE + ) as? InputMethodManager + inputMethodManager?.hideSoftInputFromWindow(windowToken, 0) + } + private fun applyTheme() { setBackgroundColor(backgroundColorValue) container.setBackgroundColor(backgroundColorValue) diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift index 39e1874d5d7..f68cc6b4a11 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift @@ -7,7 +7,7 @@ public class T3TerminalModule: Module { // Bumped when native hardware-keyboard handling changes; surfaced in the JS debug // logs so a stale native binary is distinguishable from a broken key pipeline. Constants([ - "hardwareKeyRevision": 2, + "hardwareKeyRevision": 3, ]) View(T3TerminalView.self) { @@ -27,6 +27,10 @@ public class T3TerminalModule: Module { view.focusRequest = focusRequest } + Prop("autoFocus") { (view: T3TerminalView, autoFocus: Bool) in + view.autoFocus = autoFocus + } + Prop("appearanceScheme") { (view: T3TerminalView, appearanceScheme: String) in view.appearanceScheme = appearanceScheme } diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 14cdb4b7802..659837fc7ab 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -119,6 +119,21 @@ private enum TerminalHardwareKeyEncoder { } } +private enum TerminalInputSequence { + /// Terminal Enter is carriage return. Sending line feed instead is Ctrl+J, + /// which raw-mode TUIs may interpret as the literal J key. + static let carriageReturn = "\r" + + static func normalizingReturn(_ input: String) -> String { + switch input { + case "\n", "\r\n": + return carriageReturn + default: + return input + } + } +} + private final class TerminalInputField: UITextField { var onDeleteBackward: (() -> Void)? var onInsert: ((String) -> Void)? @@ -233,6 +248,17 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } } + var autoFocus = true { + didSet { + guard oldValue != autoFocus else { return } + if autoFocus { + requestKeyboardFocus() + } else { + inputField.resignFirstResponder() + } + } + } + var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue { didSet { guard oldValue != appearanceScheme else { return } @@ -344,7 +370,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { public override func didMoveToWindow() { super.didMoveToWindow() - guard window != nil else { return } + guard window != nil, autoFocus else { return } DispatchQueue.main.async { [weak self] in self?.requestKeyboardFocus() } @@ -352,7 +378,9 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { if !string.isEmpty { - emitInput(string) + // Some software keyboards deliver Return through this delegate instead of + // textFieldShouldReturn, so normalize that path too. + emitInput(TerminalInputSequence.normalizingReturn(string)) return false } @@ -360,7 +388,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } public func textFieldShouldReturn(_ textField: UITextField) -> Bool { - emitInput("\n") + emitInput(TerminalInputSequence.carriageReturn) textField.text = "" return false } diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 0bc0daecafc..c99351547be 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -5,12 +5,14 @@ "main": "index.ts", "scripts": { "dev": "expo start --clear", - "dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear --localhost", + "dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear --lan", "dev:client:preview": "eas env:exec preview 'EXPO_NO_DOTENV=1 APP_VARIANT=preview expo start --dev-client --scheme t3code-preview --clear --lan'", "start": "expo start", "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", + "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", "android:preview": "APP_VARIANT=preview EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android", @@ -91,6 +93,7 @@ "expo-paste-input": "^0.1.15", "expo-quick-actions": "^6.0.2", "expo-secure-store": "~56.0.4", + "expo-sharing": "~56.0.18", "expo-splash-screen": "~56.0.10", "expo-sqlite": "~56.0.5", "expo-symbols": "~56.0.6", diff --git a/apps/mobile/plugins/withShareExtensionDisplayName.cjs b/apps/mobile/plugins/withShareExtensionDisplayName.cjs new file mode 100644 index 00000000000..27c57925e8a --- /dev/null +++ b/apps/mobile/plugins/withShareExtensionDisplayName.cjs @@ -0,0 +1,93 @@ +"use strict"; + +// expo-sharing intentionally uses a fixed target name and also uses that +// internal target name as CFBundleDisplayName. Brand the extension while +// leaving its initially-empty signing team intact: Expo uses that state to +// select ios.appleTeamId and enable first-time profile provisioning. +// +// ORDERING: list this plugin BEFORE expo-sharing. Expo runs same-type mods in +// reverse registration order, so this executes after the extension exists. + +const fs = require("fs"); +const path = require("path"); +const { withDangerousMod, withXcodeProject } = require("expo/config-plugins"); + +const TARGET_NAME = "expo-sharing-extension"; + +function stripComments(section) { + return Object.fromEntries( + Object.entries(section ?? {}).filter(([key]) => !key.endsWith("_comment")), + ); +} + +function findByName(section, name) { + return Object.entries(stripComments(section)).find(([, value]) => value?.name === name) ?? null; +} + +function escapeXml(value) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function withDisplayNamePlist(config, displayName) { + return withDangerousMod(config, [ + "ios", + (cfg) => { + const plistPath = path.join(cfg.modRequest.platformProjectRoot, TARGET_NAME, "Info.plist"); + if (!fs.existsSync(plistPath)) { + throw new Error( + `${TARGET_NAME}/Info.plist was not generated. Register withShareExtensionDisplayName before expo-sharing.`, + ); + } + const source = fs.readFileSync(plistPath, "utf8"); + const displayNamePattern = /(CFBundleDisplayName<\/key>\s*)[^<]*(<\/string>)/; + if (!displayNamePattern.test(source)) { + throw new Error(`Could not update CFBundleDisplayName in ${plistPath}.`); + } + const next = source.replace(displayNamePattern, `$1${escapeXml(displayName)}$2`); + if (next !== source) { + fs.writeFileSync(plistPath, next); + } + return cfg; + }, + ]); +} + +function withExtensionDisplayNameBuildSetting(config, displayName) { + return withXcodeProject(config, (cfg) => { + const objects = cfg.modResults.hash.project.objects; + const targetEntry = findByName(objects.PBXNativeTarget, TARGET_NAME); + if (!targetEntry) { + throw new Error( + `${TARGET_NAME} Xcode target was not generated. Register withShareExtensionDisplayName before expo-sharing.`, + ); + } + const target = targetEntry[1]; + const configurationList = stripComments(objects.XCConfigurationList)[ + target.buildConfigurationList + ]; + if (!configurationList) { + throw new Error(`Could not find build configurations for ${TARGET_NAME}.`); + } + const buildConfigurations = stripComments(objects.XCBuildConfiguration); + for (const reference of configurationList.buildConfigurations ?? []) { + const buildConfiguration = buildConfigurations[reference.value]; + if (buildConfiguration?.buildSettings) { + buildConfiguration.buildSettings.INFOPLIST_KEY_CFBundleDisplayName = `"${displayName}"`; + } + } + return cfg; + }); +} + +module.exports = function withShareExtensionDisplayName(config) { + const displayName = config.name; + return withExtensionDisplayNameBuildSetting( + withDisplayNamePlist(config, displayName), + displayName, + ); +}; diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index c76d3010081..835f54491f6 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -11,6 +11,8 @@ import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigati import { RegistryContext } from "@effect/atom-react"; import { ConfirmDialogHost } from "./components/ConfirmDialogHost"; import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; +import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene"; +import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider"; import { AppearancePreferencesProvider } from "./features/settings/appearance/AppearancePreferencesProvider"; import { RootStack } from "./Stack"; import { appAtomRegistry } from "./state/atom-registry"; @@ -20,13 +22,20 @@ import { useThemeColor } from "./lib/useThemeColor"; import "../global.css"; +if (process.env.EXPO_PUBLIC_SHOWCASE === "1") { + prepareNativeShowcaseCapture(); +} + const appLinking = { prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"], // The Expo dev client launches the app via // ://expo-development-client/?url= — that URL addresses // the launcher, not app navigation. Without this filter it falls through // to the NotFound wildcard route on every dev launch. - filter: (url: string) => !url.includes("expo-development-client"), + // expo-sharing uses a private lifecycle URL only to wake the app. The + // persisted share inbox below owns navigation once the payload is durable. + filter: (url: string) => + !url.includes("expo-development-client") && !url.includes("://expo-sharing"), }; const Navigation = createStaticNavigation(RootStack); @@ -58,10 +67,12 @@ export default function App() { the system is in dark mode. */} {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */} - + + + {/* Anchored-menu overlays render here — in-window, so the diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 3682da6dab1..4eb4a5061e8 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -10,6 +10,7 @@ import { createNativeStackScreen, type NativeStackNavigationOptions, } from "@react-navigation/native-stack"; +import { useEffect, useRef } from "react"; import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "react-native"; import { useResolveClassNames } from "uniwind"; @@ -44,9 +45,20 @@ import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppea import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen"; import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; +import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; import { SettingsWaitlistRouteScreen } from "./features/settings/SettingsWaitlistRouteScreen"; +import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; +import { + SettingsLegalDocumentCloseHeaderButton, + SettingsLegalDocumentExternalHeaderButton, +} from "./features/settings/components/SettingsLegalDocumentRouteScreen"; import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts"; +import { useIncomingShare } from "./features/sharing/IncomingShareProvider"; +import { + EMPTY_INCOMING_SHARE_PRESENTATION_STATE, + transitionIncomingSharePresentation, +} from "./features/sharing/incoming-share-presentation"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; @@ -106,6 +118,14 @@ const SHEET_SOLID_HEADER_OPTIONS: AppScreenOptions = { unstable_navigationItemStyle: undefined, }; +const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { + ...SHEET_SOLID_HEADER_OPTIONS, + headerBackVisible: false, + headerLeft: SettingsLegalDocumentCloseHeaderButton, + headerRight: () => , + presentation: "fullScreenModal", +}; + const SettingsSheetStack = createNativeStackNavigator({ initialRouteName: "Settings", screenOptions: { @@ -239,6 +259,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([ "GitConfirm", "GitOverview", "NewTaskSheet", + "SettingsLegal", "SettingsSheet", "ThreadReviewComment", ]); @@ -261,12 +282,30 @@ function RootStackLayout(props: { readonly children: React.ReactNode; readonly state: NavigationState; }) { + const navigation = useNavigation(); + const { pendingShare } = useIncomingShare(); + const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE); useAgentNotificationNavigation(); useThreadOutboxDrain(); // Presents the T3 Connect onboarding sheet after an in-session sign-in. useConnectOnboardingNavigation(); // Launcher app shortcuts: routes shortcut taps and tracks opened threads. useAppShortcuts(props.state); + useEffect(() => { + const topRouteName = props.state.routes[props.state.index]?.name; + const transition = transitionIncomingSharePresentation(sharePresentationRef.current, { + isShareSheetPresented: topRouteName === "NewTaskSheet", + pendingShareId: pendingShare?.id ?? null, + }); + sharePresentationRef.current = transition.state; + if (!transition.shareIdToPresent) { + return; + } + navigation.navigate("NewTaskSheet", { + screen: "NewTask", + params: { incomingShareId: transition.shareIdToPresent }, + }); + }, [navigation, pendingShare, props.state]); // Full pathname (sheets included) for keyboard-command scoping; the // workspace layout only reacts to the underlying non-overlay route. const path = getPathFromState(props.state, navigationPathConfig); @@ -275,6 +314,7 @@ function RootStackLayout(props: { return ( + {props.children} @@ -436,13 +476,21 @@ export const RootStack = createNativeStackNavigator({ }), }, }), + SettingsLegal: createNativeStackScreen({ + screen: SettingsLegalRouteScreen, + linking: "settings/legal", + options: { + ...LEGAL_DOCUMENT_HEADER_OPTIONS, + title: "Legal", + }, + }), ConnectOnboarding: createNativeStackScreen({ screen: ConnectOnboardingRouteScreen, linking: "connect-onboarding", options: { - // Root screenOptions hide headers; formSheets that want the native - // title bar opt back in with the sheet header preset. - ...SHEET_SOLID_HEADER_OPTIONS, + // A root-level Android formSheet does not host the native stack bar; + // the route renders an embedded AndroidSheetHeader instead. + ...(Platform.OS === "android" ? { headerShown: false } : SHEET_SOLID_HEADER_OPTIONS), title: "Set up T3 Connect", gestureEnabled: true, presentation: "formSheet", diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx index 8e803d3c1a6..ef5319a1b6f 100644 --- a/apps/mobile/src/components/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -51,6 +51,7 @@ export function AndroidScreenHeader(props: { readonly actions?: ReadonlyArray; readonly trailing?: ReactNode; readonly onBack?: () => void; + readonly embedded?: boolean; }) { const insets = useSafeAreaInsets(); const foregroundColor = useThemeColor("--color-foreground"); @@ -59,7 +60,7 @@ export function AndroidScreenHeader(props: { @@ -108,3 +109,9 @@ export function AndroidScreenHeader(props: { ); } + +export function AndroidSheetHeader( + props: Omit[0], "embedded">, +) { + return ; +} diff --git a/apps/mobile/src/components/BrandMark.tsx b/apps/mobile/src/components/BrandMark.tsx index 4d1ac01b1ff..c8f1d438551 100644 --- a/apps/mobile/src/components/BrandMark.tsx +++ b/apps/mobile/src/components/BrandMark.tsx @@ -1,13 +1,23 @@ -import { Image, View } from "react-native"; +import Constants from "expo-constants"; +import { Image } from "expo-image"; +import { View } from "react-native"; import { AppText as Text } from "./AppText"; -const BRAND_MARK_SOURCE = require("../../../../assets/dev/blueprint-ios-1024.png"); +const appVariant = Constants.expoConfig?.extra?.appVariant; +const BRAND_MARK_SOURCE = + appVariant === "development" + ? require("../../../../assets/dev/blueprint-ios-1024.png") + : appVariant === "preview" + ? require("../../../../assets/nightly/nightly-ios-1024.png") + : require("../../../../assets/prod/black-ios-1024.png"); +const DEFAULT_STAGE_LABEL = + appVariant === "development" ? "Dev" : appVariant === "preview" ? "Preview" : "Alpha"; export function BrandMark(props: { readonly compact?: boolean; readonly stageLabel?: string }) { const compact = props.compact ?? false; const iconSize = compact ? 32 : 44; - const stageLabel = props.stageLabel ?? "Alpha"; + const stageLabel = props.stageLabel ?? DEFAULT_STAGE_LABEL; return ( diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 7c29eeaa24d..772d5e8cc14 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -3,6 +3,7 @@ import { Image } from "expo-image"; import { useState } from "react"; import { View } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; +import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; import { useThemeColor } from "../lib/useThemeColor"; import { useAssetUrl } from "../state/assets"; @@ -24,11 +25,12 @@ export function ProjectFavicon(props: { ? null : { _tag: "project-favicon", cwd: props.workspaceRoot }, ); + const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; return ( - - - + {Platform.OS === "android" ? ( + + ) : ( + + + + )} ; readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void; + readonly showcaseAvailableEnvironments?: ReadonlyArray; + readonly showcaseSignedIn?: boolean; /** * Hide the "T3 Connect" section title + refresh button for hosts that * provide their own chrome (the onboarding sheet's native header and @@ -43,7 +45,8 @@ export function CloudEnvironmentRows(props: { const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const controller = useConnectionController(); const iconColor = useThemeColor("--color-icon"); - const availableCloudEnvironments = controller.availableRelayEnvironments; + const availableCloudEnvironments = + props.showcaseAvailableEnvironments ?? controller.availableRelayEnvironments; const [expandedErrorId, setExpandedErrorId] = useState(null); const hasCloudRows = props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0; @@ -64,7 +67,7 @@ export function CloudEnvironmentRows(props: { const showHeader = props.showHeader ?? true; - if (!isSignedIn) return null; + if (!(props.showcaseSignedIn ?? isSignedIn)) return null; return ( diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts new file mode 100644 index 00000000000..9e1480d1de9 --- /dev/null +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { NativeReviewDiffRow } from "./nativeReviewDiffSurface"; +import type { NativeReviewDiffFile } from "./nativeReviewDiffTypes"; +import { highlightNativeReviewDiffVisibleRows } from "./nativeReviewDiffHighlighter"; + +const TYPESCRIPT_FILE: NativeReviewDiffFile = { + id: "file-1", + path: "example.ts", + language: "typescript", + additions: 0, + deletions: 0, +}; + +function makeLine( + input: Pick, +): NativeReviewDiffRow { + return { + kind: "line", + fileId: TYPESCRIPT_FILE.id, + ...input, + }; +} + +function makeHunk(id: string): NativeReviewDiffRow { + return { + kind: "hunk", + id, + fileId: TYPESCRIPT_FILE.id, + text: "@@", + }; +} + +function highlight( + rows: ReadonlyArray, + alreadyHighlightedRowIds?: ReadonlySet, +) { + return highlightNativeReviewDiffVisibleRows({ + rows, + files: [TYPESCRIPT_FILE], + scheme: "dark", + engine: "javascript", + firstRowIndex: 0, + lastRowIndex: rows.length - 1, + overscanRows: 0, + maxRows: 100, + alreadyHighlightedRowIds, + }); +} + +describe("highlightNativeReviewDiffVisibleRows", () => { + it("does not carry grammar state across hunk boundaries", async () => { + const exportRow = makeLine({ + id: "export-row", + content: "export async function run() {}", + change: "add", + oldLineNumber: null, + newLineNumber: 100, + }); + const rows = [ + makeHunk("hunk-1"), + makeLine({ + id: "import-open", + content: "import {", + change: "context", + oldLineNumber: 1, + newLineNumber: 1, + }), + makeLine({ + id: "import-entry", + content: " Model,", + change: "context", + oldLineNumber: 2, + newLineNumber: 2, + }), + makeHunk("hunk-2"), + exportRow, + ]; + + const [highlighted, standalone] = await Promise.all([ + highlight(rows), + highlight([makeHunk("standalone-hunk"), exportRow]), + ]); + + expect(highlighted.tokensByRowId[exportRow.id]).toEqual(standalone.tokensByRowId[exportRow.id]); + }); + + it("keeps grammar state across inline comment rows", async () => { + const openingRow = makeLine({ + id: "template-open", + content: "const message = `open", + change: "add", + oldLineNumber: null, + newLineNumber: 1, + }); + const closingRow = makeLine({ + id: "template-close", + content: "closed`;", + change: "add", + oldLineNumber: null, + newLineNumber: 2, + }); + const trailingRow = makeLine({ + id: "trailing-row", + content: "export const answer = 42;", + change: "add", + oldLineNumber: null, + newLineNumber: 3, + }); + const commentRow: NativeReviewDiffRow = { + kind: "comment", + id: "comment-1", + fileId: TYPESCRIPT_FILE.id, + commentText: "Review note", + }; + + const [withComment, contiguous] = await Promise.all([ + highlight([openingRow, commentRow, closingRow, trailingRow]), + highlight([openingRow, closingRow, trailingRow]), + ]); + + expect(withComment.tokensByRowId).toEqual(contiguous.tokensByRowId); + }); + + it("does not join unhighlighted rows across cached gaps", async () => { + const trailingRow = makeLine({ + id: "trailing-row", + content: "export const answer = 42;", + change: "add", + oldLineNumber: null, + newLineNumber: 3, + }); + const rows = [ + makeLine({ + id: "template-open", + content: "const message = `open", + change: "add", + oldLineNumber: null, + newLineNumber: 1, + }), + makeLine({ + id: "template-close", + content: "closed`;", + change: "add", + oldLineNumber: null, + newLineNumber: 2, + }), + trailingRow, + ]; + + const [highlighted, standalone] = await Promise.all([ + highlight(rows, new Set(["template-close"])), + highlight([trailingRow]), + ]); + + expect(highlighted.tokensByRowId[trailingRow.id]).toEqual( + standalone.tokensByRowId[trailingRow.id], + ); + }); + + it("keeps deletion grammar state out of addition rows", async () => { + const additionRow = makeLine({ + id: "addition-row", + content: "export const answer = 42;", + change: "add", + oldLineNumber: null, + newLineNumber: 1, + }); + const rows = [ + makeLine({ + id: "deletion-row", + content: "const removed = `open", + change: "delete", + oldLineNumber: 1, + newLineNumber: null, + }), + additionRow, + ]; + + const [highlighted, standalone] = await Promise.all([ + highlight(rows), + highlight([additionRow]), + ]); + + expect(highlighted.tokensByRowId[additionRow.id]).toEqual( + standalone.tokensByRowId[additionRow.id], + ); + }); +}); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts index 6c8c957f541..14158e61c7d 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts @@ -56,6 +56,11 @@ interface NativeReviewDiffLineRow extends NativeReviewDiffRow { readonly content: string; } +interface IndexedNativeReviewDiffLineRow { + readonly row: NativeReviewDiffLineRow; + readonly rowIndex: number; +} + export interface NativeReviewDiffTokenChunk { readonly chunkIndex: number; readonly fileId: string; @@ -308,6 +313,58 @@ function isHighlightableLineRow(row: NativeReviewDiffRow): row is NativeReviewDi return row.kind === "line" && typeof row.fileId === "string" && typeof row.content === "string"; } +function hasConsecutiveLineNumbers( + previous: number | null | undefined, + next: number | null | undefined, +): boolean { + return typeof previous === "number" && typeof next === "number" && next === previous + 1; +} + +function hasOnlyCommentRowsBetween( + rows: ReadonlyArray, + previousRowIndex: number, + nextRowIndex: number, +): boolean { + for (let rowIndex = previousRowIndex + 1; rowIndex < nextRowIndex; rowIndex += 1) { + if (rows[rowIndex]?.kind !== "comment") { + return false; + } + } + return true; +} + +function canShareGrammarContext( + previous: IndexedNativeReviewDiffLineRow, + next: IndexedNativeReviewDiffLineRow, + rows: ReadonlyArray, +): boolean { + if ( + next.row.fileId !== previous.row.fileId || + !hasOnlyCommentRowsBetween(rows, previous.rowIndex, next.rowIndex) + ) { + return false; + } + + if (previous.row.change === "delete" || next.row.change === "delete") { + return ( + previous.row.change !== "add" && + next.row.change !== "add" && + hasConsecutiveLineNumbers(previous.row.oldLineNumber, next.row.oldLineNumber) + ); + } + + if (previous.row.change === "add" || next.row.change === "add") { + return hasConsecutiveLineNumbers(previous.row.newLineNumber, next.row.newLineNumber); + } + + return ( + previous.row.change === "context" && + next.row.change === "context" && + hasConsecutiveLineNumbers(previous.row.oldLineNumber, next.row.oldLineNumber) && + hasConsecutiveLineNumbers(previous.row.newLineNumber, next.row.newLineNumber) + ); +} + function groupLineRowsByFileId(rows: ReadonlyArray) { const rowsByFileId = new Map(); for (const row of rows) { @@ -360,7 +417,7 @@ export async function highlightNativeReviewDiffVisibleRows( const maxRows = input.maxRows ?? NATIVE_REVIEW_DIFF_VISIBLE_MAX_ROWS; const startIndex = clampRowIndex(input.firstRowIndex - overscanRows, input.rows); const endIndex = clampRowIndex(input.lastRowIndex + overscanRows, input.rows); - const selectedRows: NativeReviewDiffLineRow[] = []; + const selectedRows: IndexedNativeReviewDiffLineRow[] = []; for ( let rowIndex = startIndex; @@ -374,12 +431,12 @@ export async function highlightNativeReviewDiffVisibleRows( !input.alreadyHighlightedRowIds?.has(row.id) && fileMap.has(row.fileId) ) { - selectedRows.push(row); + selectedRows.push({ row, rowIndex }); } } const tokensByRowId: Record> = {}; - let segmentRows: NativeReviewDiffLineRow[] = []; + let segmentRows: IndexedNativeReviewDiffLineRow[] = []; let segmentFile: NativeReviewDiffFile | undefined; const flushSegment = () => { @@ -389,27 +446,34 @@ export async function highlightNativeReviewDiffVisibleRows( return; } - const code = segmentRows.map((row) => row.content).join("\n"); + const code = segmentRows.map(({ row }) => row.content).join("\n"); const tokenLines = highlighter.tokenize(code, { lang: segmentFile.language, theme }); - segmentRows.forEach((row, rowIndex) => { + segmentRows.forEach(({ row }, rowIndex) => { tokensByRowId[row.id] = tokenLines[rowIndex] ?? makePlainTokenFallback(row); }); segmentRows = []; segmentFile = undefined; }; - for (const row of selectedRows) { + for (const selectedRow of selectedRows) { + const { row } = selectedRow; const file = fileMap.get(row.fileId); if (!file) { continue; } - if (segmentFile && segmentFile.id !== file.id) { + const previousRow = segmentRows.at(-1); + if ( + segmentFile && + (segmentFile.id !== file.id || + (previousRow !== undefined && + !canShareGrammarContext(previousRow, selectedRow, input.rows))) + ) { flushSegment(); } segmentFile = file; - segmentRows.push(row); + segmentRows.push(selectedRow); } flushSegment(); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts index 0409655583b..438b50a27ee 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts @@ -96,5 +96,22 @@ describe("isPendingNativeViewRegistration", () => { new Error("Unable to find the 'T3ReviewDiffView' view for this native tag"), ), ).toBe(false); + expect( + isPendingNativeViewRegistration( + new Error( + "Unable to find the class expo.modules.t3reviewdiff.T3ReviewDiffView view with tag 1150", + ), + ), + ).toBe(true); + }); +}); + +describe("isNativeReviewDiffDrawEvent", () => { + it("accepts only native events emitted after diff rows draw", async () => { + const { isNativeReviewDiffDrawEvent } = await import("./nativeReviewDiffSurface"); + + expect(isNativeReviewDiffDrawEvent({ message: "draw-metrics" })).toBe(true); + expect(isNativeReviewDiffDrawEvent({ message: "visible-range" })).toBe(true); + expect(isNativeReviewDiffDrawEvent({ message: "rows-decoded" })).toBe(false); }); }); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts index 551e577b294..fda73410668 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts @@ -149,6 +149,10 @@ export interface NativeReviewDiffViewHandle { readonly scrollToTop: (animated?: boolean) => Promise; } +export function isNativeReviewDiffDrawEvent(payload: Readonly>): boolean { + return payload.message === "draw-metrics" || payload.message === "visible-range"; +} + interface NativeReviewDiffViewRef { readonly setRowsJson: (rowsJson: string) => Promise; readonly setTokensJson: (tokensJson: string) => Promise; @@ -170,9 +174,11 @@ let nativeReviewDiffViewResolutionFailed = false; type NativeReviewDiffPayloadMethod = "setRowsJson" | "setTokensJson" | "setTokensPatchJson"; export function isPendingNativeViewRegistration(error: unknown): boolean { + if (!(error instanceof Error)) return false; return ( - error instanceof Error && - error.message.includes(`Unable to find the '${NATIVE_REVIEW_DIFF_MODULE_NAME}' view`) + error.message.includes(`Unable to find the '${NATIVE_REVIEW_DIFF_MODULE_NAME}' view`) || + (error.message.includes("Unable to find the class") && + error.message.includes("T3ReviewDiffView view with tag")) ); } diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 0807304631d..456afc438d9 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -167,7 +167,6 @@ export function HomeScreen(props: HomeScreenProps) { const listRef = useRef(null); const insets = useSafeAreaInsets(); const accentColor = useThemeColor("--color-icon-muted"); - const effectiveGroupDisplayStates = useMemo(() => { const next = new Map(groupDisplayStates); if (!AsyncResult.isSuccess(preferencesResult)) { diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index cafc0326d2f..1ebb3aaf7b1 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -56,6 +56,7 @@ import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { ThreadGitMenu } from "../threads/ThreadGitControls"; import { useReviewCacheForThread } from "./reviewState"; import { + isNativeReviewDiffDrawEvent, type NativeReviewDiffViewHandle, resolveNativeReviewDiffView, } from "../diffs/nativeReviewDiffSurface"; @@ -71,8 +72,10 @@ import { resolveReviewAvailability } from "./reviewAvailability"; import { resolveSelectedReviewFileId } from "./reviewPaneSelection"; import { buildReviewSectionMenu } from "./review-section-menu"; import type { ReviewSectionItem } from "./reviewModel"; +import { markNativeShowcaseReady } from "../showcase/nativeShowcaseScene"; const REVIEW_HEADER_SPACING = 0; +const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { return ( @@ -394,6 +397,7 @@ export function ReviewSheet(props: ReviewSheetProps) { }); const NativeReviewDiffView = resolveNativeReviewDiffView()!; const nativeReviewDiffViewRef = useRef(null); + const showcasedReviewDrawRef = useRef(null); // Native pull-to-refresh on the diff surface (replaces the old Refresh menu item). const [isPullRefreshing, setIsPullRefreshing] = useState(false); const handlePullToRefresh = useCallback(async () => { @@ -435,6 +439,25 @@ export function ReviewSheet(props: ReviewSheetProps) { selectedRowIds: commentSelection.selectedRowIds, canHighlight: parsedDiff.kind === "files", }); + const showcaseReviewKey = + SHOWCASE_ENABLED && parsedDiff.kind === "files" && selectedSection + ? `${reviewCache.threadKey}:${selectedSection.id}:${nativeBridge.tokensResetKey}` + : null; + const handleNativeDebug = useCallback( + (event: NativeSyntheticEvent>) => { + nativeBridge.onDebug(event); + if ( + showcaseReviewKey === null || + showcasedReviewDrawRef.current === showcaseReviewKey || + !isNativeReviewDiffDrawEvent(event.nativeEvent) + ) { + return; + } + showcasedReviewDrawRef.current = showcaseReviewKey; + markNativeShowcaseReady("review"); + }, + [nativeBridge.onDebug, showcaseReviewKey], + ); const handleSelectFile = useCallback( (fileId: string | null) => { @@ -788,7 +811,7 @@ export function ReviewSheet(props: ReviewSheetProps) { tokensPatchJson={nativeBridge.tokensPatchJson} tokensResetKey={nativeBridge.tokensResetKey} viewedFileIdsJson={nativeBridge.viewedFileIdsJson} - onDebug={nativeBridge.onDebug} + onDebug={handleNativeDebug} onPressLine={commentSelection.onPressLine} onVisibleFileChange={handleVisibleFileChange} onToggleComment={nativeBridge.onToggleComment} diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index ccf049e0c21..93b806f6487 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -2,7 +2,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac import { useNavigation } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import type { EnvironmentId } from "@t3tools/contracts"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -15,6 +15,15 @@ import { splitEnvironmentSections } from "../connection/environmentSections"; import { cn } from "../../lib/cn"; import { useThemeColor } from "../../lib/useThemeColor"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; +import { + applyShowcaseLocalEnvironmentDisplayUrls, + resolveShowcaseEnvironmentUpdateDisplayUrl, + SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS, + SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS, +} from "../showcase/showcaseEnvironmentRows"; +import { markNativeShowcaseReady } from "../showcase/nativeShowcaseScene"; + +const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; export function SettingsEnvironmentsRouteScreen() { const { @@ -25,18 +34,56 @@ export function SettingsEnvironmentsRouteScreen() { } = useRemoteConnections(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const { localEnvironments, connectedCloudEnvironments } = splitEnvironmentSections({ + const environmentSections = splitEnvironmentSections({ connectedEnvironments, cloudEnvironments: null, }); + const localEnvironments = SHOWCASE_ENABLED + ? applyShowcaseLocalEnvironmentDisplayUrls(environmentSections.localEnvironments) + : environmentSections.localEnvironments; + const connectedCloudEnvironments = SHOWCASE_ENABLED + ? SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS + : environmentSections.connectedCloudEnvironments; const hasLocalEnvironments = localEnvironments.length > 0; const [expandedId, setExpandedId] = useState(null); const accentColor = useThemeColor("--color-icon-muted"); const headerIconColor = useThemeColor("--color-icon"); + useEffect(() => { + if (!SHOWCASE_ENABLED) return; + const timer = setTimeout(() => markNativeShowcaseReady("environments"), 500); + return () => clearTimeout(timer); + }, []); + const handleToggle = useCallback((environmentId: EnvironmentId) => { setExpandedId((prev) => (prev === environmentId ? null : environmentId)); }, []); + const handleUpdateEnvironment = useCallback( + ( + environmentId: EnvironmentId, + updates: { readonly label: string; readonly displayUrl: string }, + ) => { + if (!SHOWCASE_ENABLED) return onUpdateEnvironment(environmentId, updates); + const actualEnvironment = environmentSections.localEnvironments.find( + (environment) => environment.environmentId === environmentId, + ); + const presentedEnvironment = localEnvironments.find( + (environment) => environment.environmentId === environmentId, + ); + return onUpdateEnvironment(environmentId, { + ...updates, + displayUrl: + actualEnvironment && presentedEnvironment + ? resolveShowcaseEnvironmentUpdateDisplayUrl({ + actualDisplayUrl: actualEnvironment.displayUrl, + presentedDisplayUrl: presentedEnvironment.displayUrl, + submittedDisplayUrl: updates.displayUrl, + }) + : updates.displayUrl, + }); + }, + [environmentSections.localEnvironments, localEnvironments, onUpdateEnvironment], + ); return ( @@ -92,7 +139,7 @@ export function SettingsEnvironmentsRouteScreen() { onToggle={() => handleToggle(environment.environmentId)} onReconnect={onReconnectEnvironment} onRemove={onRemoveEnvironmentPress} - onUpdate={onUpdateEnvironment} + onUpdate={handleUpdateEnvironment} /> ))} @@ -114,10 +161,16 @@ export function SettingsEnvironmentsRouteScreen() { )} - {hasCloudPublicConfig() ? ( + {hasCloudPublicConfig() || SHOWCASE_ENABLED ? ( ) : null} diff --git a/apps/mobile/src/features/settings/SettingsLegalRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsLegalRouteScreen.tsx new file mode 100644 index 00000000000..7254186cf4c --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsLegalRouteScreen.tsx @@ -0,0 +1,6 @@ +import { SettingsLegalDocumentRouteScreen } from "./components/SettingsLegalDocumentRouteScreen"; +import { LEGAL_URL } from "./lib/legal-document-url"; + +export function SettingsLegalRouteScreen() { + return ; +} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 6f87eeeba84..6c67a4d89e8 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -537,6 +537,7 @@ function AppSettingsSection() { return ( + navigation.goBack()} + className="p-2 active:opacity-60" + > + + + ); +} + +export function SettingsLegalDocumentExternalHeaderButton({ + externalUrl = LEGAL_URL, +}: { + readonly externalUrl?: string; +}) { + const iconColor = useThemeColor("--color-icon"); + const safeExternalUrl = isLegalDocumentUrl(externalUrl) ? externalUrl : LEGAL_URL; + + return ( + void Linking.openURL(safeExternalUrl).catch(() => undefined)} + className="p-2 active:opacity-60" + > + + + ); +} + +interface SettingsLegalDocumentRouteScreenProps { + readonly documentName: string; + readonly documentUrl: string; +} + +export function SettingsLegalDocumentRouteScreen({ + documentName, + documentUrl, +}: SettingsLegalDocumentRouteScreenProps) { + const navigation = useNavigation>(); + const iconColor = useThemeColor("--color-icon"); + const [reloadKey, setReloadKey] = useState(0); + const [loadProgress, setLoadProgress] = useState(0); + const [loadError, setLoadError] = useState(null); + const [externalUrl, setExternalUrl] = useState(documentUrl); + const renderExternalHeaderButton = useCallback( + () => , + [externalUrl], + ); + + useLayoutEffect(() => { + navigation.setOptions({ + headerRight: renderExternalHeaderButton, + }); + }, [navigation, renderExternalHeaderButton]); + + const openExternalUrl = useCallback((url: string) => { + void Linking.openURL(url).catch(() => undefined); + }, []); + if (loadError) { + return ( + + + + + Couldn't load the {documentName.toLowerCase()} + + + {loadError} + + + + { + setLoadError(null); + setReloadKey((value) => value + 1); + }} + className="items-center rounded-xl bg-foreground px-4 py-3 active:opacity-80" + > + Try Again + + openExternalUrl(documentUrl)} + className="items-center rounded-xl px-4 py-3 active:bg-foreground/5" + > + Open in Browser + + + + ); + } + + return ( + + {loadProgress > 0 && loadProgress < 1 ? : null} + { + if (isLegalDocumentUrl(request.url)) return true; + + openExternalUrl(request.url); + return false; + }} + onLoadProgress={(event) => { + setLoadProgress(event.nativeEvent.progress); + }} + onLoadStart={() => { + setLoadProgress(0.05); + setLoadError(null); + }} + onLoadEnd={(event) => { + if (isLegalDocumentUrl(event.nativeEvent.url)) { + setExternalUrl(event.nativeEvent.url); + } + setLoadProgress(0); + }} + onError={(event) => { + setLoadProgress(0); + setLoadError(event.nativeEvent.description || "The page could not be loaded."); + }} + onHttpError={(event) => { + if (!isLegalDocumentUrl(event.nativeEvent.url)) return; + setLoadProgress(0); + setLoadError(`The server returned status ${event.nativeEvent.statusCode}.`); + }} + renderLoading={() => ( + + + + )} + style={{ flex: 1, backgroundColor: "transparent" }} + /> + + ); +} diff --git a/apps/mobile/src/features/settings/components/SettingsRow.tsx b/apps/mobile/src/features/settings/components/SettingsRow.tsx index 7c3d9144e1a..2f435c3a47f 100644 --- a/apps/mobile/src/features/settings/components/SettingsRow.tsx +++ b/apps/mobile/src/features/settings/components/SettingsRow.tsx @@ -6,7 +6,7 @@ import { SymbolView } from "../../../components/AppSymbol"; import { AppText as Text } from "../../../components/AppText"; import { useThemeColor } from "../../../lib/useThemeColor"; -import type { SettingsSheetTarget } from "./settings-sheet-targets"; +import type { SettingsLegalDocumentTarget, SettingsSheetTarget } from "./settings-sheet-targets"; type SymbolName = ComponentProps["name"]; @@ -16,6 +16,7 @@ export function SettingsRow(props: { readonly label: string; readonly value?: string; readonly target?: SettingsSheetTarget; + readonly fullScreenTarget?: SettingsLegalDocumentTarget; readonly onPress?: () => void; }) { const navigation = useNavigation(); @@ -72,6 +73,20 @@ export function SettingsRow(props: { ); } + const fullScreenTarget = props.fullScreenTarget; + if (fullScreenTarget) { + return ( + navigation.navigate(fullScreenTarget)} + > + {content} + + ); + } + return ( {content} diff --git a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts index 677dd5e4ed7..71c059bedb4 100644 --- a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts +++ b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts @@ -3,3 +3,5 @@ export type SettingsSheetTarget = | "SettingsArchive" | "SettingsAppearance" | "SettingsClientStorage"; + +export type SettingsLegalDocumentTarget = "SettingsLegal"; diff --git a/apps/mobile/src/features/settings/lib/legal-document-url.test.ts b/apps/mobile/src/features/settings/lib/legal-document-url.test.ts new file mode 100644 index 00000000000..10ce4ca4879 --- /dev/null +++ b/apps/mobile/src/features/settings/lib/legal-document-url.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isLegalDocumentUrl } from "./legal-document-url"; + +describe("isLegalDocumentUrl", () => { + it.each([ + "https://t3.codes/legal", + "https://t3.codes/legal/", + "https://t3.codes/privacy-policy?source=app", + "https://t3.codes/terms-of-service#updates", + "https://t3.codes/security-policy", + ])("allows a configured legal document: %s", (url) => { + expect(isLegalDocumentUrl(url)).toBe(true); + }); + + it.each([ + "https://t3.codes/download", + "https://example.com/legal", + "javascript:alert(1)", + "not-a-url", + ])("rejects a URL outside the legal-document allowlist: %s", (url) => { + expect(isLegalDocumentUrl(url)).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/settings/lib/legal-document-url.ts b/apps/mobile/src/features/settings/lib/legal-document-url.ts new file mode 100644 index 00000000000..4556375615d --- /dev/null +++ b/apps/mobile/src/features/settings/lib/legal-document-url.ts @@ -0,0 +1,58 @@ +const DEFAULT_MARKETING_SITE_URL = "https://t3.codes"; + +function resolveMarketingSiteUrl(override: string | undefined): URL { + try { + const url = new URL(override?.trim() || DEFAULT_MARKETING_SITE_URL); + if (url.protocol !== "https:" && url.protocol !== "http:") { + return new URL(DEFAULT_MARKETING_SITE_URL); + } + + url.search = ""; + url.hash = ""; + url.pathname = `${url.pathname.replace(/\/+$/, "")}/`; + return url; + } catch { + return new URL(DEFAULT_MARKETING_SITE_URL); + } +} + +const MARKETING_SITE_URL = resolveMarketingSiteUrl(process.env.EXPO_PUBLIC_MARKETING_SITE_URL); + +function marketingSiteDocumentUrl(path: string): string { + return new URL(path, MARKETING_SITE_URL).toString(); +} + +export const PRIVACY_POLICY_URL = marketingSiteDocumentUrl("privacy-policy"); +export const SECURITY_POLICY_URL = marketingSiteDocumentUrl("security-policy"); +export const TERMS_OF_SERVICE_URL = marketingSiteDocumentUrl("terms-of-service"); +export const LEGAL_URL = marketingSiteDocumentUrl("legal"); + +export const ALLOWED_LEGAL_DOCUMENT_URLS = [ + LEGAL_URL, + PRIVACY_POLICY_URL, + TERMS_OF_SERVICE_URL, + SECURITY_POLICY_URL, +] as const; + +function webDocumentIdentity(value: string): string | null { + try { + const url = new URL(value); + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + + const pathname = url.pathname.replace(/\/+$/, "") || "/"; + return `${url.origin}${pathname}`; + } catch { + return null; + } +} + +const ALLOWED_LEGAL_DOCUMENT_IDENTITIES = new Set( + ALLOWED_LEGAL_DOCUMENT_URLS.map(webDocumentIdentity).filter( + (value): value is string => value !== null, + ), +); + +export function isLegalDocumentUrl(value: string): boolean { + const identity = webDocumentIdentity(value); + return identity !== null && ALLOWED_LEGAL_DOCUMENT_IDENTITIES.has(identity); +} diff --git a/apps/mobile/src/features/sharing/IncomingShareProvider.tsx b/apps/mobile/src/features/sharing/IncomingShareProvider.tsx new file mode 100644 index 00000000000..04d371e5d2f --- /dev/null +++ b/apps/mobile/src/features/sharing/IncomingShareProvider.tsx @@ -0,0 +1,310 @@ +import Constants from "expo-constants"; +import * as Crypto from "expo-crypto"; +import { + clearSharedPayloads, + getResolvedSharedPayloadsAsync, + getSharedPayloads, + type ResolvedSharePayload, + type SharePayload, +} from "expo-sharing"; +import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; +import { Alert, AppState, Platform } from "react-native"; + +import { + buildIncomingShareDraft, + type IncomingShareDestination, + type IncomingShareDraft, +} from "./incoming-share-model"; +import { IncomingShareInbox } from "./incoming-share-inbox"; +import { + loadIncomingShareDrafts, + removeIncomingShareDraft, + writeIncomingShareDraft, +} from "./incoming-share-storage"; + +type IncomingShareContextValue = { + readonly pendingShare: IncomingShareDraft | null; + readonly isLoading: boolean; + readonly error: Error | null; + readonly getShare: (shareId: string) => IncomingShareDraft | null; + readonly reserveShare: (shareId: string, destination: IncomingShareDestination) => Promise; + readonly releaseShareReservation: ( + shareId: string, + expectedDestination: IncomingShareDestination, + ) => Promise; + readonly consumeShare: (shareId: string) => Promise; + readonly refresh: () => Promise; +}; + +const IncomingShareContext = React.createContext(null); + +function receiveSharingEnabled(): boolean { + if (Platform.OS === "android") { + return true; + } + if (Platform.OS !== "ios") { + return false; + } + return Constants.expoConfig?.extra?.iosPersonalTeamBuild !== true; +} + +async function resolvedPayloadsForImages(): Promise> { + try { + return await getResolvedSharedPayloadsAsync(); + } catch (error) { + // iOS already gives the containing app a copied file:// URL, so raw + // payloads remain usable. Android normally resolves content:// into a + // private cache file; its modern File API can still read the raw URI when + // resolution fails. + console.warn("[incoming-share] could not resolve shared file metadata", error); + return []; + } +} + +async function incomingShareIdForPayloads(payloads: ReadonlyArray): Promise { + const fingerprint = JSON.stringify( + payloads.map((payload) => ({ + shareType: payload.shareType, + mimeType: payload.mimeType ?? null, + value: payload.value, + })), + ); + const digest = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, fingerprint); + return `share-${digest}`; +} + +async function readBase64(uri: string): Promise { + const { File } = await import("expo-file-system"); + return new File(uri).base64(); +} + +async function removeOwnedFile(uri: string): Promise { + if (!uri.startsWith("file:")) { + return; + } + try { + const { File } = await import("expo-file-system"); + const file = new File(uri); + if (file.exists) { + file.delete(); + } + } catch (error) { + console.warn("[incoming-share] could not remove temporary shared file", error); + } +} + +async function removeReplayedImagePayloadFiles( + payloads: ReadonlyArray, +): Promise { + const uris = new Set(); + for (const payload of payloads) { + if (payload.shareType === "image") { + uris.add(payload.value); + } + } + if (uris.size === 0) { + return; + } + const resolvedPayloads = await resolvedPayloadsForImages(); + for (const payload of resolvedPayloads) { + if (payload.shareType === "image" && payload.contentUri) { + uris.add(payload.contentUri); + } + } + await Promise.all([...uris].map(removeOwnedFile)); +} + +// Keep one operation queue across provider remounts (including development +// Strict Mode remounts) so two app lifecycle notifications cannot ingest the +// same native handoff independently. +const incomingShareInbox = new IncomingShareInbox({ + loadDrafts: loadIncomingShareDrafts, + writeDraft: writeIncomingShareDraft, + removeDraft: removeIncomingShareDraft, + getPayloads: getSharedPayloads, + clearPayloads: clearSharedPayloads, + buildDraft: async ({ payloads, id, createdAt }) => { + const cleanupUris = new Set(); + const resolvedPayloads = payloads.some((payload) => payload.shareType === "image") + ? await resolvedPayloadsForImages() + : []; + const draft = await buildIncomingShareDraft({ + payloads, + resolvedPayloads, + fileReader: { + readBase64, + removeOwnedFile: (uri) => { + cleanupUris.add(uri); + }, + }, + id, + createdAt, + }); + return { + draft, + cleanup: async () => { + await Promise.all([...cleanupUris].map(removeOwnedFile)); + }, + }; + }, + cleanupReplayedPayloads: removeReplayedImagePayloadFiles, + idForPayloads: incomingShareIdForPayloads, + now: () => new Date().toISOString(), + onClearError: (error) => { + console.warn("[incoming-share] could not acknowledge native payload", error); + }, + onCleanupError: (error) => { + console.warn("[incoming-share] could not remove temporary shared file", error); + }, +}); + +export function IncomingShareProvider(props: React.PropsWithChildren) { + const enabled = receiveSharingEnabled(); + const [drafts, setDrafts] = useState>([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const refreshPromiseRef = useRef | null>(null); + const mountedRef = useRef(false); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const refresh = useCallback(async () => { + if (refreshPromiseRef.current) { + return refreshPromiseRef.current; + } + + const operation = (async () => { + try { + const snapshot = await incomingShareInbox.refresh({ ingestNative: enabled }); + if (mountedRef.current) { + setDrafts(snapshot); + setError(null); + } + } catch (cause) { + const persisted = await incomingShareInbox + .refresh({ ingestNative: false }) + .catch(() => null); + if (mountedRef.current) { + if (persisted) { + setDrafts(persisted); + } + setError(cause instanceof Error ? cause : new Error("Could not import shared content.")); + } + } + })().finally(() => { + refreshPromiseRef.current = null; + }); + + refreshPromiseRef.current = operation; + return operation; + }, [enabled]); + + useEffect(() => { + void refresh().finally(() => { + if (mountedRef.current) { + setIsLoading(false); + } + }); + }, [refresh]); + + const refreshOnAppActive = useEffectEvent(() => { + void refresh(); + }); + + useEffect(() => { + if (!enabled) { + return; + } + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") { + refreshOnAppActive(); + } + }); + return () => subscription.remove(); + }, [enabled]); + + useEffect(() => { + if (!error) { + return; + } + Alert.alert("Could not import shared content", error.message, [ + { text: "Dismiss", style: "cancel", onPress: () => setError(null) }, + { + text: "Retry", + onPress: () => { + setError(null); + void refresh(); + }, + }, + ]); + }, [error, refresh]); + + const consumeShare = useCallback(async (shareId: string) => { + const snapshot = await incomingShareInbox.consume(shareId); + if (mountedRef.current) { + setDrafts(snapshot); + } + }, []); + const reserveShare = useCallback( + async (shareId: string, destination: IncomingShareDestination) => { + const snapshot = await incomingShareInbox.reserve(shareId, destination); + if (mountedRef.current) { + setDrafts(snapshot); + } + }, + [], + ); + const releaseShareReservation = useCallback( + async (shareId: string, expectedDestination: IncomingShareDestination) => { + const snapshot = await incomingShareInbox.releaseReservation(shareId, expectedDestination); + if (mountedRef.current) { + setDrafts(snapshot); + } + }, + [], + ); + const getShare = useCallback( + (shareId: string) => drafts.find((draft) => draft.id === shareId) ?? null, + [drafts], + ); + + const value = useMemo( + () => ({ + pendingShare: drafts[0] ?? null, + isLoading, + error, + getShare, + releaseShareReservation, + reserveShare, + consumeShare, + refresh, + }), + [ + consumeShare, + drafts, + error, + getShare, + isLoading, + refresh, + releaseShareReservation, + reserveShare, + ], + ); + + return ( + {props.children} + ); +} + +export function useIncomingShare(): IncomingShareContextValue { + const value = React.use(IncomingShareContext); + if (value === null) { + throw new Error("useIncomingShare must be used within IncomingShareProvider."); + } + return value; +} diff --git a/apps/mobile/src/features/sharing/incoming-share-inbox.test.ts b/apps/mobile/src/features/sharing/incoming-share-inbox.test.ts new file mode 100644 index 00000000000..ff50c6a917a --- /dev/null +++ b/apps/mobile/src/features/sharing/incoming-share-inbox.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import type { SharePayload } from "expo-sharing"; + +import type { IncomingShareDraft } from "./incoming-share-model"; +import { IncomingShareInbox, type IncomingShareInboxDependencies } from "./incoming-share-inbox"; + +const PAYLOAD: SharePayload = { + shareType: "text", + mimeType: "text/plain", + value: "Fix this", +}; + +function draft(id: string, createdAt = "2026-07-16T08:00:00.000Z"): IncomingShareDraft { + return { + schemaVersion: 1, + id, + createdAt, + text: PAYLOAD.value, + attachments: [], + warnings: [], + }; +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +function createHarness(overrides: Partial = {}) { + const persisted = new Map(); + let payloads: ReadonlyArray = [PAYLOAD]; + const dependencies: IncomingShareInboxDependencies = { + loadDrafts: async () => [...persisted.values()], + writeDraft: async (value) => { + persisted.set(value.id, value); + }, + removeDraft: async (shareId) => { + persisted.delete(shareId); + }, + getPayloads: () => payloads, + clearPayloads: () => { + payloads = []; + }, + buildDraft: async ({ id, createdAt }) => ({ + draft: draft(id, createdAt), + cleanup: async () => undefined, + }), + idForPayloads: async () => "share-stable", + now: () => "2026-07-16T08:00:00.000Z", + ...overrides, + }; + return { inbox: new IncomingShareInbox(dependencies), persisted }; +} + +describe("IncomingShareInbox", () => { + it("coalesces a replay of an already-persisted native handoff", async () => { + const buildDraft = vi.fn(async ({ id, createdAt }) => ({ + draft: draft(id, createdAt), + cleanup: async () => undefined, + })); + const cleanupReplayedPayloads = vi.fn(async () => undefined); + const { inbox, persisted } = createHarness({ buildDraft, cleanupReplayedPayloads }); + persisted.set("share-stable", draft("share-stable")); + + await expect(inbox.refresh({ ingestNative: true })).resolves.toEqual([draft("share-stable")]); + expect(buildDraft).not.toHaveBeenCalled(); + expect(cleanupReplayedPayloads).toHaveBeenCalledWith([PAYLOAD]); + }); + + it("serializes concurrent refreshes so one native payload creates one inbox item", async () => { + const building = deferred(); + const buildDraft = vi.fn(async ({ id, createdAt }) => { + await building.promise; + return { draft: draft(id, createdAt), cleanup: async () => undefined }; + }); + const { inbox } = createHarness({ buildDraft }); + + const first = inbox.refresh({ ingestNative: true }); + const second = inbox.refresh({ ingestNative: true }); + building.resolve(); + + await expect(Promise.all([first, second])).resolves.toEqual([ + [draft("share-stable")], + [draft("share-stable")], + ]); + expect(buildDraft).toHaveBeenCalledTimes(1); + }); + + it("orders consumption after an in-flight refresh without restoring stale state", async () => { + const building = deferred(); + const { inbox, persisted } = createHarness({ + buildDraft: async ({ id, createdAt }) => { + await building.promise; + return { draft: draft(id, createdAt), cleanup: async () => undefined }; + }, + }); + + const refresh = inbox.refresh({ ingestNative: true }); + const consume = inbox.consume("share-stable"); + building.resolve(); + + await expect(refresh).resolves.toEqual([draft("share-stable")]); + await expect(consume).resolves.toEqual([]); + expect([...persisted.values()]).toEqual([]); + }); + + it("consumes only the addressed share when another share has identical content", async () => { + const { inbox, persisted } = createHarness(); + persisted.set("share-first", draft("share-first", "2026-07-16T07:59:00.000Z")); + persisted.set("share-second", draft("share-second")); + + await expect(inbox.refresh({ ingestNative: false })).resolves.toEqual([ + draft("share-second"), + draft("share-first", "2026-07-16T07:59:00.000Z"), + ]); + await expect(inbox.consume("share-second")).resolves.toEqual([ + draft("share-first", "2026-07-16T07:59:00.000Z"), + ]); + expect([...persisted.values()]).toEqual([draft("share-first", "2026-07-16T07:59:00.000Z")]); + }); + + it("keeps content-identical shares addressable by their own ids", async () => { + const { inbox, persisted } = createHarness(); + persisted.set("share-open-flow", draft("share-open-flow", "2026-07-16T07:59:00.000Z")); + persisted.set("share-newer", draft("share-newer")); + + await expect(inbox.refresh({ ingestNative: false })).resolves.toEqual([ + draft("share-newer"), + draft("share-open-flow", "2026-07-16T07:59:00.000Z"), + ]); + }); + + it("does not acknowledge a supported payload when its durable write fails", async () => { + const clearPayloads = vi.fn(); + const cleanup = vi.fn(async () => undefined); + const { inbox } = createHarness({ + clearPayloads, + buildDraft: async ({ id, createdAt }) => ({ + draft: draft(id, createdAt), + cleanup, + }), + writeDraft: async () => { + throw new Error("disk full"); + }, + }); + + await expect(inbox.refresh({ ingestNative: true })).rejects.toThrow("disk full"); + expect(clearPayloads).not.toHaveBeenCalled(); + expect(cleanup).not.toHaveBeenCalled(); + }); + + it("durably reserves a share for one project before draft import", async () => { + const { inbox, persisted } = createHarness(); + persisted.set("share-stable", draft("share-stable")); + const destination = { environmentId: "environment-1", projectId: "project-1" }; + + await expect(inbox.reserve("share-stable", destination)).resolves.toEqual([ + { ...draft("share-stable"), destination }, + ]); + expect(persisted.get("share-stable")?.destination).toEqual(destination); + await expect(inbox.reserve("share-stable", destination)).resolves.toEqual([ + { ...draft("share-stable"), destination }, + ]); + }); + + it("rejects moving a reserved share to another project", async () => { + const { inbox, persisted } = createHarness(); + persisted.set("share-stable", { + ...draft("share-stable"), + destination: { environmentId: "environment-1", projectId: "project-1" }, + }); + + await expect( + inbox.reserve("share-stable", { + environmentId: "environment-1", + projectId: "project-2", + }), + ).rejects.toThrow("already reserved"); + expect(persisted.get("share-stable")?.destination?.projectId).toBe("project-1"); + }); + + it("conditionally releases a reservation when its project is unavailable", async () => { + const { inbox, persisted } = createHarness(); + const destination = { environmentId: "environment-1", projectId: "project-1" }; + persisted.set("share-stable", { ...draft("share-stable"), destination }); + + await expect(inbox.releaseReservation("share-stable", destination)).resolves.toEqual([ + draft("share-stable"), + ]); + expect(persisted.get("share-stable")?.destination).toBeUndefined(); + + await expect( + inbox.reserve("share-stable", { + environmentId: "environment-2", + projectId: "project-2", + }), + ).resolves.toEqual([ + { + ...draft("share-stable"), + destination: { environmentId: "environment-2", projectId: "project-2" }, + }, + ]); + }); + + it("does not release a reservation that changed concurrently", async () => { + const { inbox, persisted } = createHarness(); + persisted.set("share-stable", { + ...draft("share-stable"), + destination: { environmentId: "environment-2", projectId: "project-2" }, + }); + + await expect( + inbox.releaseReservation("share-stable", { + environmentId: "environment-1", + projectId: "project-1", + }), + ).rejects.toThrow("reservation changed"); + expect(persisted.get("share-stable")?.destination?.projectId).toBe("project-2"); + }); + + it("treats an already-removed share as an already-released reservation", async () => { + const { inbox, persisted } = createHarness(); + persisted.set("share-other", draft("share-other")); + + await expect( + inbox.releaseReservation("share-stable", { + environmentId: "environment-1", + projectId: "project-1", + }), + ).resolves.toEqual([draft("share-other")]); + expect([...persisted.values()]).toEqual([draft("share-other")]); + }); +}); diff --git a/apps/mobile/src/features/sharing/incoming-share-inbox.ts b/apps/mobile/src/features/sharing/incoming-share-inbox.ts new file mode 100644 index 00000000000..1f61ea710bb --- /dev/null +++ b/apps/mobile/src/features/sharing/incoming-share-inbox.ts @@ -0,0 +1,193 @@ +import type { SharePayload } from "expo-sharing"; + +import { SerializedAsyncQueue } from "../../lib/serialized-async-queue"; +import { + hasIncomingShareContent, + type IncomingShareDestination, + type IncomingShareDraft, +} from "./incoming-share-model"; + +export interface IncomingShareInboxDependencies { + readonly loadDrafts: () => Promise>; + readonly writeDraft: (draft: IncomingShareDraft) => Promise; + readonly removeDraft: (shareId: string) => Promise; + readonly getPayloads: () => ReadonlyArray; + readonly clearPayloads: () => void; + readonly buildDraft: (input: { + readonly payloads: ReadonlyArray; + readonly id: string; + readonly createdAt: string; + }) => Promise<{ + readonly draft: IncomingShareDraft; + readonly cleanup: () => Promise; + }>; + readonly cleanupReplayedPayloads?: (payloads: ReadonlyArray) => Promise; + readonly idForPayloads: (payloads: ReadonlyArray) => Promise; + readonly now: () => string; + readonly onClearError?: (error: unknown) => void; + readonly onCleanupError?: (error: unknown) => void; +} + +export function sortAndDedupeIncomingShares( + drafts: ReadonlyArray, +): ReadonlyArray { + const ids = new Set(); + return [...drafts] + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) + .filter((draft) => { + if (ids.has(draft.id)) { + return false; + } + ids.add(draft.id); + return true; + }); +} + +/** + * Serializes every durable inbox mutation. This prevents a stale storage load + * or a foreground refresh from restoring an item after it has been consumed. + */ +export class IncomingShareInbox { + private readonly operations = new SerializedAsyncQueue(); + + constructor(private readonly dependencies: IncomingShareInboxDependencies) {} + + private runExclusive(operation: () => Promise): Promise { + return this.operations.run(operation); + } + + private clearNativePayloads(): void { + try { + this.dependencies.clearPayloads(); + } catch (error) { + this.dependencies.onClearError?.(error); + } + } + + private async cleanup(operation: () => Promise): Promise { + try { + await operation(); + } catch (error) { + this.dependencies.onCleanupError?.(error); + } + } + + refresh(options: { readonly ingestNative: boolean }): Promise> { + return this.runExclusive(async () => { + const loaded = await this.dependencies.loadDrafts(); + const persisted = sortAndDedupeIncomingShares(loaded); + if (!options.ingestNative) { + return persisted; + } + + const payloads = this.dependencies.getPayloads(); + if (payloads.length === 0) { + return persisted; + } + + // A share extension payload remains available until the containing app + // acknowledges it. Use a content-derived id so a crash after the durable + // write but before acknowledgement reuses the same inbox item. + const shareId = await this.dependencies.idForPayloads(payloads); + if (loaded.some((draft) => draft.id === shareId)) { + if (this.dependencies.cleanupReplayedPayloads) { + await this.cleanup(() => this.dependencies.cleanupReplayedPayloads!(payloads)); + } + this.clearNativePayloads(); + return persisted; + } + + const built = await this.dependencies.buildDraft({ + payloads, + id: shareId, + createdAt: this.dependencies.now(), + }); + const { draft } = built; + if (!hasIncomingShareContent(draft)) { + // Unsupported native payloads cannot become actionable on retry and + // would otherwise reopen the project picker on every foreground. + await this.cleanup(built.cleanup); + this.clearNativePayloads(); + throw new Error( + draft.warnings[0] ?? "The shared content is not supported by the composer.", + ); + } + + // The durable inbox write is the transaction boundary. Never clear the + // native handoff first: a process termination must leave one recoverable + // copy on one side of the boundary. + await this.dependencies.writeDraft(draft); + await this.cleanup(built.cleanup); + this.clearNativePayloads(); + return sortAndDedupeIncomingShares([draft, ...persisted]); + }); + } + + consume(shareId: string): Promise> { + return this.runExclusive(async () => { + // The stable payload-derived id already coalesces retries of the same + // native handoff. Payload equality cannot identify duplicate handoffs: + // users may intentionally share identical content more than once. + await this.dependencies.removeDraft(shareId); + return sortAndDedupeIncomingShares(await this.dependencies.loadDrafts()); + }); + } + + reserve( + shareId: string, + destination: IncomingShareDestination, + ): Promise> { + return this.runExclusive(async () => { + const persisted = await this.dependencies.loadDrafts(); + const target = persisted.find((draft) => draft.id === shareId); + if (!target) { + throw new Error("The shared content is no longer available."); + } + if (target.destination) { + if ( + target.destination.environmentId !== destination.environmentId || + target.destination.projectId !== destination.projectId + ) { + throw new Error("The shared content is already reserved for another project draft."); + } + return sortAndDedupeIncomingShares(persisted); + } + + const reserved = { ...target, destination }; + await this.dependencies.writeDraft(reserved); + return sortAndDedupeIncomingShares( + persisted.map((draft) => (draft.id === shareId ? reserved : draft)), + ); + }); + } + + releaseReservation( + shareId: string, + expectedDestination: IncomingShareDestination, + ): Promise> { + return this.runExclusive(async () => { + const persisted = await this.dependencies.loadDrafts(); + const target = persisted.find((draft) => draft.id === shareId); + if (!target) { + // Conditional release is idempotent: if another operation already + // consumed the share, no reservation remains to clean up. + return sortAndDedupeIncomingShares(persisted); + } + if (!target.destination) { + return sortAndDedupeIncomingShares(persisted); + } + if ( + target.destination.environmentId !== expectedDestination.environmentId || + target.destination.projectId !== expectedDestination.projectId + ) { + throw new Error("The shared content reservation changed before it could be released."); + } + + const { destination: _destination, ...unreserved } = target; + await this.dependencies.writeDraft(unreserved); + return sortAndDedupeIncomingShares( + persisted.map((draft) => (draft.id === shareId ? unreserved : draft)), + ); + }); + } +} diff --git a/apps/mobile/src/features/sharing/incoming-share-model.test.ts b/apps/mobile/src/features/sharing/incoming-share-model.test.ts new file mode 100644 index 00000000000..07ede18b8ef --- /dev/null +++ b/apps/mobile/src/features/sharing/incoming-share-model.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import { + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, +} from "@t3tools/contracts"; +import type { ResolvedSharePayload, SharePayload } from "expo-sharing"; + +import { buildIncomingShareDraft, hasIncomingShareContent } from "./incoming-share-model"; + +describe("incoming native shares", () => { + it("converts shared text, URLs, and images into a durable composer draft", async () => { + const image: SharePayload = { + shareType: "image", + value: "file:///shared/Screenshot.png", + mimeType: "image/png", + }; + const payloads: SharePayload[] = [ + { shareType: "text", value: "Please explain this error" }, + { shareType: "url", value: "https://example.com/issue/1" }, + { shareType: "text", value: "Please explain this error" }, + image, + ]; + const resolvedImage: ResolvedSharePayload = { + ...image, + contentUri: image.value, + contentType: "image", + contentMimeType: "image/png", + contentSize: 3, + originalName: "Screenshot.png", + }; + const removeOwnedFile = vi.fn(() => Promise.resolve()); + + const result = await buildIncomingShareDraft({ + id: "share-1", + createdAt: "2026-07-15T10:00:00.000Z", + payloads, + resolvedPayloads: [resolvedImage], + fileReader: { + readBase64: async () => "YWJj", + removeOwnedFile, + }, + }); + + expect(result).toEqual({ + schemaVersion: 1, + id: "share-1", + createdAt: "2026-07-15T10:00:00.000Z", + text: "Please explain this error\n\nhttps://example.com/issue/1", + attachments: [ + { + id: "share-1:image:3", + type: "image", + name: "Screenshot.png", + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + previewUri: "data:image/png;base64,YWJj", + }, + ], + warnings: [], + }); + expect(removeOwnedFile).toHaveBeenCalledWith(image.value); + expect(hasIncomingShareContent(result)).toBe(true); + }); + + it("skips oversized images and releases the temporary native file", async () => { + const image: SharePayload = { + shareType: "image", + value: "file:///shared/huge.png", + mimeType: "image/png", + }; + const readBase64 = vi.fn(async () => "unused"); + const removeOwnedFile = vi.fn(() => Promise.resolve()); + + const result = await buildIncomingShareDraft({ + id: "share-2", + createdAt: "2026-07-15T10:00:00.000Z", + payloads: [image], + resolvedPayloads: [ + { + ...image, + contentUri: image.value, + contentType: "image", + contentMimeType: "image/png", + contentSize: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1, + originalName: "huge.png", + }, + ], + fileReader: { readBase64, removeOwnedFile }, + }); + + expect(result.attachments).toEqual([]); + expect(result.warnings).toEqual(["'huge.png' exceeds the 10 MB attachment limit."]); + expect(readBase64).not.toHaveBeenCalled(); + expect(removeOwnedFile).toHaveBeenCalledWith(image.value); + expect(hasIncomingShareContent(result)).toBe(false); + }); + + it("releases every temporary file when a share exceeds the attachment limit", async () => { + const payloads = Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS + 1 }, (_, index) => ({ + shareType: "image" as const, + value: `file:///shared/${index}.png`, + mimeType: "image/png", + })); + const removeOwnedFile = vi.fn(() => Promise.resolve()); + const readBase64 = vi.fn(async () => "YWJj"); + + const result = await buildIncomingShareDraft({ + id: "share-3", + createdAt: "2026-07-15T10:00:00.000Z", + payloads, + resolvedPayloads: [], + fileReader: { readBase64, removeOwnedFile }, + }); + + expect(result.attachments).toHaveLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS); + expect(result.warnings).toEqual([ + `Only the first ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} shared images were attached.`, + ]); + expect(readBase64).toHaveBeenCalledTimes(PROVIDER_SEND_TURN_MAX_ATTACHMENTS); + expect(removeOwnedFile).toHaveBeenCalledTimes(payloads.length); + }); + + it("maps duplicate image payloads to distinct resolved files", async () => { + const duplicate: SharePayload = { + shareType: "image", + value: "content://shared/screenshot", + mimeType: "image/png", + }; + const resolvedPayloads: ResolvedSharePayload[] = [ + { + ...duplicate, + contentUri: "file:///cache/first.png", + contentType: "image", + contentMimeType: "image/png", + contentSize: 3, + originalName: "first.png", + }, + { + ...duplicate, + contentUri: "file:///cache/second.png", + contentType: "image", + contentMimeType: "image/png", + contentSize: 3, + originalName: "second.png", + }, + ]; + const readBase64 = vi.fn(async (uri: string) => + uri.includes("first") ? "Zmlyc3Q=" : "c2Vjb25k", + ); + const removeOwnedFile = vi.fn(async () => undefined); + + const result = await buildIncomingShareDraft({ + id: "share-duplicates", + createdAt: "2026-07-16T08:00:00.000Z", + payloads: [duplicate, duplicate], + resolvedPayloads, + fileReader: { readBase64, removeOwnedFile }, + }); + + expect(readBase64.mock.calls.map(([uri]) => uri)).toEqual([ + "file:///cache/first.png", + "file:///cache/second.png", + ]); + expect(result.attachments.map((attachment) => attachment.name)).toEqual([ + "first.png", + "second.png", + ]); + expect(removeOwnedFile).toHaveBeenCalledWith("file:///cache/first.png"); + expect(removeOwnedFile).toHaveBeenCalledWith("file:///cache/second.png"); + }); + + it("keeps imported content when temporary-file cleanup fails", async () => { + const image: SharePayload = { + shareType: "image", + value: "file:///shared/screenshot.png", + mimeType: "image/png", + }; + + const result = await buildIncomingShareDraft({ + id: "share-cleanup-failure", + createdAt: "2026-07-16T08:00:00.000Z", + payloads: [image], + resolvedPayloads: [], + fileReader: { + readBase64: async () => "YWJj", + removeOwnedFile: async () => { + throw new Error("file is busy"); + }, + }, + }); + + expect(result.attachments).toHaveLength(1); + expect(result.warnings).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/sharing/incoming-share-model.ts b/apps/mobile/src/features/sharing/incoming-share-model.ts new file mode 100644 index 00000000000..873574209fc --- /dev/null +++ b/apps/mobile/src/features/sharing/incoming-share-model.ts @@ -0,0 +1,217 @@ +import { + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import type { ResolvedSharePayload, SharePayload } from "expo-sharing"; + +import { DraftComposerImageAttachmentSchema } from "../../lib/composer-image-schema"; +import type { DraftComposerImageAttachment } from "../../lib/composerImages"; +import { estimateBase64ByteSize } from "../../lib/base64"; + +export interface IncomingShareDraft { + readonly schemaVersion: 1; + readonly id: string; + readonly createdAt: string; + readonly destination?: IncomingShareDestination; + readonly text: string; + readonly attachments: ReadonlyArray; + readonly warnings: ReadonlyArray; +} + +export interface IncomingShareDestination { + readonly environmentId: string; + readonly projectId: string; +} + +const IncomingShareDestinationSchema = Schema.Struct({ + environmentId: Schema.String, + projectId: Schema.String, +}); + +export const IncomingShareDraftSchema = Schema.Struct({ + schemaVersion: Schema.Literal(1), + id: Schema.String, + createdAt: Schema.String, + destination: Schema.optional(IncomingShareDestinationSchema), + text: Schema.String, + attachments: Schema.Array(DraftComposerImageAttachmentSchema), + warnings: Schema.Array(Schema.String), +}); + +const decodeIncomingShareDraftSync = Schema.decodeUnknownSync(IncomingShareDraftSchema); + +export function decodeIncomingShareDraft(value: unknown): IncomingShareDraft { + return decodeIncomingShareDraftSync(value); +} + +export interface IncomingShareFileReader { + readonly readBase64: (uri: string) => Promise; + readonly removeOwnedFile: (uri: string) => Promise | void; +} + +function sharedText(payloads: ReadonlyArray): string { + const seen = new Set(); + const values: string[] = []; + for (const payload of payloads) { + if (payload.shareType !== "text" && payload.shareType !== "url") { + continue; + } + const value = payload.value.trim(); + if (!value || seen.has(value)) { + continue; + } + seen.add(value); + values.push(value); + } + return values.join("\n\n"); +} + +function resolvedImageFor( + payload: SharePayload, + index: number, + resolvedPayloads: ReadonlyArray, + consumedIndexes: Set, +): ResolvedSharePayload | undefined { + const sameIndex = resolvedPayloads[index]; + if ( + !consumedIndexes.has(index) && + sameIndex?.shareType === payload.shareType && + sameIndex.value === payload.value + ) { + consumedIndexes.add(index); + return sameIndex; + } + const matchingIndex = resolvedPayloads.findIndex( + (candidate, candidateIndex) => + !consumedIndexes.has(candidateIndex) && + candidate.shareType === payload.shareType && + candidate.value === payload.value, + ); + if (matchingIndex < 0) { + return undefined; + } + consumedIndexes.add(matchingIndex); + return resolvedPayloads[matchingIndex]; +} + +async function releaseOwnedFiles( + fileReader: IncomingShareFileReader, + uris: ReadonlyArray, +): Promise { + for (const uri of new Set(uris.filter((candidate): candidate is string => Boolean(candidate)))) { + try { + await fileReader.removeOwnedFile(uri); + } catch { + // Temporary-file cleanup is best-effort and must never discard content + // that was successfully converted into a durable composer attachment. + } + } +} + +function fallbackName(uri: string, index: number, mimeType: string): string { + try { + const pathName = new URL(uri).pathname.split("/").findLast((segment) => segment.length > 0); + if (pathName) { + return decodeURIComponent(pathName); + } + } catch { + // Fall through to a deterministic attachment name. + } + const extension = mimeType.split("/")[1]?.replace(/[^a-z0-9.+-]/gi, "") || "png"; + return `shared-image-${index + 1}.${extension}`; +} + +export async function buildIncomingShareDraft(input: { + readonly payloads: ReadonlyArray; + readonly resolvedPayloads: ReadonlyArray; + readonly fileReader: IncomingShareFileReader; + readonly id: string; + readonly createdAt: string; +}): Promise { + const attachments: DraftComposerImageAttachment[] = []; + const warnings: string[] = []; + const consumedResolvedPayloadIndexes = new Set(); + let warnedAttachmentLimit = false; + + for (const [index, payload] of input.payloads.entries()) { + if (payload.shareType !== "image") { + continue; + } + const resolved = resolvedImageFor( + payload, + index, + input.resolvedPayloads, + consumedResolvedPayloadIndexes, + ); + const uri = resolved?.contentUri ?? payload.value; + if (attachments.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + if (!warnedAttachmentLimit) { + warnings.push( + `Only the first ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} shared images were attached.`, + ); + warnedAttachmentLimit = true; + } + await releaseOwnedFiles(input.fileReader, [uri, payload.value]); + continue; + } + + const mimeType = (resolved?.contentMimeType ?? payload.mimeType ?? "image/png").toLowerCase(); + if (!uri || !mimeType.startsWith("image/")) { + warnings.push("One shared item was not a supported image."); + await releaseOwnedFiles(input.fileReader, [uri, payload.value]); + continue; + } + if ( + resolved?.contentSize !== null && + resolved?.contentSize !== undefined && + resolved.contentSize > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + ) { + warnings.push( + `'${resolved.originalName ?? fallbackName(uri, index, mimeType)}' exceeds the 10 MB attachment limit.`, + ); + await releaseOwnedFiles(input.fileReader, [uri, payload.value]); + continue; + } + + try { + const base64 = await input.fileReader.readBase64(uri); + const sizeBytes = resolved?.contentSize ?? estimateBase64ByteSize(base64); + if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + warnings.push( + `'${resolved?.originalName ?? fallbackName(uri, index, mimeType)}' exceeds the 10 MB attachment limit.`, + ); + continue; + } + const dataUrl = `data:${mimeType};base64,${base64}`; + attachments.push({ + id: `${input.id}:image:${index}`, + type: "image", + name: resolved?.originalName ?? fallbackName(uri, index, mimeType), + mimeType, + sizeBytes, + dataUrl, + // The share provider's file is temporary. A data-backed preview keeps + // the composer valid after its source file and App Group entry are gone. + previewUri: dataUrl, + }); + } catch { + warnings.push(`Could not read '${fallbackName(uri, index, mimeType)}'.`); + } finally { + await releaseOwnedFiles(input.fileReader, [uri, payload.value]); + } + } + + return { + schemaVersion: 1, + id: input.id, + createdAt: input.createdAt, + text: sharedText(input.payloads), + attachments, + warnings, + }; +} + +export function hasIncomingShareContent(draft: IncomingShareDraft): boolean { + return draft.text.trim().length > 0 || draft.attachments.length > 0; +} diff --git a/apps/mobile/src/features/sharing/incoming-share-presentation.test.ts b/apps/mobile/src/features/sharing/incoming-share-presentation.test.ts new file mode 100644 index 00000000000..c46ee6eb300 --- /dev/null +++ b/apps/mobile/src/features/sharing/incoming-share-presentation.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + EMPTY_INCOMING_SHARE_PRESENTATION_STATE, + transitionIncomingSharePresentation, +} from "./incoming-share-presentation"; + +describe("incoming share presentation", () => { + it("does not reopen a dismissed share when refresh returns a new object for the same id", () => { + const presented = transitionIncomingSharePresentation(EMPTY_INCOMING_SHARE_PRESENTATION_STATE, { + isShareSheetPresented: false, + pendingShareId: "share-1", + }); + expect(presented.shareIdToPresent).toBe("share-1"); + + const whilePresented = transitionIncomingSharePresentation(presented.state, { + isShareSheetPresented: true, + pendingShareId: "share-1", + }); + expect(whilePresented).toEqual({ state: presented.state, shareIdToPresent: null }); + + const dismissed = transitionIncomingSharePresentation(whilePresented.state, { + isShareSheetPresented: false, + pendingShareId: "share-1", + }); + expect(dismissed.state.dismissedShareId).toBe("share-1"); + + expect( + transitionIncomingSharePresentation(dismissed.state, { + isShareSheetPresented: false, + pendingShareId: "share-1", + }), + ).toEqual({ state: dismissed.state, shareIdToPresent: null }); + }); + + it("presents the next queued share immediately after the previous sheet closes", () => { + const first = transitionIncomingSharePresentation(EMPTY_INCOMING_SHARE_PRESENTATION_STATE, { + isShareSheetPresented: false, + pendingShareId: "share-1", + }); + + const next = transitionIncomingSharePresentation(first.state, { + isShareSheetPresented: false, + pendingShareId: "share-2", + }); + expect(next.shareIdToPresent).toBe("share-2"); + expect(next.state).toEqual({ presentedShareId: "share-2", dismissedShareId: null }); + }); + + it("forgets dismissal after consumption so a later handoff may reuse the id", () => { + const dismissed = { + presentedShareId: null, + dismissedShareId: "share-1", + }; + const consumed = transitionIncomingSharePresentation(dismissed, { + isShareSheetPresented: false, + pendingShareId: null, + }); + expect(consumed.state).toEqual(EMPTY_INCOMING_SHARE_PRESENTATION_STATE); + + expect( + transitionIncomingSharePresentation(consumed.state, { + isShareSheetPresented: false, + pendingShareId: "share-1", + }).shareIdToPresent, + ).toBe("share-1"); + }); + + it("forgets a consumed presentation while its sheet is still mounted", () => { + const presented = { + presentedShareId: "share-1", + dismissedShareId: null, + }; + const consumed = transitionIncomingSharePresentation(presented, { + isShareSheetPresented: true, + pendingShareId: null, + }); + expect(consumed.state).toEqual(EMPTY_INCOMING_SHARE_PRESENTATION_STATE); + + const replacementWhileOpen = transitionIncomingSharePresentation(consumed.state, { + isShareSheetPresented: true, + pendingShareId: "share-1", + }); + expect(replacementWhileOpen.shareIdToPresent).toBeNull(); + expect( + transitionIncomingSharePresentation(replacementWhileOpen.state, { + isShareSheetPresented: false, + pendingShareId: "share-1", + }).shareIdToPresent, + ).toBe("share-1"); + }); +}); diff --git a/apps/mobile/src/features/sharing/incoming-share-presentation.ts b/apps/mobile/src/features/sharing/incoming-share-presentation.ts new file mode 100644 index 00000000000..663d9dc17de --- /dev/null +++ b/apps/mobile/src/features/sharing/incoming-share-presentation.ts @@ -0,0 +1,71 @@ +export interface IncomingSharePresentationState { + readonly presentedShareId: string | null; + readonly dismissedShareId: string | null; +} + +export interface IncomingSharePresentationTransition { + readonly state: IncomingSharePresentationState; + readonly shareIdToPresent: string | null; +} + +export const EMPTY_INCOMING_SHARE_PRESENTATION_STATE: IncomingSharePresentationState = { + presentedShareId: null, + dismissedShareId: null, +}; + +/** + * Tracks presentation by durable share id rather than object identity. A user + * dismissal suppresses only that inbox item until it is consumed or replaced. + */ +export function transitionIncomingSharePresentation( + state: IncomingSharePresentationState, + input: { + readonly isShareSheetPresented: boolean; + readonly pendingShareId: string | null; + }, +): IncomingSharePresentationTransition { + if (input.isShareSheetPresented) { + if (state.presentedShareId !== null && input.pendingShareId !== state.presentedShareId) { + // Consumption may happen while the sheet remains mounted. Forget the + // old presentation immediately so a later handoff may reuse its id. + return { + state: EMPTY_INCOMING_SHARE_PRESENTATION_STATE, + shareIdToPresent: null, + }; + } + return { state, shareIdToPresent: null }; + } + + let nextState = state; + if (state.presentedShareId !== null) { + if (input.pendingShareId === state.presentedShareId) { + return { + state: { + presentedShareId: null, + dismissedShareId: state.presentedShareId, + }, + shareIdToPresent: null, + }; + } + nextState = { ...state, presentedShareId: null }; + } + + if (input.pendingShareId === null) { + return { + state: EMPTY_INCOMING_SHARE_PRESENTATION_STATE, + shareIdToPresent: null, + }; + } + + if (nextState.dismissedShareId === input.pendingShareId) { + return { state: nextState, shareIdToPresent: null }; + } + + return { + state: { + presentedShareId: input.pendingShareId, + dismissedShareId: null, + }, + shareIdToPresent: input.pendingShareId, + }; +} diff --git a/apps/mobile/src/features/sharing/incoming-share-storage.ts b/apps/mobile/src/features/sharing/incoming-share-storage.ts new file mode 100644 index 00000000000..8364b4c98a4 --- /dev/null +++ b/apps/mobile/src/features/sharing/incoming-share-storage.ts @@ -0,0 +1,80 @@ +import * as Schema from "effect/Schema"; + +import { decodeIncomingShareDraft, type IncomingShareDraft } from "./incoming-share-model"; + +const INCOMING_SHARE_DIRECTORY = "incoming-shares"; + +export class IncomingShareStorageError extends Schema.TaggedErrorClass()( + "IncomingShareStorageError", + { + operation: Schema.Literals(["load", "write", "remove"]), + shareId: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Incoming share storage operation ${this.operation} failed for ${this.shareId ?? "unknown"}.`; + } +} + +function fileName(shareId: string): string { + return `${encodeURIComponent(shareId)}.json`; +} + +async function getDirectory() { + const { Directory, Paths } = await import("expo-file-system"); + const directory = new Directory(Paths.document, INCOMING_SHARE_DIRECTORY); + directory.create({ idempotent: true, intermediates: true }); + return directory; +} + +async function getFile(shareId: string) { + const { File } = await import("expo-file-system"); + return new File(await getDirectory(), fileName(shareId)); +} + +export async function loadIncomingShareDrafts(): Promise> { + try { + const { File } = await import("expo-file-system"); + const drafts: IncomingShareDraft[] = []; + for (const entry of (await getDirectory()).list()) { + if (!(entry instanceof File) || !entry.name.endsWith(".json")) { + continue; + } + try { + drafts.push(decodeIncomingShareDraft(JSON.parse(await entry.text()) as unknown)); + } catch (cause) { + console.warn( + "[incoming-share] ignored invalid persisted share", + new IncomingShareStorageError({ operation: "load", shareId: null, cause }), + ); + } + } + return drafts.sort((left, right) => right.createdAt.localeCompare(left.createdAt)); + } catch (cause) { + throw new IncomingShareStorageError({ operation: "load", shareId: null, cause }); + } +} + +export async function writeIncomingShareDraft(draft: IncomingShareDraft): Promise { + try { + const file = await getFile(draft.id); + if (!file.exists) { + file.create({ intermediates: true, overwrite: true }); + } + file.write(JSON.stringify(draft)); + } catch (cause) { + throw new IncomingShareStorageError({ operation: "write", shareId: draft.id, cause }); + } +} + +export async function removeIncomingShareDraft(shareId: string): Promise { + try { + const file = await getFile(shareId); + if (file.exists) { + file.delete(); + } + } catch (cause) { + throw new IncomingShareStorageError({ operation: "remove", shareId, cause }); + } +} diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx new file mode 100644 index 00000000000..9a4272824ba --- /dev/null +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -0,0 +1,224 @@ +import { useEffect, useRef, useState } from "react"; +import { Keyboard, View } from "react-native"; +import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; +import { AsyncResult } from "effect/unstable/reactivity"; + +import { useConnectionController } from "../connection/useConnectionController"; +import { useProjects, useThreadShells } from "../../state/entities"; +import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; +import { holdEditingQueuedMessage } from "../../state/use-thread-outbox"; +import { useWorkspaceState } from "../../state/workspace"; +import { + getNativeShowcasePairingUrls, + getNativeShowcaseScene, + markNativeShowcaseReady, + type ShowcaseScene, +} from "./nativeShowcaseScene"; +import { + buildShowcasePendingTasks, + SHOWCASE_PENDING_TASK_DEFINITIONS, +} from "./showcasePendingTasks"; +import { retryShowcaseOperation } from "./showcaseRetry"; + +const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; +const SHOWCASE_THREAD_ID = "remote-command-center"; + +function sceneFromPathname(pathname: string): ShowcaseScene | null { + const routePath = pathname.split(/[?#]/u, 1)[0] ?? pathname; + if (routePath === "/settings" || routePath.endsWith("/settings/environments")) { + return "environments"; + } + if (routePath.endsWith("/terminal")) return "terminal"; + if (routePath.endsWith("/review")) return "review"; + if (routePath.startsWith("/threads/")) return "thread"; + if (routePath === "/") return "threads"; + return null; +} + +export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) { + const navigation = useNavigation(); + const { connectPairingUrl } = useConnectionController(); + const workspace = useWorkspaceState(); + const projects = useProjects(); + const threads = useThreadShells(); + const attemptedPairingRef = useRef(new Set()); + const seededPendingTaskIdsRef = useRef(new Set()); + const [pairingUrls, setPairingUrls] = useState>([]); + const [pendingTasksReady, setPendingTasksReady] = useState(false); + const [requestedScene, setRequestedScene] = useState(null); + const [readyScene, setReadyScene] = useState(null); + + useEffect(() => { + if (!SHOWCASE_ENABLED || pairingUrls.length > 0) return; + + const readPairingUrls = () => { + const values = getNativeShowcasePairingUrls(); + if (values.length > 0) setPairingUrls(values); + }; + readPairingUrls(); + const interval = setInterval(readPairingUrls, 250); + return () => clearInterval(interval); + }, [pairingUrls.length]); + + useEffect(() => { + if (!SHOWCASE_ENABLED) return; + + const readRequestedScene = () => { + const value = getNativeShowcaseScene(); + if (value) setRequestedScene(value); + }; + readRequestedScene(); + const interval = setInterval(readRequestedScene, 250); + return () => clearInterval(interval); + }, []); + + useEffect(() => { + if (!SHOWCASE_ENABLED || pairingUrls.length === 0) return; + let cancelled = false; + void (async () => { + await Promise.all( + pairingUrls.map(async (pairingUrl) => { + if (cancelled || attemptedPairingRef.current.has(pairingUrl)) return; + const paired = await retryShowcaseOperation( + async () => AsyncResult.isSuccess(await connectPairingUrl(pairingUrl)), + { isCancelled: () => cancelled }, + ); + if (paired) attemptedPairingRef.current.add(pairingUrl); + }), + ); + })(); + return () => { + cancelled = true; + }; + }, [connectPairingUrl, pairingUrls]); + + const scene = sceneFromPathname(props.pathname); + const hasServerFixture = + workspace.state.hasReadyEnvironment && + workspace.environments.length >= 3 && + projects.length >= 3 && + threads.some((thread) => String(thread.id) === SHOWCASE_THREAD_ID); + const hasFixture = hasServerFixture && pendingTasksReady; + const showcaseThread = threads.find((thread) => String(thread.id) === SHOWCASE_THREAD_ID); + + useEffect(() => { + if (!SHOWCASE_ENABLED || !hasServerFixture || pendingTasksReady) return; + + const pendingTasks = buildShowcasePendingTasks(projects, Date.now()); + if (pendingTasks.length !== SHOWCASE_PENDING_TASK_DEFINITIONS.length) return; + + let cancelled = false; + for (const task of pendingTasks) holdEditingQueuedMessage(task.messageId); + void (async () => { + const results = await Promise.all( + pendingTasks.map(async (task) => { + const messageId = String(task.messageId); + if (seededPendingTaskIdsRef.current.has(messageId)) return true; + const seeded = await retryShowcaseOperation( + async () => { + await enqueueThreadOutboxMessage(task); + return true; + }, + { isCancelled: () => cancelled }, + ); + if (seeded) seededPendingTaskIdsRef.current.add(messageId); + return seeded; + }), + ); + if (!cancelled && results.every(Boolean)) setPendingTasksReady(true); + })(); + return () => { + cancelled = true; + }; + }, [hasServerFixture, pendingTasksReady, projects]); + + useEffect(() => { + if (!SHOWCASE_ENABLED || requestedScene === null || !hasFixture || !showcaseThread) return; + if (scene === requestedScene) return; + + const params = { + environmentId: String(showcaseThread.environmentId), + threadId: SHOWCASE_THREAD_ID, + }; + if (requestedScene === "threads") { + navigation.dispatch(StackActions.popToTop()); + return; + } + const routes: Array<{ + name: string; + params?: Record; + state?: { index: number; routes: Array<{ name: string }> }; + }> = [{ name: "Home" }]; + if (requestedScene === "environments") { + routes.push({ + name: "SettingsSheet", + state: { + index: 1, + routes: [{ name: "Settings" }, { name: "SettingsEnvironments" }], + }, + }); + } else { + routes.push({ name: "Thread", params }); + if (requestedScene === "terminal") { + routes.push({ + name: "ThreadTerminal", + params: { ...params, terminalId: "term-1" }, + }); + } else if (requestedScene === "review") { + routes.push({ name: "ThreadReview", params }); + } + } + navigation.dispatch( + CommonActions.reset({ + index: routes.length - 1, + routes, + }), + ); + }, [hasFixture, navigation, requestedScene, scene, showcaseThread]); + + useEffect(() => { + if ( + !SHOWCASE_ENABLED || + scene === null || + requestedScene === null || + scene !== requestedScene || + !hasFixture + ) { + setReadyScene(null); + return; + } + // Review owns its readiness marker because route activation happens before + // the VCS request is parsed and the native diff surface is mounted. + if (scene === "review") { + setReadyScene(null); + return; + } + if (scene === "terminal") Keyboard.dismiss(); + + let renderFrame: number | null = null; + let readyFrame: number | null = null; + const settleTimer = setTimeout(() => { + renderFrame = requestAnimationFrame(() => { + readyFrame = requestAnimationFrame(() => { + markNativeShowcaseReady(scene); + setReadyScene(scene); + }); + }); + }, 500); + return () => { + clearTimeout(settleTimer); + if (renderFrame !== null) cancelAnimationFrame(renderFrame); + if (readyFrame !== null) cancelAnimationFrame(readyFrame); + }; + }, [hasFixture, requestedScene, scene]); + + if (!SHOWCASE_ENABLED || readyScene === null) return null; + + return ( + + ); +} diff --git a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts new file mode 100644 index 00000000000..1f2e263ebf1 --- /dev/null +++ b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts @@ -0,0 +1,68 @@ +import { requireOptionalNativeModule } from "expo"; + +export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const; +export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; + +interface NativeShowcaseControls { + readonly getShowcasePairingUrl?: () => string | null; + readonly getShowcaseScene?: () => string | null; + readonly prepareShowcaseCapture?: () => void; + readonly markShowcaseReady?: (scene: ShowcaseScene) => void; +} + +function nativeShowcaseControls(): NativeShowcaseControls | null { + return requireOptionalNativeModule("T3NativeControls"); +} + +export function getNativeShowcasePairingUrls(): ReadonlyArray { + try { + let raw = nativeShowcaseControls()?.getShowcasePairingUrl?.()?.trim(); + if (!raw) return []; + if (raw.startsWith("json-uri:")) { + try { + raw = decodeURIComponent(raw.slice("json-uri:".length)); + } catch { + return []; + } + } + try { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter( + (candidate): candidate is string => + typeof candidate === "string" && candidate.trim().length > 0, + ); + } + } catch { + // Older runners pass a single URL rather than a JSON array. + } + return [raw]; + } catch { + return []; + } +} + +export function getNativeShowcaseScene(): ShowcaseScene | null { + try { + const scene = nativeShowcaseControls()?.getShowcaseScene?.()?.trim(); + return SHOWCASE_SCENES.find((candidate) => candidate === scene) ?? null; + } catch { + return null; + } +} + +export function prepareNativeShowcaseCapture(): void { + try { + nativeShowcaseControls()?.prepareShowcaseCapture?.(); + } catch { + // The harness still works when a development build predates this helper. + } +} + +export function markNativeShowcaseReady(scene: ShowcaseScene): void { + try { + nativeShowcaseControls()?.markShowcaseReady?.(scene); + } catch { + // The readiness marker is capture-runner metadata, never app functionality. + } +} diff --git a/apps/mobile/src/features/showcase/showcaseEnvironmentRows.test.ts b/apps/mobile/src/features/showcase/showcaseEnvironmentRows.test.ts new file mode 100644 index 00000000000..a459cbe1db3 --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseEnvironmentRows.test.ts @@ -0,0 +1,70 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; + +import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; +import { + applyShowcaseLocalEnvironmentDisplayUrls, + resolveShowcaseEnvironmentUpdateDisplayUrl, +} from "./showcaseEnvironmentRows"; + +function environment( + environmentId: string, + environmentLabel: string, + displayUrl = "http://127.0.0.1:3773/", +): ConnectedEnvironmentSummary { + return { + environmentId: EnvironmentId.make(environmentId), + environmentLabel, + displayUrl, + isRelayManaged: false, + connectionState: "connected", + connectionError: null, + connectionErrorTraceId: null, + }; +} + +it("presents showcase transports as remote endpoints", () => { + const environments = applyShowcaseLocalEnvironmentDisplayUrls([ + environment("runtime-id-1", "Moonbase Terminal"), + environment("runtime-id-2", "Suspense Station"), + environment("runtime-id-3", "Kernel Cabin"), + ]); + + assert.deepStrictEqual( + environments.map(({ displayUrl }) => displayUrl), + [ + "https://moonbase.tail9f3a.ts.net/", + "https://suspense-vps.hel1.t3.sh/", + "http://100.82.16.5:3773/", + ], + ); +}); + +it("leaves environments outside the showcase fixture unchanged", () => { + const original = environment( + "runtime-id-4", + "My Workstation", + "https://workstation.example.test/", + ); + + assert.deepStrictEqual(applyShowcaseLocalEnvironmentDisplayUrls([original]), [original]); +}); + +it("does not persist a cosmetic showcase URL when only the label is saved", () => { + assert.equal( + resolveShowcaseEnvironmentUpdateDisplayUrl({ + actualDisplayUrl: "http://127.0.0.1:3773/", + presentedDisplayUrl: "https://moonbase.tail9f3a.ts.net/", + submittedDisplayUrl: "https://moonbase.tail9f3a.ts.net/", + }), + "http://127.0.0.1:3773/", + ); + assert.equal( + resolveShowcaseEnvironmentUpdateDisplayUrl({ + actualDisplayUrl: "http://127.0.0.1:3773/", + presentedDisplayUrl: "https://moonbase.tail9f3a.ts.net/", + submittedDisplayUrl: "https://new-host.example.com/", + }), + "https://new-host.example.com/", + ); +}); diff --git a/apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts b/apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts new file mode 100644 index 00000000000..66ec3046c67 --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts @@ -0,0 +1,70 @@ +import { EnvironmentId } from "@t3tools/contracts"; + +import type { RelayEnvironmentView } from "../connection/useConnectionController"; +import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; + +const SHOWCASE_LOCAL_ENVIRONMENT_DISPLAY_URLS: Readonly> = { + "Moonbase Terminal": "https://moonbase.tail9f3a.ts.net/", + "Suspense Station": "https://suspense-vps.hel1.t3.sh/", + "Kernel Cabin": "http://100.82.16.5:3773/", +}; + +export function applyShowcaseLocalEnvironmentDisplayUrls( + environments: ReadonlyArray, +): ReadonlyArray { + return environments.map((environment) => ({ + ...environment, + displayUrl: + SHOWCASE_LOCAL_ENVIRONMENT_DISPLAY_URLS[environment.environmentLabel] ?? + environment.displayUrl, + })); +} + +export function resolveShowcaseEnvironmentUpdateDisplayUrl(input: { + readonly actualDisplayUrl: string; + readonly presentedDisplayUrl: string; + readonly submittedDisplayUrl: string; +}): string { + return input.submittedDisplayUrl === input.presentedDisplayUrl + ? input.actualDisplayUrl + : input.submittedDisplayUrl; +} + +const pocketPiId = EnvironmentId.make("showcase-pocket-pi"); +const pocketPiEndpoint = { + httpBaseUrl: "https://pocket-pi.t3.sh", + wsBaseUrl: "wss://pocket-pi.t3.sh", + providerKind: "t3_relay" as const, +}; + +export const SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS: ReadonlyArray = [ + { + environmentId: EnvironmentId.make("showcase-aurora-gpu"), + environmentLabel: "Aurora GPU Pod", + displayUrl: "https://aurora-gpu.t3.sh", + isRelayManaged: true, + connectionState: "connected", + connectionError: null, + connectionErrorTraceId: null, + }, +]; + +export const SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS: ReadonlyArray = [ + { + environment: { + environmentId: pocketPiId, + label: "Pocket Pi", + endpoint: pocketPiEndpoint, + linkedAt: "2026-07-16T08:00:00.000Z", + }, + availability: "online", + status: { + environmentId: pocketPiId, + endpoint: pocketPiEndpoint, + status: "online", + checkedAt: "2026-07-16T08:41:00.000Z", + }, + error: null, + traceId: null, + }, +]; diff --git a/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts b/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts new file mode 100644 index 00000000000..8ab043d7cd8 --- /dev/null +++ b/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts @@ -0,0 +1,72 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ProjectId, ProviderInstanceId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; + +import { + buildShowcasePendingTasks, + SHOWCASE_PENDING_TASK_DEFINITIONS, +} from "./showcasePendingTasks"; + +const projects: ReadonlyArray = [ + { + environmentId: EnvironmentId.make("moonbase-terminal"), + id: ProjectId.make("t3code"), + title: "T3 Code", + workspaceRoot: "/workspace/t3code", + repositoryIdentity: null, + defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + scripts: [], + createdAt: "2026-07-16T08:00:00.000Z", + updatedAt: "2026-07-16T08:00:00.000Z", + }, + { + environmentId: EnvironmentId.make("suspense-station"), + id: ProjectId.make("react"), + title: "React", + workspaceRoot: "/workspace/react", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-07-16T08:00:00.000Z", + updatedAt: "2026-07-16T08:00:00.000Z", + }, +]; + +it("builds sendable-looking pending tasks against real showcase projects", () => { + const tasks = buildShowcasePendingTasks(projects, Date.parse("2026-07-16T09:00:00.000Z")); + + assert.equal(tasks.length, SHOWCASE_PENDING_TASK_DEFINITIONS.length); + assert.deepStrictEqual( + tasks.map((task) => ({ + environmentId: String(task.environmentId), + projectId: task.creation ? String(task.creation.projectId) : undefined, + title: task.creation?.projectTitle, + branch: task.creation?.branch, + createdAt: task.createdAt, + })), + [ + { + environmentId: "moonbase-terminal", + projectId: "t3code", + title: "T3 Code", + branch: "feat/offline-launchpad", + createdAt: "2026-07-16T08:52:00.000Z", + }, + { + environmentId: "suspense-station", + projectId: "react", + title: "React", + branch: "perf/tunnel-handoff", + createdAt: "2026-07-16T08:33:00.000Z", + }, + ], + ); + assert.equal( + tasks.every((task) => task.modelSelection !== undefined), + true, + ); +}); + +it("waits until every referenced project has hydrated", () => { + assert.equal(buildShowcasePendingTasks(projects.slice(0, 1), Date.now()).length, 1); +}); diff --git a/apps/mobile/src/features/showcase/showcasePendingTasks.ts b/apps/mobile/src/features/showcase/showcasePendingTasks.ts new file mode 100644 index 00000000000..1b0b5ed22f4 --- /dev/null +++ b/apps/mobile/src/features/showcase/showcasePendingTasks.ts @@ -0,0 +1,66 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + MessageId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; + +import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; + +export const SHOWCASE_PENDING_TASK_DEFINITIONS = [ + { + projectId: "t3code", + id: "offline-launch-checklist", + text: "Ship the offline launch checklist before touchdown ✈️", + branch: "feat/offline-launchpad", + minutesAgo: 8, + }, + { + projectId: "react", + id: "train-tunnel-suspense", + text: "Polish the Suspense handoff for the train tunnel 🚇", + branch: "perf/tunnel-handoff", + minutesAgo: 27, + }, +] as const; + +const FALLBACK_MODEL_SELECTION = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", +} as const; + +export function buildShowcasePendingTasks( + projects: ReadonlyArray, + now: number, +): ReadonlyArray { + return SHOWCASE_PENDING_TASK_DEFINITIONS.flatMap((definition) => { + const project = projects.find((candidate) => String(candidate.id) === definition.projectId); + if (!project) return []; + + return [ + { + environmentId: project.environmentId, + threadId: ThreadId.make(`showcase-pending-${definition.id}`), + messageId: MessageId.make(`showcase-pending-message-${definition.id}`), + commandId: CommandId.make(`showcase-pending-command-${definition.id}`), + text: definition.text, + attachments: [], + modelSelection: project.defaultModelSelection ?? FALLBACK_MODEL_SELECTION, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + creation: { + projectId: project.id, + projectTitle: project.title, + projectCwd: project.workspaceRoot, + workspaceMode: "local" as const, + branch: definition.branch, + worktreePath: project.workspaceRoot, + }, + createdAt: new Date(now - definition.minutesAgo * 60_000).toISOString(), + }, + ]; + }); +} diff --git a/apps/mobile/src/features/showcase/showcaseRetry.test.ts b/apps/mobile/src/features/showcase/showcaseRetry.test.ts new file mode 100644 index 00000000000..86c2890366e --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseRetry.test.ts @@ -0,0 +1,44 @@ +import { assert, it } from "@effect/vitest"; + +import { retryShowcaseOperation } from "./showcaseRetry"; + +it("retries a failed showcase operation until it succeeds", async () => { + let attempts = 0; + const succeeded = await retryShowcaseOperation( + async () => { + attempts += 1; + return attempts === 3; + }, + { isCancelled: () => false, retryDelayMs: 0 }, + ); + + assert.equal(succeeded, true); + assert.equal(attempts, 3); +}); + +it("recovers when a showcase operation attempt hangs", async () => { + let attempts = 0; + const succeeded = await retryShowcaseOperation( + () => { + attempts += 1; + return attempts === 1 ? new Promise(() => undefined) : Promise.resolve(true); + }, + { isCancelled: () => false, attemptTimeoutMs: 1, retryDelayMs: 0 }, + ); + + assert.equal(succeeded, true); + assert.equal(attempts, 2); +}); + +it("stops retrying when the owning showcase effect is cancelled", async () => { + let cancelled = false; + const succeeded = await retryShowcaseOperation( + async () => { + cancelled = true; + return false; + }, + { isCancelled: () => cancelled, retryDelayMs: 0 }, + ); + + assert.equal(succeeded, false); +}); diff --git a/apps/mobile/src/features/showcase/showcaseRetry.ts b/apps/mobile/src/features/showcase/showcaseRetry.ts new file mode 100644 index 00000000000..0c7aeb12019 --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseRetry.ts @@ -0,0 +1,46 @@ +export interface ShowcaseRetryOptions { + readonly isCancelled: () => boolean; + readonly attemptTimeoutMs?: number; + readonly retryDelayMs?: number; +} + +const DEFAULT_ATTEMPT_TIMEOUT_MS = 10_000; +const DEFAULT_RETRY_DELAY_MS = 500; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function runAttemptWithTimeout( + operation: () => Promise, + timeoutMs: number, +): Promise { + let timeout: ReturnType | null = null; + try { + return await Promise.race([ + operation(), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), timeoutMs); + }), + ]); + } catch { + return false; + } finally { + if (timeout !== null) clearTimeout(timeout); + } +} + +/** Retry transient showcase setup work until it succeeds or the owning effect unmounts. */ +export async function retryShowcaseOperation( + operation: () => Promise, + options: ShowcaseRetryOptions, +): Promise { + const attemptTimeoutMs = options.attemptTimeoutMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS; + const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS; + + while (!options.isCancelled()) { + if (await runAttemptWithTimeout(operation, attemptTimeoutMs)) return true; + if (!options.isCancelled()) await delay(retryDelayMs); + } + return false; +} diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx index 68d214193bd..b205b4df72c 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx @@ -37,6 +37,7 @@ interface TerminalSurfaceProps extends ViewProps { readonly buffer: string; readonly fontSize?: number; readonly isRunning: boolean; + readonly autoFocus?: boolean; readonly keyboardFocusRequest?: number; readonly theme?: TerminalTheme; readonly onInput: (data: string) => void; @@ -145,7 +146,8 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter onSubmitEditing={(event) => { const text = event.nativeEvent.text; if (text.length > 0) { - props.onInput(`${text}\n`); + // Terminal Enter is CR. LF is Ctrl+J and raw-mode TUIs can treat it as J. + props.onInput(`${text}\r`); } }} /> @@ -214,6 +216,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf ) { @@ -36,6 +37,9 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps state.isVisible); @@ -82,10 +98,94 @@ export function NewTaskDraftScreen(props: { const promptInputRef = useRef(null); const loadedBranchesProjectKeyRef = useRef(null); const [isComposerFocused, setIsComposerFocused] = useState(false); + const [importingShareKey, setImportingShareKey] = useState(null); + const [isCancellingShareImport, setIsCancellingShareImport] = useState(false); + const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState(null); + const [isReturningToProjectPicker, setIsReturningToProjectPicker] = useState(false); + const [shareImportAttempt, setShareImportAttempt] = useState(0); + const startedShareImportKeyRef = useRef(null); + const cancellingShareImportKeyRef = useRef(null); + const shareImportDraftBackupRef = useRef(new Map()); + const activeShareImportTokenRef = useRef(null); + const shareImportMountedRef = useRef(true); + const latestDraftKeyRef = useRef(flow.draftKey); + const latestIncomingShareIdRef = useRef(props.incomingShareId); + latestDraftKeyRef.current = flow.draftKey; + latestIncomingShareIdRef.current = props.incomingShareId; + const isImportingShare = importingShareKey !== null; + const alertedUnavailableIncomingShareIdRef = useRef(null); + const incomingShare = props.incomingShareId ? getShare(props.incomingShareId) : null; + const requestedInitialProjectAvailable = Boolean( + props.initialProjectRef?.environmentId && + props.initialProjectRef.projectId && + projects.some( + (project) => + project.environmentId === props.initialProjectRef?.environmentId && + project.id === props.initialProjectRef?.projectId, + ), + ); + const isProjectPickerReturnActive = + isReturningToProjectPicker && !requestedInitialProjectAvailable; + const isIncomingShareTransferPending = Boolean( + incomingShare && cancelledIncomingShareId !== props.incomingShareId, + ); + usePreventRemove( + (isIncomingShareTransferPending && !isProjectPickerReturnActive) || isCancellingShareImport, + () => undefined, + ); + const hasImportedIncomingShare = Boolean( + props.incomingShareId && + flow.draftKey && + getComposerDraftSnapshot(flow.draftKey).importedShareIds?.includes(props.incomingShareId), + ); + const isIncomingShareUnavailable = Boolean( + props.incomingShareId && + !isIncomingShareInboxLoading && + !incomingShare && + !hasImportedIncomingShare, + ); + const isIncomingShareReady = + !props.incomingShareId || + (hasImportedIncomingShare && !incomingShare) || + isIncomingShareUnavailable; const appliedInitialProjectKeyRef = useRef(null); useEffect(() => { + if (cancelledIncomingShareId === props.incomingShareId) { + navigation.goBack(); + } + }, [cancelledIncomingShareId, navigation, props.incomingShareId]); + useEffect(() => { + if (!isReturningToProjectPicker) { + return; + } + if (requestedInitialProjectAvailable) { + setIsReturningToProjectPicker(false); + return; + } + // Let usePreventRemove commit its disabled state before replacing this + // route, otherwise the transfer guard can swallow the fallback action. + const frame = requestAnimationFrame(() => { + navigation.dispatch( + StackActions.replace("NewTask", { incomingShareId: props.incomingShareId }), + ); + }); + return () => cancelAnimationFrame(frame); + }, [ + isReturningToProjectPicker, + navigation, + props.incomingShareId, + requestedInitialProjectAvailable, + ]); + useEffect(() => { + if (!shareImportMountedRef.current) { + startedShareImportKeyRef.current = null; + } + shareImportMountedRef.current = true; return () => { appliedInitialProjectKeyRef.current = null; + shareImportMountedRef.current = false; + activeShareImportTokenRef.current = null; + cancellingShareImportKeyRef.current = null; }; }, []); @@ -163,6 +263,14 @@ export function NewTaskDraftScreen(props: { setProject(directProject); return; } + + if (projects.length > 0) { + // Never fall through to the flow provider's temporary first-project + // default. Return to the picker with the share id intact so the user + // can choose an available destination. + setIsReturningToProjectPicker(true); + } + return; } if (selectedProject) { @@ -179,6 +287,7 @@ export function NewTaskDraftScreen(props: { logicalProjects, projects, props.initialProjectRef, + props.incomingShareId, props.pendingTaskId, navigation, selectedProject, @@ -198,6 +307,205 @@ export function NewTaskDraftScreen(props: { void flow.loadBranches(); }, [flow.loadBranches, selectedProject]); + useEffect(() => { + const shareId = props.incomingShareId; + const draftKey = flow.draftKey; + const destinationProject = selectedProject; + const initialEnvironmentId = props.initialProjectRef?.environmentId; + const initialProjectId = props.initialProjectRef?.projectId; + const selectedProjectMatchesRoute = + !initialEnvironmentId || + !initialProjectId || + (destinationProject?.environmentId === initialEnvironmentId && + destinationProject.id === initialProjectId); + if ( + !shareId || + !draftKey || + !destinationProject || + !selectedProjectMatchesRoute || + cancelledIncomingShareId === shareId + ) { + return; + } + const importKey = `${shareId}:${draftKey}`; + if ( + startedShareImportKeyRef.current === importKey || + cancellingShareImportKeyRef.current === importKey + ) { + return; + } + + if (!incomingShare) { + if (isIncomingShareUnavailable && alertedUnavailableIncomingShareIdRef.current !== shareId) { + alertedUnavailableIncomingShareIdRef.current = shareId; + Alert.alert( + "Shared content unavailable", + "The shared content is no longer in the inbox. You can continue editing this task draft.", + ); + } + return; + } + + if (alertedUnavailableIncomingShareIdRef.current === shareId) { + alertedUnavailableIncomingShareIdRef.current = null; + } + startedShareImportKeyRef.current = importKey; + const draftBackup = + shareImportDraftBackupRef.current.get(importKey) ?? getComposerDraftSnapshot(draftKey); + shareImportDraftBackupRef.current.set(importKey, draftBackup); + const importToken = Symbol(importKey); + let didReserveShare = false; + let needsDraftRestore = false; + activeShareImportTokenRef.current = importToken; + setImportingShareKey(importKey); + void (async () => { + await reserveShare(shareId, { + environmentId: String(destinationProject.environmentId), + projectId: String(destinationProject.id), + }); + didReserveShare = true; + if ( + !shareImportMountedRef.current || + activeShareImportTokenRef.current !== importToken || + latestDraftKeyRef.current !== draftKey || + latestIncomingShareIdRef.current !== shareId + ) { + return; + } + needsDraftRestore = true; + const { skippedAttachmentCount } = await mergeComposerDraftContent(draftKey, { + text: incomingShare.text, + attachments: incomingShare.attachments, + sourceShareId: shareId, + }); + if ( + !shareImportMountedRef.current || + activeShareImportTokenRef.current !== importToken || + latestDraftKeyRef.current !== draftKey || + latestIncomingShareIdRef.current !== shareId + ) { + // The durable reservation makes an interrupted transfer resume only + // in this project instead of copying into a second project draft. + return; + } + await consumeShare(shareId); + if (!shareImportMountedRef.current || activeShareImportTokenRef.current !== importToken) { + return; + } + const warnings = [...incomingShare.warnings]; + if (skippedAttachmentCount > 0) { + warnings.push( + `${skippedAttachmentCount} shared image${skippedAttachmentCount === 1 ? " was" : "s were"} skipped because this draft reached the attachment limit.`, + ); + } + if (warnings.length > 0) { + Alert.alert("Some shared content was skipped", warnings.join("\n")); + } + shareImportDraftBackupRef.current.delete(importKey); + })() + .catch((error) => { + if (!shareImportMountedRef.current || activeShareImportTokenRef.current !== importToken) { + return; + } + Alert.alert( + "Could not import shared content", + error instanceof Error ? error.message : "The shared content could not be saved.", + [ + { + text: "Cancel import", + style: "cancel", + onPress: () => { + const cancelImport = async (): Promise => { + if (!shareImportMountedRef.current) { + return; + } + // Latch synchronously before restoring the draft. The + // restore publishes atom state and can re-run the import + // effect before React commits the cancelling state update. + cancellingShareImportKeyRef.current = importKey; + setIsCancellingShareImport(true); + try { + if (needsDraftRestore) { + await restoreComposerDraftSnapshot(draftKey, draftBackup); + needsDraftRestore = false; + } + if (didReserveShare) { + await releaseShareReservation(shareId, { + environmentId: String(destinationProject.environmentId), + projectId: String(destinationProject.id), + }); + } + shareImportDraftBackupRef.current.delete(importKey); + if (shareImportMountedRef.current) { + setIsCancellingShareImport(false); + setCancelledIncomingShareId(shareId); + } + } catch (cancelError) { + if (!shareImportMountedRef.current) { + return; + } + Alert.alert( + "Could not cancel import", + cancelError instanceof Error + ? cancelError.message + : "The shared content could not be restored safely.", + [ + { + text: "Retry import", + onPress: () => { + cancellingShareImportKeyRef.current = null; + setIsCancellingShareImport(false); + setShareImportAttempt((attempt) => attempt + 1); + }, + }, + { + text: "Retry cancel", + onPress: () => void cancelImport(), + }, + ], + { cancelable: false }, + ); + } + }; + void cancelImport(); + }, + }, + { + text: "Retry", + onPress: () => setShareImportAttempt((attempt) => attempt + 1), + }, + ], + { cancelable: false }, + ); + }) + .finally(() => { + if (startedShareImportKeyRef.current === importKey) { + // Every terminal path, including an invalidated operation, must + // release the synchronous start latch so this transfer can retry. + startedShareImportKeyRef.current = null; + } + if (shareImportMountedRef.current && activeShareImportTokenRef.current === importToken) { + activeShareImportTokenRef.current = null; + setImportingShareKey(null); + } + }); + }, [ + consumeShare, + cancelledIncomingShareId, + flow.draftKey, + hasImportedIncomingShare, + incomingShare, + isIncomingShareInboxLoading, + isIncomingShareUnavailable, + props.incomingShareId, + props.initialProjectRef?.environmentId, + props.initialProjectRef?.projectId, + releaseShareReservation, + reserveShare, + selectedProject, + shareImportAttempt, + ]); + useEffect(() => { // Android starts with the collapsed composer pill (like an open thread) // and only expands/focuses when tapped. @@ -223,10 +531,11 @@ export function NewTaskDraftScreen(props: { flow.environments.map((environment) => ({ id: `environment:${environment.environmentId}`, title: environment.environmentLabel, + attributes: isIncomingShareTransferPending ? { disabled: true } : undefined, state: flow.selectedEnvironmentId === environment.environmentId ? ("on" as const) : undefined, })), - [flow.environments, flow.selectedEnvironmentId], + [flow.environments, flow.selectedEnvironmentId, isIncomingShareTransferPending], ); const modelMenuActions = useMemo( @@ -391,20 +700,23 @@ export function NewTaskDraftScreen(props: { [currentBranchName, flow.selectedBranchName, flow.workspaceMode], ); function handleModelMenuAction(event: string) { - if (!event.startsWith("model:")) { + if (isIncomingShareTransferPending || !event.startsWith("model:")) { return; } flow.setSelectedModelKey(event.slice("model:".length)); } function handleEnvironmentMenuAction(event: string) { - if (!event.startsWith("environment:")) { + if (isIncomingShareTransferPending || !event.startsWith("environment:")) { return; } flow.selectEnvironment(EnvironmentId.make(event.slice("environment:".length))); } function handleOptionsMenuAction(event: string) { + if (isIncomingShareTransferPending) { + return; + } const providerOptions = applyProviderOptionMenuEvent(providerOptionDescriptors, event); if (providerOptions) { flow.setSelectedModelOptions(providerOptions); @@ -424,6 +736,9 @@ export function NewTaskDraftScreen(props: { } function handleWorkspaceMenuAction(event: string) { + if (isIncomingShareTransferPending) { + return; + } if (event.startsWith("workspace:mode:")) { flow.setWorkspaceMode( event.slice("workspace:mode:".length) as Parameters[0], @@ -444,6 +759,9 @@ export function NewTaskDraftScreen(props: { } async function handlePickImages(): Promise { + if (isIncomingShareTransferPending) { + return; + } const result = await pickComposerImages({ existingCount: flow.attachments.length }); if (result.images.length > 0) { flow.appendAttachments(result.images); @@ -526,8 +844,7 @@ export function NewTaskDraftScreen(props: { if (editingPendingTask) { flow.finishEditingPendingTask(); } else { - flow.setPrompt(""); - flow.clearAttachments(); + clearComposerDraftContent(draftKey); } navigation.getParent()?.goBack(); return; @@ -585,8 +902,7 @@ export function NewTaskDraftScreen(props: { } flow.finishEditingPendingTask(); } else { - flow.setPrompt(""); - flow.clearAttachments(); + clearComposerDraftContent(draftKey); } navigation.dispatch( StackActions.replace("Thread", { @@ -620,12 +936,15 @@ export function NewTaskDraftScreen(props: { Boolean(flow.selectedProject) && Boolean(flow.selectedModel) && flow.prompt.trim().length > 0 && + isIncomingShareReady && + !isImportingShare && !flow.submitting && !(flow.workspaceMode === "worktree" && !flow.selectedBranchName); const promptEditor = ( void handlePickImages()} showChevron={false} + disabled={isIncomingShareTransferPending} /> } label={flow.selectedModelOption?.label ?? "Model"} /> @@ -677,6 +998,7 @@ export function NewTaskDraftScreen(props: { > @@ -687,6 +1009,7 @@ export function NewTaskDraftScreen(props: { > @@ -697,6 +1020,7 @@ export function NewTaskDraftScreen(props: { > @@ -763,7 +1087,9 @@ export function NewTaskDraftScreen(props: { undefined : flow.removeAttachment + } /> ) : null} @@ -807,7 +1133,7 @@ export function NewTaskDraftScreen(props: { undefined : flow.removeAttachment} imageSize={88} imageBorderRadius={20} /> diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index ca857f6786a..99985385ea7 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -1,9 +1,9 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { useNavigation } from "@react-navigation/native"; +import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; -import { useMemo } from "react"; -import { ActivityIndicator, Platform, Pressable, ScrollView, View } from "react-native"; +import { useEffect, useMemo, useRef } from "react"; +import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; import { cn } from "../../lib/cn"; @@ -16,6 +16,11 @@ import type { WorkspaceState } from "../../state/workspaceModel"; import { useWorkspaceState } from "../../state/workspace"; import { groupProjectsByRepository } from "../../lib/repositoryGroups"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; +import { useIncomingShare } from "../sharing/IncomingShareProvider"; + +type NewTaskRouteParams = { + readonly incomingShareId?: string | string[]; +}; function deriveProjectEmptyState(catalogState: WorkspaceState): { readonly title: string; @@ -72,15 +77,29 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): { }; } -export function NewTaskRouteScreen() { +export function NewTaskRouteScreen({ route }: StaticScreenProps) { const projects = useProjects(); const threads = useThreadShells(); const { state: catalogState } = useWorkspaceState(); const navigation = useNavigation(); + const isFocused = useIsFocused(); const { layout } = useAdaptiveWorkspaceLayout(); const insets = useSafeAreaInsets(); const chevronColor = useThemeColor("--color-chevron"); const accentColor = useThemeColor("--color-icon-muted"); + const { getShare, releaseShareReservation } = useIncomingShare(); + const routeShareId = Array.isArray(route.params?.incomingShareId) + ? route.params.incomingShareId[0] + : route.params?.incomingShareId; + const incomingShare = routeShareId ? getShare(routeShareId) : null; + const incomingShareSubtitle = incomingShare + ? incomingShare.attachments.length === 0 + ? "Choose a project for what you shared" + : incomingShare.attachments.length === 1 + ? "Choose a project for the image you shared" + : `Choose a project for the ${incomingShare.attachments.length} images you shared` + : null; + const screenTitle = incomingShare ? "Start a task" : "Choose project"; const repositoryGroups = useMemo( () => groupProjectsByRepository({ projects, threads }), [projects, threads], @@ -109,6 +128,70 @@ export function NewTaskRouteScreen() { return nextItems; }, [repositoryGroups]); const projectEmptyState = deriveProjectEmptyState(catalogState); + const resumedDestinationKeyRef = useRef(null); + const reservedDestinationProject = incomingShare?.destination + ? (projects.find( + (project) => + project.environmentId === incomingShare.destination?.environmentId && + project.id === incomingShare.destination?.projectId, + ) ?? null) + : null; + + async function selectProject(item: (typeof items)[number]): Promise { + if (incomingShare?.destination && !reservedDestinationProject) { + try { + await releaseShareReservation(incomingShare.id, incomingShare.destination); + } catch (error) { + Alert.alert( + "Could not change project", + error instanceof Error + ? error.message + : "The shared content reservation could not be updated.", + ); + return; + } + } + navigation.navigate("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: item.environmentId, + projectId: item.id, + title: item.title, + incomingShareId: incomingShare?.id, + }, + }); + } + + useEffect(() => { + const destination = incomingShare?.destination; + if (!destination) { + resumedDestinationKeyRef.current = null; + return; + } + if (!isFocused) { + // Returning from the reserved draft is a fresh resume attempt. Keeping + // this latch set would leave every project row disabled with no route. + resumedDestinationKeyRef.current = null; + return; + } + const destinationKey = `${incomingShare.id}:${destination.environmentId}:${destination.projectId}`; + if (resumedDestinationKeyRef.current === destinationKey) { + return; + } + if (!reservedDestinationProject) { + return; + } + resumedDestinationKeyRef.current = destinationKey; + navigation.navigate("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: reservedDestinationProject.environmentId, + projectId: reservedDestinationProject.id, + title: reservedDestinationProject.title, + incomingShareId: incomingShare.id, + }, + }); + }, [incomingShare, isFocused, navigation, reservedDestinationProject]); return ( @@ -117,7 +200,8 @@ export function NewTaskRouteScreen() { {/* Android renders its own in-screen header instead of the native bar. */} navigation.goBack() : undefined} actions={[ { @@ -129,21 +213,29 @@ export function NewTaskRouteScreen() { /> ) : ( - - {layout.usesSplitView ? ( + <> + + + {layout.usesSplitView ? ( + navigation.goBack()} + separateBackground + /> + ) : null} navigation.goBack()} + icon="plus" + onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })} separateBackground /> - ) : null} - navigation.navigate("NewTaskSheet", { screen: "AddProject" })} - separateBackground - /> - + + )} - navigation.navigate("NewTaskSheet", { - screen: "NewTaskDraft", - params: { - environmentId: item.environmentId, - projectId: item.id, - title: item.title, - }, - }) - } + disabled={reservedDestinationProject !== null} + onPress={() => void selectProject(item)} className={cn( "bg-card px-4 py-3.5", !isFirst && "border-t border-border-subtle", diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index 58c13ae7432..c66fc788762 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -1,9 +1,10 @@ import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useState } from "react"; -import { Pressable, ScrollView, View } from "react-native"; +import { Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; import { cn } from "../../../lib/cn"; import { useEnvironmentQuery } from "../../../state/query"; @@ -57,115 +58,125 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { const disabledExistingBranches = new Set(disabledExistingBranchNames); return ( - - - - New branch - - - { - const branch = sanitizeFeatureBranchName(newBranchName.trim()); - if (branch.length === 0) return; - void gitActions.onCreateSelectedThreadBranch(branch).then(() => { - setNewBranchName(""); - navigation.goBack(); - }); - }} - /> - + + {Platform.OS === "android" ? ( + navigation.goBack()} /> + ) : null} + + + + New branch + + + { + const branch = sanitizeFeatureBranchName(newBranchName.trim()); + if (branch.length === 0) return; + void gitActions.onCreateSelectedThreadBranch(branch).then(() => { + setNewBranchName(""); + navigation.goBack(); + }); + }} + /> + - - - New worktree - - - - { - const baseBranch = worktreeBaseBranch.trim(); - const newBranch = worktreeBranchName.trim(); - if (baseBranch.length === 0 || newBranch.length === 0) return; - void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => { - setWorktreeBranchName(""); - navigation.goBack(); - }); - }} - /> - + + + New worktree + + + + { + const baseBranch = worktreeBaseBranch.trim(); + const newBranch = worktreeBranchName.trim(); + if (baseBranch.length === 0 || newBranch.length === 0) return; + void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => { + setWorktreeBranchName(""); + navigation.goBack(); + }); + }} + /> + - - - Existing branches - - {branchesLoading ? ( - Loading branches... - ) : null} - {!branchesLoading && availableBranches.length === 0 ? ( - - No local branches found. + + + Existing branches - ) : null} - {availableBranches.map((branch) => { - const disabled = disabledExistingBranches.has(branch.name); - const subtitle = branch.worktreePath - ? branch.worktreePath === currentWorktreePath - ? "Checked out in this thread" - : "Checked out in another worktree" - : branch.isDefault - ? "Default branch" - : "Local branch"; + {branchesLoading ? ( + + Loading branches... + + ) : null} + {!branchesLoading && availableBranches.length === 0 ? ( + + No local branches found. + + ) : null} + {availableBranches.map((branch) => { + const disabled = disabledExistingBranches.has(branch.name); + const subtitle = branch.worktreePath + ? branch.worktreePath === currentWorktreePath + ? "Checked out in this thread" + : "Checked out in another worktree" + : branch.isDefault + ? "Default branch" + : "Local branch"; - return ( - { - void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { - navigation.goBack(); - }); - }} - > - - {branch.name} - {subtitle} - - ); - })} - - + return ( + { + void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { + navigation.goBack(); + }); + }} + > + + {branch.name} + {subtitle} + + ); + })} + + + ); } diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index 2f158013046..cdc7f1a64a9 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -1,8 +1,9 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useState } from "react"; -import { Pressable, ScrollView, View } from "react-native"; +import { Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; import { cn } from "../../../lib/cn"; import { useEnvironmentQuery } from "../../../state/query"; @@ -65,160 +66,168 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { ); return ( - - - - Branch - - {gitStatus.data?.refName ?? "(detached HEAD)"} - - - {isDefaultRef ? ( - - Warning: this is the default branch. - - ) : null} - - - - - - Files - - {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions} + + {Platform.OS === "android" ? ( + navigation.goBack()} /> + ) : null} + + + + Branch + + {gitStatus.data?.refName ?? "(detached HEAD)"} - - {!allSelected && isEditingFiles ? ( + {isDefaultRef ? ( + + Warning: this is the default branch. + + ) : null} + + + + + + Files + + {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions} + + + + {!allSelected && isEditingFiles ? ( + setExcludedFiles(new Set())} + > + Reset + + ) : null} setExcludedFiles(new Set())} + onPress={() => setIsEditingFiles((current) => !current)} > - Reset + + {isEditingFiles ? "Done" : "Edit"} + - ) : null} - setIsEditingFiles((current) => !current)} - > - - {isEditingFiles ? "Done" : "Edit"} - - + - - {allFiles.length === 0 ? ( - - No changed files are available to commit. - - ) : !isEditingFiles ? ( - - {selectedFilePreview.map((file) => ( - - - {file.path} + {allFiles.length === 0 ? ( + + No changed files are available to commit. + + ) : !isEditingFiles ? ( + + {selectedFilePreview.map((file) => ( + + + {file.path} + + +{file.insertions} + -{file.deletions} + + ))} + {selectedFiles.length > selectedFilePreview.length ? ( + + +{selectedFiles.length - selectedFilePreview.length} more files - +{file.insertions} - -{file.deletions} - - ))} - {selectedFiles.length > selectedFilePreview.length ? ( - - +{selectedFiles.length - selectedFilePreview.length} more files - - ) : null} - - ) : ( - - {allFiles.map((file) => { - const included = !excludedFiles.has(file.path); - return ( - { - setExcludedFiles((current) => { - const next = new Set(current); - if (next.has(file.path)) { - next.delete(file.path); - } else { - next.add(file.path); - } - return next; - }); - }} - > - - - - - {file.path} - - {!included ? ( - - Excluded from this commit + ) : null} + + ) : ( + + {allFiles.map((file) => { + const included = !excludedFiles.has(file.path); + return ( + { + setExcludedFiles((current) => { + const next = new Set(current); + if (next.has(file.path)) { + next.delete(file.path); + } else { + next.add(file.path); + } + return next; + }); + }} + > + + + + + {file.path} - ) : null} - - - - +{file.insertions} - - -{file.deletions} + {!included ? ( + + Excluded from this commit + + ) : null} + + + + +{file.insertions} + + + -{file.deletions} + + - - - ); - })} - - )} - - - - Commit message - - + + ); + })} + + )} + - - - void runCommitAction(true)} + + Commit message + - - void runCommitAction(false)} - /> + + + + void runCommitAction(true)} + /> + + + void runCommitAction(false)} + /> + - - + + ); } diff --git a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx index e5bf3e6bb32..cddf1c614bd 100644 --- a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx @@ -4,10 +4,11 @@ import * as Arr from "effect/Array"; import * as Result from "effect/Result"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useMemo } from "react"; -import { View } from "react-native"; +import { Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; import { AppText as Text } from "../../../components/AppText"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; @@ -102,7 +103,11 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { return ( - + {Platform.OS === "android" ? ( + navigation.goBack()} /> + ) : ( + + )} diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 89e84df92a3..17e4de0ab6f 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -5,7 +5,12 @@ import { requiresDefaultBranchConfirmation, } from "@t3tools/client-runtime/state/vcs"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { + CommonActions, + StackActions, + useNavigation, + type StaticScreenProps, +} from "@react-navigation/native"; import { SymbolView } from "../../../components/AppSymbol"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; @@ -14,6 +19,7 @@ import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-scree import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../../lib/useThemeColor"; +import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; import { AppText as Text } from "../../../components/AppText"; import { nativeHeaderScrollEdgeEffects } from "../../../native/StackHeader"; import { tryOpenExternalUrl } from "../../../lib/openExternalUrl"; @@ -23,6 +29,7 @@ import { useSelectedThreadGitActions } from "../../../state/use-selected-thread- import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; import { vcsEnvironment } from "../../../state/vcs"; +import { resolveGitOverviewReviewNavigationAction } from "./git-overview-navigation"; import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -255,7 +262,14 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { title="Review changes" subtitle="Inspect turn diffs, worktree changes, and base branch diff" disabled={busy || !isRepo} - onPress={() => navigation.navigate("ThreadReview", { environmentId, threadId })} + onPress={() => { + const params = { environmentId, threadId }; + navigation.dispatch( + resolveGitOverviewReviewNavigationAction(presentation) === "replace" + ? StackActions.replace("ThreadReview", params) + : CommonActions.navigate("ThreadReview", params), + ); + }} /> - + {isInspector ? ( + + ) : null} {isInspector ? ( @@ -383,24 +399,19 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { ) : ( - // Compact header row: labeled branch on the left, status summary at - // the trailing end. Horizontal padding lines the text up with the - // rows' icon column inside the card below (20 screen + 16 card + 4 - // row). The sheet relies on pull-to-refresh instead of a corner - // refresh button. - - - - Branch - - - {currentBranchLabel} - - - - {currentStatusSummary} - - + navigation.goBack()} + actions={[ + { + accessibilityLabel: "Refresh repository status", + disabled: busy, + icon: "arrow.clockwise", + onPress: () => void gitActions.refreshSelectedThreadGitStatus(), + }, + ]} + /> )} {content} diff --git a/apps/mobile/src/features/threads/git/git-overview-navigation.test.ts b/apps/mobile/src/features/threads/git/git-overview-navigation.test.ts new file mode 100644 index 00000000000..b50bc42a9b3 --- /dev/null +++ b/apps/mobile/src/features/threads/git/git-overview-navigation.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveGitOverviewReviewNavigationAction } from "./git-overview-navigation"; + +describe("resolveGitOverviewReviewNavigationAction", () => { + it("replaces the sheet so Back returns directly to the thread", () => { + expect(resolveGitOverviewReviewNavigationAction("sheet")).toBe("replace"); + }); + + it("pushes Review normally when Git is a persistent inspector", () => { + expect(resolveGitOverviewReviewNavigationAction("inspector")).toBe("navigate"); + }); +}); diff --git a/apps/mobile/src/features/threads/git/git-overview-navigation.ts b/apps/mobile/src/features/threads/git/git-overview-navigation.ts new file mode 100644 index 00000000000..53927356c65 --- /dev/null +++ b/apps/mobile/src/features/threads/git/git-overview-navigation.ts @@ -0,0 +1,5 @@ +export function resolveGitOverviewReviewNavigationAction( + presentation: "sheet" | "inspector", +): "replace" | "navigate" { + return presentation === "sheet" ? "replace" : "navigate"; +} diff --git a/apps/mobile/src/lib/base64.ts b/apps/mobile/src/lib/base64.ts new file mode 100644 index 00000000000..640e2260be4 --- /dev/null +++ b/apps/mobile/src/lib/base64.ts @@ -0,0 +1,4 @@ +export function estimateBase64ByteSize(base64: string): number { + const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; + return Math.floor((base64.length * 3) / 4) - padding; +} diff --git a/apps/mobile/src/lib/composerImages.test.ts b/apps/mobile/src/lib/composerImages.test.ts index 40e00a271f7..21f2edaf52f 100644 --- a/apps/mobile/src/lib/composerImages.test.ts +++ b/apps/mobile/src/lib/composerImages.test.ts @@ -36,7 +36,37 @@ vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id", })); -import { convertPastedImagesToAttachments, isOwnedPastedImageUri } from "./composerImages"; +import { + convertPastedImagesToAttachments, + isOwnedPastedImageUri, + toUploadChatImageAttachments, +} from "./composerImages"; + +describe("toUploadChatImageAttachments", () => { + it("strips client draft id and previewUri for the startTurn wire shape", () => { + expect( + toUploadChatImageAttachments([ + { + id: "client-draft-id", + type: "image", + name: "pasted-image.png", + mimeType: "image/png", + sizeBytes: 12, + dataUrl: "data:image/png;base64,AA==", + previewUri: "file:///tmp/preview.png", + }, + ]), + ).toEqual([ + { + type: "image", + name: "pasted-image.png", + mimeType: "image/png", + sizeBytes: 12, + dataUrl: "data:image/png;base64,AA==", + }, + ]); + }); +}); describe("native pasted image cleanup", () => { beforeEach(() => { diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 13b53af724e..5c79b5b5eb8 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -3,6 +3,7 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type UploadChatImageAttachment, } from "@t3tools/contracts"; +import { estimateBase64ByteSize } from "./base64"; import { uuidv4 } from "./uuid"; export interface DraftComposerImageAttachment extends UploadChatImageAttachment { @@ -10,13 +11,21 @@ export interface DraftComposerImageAttachment extends UploadChatImageAttachment readonly previewUri: string; } -const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; - -function estimateBase64ByteSize(base64: string): number { - const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; - return Math.floor((base64.length * 3) / 4) - padding; +/** Wire shape for startTurn: pure uploads without client draft id / previewUri. */ +export function toUploadChatImageAttachments( + attachments: ReadonlyArray, +): ReadonlyArray { + return attachments.map((attachment) => ({ + type: attachment.type, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + dataUrl: attachment.dataUrl, + })); } +const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; + async function loadImagePicker() { try { return await import("expo-image-picker"); diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts index e3c2f744ada..85523175a2f 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.ts @@ -8,7 +8,7 @@ import { type RuntimeMode, } from "@t3tools/contracts"; -import type { DraftComposerImageAttachment } from "./composerImages"; +import { toUploadChatImageAttachments, type DraftComposerImageAttachment } from "./composerImages"; export function deriveThreadTitleFromPrompt(value: string): string { const trimmed = value.trim(); @@ -55,7 +55,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe messageId: MessageId.make(spec.messageId), role: "user" as const, text: spec.text, - attachments: spec.attachments, + attachments: toUploadChatImageAttachments(spec.attachments), }, modelSelection: spec.modelSelection, titleSeed: title, diff --git a/apps/mobile/src/lib/serialized-async-queue.test.ts b/apps/mobile/src/lib/serialized-async-queue.test.ts new file mode 100644 index 00000000000..71dc9aa765a --- /dev/null +++ b/apps/mobile/src/lib/serialized-async-queue.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { SerializedAsyncQueue } from "./serialized-async-queue"; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +describe("SerializedAsyncQueue", () => { + it("does not let a newer operation overtake an in-flight operation", async () => { + const queue = new SerializedAsyncQueue(); + const firstGate = deferred(); + const events: string[] = []; + + const first = queue.run(async () => { + events.push("first:start"); + await firstGate.promise; + events.push("first:end"); + }); + const second = queue.run(async () => { + events.push("second"); + }); + + await Promise.resolve(); + expect(events).toEqual(["first:start"]); + firstGate.resolve(); + await Promise.all([first, second]); + expect(events).toEqual(["first:start", "first:end", "second"]); + }); + + it("continues after a rejected operation", async () => { + const queue = new SerializedAsyncQueue(); + const first = queue.run(async () => { + throw new Error("failed"); + }); + const second = queue.run(async () => "recovered"); + + await expect(first).rejects.toThrow("failed"); + await expect(second).resolves.toBe("recovered"); + }); +}); diff --git a/apps/mobile/src/lib/serialized-async-queue.ts b/apps/mobile/src/lib/serialized-async-queue.ts new file mode 100644 index 00000000000..e989eb7da65 --- /dev/null +++ b/apps/mobile/src/lib/serialized-async-queue.ts @@ -0,0 +1,16 @@ +/** + * Runs asynchronous operations in call order while keeping the queue usable + * after an individual operation rejects. + */ +export class SerializedAsyncQueue { + private tail: Promise = Promise.resolve(); + + run(operation: () => Promise): Promise { + const result = this.tail.then(operation, operation); + this.tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } +} diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index e2708757995..bbcbb8e4282 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -195,7 +195,7 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { if (typeName === "NativeHeaderToolbarButton") { return { type: "button", - label: "", + label: typeof child.props.label === "string" ? child.props.label : "", accessibilityLabel: typeof child.props.accessibilityLabel === "string" ? child.props.accessibilityLabel @@ -293,6 +293,7 @@ function NativeHeaderToolbarButton(_props: { readonly accessibilityLabel?: string; readonly disabled?: boolean; readonly icon?: string; + readonly label?: string; readonly onPress?: () => void; readonly separateBackground?: boolean; readonly tintColor?: ColorValue; diff --git a/apps/mobile/src/state/thread-outbox-manager.ts b/apps/mobile/src/state/thread-outbox-manager.ts index fc3e5069dc9..19f89d13c51 100644 --- a/apps/mobile/src/state/thread-outbox-manager.ts +++ b/apps/mobile/src/state/thread-outbox-manager.ts @@ -101,7 +101,10 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { cause, }); } - setMessages([...currentMessages(), message]); + setMessages([ + ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId), + message, + ]); }); // Rewrites an already-queued message. A no-op when the message has been diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index 2ccaa471c87..6c665c432f4 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -294,6 +294,31 @@ describe("thread outbox", () => { registry.dispose(); }); + it("replaces an existing message when an enqueue retry uses the same id", async () => { + const registry = AtomRegistry.make(); + const manager = createThreadOutboxManager({ + registry, + storage: { + load: async () => [], + write: async () => undefined, + remove: async () => undefined, + }, + }); + const message = queuedMessage({ + messageId: "message-1", + createdAt: "2026-06-08T10:00:01.000Z", + }); + const retried = { ...message, text: "retried" }; + + await manager.enqueue(message); + await manager.enqueue(retried); + + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ + "environment-1:thread-1": [retried], + }); + registry.dispose(); + }); + it("updates a queued message in place but never resurrects a removed one", async () => { const registry = AtomRegistry.make(); const stored = new Map(); diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index d02abb6a265..fdabe67bc71 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -8,7 +8,9 @@ import { decodePersistedComposerDrafts, type ComposerDraft, getComposerDraftSnapshot, + mergeComposerDraftContentState, removeComposerDraftsForEnvironment, + restoreComposerDraftSnapshotState, } from "./use-composer-drafts"; const DRAFT: ComposerDraft = { @@ -94,6 +96,7 @@ describe("mobile composer drafts", () => { const draft: ComposerDraft = { text: "send this", attachments: [], + importedShareIds: ["share-1"], modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", @@ -108,7 +111,8 @@ describe("mobile composer drafts", () => { expect(clearComposerDraftContentState({ [draftKey]: draft }, draftKey)).toEqual({ [draftKey]: { - ...draft, + modelSelection: draft.modelSelection, + workspaceSelection: draft.workspaceSelection, text: "", attachments: [], }, @@ -131,6 +135,90 @@ describe("mobile composer drafts", () => { expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft); }); + it("merges shared content into a project draft without duplicating retries", () => { + const draftKey = "new-task:environment-1:project-1"; + const sharedAttachment = { + id: "share-1:image:0", + type: "image" as const, + name: "Screenshot.png", + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + previewUri: "data:image/png;base64,YWJj", + }; + const existing: Record = { + [draftKey]: { text: "Existing context", attachments: [] }, + }; + const content = { + text: "Shared note", + attachments: [sharedAttachment], + sourceShareId: "share-1", + }; + + const merged = mergeComposerDraftContentState(existing, draftKey, content); + expect(merged[draftKey]).toMatchObject({ + text: "Existing context\n\nShared note", + attachments: [sharedAttachment], + importedShareIds: ["share-1"], + }); + expect(mergeComposerDraftContentState(merged, draftKey, content)).toBe(merged); + + const edited = { + ...merged, + [draftKey]: { ...merged[draftKey]!, text: "User edited the imported context" }, + }; + expect(mergeComposerDraftContentState(edited, draftKey, content)).toBe(edited); + }); + + it("preserves existing images when shared content exceeds the draft attachment limit", () => { + const draftKey = "new-task:environment-1:project-1"; + const image = (id: string) => ({ + id, + type: "image" as const, + name: `${id}.png`, + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + previewUri: "data:image/png;base64,YWJj", + }); + const existingImage = image("existing"); + const sharedImages = Array.from({ length: 8 }, (_, index) => image(`shared-${index}`)); + + const merged = mergeComposerDraftContentState( + { [draftKey]: { text: "", attachments: [existingImage] } }, + draftKey, + { text: "", attachments: sharedImages }, + ); + + expect(merged[draftKey]?.attachments).toHaveLength(8); + expect(merged[draftKey]?.attachments[0]).toEqual(existingImage); + expect(merged[draftKey]?.attachments.at(-1)?.id).toBe("shared-6"); + }); + + it("restores the exact draft captured before an interrupted share import", () => { + const draftKey = "new-task:environment-1:project-1"; + const beforeImport: ComposerDraft = { + text: "Existing context", + attachments: [], + runtimeMode: "approval-required", + }; + const imported: ComposerDraft = { + ...beforeImport, + text: "Existing context\n\nShared note", + importedShareIds: ["share-1"], + }; + + expect( + restoreComposerDraftSnapshotState({ [draftKey]: imported }, draftKey, beforeImport), + ).toEqual({ [draftKey]: beforeImport }); + expect( + restoreComposerDraftSnapshotState({ [draftKey]: imported }, draftKey, { + text: "", + attachments: [], + }), + ).toEqual({}); + }); + it("removes only drafts owned by the selected environment", () => { const environmentId = EnvironmentId.make("environment-cloud"); const retainedEnvironmentId = EnvironmentId.make("environment-local"); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index aa2e2dd5cca..cdef999e043 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1,6 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import { ModelSelection as ModelSelectionSchema, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderInteractionMode as ProviderInteractionModeSchema, RuntimeMode as RuntimeModeSchema, type EnvironmentId, @@ -14,6 +15,7 @@ import { Atom } from "effect/unstable/reactivity"; import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; +import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; import { appAtomRegistry } from "./atom-registry"; const COMPOSER_DRAFTS_SCHEMA_VERSION = 1; @@ -38,12 +40,19 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass; + readonly importedShareIds?: ReadonlyArray; readonly modelSelection?: ModelSelection; readonly runtimeMode?: RuntimeMode; readonly interactionMode?: ProviderInteractionMode; readonly workspaceSelection?: ComposerDraftWorkspaceSelection; } +export interface ComposerDraftContent { + readonly text: string; + readonly attachments: ReadonlyArray; + readonly sourceShareId?: string; +} + export interface ComposerDraftWorkspaceSelection { readonly mode: "local" | "worktree"; readonly branch: string | null; @@ -66,6 +75,7 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ const ComposerDraftSchema = Schema.Struct({ text: Schema.String, attachments: Schema.Array(DraftComposerImageAttachmentSchema), + importedShareIds: Schema.optional(Schema.Array(Schema.String)), modelSelection: Schema.optional(ModelSelectionSchema), runtimeMode: Schema.optional(RuntimeModeSchema), interactionMode: Schema.optional(ProviderInteractionModeSchema), @@ -93,6 +103,7 @@ export const composerDraftsAtom = Atom.make>({}).p let loadPromise: Promise | null = null; let persistTimer: ReturnType | null = null; +const persistenceQueue = new SerializedAsyncQueue(); function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { if (!draft) { @@ -193,7 +204,7 @@ async function writePersistedComposerDrafts(drafts: Record): Promise { try { - await writePersistedComposerDrafts(drafts); + await persistenceQueue.run(() => writePersistedComposerDrafts(drafts)); } catch (error) { console.warn("[composer-drafts] failed to persist drafts", error); // Draft persistence is best-effort; in-memory drafts still keep working. @@ -366,8 +377,9 @@ export function clearComposerDraftContentState( if (!existing) { return current; } + const { importedShareIds: _importedShareIds, ...retained } = existing; const draft = { - ...existing, + ...retained, text: "", attachments: [], }; @@ -382,6 +394,138 @@ export function clearComposerDraftContentState( }; } +export function restoreComposerDraftSnapshotState( + current: Record, + draftKey: string, + snapshot: ComposerDraft, +): Record { + const next = { ...current }; + if (isEmptyDraft(snapshot)) { + delete next[draftKey]; + } else { + next[draftKey] = snapshot; + } + return next; +} + +function mergeComposerDraftText(existing: string, incoming: string): string { + if (incoming.length === 0) { + return existing; + } + if (existing.length === 0) { + return incoming; + } + // Import retries are possible after an interrupted native handoff. Keep the + // operation idempotent when the same shared text is already present. + if (existing === incoming || existing.endsWith(`\n\n${incoming}`)) { + return existing; + } + return `${existing}\n\n${incoming}`; +} + +export function mergeComposerDraftContentState( + current: Record, + draftKey: string, + content: ComposerDraftContent, +): Record { + const existing = normalizeDraft(current[draftKey]); + if (content.sourceShareId && existing.importedShareIds?.includes(content.sourceShareId)) { + return current; + } + const attachmentIds = new Set(existing.attachments.map((attachment) => attachment.id)); + const incomingAttachments = content.attachments.filter((attachment) => { + if (attachmentIds.has(attachment.id)) { + return false; + } + attachmentIds.add(attachment.id); + return true; + }); + const attachments = [...existing.attachments, ...incomingAttachments].slice( + 0, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + ); + const text = mergeComposerDraftText(existing.text, content.text); + const importedShareIds = content.sourceShareId + ? [...(existing.importedShareIds ?? []), content.sourceShareId] + : existing.importedShareIds; + if ( + text === existing.text && + attachments.length === existing.attachments.length && + importedShareIds === existing.importedShareIds + ) { + return current; + } + return { + ...current, + [draftKey]: { + ...existing, + text, + attachments, + ...(importedShareIds ? { importedShareIds } : {}), + }, + }; +} + +/** + * Atomically moves an incoming share into a project-scoped composer draft. + * The durable write happens before the share inbox item can be acknowledged. + */ +export async function mergeComposerDraftContent( + draftKey: string, + content: ComposerDraftContent, +): Promise<{ readonly skippedAttachmentCount: number }> { + ensureComposerDraftsLoaded(); + if (loadPromise !== null) { + await loadPromise; + } + if (persistTimer !== null) { + clearTimeout(persistTimer); + persistTimer = null; + } + const current = appAtomRegistry.get(composerDraftsAtom); + const next = mergeComposerDraftContentState(current, draftKey, content); + const currentAttachmentIds = new Set( + normalizeDraft(current[draftKey]).attachments.map((attachment) => attachment.id), + ); + const nextAttachmentIds = new Set( + normalizeDraft(next[draftKey]).attachments.map((attachment) => attachment.id), + ); + const skippedAttachmentCount = content.attachments.filter( + (attachment) => + !currentAttachmentIds.has(attachment.id) && !nextAttachmentIds.has(attachment.id), + ).length; + // Publish the content and its import receipt together before the filesystem + // await. Typing during persistence then builds on the receipt-bearing state, + // and its debounced write is serialized after this transaction. + if (next !== current) { + appAtomRegistry.set(composerDraftsAtom, next); + } + await persistenceQueue.run(() => writePersistedComposerDrafts(next)); + return { skippedAttachmentCount }; +} + +/** Restores the exact content/settings captured before an interrupted import. */ +export async function restoreComposerDraftSnapshot( + draftKey: string, + snapshot: ComposerDraft, +): Promise { + ensureComposerDraftsLoaded(); + if (loadPromise !== null) { + await loadPromise; + } + if (persistTimer !== null) { + clearTimeout(persistTimer); + persistTimer = null; + } + const next = restoreComposerDraftSnapshotState( + appAtomRegistry.get(composerDraftsAtom), + draftKey, + snapshot, + ); + appAtomRegistry.set(composerDraftsAtom, next); + await persistenceQueue.run(() => writePersistedComposerDrafts(next)); +} + export function clearComposerDraftContent(draftKey: string): void { updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey)); } diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index f4de9c39492..3559fa140fe 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -17,6 +17,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; +import { toUploadChatImageAttachments } from "../lib/composerImages"; import { randomHex } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; import { useProjects, useThreadShells } from "./entities"; @@ -221,7 +222,7 @@ export function useThreadOutboxDrain(): void { messageId: queuedMessage.messageId, role: "user", text: queuedMessage.text, - attachments: queuedMessage.attachments, + attachments: toUploadChatImageAttachments(queuedMessage.attachments), }, modelSelection: settings.modelSelection, runtimeMode: settings.runtimeMode, diff --git a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts index b36c2b2d496..7e4e88aeb2b 100644 --- a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts +++ b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts @@ -62,7 +62,7 @@ const promptText = NodeProcess.argv[4] ?? "helo"; const targetReasoning = NodeProcess.env.CURSOR_REASONING ?? ""; const targetContext = NodeProcess.env.CURSOR_CONTEXT ?? ""; const targetFast = NodeProcess.env.CURSOR_FAST ?? ""; -const agentBin = NodeProcess.env.CURSOR_AGENT_BIN ?? "agent"; +const agentBin = NodeProcess.env.CURSOR_AGENT_BIN ?? "cursor-agent"; const promptWaitMs = Number(NodeProcess.env.CURSOR_PROMPT_WAIT_MS ?? "4000"); const requestTimeoutMs = Number(NodeProcess.env.CURSOR_REQUEST_TIMEOUT_MS ?? "20000"); diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index f790e71f5cd..06a22754e55 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1,5 +1,6 @@ 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 Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -228,6 +229,7 @@ describe("AssetAccess", () => { const fallbackResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, }); + expect(fallbackResult.relativeUrl.endsWith(`/${PROJECT_FAVICON_FALLBACK_MARKER}`)).toBe(true); const fallbackSuffix = fallbackResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); const fallbackSeparatorIndex = fallbackSuffix.indexOf("/"); expect( @@ -235,7 +237,7 @@ describe("AssetAccess", () => { fallbackSuffix.slice(0, fallbackSeparatorIndex), fallbackSuffix.slice(fallbackSeparatorIndex + 1), ), - ).toEqual({ kind: "project-favicon-fallback" }); + ).toBeNull(); }).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 8d8ecbc2af3..b469e0e315b 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -19,6 +19,7 @@ import { WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, } from "@t3tools/shared/filePreview"; +import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -40,7 +41,6 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; export const ASSET_ROUTE_PREFIX = "/api/assets"; -export const FALLBACK_PROJECT_FAVICON_SVG = ``; const SIGNING_SECRET_NAME = "asset-access-signing-key"; const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; @@ -91,9 +91,7 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema); const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); -export type ResolvedAsset = - | { readonly kind: "file"; readonly path: string } - | { readonly kind: "project-favicon-fallback" }; +export type ResolvedAsset = { readonly kind: "file"; readonly path: string }; function decodeClaims(encodedPayload: string): AssetClaims | null { try { @@ -326,7 +324,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i relativePath, expiresAt, }; - fileName = relativePath ? path.basename(relativePath) : "favicon.svg"; + fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER; break; } } @@ -391,9 +389,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( } if (claims.kind === "project-favicon") { - if (claims.relativePath === null) { - return { kind: "project-favicon-fallback" } satisfies ResolvedAsset; - } + if (claims.relativePath === null) return null; const faviconPath = yield* resolveCanonicalWorkspaceFileForRequest({ workspaceRoot: claims.workspaceRoot, relativePath: claims.relativePath, diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index ed21ed00fb2..2a8f29ddb70 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -26,11 +26,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { OtlpTracer } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts"; -import { - ASSET_ROUTE_PREFIX, - FALLBACK_PROJECT_FAVICON_SVG, - resolveAsset, -} from "./assets/AssetAccess.ts"; +import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; @@ -206,17 +202,6 @@ export const assetRouteLayer = HttpRouter.add( if (!asset) { return HttpServerResponse.text("Not Found", { status: 404 }); } - if (asset.kind === "project-favicon-fallback") { - return HttpServerResponse.text(FALLBACK_PROJECT_FAVICON_SVG, { - status: 200, - contentType: "image/svg+xml", - headers: { - "Cache-Control": "private, max-age=3600", - "X-Content-Type-Options": "nosniff", - }, - }); - } - return yield* HttpServerResponse.file(asset.path, { status: 200, headers: { diff --git a/apps/server/src/imageMime.test.ts b/apps/server/src/imageMime.test.ts index cc1cc2ff776..e87e4dcbd0d 100644 --- a/apps/server/src/imageMime.test.ts +++ b/apps/server/src/imageMime.test.ts @@ -32,6 +32,63 @@ describe("imageMime", () => { }); }); + it("rejects payload with characters outside the base64 alphabet", () => { + expect(parseBase64DataUrl("data:image/png;base64,SGVs!bG8=")).toBeNull(); + expect(parseBase64DataUrl("data:image/png;base64,SGVs,bG8=")).toBeNull(); + }); + + it("rejects structurally malformed base64", () => { + // '=' before the trailing padding position + expect(parseBase64DataUrl("data:image/png;base64,AB=CD===")).toBeNull(); + expect(parseBase64DataUrl("data:image/png;base64,SGV=bG8=")).toBeNull(); + // more than two padding characters + expect(parseBase64DataUrl("data:image/png;base64,SGVsbG8=====AAA")).toBeNull(); + // length not a multiple of 4 + expect(parseBase64DataUrl("data:image/png;base64,SGVsbG8")).toBeNull(); + }); + + it("accepts base64 with one or two trailing padding characters", () => { + expect(parseBase64DataUrl("data:image/png;base64,SGVsbA==")).toEqual({ + mimeType: "image/png", + base64: "SGVsbA==", + }); + expect(parseBase64DataUrl("data:image/png;base64,SGVsbG8h")).toEqual({ + mimeType: "image/png", + base64: "SGVsbG8h", + }); + }); + + it("rejects empty and whitespace-only payloads", () => { + expect(parseBase64DataUrl("data:image/png;base64,")).toBeNull(); + expect(parseBase64DataUrl("data:image/png;base64, \r\n")).toBeNull(); + }); + + it("parses a case-insensitive scheme and mime type", () => { + expect(parseBase64DataUrl("DATA:IMAGE/PNG;BASE64,SGVsbG8=")).toEqual({ + mimeType: "image/png", + base64: "SGVsbG8=", + }); + }); + + it("parses a multi-megabyte payload from a deep call stack", () => { + // Regression: matching the payload with a regex borrowed the JS call + // stack, so a ~10 MB image parsed inside fiber execution threw + // "RangeError: Maximum call stack size exceeded". + const dataUrl = `data:image/png;base64,${"A".repeat(14_000_000)}`; + const atDepth = (depth: number): ReturnType => + depth === 0 ? parseBase64DataUrl(dataUrl) : atDepth(depth - 1); + const findMaxDepth = (depth: number): number => { + try { + return findMaxDepth(depth + 1); + } catch { + return depth; + } + }; + const result = atDepth(Math.floor(findMaxDepth(0) * 0.85)); + expect(result?.mimeType).toBe("image/png"); + expect(result?.base64.length).toBe(14_000_000); + }); + it("does not read inherited keys from mime extension map", () => { expect(inferImageExtension({ mimeType: "constructor" })).toBe(".bin"); }); diff --git a/apps/server/src/imageMime.ts b/apps/server/src/imageMime.ts index c8761285abe..66ce6096e85 100644 --- a/apps/server/src/imageMime.ts +++ b/apps/server/src/imageMime.ts @@ -29,17 +29,43 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ ".webp", ]); +// Whether `code` is a character the base64 payload may contain, aside from +// the whitespace handled separately below. +function isBase64Char(code: number): boolean { + return ( + (code >= 0x61 && code <= 0x7a) || // a-z + (code >= 0x41 && code <= 0x5a) || // A-Z + (code >= 0x30 && code <= 0x39) || // 0-9 + code === 0x2b || // + + code === 0x2f || // / + code === 0x3d // = + ); +} + +function isBase64Whitespace(code: number): boolean { + return code === 0x0d || code === 0x0a || code === 0x20; // \r \n space +} + +// Data URLs carry the full image payload, so this parser must never run a +// regex across the payload: V8's regex engine borrows the JS call stack, and +// matching a multi-megabyte string from a deep call stack (e.g. inside fiber +// execution) throws "Maximum call stack size exceeded". export function parseBase64DataUrl( dataUrl: string, ): { readonly mimeType: string; readonly base64: string } | null { - const match = /^data:([^,]+),([a-z0-9+/=\r\n ]+)$/i.exec(dataUrl.trim()); - if (!match) return null; + const trimmed = dataUrl.trim(); + if (trimmed.slice(0, 5).toLowerCase() !== "data:") return null; + + const commaIndex = trimmed.indexOf(","); + if (commaIndex === -1) return null; + const header = trimmed.slice(5, commaIndex); + if (header.length === 0) return null; const headerParts: Array = []; - for (const part of (match[1] ?? "").split(";")) { - const trimmed = part.trim(); - if (trimmed.length > 0) { - headerParts.push(trimmed); + for (const part of header.split(";")) { + const partTrimmed = part.trim(); + if (partTrimmed.length > 0) { + headerParts.push(partTrimmed); } } if (headerParts.length < 2) { @@ -51,8 +77,37 @@ export function parseBase64DataUrl( } const mimeType = headerParts[0]?.toLowerCase(); - const base64 = match[2]?.replace(/\s+/g, ""); - if (!mimeType || !base64) return null; + if (!mimeType) return null; + + const payload = trimmed.slice(commaIndex + 1); + const runs: Array = []; + let runStart = -1; + for (let index = 0; index < payload.length; index += 1) { + const code = payload.charCodeAt(index); + if (isBase64Char(code)) { + if (runStart === -1) runStart = index; + continue; + } + if (!isBase64Whitespace(code)) return null; + if (runStart !== -1) { + runs.push(payload.slice(runStart, index)); + runStart = -1; + } + } + if (runStart !== -1) { + runs.push(payload.slice(runStart)); + } + const base64 = runs.length === 1 ? runs[0]! : runs.join(""); + if (base64.length === 0 || base64.length % 4 !== 0) return null; + const firstPad = base64.indexOf("="); + if (firstPad !== -1) { + // '=' is only valid as one or two trailing padding characters; Node's + // decoder would otherwise silently truncate at the first '='. + if (base64.length - firstPad > 2) return null; + for (let index = firstPad; index < base64.length; index += 1) { + if (base64.charCodeAt(index) !== 0x3d) return null; + } + } return { mimeType, base64 }; } diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index a51ad20afbe..2eef6ac8416 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -411,6 +411,28 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); + it.effect("replacing with a rule that already exists elsewhere does not duplicate it", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+r", command: "script.run-tests.run" }, + { key: "mod+alt+r", command: "script.run-tests.run" }, + ]); + yield* Effect.gen(function* () { + const keybindings = yield* Keybindings.Keybindings; + return yield* keybindings.upsertKeybindingRule({ + key: "mod+alt+r", + command: "script.run-tests.run", + replace: { key: "mod+r", command: "script.run-tests.run" }, + }); + }); + + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + const persistedView = persisted.map(({ key, command }) => ({ key, command })); + assert.deepEqual(persistedView, [{ key: "mod+alt+r", command: "script.run-tests.run" }]); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + it.effect("removes only the targeted custom keybinding", () => Effect.gen(function* () { const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 5ddae4943f8..18a78fe3623 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -644,7 +644,9 @@ const make = Effect.gen(function* () { const nextConfig = [ ...customConfig.filter((entry) => { if (replaceTarget) { - return !isSameKeybindingRule(entry, replaceTarget); + return ( + !isSameKeybindingRule(entry, replaceTarget) && !isSameKeybindingRule(entry, rule) + ); } return !isSameKeybindingRule(entry, rule); }), diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index f5ab794bce7..b59ded77f4f 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -6,6 +6,7 @@ import type { ProjectId, ThreadId, } from "@t3tools/contracts"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; import * as Effect from "effect/Effect"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; @@ -71,6 +72,30 @@ export function requireProjectAbsent(input: { ); } +export function requireActiveProjectWorkspaceRootAbsent(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly workspaceRoot: string; + readonly exceptProjectId?: ProjectId; +}): Effect.Effect { + const normalizedWorkspaceRoot = normalizeProjectPathForComparison(input.workspaceRoot); + const existingProject = input.readModel.projects.find( + (project) => + project.deletedAt === null && + normalizeProjectPathForComparison(project.workspaceRoot) === normalizedWorkspaceRoot && + project.id !== input.exceptProjectId, + ); + if (existingProject === undefined) { + return Effect.void; + } + return Effect.fail( + invariantError( + input.command.type, + `Active project '${existingProject.id}' already exists for workspace root '${normalizedWorkspaceRoot}'.`, + ), + ); +} + export function requireThread(input: { readonly readModel: OrchestrationReadModel; readonly command: OrchestrationCommand; diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 64ba159c740..a0c06840733 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -94,6 +94,117 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { }), ); + it.effect("rejects project.create for an active workspace root that already exists", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const initial = createEmptyReadModel(now); + const readModel = yield* projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project-create"), + aggregateKind: "project", + aggregateId: asProjectId("project-existing"), + type: "project.created", + occurredAt: now, + commandId: CommandId.make("cmd-project-create"), + causationEventId: null, + correlationId: CommandId.make("cmd-project-create"), + metadata: {}, + payload: { + projectId: asProjectId("project-existing"), + title: "Project", + workspaceRoot: "/tmp/project", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + const failure = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "project.create", + commandId: CommandId.make("cmd-project-create-duplicate-root"), + projectId: asProjectId("project-duplicate-root"), + title: "Duplicate Project", + workspaceRoot: "/tmp/project/", + createdAt: now, + }, + readModel, + }), + ); + + expect(failure.message).toContain( + "Active project 'project-existing' already exists for workspace root '/tmp/project'.", + ); + }), + ); + + it.effect("rejects project.meta.update when moving onto another active workspace root", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const initial = createEmptyReadModel(now); + const withFirstProject = yield* projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project-create-first"), + aggregateKind: "project", + aggregateId: asProjectId("project-first"), + type: "project.created", + occurredAt: now, + commandId: CommandId.make("cmd-project-create-first"), + causationEventId: null, + correlationId: CommandId.make("cmd-project-create-first"), + metadata: {}, + payload: { + projectId: asProjectId("project-first"), + title: "First", + workspaceRoot: "/tmp/project-first", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + const readModel = yield* projectEvent(withFirstProject, { + sequence: 2, + eventId: asEventId("evt-project-create-second"), + aggregateKind: "project", + aggregateId: asProjectId("project-second"), + type: "project.created", + occurredAt: now, + commandId: CommandId.make("cmd-project-create-second"), + causationEventId: null, + correlationId: CommandId.make("cmd-project-create-second"), + metadata: {}, + payload: { + projectId: asProjectId("project-second"), + title: "Second", + workspaceRoot: "/tmp/project-second", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + const failure = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-update-duplicate-root"), + projectId: asProjectId("project-second"), + workspaceRoot: "/tmp/project-first", + }, + readModel, + }), + ); + + expect(failure.message).toContain( + "Active project 'project-first' already exists for workspace root '/tmp/project-first'.", + ); + }), + ); + it.effect("emits user message and turn-start-requested events for thread.turn.start", () => Effect.gen(function* () { const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 492a865b5d6..7c81f389169 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -12,6 +12,7 @@ import type * as PlatformError from "effect/PlatformError"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; import { listThreadsByProjectId, + requireActiveProjectWorkspaceRootAbsent, requireProject, requireProjectAbsent, requireThread, @@ -111,6 +112,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, projectId: command.projectId, }); + yield* requireActiveProjectWorkspaceRootAbsent({ + readModel, + command, + workspaceRoot: command.workspaceRoot, + exceptProjectId: command.projectId, + }); return { ...(yield* withEventBase({ @@ -138,6 +145,14 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, projectId: command.projectId, }); + if (command.workspaceRoot !== undefined) { + yield* requireActiveProjectWorkspaceRootAbsent({ + readModel, + command, + workspaceRoot: command.workspaceRoot, + exceptProjectId: command.projectId, + }); + } const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts index a3475d2f190..2ccdd862522 100644 --- a/apps/server/src/persistence/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -1,7 +1,9 @@ +import * as Arr from "effect/Array"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -280,18 +282,30 @@ export const make = Effect.gen(function* () { ), ), Effect.flatMap((rows) => + // Skip rows that no longer decode (e.g. written by an older build) + // instead of failing the whole list — one stale row must not disable + // every consumer that enumerates sessions, such as the reaper. Effect.forEach(rows, (row) => decodeRuntimeRow(row).pipe( - Effect.mapError((cause) => - PersistenceDecodeError.fromSchemaError( - "ProviderSessionRuntimeRepository.list:decodeRows", - cause, - { threadId: row.threadId }, - ), + Effect.map(Option.some), + Effect.catch((cause) => + Effect.logWarning("provider.session.runtime.row-skipped", { + threadId: row.threadId, + error: PersistenceDecodeError.fromSchemaError( + "ProviderSessionRuntimeRepository.list:decodeRows", + cause, + { threadId: row.threadId }, + ).message, + }).pipe(Effect.as(Option.none())), ), ), ), ), + Effect.map((decoded) => + Arr.filterMap(decoded, (row) => + Option.isSome(row) ? Result.succeed(row.value) : Result.failVoid, + ), + ), ); const deleteByThreadId: ProviderSessionRuntimeRepository["Service"]["deleteByThreadId"] = ( diff --git a/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts b/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts index f7425200fd1..379b06e2a22 100644 --- a/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts +++ b/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts @@ -182,7 +182,7 @@ describe("persistence error correlation", () => { }).pipe(Effect.provide(authPairingLinkLayer)), ); - it.effect("correlates provider runtime SQL and per-row decode failures by thread", () => + it.effect("skips undecodable provider runtime rows and correlates SQL failures by thread", () => Effect.gen(function* () { const runtimes = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const sql = yield* SqlClient.SqlClient; @@ -215,16 +215,24 @@ describe("persistence error correlation", () => { ) `; - const decodeError = yield* Effect.flip(runtimes.list()); - assert.instanceOf(decodeError, PersistenceErrors.PersistenceDecodeError); - assert.deepStrictEqual(decodeError.correlation, { threadId }); - assert.equal( - decodeError.message, - `Decode error in ProviderSessionRuntimeRepository.list:decodeRows: ${decodeError.issue}`, + const validThreadId = ThreadId.make("thread-valid"); + yield* runtimes.upsert({ + threadId: validThreadId, + providerName: "codex", + providerInstanceId: null, + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt, + resumeCursor: null, + runtimePayload: null, + }); + + const listed = yield* runtimes.list(); + assert.deepStrictEqual( + listed.map((runtime) => runtime.threadId), + [validThreadId], ); - assert.notInclude(decodeError.issue, runtimePayload); - assert.notInclude(decodeError.message, runtimePayload); - assert.notInclude(decodeError.message, lastSeenAt); yield* sql`DROP TABLE provider_session_runtime`; const sqlFailure = yield* Effect.flip( diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index b46a4ce1ba3..35ffd1a4756 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -1,3 +1,5 @@ +import type { ProviderInteractionMode } from "@t3tools/contracts"; + const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = ` ## T3 Code collaborative browser @@ -145,3 +147,26 @@ The \`request_user_input\` tool is unavailable in Default mode. If you call it w In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message. ${T3_CODE_BROWSER_TOOL_INSTRUCTIONS} `; + +export interface CodexRuntimeInfo { + readonly model: string; + readonly reasoningEffort: string; +} + +// Values come from trusted config, but keep the block single-line regardless. +function toSingleLine(value: string): string { + return value.replaceAll(/\s+/g, " ").trim(); +} + +export function buildCodexDeveloperInstructions( + interactionMode: ProviderInteractionMode, + runtime: CodexRuntimeInfo, +): string { + const base = + interactionMode === "plan" + ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS + : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS; + return `${base} + +In case you're asked: you are running in T3 Code through the Codex harness, as ${toSingleLine(runtime.model)} with ${toSingleLine(runtime.reasoningEffort)} reasoning effort. No need to mention this otherwise.`; +} diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index f2b04b3a282..ee9cddf949c 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -119,6 +119,7 @@ export const ClaudeDriver: ProviderDriver = { Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; + const { cwd } = yield* ServerConfig; const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; @@ -154,11 +155,11 @@ export const ClaudeDriver: ProviderDriver = { capacity: 1, timeToLive: CAPABILITIES_PROBE_TTL, lookup: () => - probeClaudeCapabilities(effectiveConfig, processEnv).pipe( + probeClaudeCapabilities(effectiveConfig, processEnv, cwd).pipe( Effect.provideService(Path.Path, path), ), }); - const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig); + const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); const checkProvider = checkClaudeProviderStatus( effectiveConfig, diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index 87298f80125..a7ff41fc89e 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -31,14 +31,23 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { const resolved = path.resolve(NodeOS.homedir(), ".claude-work"); expect(yield* resolveClaudeHomePath({ homePath })).toBe(resolved); - expect((yield* makeClaudeEnvironment({ homePath })).HOME).toBe(resolved); + expect((yield* makeClaudeEnvironment({ homePath })).CLAUDE_CONFIG_DIR).toBe(resolved); expect(yield* makeClaudeContinuationGroupKey({ homePath })).toBe(`claude:home:${resolved}`); expect(yield* makeClaudeCapabilitiesCacheKey({ binaryPath: "claude", homePath })).toBe( - `claude\0${resolved}`, + `claude\0${resolved}\0`, ); }), ); + it.effect("separates capability probes by cwd", () => + Effect.gen(function* () { + const config = { binaryPath: "claude", homePath: "" }; + const first = yield* makeClaudeCapabilitiesCacheKey(config, "/repo-a"); + const second = yield* makeClaudeCapabilitiesCacheKey(config, "/repo-b"); + expect(first).not.toBe(second); + }), + ); + it.effect("keeps continuation compatible across instances with the same Claude HOME", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index 65c74f9764a..b3ef22b640c 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -24,7 +24,13 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function const resolvedHomePath = yield* resolveClaudeHomePath(config); return { ...resolvedBaseEnv, - HOME: resolvedHomePath, + // Isolate this instance's config via CLAUDE_CONFIG_DIR rather than HOME. + // Overriding HOME also relocates the macOS login keychain lookup + // ($HOME/Library/Keychains), so the spawned CLI can't find its stored + // OAuth credentials and reports "Not logged in". CLAUDE_CONFIG_DIR points + // Claude Code at its config dir directly while leaving HOME (and the + // keychain) intact. + CLAUDE_CONFIG_DIR: resolvedHomePath, }; }); @@ -38,8 +44,9 @@ export const makeClaudeContinuationGroupKey = Effect.fn("makeClaudeContinuationG export const makeClaudeCapabilitiesCacheKey = Effect.fn("makeClaudeCapabilitiesCacheKey")( function* ( config: Pick, + cwd?: string, ): Effect.fn.Return { const resolvedHomePath = yield* resolveClaudeHomePath(config); - return `${config.binaryPath}\0${resolvedHomePath}`; + return `${config.binaryPath}\0${resolvedHomePath}\0${cwd ?? ""}`; }, ); diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index ec78b1665ef..03c717abb14 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -114,6 +114,9 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { const sessionsTarget = yield* fileSystem.readLink(path.join(shadowHome, "sessions")); const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml")); + const mcpOauthLocksTarget = yield* fileSystem.readLink( + path.join(shadowHome, "mcp-oauth-locks"), + ); const modelsCacheExists = yield* fileSystem.exists( path.join(shadowHome, "models_cache.json"), ); @@ -124,12 +127,45 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { expect(sessionsTarget).toBe(path.join(sharedHome, "sessions")); expect(configTarget).toBe(path.join(sharedHome, "config.toml")); + expect(mcpOauthLocksTarget).toBe(path.join(sharedHome, "mcp-oauth-locks")); expect(modelsCacheExists).toBe(false); expect(authLinkResult._tag).toBe("Failure"); expect(authContents).toContain("shadow"); }), ); + it.effect("replaces Codex-created local MCP OAuth locks with the shared lock directory", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + const sharedLocks = path.join(sharedHome, "mcp-oauth-locks"); + const shadowLocks = path.join(shadowHome, "mcp-oauth-locks"); + + yield* writeTextFile(path.join(sharedLocks, "file-store.lock"), ""); + yield* writeTextFile(path.join(shadowLocks, "file-store.lock"), ""); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + yield* materializeCodexShadowHome(layout); + + const locksTarget = yield* fileSystem.readLink(shadowLocks); + const sharedLockExists = yield* fileSystem.exists( + path.join(sharedLocks, "file-store.lock"), + ); + + expect(locksTarget).toBe(sharedLocks); + expect(sharedLockExists).toBe(true); + }), + ); + it.effect("accepts Codex-created shadow-local runtime directories", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index d2d09e9d844..92e923c0b66 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -26,10 +26,12 @@ const KNOWN_SHARED_DIRECTORIES = [ "plugins", "cache", "logs", + "mcp-oauth-locks", ] as const; const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "models_cache.json"]); const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp"]); +const REPLACEABLE_SHARED_RUNTIME_DIRECTORIES = new Set(["mcp-oauth-locks"]); function resolveHomePath(path: Path.Path, value: string | undefined): string { const expanded = @@ -225,16 +227,6 @@ const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (inpu linkPath: link, }); - if (state._tag === "NotSymlink") { - return yield* new CodexShadowHomeEntryConflictError({ - sharedHomePath: input.sharedHomePath, - effectiveHomePath: input.effectiveHomePath, - entryName: input.entryName, - linkPath: link, - targetPath: target, - }); - } - const createLink = input.fileSystem.symlink(target, link).pipe( Effect.catchTags({ PlatformError: (cause) => @@ -250,6 +242,33 @@ const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (inpu }), ); + if (state._tag === "NotSymlink") { + if (!REPLACEABLE_SHARED_RUNTIME_DIRECTORIES.has(input.entryName)) { + return yield* new CodexShadowHomeEntryConflictError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + entryName: input.entryName, + linkPath: link, + targetPath: target, + }); + } + + yield* input.fileSystem.remove(link, { recursive: true }).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + operation: "remove", + path: link, + entryName: input.entryName, + cause, + }), + }), + ); + return yield* createLink; + } + if (state._tag === "Missing") { return yield* createLink; } diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index c394a7d1b43..61f80489774 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -1,5 +1,5 @@ /** - * CursorDriver — `ProviderDriver` for the Cursor Agent (`agent`) runtime. + * CursorDriver — `ProviderDriver` for the Cursor Agent (`cursor-agent`) runtime. * * Cursor exposes an ACP-based CLI. Model catalog and capability refreshes * happen during the managed provider status check via Cursor's @@ -42,7 +42,7 @@ import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { makeProviderMaintenanceCapabilities, - makeStaticProviderMaintenanceResolver, + type ProviderMaintenanceCapabilitiesResolver, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; import { @@ -54,15 +54,16 @@ const decodeCursorSettings = Schema.decodeSync(CursorSettings); const DRIVER_KIND = ProviderDriverKind.make("cursor"); const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); -const UPDATE = makeStaticProviderMaintenanceResolver( - makeProviderMaintenanceCapabilities({ - provider: DRIVER_KIND, - packageName: null, - updateExecutable: "agent", - updateArgs: ["update"], - updateLockKey: "cursor-agent", - }), -); +const UPDATE: ProviderMaintenanceCapabilitiesResolver = { + resolve: (options) => + makeProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + updateExecutable: options?.binaryPath?.trim() || "cursor-agent", + updateArgs: ["update"], + updateLockKey: "cursor-agent", + }), +}; export type CursorDriverEnv = | ChildProcessSpawner.ChildProcessSpawner @@ -100,6 +101,7 @@ export const CursorDriver: ProviderDriver = { defaultConfig: (): CursorSettings => decodeCursorSettings({}), create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -132,6 +134,7 @@ export const CursorDriver: ProviderDriver = { const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe( Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index d855d1a4515..4eb32c20c47 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -84,6 +84,7 @@ export const GrokDriver: ProviderDriver = { defaultConfig: (): GrokSettings => decodeGrokSettings({}), create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; @@ -114,6 +115,7 @@ export const GrokDriver: ProviderDriver = { const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe( Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 23ab6157046..f5c3275bb39 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -418,7 +418,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("runs Claude SDK sessions with the configured Claude HOME", () => { + it.effect("runs Claude SDK sessions with the configured CLAUDE_CONFIG_DIR", () => { const harness = makeHarness({ claudeConfig: { homePath: "~/.claude-work" } }); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -433,7 +433,10 @@ describe("ClaudeAdapterLive", () => { }); const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.env?.HOME, NodePath.join(NodeOS.homedir(), ".claude-work")); + assert.equal( + createInput?.options.env?.CLAUDE_CONFIG_DIR, + NodePath.join(NodeOS.homedir(), ".claude-work"), + ); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 59b7d0464de..af8f5d6704b 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -583,6 +583,7 @@ function waitForAbortSignal(signal: AbortSignal): Promise { const probeClaudeCapabilities = ( claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, + cwd?: string, ) => { const abort = new AbortController(); return Effect.gen(function* () { @@ -602,6 +603,7 @@ const probeClaudeCapabilities = ( settingSources: ["user", "project", "local"], allowedTools: [], env: claudeEnvironment, + ...(cwd ? { cwd } : {}), stderr: () => {}, }, }); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 8aeacd870cc..1527072dae7 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -4,11 +4,12 @@ import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { describe } from "vite-plus/test"; -import { ThreadId } from "@t3tools/contracts"; +import { DEFAULT_MODEL, ThreadId } from "@t3tools/contracts"; import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexRpc from "effect-codex-app-server/rpc"; import { + buildCodexDeveloperInstructions, CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, } from "../CodexDeveloperInstructions.ts"; @@ -118,7 +119,10 @@ describe("buildTurnStartParams", () => { settings: { model: "gpt-5.3-codex", reasoning_effort: "medium", - developer_instructions: CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, + developer_instructions: buildCodexDeveloperInstructions("plan", { + model: "gpt-5.3-codex", + reasoningEffort: "medium", + }), }, }, }); @@ -163,12 +167,31 @@ describe("buildTurnStartParams", () => { settings: { model: "gpt-5.3-codex", reasoning_effort: "medium", - developer_instructions: CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, + developer_instructions: buildCodexDeveloperInstructions("default", { + model: "gpt-5.3-codex", + reasoningEffort: "medium", + }), }, }, }); }); + it("reports the same fallback model and effort in settings and instructions", () => { + const params = Effect.runSync( + buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: "Go", + interactionMode: "default", + }), + ); + + const settings = params.collaborationMode?.settings; + NodeAssert.equal(settings?.model, DEFAULT_MODEL); + NodeAssert.equal(settings?.reasoning_effort, "medium"); + NodeAssert.ok(settings?.developer_instructions?.includes(`as ${DEFAULT_MODEL} with medium`)); + }); + it("omits collaboration mode when interaction mode is absent", () => { const params = Effect.runSync( buildTurnStartParams({ @@ -194,6 +217,53 @@ describe("buildTurnStartParams", () => { }); }); +describe("buildCodexDeveloperInstructions", () => { + it("appends runtime info after the mode instructions", () => { + const instructions = buildCodexDeveloperInstructions("default", { + model: "gpt-5.3-codex", + reasoningEffort: "high", + }); + + NodeAssert.ok(instructions.startsWith(CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS)); + NodeAssert.match(instructions, /T3 Code/); + NodeAssert.match(instructions, /Codex harness/); + NodeAssert.match(instructions, /as gpt-5\.3-codex with high reasoning effort/); + }); + + it("includes runtime info alongside plan mode instructions", () => { + const instructions = buildCodexDeveloperInstructions("plan", { + model: "gpt-5.3-codex", + reasoningEffort: "medium", + }); + + NodeAssert.ok(instructions.startsWith(CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS)); + NodeAssert.match(instructions, /as gpt-5\.3-codex with medium reasoning effort/); + }); + + it("varies with the model and effort of each turn", () => { + const first = buildCodexDeveloperInstructions("default", { + model: "gpt-5.3-codex", + reasoningEffort: "medium", + }); + const second = buildCodexDeveloperInstructions("default", { + model: "gpt-5.4", + reasoningEffort: "high", + }); + + NodeAssert.notEqual(first, second); + }); + + it("flattens multiline metadata into single-line runtime info", () => { + const instructions = buildCodexDeveloperInstructions("default", { + model: "gpt\n5.3\ncodex", + reasoningEffort: " high\neffort ", + }); + + NodeAssert.match(instructions, /as gpt 5\.3 codex with high effort reasoning effort/); + NodeAssert.doesNotMatch(instructions, /[^<]*\n/); + }); +}); + describe("T3 browser developer instructions", () => { it("prefers the product-native preview tools in both collaboration modes", () => { for (const instructions of [ diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 5611384bbfd..9a953f24a43 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -38,10 +38,7 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { buildCodexInitializeParams } from "./CodexProvider.ts"; import { expandHomePath } from "../../pathExpansion.ts"; -import { - CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, - CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, -} from "../CodexDeveloperInstructions.ts"; +import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse); const decodeThreadGoalGetResponse = Schema.decodeUnknownEffect( Schema.Struct({ @@ -343,15 +340,16 @@ function buildCodexCollaborationMode(input: { return undefined; } const model = normalizeCodexModelSlug(input.model) ?? DEFAULT_MODEL; + const reasoningEffort = input.effort ?? "medium"; return { mode: input.interactionMode, settings: { model, - reasoning_effort: input.effort ?? "medium", - developer_instructions: - input.interactionMode === "plan" - ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS - : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, + reasoning_effort: reasoningEffort, + developer_instructions: buildCodexDeveloperInstructions(input.interactionMode, { + model, + reasoningEffort, + }), }, }; } diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 37b71d98b46..dde6b3826af 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -228,9 +228,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { assert.isDefined(delta); if (delta?.type === "content.delta") { assert.equal(delta.payload.delta, "hello from mock"); - // The middle part is a per-runtime tag that keeps segment ids unique - // across restarts that resume the same ACP session. - assert.match(String(delta.itemId), /^assistant:mock-session-1:[^:]+:segment:0$/); + assert.match(String(delta.itemId), /^assistant:mock-session-1:runtime:[^:]+:segment:0$/); } const assistantCompleted = runtimeEvents.find( @@ -689,7 +687,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { if (contentDelta?.type === "content.delta") { assert.equal(String(contentDelta.turnId), String(turn.turnId)); assert.equal(contentDelta.payload.delta, "hello from mock"); - assert.match(String(contentDelta.itemId), /^assistant:mock-session-1:[^:]+:segment:0$/); + assert.match( + String(contentDelta.itemId), + /^assistant:mock-session-1:runtime:[^:]+:segment:0$/, + ); } }); @@ -976,8 +977,13 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { if (String(event.threadId) !== String(threadId)) { return; } - if (event.type === "request.opened" && !interrupted) { + if (event.type === "request.opened" && event.requestId && !interrupted) { interrupted = true; + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(event.requestId)), + "cancel", + ); yield* adapter.interruptTurn(threadId); return; } @@ -1032,15 +1038,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { entry.result.outcome !== null && "outcome" in entry.result.outcome && entry.result.outcome.outcome === "cancelled"; - const cancelRequests = yield* waitForJsonLogMatch( - requestLogPath, - (entry) => entry.method === "session/cancel", - ); const approvalResponses = yield* waitForJsonLogMatch( requestLogPath, isCancelledApprovalResponse, ); - assert.isTrue(cancelRequests.some((entry) => entry.method === "session/cancel")); assert.isTrue(approvalResponses.some(isCancelledApprovalResponse)); yield* adapter.stopSession(threadId); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index ca0e4b7f10f..9da20a86cdb 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -559,6 +559,7 @@ export function makeCursorAdapter( : {}), ...acpNativeLoggers, }).pipe( + Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(Scope.Scope, sessionScope), Effect.mapError( (cause) => diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 78f62ac2123..e969a7beab4 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -1,6 +1,7 @@ import * as NodeOS from "node:os"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import type * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -28,7 +29,7 @@ const runNode = ( effect: Effect.Effect< A, E, - ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path >, ): Promise => Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer))); @@ -294,7 +295,7 @@ const parameterizedClaudeModelOptionConfigOptions = [ const baseCursorSettings: CursorSettings = { enabled: true, - binaryPath: "agent", + binaryPath: "cursor-agent", apiEndpoint: "", customModels: [], }; diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index cd9b93a4734..063f2c1bf2b 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -11,6 +11,7 @@ import type { import { ProviderDriverKind } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -992,7 +993,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( ): Effect.fn.Return< ServerProviderDraft, never, - ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path > { const checkedAt = DateTime.formatIso(yield* DateTime.now); const fallbackModels = getCursorFallbackModels(cursorSettings); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 7ca54267a0a..b36973b9971 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -597,6 +597,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte : {}), ...acpNativeLoggers, }).pipe( + Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(Scope.Scope, sessionScope), Effect.mapError( (cause) => diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index cf5d5ad9c8d..33f61ad97f6 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -7,6 +7,7 @@ import { } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -167,7 +168,11 @@ const runGrokVersionCommand = ( export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, -): Effect.fn.Return { +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto +> { const checkedAt = DateTime.formatIso(yield* DateTime.now); const fallbackModels = grokModelsFromSettings(grokSettings.customModels); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index dbfa7faffea..73390450efa 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -76,7 +76,7 @@ const makeClaudeConfig = (overrides: Partial): ClaudeSettings => const makeCursorConfig = (overrides: Partial): CursorSettings => ({ enabled: false, - binaryPath: "agent", + binaryPath: "cursor-agent", apiEndpoint: "", customModels: [], ...overrides, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index b3ab1145495..5456a90bdf5 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1396,7 +1396,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge( mockCommandSpawnerLayer((command, args) => { - if (command === "agent") { + if (command === "cursor-agent") { cursorSpawned = true; } const joined = args.join(" "); @@ -1719,8 +1719,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); - it.effect("runs Claude status probes with the configured Claude HOME", () => { - const claudeHome = "/tmp/t3code-claude-home"; + it.effect("runs Claude status probes with the configured CLAUDE_CONFIG_DIR", () => { + const claudeConfigDir = "/tmp/t3code-claude-home"; const recorded = recordingMockSpawnerLayer((args) => { const joined = args.join(" "); if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; @@ -1737,14 +1737,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te const status = yield* checkClaudeProviderStatus( { ...defaultClaudeSettings, - homePath: claudeHome, + homePath: claudeConfigDir, }, claudeCapabilities(), ); assert.strictEqual(status.status, "ready"); assert.deepStrictEqual( - recorded.commands.map((command) => command.env?.HOME), - [claudeHome], + recorded.commands.map((command) => command.env?.CLAUDE_CONFIG_DIR), + [claudeConfigDir], ); }).pipe(Effect.provide(recorded.layer)); }); diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index db5dd961494..31a4ca66731 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -116,6 +116,43 @@ describe("AcpSessionRuntime", () => { ), ); + it.effect("keeps assistant item IDs unique when a provider session restarts", () => { + const collectFirstAssistantItemId = Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + const started = yield* runtime.start(); + expect(started.sessionId).toBe("mock-session-1"); + + yield* runtime.prompt({ + prompt: [{ type: "text", text: "hi" }], + }); + + const events = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4))); + const assistantStart = events.find((event) => event._tag === "AssistantItemStarted"); + expect(assistantStart?._tag).toBe("AssistantItemStarted"); + return assistantStart?._tag === "AssistantItemStarted" ? assistantStart.itemId : ""; + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + ); + + return Effect.gen(function* () { + const beforeRestart = yield* collectFirstAssistantItemId; + const afterRestart = yield* collectFirstAssistantItemId; + + expect(afterRestart).not.toBe(beforeRestart); + }).pipe(Effect.provide(NodeServices.layer)); + }); + it.effect("drops session updates emitted for a child ACP session", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index dd841a4c979..b348b537d95 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -1,6 +1,7 @@ import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -283,30 +284,30 @@ interface EnsureActiveAssistantSegmentResult { /** Keeps thought accumulation bounded for very long reasoning segments. */ const MAX_THOUGHT_SEGMENT_CHARS = 20_000; -/** Differentiates runtime instances within one process; combined with the - * startup timestamp it makes segment item ids unique across restarts. */ -let runtimeInstanceCounter = 0; - export const make = ( options: AcpSessionRuntimeOptions, ): Effect.Effect< AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner | Scope.Scope + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope > => Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const runtimeScope = yield* Scope.Scope; const eventQueue = yield* Queue.unbounded(); const modeStateRef = yield* Ref.make(undefined); const toolCallsRef = yield* Ref.make(new Map()); + const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to generate an ACP assistant item runtime identifier.", + cause, + }), + ), + ); const assistantSegmentRef = yield* Ref.make({ nextSegmentIndex: 0 }); - // Segment item ids must not repeat across runtimes that resume the same - // ACP session (e.g. after a server restart), otherwise downstream - // consumers derive colliding message ids and new output gets appended to - // messages from a previous run. The tag makes ids runtime-unique. - runtimeInstanceCounter += 1; - const runtimeTag = `${(yield* Clock.currentTimeMillis).toString(36)}-${runtimeInstanceCounter.toString(36)}`; const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); const promptSerializationSemaphore = yield* Semaphore.make(1); @@ -417,7 +418,7 @@ export const make = ( modeStateRef, toolCallsRef, assistantSegmentRef, - runtimeTag, + assistantItemRuntimeId, params: notification, }); }), @@ -833,7 +834,7 @@ export const layer = ( ): Layer.Layer< AcpSessionRuntime, EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto > => Layer.effect(AcpSessionRuntime, make(options)); function sessionConfigOptionsFromSetup( @@ -865,14 +866,14 @@ const handleSessionUpdate = ({ modeStateRef, toolCallsRef, assistantSegmentRef, - runtimeTag, + assistantItemRuntimeId, params, }: { readonly queue: Queue.Queue; readonly modeStateRef: Ref.Ref; readonly toolCallsRef: Ref.Ref>; readonly assistantSegmentRef: Ref.Ref; - readonly runtimeTag: string; + readonly assistantItemRuntimeId: string; readonly params: EffectAcpSchema.SessionNotification; }): Effect.Effect => Effect.gen(function* () { @@ -921,8 +922,8 @@ const handleSessionUpdate = ({ queue, assistantSegmentRef, sessionId: params.sessionId, - runtimeTag, channel: event.channel, + assistantItemRuntimeId, }); if (event.channel === "thought") { yield* Ref.update(assistantSegmentRef, (current) => @@ -975,11 +976,11 @@ function shouldEmitToolCallUpdate( const assistantItemId = (input: { readonly sessionId: string; - readonly runtimeTag: string; + readonly runtimeId: string; readonly channel: AcpAssistantChannel; readonly segmentIndex: number; }) => - `${input.channel === "thought" ? "thought" : "assistant"}:${input.sessionId}:${input.runtimeTag}:segment:${input.segmentIndex}`; + `${input.channel === "thought" ? "thought" : "assistant"}:${input.sessionId}:runtime:${input.runtimeId}:segment:${input.segmentIndex}`; function appendThoughtSegmentText(current: string, delta: string): string { if (current.length >= MAX_THOUGHT_SEGMENT_CHARS) { @@ -1003,14 +1004,14 @@ const ensureActiveAssistantSegment = ({ queue, assistantSegmentRef, sessionId, - runtimeTag, channel, + assistantItemRuntimeId, }: { readonly queue: Queue.Queue; readonly assistantSegmentRef: Ref.Ref; readonly sessionId: string; - readonly runtimeTag: string; readonly channel: AcpAssistantChannel; + readonly assistantItemRuntimeId: string; }) => Ref.modify( assistantSegmentRef, @@ -1020,7 +1021,7 @@ const ensureActiveAssistantSegment = ({ } const itemId = assistantItemId({ sessionId, - runtimeTag, + runtimeId: assistantItemRuntimeId, channel, segmentIndex: current.nextSegmentIndex, }); diff --git a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts index eebe5ddd92e..6be4faf9670 100644 --- a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts +++ b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts @@ -1,5 +1,5 @@ /** - * Optional integration check against a real `agent acp` install. + * Optional integration check against a real `cursor-agent acp` install. * Enable with: T3_CURSOR_ACP_PROBE=1 bun run test --filter CursorAcpCliProbe */ import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -12,7 +12,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", () => { - it.effect("initialize and authenticate against real agent acp", () => + it.effect("initialize and authenticate against real cursor-agent acp", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; const started = yield* runtime.start(); @@ -21,7 +21,7 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", Effect.provide( AcpSessionRuntime.layer({ spawn: { - command: "agent", + command: "cursor-agent", args: ["acp"], cwd: process.cwd(), }, @@ -77,7 +77,7 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", AcpSessionRuntime.layer({ authMethodId: "cursor_login", spawn: { - command: "agent", + command: "cursor-agent", args: ["acp"], cwd: process.cwd(), }, @@ -133,7 +133,7 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", AcpSessionRuntime.layer({ authMethodId: "cursor_login", spawn: { - command: "agent", + command: "cursor-agent", args: ["acp"], cwd: process.cwd(), }, diff --git a/apps/server/src/provider/acp/CursorAcpSupport.test.ts b/apps/server/src/provider/acp/CursorAcpSupport.test.ts index 78bc16c9f3b..a095fdd679b 100644 --- a/apps/server/src/provider/acp/CursorAcpSupport.test.ts +++ b/apps/server/src/provider/acp/CursorAcpSupport.test.ts @@ -53,7 +53,7 @@ const parameterizedGpt54ConfigOptions: ReadonlyArray { it("builds the default Cursor ACP command", () => { expect(buildCursorAcpSpawnInput(undefined, "/tmp/project")).toEqual({ - command: "agent", + command: "cursor-agent", args: ["acp"], cwd: "/tmp/project", }); diff --git a/apps/server/src/provider/acp/CursorAcpSupport.ts b/apps/server/src/provider/acp/CursorAcpSupport.ts index 169d7c6206d..30203ad77b1 100644 --- a/apps/server/src/provider/acp/CursorAcpSupport.ts +++ b/apps/server/src/provider/acp/CursorAcpSupport.ts @@ -1,4 +1,5 @@ import { type CursorSettings, type ProviderOptionSelection } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Scope from "effect/Scope"; @@ -35,7 +36,7 @@ export function buildCursorAcpSpawnInput( environment?: NodeJS.ProcessEnv, ): AcpSessionRuntime.AcpSpawnInput { return { - command: cursorSettings?.binaryPath || "agent", + command: cursorSettings?.binaryPath || "cursor-agent", args: [ ...(cursorSettings?.apiEndpoint ? (["-e", cursorSettings.apiEndpoint] as const) : []), "acp", @@ -50,7 +51,7 @@ export const makeCursorAcpRuntime = ( ): Effect.Effect< AcpSessionRuntime.AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - Scope.Scope + Crypto.Crypto | Scope.Scope > => Effect.gen(function* () { const acpContext = yield* Layer.build( diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index 3e6d63a5393..c928b3ed80e 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -1,4 +1,5 @@ import { type GrokSettings, ProviderDriverKind } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Scope from "effect/Scope"; @@ -55,7 +56,7 @@ export const makeGrokAcpRuntime = ( ): Effect.Effect< AcpSessionRuntime.AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - Scope.Scope + Crypto.Crypto | Scope.Scope > => Effect.gen(function* () { const acpContext = yield* Layer.build( diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index f33a9bbce42..641c9b52e56 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -48,7 +48,7 @@ function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabili return makeProviderMaintenanceCapabilities({ provider, packageName: null, - updateExecutable: "agent", + updateExecutable: "cursor-agent", updateArgs: ["update"], updateLockKey: "cursor-agent", }); @@ -226,7 +226,7 @@ describe("providerMaintenanceRunner", () => { const result = yield* updater.updateProvider(CURSOR_DRIVER); assert.deepStrictEqual(calls, [ { - command: "agent", + command: "cursor-agent", args: ["update"], }, ]); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index c707aed3940..ff4d448619c 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -137,7 +137,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { expect(AgentAwarenessRelay.eventThreadId(event)).toBe(threadId); }); - it("does not publish streaming content or non-awareness activity events", () => { + it("does not publish start intents, streaming content, or non-awareness activity events", () => { const now = "2026-05-25T00:00:00.000Z"; const base = { sequence: 1, @@ -191,7 +191,16 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { streaming: false, }, } as unknown as OrchestrationEvent), - ).toBe(true); + ).toBe(false); + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.turn-start-requested", + payload: { + threadId: "thread-1" as ThreadId, + }, + } as unknown as OrchestrationEvent), + ).toBe(false); }); it("deduplicates awareness state updates whose only change is their event timestamp", () => { diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index d4bcd2096f1..58de98f1ca1 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -67,7 +67,13 @@ export function eventThreadId(event: OrchestrationEvent): ThreadId | null { export function shouldPublishAgentAwarenessEvent(event: OrchestrationEvent): boolean { switch (event.type) { case "thread.message-sent": - return !event.payload.streaming; + case "thread.turn-start-requested": + // These events express intent to start work, but the shell still contains + // the previous turn's terminal state until the provider acknowledges the + // new turn. Publishing that snapshot can queue a fresh "Done" alert just + // before the real running state arrives. Provider lifecycle events publish + // the authoritative starting/running state instead. + return false; case "thread.proposed-plan-upserted": case "thread.runtime-mode-set": case "thread.interaction-mode-set": diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 4917b8e7228..79aa2cef87a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5612,6 +5612,64 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("buffers thread events published while the initial snapshot loads", () => + Effect.gen(function* () { + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + const liveEvents = yield* PubSub.unbounded(); + const messageEvent = { + sequence: 2, + eventId: EventId.make("event-message"), + aggregateKind: "thread", + aggregateId: defaultThreadId, + occurredAt: "2026-01-01T00:00:01.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: { + threadId: defaultThreadId, + messageId: MessageId.make("message-1"), + role: "user", + text: "First message", + turnId: null, + streaming: false, + createdAt: "2026-01-01T00:00:01.000Z", + updatedAt: "2026-01-01T00:00:01.000Z", + }, + } satisfies Extract; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + streamDomainEvents: Stream.fromPubSub(liveEvents), + }, + projectionSnapshotQuery: { + getThreadDetailSnapshot: () => + Effect.gen(function* () { + yield* Effect.sleep("25 millis"); + yield* PubSub.publish(liveEvents, messageEvent); + return Option.some({ snapshotSequence: 1, thread }); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + }).pipe(Stream.take(2), Stream.runCollect), + ), + ).pipe(Effect.timeout("2 seconds")); + + assert.equal(items[0]?.kind, "snapshot"); + assert.equal(items[1]?.kind, "event"); + assert.equal(items[1]?.kind === "event" ? items[1].event.sequence : null, 2); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + it.effect("enriches replayed project events with repository identity metadata", () => Effect.gen(function* () { const repositoryIdentity = { diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index ccf2e8097d4..ccc7a0cfa6c 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1340,6 +1340,63 @@ it.layer( }), ); + it.effect("strips AppImage runtime env from terminal sessions", () => + Effect.gen(function* () { + const appDir = "/tmp/.mount_T3Codeabc123"; + const { manager, ptyAdapter } = yield* createManager(5, { + env: { + APPIMAGE: "/home/user/T3-Code.AppImage", + APPDIR: appDir, + ARGV0: "/home/user/T3-Code.AppImage", + OWD: "/home/user/project", + PATH: `${appDir}/usr/bin:${appDir}:/usr/local/bin:/usr/bin:/bin`, + LD_LIBRARY_PATH: `${appDir}/usr/lib:/home/user/.local/lib`, + TEST_TERMINAL_KEEP: "keep-me", + }, + }); + yield* manager.open(openInput()); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + + // AppImage runtime markers must never reach the PTY — tools inside the + // terminal otherwise resolve against the AppImage mount (e.g. PHP_BINARY + // reporting the AppImage path instead of the real binary). + expect(spawnInput.env.APPIMAGE).toBeUndefined(); + expect(spawnInput.env.APPDIR).toBeUndefined(); + expect(spawnInput.env.ARGV0).toBeUndefined(); + expect(spawnInput.env.OWD).toBeUndefined(); + // PATH/LD_LIBRARY_PATH keep the user's real entries but drop the AppImage + // mount segments that the runtime prepended. + expect(spawnInput.env.PATH).toBe("/usr/local/bin:/usr/bin:/bin"); + expect(spawnInput.env.LD_LIBRARY_PATH).toBe("/home/user/.local/lib"); + // Unrelated host vars still pass through untouched. + expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); + }), + ); + + it.effect("leaves the environment untouched when not launched from an AppImage", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + env: { + PATH: "/usr/local/bin:/usr/bin:/bin", + LD_LIBRARY_PATH: "/home/user/.local/lib", + // Without APPIMAGE/APPDIR set, OWD is an ordinary variable and must + // not be stripped — only an AppImage launch gives it special meaning. + OWD: "/home/user/keep-this", + }, + }); + yield* manager.open(openInput()); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + + expect(spawnInput.env.PATH).toBe("/usr/local/bin:/usr/bin:/bin"); + expect(spawnInput.env.LD_LIBRARY_PATH).toBe("/home/user/.local/lib"); + expect(spawnInput.env.OWD).toBe("/home/user/keep-this"); + }), + ); + it.effect("injects runtime env overrides into spawned terminals", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(5, { diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index fe733740fcf..cdf8b572ddc 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1076,6 +1076,53 @@ function shouldExcludeTerminalEnvKey(key: string): boolean { return TERMINAL_ENV_BLOCKLIST.has(normalizedKey); } +// Marker variables the AppImage runtime injects into the process it launches. +// They describe the AppImage itself, not the user's session, so terminals must +// not inherit them. +const APPIMAGE_RUNTIME_ENV_KEYS = ["APPIMAGE", "APPDIR", "ARGV0", "OWD"] as const; +// PATH-style variables the AppImage runtime prepends with its temporary mount +// (e.g. /tmp/.mount_T3-XXXX/usr/bin). Only the mount segments are dropped; the +// user's real entries are preserved. +const APPIMAGE_PATH_LIKE_ENV_KEYS = ["PATH", "LD_LIBRARY_PATH"] as const; + +function isPathSegmentUnderAppDir(segment: string, appDir: string): boolean { + return segment === appDir || segment.startsWith(`${appDir}/`); +} + +// On Linux AppImage builds the runtime mounts the app under a temporary dir and +// injects APPIMAGE/APPDIR/ARGV0/OWD plus mount entries on PATH/LD_LIBRARY_PATH. +// The integrated terminal inherits the server process environment, so without +// this scrub those leak into the PTY and tools resolve against the AppImage +// mount instead of the user's real environment (e.g. `php` reporting +// PHP_BINARY as the AppImage path). See issue #1699. The scrub is gated on an +// actual AppImage launch so non-AppImage environments are left untouched. +function stripAppImageRuntimeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + if (env.APPIMAGE === undefined && env.APPDIR === undefined) return env; + + const scrubbed: NodeJS.ProcessEnv = { ...env }; + for (const key of APPIMAGE_RUNTIME_ENV_KEYS) { + delete scrubbed[key]; + } + + const appDir = env.APPDIR?.replace(/\/+$/, ""); + if (appDir) { + for (const key of APPIMAGE_PATH_LIKE_ENV_KEYS) { + const value = scrubbed[key]; + if (value === undefined) continue; + const kept = value + .split(":") + .filter((segment) => segment.length > 0 && !isPathSegmentUnderAppDir(segment, appDir)); + if (kept.length > 0) { + scrubbed[key] = kept.join(":"); + } else { + delete scrubbed[key]; + } + } + } + + return scrubbed; +} + function createTerminalSpawnEnv( baseEnv: NodeJS.ProcessEnv, runtimeEnv?: Record | null, @@ -1091,7 +1138,7 @@ function createTerminalSpawnEnv( spawnEnv[key] = value; } } - return spawnEnv; + return stripAppImageRuntimeEnv(spawnEnv); } function normalizedRuntimeEnv( diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index c8fe4ead3be..0f3905a0cb1 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -51,8 +51,8 @@ function makeFakeClaudeBinary(dir: string) { " exit 4", " }", "fi", - 'if [ -n "$T3_FAKE_CLAUDE_HOME_MUST_BE" ] && [ "$HOME" != "$T3_FAKE_CLAUDE_HOME_MUST_BE" ]; then', - ' printf "%s\\n" "HOME was $HOME" >&2', + 'if [ -n "$T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE" ] && [ "$CLAUDE_CONFIG_DIR" != "$T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE" ]; then', + ' printf "%s\\n" "CLAUDE_CONFIG_DIR was $CLAUDE_CONFIG_DIR" >&2', " exit 5", "fi", 'if [ -n "$T3_FAKE_CLAUDE_STDERR" ]; then', @@ -76,7 +76,7 @@ function withFakeClaudeEnv( argsMustContain?: string; argsMustNotContain?: string; stdinMustContain?: string; - homeMustBe?: string; + configDirMustBe?: string; claudeConfig?: Partial; }, effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, @@ -92,7 +92,7 @@ function withFakeClaudeEnv( const previousArgsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; - const previousHomeMustBe = process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; + const previousConfigDirMustBe = process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE; yield* Effect.acquireRelease( Effect.sync(() => { @@ -129,10 +129,10 @@ function withFakeClaudeEnv( delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; } - if (input.homeMustBe !== undefined) { - process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = input.homeMustBe; + if (input.configDirMustBe !== undefined) { + process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE = input.configDirMustBe; } else { - delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; + delete process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE; } }), () => @@ -175,10 +175,10 @@ function withFakeClaudeEnv( process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previousStdinMustContain; } - if (previousHomeMustBe === undefined) { - delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; + if (previousConfigDirMustBe === undefined) { + delete process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE; } else { - process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = previousHomeMustBe; + process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE = previousConfigDirMustBe; } }), ); @@ -286,10 +286,10 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { ), ); - it.effect("runs Claude text generation with the configured Claude HOME", () => + it.effect("runs Claude text generation with the configured CLAUDE_CONFIG_DIR", () => Effect.gen(function* () { const path = yield* Path.Path; - const claudeHome = path.join(process.cwd(), ".claude-work-test"); + const claudeConfigDir = path.join(process.cwd(), ".claude-work-test"); return yield* withFakeClaudeEnv( { // @effect-diagnostics-next-line preferSchemaOverJson:off @@ -298,8 +298,8 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { title: "Use Claude home", }, }), - homeMustBe: claudeHome, - claudeConfig: { homePath: claudeHome }, + configDirMustBe: claudeConfigDir, + claudeConfig: { homePath: claudeConfigDir }, }, (textGeneration) => Effect.gen(function* () { diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index 3e1f4eb8bbc..6d1e5d02db3 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -1,3 +1,4 @@ +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -38,6 +39,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu cursorSettings: CursorSettings, environment?: NodeJS.ProcessEnv, ) { + const crypto = yield* Crypto.Crypto; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const resolvedEnvironment = environment ?? process.env; @@ -66,7 +68,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu childProcessSpawner: commandSpawner, cwd, clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, - }); + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); yield* runtime.handleSessionUpdate((notification) => { const update = notification.update; diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index 1bb58216305..fd367acdf4c 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -1,3 +1,4 @@ +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -37,6 +38,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, ) { + const crypto = yield* Crypto.Crypto; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const runGrokJson = ({ @@ -65,7 +67,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi childProcessSpawner: commandSpawner, cwd, clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, - }); + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); yield* runtime.handleSessionUpdate((notification) => { const update = notification.update; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index dc58fc2543c..8c627525b4c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -6,6 +6,9 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Scope from "effect/Scope"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; @@ -20,6 +23,21 @@ const TestLayer = GitVcsDriver.layer.pipe( Layer.provideMerge(NodeServices.layer), ); +const makeNonRepositoryHandle = () => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.encodeText(Stream.make("fatal: not a git repository")), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + const makeTmpDir = ( prefix = "git-vcs-driver-test-", ): Effect.Effect => @@ -77,7 +95,62 @@ const initRepoWithCommit = ( return { initialBranch }; }); +it.effect("uses stable diagnostics for every parsed non-repository command", () => { + const commands: Array<{ readonly args: ReadonlyArray; readonly lcAll?: string }> = []; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + if (!ChildProcess.isStandardCommand(command)) { + return assert.fail("expected a standard Git command"); + } + commands.push({ + args: command.args, + ...(command.options.env?.LC_ALL ? { lcAll: command.options.env.LC_ALL } : {}), + }); + return makeNonRepositoryHandle(); + }), + ); + const nodeServicesLayer = Layer.merge( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const layer = GitVcsDriver.layer.pipe( + Layer.provide(ServerConfigLayer), + Layer.provideMerge(nodeServicesLayer), + ); + + return Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const cwd = "/repo"; + + yield* driver.statusDetailsLocal(cwd); + yield* driver.statusDetailsRemote(cwd, { refreshUpstream: false }); + yield* driver.listRefs({ cwd }); + + assert.deepStrictEqual(commands, [ + { args: ["status", "--porcelain=2", "--branch"], lcAll: "C" }, + { args: ["rev-parse", "--abbrev-ref", "HEAD"], lcAll: "C" }, + { args: ["branch", "--no-color", "--no-column"], lcAll: "C" }, + ]); + }).pipe(Effect.provide(layer)); +}); + it.layer(TestLayer)("GitVcsDriver core integration", (it) => { + describe("process environment", () => { + it.effect("preserves the caller locale for general Git subprocesses", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + + const locale = yield* git( + cwd, + ["-c", 'alias.print-locale=!printf "%s" "$LC_ALL"', "print-locale"], + { LC_ALL: "zh_CN.UTF-8" }, + ); + + assert.equal(locale, "zh_CN.UTF-8"); + }), + ); + }); + describe("structured errors", () => { it.effect("preserves structured spawn context and the platform cause", () => Effect.gen(function* () { @@ -620,6 +693,24 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.notInclude(status, "a.txt"); }), ); + + it.effect("treats selected file paths literally", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* writeTextFile(cwd, "selected[1].txt", "literal\n"); + yield* writeTextFile(cwd, "selected1.txt", "pattern match\n"); + + yield* driver.prepareCommitContext(cwd, ["selected[1].txt"]); + + assert.equal(yield* git(cwd, ["diff", "--cached", "--name-only"]), "selected[1].txt"); + + const status = yield* git(cwd, ["status", "--porcelain"]); + assert.include(status, "?? selected1.txt"); + }), + ); }); describe("remote operations", () => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a406cbce549..6293ff7c29c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -830,6 +830,20 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); + const executeGitWithStableDiagnostics = ( + operation: string, + cwd: string, + args: readonly string[], + options: ExecuteGitOptions = {}, + ): Effect.Effect => + executeGit(operation, cwd, args, { + ...options, + env: { + ...options.env, + LC_ALL: "C", + }, + }); + const runGit = ( operation: string, cwd: string, @@ -1184,7 +1198,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); const readStatusDetailsRemote = Effect.fn("readStatusDetailsRemote")(function* (cwd: string) { - const branchResult = yield* executeGit( + const branchResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.statusDetailsRemote.branch", cwd, ["rev-parse", "--abbrev-ref", "HEAD"], @@ -1304,7 +1318,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) { - const statusResult = yield* executeGit( + const statusResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.statusDetails.status", cwd, ["status", "--porcelain=2", "--branch"], @@ -1524,6 +1538,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [ + "--literal-pathspecs", "add", "-A", "--", @@ -1985,7 +2000,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const branchRecencyPromise = readBranchRecency(input.cwd).pipe( Effect.orElseSucceed(() => new Map()), ); - const localBranchResult = yield* executeGit( + const localBranchResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.listRefs.branchNoColor", input.cwd, ["branch", "--no-color", "--no-column"], diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fafdbfa6814..32dcb8a13d6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1166,6 +1166,14 @@ const makeWsRpcLayer = ( })), ); + // Attach live delivery before reading either replay or snapshot state. + // Otherwise an event published while the snapshot is loading is lost. + const liveBuffer = yield* Queue.unbounded(); + yield* Effect.forkScoped( + liveStream.pipe(Stream.runForEach((item) => Queue.offer(liveBuffer, item))), + ); + const bufferedLiveStream = Stream.fromQueue(liveBuffer); + // When the client already loaded the snapshot over HTTP it passes // that snapshot's sequence, and we resume the live subscription by // replaying persisted events after it instead of re-sending the @@ -1185,28 +1193,20 @@ const makeWsRpcLayer = ( // so a global cap could otherwise omit this thread's events. if (input.afterSequence !== undefined) { const afterSequence = input.afterSequence; - return Stream.unwrap( - Effect.gen(function* () { - const liveBuffer = yield* Queue.unbounded(); - yield* Effect.forkScoped( - liveStream.pipe(Stream.runForEach((item) => Queue.offer(liveBuffer, item))), - ); - const catchUpStream = orchestrationEngine - .readEvents(afterSequence, Number.MAX_SAFE_INTEGER) - .pipe( - Stream.filter(isThisThreadDetailEvent), - Stream.map((event) => ({ kind: "event" as const, event })), - Stream.mapError( - (cause) => - new OrchestrationGetSnapshotError({ - message: `Failed to replay thread ${input.threadId} events`, - cause, - }), - ), - ); - return Stream.concat(catchUpStream, Stream.fromQueue(liveBuffer)); - }), - ); + const catchUpStream = orchestrationEngine + .readEvents(afterSequence, Number.MAX_SAFE_INTEGER) + .pipe( + Stream.filter(isThisThreadDetailEvent), + Stream.map((event) => ({ kind: "event" as const, event })), + Stream.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: `Failed to replay thread ${input.threadId} events`, + cause, + }), + ), + ); + return Stream.concat(catchUpStream, bufferedLiveStream); } const snapshot = yield* projectionSnapshotQuery @@ -1233,7 +1233,7 @@ const makeWsRpcLayer = ( kind: "snapshot" as const, snapshot: snapshot.value, }), - liveStream, + bufferedLiveStream, ); }), { "rpc.aggregate": "orchestration" }, diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index d3c7f6a8dab..6f89d86df88 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -25,6 +25,89 @@ describe("browser target resolver", () => { }); }); + it("maps localhost URL navigation onto a remote Tailscale IPv4 host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://localhost:5173/dashboard?mode=test#results", + }), + ).toEqual({ + requestedUrl: "http://localhost:5173/dashboard?mode=test#results", + resolvedUrl: "http://100.65.180.100:5173/dashboard?mode=test#results", + resolutionKind: "direct-private-network", + environmentId: "environment-1", + }); + }); + + it("preserves URL credentials when mapping localhost onto a remote host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://user:p%40ss@localhost:5173/dashboard", + }).resolvedUrl, + ).toBe("http://user:p%40ss@100.65.180.100:5173/dashboard"); + }); + + it("maps credentialed localhost URLs onto private IPv6 hosts", async () => { + readPreparedConnection.mockReturnValue({ + httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", + }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://user:p%40ss@localhost:5173/dashboard?mode=test#results", + }).resolvedUrl, + ).toBe("http://user:p%40ss@[fd7a:115c:a1e0::53]:5173/dashboard?mode=test#results"); + }); + + it("maps schemeless localhost navigation onto a remote environment host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "localhost:3000/app", + }).resolvedUrl, + ).toBe("http://192.168.1.25:3000/app"); + }); + + it("keeps localhost navigation local for a local environment", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "localhost:3000/app", + }), + ).toEqual({ + requestedUrl: "localhost:3000/app", + resolvedUrl: "localhost:3000/app", + resolutionKind: "direct", + environmentId: "environment-1", + }); + }); + + it("keeps localhost navigation local for the full IPv4 loopback range", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.2:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://localhost:3000/app", + }), + ).toEqual({ + requestedUrl: "http://localhost:3000/app", + resolvedUrl: "http://localhost:3000/app", + resolutionKind: "direct", + environmentId: "environment-1", + }); + }); + it("refuses public relay hosts until the authenticated gateway exists", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://relay.example.com" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); @@ -34,6 +117,12 @@ describe("browser target resolver", () => { port: 5173, }), ).toThrow(/authenticated preview gateway/); + expect(() => + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://localhost:5173", + }), + ).toThrow(/authenticated preview gateway/); }); it("normalizes schemeless localhost server-picker values", async () => { @@ -63,7 +152,9 @@ describe("browser target resolver", () => { }); it("supports private IPv6 environment hosts", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[::1]:3773" }); + readPreparedConnection.mockReturnValue({ + httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", + }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect( resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { @@ -71,7 +162,18 @@ describe("browser target resolver", () => { port: 5173, path: "/app?mode=test", }).resolvedUrl, - ).toBe("http://[::1]:5173/app?mode=test"); + ).toBe("http://[fd7a:115c:a1e0::53]:5173/app?mode=test"); + }); + + it("supports a local IPv6 environment host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[::1]:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "environment-port", + port: 5173, + }).resolvedUrl, + ).toBe("http://[::1]:5173/"); }); it("leaves malformed input for the normal navigation error path", async () => { diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 9142cce1e72..9b201dbdbae 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -7,43 +7,60 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; import { readPreparedConnection } from "~/state/session"; +const normalizeHostname = (host: string): string => host.toLowerCase().replace(/^\[|\]$/g, ""); + +const parseIpv4Address = (host: string): readonly number[] | null => { + const parts = normalizeHostname(host).split(".").map(Number); + return parts.length === 4 && + parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) + ? parts + : null; +}; + +const isLocalLoopbackHost = (host: string): boolean => { + const normalized = normalizeHostname(host); + if (normalized === "localhost" || normalized === "::1") return true; + return parseIpv4Address(normalized)?.[0] === 127; +}; + const isPrivateNetworkHost = (host: string): boolean => { - const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); - if (normalized === "localhost" || normalized === "::1" || normalized.endsWith(".local")) { + const normalized = normalizeHostname(host); + if (isLocalLoopbackHost(normalized) || normalized.endsWith(".local")) { return true; } if (normalized.endsWith(".ts.net")) return true; - const parts = normalized.split(".").map(Number); - if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) return false; + const parts = parseIpv4Address(normalized); + if (parts) { + return ( + parts[0] === 10 || + (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) || + (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || + (parts[0] === 192 && parts[1] === 168) || + (parts[0] === 169 && parts[1] === 254) + ); + } + const firstIpv6Token = normalized.split(":", 1)[0] ?? ""; + if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false; + const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16); return ( - parts[0] === 10 || - (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || - (parts[0] === 192 && parts[1] === 168) || - parts[0] === 127 || - (parts[0] === 169 && parts[1] === 254) + Number.isInteger(firstIpv6Hextet) && + ((firstIpv6Hextet & 0xfe00) === 0xfc00 || (firstIpv6Hextet & 0xffc0) === 0xfe80) ); }; -const isLocalLoopbackHost = (host: string): boolean => { - const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); - return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1"; +const readEnvironmentUrl = (environmentId: EnvironmentId): URL => { + const connection = readPreparedConnection(environmentId); + if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); + return new URL(connection.httpBaseUrl); }; -export function resolveBrowserNavigationTarget( +const resolveEnvironmentPortTarget = ( environmentId: EnvironmentId, - target: BrowserNavigationTarget, -): PreviewUrlResolution { - if (target.kind === "url") { - return { - requestedUrl: target.url, - resolvedUrl: target.url, - resolutionKind: "direct", - environmentId, - }; - } - const connection = readPreparedConnection(environmentId); - if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); - const environmentUrl = new URL(connection.httpBaseUrl); + target: Extract, + environmentUrl: URL, + requestedUrl?: string, + sourceUrl?: URL, +): PreviewUrlResolution => { if (!isPrivateNetworkHost(environmentUrl.hostname)) { throw new Error( "This environment port needs the planned authenticated preview gateway; its server address is not directly private-network reachable.", @@ -51,40 +68,72 @@ export function resolveBrowserNavigationTarget( } const protocol = target.protocol ?? "http"; const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`; - const requestedUrl = `${protocol}://localhost:${target.port}${path}`; const normalizedEnvironmentHost = environmentUrl.hostname.replace(/^\[|\]$/g, ""); const resolvedHost = normalizedEnvironmentHost.includes(":") ? `[${normalizedEnvironmentHost}]` : normalizedEnvironmentHost; - const resolved = new URL(path, `${protocol}://${resolvedHost}:${target.port}`); + const resolved = sourceUrl + ? new URL(sourceUrl) + : new URL(path, `${protocol}://${resolvedHost}:${target.port}`); + if (sourceUrl) { + resolved.hostname = resolvedHost; + resolved.port = String(target.port); + } return { - requestedUrl, + requestedUrl: requestedUrl ?? `${protocol}://localhost:${target.port}${path}`, resolvedUrl: resolved.toString(), - resolutionKind: - normalizedEnvironmentHost === "localhost" || normalizedEnvironmentHost === "127.0.0.1" - ? "direct" - : "direct-private-network", + resolutionKind: isLocalLoopbackHost(normalizedEnvironmentHost) + ? "direct" + : "direct-private-network", environmentId, }; +}; + +export function resolveBrowserNavigationTarget( + environmentId: EnvironmentId, + target: BrowserNavigationTarget, +): PreviewUrlResolution { + if (target.kind === "url") { + let parsed: URL | null = null; + try { + parsed = new URL(normalizePreviewUrl(target.url)); + } catch { + // Preserve the existing direct-navigation behavior so the preview host + // reports malformed URL errors through its normal navigation path. + } + if (parsed && isLoopbackHost(parsed.hostname)) { + const environmentUrl = readEnvironmentUrl(environmentId); + if (parsed.hostname === "0.0.0.0" || !isLocalLoopbackHost(environmentUrl.hostname)) { + return resolveEnvironmentPortTarget( + environmentId, + { + kind: "environment-port", + port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)), + protocol: parsed.protocol === "https:" ? "https" : "http", + path: `${parsed.pathname}${parsed.search}${parsed.hash}`, + }, + environmentUrl, + target.url, + parsed, + ); + } + } + return { + requestedUrl: target.url, + resolvedUrl: target.url, + resolutionKind: "direct", + environmentId, + }; + } + return resolveEnvironmentPortTarget(environmentId, target, readEnvironmentUrl(environmentId)); } export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string { try { const normalizedUrl = normalizePreviewUrl(rawUrl); - const parsed = new URL(normalizedUrl); - if (!isLoopbackHost(parsed.hostname)) return normalizedUrl; - const connection = readPreparedConnection(environmentId); - if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); - const environmentUrl = new URL(connection.httpBaseUrl); - if (parsed.hostname !== "0.0.0.0" && isLocalLoopbackHost(environmentUrl.hostname)) { - return normalizedUrl; - } - const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)); return resolveBrowserNavigationTarget(environmentId, { - kind: "environment-port", - port, - protocol: parsed.protocol === "https:" ? "https" : "http", - path: `${parsed.pathname}${parsed.search}${parsed.hash}`, + kind: "url", + url: normalizedUrl, }).resolvedUrl; } catch { return rawUrl; diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 0f1a8f9d429..aefa9ab9f41 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,6 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; -import { useEffect, type CSSProperties, type ReactNode } from "react"; -import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { useLocation, useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; @@ -55,11 +55,38 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + const pathname = useLocation({ select: (location) => location.pathname }); + const pathnameRef = useRef(pathname); + pathnameRef.current = pathname; + const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); + const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { + const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; + return isMacosDesktop && typeof getWindowFullscreenState === "function" + ? getWindowFullscreenState() + : false; + }); const macosWindowControlsStyle = - isElectron && isMacPlatform(navigator.platform) + isMacosDesktop && !isWindowFullscreen ? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties) : undefined; + useEffect(() => { + if (!isMacosDesktop) return; + const bridge = window.desktopBridge; + if (!bridge) return; + const { getWindowFullscreenState, onWindowFullscreenStateChange } = bridge; + if ( + typeof getWindowFullscreenState !== "function" || + typeof onWindowFullscreenStateChange !== "function" + ) { + return; + } + + const unsubscribe = onWindowFullscreenStateChange(setIsWindowFullscreen); + setIsWindowFullscreen(getWindowFullscreenState()); + return unsubscribe; + }, [isMacosDesktop]); + useEffect(() => { const onMenuAction = window.desktopBridge?.onMenuAction; if (typeof onMenuAction !== "function") { @@ -68,7 +95,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const unsubscribe = onMenuAction((action) => { if (action === "open-settings") { - void navigate({ to: "/settings" }); + const isSettingsRoute = /^\/settings(\/|$)/.test(pathnameRef.current); + if (!isSettingsRoute) { + void navigate({ to: "/settings" }); + } } }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b04e98a8a60..c85340c715b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -32,7 +32,7 @@ import React, { useState, type ReactNode, } from "react"; -import type { Components } from "react-markdown"; +import type { Components, Options as ReactMarkdownOptions } from "react-markdown"; import ReactMarkdown from "react-markdown"; import { defaultUrlTransform } from "react-markdown"; import rehypeRaw from "rehype-raw"; @@ -60,6 +60,7 @@ import { serializeTableElementToCsv, serializeTableElementToMarkdown, } from "../markdown-clipboard"; +import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { normalizeMarkdownLinkDestination, resolveMarkdownFileLinkMeta, @@ -164,6 +165,24 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { }, } satisfies Parameters[0]; +const CHAT_MARKDOWN_REMARK_PLUGINS = [ + remarkGfm, + remarkNormalizeListItemIndentation, + remarkPreserveCodeMeta, +] satisfies NonNullable; + +const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ + remarkGfm, + remarkNormalizeListItemIndentation, + remarkBreaks, + remarkPreserveCodeMeta, +] satisfies NonNullable; + +const CHAT_MARKDOWN_REHYPE_PLUGINS = [ + rehypeRaw, + [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], +] satisfies NonNullable; + function extractFenceLanguage(className: string | undefined): string { const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); const raw = match?.[1] ?? "text"; @@ -1545,11 +1564,9 @@ function ChatMarkdown({ > diff --git a/apps/web/src/components/ConnectionStatusDot.tsx b/apps/web/src/components/ConnectionStatusDot.tsx index b12acf1ba40..0c22f1702e5 100644 --- a/apps/web/src/components/ConnectionStatusDot.tsx +++ b/apps/web/src/components/ConnectionStatusDot.tsx @@ -17,7 +17,7 @@ export function ConnectionStatusDot({ {pingClassName ? ( diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 481ddab05ab..f7603a36e9a 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,4 +1,5 @@ import type { EnvironmentId } from "@t3tools/contracts"; +import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; import { FolderIcon } from "lucide-react"; import { useState } from "react"; import { useAssetUrl } from "../assets/assetUrls"; @@ -15,7 +16,7 @@ export function ProjectFavicon(input: { cwd: input.cwd, }); - if (!src) { + if (!src || isProjectFaviconFallbackUrl(src)) { return ; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 22dde9a565a..cd776e29768 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -805,7 +805,9 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr /> } > - + {terminalStatus.label} @@ -2246,7 +2248,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec @@ -3338,8 +3340,9 @@ export default function Sidebar() { return buildPhysicalToLogicalProjectKeyMap({ projects: visibleOrderedProjects, settings: projectGroupingSettings, + primaryEnvironmentId, }); - }, [visibleOrderedProjects, projectGroupingSettings]); + }, [visibleOrderedProjects, projectGroupingSettings, primaryEnvironmentId]); const projectPhysicalKeyByScopedRef = useMemo( () => new Map( diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 63cfb1095f3..2a390922eaa 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -173,7 +173,7 @@ export function ThreadStatusLabel({ > @@ -194,7 +194,7 @@ export function ThreadStatusLabel({ > {status.label} @@ -317,7 +317,9 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma /> } > - + {terminalStatus.label} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index eaf527eb5c1..6dda6362292 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -41,6 +41,7 @@ import { detectComposerTrigger, expandCollapsedComposerCursor, replaceTextRange, + shouldSubmitComposerOnEnter, } from "../../composer-logic"; import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; import { @@ -1791,7 +1792,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return true; } } - if (key === "Enter" && !event.shiftKey) { + if ( + key === "Enter" && + shouldSubmitComposerOnEnter({ isMobileViewport, shiftKey: event.shiftKey }) + ) { submitComposer(); return true; } diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index d826eca0063..bd02a91b248 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -190,7 +190,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
{option.label} {option.description && option.description !== option.label ? ( - {option.description} + {option.description} ) : null}
{isSelected ? ( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 39a43b9781b..564c4cb2b0c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1060,9 +1060,9 @@ function WorkingTimelineRow({ row }: { row: Extract
- - - + + + {row.createdAt ? ( diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx index 7f2d63e7ad2..460a253812a 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -1,5 +1,6 @@ import { findErrorTraceId } from "@t3tools/client-runtime/errors"; import { + type EnvironmentConnectionPresentation, RelayConnectionRegistration, RelayConnectionTarget, } from "@t3tools/client-runtime/connection"; @@ -10,7 +11,7 @@ import { import type { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import * as Option from "effect/Option"; -import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useState } from "react"; import { environmentCatalog } from "~/connection/catalog"; import { cn } from "~/lib/utils"; @@ -22,6 +23,12 @@ import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "../settings/itemRo import { Button } from "../ui/button"; import { Skeleton } from "../ui/skeleton"; import { toastManager } from "../ui/toast"; +import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnectionPresentation"; + +export interface SavedCloudEnvironmentConnection { + readonly environmentId: EnvironmentId; + readonly connection: EnvironmentConnectionPresentation; +} export function RemoteEnvironmentRowsSkeleton() { return ( @@ -40,19 +47,19 @@ export function RemoteEnvironmentRowsSkeleton() { /** * The user's T3 Connect environments from relay discovery, each with a * Connect button. The primary environment is always excluded; already-saved - * environments are hidden unless `showSavedAsConnected` renders them as - * connected instead (used by onboarding, where the full device mesh should be - * visible). + * environments are hidden unless `showSavedEnvironments` renders them with + * their live connection state (used by onboarding, where the full device mesh + * should be visible). */ export function CloudEnvironmentConnectRows({ primaryEnvironmentId, - savedEnvironmentIds, - showSavedAsConnected = false, + savedEnvironments, + showSavedEnvironments = false, empty = null, }: { readonly primaryEnvironmentId: EnvironmentId | null; - readonly savedEnvironmentIds: ReadonlyArray; - readonly showSavedAsConnected?: boolean; + readonly savedEnvironments: ReadonlyArray; + readonly showSavedEnvironments?: boolean; readonly empty?: ReactNode; }) { const environmentsState = useRelayEnvironmentDiscovery(); @@ -77,7 +84,9 @@ export function CloudEnvironmentConnectRows({ const [connectingEnvironmentId, setConnectingEnvironmentId] = useState( null, ); - const savedIds = useMemo(() => new Set(savedEnvironmentIds), [savedEnvironmentIds]); + const savedById = new Map( + savedEnvironments.map((environment) => [environment.environmentId, environment]), + ); useEffect(() => { void refreshRelayEnvironments(); @@ -90,8 +99,8 @@ export function CloudEnvironmentConnectRows({ if (result._tag === "Success") { toastManager.add({ type: "success", - title: "Environment connected", - description: `${environment.label} is available through T3 Connect.`, + title: "Environment added", + description: `Connecting to ${environment.label} through T3 Connect.`, }); return; } @@ -121,10 +130,10 @@ export function CloudEnvironmentConnectRows({ const visibleEnvironments = [...environmentsState.environments.values()].filter( ({ environment }) => environment.environmentId !== primaryEnvironmentId && - (showSavedAsConnected || !savedIds.has(environment.environmentId)), + (showSavedEnvironments || !savedById.has(environment.environmentId)), ); - const standalone = showSavedAsConnected || savedEnvironmentIds.length === 0; + const standalone = showSavedEnvironments || savedEnvironments.length === 0; if ( standalone && @@ -163,31 +172,57 @@ export function CloudEnvironmentConnectRows({ } return visibleEnvironments.map(({ environment, availability, error }) => { - const alreadyConnected = savedIds.has(environment.environmentId); + const savedEnvironment = savedById.get(environment.environmentId); + const savedConnection = savedEnvironment + ? presentSavedCloudEnvironmentConnection(savedEnvironment.connection) + : null; + const dotClassName = savedConnection + ? savedConnection.tone === "connected" + ? "bg-success" + : savedConnection.tone === "connecting" + ? "bg-warning" + : savedConnection.tone === "error" + ? "bg-destructive" + : "bg-muted-foreground/35" + : availability === "online" + ? "bg-success" + : availability === "error" + ? "bg-destructive" + : availability === "checking" + ? "bg-warning" + : "bg-muted-foreground/35"; + const statusText = savedConnection + ? savedConnection.statusText + : availability === "online" + ? "Available · Relay online" + : availability === "offline" + ? "Available · Relay offline" + : availability === "checking" + ? "Available · Checking relay status…" + : (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable"); return (

{environment.label}

@@ -195,21 +230,19 @@ export function CloudEnvironmentConnectRows({

- {availability === "online" - ? "Available · Relay online" - : availability === "offline" - ? "Available · Relay offline" - : availability === "checking" - ? "Available · Checking relay status…" - : (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable")} + {statusText}

- {alreadyConnected ? ( + {savedConnection ? ( ) : ( } /> - {tooltip} + 0 + ? "max-w-none text-balance" + : undefined + } + side="top" + > + {state ? ( + + ) : ( + tooltip + )} + {action === "download" && ( diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 097568f77f0..50fb652a495 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -216,7 +216,7 @@ function Sidebar({
@@ -622,7 +622,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { return (
{ + it("submits plain Enter on desktop", () => { + expect(shouldSubmitComposerOnEnter({ isMobileViewport: false, shiftKey: false })).toBe(true); + }); + + it("inserts a newline for plain Enter on mobile", () => { + expect(shouldSubmitComposerOnEnter({ isMobileViewport: true, shiftKey: false })).toBe(false); + }); + + it("inserts a newline for Shift+Enter", () => { + expect(shouldSubmitComposerOnEnter({ isMobileViewport: false, shiftKey: true })).toBe(false); + }); +}); + describe("detectComposerTrigger", () => { it("detects @path trigger at cursor", () => { const text = "Please check @src/com"; diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 4b30f449710..4b8aaf23dd1 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -17,6 +17,13 @@ export interface ComposerTrigger { rangeEnd: number; } +export function shouldSubmitComposerOnEnter(input: { + isMobileViewport: boolean; + shiftKey: boolean; +}): boolean { + return !input.isMobileViewport && !input.shiftKey; +} + const isInlineTokenSegment = ( segment: | { type: "text"; text: string } diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts index 56b4596b9f0..29596e72a9f 100644 --- a/apps/web/src/contextMenuFallback.test.ts +++ b/apps/web/src/contextMenuFallback.test.ts @@ -196,6 +196,23 @@ describe("showContextMenuFallback", () => { await expect(selectionPromise).resolves.toBe("rename"); }); + it("ignores a click from the gesture that opened the menu", async () => { + let enablePointerSelection: ((time: number) => void) | undefined; + vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => { + enablePointerSelection = callback; + return 0; + }); + + const selectionPromise = showContextMenuFallback([{ id: "rename", label: "Rename" }]); + const renameButton = findButton("Rename"); + + renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + enablePointerSelection?.(0); + renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + await expect(selectionPromise).resolves.toBe("rename"); + }); + it("opens nested submenus and resolves the clicked leaf id", async () => { const selectionPromise = showContextMenuFallback([ { diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index a301e0c92c8..9b3bb94dbce 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -272,7 +272,9 @@ export function showContextMenuFallback( button.addEventListener("mouseenter", () => { closeMenusFromLevel(level + 1); }); - button.addEventListener("click", () => cleanup(item.id)); + button.addEventListener("click", () => { + if (canDismissFromPointer) cleanup(item.id); + }); } } diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index c66bf4977b2..2e4e9c7f3dd 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -7,6 +7,10 @@ import { derivePhysicalProjectKey, resolveProjectGroupingMode, } from "./logicalProject"; +import { + buildPhysicalToLogicalProjectKeyMap, + buildSidebarProjectSnapshots, +} from "./sidebarProjectGrouping"; import type { Project } from "./types"; const primaryEnvironmentId = EnvironmentId.make("env-primary"); @@ -119,4 +123,121 @@ describe("environment grouping", () => { }), ).toBe("separate"); }); + + it("dedupes stale project rows with the same environment and workspace path", () => { + const duplicate = makeProject({ + id: ProjectId.make("project-duplicate"), + workspaceRoot: "/tmp/shared-repo/", + repositoryIdentity, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + const primary = makeProject({ + id: ProjectId.make("project-primary"), + repositoryIdentity, + updatedAt: "2026-01-02T00:00:00.000Z", + }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + workspaceRoot: "/tmp/shared-repo", + repositoryIdentity, + }); + + const snapshots = buildSidebarProjectSnapshots({ + projects: [primary, duplicate, remote], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => + environmentId === remoteEnvironmentId ? "remote" : "primary", + }); + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]?.groupedProjectCount).toBe(2); + expect(snapshots[0]?.memberProjects.map((project) => project.id)).toEqual([ + primary.id, + remote.id, + ]); + }); + + it("prefers the fresher project row when duplicate stale rows are ordered first", () => { + const staleDuplicate = makeProject({ + id: ProjectId.make("project-stale"), + workspaceRoot: "/tmp/shared-repo/", + repositoryIdentity, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + const canonical = makeProject({ + id: ProjectId.make("project-canonical"), + workspaceRoot: "/tmp/shared-repo", + repositoryIdentity, + updatedAt: "2026-01-02T00:00:00.000Z", + }); + + const snapshots = buildSidebarProjectSnapshots({ + projects: [staleDuplicate, canonical], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => "primary", + }); + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]?.memberProjects.map((project) => project.id)).toEqual([canonical.id]); + expect(snapshots[0]?.id).toBe(canonical.id); + }); + + it("dedupes stale project rows before logical grouping", () => { + const staleWithoutRepositoryIdentity = makeProject({ + id: ProjectId.make("project-stale"), + repositoryIdentity: null, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + const canonical = makeProject({ + id: ProjectId.make("project-canonical"), + repositoryIdentity, + updatedAt: "2026-01-02T00:00:00.000Z", + }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + + const snapshots = buildSidebarProjectSnapshots({ + projects: [staleWithoutRepositoryIdentity, canonical, remote], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => + environmentId === remoteEnvironmentId ? "remote" : "primary", + }); + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]?.projectKey).toBe(repositoryIdentity.canonicalKey); + expect(snapshots[0]?.memberProjects.map((project) => project.id)).toEqual([ + canonical.id, + remote.id, + ]); + }); + + it("routes duplicate physical project keys to the winning logical group", () => { + const staleWithoutRepositoryIdentity = makeProject({ + id: ProjectId.make("project-stale"), + repositoryIdentity: null, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + const canonical = makeProject({ + id: ProjectId.make("project-canonical"), + repositoryIdentity, + updatedAt: "2026-01-02T00:00:00.000Z", + }); + + const physicalToLogicalKey = buildPhysicalToLogicalProjectKeyMap({ + projects: [staleWithoutRepositoryIdentity, canonical], + settings: defaultGroupingSettings, + primaryEnvironmentId, + }); + + expect(physicalToLogicalKey.get(derivePhysicalProjectKey(staleWithoutRepositoryIdentity))).toBe( + repositoryIdentity.canonicalKey, + ); + }); }); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 3ae42a3e61c..4aa7a64fcfe 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -6,6 +6,7 @@ :root { --app-scrollbar-width: 6px; + --desktop-window-right-resize-inset: 0px; --workspace-topbar-height: 52px; --workspace-controls-top: 0px; --workspace-controls-left: calc(env(safe-area-inset-left) + 0.75rem); @@ -36,6 +37,11 @@ @theme inline { --animate-skeleton: skeleton 2s -1s infinite linear; + /* Duty-cycled indicator animations: long opacity holds with short stepped + ramps, so the compositor only produces frames while the value changes + (~20% of the cycle) instead of every vsync. Keep flat holds dominant. */ + --animate-status-pulse: status-pulse 2s infinite; + --animate-status-ping: status-ping 2s infinite; --font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; @@ -78,6 +84,36 @@ background-position: -200% 0; } } + @keyframes status-pulse { + 0%, + 40% { + opacity: 1; + animation-timing-function: steps(6); + } + 50%, + 90% { + opacity: 0.5; + animation-timing-function: steps(6); + } + 100% { + opacity: 1; + } + } + @keyframes status-ping { + /* Burst first (immediate feedback for click ripples), then hold + invisible for the rest of the cycle. Mirrors animate-ping's + 75%-scale start. */ + 0% { + opacity: 0.9; + scale: 0.75; + animation-timing-function: steps(8); + } + 40%, + 100% { + opacity: 0; + scale: 2; + } + } } @layer base { @@ -151,11 +187,18 @@ padding-inline-end: calc(env(safe-area-inset-right) + 0.75rem); } - .chat-composer-glass, - .chat-composer-lower-chrome { + .chat-composer-glass { background: color-mix(in srgb, var(--card) 20%, transparent); } + /* Mix from --background (not --card): the lower chrome is a full-width + strip over the main view, and a card-tinted wash reads as a lighter + seam against it when no content is scrolled underneath. The backdrop + blur, not the tint, carries the frosted effect. */ + .chat-composer-lower-chrome { + background: color-mix(in srgb, var(--background) 20%, transparent); + } + .chat-composer-glass { box-shadow: 0 18px 48px -20px rgb(0 0 0 / 28%), @@ -167,11 +210,14 @@ backdrop-filter: blur(16px); } - .dark .chat-composer-glass, - .dark .chat-composer-lower-chrome { + .dark .chat-composer-glass { background: color-mix(in srgb, var(--card) 45%, transparent); } + .dark .chat-composer-lower-chrome { + background: color-mix(in srgb, var(--background) 45%, transparent); + } + .dark .chat-composer-glass { box-shadow: 0 18px 48px -20px rgb(0 0 0 / 60%), @@ -192,10 +238,13 @@ } @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { - .chat-composer-glass, - .chat-composer-lower-chrome { + .chat-composer-glass { background: var(--card); } + + .chat-composer-lower-chrome { + background: var(--background); + } } } @@ -215,6 +264,15 @@ padding-right: max(env(safe-area-inset-right), 0px); } +/* Grain texture for chrome surfaces that float above the body (see the + --surface-grain note). Layered over the element's background-color, so it + composes with bg-* utilities. */ +@utility surface-grain { + background-image: var(--surface-grain); + background-repeat: repeat; + background-size: 256px 256px; +} + /* Suppress all transitions during theme changes */ .no-transitions, .no-transitions *, @@ -318,15 +376,26 @@ body { overflow-y: hidden; overscroll-behavior-y: none; padding-top: max(env(safe-area-inset-top), 0px); + padding-right: var(--desktop-window-right-resize-inset); } -body::after { - content: ""; - position: fixed; - inset: 0; - pointer-events: none; - opacity: 0.035; - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); +.electron-windows { + --desktop-window-right-resize-inset: 6px; +} + +/* App-chrome grain. Baked into each surface's own background (behind + content) rather than a fixed overlay on top: a full-viewport overlay + forces the compositor to re-blend every frame any animation produces, + which multiplied idle GPU cost. The overlay's 0.035 opacity lives in the + SVG rect instead. Surfaces that float over the body (e.g. the inset main + card) must opt in via the surface-grain utility, or they lose the + grain's subtle brightening and stand out against the body. */ +:root { + --surface-grain: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E"); +} + +body { + background-image: var(--surface-grain); background-repeat: repeat; background-size: 256px 256px; } diff --git a/apps/web/src/lib/windowControlsOverlay.ts b/apps/web/src/lib/windowControlsOverlay.ts index 8f9ae786b0e..42f9f13c7cd 100644 --- a/apps/web/src/lib/windowControlsOverlay.ts +++ b/apps/web/src/lib/windowControlsOverlay.ts @@ -1,4 +1,8 @@ +import { isWindowsPlatform } from "./utils"; + const WCO_CLASS_NAME = "wco"; +const ELECTRON_CLASS_NAME = "electron"; +const ELECTRON_WINDOWS_CLASS_NAME = "electron-windows"; interface WindowControlsOverlayLike { readonly visible: boolean; @@ -38,3 +42,25 @@ export function syncDocumentWindowControlsOverlayClass(): () => void { overlay.removeEventListener("geometrychange", update); }; } + +export function getElectronPlatformClassNames( + platform: string, +): + | readonly [typeof ELECTRON_CLASS_NAME] + | readonly [typeof ELECTRON_CLASS_NAME, typeof ELECTRON_WINDOWS_CLASS_NAME] { + return isWindowsPlatform(platform) + ? [ELECTRON_CLASS_NAME, ELECTRON_WINDOWS_CLASS_NAME] + : [ELECTRON_CLASS_NAME]; +} + +export function syncDocumentElectronPlatformClasses(platform: string): () => void { + if (typeof document === "undefined") { + return () => {}; + } + + const classNames = getElectronPlatformClassNames(platform); + document.documentElement.classList.add(...classNames); + return () => { + document.documentElement.classList.remove(...classNames); + }; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index dcb6d28a0ce..cad56569335 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -15,8 +15,11 @@ import { isElectron } from "./env"; import { ManagedRelayAuthProvider } from "./cloud/managedAuth"; import { hasCloudPublicConfig } from "./cloud/publicConfig"; import { getRouter } from "./router"; -import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; import { BASE_PATH } from "./basePath"; +import { + syncDocumentElectronPlatformClasses, + syncDocumentWindowControlsOverlayClass, +} from "./lib/windowControlsOverlay"; import { AppRoot } from "./AppRoot"; // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. @@ -25,6 +28,7 @@ const history = isElectron ? createHashHistory() : createBrowserHistory(); const router = getRouter(history, BASE_PATH); if (isElectron) { + syncDocumentElectronPlatformClasses(navigator.platform); syncDocumentWindowControlsOverlayClass(); } diff --git a/apps/web/src/markdown-list-indentation.test.tsx b/apps/web/src/markdown-list-indentation.test.tsx new file mode 100644 index 00000000000..2c04ed5fa83 --- /dev/null +++ b/apps/web/src/markdown-list-indentation.test.tsx @@ -0,0 +1,82 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { describe, expect, it } from "vite-plus/test"; + +import { remarkNormalizeListItemIndentation } from "./markdown-list-indentation"; + +function renderMarkdown(markdown: string): string { + return renderToStaticMarkup( + + {markdown} + , + ); +} + +describe("remarkNormalizeListItemIndentation", () => { + it("renders same-line over-indented list content as list text", () => { + const html = renderMarkdown(`why did you do this? + +- for (const step of rest.steps) { +- if (step.request.body) { +- step.request.body = ""; +- } +- }`); + + expect(html).not.toContain("
");
+    expect(html).toContain("
  • for (const step of rest.steps) {
  • "); + expect(html).toContain("
  • if (step.request.body) {
  • "); + expect(html).toContain("
  • step.request.body = "<redacted>";
  • "); + }); + + it("parses inline markdown in recovered list content", () => { + const html = renderMarkdown( + "- **important** [docs](https://example.com) use `inline code`, not ~~plain text~~", + ); + + expect(html).toContain("important"); + expect(html).toContain('docs'); + expect(html).toContain("inline code"); + expect(html).toContain("plain text"); + expect(html).not.toContain("**important**"); + }); + + it("preserves every recovered block separated by blank lines", () => { + const html = renderMarkdown(`- **first block** + + [second block](https://example.com)`); + + expect(html).toContain("first block"); + expect(html).toContain('
    second block'); + }); + + it("recursively normalizes lists in recovered tail blocks", () => { + const html = renderMarkdown(`- first block + + - nested block`); + + expect(html).not.toContain("
    ");
    +    expect(html).toContain("
  • nested block
  • "); + }); + + it("preserves fenced code blocks within list items", () => { + const html = renderMarkdown(`- \`\`\`ts + const value = 1; + \`\`\``); + + expect(html).toContain('
    const value = 1;');
    +  });
    +
    +  it("preserves indented code blocks that start below a list marker", () => {
    +    const html = renderMarkdown(`-
    +      const value = 1;`);
    +
    +    expect(html).toContain("
    const value = 1;");
    +  });
    +
    +  it("preserves same-line code blocks without excess indentation", () => {
    +    const html = renderMarkdown("-     const value = 1;");
    +
    +    expect(html).toContain("
    const value = 1;");
    +  });
    +});
    diff --git a/apps/web/src/markdown-list-indentation.ts b/apps/web/src/markdown-list-indentation.ts
    new file mode 100644
    index 00000000000..bd1728e4d93
    --- /dev/null
    +++ b/apps/web/src/markdown-list-indentation.ts
    @@ -0,0 +1,143 @@
    +interface MarkdownPosition {
    +  readonly start?: {
    +    readonly line?: number;
    +    readonly offset?: number;
    +  };
    +}
    +
    +interface MarkdownAstNode {
    +  readonly type: string;
    +  readonly value?: unknown;
    +  readonly position?: MarkdownPosition;
    +  children?: MarkdownAstNode[];
    +}
    +
    +interface MarkdownFile {
    +  readonly value?: unknown;
    +}
    +
    +interface MarkdownParser {
    +  parse(markdown: string): unknown;
    +}
    +
    +interface RecoveredMarkdown {
    +  readonly blocks: MarkdownAstNode[];
    +  readonly source: string;
    +}
    +
    +const INLINE_PARSE_PREFIX = "t3-markdown-inline-prefix:";
    +
    +function isSameLineOverIndentedCode(
    +  node: MarkdownAstNode,
    +  parent: MarkdownAstNode | undefined,
    +  markdown: string,
    +): boolean {
    +  if (
    +    node.type !== "code" ||
    +    parent?.type !== "listItem" ||
    +    typeof node.value !== "string" ||
    +    !/^[\t ]/.test(node.value)
    +  ) {
    +    return false;
    +  }
    +
    +  const nodeStart = node.position?.start;
    +  const parentStart = parent.position?.start;
    +  if (
    +    nodeStart?.line === undefined ||
    +    nodeStart.offset === undefined ||
    +    parentStart?.line === undefined ||
    +    nodeStart.line !== parentStart.line
    +  ) {
    +    return false;
    +  }
    +
    +  const sourceCharacter = markdown[nodeStart.offset];
    +  return sourceCharacter !== "`" && sourceCharacter !== "~";
    +}
    +
    +function parseRecoveredMarkdown(value: string, parser: MarkdownParser): RecoveredMarkdown {
    +  // A text prefix forces block-looking input into a paragraph while preserving
    +  // the processor's configured inline extensions (for example, GFM syntax).
    +  // Later root children are kept as blocks so blank-line-separated content is
    +  // never discarded.
    +  const source = `${INLINE_PARSE_PREFIX}${value}`;
    +  const document = parser.parse(source) as MarkdownAstNode;
    +  const blocks = document.children;
    +  const paragraph = blocks?.[0];
    +  const children = paragraph?.type === "paragraph" ? paragraph.children : undefined;
    +  const first = children?.[0];
    +  if (
    +    !blocks ||
    +    !children ||
    +    first?.type !== "text" ||
    +    typeof first.value !== "string" ||
    +    !first.value.startsWith(INLINE_PARSE_PREFIX)
    +  ) {
    +    return { blocks: [{ type: "text", value }], source };
    +  }
    +
    +  const firstValue = first.value.slice(INLINE_PARSE_PREFIX.length);
    +  return {
    +    blocks: [
    +      {
    +        ...paragraph,
    +        type: "paragraph",
    +        children: [...(firstValue ? [{ ...first, value: firstValue }] : []), ...children.slice(1)],
    +      },
    +      ...blocks.slice(1),
    +    ],
    +    source,
    +  };
    +}
    +
    +function blocksFromIndentedCode(node: MarkdownAstNode, parser: MarkdownParser): RecoveredMarkdown {
    +  const value = typeof node.value === "string" ? node.value.trim() : "";
    +  const recovered = parseRecoveredMarkdown(value, parser);
    +  const first = recovered.blocks[0];
    +  return {
    +    ...recovered,
    +    blocks:
    +      first && node.position
    +        ? [{ ...first, position: node.position }, ...recovered.blocks.slice(1)]
    +        : recovered.blocks,
    +  };
    +}
    +
    +/**
    + * CommonMark treats four or more spaces after a list marker as an indented
    + * code block. In chat output, excessive spacing is commonly accidental
    + * alignment such as `-       text`, which otherwise produces a full code card
    + * for every bullet. Only normalize blocks that retain excess indentation and
    + * start on the marker's own line; explicit fences and conventional indented
    + * blocks remain code.
    + */
    +function attachListItemIndentationNormalizer(this: MarkdownParser) {
    +  return (tree: MarkdownAstNode, file: MarkdownFile) => {
    +    if (typeof file.value !== "string") {
    +      return;
    +    }
    +    const markdown = file.value;
    +
    +    const visit = (node: MarkdownAstNode, source: string) => {
    +      if (!node.children) {
    +        return;
    +      }
    +      node.children = node.children.flatMap((child) => {
    +        if (isSameLineOverIndentedCode(child, node, source)) {
    +          const recovered = blocksFromIndentedCode(child, this);
    +          for (const block of recovered.blocks) {
    +            visit(block, recovered.source);
    +          }
    +          return recovered.blocks;
    +        }
    +        visit(child, source);
    +        return [child];
    +      });
    +    };
    +
    +    visit(tree, markdown);
    +  };
    +}
    +
    +export const remarkNormalizeListItemIndentation = attachListItemIndentationNormalizer;
    diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts
    index 599d43143df..7ca9370b514 100644
    --- a/apps/web/src/sidebarProjectGrouping.ts
    +++ b/apps/web/src/sidebarProjectGrouping.ts
    @@ -31,16 +31,77 @@ export interface SidebarProjectSnapshot extends Project {
       remoteEnvironmentLabels: readonly string[];
     }
     
    +interface SidebarProjectGroupCandidate {
    +  readonly logicalKey: string;
    +  readonly project: Project;
    +}
    +
    +function getProjectFreshnessTime(project: Project): number {
    +  const updatedAtTime = Date.parse(project.updatedAt);
    +  if (Number.isFinite(updatedAtTime)) {
    +    return updatedAtTime;
    +  }
    +  const createdAtTime = Date.parse(project.createdAt);
    +  return Number.isFinite(createdAtTime) ? createdAtTime : 0;
    +}
    +
    +function shouldReplaceDuplicateMember(input: {
    +  existingMember: Project;
    +  candidateMember: Project;
    +  primaryEnvironmentId: EnvironmentId | null;
    +}): boolean {
    +  if (
    +    input.primaryEnvironmentId !== null &&
    +    input.existingMember.environmentId !== input.primaryEnvironmentId &&
    +    input.candidateMember.environmentId === input.primaryEnvironmentId
    +  ) {
    +    return true;
    +  }
    +
    +  const existingFreshness = getProjectFreshnessTime(input.existingMember);
    +  const candidateFreshness = getProjectFreshnessTime(input.candidateMember);
    +  if (candidateFreshness !== existingFreshness) {
    +    return candidateFreshness > existingFreshness;
    +  }
    +
    +  return input.candidateMember.id > input.existingMember.id;
    +}
    +
    +function collectProjectWinnersByPhysicalKey(input: {
    +  projects: ReadonlyArray;
    +  settings: ProjectGroupingSettings;
    +  primaryEnvironmentId: EnvironmentId | null;
    +}): Map {
    +  const winnersByPhysicalKey = new Map();
    +  for (const project of input.projects) {
    +    const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings);
    +    const physicalProjectKey = derivePhysicalProjectKey(project);
    +    const existing = winnersByPhysicalKey.get(physicalProjectKey);
    +    if (!existing) {
    +      winnersByPhysicalKey.set(physicalProjectKey, { logicalKey, project });
    +      continue;
    +    }
    +    if (
    +      shouldReplaceDuplicateMember({
    +        existingMember: existing.project,
    +        candidateMember: project,
    +        primaryEnvironmentId: input.primaryEnvironmentId,
    +      })
    +    ) {
    +      winnersByPhysicalKey.set(physicalProjectKey, { logicalKey, project });
    +    }
    +  }
    +  return winnersByPhysicalKey;
    +}
    +
     export function buildPhysicalToLogicalProjectKeyMap(input: {
       projects: ReadonlyArray;
       settings: ProjectGroupingSettings;
    +  primaryEnvironmentId: EnvironmentId | null;
     }): Map {
       const mapping = new Map();
    -  for (const project of input.projects) {
    -    mapping.set(
    -      derivePhysicalProjectKey(project),
    -      deriveLogicalProjectKeyFromSettings(project, input.settings),
    -    );
    +  for (const [physicalProjectKey, winner] of collectProjectWinnersByPhysicalKey(input)) {
    +    mapping.set(physicalProjectKey, winner.logicalKey);
       }
       return mapping;
     }
    @@ -56,17 +117,17 @@ export function buildSidebarProjectSnapshots(input: {
       // legacy behavior.
       isDesktopLocalEnvironment?: (environmentId: EnvironmentId) => boolean;
     }): SidebarProjectSnapshot[] {
    +  const winnersByPhysicalKey = collectProjectWinnersByPhysicalKey(input);
       const groupedMembers = new Map();
    -  for (const project of input.projects) {
    -    const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings);
    +  for (const { logicalKey, project } of winnersByPhysicalKey.values()) {
         const member: SidebarProjectGroupMember = {
           ...project,
           physicalProjectKey: derivePhysicalProjectKey(project),
           environmentLabel: input.resolveEnvironmentLabel(project.environmentId),
         };
    -    const existing = groupedMembers.get(logicalKey);
    -    if (existing) {
    -      existing.push(member);
    +    const existingMembers = groupedMembers.get(logicalKey);
    +    if (existingMembers) {
    +      existingMembers.push(member);
         } else {
           groupedMembers.set(logicalKey, [member]);
         }
    diff --git a/apps/web/src/state/desktopUpdate.test.ts b/apps/web/src/state/desktopUpdate.test.ts
    index a2bcbd19a33..0c05b2d4539 100644
    --- a/apps/web/src/state/desktopUpdate.test.ts
    +++ b/apps/web/src/state/desktopUpdate.test.ts
    @@ -15,6 +15,7 @@ const baseState: DesktopUpdateState = {
       runningUnderArm64Translation: false,
       availableVersion: null,
       downloadedVersion: null,
    +  releaseNotes: [],
       downloadPercent: null,
       checkedAt: null,
       message: null,
    diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts
    index c68d5b7ee46..c2fe4b62714 100644
    --- a/apps/web/src/timestampFormat.test.ts
    +++ b/apps/web/src/timestampFormat.test.ts
    @@ -3,7 +3,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"
     import {
       formatElapsedDurationLabel,
       formatExpiresInLabel,
    +  formatRelativeTime,
    +  formatRelativeTimeLabel,
    +  formatRelativeTimeUntil,
       formatRelativeTimeUntilLabel,
    +  formatShortTimestamp,
    +  formatTimestamp,
    +  getRelativeTimeState,
       getTimestampFormatOptions,
     } from "./timestampFormat";
     
    @@ -90,6 +96,60 @@ describe("formatExpiresInLabel", () => {
       });
     });
     
    +describe("invalid timestamp inputs", () => {
    +  it("returns an empty timestamp instead of throwing", () => {
    +    expect(() => formatTimestamp("not-a-date", "12-hour")).not.toThrow();
    +    expect(formatTimestamp("not-a-date", "12-hour")).toBe("");
    +  });
    +
    +  it("returns an empty short timestamp instead of throwing", () => {
    +    expect(() => formatShortTimestamp("not-a-date", "12-hour")).not.toThrow();
    +    expect(formatShortTimestamp("not-a-date", "12-hour")).toBe("");
    +  });
    +
    +  it("returns an empty relative time label instead of a NaN label", () => {
    +    expect(formatRelativeTime("not-a-date")).toBeNull();
    +    expect(formatRelativeTimeLabel("not-a-date")).toBe("");
    +  });
    +
    +  it("distinguishes missing and invalid relative time state", () => {
    +    expect(getRelativeTimeState(null)).toEqual({ status: "missing" });
    +    expect(getRelativeTimeState("not-a-date")).toEqual({ status: "invalid" });
    +  });
    +
    +  it("returns an empty elapsed duration instead of a NaN label", () => {
    +    expect(formatElapsedDurationLabel("not-a-date")).toBe("");
    +  });
    +
    +  it("returns an empty relative time until label instead of a NaN label", () => {
    +    expect(formatRelativeTimeUntil("not-a-date")).toBeNull();
    +    expect(formatRelativeTimeUntilLabel("not-a-date")).toBe("");
    +  });
    +
    +  it("returns an empty expires-in label instead of a NaN label", () => {
    +    expect(formatExpiresInLabel("not-a-date")).toBe("");
    +  });
    +});
    +
    +describe("getRelativeTimeState", () => {
    +  beforeEach(() => {
    +    vi.useFakeTimers();
    +    vi.setSystemTime(new Date("2026-04-07T12:00:00.000Z"));
    +  });
    +
    +  afterEach(() => {
    +    vi.useRealTimers();
    +  });
    +
    +  it("returns relative parts for valid timestamps", () => {
    +    expect(getRelativeTimeState("2026-04-07T11:45:00.000Z")).toEqual({
    +      status: "relative",
    +      value: "15m",
    +      suffix: "ago",
    +    });
    +  });
    +});
    +
     describe("formatElapsedDurationLabel", () => {
       beforeEach(() => {
         vi.useFakeTimers();
    diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts
    index 28facf877e5..31cc41798eb 100644
    --- a/apps/web/src/timestampFormat.ts
    +++ b/apps/web/src/timestampFormat.ts
    @@ -40,8 +40,15 @@ function getTimestampFormatter(
       return formatter;
     }
     
    +function parseTimestampDate(isoDate: string): Date | null {
    +  const date = new Date(isoDate);
    +  return Number.isNaN(date.getTime()) ? null : date;
    +}
    +
     export function formatTimestamp(isoDate: string, timestampFormat: TimestampFormat): string {
    -  return getTimestampFormatter(timestampFormat, true).format(new Date(isoDate));
    +  const date = parseTimestampDate(isoDate);
    +  if (!date) return "";
    +  return getTimestampFormatter(timestampFormat, true).format(date);
     }
     
     const monthNameFormatter = new Intl.DateTimeFormat(undefined, { month: "long" });
    @@ -69,8 +76,8 @@ export function formatChatTimestampTooltip(
       isoDate: string,
       timestampFormat: TimestampFormat,
     ): string {
    -  const date = new Date(isoDate);
    -  if (Number.isNaN(date.getTime())) return "";
    +  const date = parseTimestampDate(isoDate);
    +  if (!date) return "";
       const time = formatShortTimestamp(isoDate, timestampFormat);
       const day = date.getDate();
       const month = monthNameFormatter.format(date);
    @@ -79,7 +86,9 @@ export function formatChatTimestampTooltip(
     }
     
     export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string {
    -  return getTimestampFormatter(timestampFormat, false).format(new Date(isoDate));
    +  const date = parseTimestampDate(isoDate);
    +  if (!date) return "";
    +  return getTimestampFormatter(timestampFormat, false).format(date);
     }
     
     /**
    @@ -87,8 +96,16 @@ export function formatShortTimestamp(isoDate: string, timestampFormat: Timestamp
      * Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }`
      * so callers can style the numeric portion independently.
      */
    -export function formatRelativeTime(isoDate: string): { value: string; suffix: string | null } {
    -  const diffMs = Date.now() - new Date(isoDate).getTime();
    +type RelativeTimeParts = { value: string; suffix: string | null };
    +export type RelativeTimeState =
    +  | { status: "missing" }
    +  | { status: "invalid" }
    +  | { status: "relative"; value: string; suffix: string | null };
    +
    +export function formatRelativeTime(isoDate: string): RelativeTimeParts | null {
    +  const date = parseTimestampDate(isoDate);
    +  if (!date) return null;
    +  const diffMs = Date.now() - date.getTime();
       if (diffMs < 0) return { value: "just now", suffix: null };
       const seconds = Math.floor(diffMs / 1000);
       if (seconds < 60) return { value: "just now", suffix: null };
    @@ -102,15 +119,25 @@ export function formatRelativeTime(isoDate: string): { value: string; suffix: st
     
     export function formatRelativeTimeLabel(isoDate: string) {
       const relative = formatRelativeTime(isoDate);
    +  if (!relative) return "";
       return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value;
     }
     
    +export function getRelativeTimeState(isoDate: string | null): RelativeTimeState {
    +  if (!isoDate) return { status: "missing" };
    +  const relative = formatRelativeTime(isoDate);
    +  if (!relative) return { status: "invalid" };
    +  return { status: "relative", ...relative };
    +}
    +
     /**
      * Relative elapsed duration since an ISO instant, without an "ago" suffix.
      * Useful for labels like "Connected for 3m".
      */
     export function formatElapsedDurationLabel(isoDate: string, nowMs: number = Date.now()): string {
    -  const diffMs = nowMs - new Date(isoDate).getTime();
    +  const date = parseTimestampDate(isoDate);
    +  if (!date) return "";
    +  const diffMs = nowMs - date.getTime();
       if (diffMs <= 0) return "just now";
     
       const seconds = Math.floor(diffMs / 1000);
    @@ -130,8 +157,10 @@ export function formatElapsedDurationLabel(isoDate: string, nowMs: number = Date
     /**
      * Relative time until an ISO instant (e.g. expiry). Mirrors {@link formatRelativeTime} but for future times.
      */
    -export function formatRelativeTimeUntil(isoDate: string): { value: string; suffix: string | null } {
    -  const diffMs = new Date(isoDate).getTime() - Date.now();
    +export function formatRelativeTimeUntil(isoDate: string): RelativeTimeParts | null {
    +  const date = parseTimestampDate(isoDate);
    +  if (!date) return null;
    +  const diffMs = date.getTime() - Date.now();
       if (diffMs <= 0) return { value: "Expired", suffix: null };
       const seconds = Math.floor(diffMs / 1000);
       if (seconds < 5) return { value: "Soon", suffix: null };
    @@ -146,6 +175,7 @@ export function formatRelativeTimeUntil(isoDate: string): { value: string; suffi
     
     export function formatRelativeTimeUntilLabel(isoDate: string): string {
       const relative = formatRelativeTimeUntil(isoDate);
    +  if (!relative) return "";
       return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value;
     }
     
    @@ -154,7 +184,9 @@ export function formatRelativeTimeUntilLabel(isoDate: string): string {
      * Pass `nowMs` when a parent tick drives re-renders so the diff matches that snapshot.
      */
     export function formatExpiresInLabel(isoDate: string, nowMs: number = Date.now()): string {
    -  const diffMs = new Date(isoDate).getTime() - nowMs;
    +  const date = parseTimestampDate(isoDate);
    +  if (!date) return "";
    +  const diffMs = date.getTime() - nowMs;
       if (diffMs <= 0) return "Expired";
     
       const totalSeconds = Math.floor(diffMs / 1000);
    diff --git a/assets/README.md b/assets/README.md
    new file mode 100644
    index 00000000000..8a7e3c5f20a
    --- /dev/null
    +++ b/assets/README.md
    @@ -0,0 +1,50 @@
    +# Brand icons
    +
    +The three Icon Composer projects are the source of truth for full application icons:
    +
    +- `dev/app-icon.icon`
    +- `nightly/app-icon.icon`
    +- `prod/app-icon.icon`
    +
    +Each project uses `text.svg` for the T3 mark and `background.svg` when the background is a vector layer. Additional layers use semantic names that describe their role and placement.
    +
    +Run `vp run icons:export` from the repository root to regenerate the tracked iOS, Linux, Windows, and web assets. Run `vp run icons:check` to verify that those generated assets match their sources without changing files.
    +
    +Exporting requires Icon Composer 2 or newer on macOS. The script selects the newest compatible exporter from Xcode or a standalone Icon Composer installation and pins design generation 26. Set `ICON_COMPOSER_TOOL` to the full path of `Icon Composer.app/Contents/Executables/ictool` to override automatic discovery.
    +
    +## macOS exports
    +
    +Icon Composer's command-line exporter does not expose the `macOS pre-Tahoe` preset. A plain command-line `macOS` export is full bleed and is not suitable for the desktop app, so the export script intentionally leaves the tracked macOS PNGs unchanged and prints a reminder after every run.
    +
    +After changing an Icon Composer project, open it in Icon Composer and export the macOS PNG with exactly these settings:
    +
    +- Platform: `macOS pre-Tahoe`
    +- Appearance: `Default`
    +- Size: `1024pt`
    +- Scale: `1×`
    +
    +Save the three exports to:
    +
    +- `dev/app-icon.icon` -> `dev/blueprint-macos-1024.png`
    +- `nightly/app-icon.icon` -> `nightly/nightly-macos-1024.png`
    +- `prod/app-icon.icon` -> `prod/black-macos-1024.png`
    +
    +The result must be a 1024×1024 PNG with the classic macOS safe area: the opaque icon body is 824×824, inset 100 pixels on every side, with only the native Icon Composer shadow extending into the surrounding transparent canvas.
    +
    +To have Codex perform the native exports, paste this prompt into a task opened at the repository root:
    +
    +```text
    +Use [@Computer](plugin://computer-use@openai-bundled) and the Icon Composer app to export the three macOS app icons in this repository.
    +
    +For each project below, use Platform: macOS pre-Tahoe, Appearance: Default, Size: 1024pt, and Scale: 1×, then save the PNG to the exact destination:
    +
    +- assets/dev/app-icon.icon -> assets/dev/blueprint-macos-1024.png
    +- assets/nightly/app-icon.icon -> assets/nightly/nightly-macos-1024.png
    +- assets/prod/app-icon.icon -> assets/prod/black-macos-1024.png
    +
    +Do not resize, composite, or otherwise post-process the exported PNGs.
    +
    +Verify every result is 1024×1024 and has the classic macOS safe area: an 824×824 opaque body inset 100px on every side, with only Icon Composer's native shadow extending beyond it.
    +```
    +
    +Do not edit the generated PNG or ICO files directly.
    diff --git a/assets/dev/app-icon.icon/Assets/annotations.svg b/assets/dev/app-icon.icon/Assets/annotations.svg
    new file mode 100644
    index 00000000000..dffde33559e
    --- /dev/null
    +++ b/assets/dev/app-icon.icon/Assets/annotations.svg
    @@ -0,0 +1,37 @@
    +
    +  
    +    
    +      
    +    
    +    
    +      
    +    
    +  
    +
    +  
    +    
    +      
    +      
    +      
    +      
    +      
    +      
    +      
    +      
    +      
    +    
    +
    +    
    +      
    +      
    +      
    +      
    +    
    +
    +    
    +      
    +      
    +      
    +    
    +  
    +
    diff --git a/assets/dev/app-icon.icon/Assets/background.svg b/assets/dev/app-icon.icon/Assets/background.svg
    new file mode 100644
    index 00000000000..db5c319d607
    --- /dev/null
    +++ b/assets/dev/app-icon.icon/Assets/background.svg
    @@ -0,0 +1,51 @@
    +
    +  
    +    
    +      
    +      
    +      
    +    
    +    
    +      
    +      
    +      
    +    
    +    
    +      
    +      
    +    
    +    
    +      
    +    
    +    
    +      
    +      
    +    
    +    
    +      
    +    
    +  
    +
    +  
    +    
    +    
    +    
    +    
    +    
    +
    +    
    +      
    +      
    +      
    +      
    +    
    +    
    +      
    +      
    +      
    +      
    +      
    +    
    +    
    +  
    +
    diff --git a/assets/dev/app-icon.icon/Assets/text.svg b/assets/dev/app-icon.icon/Assets/text.svg
    new file mode 100644
    index 00000000000..0b1825f2ef4
    --- /dev/null
    +++ b/assets/dev/app-icon.icon/Assets/text.svg
    @@ -0,0 +1,3 @@
    +
    +  
    +
    diff --git a/apps/mobile/assets/icon-composer-dev.icon/icon.json b/assets/dev/app-icon.icon/icon.json
    similarity index 53%
    rename from apps/mobile/assets/icon-composer-dev.icon/icon.json
    rename to assets/dev/app-icon.icon/icon.json
    index fd3fb6819a9..ff7d43d7811 100644
    --- a/apps/mobile/assets/icon-composer-dev.icon/icon.json
    +++ b/assets/dev/app-icon.icon/icon.json
    @@ -6,35 +6,40 @@
         {
           "layers": [
             {
    -          "hidden": false,
    -          "image-name": "gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png",
    -          "name": "gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr",
    +          "glass": true,
    +          "image-name": "text.svg",
    +          "name": "Text",
               "position": {
    -            "scale": 1.1,
    +            "scale": 8.5,
                 "translation-in-points": [0, 0]
               }
             },
             {
    -          "image-name": "T3.svg",
    -          "name": "T3",
    +          "glass": false,
    +          "image-name": "annotations.svg",
    +          "name": "Annotations",
               "position": {
    -            "scale": 10,
    +            "scale": 8.5,
                 "translation-in-points": [0, 0]
               }
             },
             {
    -          "hidden": true,
    -          "image-name": "Texturelabs_Paper_381XL.jpg",
    -          "name": "Texturelabs_Paper_381XL"
    +          "glass": true,
    +          "image-name": "background.svg",
    +          "name": "Background",
    +          "position": {
    +            "scale": 8.1,
    +            "translation-in-points": [0, 0]
    +          }
             }
           ],
           "shadow": {
             "kind": "neutral",
    -        "opacity": 0.5
    +        "opacity": 0.6
           },
           "translucency": {
             "enabled": true,
    -        "value": 0.5
    +        "value": 0.2
           }
         }
       ],
    diff --git a/assets/dev/blueprint-icon-composer.icon/Assets/T3.svg b/assets/dev/blueprint-icon-composer.icon/Assets/T3.svg
    deleted file mode 100644
    index b12706fdfc2..00000000000
    --- a/assets/dev/blueprint-icon-composer.icon/Assets/T3.svg
    +++ /dev/null
    @@ -1,3 +0,0 @@
    -
    -
    -
    diff --git a/assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg b/assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg
    deleted file mode 100644
    index d98c41a4e3e..00000000000
    Binary files a/assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg and /dev/null differ
    diff --git a/assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png b/assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png
    deleted file mode 100644
    index fa7700d01c7..00000000000
    Binary files a/assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png and /dev/null differ
    diff --git a/assets/dev/blueprint-icon-composer.icon/icon.json b/assets/dev/blueprint-icon-composer.icon/icon.json
    deleted file mode 100644
    index 1c15123dab4..00000000000
    --- a/assets/dev/blueprint-icon-composer.icon/icon.json
    +++ /dev/null
    @@ -1,47 +0,0 @@
    -{
    -  "fill": {
    -    "solid": "display-p3:0.00000,0.00000,0.00000,1.00000"
    -  },
    -  "groups": [
    -    {
    -      "layers": [
    -        {
    -          "blend-mode": "normal",
    -          "fill": "automatic",
    -          "glass": true,
    -          "hidden": false,
    -          "image-name": "gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png",
    -          "name": "gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871",
    -          "position": {
    -            "scale": 1.05,
    -            "translation-in-points": [0, 0]
    -          }
    -        },
    -        {
    -          "image-name": "T3.svg",
    -          "name": "T3",
    -          "position": {
    -            "scale": 10,
    -            "translation-in-points": [0, 0]
    -          }
    -        },
    -        {
    -          "hidden": false,
    -          "image-name": "Texturelabs_Paper_381XL.jpg",
    -          "name": "Texturelabs_Paper_381XL"
    -        }
    -      ],
    -      "shadow": {
    -        "kind": "neutral",
    -        "opacity": 0.5
    -      },
    -      "translucency": {
    -        "enabled": true,
    -        "value": 0.5
    -      }
    -    }
    -  ],
    -  "supported-platforms": {
    -    "squares": ["iOS", "macOS"]
    -  }
    -}
    diff --git a/assets/dev/blueprint-ios-1024.png b/assets/dev/blueprint-ios-1024.png
    index 51b764ac72a..a53c8d19aa8 100644
    Binary files a/assets/dev/blueprint-ios-1024.png and b/assets/dev/blueprint-ios-1024.png differ
    diff --git a/assets/dev/blueprint-macos-1024.png b/assets/dev/blueprint-macos-1024.png
    index 7c165fdb851..68a567058be 100644
    Binary files a/assets/dev/blueprint-macos-1024.png and b/assets/dev/blueprint-macos-1024.png differ
    diff --git a/assets/dev/blueprint-universal-1024.png b/assets/dev/blueprint-universal-1024.png
    index 51b764ac72a..a53c8d19aa8 100644
    Binary files a/assets/dev/blueprint-universal-1024.png and b/assets/dev/blueprint-universal-1024.png differ
    diff --git a/assets/dev/blueprint-web-apple-touch-180.png b/assets/dev/blueprint-web-apple-touch-180.png
    index 4a135d22fdb..3eed25ea6b7 100644
    Binary files a/assets/dev/blueprint-web-apple-touch-180.png and b/assets/dev/blueprint-web-apple-touch-180.png differ
    diff --git a/assets/dev/blueprint-web-favicon-16x16.png b/assets/dev/blueprint-web-favicon-16x16.png
    index 56e0e837131..a3431b8c6df 100644
    Binary files a/assets/dev/blueprint-web-favicon-16x16.png and b/assets/dev/blueprint-web-favicon-16x16.png differ
    diff --git a/assets/dev/blueprint-web-favicon-32x32.png b/assets/dev/blueprint-web-favicon-32x32.png
    index 9e9f31ea989..862f7629971 100644
    Binary files a/assets/dev/blueprint-web-favicon-32x32.png and b/assets/dev/blueprint-web-favicon-32x32.png differ
    diff --git a/assets/dev/blueprint-web-favicon.ico b/assets/dev/blueprint-web-favicon.ico
    index e0ee3e1408f..750da22602e 100644
    Binary files a/assets/dev/blueprint-web-favicon.ico and b/assets/dev/blueprint-web-favicon.ico differ
    diff --git a/assets/dev/blueprint-windows.ico b/assets/dev/blueprint-windows.ico
    index e0ee3e1408f..750da22602e 100644
    Binary files a/assets/dev/blueprint-windows.ico and b/assets/dev/blueprint-windows.ico differ
    diff --git a/assets/nightly/app-icon.icon/Assets/background.svg b/assets/nightly/app-icon.icon/Assets/background.svg
    new file mode 100644
    index 00000000000..1ceca91c023
    --- /dev/null
    +++ b/assets/nightly/app-icon.icon/Assets/background.svg
    @@ -0,0 +1,43 @@
    +
    +  
    +    
    +      
    +      
    +      
    +    
    +    
    +      
    +      
    +      
    +    
    +    
    +      
    +      
    +    
    +    
    +      
    +    
    +  
    +
    +  
    +    
    +    
    +    
    +
    +
    +    
    +    
    +      
    +      
    +      
    +      
    +      
    +    
    +    
    +    
    +
    +    
    +    
    +    
    +  
    +
    diff --git a/assets/nightly/app-icon.icon/Assets/cloud-lower-left.svg b/assets/nightly/app-icon.icon/Assets/cloud-lower-left.svg
    new file mode 100644
    index 00000000000..d43478860f4
    --- /dev/null
    +++ b/assets/nightly/app-icon.icon/Assets/cloud-lower-left.svg
    @@ -0,0 +1,21 @@
    +
    +  
    +    
    +      
    +      
    +      
    +    
    +    
    +      
    +      
    +    
    +    
    +      
    +    
    +  
    +
    +  
    +    
    +    
    +  
    +
    diff --git a/assets/nightly/app-icon.icon/Assets/cloud-upper-right.svg b/assets/nightly/app-icon.icon/Assets/cloud-upper-right.svg
    new file mode 100644
    index 00000000000..7b094441db1
    --- /dev/null
    +++ b/assets/nightly/app-icon.icon/Assets/cloud-upper-right.svg
    @@ -0,0 +1,21 @@
    +
    +  
    +    
    +      
    +      
    +      
    +    
    +    
    +      
    +      
    +    
    +    
    +      
    +    
    +  
    +
    +  
    +    
    +    
    +  
    +
    diff --git a/assets/nightly/app-icon.icon/Assets/text.svg b/assets/nightly/app-icon.icon/Assets/text.svg
    new file mode 100644
    index 00000000000..0b1825f2ef4
    --- /dev/null
    +++ b/assets/nightly/app-icon.icon/Assets/text.svg
    @@ -0,0 +1,3 @@
    +
    +  
    +
    diff --git a/assets/nightly/app-icon.icon/icon.json b/assets/nightly/app-icon.icon/icon.json
    new file mode 100644
    index 00000000000..eb48d26b4a5
    --- /dev/null
    +++ b/assets/nightly/app-icon.icon/icon.json
    @@ -0,0 +1,94 @@
    +{
    +  "fill": {
    +    "automatic-gradient": "display-p3:0.00000,0.00000,0.00000,1.00000"
    +  },
    +  "groups": [
    +    {
    +      "layers": [
    +        {
    +          "glass": true,
    +          "image-name": "text.svg",
    +          "name": "Text",
    +          "position": {
    +            "scale": 8.5,
    +            "translation-in-points": [0, 0]
    +          }
    +        },
    +        {
    +          "glass": false,
    +          "image-name": "cloud-upper-right.svg",
    +          "name": "Cloud Upper Right",
    +          "opacity": 1,
    +          "position": {
    +            "scale": 15,
    +            "translation-in-points": [387.9605131881942, -134.30064713259117]
    +          }
    +        },
    +        {
    +          "glass": false,
    +          "image-name": "cloud-lower-left.svg",
    +          "name": "Cloud Lower Left",
    +          "position": {
    +            "scale": 25,
    +            "translation-in-points": [-309.63750000000005, 268.66077693836917]
    +          }
    +        },
    +        {
    +          "glass": true,
    +          "hidden": false,
    +          "image-name": "background.svg",
    +          "name": "Background",
    +          "position": {
    +            "scale": 8.1,
    +            "translation-in-points": [0, -2.171875]
    +          }
    +        }
    +      ],
    +      "shadow": {
    +        "kind": "neutral",
    +        "opacity": 0.6
    +      },
    +      "translucency": {
    +        "enabled": true,
    +        "value": 0.2
    +      }
    +    },
    +    {
    +      "layers": [],
    +      "shadow": {
    +        "kind": "neutral",
    +        "opacity": 0.6
    +      },
    +      "translucency": {
    +        "enabled": true,
    +        "value": 0.2
    +      }
    +    },
    +    {
    +      "layers": [],
    +      "shadow": {
    +        "kind": "neutral",
    +        "opacity": 0.6
    +      },
    +      "translucency": {
    +        "enabled": true,
    +        "value": 0.2
    +      }
    +    },
    +    {
    +      "layers": [],
    +      "shadow": {
    +        "kind": "neutral",
    +        "opacity": 0.6
    +      },
    +      "translucency": {
    +        "enabled": true,
    +        "value": 0.2
    +      }
    +    }
    +  ],
    +  "supported-platforms": {
    +    "circles": ["watchOS"],
    +    "squares": "shared"
    +  }
    +}
    diff --git a/assets/nightly/blueprint-ios-1024.png b/assets/nightly/blueprint-ios-1024.png
    deleted file mode 100644
    index b33d6f337b0..00000000000
    Binary files a/assets/nightly/blueprint-ios-1024.png and /dev/null differ
    diff --git a/assets/nightly/blueprint-macos-1024.png b/assets/nightly/blueprint-macos-1024.png
    deleted file mode 100644
    index 8dba03e01fe..00000000000
    Binary files a/assets/nightly/blueprint-macos-1024.png and /dev/null differ
    diff --git a/assets/nightly/blueprint-universal-1024.png b/assets/nightly/blueprint-universal-1024.png
    deleted file mode 100644
    index b33d6f337b0..00000000000
    Binary files a/assets/nightly/blueprint-universal-1024.png and /dev/null differ
    diff --git a/assets/nightly/blueprint-web-apple-touch-180.png b/assets/nightly/blueprint-web-apple-touch-180.png
    deleted file mode 100644
    index e0e1b9659b8..00000000000
    Binary files a/assets/nightly/blueprint-web-apple-touch-180.png and /dev/null differ
    diff --git a/assets/nightly/blueprint-web-favicon-16x16.png b/assets/nightly/blueprint-web-favicon-16x16.png
    deleted file mode 100644
    index 673d8459998..00000000000
    Binary files a/assets/nightly/blueprint-web-favicon-16x16.png and /dev/null differ
    diff --git a/assets/nightly/blueprint-web-favicon-32x32.png b/assets/nightly/blueprint-web-favicon-32x32.png
    deleted file mode 100644
    index 25bcc95d4aa..00000000000
    Binary files a/assets/nightly/blueprint-web-favicon-32x32.png and /dev/null differ
    diff --git a/assets/nightly/blueprint-web-favicon.ico b/assets/nightly/blueprint-web-favicon.ico
    deleted file mode 100644
    index 36975b99782..00000000000
    Binary files a/assets/nightly/blueprint-web-favicon.ico and /dev/null differ
    diff --git a/assets/nightly/blueprint-windows.ico b/assets/nightly/blueprint-windows.ico
    deleted file mode 100644
    index 36975b99782..00000000000
    Binary files a/assets/nightly/blueprint-windows.ico and /dev/null differ
    diff --git a/assets/nightly/nightly-ios-1024.png b/assets/nightly/nightly-ios-1024.png
    new file mode 100644
    index 00000000000..42ce5589e9c
    Binary files /dev/null and b/assets/nightly/nightly-ios-1024.png differ
    diff --git a/assets/nightly/nightly-macos-1024.png b/assets/nightly/nightly-macos-1024.png
    new file mode 100644
    index 00000000000..1beacd9f34a
    Binary files /dev/null and b/assets/nightly/nightly-macos-1024.png differ
    diff --git a/assets/nightly/nightly-universal-1024.png b/assets/nightly/nightly-universal-1024.png
    new file mode 100644
    index 00000000000..42ce5589e9c
    Binary files /dev/null and b/assets/nightly/nightly-universal-1024.png differ
    diff --git a/assets/nightly/nightly-web-apple-touch-180.png b/assets/nightly/nightly-web-apple-touch-180.png
    new file mode 100644
    index 00000000000..f09a169458a
    Binary files /dev/null and b/assets/nightly/nightly-web-apple-touch-180.png differ
    diff --git a/assets/nightly/nightly-web-favicon-16x16.png b/assets/nightly/nightly-web-favicon-16x16.png
    new file mode 100644
    index 00000000000..20208f5f369
    Binary files /dev/null and b/assets/nightly/nightly-web-favicon-16x16.png differ
    diff --git a/assets/nightly/nightly-web-favicon-32x32.png b/assets/nightly/nightly-web-favicon-32x32.png
    new file mode 100644
    index 00000000000..b9008d518bc
    Binary files /dev/null and b/assets/nightly/nightly-web-favicon-32x32.png differ
    diff --git a/assets/nightly/nightly-web-favicon.ico b/assets/nightly/nightly-web-favicon.ico
    new file mode 100644
    index 00000000000..b6a0b43b93d
    Binary files /dev/null and b/assets/nightly/nightly-web-favicon.ico differ
    diff --git a/assets/nightly/nightly-windows.ico b/assets/nightly/nightly-windows.ico
    new file mode 100644
    index 00000000000..b6a0b43b93d
    Binary files /dev/null and b/assets/nightly/nightly-windows.ico differ
    diff --git a/apps/mobile/assets/icon-composer-dev.icon/Assets/T3.svg b/assets/prod/app-icon.icon/Assets/text.svg
    similarity index 100%
    rename from apps/mobile/assets/icon-composer-dev.icon/Assets/T3.svg
    rename to assets/prod/app-icon.icon/Assets/text.svg
    diff --git a/apps/mobile/assets/icon-composer-prod.icon/icon.json b/assets/prod/app-icon.icon/icon.json
    similarity index 88%
    rename from apps/mobile/assets/icon-composer-prod.icon/icon.json
    rename to assets/prod/app-icon.icon/icon.json
    index 8f7579311f2..8c12c022ef8 100644
    --- a/apps/mobile/assets/icon-composer-prod.icon/icon.json
    +++ b/assets/prod/app-icon.icon/icon.json
    @@ -6,8 +6,8 @@
         {
           "layers": [
             {
    -          "image-name": "T3.svg",
    -          "name": "T3",
    +          "image-name": "text.svg",
    +          "name": "Text",
               "position": {
                 "scale": 10,
                 "translation-in-points": [0, 0]
    diff --git a/assets/prod/black-ios-1024.png b/assets/prod/black-ios-1024.png
    index 2ff311ce786..a8a458337ab 100644
    Binary files a/assets/prod/black-ios-1024.png and b/assets/prod/black-ios-1024.png differ
    diff --git a/assets/prod/black-macos-1024.png b/assets/prod/black-macos-1024.png
    index 0a6e1cbfcff..f5b68b1a6d4 100644
    Binary files a/assets/prod/black-macos-1024.png and b/assets/prod/black-macos-1024.png differ
    diff --git a/assets/prod/black-universal-1024.png b/assets/prod/black-universal-1024.png
    index 88317dd41fb..a8a458337ab 100644
    Binary files a/assets/prod/black-universal-1024.png and b/assets/prod/black-universal-1024.png differ
    diff --git a/assets/prod/t3-black-web-apple-touch-180.png b/assets/prod/t3-black-web-apple-touch-180.png
    index 9c593ce352c..2e30aecbc72 100644
    Binary files a/assets/prod/t3-black-web-apple-touch-180.png and b/assets/prod/t3-black-web-apple-touch-180.png differ
    diff --git a/assets/prod/t3-black-web-favicon-16x16.png b/assets/prod/t3-black-web-favicon-16x16.png
    index f85017caf75..c175f2c87a2 100644
    Binary files a/assets/prod/t3-black-web-favicon-16x16.png and b/assets/prod/t3-black-web-favicon-16x16.png differ
    diff --git a/assets/prod/t3-black-web-favicon-32x32.png b/assets/prod/t3-black-web-favicon-32x32.png
    index fae2d285b7a..fe6fb545b85 100644
    Binary files a/assets/prod/t3-black-web-favicon-32x32.png and b/assets/prod/t3-black-web-favicon-32x32.png differ
    diff --git a/assets/prod/t3-black-web-favicon.ico b/assets/prod/t3-black-web-favicon.ico
    index e3ab4ae5e02..a0fe86da92f 100644
    Binary files a/assets/prod/t3-black-web-favicon.ico and b/assets/prod/t3-black-web-favicon.ico differ
    diff --git a/assets/prod/t3-black-windows.ico b/assets/prod/t3-black-windows.ico
    index e3ab4ae5e02..a0fe86da92f 100644
    Binary files a/assets/prod/t3-black-windows.ico and b/assets/prod/t3-black-windows.ico differ
    diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md
    index ce5a0afe92a..a7b777fb773 100644
    --- a/docs/architecture/overview.md
    +++ b/docs/architecture/overview.md
    @@ -60,11 +60,11 @@ sequenceDiagram
         Transport-->>Browser: Hydrate initial state
     ```
     
    -1. The browser boots [`WsTransport`][1] and registers typed listeners in [`wsNativeApi`][2].
    -2. The server accepts the connection in [`wsServer`][3] and brings up the runtime graph defined in [`serverLayers`][7].
    -3. [`ServerReadiness`][4] waits until the key startup barriers are complete.
    -4. Once the server is ready, [`wsServer`][3] sends `server.welcome` from the contracts in [`ws.ts`][6] through [`ServerPushBus`][5].
    -5. The browser receives that ordered push through [`WsTransport`][1], and [`wsNativeApi`][2] uses it to seed local client state.
    +1. The browser boots `WsTransport` and registers typed listeners in `wsNativeApi`.
    +2. The server accepts the connection in `wsServer` and brings up the runtime graph defined in `serverLayers`.
    +3. `ServerReadiness` waits until the key startup barriers are complete.
    +4. Once the server is ready, `wsServer` sends `server.welcome` from the contracts in `ws.ts` through `ServerPushBus`.
    +5. The browser receives that ordered push through `WsTransport`, and `wsNativeApi` uses it to seed local client state.
     
     ### User turn flow
     
    @@ -90,12 +90,12 @@ sequenceDiagram
         Push-->>Browser: Typed push
     ```
     
    -1. A user action in the browser becomes a typed request through [`WsTransport`][1] and the browser API layer in [`nativeApi`][12].
    -2. [`wsServer`][3] decodes that request using the shared WebSocket contracts in [`ws.ts`][6] and routes it to the right service.
    +1. A user action in the browser becomes a typed request through `WsTransport` and the browser API layer in `nativeApi`.
    +2. `wsServer` decodes that request using the shared WebSocket contracts in `ws.ts` and routes it to the right service.
     3. [`ProviderService`][8] starts or resumes a session and talks to `codex app-server` over JSON-RPC on stdio.
     4. Provider-native events are pulled back into the server by [`ProviderRuntimeIngestion`][9], which converts them into orchestration events.
     5. [`OrchestrationEngine`][10] persists those events, updates the read model, and exposes them as domain events.
    -6. [`wsServer`][3] pushes those updates to the browser through [`ServerPushBus`][5] on channels defined in [`orchestration.ts`][11].
    +6. `wsServer` pushes those updates to the browser through `ServerPushBus` on channels defined in [`orchestration.ts`][11].
     
     ### Async completion flow
     
    @@ -123,21 +123,13 @@ sequenceDiagram
     2. These flows run as queue-backed workers using [`DrainableWorker`][16], which helps keep side effects ordered and test synchronization deterministic.
     3. When a milestone completes, the server emits a typed receipt on [`RuntimeReceiptBus`][15], such as checkpoint completion or turn quiescence.
     4. Tests and orchestration code wait on those receipts instead of polling git state, projections, or timers.
    -5. Any user-visible state changes produced by that async work still go back through [`wsServer`][3] and [`ServerPushBus`][5].
    -
    -[1]: ../apps/web/src/wsTransport.ts
    -[2]: ../apps/web/src/wsNativeApi.ts
    -[3]: ../apps/server/src/wsServer.ts
    -[4]: ../apps/server/src/wsServer/readiness.ts
    -[5]: ../apps/server/src/wsServer/pushBus.ts
    -[6]: ../packages/contracts/src/ws.ts
    -[7]: ../apps/server/src/serverLayers.ts
    -[8]: ../apps/server/src/provider/Layers/ProviderService.ts
    -[9]: ../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
    -[10]: ../apps/server/src/orchestration/Layers/OrchestrationEngine.ts
    -[11]: ../packages/contracts/src/orchestration.ts
    -[12]: ../apps/web/src/nativeApi.ts
    -[13]: ../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
    -[14]: ../apps/server/src/orchestration/Layers/CheckpointReactor.ts
    -[15]: ../apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts
    -[16]: ../packages/shared/src/DrainableWorker.ts
    +5. Any user-visible state changes produced by that async work still go back through `wsServer` and `ServerPushBus`.
    +
    +[8]: ../../apps/server/src/provider/Layers/ProviderService.ts
    +[9]: ../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
    +[10]: ../../apps/server/src/orchestration/Layers/OrchestrationEngine.ts
    +[11]: ../../packages/contracts/src/orchestration.ts
    +[13]: ../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
    +[14]: ../../apps/server/src/orchestration/Layers/CheckpointReactor.ts
    +[15]: ../../apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts
    +[16]: ../../packages/shared/src/DrainableWorker.ts
    diff --git a/docs/operations/ci.md b/docs/operations/ci.md
    index d030b446b6a..7a0447ec070 100644
    --- a/docs/operations/ci.md
    +++ b/docs/operations/ci.md
    @@ -1,6 +1,6 @@
     # CI quality gates
     
    -- `.github/workflows/ci.yml` runs `bun run lint`, `bun run typecheck`, and `bun run test` on pull requests and pushes to `main`.
    +- `.github/workflows/ci.yml` runs `vp check` (lint + typecheck), `vpr typecheck`, and `vp run test` on pull requests and pushes to `main`.
     - `.github/workflows/release.yml` builds macOS (`arm64` and `x64`), Linux (`x64`), and Windows (`x64`) desktop artifacts from a single `v*.*.*` tag and publishes one GitHub release.
     - The release workflow auto-enables signing only when platform credentials are present. macOS passkey builds additionally require `APPLE_TEAM_ID` and the `MACOS_PROVISIONING_PROFILE` secret; Windows uses Azure Trusted Signing. Without the core signing credentials, it still releases unsigned artifacts.
     - See [Release Checklist](./release.md) for the full release/signing setup checklist.
    diff --git a/docs/operations/effect-fn-checklist.md b/docs/operations/effect-fn-checklist.md
    index 279b5646d32..938dea8d681 100644
    --- a/docs/operations/effect-fn-checklist.md
    +++ b/docs/operations/effect-fn-checklist.md
    @@ -56,43 +56,43 @@ Effect.fn("name")(
     
     ### `apps/server/src/provider/Layers/ClaudeAdapter.ts` (`62`)
     
    -- [x] [buildUserMessageEffect](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ClaudeAdapter.ts#L554)
    -- [x] [makeClaudeAdapter](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ClaudeAdapter.ts#L913)
    -- [x] [startSession](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ClaudeAdapter.ts#L2414)
    -- [x] [sendTurn](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ClaudeAdapter.ts#L2887)
    -- [x] [interruptTurn](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ClaudeAdapter.ts#L2975)
    -- [x] [readThread](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ClaudeAdapter.ts#L2984)
    -- [x] [rollbackThread](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ClaudeAdapter.ts#L2990)
    -- [x] [stopSession](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ClaudeAdapter.ts#L3039)
    +- [x] [buildUserMessageEffect](../../apps/server/src/provider/Layers/ClaudeAdapter.ts#L554)
    +- [x] [makeClaudeAdapter](../../apps/server/src/provider/Layers/ClaudeAdapter.ts#L913)
    +- [x] [startSession](../../apps/server/src/provider/Layers/ClaudeAdapter.ts#L2414)
    +- [x] [sendTurn](../../apps/server/src/provider/Layers/ClaudeAdapter.ts#L2887)
    +- [x] [interruptTurn](../../apps/server/src/provider/Layers/ClaudeAdapter.ts#L2975)
    +- [x] [readThread](../../apps/server/src/provider/Layers/ClaudeAdapter.ts#L2984)
    +- [x] [rollbackThread](../../apps/server/src/provider/Layers/ClaudeAdapter.ts#L2990)
    +- [x] [stopSession](../../apps/server/src/provider/Layers/ClaudeAdapter.ts#L3039)
     - [x] Internal helpers and callback wrappers in this file
     
     ### `apps/server/src/git/Layers/GitCore.ts` (`58`)
     
    -- [x] [makeGitCore](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L513)
    -- [x] [handleTraceLine](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L324)
    -- [x] [emitCompleteLines](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L455)
    -- [x] [commit](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1190)
    -- [x] [pushCurrentBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1223)
    -- [x] [pullCurrentBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1323)
    -- [x] [checkoutBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1727)
    +- [x] [makeGitCore](../../apps/server/src/git/Layers/GitCore.ts#L513)
    +- [x] [handleTraceLine](../../apps/server/src/git/Layers/GitCore.ts#L324)
    +- [x] [emitCompleteLines](../../apps/server/src/git/Layers/GitCore.ts#L455)
    +- [x] [commit](../../apps/server/src/git/Layers/GitCore.ts#L1190)
    +- [x] [pushCurrentBranch](../../apps/server/src/git/Layers/GitCore.ts#L1223)
    +- [x] [pullCurrentBranch](../../apps/server/src/git/Layers/GitCore.ts#L1323)
    +- [x] [checkoutBranch](../../apps/server/src/git/Layers/GitCore.ts#L1727)
     - [x] Service methods and callback wrappers in this file
     
     ### `apps/server/src/git/Layers/GitManager.ts` (`28`)
     
    -- [x] [configurePullRequestHeadUpstream](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L387)
    -- [x] [materializePullRequestHeadBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L428)
    -- [x] [findOpenPr](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L576)
    -- [x] [findLatestPr](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L602)
    -- [x] [runCommitStep](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L728)
    -- [x] [runPrStep](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L842)
    -- [x] [runFeatureBranchStep](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L1106)
    +- [x] [configurePullRequestHeadUpstream](../../apps/server/src/git/Layers/GitManager.ts#L387)
    +- [x] [materializePullRequestHeadBranch](../../apps/server/src/git/Layers/GitManager.ts#L428)
    +- [x] [findOpenPr](../../apps/server/src/git/Layers/GitManager.ts#L576)
    +- [x] [findLatestPr](../../apps/server/src/git/Layers/GitManager.ts#L602)
    +- [x] [runCommitStep](../../apps/server/src/git/Layers/GitManager.ts#L728)
    +- [x] [runPrStep](../../apps/server/src/git/Layers/GitManager.ts#L842)
    +- [x] [runFeatureBranchStep](../../apps/server/src/git/Layers/GitManager.ts#L1106)
     - [x] Remaining helpers and nested callback wrappers in this file
     
     ### `apps/server/src/orchestration/Layers/ProjectionPipeline.ts` (`25`)
     
    -- [x] [runProjectorForEvent](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/ProjectionPipeline.ts#L1161)
    -- [x] [applyProjectsProjection](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/ProjectionPipeline.ts#L357)
    -- [x] [applyThreadsProjection](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/ProjectionPipeline.ts#L415)
    +- [x] [runProjectorForEvent](../../apps/server/src/orchestration/Layers/ProjectionPipeline.ts#L1161)
    +- [x] [applyProjectsProjection](../../apps/server/src/orchestration/Layers/ProjectionPipeline.ts#L357)
    +- [x] [applyThreadsProjection](../../apps/server/src/orchestration/Layers/ProjectionPipeline.ts#L415)
     - [x] `Effect.forEach(..., threadId => Effect.gen(...))` callbacks around `L250`
     - [x] `Effect.forEach(..., entry => Effect.gen(...))` callbacks around `L264`
     - [x] `Effect.forEach(..., entry => Effect.gen(...))` callbacks around `L305`
    @@ -100,98 +100,98 @@ Effect.fn("name")(
     
     ### `apps/server/src/provider/Layers/ProviderService.ts` (`24`)
     
    -- [ ] [makeProviderService](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L134)
    -- [ ] [recoverSessionForThread](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L196)
    -- [ ] [resolveRoutableSession](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L255)
    -- [ ] [startSession](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L284)
    -- [ ] [sendTurn](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L347)
    -- [ ] [interruptTurn](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L393)
    -- [ ] [respondToRequest](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L411)
    -- [ ] [respondToUserInput](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L430)
    -- [ ] [stopSession](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L445)
    -- [ ] [listSessions](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L466)
    -- [ ] [rollbackConversation](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L516)
    -- [ ] [runStopAll](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L538)
    +- [ ] [makeProviderService](../../apps/server/src/provider/Layers/ProviderService.ts#L134)
    +- [ ] [recoverSessionForThread](../../apps/server/src/provider/Layers/ProviderService.ts#L196)
    +- [ ] [resolveRoutableSession](../../apps/server/src/provider/Layers/ProviderService.ts#L255)
    +- [ ] [startSession](../../apps/server/src/provider/Layers/ProviderService.ts#L284)
    +- [ ] [sendTurn](../../apps/server/src/provider/Layers/ProviderService.ts#L347)
    +- [ ] [interruptTurn](../../apps/server/src/provider/Layers/ProviderService.ts#L393)
    +- [ ] [respondToRequest](../../apps/server/src/provider/Layers/ProviderService.ts#L411)
    +- [ ] [respondToUserInput](../../apps/server/src/provider/Layers/ProviderService.ts#L430)
    +- [ ] [stopSession](../../apps/server/src/provider/Layers/ProviderService.ts#L445)
    +- [ ] [listSessions](../../apps/server/src/provider/Layers/ProviderService.ts#L466)
    +- [ ] [rollbackConversation](../../apps/server/src/provider/Layers/ProviderService.ts#L516)
    +- [ ] [runStopAll](../../apps/server/src/provider/Layers/ProviderService.ts#L538)
     
     ### `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` (`14`)
     
    -- [x] [finalizeAssistantMessage](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts#L680)
    -- [x] [upsertProposedPlan](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts#L722)
    -- [x] [finalizeBufferedProposedPlan](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts#L761)
    -- [x] [clearTurnStateForSession](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts#L800)
    -- [x] [processRuntimeEvent](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts#L908)
    +- [x] [finalizeAssistantMessage](../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts#L680)
    +- [x] [upsertProposedPlan](../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts#L722)
    +- [x] [finalizeBufferedProposedPlan](../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts#L761)
    +- [x] [clearTurnStateForSession](../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts#L800)
    +- [x] [processRuntimeEvent](../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts#L908)
     - [x] Nested callback wrappers in this file
     
     ### `apps/server/src/provider/Layers/CodexAdapter.ts` (`12`)
     
    -- [x] [makeCodexAdapter](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1317)
    -- [x] [sendTurn](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1399)
    -- [x] [writeNativeEvent](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1546)
    -- [x] [listener](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1555)
    +- [x] [makeCodexAdapter](../../apps/server/src/provider/Layers/CodexAdapter.ts#L1317)
    +- [x] [sendTurn](../../apps/server/src/provider/Layers/CodexAdapter.ts#L1399)
    +- [x] [writeNativeEvent](../../apps/server/src/provider/Layers/CodexAdapter.ts#L1546)
    +- [x] [listener](../../apps/server/src/provider/Layers/CodexAdapter.ts#L1555)
     - [x] Remaining nested callback wrappers in this file
     
     ### `apps/server/src/checkpointing/CheckpointStore.ts` (`10`)
     
    -- [ ] [captureCheckpoint](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/CheckpointStore.ts#L123)
    -- [ ] [restoreCheckpoint](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/CheckpointStore.ts#L137)
    -- [ ] [diffCheckpoints](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/CheckpointStore.ts#L144)
    -- [ ] [deleteCheckpointRefs](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/CheckpointStore.ts#L151)
    +- [ ] [captureCheckpoint](../../apps/server/src/checkpointing/CheckpointStore.ts#L123)
    +- [ ] [restoreCheckpoint](../../apps/server/src/checkpointing/CheckpointStore.ts#L137)
    +- [ ] [diffCheckpoints](../../apps/server/src/checkpointing/CheckpointStore.ts#L144)
    +- [ ] [deleteCheckpointRefs](../../apps/server/src/checkpointing/CheckpointStore.ts#L151)
     - [ ] Nested callback wrappers in this file
     
     ### `apps/server/src/provider/Layers/EventNdjsonLogger.ts` (`9`)
     
    -- [ ] [toLogMessage](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/EventNdjsonLogger.ts#L77)
    -- [ ] [makeThreadWriter](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/EventNdjsonLogger.ts#L102)
    -- [ ] [makeEventNdjsonLogger](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/EventNdjsonLogger.ts#L174)
    -- [ ] [write](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/EventNdjsonLogger.ts#L231)
    -- [ ] [close](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/EventNdjsonLogger.ts#L247)
    +- [ ] [toLogMessage](../../apps/server/src/provider/Layers/EventNdjsonLogger.ts#L77)
    +- [ ] [makeThreadWriter](../../apps/server/src/provider/Layers/EventNdjsonLogger.ts#L102)
    +- [ ] [makeEventNdjsonLogger](../../apps/server/src/provider/Layers/EventNdjsonLogger.ts#L174)
    +- [ ] [write](../../apps/server/src/provider/Layers/EventNdjsonLogger.ts#L231)
    +- [ ] [close](../../apps/server/src/provider/Layers/EventNdjsonLogger.ts#L247)
     - [ ] Flush and writer-resolution callback wrappers in this file
     
     ### `apps/server/scripts/cli.ts` (`8`)
     
    -- [ ] Command handlers around [cli.ts](/Users/julius/Development/Work/codething-mvp/apps/server/scripts/cli.ts#L125)
    -- [ ] Command handlers around [cli.ts](/Users/julius/Development/Work/codething-mvp/apps/server/scripts/cli.ts#L170)
    -- [ ] Resource callbacks around [cli.ts](/Users/julius/Development/Work/codething-mvp/apps/server/scripts/cli.ts#L221)
    -- [ ] Resource callbacks around [cli.ts](/Users/julius/Development/Work/codething-mvp/apps/server/scripts/cli.ts#L239)
    +- [ ] Command handlers around [cli.ts](../../apps/server/scripts/cli.ts#L125)
    +- [ ] Command handlers around [cli.ts](../../apps/server/scripts/cli.ts#L170)
    +- [ ] Resource callbacks around [cli.ts](../../apps/server/scripts/cli.ts#L221)
    +- [ ] Resource callbacks around [cli.ts](../../apps/server/scripts/cli.ts#L239)
     
     ### `apps/server/src/orchestration/Layers/OrchestrationEngine.ts` (`7`)
     
    -- [ ] [processEnvelope](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/OrchestrationEngine.ts#L64)
    -- [ ] [dispatch](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/OrchestrationEngine.ts#L218)
    -- [ ] Catch/stream callback wrappers around [OrchestrationEngine.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/OrchestrationEngine.ts#L162)
    -- [ ] Catch/stream callback wrappers around [OrchestrationEngine.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/OrchestrationEngine.ts#L200)
    +- [ ] [processEnvelope](../../apps/server/src/orchestration/Layers/OrchestrationEngine.ts#L64)
    +- [ ] [dispatch](../../apps/server/src/orchestration/Layers/OrchestrationEngine.ts#L218)
    +- [ ] Catch/stream callback wrappers around [OrchestrationEngine.ts](../../apps/server/src/orchestration/Layers/OrchestrationEngine.ts#L162)
    +- [ ] Catch/stream callback wrappers around [OrchestrationEngine.ts](../../apps/server/src/orchestration/Layers/OrchestrationEngine.ts#L200)
     
     ### `apps/server/src/orchestration/projector.ts` (`5`)
     
    -- [ ] `switch` branch wrapper at [projector.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/projector.ts#L242)
    -- [ ] `switch` branch wrapper at [projector.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/projector.ts#L336)
    -- [ ] `switch` branch wrapper at [projector.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/projector.ts#L397)
    -- [ ] `switch` branch wrapper at [projector.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/projector.ts#L446)
    -- [ ] `switch` branch wrapper at [projector.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/projector.ts#L478)
    +- [ ] `switch` branch wrapper at [projector.ts](../../apps/server/src/orchestration/projector.ts#L242)
    +- [ ] `switch` branch wrapper at [projector.ts](../../apps/server/src/orchestration/projector.ts#L336)
    +- [ ] `switch` branch wrapper at [projector.ts](../../apps/server/src/orchestration/projector.ts#L397)
    +- [ ] `switch` branch wrapper at [projector.ts](../../apps/server/src/orchestration/projector.ts#L446)
    +- [ ] `switch` branch wrapper at [projector.ts](../../apps/server/src/orchestration/projector.ts#L478)
     
     ### Smaller clusters
     
    -- [ ] [packages/shared/src/DrainableWorker.ts](/Users/julius/Development/Work/codething-mvp/packages/shared/src/DrainableWorker.ts) (`4`)
    -- [ ] [apps/server/src/wsServer/pushBus.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/wsServer/pushBus.ts) (`4`)
    -- [ ] [apps/server/src/wsServer.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/wsServer.ts) (`4`)
    -- [ ] [apps/server/src/provider/Layers/ProviderRegistry.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderRegistry.ts) (`4`)
    -- [ ] [apps/server/src/persistence/Layers/Sqlite.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/persistence/Layers/Sqlite.ts) (`4`)
    -- [ ] [apps/server/src/orchestration/Layers/ProviderCommandReactor.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts) (`4`)
    -- [ ] [apps/server/src/main.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/main.ts) (`4`)
    -- [ ] [apps/server/src/keybindings.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/keybindings.ts) (`4`)
    -- [ ] [apps/server/src/git/Layers/CodexTextGeneration.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/CodexTextGeneration.ts) (`4`)
    -- [ ] [apps/server/src/serverLayers.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/serverLayers.ts) (`3`)
    -- [ ] [apps/server/src/telemetry/Layers/AnalyticsService.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/telemetry/Layers/AnalyticsService.ts) (`2`)
    -- [ ] [apps/server/src/telemetry/Identify.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/telemetry/Identify.ts) (`2`)
    -- [ ] [apps/server/src/provider/Layers/ProviderAdapterRegistry.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts) (`2`)
    -- [ ] [apps/server/src/provider/Layers/CodexProvider.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexProvider.ts) (`2`)
    -- [ ] [apps/server/src/provider/Layers/ClaudeProvider.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ClaudeProvider.ts) (`2`)
    -- [ ] [apps/server/src/persistence/NodeSqliteClient.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/persistence/NodeSqliteClient.ts) (`2`)
    -- [ ] [apps/server/src/persistence/Migrations.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/persistence/Migrations.ts) (`2`)
    -- [ ] [apps/server/src/open.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/open.ts) (`2`)
    -- [ ] [apps/server/src/git/Layers/ClaudeTextGeneration.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/ClaudeTextGeneration.ts) (`2`)
    -- [ ] [apps/server/src/checkpointing/CheckpointDiffQuery.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/CheckpointDiffQuery.ts) (`2`)
    -- [ ] [apps/server/src/provider/makeManagedServerProvider.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/makeManagedServerProvider.ts) (`1`)
    +- [ ] [packages/shared/src/DrainableWorker.ts](../../packages/shared/src/DrainableWorker.ts) (`4`)
    +- [ ] [apps/server/src/wsServer/pushBus.ts](../../apps/server/src/wsServer/pushBus.ts) (`4`)
    +- [ ] [apps/server/src/wsServer.ts](../../apps/server/src/wsServer.ts) (`4`)
    +- [ ] [apps/server/src/provider/Layers/ProviderRegistry.ts](../../apps/server/src/provider/Layers/ProviderRegistry.ts) (`4`)
    +- [ ] [apps/server/src/persistence/Layers/Sqlite.ts](../../apps/server/src/persistence/Layers/Sqlite.ts) (`4`)
    +- [ ] [apps/server/src/orchestration/Layers/ProviderCommandReactor.ts](../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts) (`4`)
    +- [ ] [apps/server/src/main.ts](../../apps/server/src/main.ts) (`4`)
    +- [ ] [apps/server/src/keybindings.ts](../../apps/server/src/keybindings.ts) (`4`)
    +- [ ] [apps/server/src/git/Layers/CodexTextGeneration.ts](../../apps/server/src/git/Layers/CodexTextGeneration.ts) (`4`)
    +- [ ] [apps/server/src/serverLayers.ts](../../apps/server/src/serverLayers.ts) (`3`)
    +- [ ] [apps/server/src/telemetry/Layers/AnalyticsService.ts](../../apps/server/src/telemetry/Layers/AnalyticsService.ts) (`2`)
    +- [ ] [apps/server/src/telemetry/Identify.ts](../../apps/server/src/telemetry/Identify.ts) (`2`)
    +- [ ] [apps/server/src/provider/Layers/ProviderAdapterRegistry.ts](../../apps/server/src/provider/Layers/ProviderAdapterRegistry.ts) (`2`)
    +- [ ] [apps/server/src/provider/Layers/CodexProvider.ts](../../apps/server/src/provider/Layers/CodexProvider.ts) (`2`)
    +- [ ] [apps/server/src/provider/Layers/ClaudeProvider.ts](../../apps/server/src/provider/Layers/ClaudeProvider.ts) (`2`)
    +- [ ] [apps/server/src/persistence/NodeSqliteClient.ts](../../apps/server/src/persistence/NodeSqliteClient.ts) (`2`)
    +- [ ] [apps/server/src/persistence/Migrations.ts](../../apps/server/src/persistence/Migrations.ts) (`2`)
    +- [ ] [apps/server/src/open.ts](../../apps/server/src/open.ts) (`2`)
    +- [ ] [apps/server/src/git/Layers/ClaudeTextGeneration.ts](../../apps/server/src/git/Layers/ClaudeTextGeneration.ts) (`2`)
    +- [ ] [apps/server/src/checkpointing/CheckpointDiffQuery.ts](../../apps/server/src/checkpointing/CheckpointDiffQuery.ts) (`2`)
    +- [ ] [apps/server/src/provider/makeManagedServerProvider.ts](../../apps/server/src/provider/makeManagedServerProvider.ts) (`1`)
     
     ```
     
    diff --git a/docs/operations/mobile-app-store-screenshots.md b/docs/operations/mobile-app-store-screenshots.md
    new file mode 100644
    index 00000000000..2c36ab9d008
    --- /dev/null
    +++ b/docs/operations/mobile-app-store-screenshots.md
    @@ -0,0 +1,159 @@
    +# Mobile app-store screenshot harness
    +
    +The screenshot harness runs the real mobile application against three disposable local T3
    +environments. It creates an isolated base directory and server for each environment, real Git
    +projects with deterministic content, seeded orchestration projections, and persisted terminal
    +history. The app pairs with every server through its normal connection flow and React Navigation
    +opens the production Home, Thread, ThreadTerminal, ThreadReview, and SettingsEnvironments routes.
    +
    +No screenshot-specific screen recreates application UI. `EXPO_PUBLIC_SHOWCASE=1` only enables the
    +non-rendering pairing/readiness coordinator, disables terminal autofocus so captures do not contain
    +the software keyboard, and supplies deterministic T3 Connect discovery rows to the real
    +Environments screen. The local environment cards always come from real paired servers.
    +
    +## Capture the default matrix
    +
    +From the repository root:
    +
    +    pnpm screenshots:mobile
    +
    +The command:
    +
    +1. Creates three temporary T3 base directories and starts a local server for each on an available
    +   port.
    +2. Creates T3 Code, React, and Linux Git repositories with recognizable favicons, feature branches,
    +   and a deterministic T3 Code review diff.
    +3. Seeds each server's migrated SQLite database with playful threads, messages, activities, and
    +   terminal history, then adds two persisted mobile-outbox tasks waiting to send.
    +4. Starts an isolated Metro server, builds the selected native apps, and boots each device.
    +5. Pairs each clean app installation with Moonbase Terminal, Suspense Station, and Kernel Cabin.
    +6. Navigates to the real application route for every requested scene.
    +7. Sets the requested system appearance and normalizes status bars, converts captures to 24-bit RGB PNGs without alpha, and
    +   validates dimensions, aspect ratio, file size, and screenshot count before succeeding.
    +8. Writes store-ready folders beneath `artifacts/app-store/screenshots/` that can be uploaded
    +   directly to App Store Connect or Google Play Console.
    +
    +The servers, Metro, temporary root directory, and devices started by the runner are cleaned up after
    +capture. Pass `--keep-running` to retain them for inspection; the runner prints the base-directory
    +paths and server ports.
    +
    +Captures wait for the real environment snapshot to hydrate and for the requested route to become
    +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
    +building it. Use --skip-build for repeated captures after the first build.
    +
    +The harness uses its own Metro port (8199 by default), so an ordinary mobile server or another
    +worktree cannot accidentally provide the bundle being photographed.
    +
    +The default matrix is:
    +
    +| Output folder                         | Capture target            | Upload dimensions | Store slot                                |
    +| ------------------------------------- | ------------------------- | ----------------- | ----------------------------------------- |
    +| `apple/iphone-6.9/{light,dark}/`      | iPhone 17 Pro Max         | 1320×2868         | App Store Connect iPhone 6.9-inch         |
    +| `apple/iphone-6.5/{light,dark}/`      | disposable iPhone 14 Plus | 1284×2778         | App Store Connect iPhone 6.5-inch         |
    +| `apple/ipad-13/{light,dark}/`         | iPad Pro 13-inch (M5)     | 2064×2752         | App Store Connect iPad 13-inch            |
    +| `google-play/phone/{light,dark}/`     | Pixel AVD at 420 dpi      | 1080×1920         | Google Play phone, portrait 9:16          |
    +| `google-play/tablet-7/{light,dark}/`  | Pixel AVD at 600dp width  | 1080×1920         | Google Play 7-inch tablet, portrait 9:16  |
    +| `google-play/tablet-10/{light,dark}/` | Pixel AVD at 800dp width  | 1440×2560         | Google Play 10-inch tablet, portrait 9:16 |
    +
    +Each target captures thread, terminal, review, thread list, and environments, producing 30 PNG
    +files for one appearance or 60 for both. Each appearance folder's five screenshots satisfy the configured Apple limit of 1–10, Google
    +phone requirement of 2–8, and Google tablet recommendation/slot minimum of 4 with a maximum of 8.
    +
    +The generated tree is deliberately aligned with the store upload fields:
    +
    +    artifacts/app-store/screenshots/
    +    ├── apple/
    +    │   ├── iphone-6.9/{light,dark}/{thread,terminal,review,threads,environments}.png
    +    │   ├── iphone-6.5/{light,dark}/{thread,terminal,review,threads,environments}.png
    +    │   └── ipad-13/{light,dark}/{thread,terminal,review,threads,environments}.png
    +    └── google-play/
    +        ├── phone/{light,dark}/{thread,terminal,review,threads,environments}.png
    +        ├── tablet-7/{light,dark}/{thread,terminal,review,threads,environments}.png
    +        └── tablet-10/{light,dark}/{thread,terminal,review,threads,environments}.png
    +
    +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.
    +
    +## Capture in GitHub Actions
    +
    +Run the `Mobile Showcase Screenshots` workflow from GitHub's Actions tab, choose `all`, `ios`, or
    +`android`, and select `light`, `dark`, or `both`. The default dispatch captures both appearances and
    +runs iOS and Android concurrently: iPhone and iPad capture on a
    +12-vCPU Blacksmith macOS runner, while Android phone, 7-inch tablet, and 10-inch tablet capture on a
    +16-vCPU Blacksmith Linux runner with a KVM-accelerated x86_64 emulator.
    +
    +Every job uploads its PNGs even when a later capture fails, which makes partial runs useful for
    +diagnosis. Download `app-store-connect-screenshots` and `google-play-screenshots` from the workflow
    +run's Artifacts section. Each job runs validation again immediately before upload. Artifacts are
    +retained for 14 days.
    +
    +The workflow uses the same checked-in device and scene matrix as local capture. Android remains
    +ARM64 by default for local Apple Silicon development; CI sets `T3_SHOWCASE_ANDROID_ABI=x86_64` so the
    +debug APK matches its accelerated emulator.
    +
    +## Fast iteration
    +
    +Capture one scene or device:
    +
    +    pnpm screenshots:mobile --device iphone-6.9 --scene thread
    +    pnpm screenshots:mobile --platform android --scene review
    +
    +Override the configured appearance or capture both variants:
    +
    +    pnpm screenshots:mobile --appearance light
    +    pnpm screenshots:mobile --appearance dark
    +    pnpm screenshots:mobile --appearance both
    +
    +Reuse the native build and retain the disposable environment:
    +
    +    pnpm screenshots:mobile --device ipad-13 --skip-build --keep-running
    +
    +Run Metro separately:
    +
    +    pnpm --filter @t3tools/mobile showcase
    +    pnpm screenshots:mobile --skip-build --skip-metro --device iphone-6.9
    +
    +List the matrix and flags:
    +
    +    pnpm screenshots:mobile --list
    +
    +Validate existing files without starting Metro, servers, simulators, or emulators:
    +
    +    pnpm screenshots:mobile --validate-only
    +    pnpm screenshots:mobile --platform ios --validate-only
    +
    +## Customize the seeded environment
    +
    +- Project repository, thread projections, conversation, terminal transcript, and Git changes:
    +  [mobile-showcase-environment.ts](../../scripts/mobile-showcase-environment.ts)
    +- Device and capture matrix:
    +  [mobile-showcase.config.ts](../../scripts/mobile-showcase.config.ts)
    +- Simulator/emulator orchestration:
    +  [mobile-showcase.ts](../../scripts/mobile-showcase.ts)
    +
    +Fixture timestamps are generated relative to capture startup so every route shows stable relative
    +labels while the server still receives valid current data. The same deterministic three-environment
    +ensemble serves iPhone, iPad, Android phone, and Android tablet captures; responsive differences
    +come entirely from the production app layout.
    +
    +The Pending rows use the production offline outbox and point at the real T3 Code and React fixture
    +projects. Showcase coordination holds those two entries in the outbox for capture, just like a task
    +currently open for editing, so reconnecting the seeded environments cannot deliver and remove them
    +before the screenshot is taken.
    +
    +The Environments capture presents the three local fixture transports as a Tailscale HTTPS hostname,
    +a Helsinki VPS hostname, and a Tailnet IPv4 address. This display-only substitution keeps the cards
    +remote-first while the harness retains reliable loopback connections to its ephemeral servers.
    +
    +## Local prerequisites
    +
    +- iOS: Xcode command-line tools, the configured simulator runtimes, and installed CocoaPods.
    +- Android: ANDROID_HOME (or the default macOS SDK path), adb, emulator, and the configured AVD.
    +
    +The harness is the source of truth for upload dimensions; do not resize its output. If store rules
    +change, update the target's `storeAsset` specification. Capture fails when a PNG is the wrong size,
    +has alpha, is not 8-bit RGB, exceeds the configured file-size limit, violates Google Play's 9:16
    +shape/bounds, or leaves a full output set below its store minimum.
    diff --git a/docs/reference/scripts.md b/docs/reference/scripts.md
    index d4d2b96869e..6bdea266665 100644
    --- a/docs/reference/scripts.md
    +++ b/docs/reference/scripts.md
    @@ -1,29 +1,29 @@
     # Scripts
     
    -- `bun run dev` — Starts contracts, server, and web in `turbo watch` mode.
    -- `bun run dev:server` — Starts just the WebSocket server (uses Bun TypeScript execution).
    -- `bun run dev:web` — Starts just the Vite dev server for the web app.
    -- Dev commands default `T3CODE_STATE_DIR` to `~/.t3/dev` to keep dev state isolated from desktop/prod state.
    +- `vp run dev` — Starts contracts, server, and web in watch mode.
    +- `vp run dev:server` — Starts just the WebSocket server. The server process runs on Bun (`@effect/platform-bun` + `BunPtyAdapter`), but task running uses `vp run`.
    +- `vp run dev:web` — Starts just the Vite dev server for the web app.
    +- Dev commands default `T3CODE_HOME` to `~/.t3` — the same shared home the desktop/production app uses. Override with `--home-dir` (see below) to keep dev state separate.
     - Override server CLI-equivalent flags from root dev commands with `--`, for example:
    -  `bun run dev -- --base-dir ~/.t3-2`
    -- `bun run start` — Runs the production server (serves built web app as static files).
    -- `bun run build` — Builds contracts, web app, and server through Turbo.
    -- `bun run typecheck` — Strict TypeScript checks for all packages.
    -- `bun run test` — Runs workspace tests.
    -- `bun run dist:desktop:artifact -- --platform  --target  --arch ` — Builds a desktop artifact for a specific platform/target/arch.
    -- `bun run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`.
    -- `bun run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`.
    -- `bun run dist:desktop:linux` — Builds a Linux AppImage into `./release`.
    -- `bun run dist:desktop:win` — Builds a Windows NSIS installer into `./release`.
    +  `vp run dev -- --home-dir ~/.t3-2`
    +- `vp run start` — Runs the production server (serves built web app as static files).
    +- `vp run build` — Builds contracts, web app, and server.
    +- `vp run typecheck` — Strict TypeScript checks for all packages.
    +- `vp run test` — Runs workspace tests.
    +- `vp run dist:desktop:artifact -- --platform  --target  --arch ` — Builds a desktop artifact for a specific platform/target/arch.
    +- `vp run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`.
    +- `vp run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`.
    +- `vp run dist:desktop:linux` — Builds a Linux AppImage into `./release`.
    +- `vp run dist:desktop:win` — Builds a Windows NSIS installer into `./release`.
     
     ## Desktop `.dmg` packaging notes
     
     - Default build is unsigned/not notarized for local sharing.
    -- The DMG build uses `assets/macos-icon-1024.png` as the production app icon source.
    +- The DMG build uses `assets/prod/black-macos-1024.png` as the production app icon source.
     - Desktop production windows load the bundled UI from `t3code://app/index.html` (not a `127.0.0.1` document URL).
     - Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an auth token for WebSocket/API traffic.
     - Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first launch.
    -- To keep staging files for debugging package contents, run: `bun run dist:desktop:dmg -- --keep-stage`
    +- To keep staging files for debugging package contents, run: `vp run dist:desktop:dmg -- --keep-stage`
     - To allow code-signing/notarization when configured in CI/secrets, add: `--signed`.
     - Signed macOS builds also require `T3CODE_APPLE_TEAM_ID` and
       `T3CODE_MACOS_PROVISIONING_PROFILE`. The passkey RP domain is derived from
    @@ -38,8 +38,8 @@
     
     Set `T3CODE_DEV_INSTANCE` to any value to deterministically shift all dev ports together.
     
    -- Default ports: server `3773`, web `5733`
    +- Default ports: server `13773`, web `5733`
     - Shifted ports: `base + offset` (offset is hashed from `T3CODE_DEV_INSTANCE`)
    -- Example: `T3CODE_DEV_INSTANCE=branch-a bun run dev:desktop`
    +- Example: `T3CODE_DEV_INSTANCE=branch-a vp run dev:desktop`
     
     If you want full control instead of hashing, set `T3CODE_PORT_OFFSET` to a numeric offset.
    diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts
    index 1fcc9d0cb99..4215f834654 100644
    --- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts
    +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts
    @@ -64,6 +64,7 @@ function makeAgentActivityRows(
         remove: () => Effect.void,
         pruneTerminal: () => Effect.void,
         listForUser: () => Effect.succeed([state]),
    +    getForUserThread: () => Effect.succeed(state),
         ...overrides,
       };
     }
    diff --git a/infra/relay/src/agentActivity/AgentActivityRows.test.ts b/infra/relay/src/agentActivity/AgentActivityRows.test.ts
    index be976d16bbb..65b0c2a0a90 100644
    --- a/infra/relay/src/agentActivity/AgentActivityRows.test.ts
    +++ b/infra/relay/src/agentActivity/AgentActivityRows.test.ts
    @@ -75,6 +75,15 @@ describe("AgentActivityRows", () => {
           const listError = yield* rows.listForUser({ userId: "user-2" }).pipe(Effect.flip);
           expect(listError).toMatchObject({ userId: "user-2", cause });
           expect(listError.message).toBe("Failed to list agent activity state for user user-2.");
    +
    +      const getError = yield* rows
    +        .getForUserThread({
    +          userId: "user-2",
    +          environmentId: "env-1",
    +          threadId: "thread-1",
    +        })
    +        .pipe(Effect.flip);
    +      expect(getError).toMatchObject({ userId: "user-2", cause });
         }).pipe(
           Effect.provide(
             AgentActivityRows.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, failingDb))),
    diff --git a/infra/relay/src/agentActivity/AgentActivityRows.ts b/infra/relay/src/agentActivity/AgentActivityRows.ts
    index 63868ad2d72..14245075e10 100644
    --- a/infra/relay/src/agentActivity/AgentActivityRows.ts
    +++ b/infra/relay/src/agentActivity/AgentActivityRows.ts
    @@ -83,6 +83,11 @@ export class AgentActivityRows extends Context.Service<
           ReadonlyArray,
           AgentActivityRowListPersistenceError
         >;
    +    readonly getForUserThread: (input: {
    +      readonly userId: string;
    +      readonly environmentId: string;
    +      readonly threadId: string;
    +    }) => Effect.Effect;
       }
     >()("t3code-relay/agentActivity/AgentActivityRows") {}
     
    @@ -241,6 +246,54 @@ export const make = Effect.gen(function* () {
               ),
             );
         }),
    +
    +    getForUserThread: Effect.fn("relay.agent_activity_rows.get_for_user_thread")(function* (input) {
    +      return yield* db
    +        .select({ stateJson: relayAgentActivityRows.stateJson })
    +        .from(relayAgentActivityRows)
    +        .innerJoin(
    +          relayEnvironmentLinks,
    +          and(
    +            eq(relayEnvironmentLinks.environmentId, relayAgentActivityRows.environmentId),
    +            eq(
    +              relayEnvironmentLinks.environmentPublicKey,
    +              relayAgentActivityRows.environmentPublicKey,
    +            ),
    +          ),
    +        )
    +        .where(
    +          and(
    +            eq(relayEnvironmentLinks.userId, input.userId),
    +            isNull(relayEnvironmentLinks.revokedAt),
    +            eq(relayAgentActivityRows.environmentId, input.environmentId),
    +            eq(relayAgentActivityRows.threadId, input.threadId),
    +          ),
    +        )
    +        .orderBy(desc(relayAgentActivityRows.updatedAt))
    +        .pipe(
    +          Effect.flatMap((rows) =>
    +            Effect.forEach(rows, (row) => encodeJsonValue(row.stateJson), {
    +              concurrency: "unbounded",
    +            }),
    +          ),
    +          Effect.map((rows) => {
    +            for (const row of rows) {
    +              const decoded = decodeRelayAgentActivityStateJson(row);
    +              if (Option.isSome(decoded)) {
    +                return decoded.value;
    +              }
    +            }
    +            return null;
    +          }),
    +          Effect.mapError(
    +            (cause) =>
    +              new AgentActivityRowListPersistenceError({
    +                userId: input.userId,
    +                cause,
    +              }),
    +          ),
    +        );
    +    }),
       });
     });
     
    diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts
    index cdafc668269..1c89b56da5c 100644
    --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts
    +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts
    @@ -5,7 +5,6 @@ import type {
     import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";
     import { describe, expect, it } from "@effect/vitest";
     import * as NodeCrypto from "node:crypto";
    -import * as DateTime from "effect/DateTime";
     import * as Effect from "effect/Effect";
     import * as Layer from "effect/Layer";
     import * as Logger from "effect/Logger";
    @@ -161,10 +160,13 @@ function makeLayer(input: {
       >;
       readonly currentTargets?: ReadonlyArray;
       readonly config?: RelayConfiguration.RelayConfiguration["Service"];
    -  // Live agent-activity rows returned by the delivery-time start recheck.
    -  // Defaults to one freshly-updated running thread so queued starts stay
    -  // deliverable in tests that don't care about the recheck.
    +  // Live agent-activity rows returned by delivery-time state rechecks.
    +  // Defaults to the fixture row so queued updates match unless a test is
    +  // explicitly exercising stale-state behavior.
       readonly activityStates?: ReadonlyArray;
    +  // Current per-thread rows used to reject queued updates/notifications that
    +  // have been superseded before APNs delivery.
    +  readonly currentActivityStates?: ReadonlyArray;
       readonly execute?: (
         request: HttpClientRequest.HttpClientRequest,
       ) => Effect.Effect;
    @@ -182,9 +184,14 @@ function makeLayer(input: {
               listForUser: () =>
                 input.activityStates !== undefined
                   ? Effect.succeed([...input.activityStates])
    -              : DateTime.now.pipe(
    -                  Effect.map((now) => [{ ...state, updatedAt: DateTime.formatIso(now) }]),
    -                ),
    +              : Effect.succeed([state]),
    +          getForUserThread: ({ environmentId, threadId }) =>
    +            Effect.succeed(
    +              (input.currentActivityStates ?? input.activityStates ?? [state]).find(
    +                (current) =>
    +                  current.environmentId === environmentId && current.threadId === threadId,
    +              ) ?? null,
    +            ),
             } satisfies AgentActivityRows.AgentActivityRows["Service"]),
             Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, {
               send: (body) =>
    @@ -674,6 +681,8 @@ describe("ApnsDeliveries", () => {
                     environmentId: "env",
                     threadId: "thread",
                     deepLink: "/",
    +                phase: "waiting_for_input",
    +                updatedAt: state.updatedAt,
                   },
                 },
               },
    @@ -778,6 +787,30 @@ describe("ApnsDeliveries", () => {
         },
       );
     
    +  it.effect("does not queue a push notification when a thread starts working", () => {
    +    const attempts: Array = [];
    +    const queuedJobs: Array = [];
    +
    +    return Effect.gen(function* () {
    +      const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
    +      const result = yield* deliveries.sendForTarget({
    +        target: {
    +          ...target,
    +          push_token: "apns-device-token",
    +          push_to_start_token: null,
    +          activity_push_token: null,
    +          remote_started_at: null,
    +        },
    +        aggregate,
    +        nowMs: 5_000,
    +      });
    +
    +      expect(result).toBeNull();
    +      expect(queuedJobs).toEqual([]);
    +      expect(attempts).toEqual([]);
    +    }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs })));
    +  });
    +
       it.effect("queues bounded alert notification payloads", () => {
         const attempts: Array = [];
         const queuedJobs: Array = [];
    @@ -1176,6 +1209,150 @@ describe("ApnsDeliveries", () => {
         );
       });
     
    +  it.effect("skips a queued Done Live Activity update after the thread resumes working", () => {
    +    const attempts: Array = [];
    +    let executeCount = 0;
    +    const completedAt = "1970-01-01T00:00:01.000Z";
    +    const completedAggregate: RelayAgentActivityAggregateState = {
    +      ...aggregate,
    +      activeCount: 0,
    +      updatedAt: completedAt,
    +      activities: [
    +        {
    +          ...aggregate.activities[0]!,
    +          phase: "completed",
    +          status: "Done",
    +          updatedAt: completedAt,
    +        },
    +      ],
    +    };
    +    const payload = makeApnsDeliveryJobPayload({
    +      kind: "live_activity_update",
    +      userId: target.user_id,
    +      deviceId: target.device_id,
    +      token: target.activity_push_token ?? "activity-token",
    +      aggregate: completedAggregate,
    +      alert: { title: "Thread", body: "Done: Project" },
    +      createdAt: completedAt,
    +      expiresAt: "1970-01-01T00:10:00.000Z",
    +      jobId: "job-update-superseded-by-running",
    +    });
    +    const signed = signApnsDeliveryJob({
    +      secret: config.apnsDeliveryJobSigningSecret,
    +      payload,
    +    });
    +    const execute = (request: HttpClientRequest.HttpClientRequest) =>
    +      Effect.sync(() => {
    +        executeCount += 1;
    +        return HttpClientResponse.fromWeb(request, new Response("", { status: 200 }));
    +      });
    +
    +    return Effect.gen(function* () {
    +      const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
    +      const result = yield* deliveries.processSignedJob(signed);
    +
    +      expect(result).toMatchObject({
    +        kind: "live_activity_update",
    +        ok: true,
    +        apnsStatus: null,
    +        apnsReason: "Stale APNs delivery job skipped.",
    +      });
    +      expect(executeCount).toBe(0);
    +      expect(attempts).toMatchObject([
    +        {
    +          sourceJobId: "job-update-superseded-by-running",
    +          apnsReason: "Stale agent activity state skipped.",
    +        },
    +      ]);
    +    }).pipe(
    +      Effect.provide(
    +        makeLayer({
    +          attempts,
    +          activityStates: [
    +            {
    +              ...state,
    +              phase: "running",
    +              headline: "Running",
    +              updatedAt: "1970-01-01T00:00:02.000Z",
    +            },
    +          ],
    +          config: signingConfig,
    +          execute,
    +        }),
    +      ),
    +    );
    +  });
    +
    +  it.effect("skips a queued Done notification after the thread resumes working", () => {
    +    const attempts: Array = [];
    +    let executeCount = 0;
    +    const completedAt = "1970-01-01T00:00:01.000Z";
    +    const payload = makeApnsDeliveryJobPayload({
    +      kind: "push_notification",
    +      userId: target.user_id,
    +      deviceId: target.device_id,
    +      token: "apns-device-token",
    +      aggregate: null,
    +      notification: {
    +        title: "Thread",
    +        body: "Done: Project",
    +        environmentId: "env",
    +        threadId: "thread",
    +        deepLink: "/",
    +        phase: "completed",
    +        updatedAt: completedAt,
    +      },
    +      createdAt: completedAt,
    +      expiresAt: "1970-01-01T00:10:00.000Z",
    +      jobId: "job-push-superseded-by-running",
    +    });
    +    const signed = signApnsDeliveryJob({
    +      secret: config.apnsDeliveryJobSigningSecret,
    +      payload,
    +    });
    +    const execute = (request: HttpClientRequest.HttpClientRequest) =>
    +      Effect.sync(() => {
    +        executeCount += 1;
    +        return HttpClientResponse.fromWeb(request, new Response("", { status: 200 }));
    +      });
    +
    +    return Effect.gen(function* () {
    +      const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
    +      const result = yield* deliveries.processSignedJob(signed);
    +
    +      expect(result).toMatchObject({
    +        kind: "push_notification",
    +        ok: true,
    +        apnsStatus: null,
    +        apnsReason: "Stale APNs delivery job skipped.",
    +      });
    +      expect(executeCount).toBe(0);
    +      expect(attempts).toMatchObject([
    +        {
    +          sourceJobId: "job-push-superseded-by-running",
    +          apnsReason: "Stale agent activity state skipped.",
    +        },
    +      ]);
    +    }).pipe(
    +      Effect.provide(
    +        makeLayer({
    +          attempts,
    +          currentTargets: [{ ...target, push_token: "apns-device-token" }],
    +          currentActivityStates: [
    +            {
    +              ...state,
    +              phase: "running",
    +              headline: "Running",
    +              updatedAt: "1970-01-01T00:00:02.000Z",
    +            },
    +          ],
    +          config: signingConfig,
    +          execute,
    +        }),
    +      ),
    +    );
    +  });
    +
       it.effect("retries signed queue jobs that are already claimed but not completed", () => {
         const attempts: Array = [];
         let executeCount = 0;
    @@ -1327,7 +1504,15 @@ describe("ApnsDeliveries", () => {
               deviceId: target.device_id,
             },
           ]);
    -    }).pipe(Effect.provide(makeLayer({ attempts, clearedStarts })));
    +    }).pipe(
    +      Effect.provide(
    +        makeLayer({
    +          attempts,
    +          clearedStarts,
    +          activityStates: [{ ...state, updatedAt: "9999-01-01T00:00:00.000Z" }],
    +        }),
    +      ),
    +    );
       });
     
       it.effect("invalidates dead push-to-start tokens after permanent APNs start failures", () => {
    @@ -1373,7 +1558,15 @@ describe("ApnsDeliveries", () => {
             },
           ]);
         }).pipe(
    -      Effect.provide(makeLayer({ attempts, invalidatedTokens, config: signingConfig, execute })),
    +      Effect.provide(
    +        makeLayer({
    +          attempts,
    +          invalidatedTokens,
    +          activityStates: [{ ...state, updatedAt: "9999-01-01T00:00:00.000Z" }],
    +          config: signingConfig,
    +          execute,
    +        }),
    +      ),
         );
       });
     
    diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts
    index 9b86ed46417..9db9be3f168 100644
    --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts
    +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts
    @@ -369,6 +369,8 @@ function notificationForAggregate(input: {
         environmentId: activity.environmentId,
         threadId: activity.threadId,
         deepLink: activity.deepLink,
    +    phase: activity.phase,
    +    updatedAt: activity.updatedAt,
       };
     }
     
    @@ -722,6 +724,87 @@ export const make = Effect.gen(function* () {
         );
       });
     
    +  const stateIdentityIsCurrent = Effect.fnUntraced(function* (input: {
    +    readonly userId: string;
    +    readonly environmentId: string;
    +    readonly threadId: string;
    +    readonly phase: RelayAgentActivityAggregateState["activities"][number]["phase"];
    +    readonly updatedAt: string;
    +  }) {
    +    return yield* activityRows
    +      .getForUserThread({
    +        userId: input.userId,
    +        environmentId: input.environmentId,
    +        threadId: input.threadId,
    +      })
    +      .pipe(
    +        Effect.map(
    +          (current) =>
    +            current !== null &&
    +            current.phase === input.phase &&
    +            current.updatedAt === input.updatedAt,
    +        ),
    +        // A transient persistence failure must not permanently discard a
    +        // legitimate alert. Fail open and let the signed job's retry/dedupe
    +        // protections handle transport failures as usual.
    +        Effect.catchCause((cause) =>
    +          Effect.logWarning("agent-activity state recheck failed; allowing queued delivery", {
    +            cause,
    +            environmentId: input.environmentId,
    +            threadId: input.threadId,
    +          }).pipe(Effect.as(true)),
    +        ),
    +      );
    +  });
    +
    +  const aggregateRowsAreCurrent = Effect.fnUntraced(function* (input: {
    +    readonly userId: string;
    +    readonly aggregate: RelayAgentActivityAggregateState;
    +  }) {
    +    return yield* activityRows.listForUser({ userId: input.userId }).pipe(
    +      Effect.map((currentStates) => {
    +        const currentByThread = new Map(
    +          currentStates.map((current) => [
    +            `${current.environmentId}\u0000${current.threadId}`,
    +            current,
    +          ]),
    +        );
    +        return input.aggregate.activities.every((row) => {
    +          const current = currentByThread.get(`${row.environmentId}\u0000${row.threadId}`);
    +          return (
    +            current !== undefined &&
    +            current.phase === row.phase &&
    +            current.updatedAt === row.updatedAt
    +          );
    +        });
    +      }),
    +      Effect.catchCause((cause) =>
    +        Effect.logWarning("agent-activity aggregate recheck failed; allowing queued delivery", {
    +          cause,
    +          userId: input.userId,
    +        }).pipe(Effect.as(true)),
    +      ),
    +    );
    +  });
    +
    +  const notificationStateIsCurrent = Effect.fnUntraced(function* (input: {
    +    readonly userId: string;
    +    readonly notification: ApnsNotificationPayload;
    +  }) {
    +    // Jobs from older relay versions do not carry a state identity. Preserve
    +    // backwards compatibility and only revalidate newly queued jobs.
    +    if (input.notification.phase === undefined || input.notification.updatedAt === undefined) {
    +      return true;
    +    }
    +    return yield* stateIdentityIsCurrent({
    +      userId: input.userId,
    +      environmentId: input.notification.environmentId,
    +      threadId: input.notification.threadId,
    +      phase: input.notification.phase,
    +      updatedAt: input.notification.updatedAt,
    +    });
    +  });
    +
       const isCurrentSignedJobToken = Effect.fnUntraced(function* (input: {
         readonly target: LiveActivityDeliveryTarget;
         readonly kind: RelayDeliveryKind;
    @@ -791,6 +874,20 @@ export const make = Effect.gen(function* () {
             });
             return staleJobResult({ deviceId: input.target.device_id, kind: input.kind });
           }
    +      if (
    +        input.kind !== "live_activity_start" &&
    +        aggregate !== null &&
    +        !(yield* aggregateRowsAreCurrent({
    +          userId: input.target.user_id,
    +          aggregate,
    +        }))
    +      ) {
    +        yield* attempts.completeSourceJob({
    +          sourceJobId: input.sourceJobId,
    +          apnsReason: "Stale agent activity state skipped.",
    +        });
    +        return staleJobResult({ deviceId: input.target.device_id, kind: input.kind });
    +      }
         }
         if (
           input.kind === "live_activity_start" &&
    @@ -930,6 +1027,21 @@ export const make = Effect.gen(function* () {
               kind: "push_notification",
             });
           }
    +      if (
    +        !(yield* notificationStateIsCurrent({
    +          userId: input.target.user_id,
    +          notification,
    +        }))
    +      ) {
    +        yield* attempts.completeSourceJob({
    +          sourceJobId: input.sourceJobId,
    +          apnsReason: "Stale agent activity state skipped.",
    +        });
    +        return staleJobResult({
    +          deviceId: input.target.device_id,
    +          kind: "push_notification",
    +        });
    +      }
         }
         const result = yield* apns
           .sendPushNotificationRequest({
    diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts
    index 1f1256e3567..ca39484373e 100644
    --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts
    +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts
    @@ -86,6 +86,7 @@ function makeAgentActivityRows(
           };
           return Effect.succeed([activeState]);
         },
    +    getForUserThread: () => Effect.succeed(null),
         ...overrides,
       };
     }
    diff --git a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts
    index 027b17bd5e0..256ca3edb5c 100644
    --- a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts
    +++ b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts
    @@ -1,6 +1,10 @@
     import * as NodeCrypto from "node:crypto";
     
    -import { RelayAgentActivityAggregateState, type RelayDeliveryKind } from "@t3tools/contracts/relay";
    +import {
    +  RelayAgentActivityAggregateState,
    +  RelayAgentAwarenessPhase,
    +  type RelayDeliveryKind,
    +} from "@t3tools/contracts/relay";
     import { stableStringify } from "@t3tools/shared/relaySigning";
     import * as DateTime from "effect/DateTime";
     import * as Option from "effect/Option";
    @@ -38,6 +42,11 @@ export const ApnsNotificationPayload = Schema.Struct({
       environmentId: Schema.String,
       threadId: Schema.String,
       deepLink: Schema.String,
    +  // Optional so delivery jobs queued by older relay builds still decode.
    +  // New jobs use these fields to avoid delivering a stale Done/attention
    +  // notification after the thread has moved to another phase.
    +  phase: Schema.optional(RelayAgentAwarenessPhase),
    +  updatedAt: Schema.optional(Schema.String),
     });
     export type ApnsNotificationPayload = typeof ApnsNotificationPayload.Type;
     
    diff --git a/package.json b/package.json
    index 8d698497b82..f274817e9e0 100644
    --- a/package.json
    +++ b/package.json
    @@ -46,6 +46,9 @@
         "start:desktop": "vp run --filter @t3tools/desktop start",
         "start:marketing": "vp run --filter @t3tools/marketing preview",
         "start:mock-update-server": "node scripts/mock-update-server.ts",
    +    "screenshots:mobile": "node scripts/mobile-showcase.ts",
    +    "icons:export": "node scripts/export-brand-icons.ts",
    +    "icons:check": "node scripts/export-brand-icons.ts --check",
         "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build",
         "build:marketing": "vp run --filter @t3tools/marketing build",
         "build:desktop": "vp run --filter @t3tools/desktop --filter t3 build",
    diff --git a/packages/client-runtime/src/state/projects.ts b/packages/client-runtime/src/state/projects.ts
    index 82a43350650..31f5ce11199 100644
    --- a/packages/client-runtime/src/state/projects.ts
    +++ b/packages/client-runtime/src/state/projects.ts
    @@ -3,16 +3,16 @@ import {
       isUncPath,
       isWindowsAbsolutePath,
       isWindowsDrivePath,
    +  normalizeProjectPathForComparison,
    +  normalizeProjectPathForDispatch,
     } from "@t3tools/shared/path";
     
    +export { normalizeProjectPathForComparison, normalizeProjectPathForDispatch };
    +
     const isWindowsPlatform = (platform: string): boolean => {
       return /^win(dows)?/i.test(platform);
     };
     
    -function isRootPath(value: string): boolean {
    -  return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value);
    -}
    -
     function getAbsolutePathKind(value: string): "unix" | "windows" | null {
       if (isWindowsDrivePath(value) || isUncPath(value)) {
         return "windows";
    @@ -23,20 +23,6 @@ function getAbsolutePathKind(value: string): "unix" | "windows" | null {
       return null;
     }
     
    -function trimTrailingPathSeparators(value: string): string {
    -  if (value.length === 0 || isRootPath(value)) {
    -    return value;
    -  }
    -  const trimmed =
    -    getAbsolutePathKind(value) === "unix"
    -      ? value.replace(/\/+$/g, "")
    -      : value.replace(/[\\/]+$/g, "");
    -  if (trimmed.length === 0) {
    -    return value;
    -  }
    -  return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed;
    -}
    -
     function preferredPathSeparator(value: string): "/" | "\\" {
       const absolutePathKind = getAbsolutePathKind(value);
       if (absolutePathKind === "windows") return "\\";
    @@ -108,10 +94,6 @@ export function isUnsupportedWindowsProjectPath(value: string, platform: string)
       return isWindowsAbsolutePath(value) && !isWindowsPlatform(platform);
     }
     
    -export function normalizeProjectPathForDispatch(value: string): string {
    -  return trimTrailingPathSeparators(value.trim());
    -}
    -
     export function resolveProjectPathForDispatch(value: string, cwd?: string | null): string {
       const trimmedValue = value.trim();
       if (!isExplicitRelativePath(trimmedValue) || !cwd) {
    @@ -139,14 +121,6 @@ export function resolveProjectPathForDispatch(value: string, cwd?: string | null
       );
     }
     
    -export function normalizeProjectPathForComparison(value: string): string {
    -  const normalized = normalizeProjectPathForDispatch(value);
    -  if (isWindowsDrivePath(normalized) || normalized.startsWith("\\\\")) {
    -    return normalized.replaceAll("/", "\\").toLowerCase();
    -  }
    -  return normalized;
    -}
    -
     export function findProjectByPath(
       projects: ReadonlyArray,
       candidatePath: string,
    @@ -198,7 +172,7 @@ export function ensureBrowseDirectoryPath(currentPath: string): string {
     }
     
     export function getBrowseParentPath(currentPath: string): string | null {
    -  const trimmed = trimTrailingPathSeparators(currentPath);
    +  const trimmed = normalizeProjectPathForDispatch(currentPath);
       const absolutePath = splitAbsolutePath(trimmed);
       if (absolutePath) {
         if (absolutePath.segments.length === 0) return null;
    diff --git a/packages/client-runtime/src/state/threadSort.test.ts b/packages/client-runtime/src/state/threadSort.test.ts
    new file mode 100644
    index 00000000000..dd6f8c3a295
    --- /dev/null
    +++ b/packages/client-runtime/src/state/threadSort.test.ts
    @@ -0,0 +1,71 @@
    +import { describe, expect, it } from "vite-plus/test";
    +
    +import { sortThreads, type ThreadSortInput } from "./threadSort.ts";
    +
    +type TestThread = { readonly id: string } & ThreadSortInput;
    +
    +function makeThread(overrides: Partial = {}): TestThread {
    +  return {
    +    id: "thread-1",
    +    createdAt: "2026-03-09T10:00:00.000Z",
    +    updatedAt: "2026-03-09T10:00:00.000Z",
    +    messages: [],
    +    latestUserMessageAt: null,
    +    ...overrides,
    +  };
    +}
    +
    +describe("sortThreads", () => {
    +  it("falls back to updatedAt and createdAt when latestUserMessageAt is invalid and there are no messages", () => {
    +    const sorted = sortThreads(
    +      [
    +        makeThread({
    +          id: "thread-1",
    +          latestUserMessageAt: "not-a-date",
    +          createdAt: "2026-03-09T10:00:00.000Z",
    +          updatedAt: "2026-03-09T10:05:00.000Z",
    +        }),
    +        makeThread({
    +          id: "thread-2",
    +          latestUserMessageAt: "still-not-a-date",
    +          createdAt: "invalid-created-at",
    +          updatedAt: "invalid-updated-at",
    +        }),
    +        makeThread({
    +          id: "thread-3",
    +          latestUserMessageAt: "invalid-latest-user-message-at",
    +          createdAt: "2026-03-09T10:06:00.000Z",
    +          updatedAt: "invalid-updated-at",
    +        }),
    +      ],
    +      "updated_at",
    +    );
    +
    +    expect(sorted.map((thread) => thread.id)).toEqual(["thread-3", "thread-1", "thread-2"]);
    +  });
    +
    +  it("falls back to the latest valid user message when latestUserMessageAt is invalid", () => {
    +    const sorted = sortThreads(
    +      [
    +        makeThread({
    +          id: "thread-1",
    +          latestUserMessageAt: "invalid-latest-user-message-at",
    +          updatedAt: "2026-03-09T10:00:00.000Z",
    +          messages: [
    +            { role: "user", createdAt: "2026-03-09T10:05:00.000Z" },
    +            { role: "assistant", createdAt: "2026-03-09T10:30:00.000Z" },
    +            { role: "user", createdAt: "2026-03-09T10:20:00.000Z" },
    +          ],
    +        }),
    +        makeThread({
    +          id: "thread-2",
    +          createdAt: "2026-03-09T10:15:00.000Z",
    +          updatedAt: "2026-03-09T10:15:00.000Z",
    +        }),
    +      ],
    +      "updated_at",
    +    );
    +
    +    expect(sorted.map((thread) => thread.id)).toEqual(["thread-1", "thread-2"]);
    +  });
    +});
    diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts
    index 4da184962e6..aed63cd442d 100644
    --- a/packages/client-runtime/src/state/threadSort.ts
    +++ b/packages/client-runtime/src/state/threadSort.ts
    @@ -32,7 +32,10 @@ function getFirstSortableTimestamp(...values: Array):
     
     function getLatestUserMessageTimestamp(thread: ThreadSortInput): number {
       if (thread.latestUserMessageAt) {
    -    return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY;
    +    const latestUserMessageTimestamp = toSortableTimestamp(thread.latestUserMessageAt);
    +    if (latestUserMessageTimestamp !== null) {
    +      return latestUserMessageTimestamp;
    +    }
       }
     
       let latestUserMessageTimestamp: number | null = null;
    diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts
    index bbb4d934ca2..6c853ca1631 100644
    --- a/packages/contracts/src/ipc.ts
    +++ b/packages/contracts/src/ipc.ts
    @@ -213,6 +213,7 @@ export interface DesktopUpdateState {
       runningUnderArm64Translation: boolean;
       availableVersion: string | null;
       downloadedVersion: string | null;
    +  releaseNotes: ReadonlyArray;
       downloadPercent: number | null;
       checkedAt: string | null;
       message: string | null;
    @@ -220,6 +221,16 @@ export interface DesktopUpdateState {
       canRetry: boolean;
     }
     
    +export interface DesktopUpdateReleaseNote {
    +  version: string;
    +  items: ReadonlyArray;
    +}
    +
    +export const DesktopUpdateReleaseNoteSchema = Schema.Struct({
    +  version: Schema.String,
    +  items: Schema.Array(Schema.String),
    +});
    +
     export const DesktopUpdateStateSchema = Schema.Struct({
       enabled: Schema.Boolean,
       status: DesktopUpdateStatusSchema,
    @@ -230,6 +241,7 @@ export const DesktopUpdateStateSchema = Schema.Struct({
       runningUnderArm64Translation: Schema.Boolean,
       availableVersion: Schema.NullOr(Schema.String),
       downloadedVersion: Schema.NullOr(Schema.String),
    +  releaseNotes: Schema.Array(DesktopUpdateReleaseNoteSchema),
       downloadPercent: Schema.NullOr(Schema.Number),
       checkedAt: Schema.NullOr(Schema.String),
       message: Schema.NullOr(Schema.String),
    @@ -994,6 +1006,8 @@ export interface DesktopBridge {
       ) => Promise;
       openExternal: (url: string) => Promise;
       onMenuAction: (listener: (action: string) => void) => () => void;
    +  getWindowFullscreenState: () => boolean;
    +  onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void;
       getUpdateState: () => Promise;
       setUpdateChannel: (channel: DesktopUpdateChannel) => Promise;
       checkForUpdate: () => Promise;
    diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
    index 9baa368a2c9..e0b185f33a9 100644
    --- a/packages/contracts/src/settings.ts
    +++ b/packages/contracts/src/settings.ts
    @@ -225,10 +225,10 @@ export const ClaudeSettings = makeProviderSettingsSchema(
         homePath: TrimmedString.pipe(
           Schema.withDecodingDefault(Effect.succeed("")),
           Schema.annotateKey({
    -        title: "Claude HOME path",
    +        title: "CLAUDE_CONFIG_DIR path",
             description:
    -          "Custom HOME used when running this Claude instance. Keeps .claude.json and .claude separate.",
    -        providerSettingsForm: { placeholder: "~", clearWhenEmpty: "omit" },
    +          "Custom Claude home and config directory. Keeps .claude.json and .claude separate.",
    +        providerSettingsForm: { placeholder: "~/.claude", clearWhenEmpty: "omit" },
           }),
         ),
         customModels: Schema.Array(Schema.String).pipe(
    @@ -259,11 +259,11 @@ export const CursorSettings = makeProviderSettingsSchema(
           Schema.withDecodingDefault(Effect.succeed(false)),
           Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
         ),
    -    binaryPath: makeBinaryPathSetting("agent").pipe(
    +    binaryPath: makeBinaryPathSetting("cursor-agent").pipe(
           Schema.annotateKey({
             title: "Binary path",
             description: "Path to the Cursor agent binary.",
    -        providerSettingsForm: { placeholder: "agent", clearWhenEmpty: "omit" },
    +        providerSettingsForm: { placeholder: "cursor-agent", clearWhenEmpty: "omit" },
           }),
         ),
         apiEndpoint: TrimmedString.pipe(
    diff --git a/packages/shared/package.json b/packages/shared/package.json
    index a1ec362716f..9673b2b203d 100644
    --- a/packages/shared/package.json
    +++ b/packages/shared/package.json
    @@ -3,6 +3,10 @@
       "private": true,
       "type": "module",
       "exports": {
    +    "./projectFavicon": {
    +      "types": "./src/projectFavicon.ts",
    +      "import": "./src/projectFavicon.ts"
    +    },
         "./model": {
           "types": "./src/model.ts",
           "import": "./src/model.ts"
    diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts
    index 2bb2ca0238d..66887d3f2ec 100644
    --- a/packages/shared/src/path.ts
    +++ b/packages/shared/src/path.ts
    @@ -20,3 +20,32 @@ export function isExplicitRelativePath(value: string): boolean {
         value.startsWith("..\\")
       );
     }
    +
    +function isRootPath(value: string): boolean {
    +  return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value);
    +}
    +
    +function trimTrailingPathSeparators(value: string): string {
    +  if (value.length === 0 || isRootPath(value)) {
    +    return value;
    +  }
    +  const trimmed = value.startsWith("/")
    +    ? value.replace(/\/+$/g, "")
    +    : value.replace(/[\\/]+$/g, "");
    +  if (trimmed.length === 0) {
    +    return value;
    +  }
    +  return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed;
    +}
    +
    +export function normalizeProjectPathForDispatch(value: string): string {
    +  return trimTrailingPathSeparators(value.trim());
    +}
    +
    +export function normalizeProjectPathForComparison(value: string): string {
    +  const normalized = normalizeProjectPathForDispatch(value);
    +  if (isWindowsDrivePath(normalized) || isUncPath(normalized)) {
    +    return normalized.replaceAll("/", "\\").toLowerCase();
    +  }
    +  return normalized;
    +}
    diff --git a/packages/shared/src/projectFavicon.test.ts b/packages/shared/src/projectFavicon.test.ts
    new file mode 100644
    index 00000000000..0011b2fc7c9
    --- /dev/null
    +++ b/packages/shared/src/projectFavicon.test.ts
    @@ -0,0 +1,28 @@
    +import { describe, expect, it } from "vite-plus/test";
    +
    +import { isProjectFaviconFallbackUrl, PROJECT_FAVICON_FALLBACK_MARKER } from "./projectFavicon.ts";
    +
    +describe("project favicon", () => {
    +  it("identifies fallback asset URLs by their dedicated filename", () => {
    +    expect(
    +      isProjectFaviconFallbackUrl(
    +        `https://environment.example/api/assets/signed-token/${PROJECT_FAVICON_FALLBACK_MARKER}`,
    +      ),
    +    ).toBe(true);
    +    expect(
    +      isProjectFaviconFallbackUrl(`/api/assets/signed-token/${PROJECT_FAVICON_FALLBACK_MARKER}`),
    +    ).toBe(true);
    +  });
    +
    +  it("does not mistake real favicons or query parameters for fallbacks", () => {
    +    expect(
    +      isProjectFaviconFallbackUrl("https://environment.example/api/assets/token/favicon.svg"),
    +    ).toBe(false);
    +    expect(
    +      isProjectFaviconFallbackUrl(
    +        `https://environment.example/api/assets/token/favicon.svg?name=${PROJECT_FAVICON_FALLBACK_MARKER}`,
    +      ),
    +    ).toBe(false);
    +    expect(isProjectFaviconFallbackUrl(null)).toBe(false);
    +  });
    +});
    diff --git a/packages/shared/src/projectFavicon.ts b/packages/shared/src/projectFavicon.ts
    new file mode 100644
    index 00000000000..2e46429b6c1
    --- /dev/null
    +++ b/packages/shared/src/projectFavicon.ts
    @@ -0,0 +1,12 @@
    +export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing";
    +
    +export function isProjectFaviconFallbackUrl(url: string | null | undefined): boolean {
    +  if (!url) return false;
    +
    +  try {
    +    const pathname = new URL(url, "https://t3.invalid").pathname;
    +    return pathname.slice(pathname.lastIndexOf("/") + 1) === PROJECT_FAVICON_FALLBACK_MARKER;
    +  } catch {
    +    return false;
    +  }
    +}
    diff --git a/packages/shared/src/remote.test.ts b/packages/shared/src/remote.test.ts
    index 24e78757009..5b7a9973652 100644
    --- a/packages/shared/src/remote.test.ts
    +++ b/packages/shared/src/remote.test.ts
    @@ -59,6 +59,70 @@ describe("remote", () => {
         });
       });
     
    +  it("treats a protocol-relative host as https", () => {
    +    expect(
    +      resolveRemotePairingTarget({
    +        host: "//remote.example.com",
    +        pairingCode: "pairing-token",
    +      }),
    +    ).toEqual({
    +      credential: "pairing-token",
    +      httpBaseUrl: "https://remote.example.com/",
    +      wsBaseUrl: "wss://remote.example.com/",
    +    });
    +  });
    +
    +  it("preserves the port when normalizing a protocol-relative host", () => {
    +    expect(
    +      resolveRemotePairingTarget({
    +        host: "//remote.example.com:3000",
    +        pairingCode: "pairing-token",
    +      }),
    +    ).toEqual({
    +      credential: "pairing-token",
    +      httpBaseUrl: "https://remote.example.com:3000/",
    +      wsBaseUrl: "wss://remote.example.com:3000/",
    +    });
    +  });
    +
    +  it("normalizes a protocol-relative host from a hosted pairing link", () => {
    +    expect(
    +      resolveRemotePairingTarget({
    +        pairingUrl: "https://app.t3.codes/pair?host=%2F%2Fremote.example.com#token=pairing-token",
    +      }),
    +    ).toEqual({
    +      credential: "pairing-token",
    +      httpBaseUrl: "https://remote.example.com/",
    +      wsBaseUrl: "wss://remote.example.com/",
    +    });
    +  });
    +
    +  it("collapses extra leading slashes instead of producing an empty host", () => {
    +    expect(
    +      resolveRemotePairingTarget({
    +        host: "///example.com",
    +        pairingCode: "pairing-token",
    +      }),
    +    ).toEqual({
    +      credential: "pairing-token",
    +      httpBaseUrl: "https://example.com/",
    +      wsBaseUrl: "wss://example.com/",
    +    });
    +  });
    +
    +  it("does not double-prepend https when the host already carries a scheme", () => {
    +    expect(
    +      resolveRemotePairingTarget({
    +        host: "//https://example.com",
    +        pairingCode: "pairing-token",
    +      }),
    +    ).toEqual({
    +      credential: "pairing-token",
    +      httpBaseUrl: "https://example.com/",
    +      wsBaseUrl: "wss://example.com/",
    +    });
    +  });
    +
       it("preserves host ports when normalizing a bare host input", () => {
         expect(
           resolveRemotePairingTarget({
    diff --git a/packages/shared/src/remote.ts b/packages/shared/src/remote.ts
    index 4c2ae982631..7df573bb1de 100644
    --- a/packages/shared/src/remote.ts
    +++ b/packages/shared/src/remote.ts
    @@ -84,10 +84,10 @@ const normalizeRemoteBaseUrl = (
         throw new RemoteBackendUrlMissingError();
       }
     
    -  const normalizedInput =
    -    /^[a-zA-Z][a-zA-Z\d+-]*:\/\//.test(trimmed) || trimmed.startsWith("//")
    -      ? trimmed
    -      : `https://${trimmed}`;
    +  const withoutLeadingSlashes = trimmed.replace(/^\/+/, "");
    +  const normalizedInput = /^[a-zA-Z][a-zA-Z\d+-]*:\/\//.test(withoutLeadingSlashes)
    +    ? withoutLeadingSlashes
    +    : `https://${withoutLeadingSlashes}`;
       let url: URL;
       try {
         url = new URL(normalizedInput);
    diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
    index c015b5ea609..6fb749ad1f2 100644
    --- a/pnpm-lock.yaml
    +++ b/pnpm-lock.yaml
    @@ -61,10 +61,12 @@ overrides:
       '@types/node': 24.12.4
       effect: 4.0.0-beta.78
       expo-modules-jsi: 56.0.10
    +  expo-sharing>@expo/config-plugins: 56.0.9
    +  expo-sharing>@expo/config-types: 56.0.6
       vite: npm:@voidzero-dev/vite-plus-core@0.2.2
       yaml: ^2.9.0
     
    -packageExtensionsChecksum: sha256-FV3+2NW/9MFbunJaPsMxvO0LAzyfccoPtPiKoIXAwUU=
    +packageExtensionsChecksum: sha256-CUzzeefpj3gNFrCKNBhV9FOaniNbrLdKyIhWQyXuaiE=
     
     patchedDependencies:
       '@effect/vitest@4.0.0-beta.78': 42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f
    @@ -191,7 +193,7 @@ importers:
             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))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)
           '@clerk/expo':
             specifier: 3.7.2
    -        version: 3.7.2(expo-auth-session@56.0.15(expo@56.0.15)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(expo-constants@56.0.20(expo@56.0.15)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)))(expo-crypto@56.0.4(expo@56.0.15))(expo-secure-store@56.0.4(expo@56.0.15))(expo-web-browser@56.0.5(expo@56.0.15)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)))(expo@56.0.15)(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))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)
    +        version: 3.7.2(expo-auth-session@56.0.15(expo@56.0.15)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(expo-constants@56.0.20(expo@56.0.15)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)))(expo-crypto@56.0.4(expo@56.0.15))(expo-secure-store@56.0.4(expo@56.0.15))(expo-web-browser@56.0.5(expo@56.0.15)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)))(expo@56.0.15)(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))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(typescript@6.0.3)
           '@effect/atom-react':
             specifier: 4.0.0-beta.78
             version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0)
    @@ -336,6 +338,9 @@ importers:
           expo-secure-store:
             specifier: ~56.0.4
             version: 56.0.4(expo@56.0.15)
    +      expo-sharing:
    +        specifier: ~56.0.18
    +        version: 56.0.18(expo@56.0.15)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(typescript@6.0.3)
           expo-splash-screen:
             specifier: ~56.0.10
             version: 56.0.12(expo@56.0.15)(typescript@6.0.3)
    @@ -897,6 +902,9 @@ importers:
           effect:
             specifier: 4.0.0-beta.78
             version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)
    +      pngjs:
    +        specifier: 7.0.0
    +        version: 7.0.0
           yaml:
             specifier: ^2.9.0
             version: 2.9.0
    @@ -904,6 +912,9 @@ importers:
           '@effect/vitest':
             specifier: 4.0.0-beta.78
             version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))
    +      '@types/pngjs':
    +        specifier: 6.0.5
    +        version: 6.0.5
           vite-plus:
             specifier: 'catalog:'
             version: 0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.49.0)(typescript@6.0.3)(yaml@2.9.0)
    @@ -2409,6 +2420,12 @@ packages:
       '@expo/config-plugins@56.0.12':
         resolution: {integrity: sha512-UKjPEOkvxBzTvjghmjUECURDoJLPJIFIevB0JQCe8l9Fg4yfy2fabU5LU3Kfrmmu1/8et/93bucCnABEmoxYEw==}
     
    +  '@expo/config-plugins@56.0.9':
    +    resolution: {integrity: sha512-/6a/S9USwx8OC9tGjHxbviLFiBHyueN3aoNWMLvWDEJoZ1CIVW800ZBzwXq/FYNK2qzcN1LxFmQtzD1zeFQKNA==}
    +
    +  '@expo/config-types@56.0.6':
    +    resolution: {integrity: sha512-4Y6Aum5J4Re5NnxGVofRNe1aDwUBOmWhQYkynZsqzRtX/zEA1ADUeyHXuEckv9YD9djiyT7bKtLt5gKL3mA6VQ==}
    +
       '@expo/config-types@56.0.7':
         resolution: {integrity: sha512-V7bxawNsNned/yMppAHdisIOxniZXgPKRWpIUiQOQBs45/A5MBd2gfDM4Ecq5gnbilnQUTaI6Zxn6JcW7L3TAA==}
     
    @@ -4485,6 +4502,9 @@ packages:
       '@types/node@24.12.4':
         resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==}
     
    +  '@types/pngjs@6.0.5':
    +    resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==}
    +
       '@types/react-dom@19.2.3':
         resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
         peerDependencies:
    @@ -6308,6 +6328,13 @@ packages:
         resolution: {integrity: sha512-SmM2p2g3Jrktpiazcst+OxhjSzOHXKAY4BPURHYHXvApzzoybMmrNF4IEZ8DKZ145BhSe4ydAmlEFCRTsdtgUQ==}
         engines: {node: '>=20.16.0'}
     
    +  expo-sharing@56.0.18:
    +    resolution: {integrity: sha512-45w4BWNFmdTczp+fJX6YfwJrn9sX+VeRWz2VWLhauygcCrym44HtVDXX5yVYPB9TW9ZesLcEI+CCrCBNWL7smQ==}
    +    peerDependencies:
    +      expo: '*'
    +      react: '*'
    +      react-native: '*'
    +
       expo-splash-screen@56.0.12:
         resolution: {integrity: sha512-RvEtJ78aNpuxdVD8TCe9viKtlYGzS9hW20fbvqvkeXDN6WAaYo1CRitPdjWNkaNNSPKOolp3zfha4EGdhXOtxg==}
         peerDependencies:
    @@ -10838,11 +10865,12 @@ snapshots:
           electron-store: 8.2.0
           react-dom: 19.2.6(react@19.2.6)
     
    -  '@clerk/expo@3.7.2(expo-auth-session@56.0.15(expo@56.0.15)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(expo-constants@56.0.20(expo@56.0.15)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)))(expo-crypto@56.0.4(expo@56.0.15))(expo-secure-store@56.0.4(expo@56.0.15))(expo-web-browser@56.0.5(expo@56.0.15)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)))(expo@56.0.15)(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))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)':
    +  '@clerk/expo@3.7.2(expo-auth-session@56.0.15(expo@56.0.15)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(expo-constants@56.0.20(expo@56.0.15)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)))(expo-crypto@56.0.4(expo@56.0.15))(expo-secure-store@56.0.4(expo@56.0.15))(expo-web-browser@56.0.5(expo@56.0.15)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)))(expo@56.0.15)(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))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(typescript@6.0.3)':
         dependencies:
           '@clerk/clerk-js': 6.25.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
           '@clerk/react': 6.12.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
           '@clerk/shared': 4.25.1(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.15(@babel/core@7.29.7)(@expo/dom-webview@56.0.6)(@expo/metro-runtime@56.0.16)(expo-widgets@56.0.22)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.17.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@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))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(typescript@6.0.3)
           react: 19.2.3
    @@ -10856,6 +10884,9 @@ snapshots:
           expo-secure-store: 56.0.4(expo@56.0.15)
           expo-web-browser: 56.0.5(expo@56.0.15)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))
           react-dom: 19.2.3(react@19.2.3)
    +    transitivePeerDependencies:
    +      - supports-color
    +      - typescript
     
       '@clerk/react@6.12.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
         dependencies:
    @@ -11177,7 +11208,7 @@ snapshots:
           fs-extra: 10.1.0
           isbinaryfile: 4.0.10
           minimist: 1.2.8
    -      plist: 3.1.0
    +      plist: 3.1.1
         transitivePeerDependencies:
           - supports-color
     
    @@ -11200,7 +11231,7 @@ snapshots:
           dir-compare: 4.2.0
           fs-extra: 11.3.6
           minimatch: 9.0.9
    -      plist: 3.1.0
    +      plist: 3.1.1
         transitivePeerDependencies:
           - supports-color
     
    @@ -11613,6 +11644,27 @@ snapshots:
           - supports-color
           - typescript
     
    +  '@expo/config-plugins@56.0.9(typescript@6.0.3)':
    +    dependencies:
    +      '@expo/config-types': 56.0.7
    +      '@expo/json-file': 10.2.0
    +      '@expo/plist': 0.7.0
    +      '@expo/require-utils': 56.1.4(typescript@6.0.3)
    +      '@expo/sdk-runtime-versions': 1.0.0
    +      chalk: 4.1.2
    +      debug: 4.4.3
    +      getenv: 2.0.0
    +      glob: 13.0.6
    +      semver: 7.8.5
    +      slugify: 1.6.9
    +      xcode: 3.0.1
    +      xml2js: 0.6.0
    +    transitivePeerDependencies:
    +      - supports-color
    +      - typescript
    +
    +  '@expo/config-types@56.0.6': {}
    +
       '@expo/config-types@56.0.7': {}
     
       '@expo/config@56.0.11(typescript@6.0.3)':
    @@ -13820,6 +13872,10 @@ snapshots:
         dependencies:
           undici-types: 7.16.0
     
    +  '@types/pngjs@6.0.5':
    +    dependencies:
    +      '@types/node': 24.12.4
    +
       '@types/react-dom@19.2.3(@types/react@19.2.17)':
         dependencies:
           '@types/react': 19.2.17
    @@ -15757,6 +15813,18 @@ snapshots:
     
       expo-server@56.0.5: {}
     
    +  expo-sharing@56.0.18(expo@56.0.15)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(typescript@6.0.3):
    +    dependencies:
    +      '@expo/config-plugins': 56.0.9(typescript@6.0.3)
    +      '@expo/config-types': 56.0.6
    +      '@expo/plist': 0.7.0
    +      expo: 56.0.15(@babel/core@7.29.7)(@expo/dom-webview@56.0.6)(@expo/metro-runtime@56.0.16)(expo-widgets@56.0.22)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.17.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@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))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(typescript@6.0.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))(@types/react@19.2.17)(react@19.2.3)
    +    transitivePeerDependencies:
    +      - supports-color
    +      - typescript
    +
       expo-splash-screen@56.0.12(expo@56.0.15)(typescript@6.0.3):
         dependencies:
           '@expo/config-plugins': 56.0.12(typescript@6.0.3)
    @@ -17642,13 +17710,13 @@ snapshots:
     
       node-abi@4.33.0:
         dependencies:
    -      semver: 7.7.4
    +      semver: 7.8.5
     
       node-addon-api@7.1.1: {}
     
       node-api-version@0.2.1:
         dependencies:
    -      semver: 7.7.4
    +      semver: 7.8.5
     
       node-fetch-native@1.6.7: {}
     
    @@ -17666,7 +17734,7 @@ snapshots:
           graceful-fs: 4.2.11
           nopt: 9.0.0
           proc-log: 6.1.0
    -      semver: 7.7.4
    +      semver: 7.8.5
           tar: 7.5.19
           tinyglobby: 0.2.17
           undici: 6.27.0
    diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
    index 7d85b1059d5..22757702fc8 100644
    --- a/pnpm-workspace.yaml
    +++ b/pnpm-workspace.yaml
    @@ -86,10 +86,15 @@ overrides:
       "@types/node": "catalog:"
       effect: "catalog:"
       expo-modules-jsi: 56.0.10
    +  "expo-sharing>@expo/config-plugins": 56.0.9
    +  "expo-sharing>@expo/config-types": 56.0.6
       vite: "catalog:"
       yaml: "catalog:"
     
     packageExtensions:
    +  "@clerk/expo@*":
    +    dependencies:
    +      "@expo/config-plugins": 56.0.9
       "@effect/vitest@*":
         dependencies:
           vite-plus: "catalog:"
    diff --git a/scripts/export-brand-icons.ts b/scripts/export-brand-icons.ts
    new file mode 100644
    index 00000000000..c24169dc2e1
    --- /dev/null
    +++ b/scripts/export-brand-icons.ts
    @@ -0,0 +1,800 @@
    +#!/usr/bin/env node
    +
    +import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
    +import * as NodeServices from "@effect/platform-node/NodeServices";
    +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
    +import * as Console from "effect/Console";
    +import * as Effect from "effect/Effect";
    +import * as FileSystem from "effect/FileSystem";
    +import * as Option from "effect/Option";
    +import * as Path from "effect/Path";
    +import * as Schema from "effect/Schema";
    +import * as Stream from "effect/Stream";
    +import { Command, Flag } from "effect/unstable/cli";
    +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
    +
    +import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";
    +import { encodePngIco, readPngDimensions, WINDOWS_ICON_SIZES } from "./lib/icon-export.ts";
    +
    +const DESIGN_GENERATION = 26;
    +const ICON_COMPOSER_EXECUTABLE_PARTS = [
    +  "Contents",
    +  "Applications",
    +  "Icon Composer.app",
    +  "Contents",
    +  "Executables",
    +  "ictool",
    +] as const;
    +const STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS = [
    +  "Icon Composer.app",
    +  "Contents",
    +  "Executables",
    +  "ictool",
    +] as const;
    +const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
    +
    +const IconComposerVersion = Schema.Struct({
    +  "bundle-version": Schema.NonEmptyString,
    +  "short-bundle-version": Schema.NonEmptyString,
    +});
    +const decodeIconComposerVersion = Schema.decodeUnknownEffect(
    +  Schema.fromJsonString(IconComposerVersion),
    +);
    +
    +type IconPlatform = "iOS";
    +
    +interface VariantOutputs {
    +  readonly ios: string;
    +  readonly macos: string;
    +  readonly universal: string;
    +  readonly appleTouch: string;
    +  readonly favicon16: string;
    +  readonly favicon32: string;
    +  readonly faviconIco: string;
    +  readonly windowsIco: string;
    +}
    +
    +interface IconVariant {
    +  readonly label: string;
    +  readonly source: string;
    +  readonly outputs: VariantOutputs;
    +}
    +
    +interface IconComposerTool {
    +  readonly path: string;
    +  readonly version: string;
    +  readonly bundleVersion: string;
    +  readonly supportsDesignGeneration: boolean;
    +}
    +
    +interface CommandResult {
    +  readonly stdout: string;
    +  readonly stderr: string;
    +  readonly exitCode: number;
    +}
    +
    +export class IconExportFileSystemError extends Schema.TaggedErrorClass()(
    +  "IconExportFileSystemError",
    +  {
    +    operation: Schema.Literals([
    +      "resolve-repository-root",
    +      "check-path",
    +      "read-directory",
    +      "read-file",
    +      "make-directory",
    +      "make-temp-directory",
    +      "make-temp-file",
    +      "write-file",
    +      "rename-file",
    +    ]),
    +    path: Schema.String,
    +    cause: Schema.Defect(),
    +  },
    +) {
    +  override get message(): string {
    +    return `Icon export file-system operation '${this.operation}' failed for ${this.path}.`;
    +  }
    +}
    +
    +export class IconExportProcessError extends Schema.TaggedErrorClass()(
    +  "IconExportProcessError",
    +  {
    +    operation: Schema.Literals(["spawn", "collect-stdout", "collect-stderr", "wait-for-exit"]),
    +    command: Schema.String,
    +    argumentCount: NonNegativeInt,
    +    cause: Schema.Defect(),
    +  },
    +) {
    +  override get message(): string {
    +    return `Icon export process operation '${this.operation}' failed for ${this.command}.`;
    +  }
    +}
    +
    +export class IconExportCommandFailedError extends Schema.TaggedErrorClass()(
    +  "IconExportCommandFailedError",
    +  {
    +    command: Schema.String,
    +    argumentCount: NonNegativeInt,
    +    exitCode: Schema.Int,
    +    sourcePath: Schema.String,
    +    size: Schema.Int,
    +    stdout: Schema.optional(Schema.String),
    +    stderr: Schema.optional(Schema.String),
    +  },
    +) {
    +  override get message(): string {
    +    return `Icon Composer failed to export ${this.sourcePath} at ${this.size}x${this.size}.`;
    +  }
    +}
    +
    +export class IconExportToolResolutionError extends Schema.TaggedErrorClass()(
    +  "IconExportToolResolutionError",
    +  {
    +    reason: Schema.Literals(["configured-invalid", "configured-outdated", "not-found"]),
    +    designGeneration: Schema.Int,
    +    toolPath: Schema.optional(Schema.String),
    +    version: Schema.optional(Schema.String),
    +  },
    +) {
    +  override get message(): string {
    +    switch (this.reason) {
    +      case "configured-invalid":
    +        return `ICON_COMPOSER_TOOL does not point to Icon Composer's export-capable ictool: ${this.toolPath}`;
    +      case "configured-outdated":
    +        return `ICON_COMPOSER_TOOL points to Icon Composer ${this.version}, but version 2 or newer is required for design generation ${this.designGeneration}.`;
    +      case "not-found":
    +        return `Could not find an Icon Composer 2.x exporter compatible with design generation ${this.designGeneration}. Install a compatible Icon Composer/Xcode or set ICON_COMPOSER_TOOL to Icon Composer.app/Contents/Executables/ictool.`;
    +    }
    +  }
    +}
    +
    +export class IconExportSourceMissingError extends Schema.TaggedErrorClass()(
    +  "IconExportSourceMissingError",
    +  {
    +    sourcePath: Schema.String,
    +  },
    +) {
    +  override get message(): string {
    +    return `Missing Icon Composer source project: ${this.sourcePath}`;
    +  }
    +}
    +
    +export class IconExportRenditionError extends Schema.TaggedErrorClass()(
    +  "IconExportRenditionError",
    +  {
    +    sourcePath: Schema.String,
    +    outputPath: Schema.String,
    +    expectedSize: Schema.Int,
    +    actualWidth: Schema.optional(Schema.Int),
    +    actualHeight: Schema.optional(Schema.Int),
    +    cause: Schema.optional(Schema.Defect()),
    +  },
    +) {
    +  override get message(): string {
    +    const actual =
    +      this.actualWidth === undefined || this.actualHeight === undefined
    +        ? "an invalid PNG"
    +        : `${this.actualWidth}x${this.actualHeight}`;
    +    return `Icon Composer produced ${actual}; expected ${this.expectedSize}x${this.expectedSize} for ${this.sourcePath}.`;
    +  }
    +}
    +
    +export class IconExportEncodingError extends Schema.TaggedErrorClass()(
    +  "IconExportEncodingError",
    +  {
    +    variant: Schema.String,
    +    cause: Schema.Defect(),
    +  },
    +) {
    +  override get message(): string {
    +    return `Failed to encode ICO renditions for the ${this.variant} icon.`;
    +  }
    +}
    +
    +export class IconExportAssetsStaleError extends Schema.TaggedErrorClass()(
    +  "IconExportAssetsStaleError",
    +  {
    +    paths: Schema.Array(Schema.String),
    +  },
    +) {
    +  override get message(): string {
    +    return `Generated icon assets are stale:\n${this.paths.map((path) => `- ${path}`).join("\n")}`;
    +  }
    +}
    +
    +const ICON_VARIANTS = [
    +  {
    +    label: "development",
    +    source: BRAND_ASSET_PATHS.developmentIconComposerProject,
    +    outputs: {
    +      ios: BRAND_ASSET_PATHS.developmentIosIconPng,
    +      macos: BRAND_ASSET_PATHS.developmentDesktopIconPng,
    +      universal: BRAND_ASSET_PATHS.developmentUniversalIconPng,
    +      appleTouch: BRAND_ASSET_PATHS.developmentWebAppleTouchIconPng,
    +      favicon16: BRAND_ASSET_PATHS.developmentWebFavicon16Png,
    +      favicon32: BRAND_ASSET_PATHS.developmentWebFavicon32Png,
    +      faviconIco: BRAND_ASSET_PATHS.developmentWebFaviconIco,
    +      windowsIco: BRAND_ASSET_PATHS.developmentWindowsIconIco,
    +    },
    +  },
    +  {
    +    label: "preview",
    +    source: BRAND_ASSET_PATHS.nightlyIconComposerProject,
    +    outputs: {
    +      ios: BRAND_ASSET_PATHS.nightlyIosIconPng,
    +      macos: BRAND_ASSET_PATHS.nightlyMacIconPng,
    +      universal: BRAND_ASSET_PATHS.nightlyLinuxIconPng,
    +      appleTouch: BRAND_ASSET_PATHS.nightlyWebAppleTouchIconPng,
    +      favicon16: BRAND_ASSET_PATHS.nightlyWebFavicon16Png,
    +      favicon32: BRAND_ASSET_PATHS.nightlyWebFavicon32Png,
    +      faviconIco: BRAND_ASSET_PATHS.nightlyWebFaviconIco,
    +      windowsIco: BRAND_ASSET_PATHS.nightlyWindowsIconIco,
    +    },
    +  },
    +  {
    +    label: "production",
    +    source: BRAND_ASSET_PATHS.productionIconComposerProject,
    +    outputs: {
    +      ios: BRAND_ASSET_PATHS.productionIosIconPng,
    +      macos: BRAND_ASSET_PATHS.productionMacIconPng,
    +      universal: BRAND_ASSET_PATHS.productionLinuxIconPng,
    +      appleTouch: BRAND_ASSET_PATHS.productionWebAppleTouchIconPng,
    +      favicon16: BRAND_ASSET_PATHS.productionWebFavicon16Png,
    +      favicon32: BRAND_ASSET_PATHS.productionWebFavicon32Png,
    +      faviconIco: BRAND_ASSET_PATHS.productionWebFaviconIco,
    +      windowsIco: BRAND_ASSET_PATHS.productionWindowsIconIco,
    +    },
    +  },
    +] as const satisfies ReadonlyArray;
    +
    +const MACOS_EXPORT_CODEX_PROMPT = [
    +  "Use [@Computer](plugin://computer-use@openai-bundled) and the Icon Composer app to export the three macOS app icons in this repository.",
    +  "For each project below, use Platform: macOS pre-Tahoe, Appearance: Default, Size: 1024pt, and Scale: 1×, then save the PNG to the exact destination:",
    +  ...ICON_VARIANTS.map((variant) => `- ${variant.source} -> ${variant.outputs.macos}`),
    +  "Do not resize, composite, or otherwise post-process the exported PNGs.",
    +  "Verify every result is 1024×1024 and has the classic macOS safe area: an 824×824 opaque body inset 100px on every side, with only Icon Composer's native shadow extending beyond it.",
    +];
    +
    +const RepositoryRoot = Effect.service(Path.Path).pipe(
    +  Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
    +  Effect.mapError(
    +    (cause) =>
    +      new IconExportFileSystemError({
    +        operation: "resolve-repository-root",
    +        path: new URL("..", import.meta.url).pathname,
    +        cause,
    +      }),
    +  ),
    +);
    +
    +const collectStreamAsString = (stream: Stream.Stream) =>
    +  stream.pipe(
    +    Stream.decodeText(),
    +    Stream.runFold(
    +      () => "",
    +      (acc, chunk) => acc + chunk,
    +    ),
    +  );
    +
    +const runCommand = Effect.fn("iconExport.runCommand")(function* (
    +  command: string,
    +  args: ReadonlyArray,
    +) {
    +  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
    +  const child = yield* spawner.spawn(ChildProcess.make(command, args)).pipe(
    +    Effect.mapError(
    +      (cause) =>
    +        new IconExportProcessError({
    +          operation: "spawn",
    +          command,
    +          argumentCount: args.length,
    +          cause,
    +        }),
    +    ),
    +  );
    +  const [stdout, stderr, exitCode] = yield* Effect.all(
    +    [
    +      collectStreamAsString(child.stdout).pipe(
    +        Effect.mapError(
    +          (cause) =>
    +            new IconExportProcessError({
    +              operation: "collect-stdout",
    +              command,
    +              argumentCount: args.length,
    +              cause,
    +            }),
    +        ),
    +      ),
    +      collectStreamAsString(child.stderr).pipe(
    +        Effect.mapError(
    +          (cause) =>
    +            new IconExportProcessError({
    +              operation: "collect-stderr",
    +              command,
    +              argumentCount: args.length,
    +              cause,
    +            }),
    +        ),
    +      ),
    +      child.exitCode.pipe(
    +        Effect.map(Number),
    +        Effect.mapError(
    +          (cause) =>
    +            new IconExportProcessError({
    +              operation: "wait-for-exit",
    +              command,
    +              argumentCount: args.length,
    +              cause,
    +            }),
    +        ),
    +      ),
    +    ],
    +    { concurrency: "unbounded" },
    +  );
    +
    +  return { stdout, stderr, exitCode } satisfies CommandResult;
    +});
    +
    +const iconComposerToolFromDeveloperDirectory = (developerDirectory: string, path: Path.Path) =>
    +  path.resolve(developerDirectory, "..", ...ICON_COMPOSER_EXECUTABLE_PARTS.slice(1));
    +
    +const readSelectedDeveloperDirectory = Effect.fn("iconExport.readSelectedDeveloperDirectory")(
    +  function* () {
    +    const result = yield* runCommand("xcode-select", ["-p"]).pipe(Effect.option);
    +    return Option.flatMap(result, (output) => {
    +      const developerDirectory = output.stdout.trim();
    +      return output.exitCode === 0 && developerDirectory.length > 0
    +        ? Option.some(developerDirectory)
    +        : Option.none();
    +    });
    +  },
    +);
    +
    +const findXcodeAppCandidates = Effect.fn("iconExport.findXcodeAppCandidates")(function* (
    +  directory: string,
    +) {
    +  const fs = yield* FileSystem.FileSystem;
    +  const path = yield* Path.Path;
    +  const entries = yield* fs.readDirectory(directory).pipe(
    +    Effect.mapError(
    +      (cause) =>
    +        new IconExportFileSystemError({
    +          operation: "read-directory",
    +          path: directory,
    +          cause,
    +        }),
    +    ),
    +    Effect.orElseSucceed(() => []),
    +  );
    +  return entries
    +    .filter((entry) => /^Xcode.*\.app$/.test(entry))
    +    .map((entry) => path.join(directory, entry, ...ICON_COMPOSER_EXECUTABLE_PARTS));
    +});
    +
    +const probeIconComposerTool = Effect.fn("iconExport.probeIconComposerTool")(function* (
    +  candidate: string,
    +) {
    +  const fs = yield* FileSystem.FileSystem;
    +  const exists = yield* fs.exists(candidate).pipe(
    +    Effect.mapError(
    +      (cause) =>
    +        new IconExportFileSystemError({
    +          operation: "check-path",
    +          path: candidate,
    +          cause,
    +        }),
    +    ),
    +    Effect.orElseSucceed(() => false),
    +  );
    +  if (!exists) return Option.none();
    +
    +  const result = yield* runCommand(candidate, ["--version"]).pipe(Effect.option);
    +  if (Option.isNone(result) || result.value.exitCode !== 0) {
    +    return Option.none();
    +  }
    +
    +  const version = yield* decodeIconComposerVersion(result.value.stdout).pipe(Effect.option);
    +  if (Option.isNone(version)) return Option.none();
    +
    +  const bundleVersion = version.value["bundle-version"];
    +  const shortVersion = version.value["short-bundle-version"];
    +  return Option.some({
    +    path: candidate,
    +    version: `${shortVersion} (${bundleVersion})`,
    +    bundleVersion,
    +    supportsDesignGeneration: Number.parseInt(shortVersion, 10) >= 2,
    +  });
    +});
    +
    +const resolveIconComposerTool = Effect.fn("iconExport.resolveIconComposerTool")(function* () {
    +  const path = yield* Path.Path;
    +  const environment = yield* HostProcessEnvironment;
    +  const configuredTool = environment.ICON_COMPOSER_TOOL?.trim();
    +  if (configuredTool) {
    +    const tool = yield* probeIconComposerTool(configuredTool);
    +    if (Option.isNone(tool)) {
    +      return yield* new IconExportToolResolutionError({
    +        reason: "configured-invalid",
    +        designGeneration: DESIGN_GENERATION,
    +        toolPath: configuredTool,
    +      });
    +    }
    +    if (!tool.value.supportsDesignGeneration) {
    +      return yield* new IconExportToolResolutionError({
    +        reason: "configured-outdated",
    +        designGeneration: DESIGN_GENERATION,
    +        toolPath: configuredTool,
    +        version: tool.value.version,
    +      });
    +    }
    +    return tool.value;
    +  }
    +
    +  const selectedDeveloperDirectory = yield* readSelectedDeveloperDirectory();
    +  const configuredDeveloperDirectory = environment.DEVELOPER_DIR?.trim();
    +  const homeDirectory = environment.HOME?.trim();
    +  const searchDirectories = [
    +    "/Applications",
    +    ...(homeDirectory ? [path.join(homeDirectory, "Downloads")] : []),
    +  ];
    +  const xcodeCandidates = yield* Effect.forEach(searchDirectories, findXcodeAppCandidates, {
    +    concurrency: "unbounded",
    +  });
    +  const candidates = new Set([
    +    ...(configuredDeveloperDirectory
    +      ? [iconComposerToolFromDeveloperDirectory(configuredDeveloperDirectory, path)]
    +      : []),
    +    ...Option.match(selectedDeveloperDirectory, {
    +      onNone: () => [],
    +      onSome: (developerDirectory) => [
    +        iconComposerToolFromDeveloperDirectory(developerDirectory, path),
    +      ],
    +    }),
    +    path.join("/Applications", ...STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS),
    +    ...(homeDirectory
    +      ? [path.join(homeDirectory, "Applications", ...STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS)]
    +      : []),
    +    ...xcodeCandidates.flat(),
    +  ]);
    +  const probed = yield* Effect.forEach([...candidates], probeIconComposerTool, {
    +    concurrency: "unbounded",
    +  });
    +  const compatibleTools = probed
    +    .filter(Option.isSome)
    +    .map((tool) => tool.value)
    +    .filter((tool) => tool.supportsDesignGeneration)
    +    .sort((left, right) =>
    +      right.bundleVersion.localeCompare(left.bundleVersion, undefined, { numeric: true }),
    +    );
    +  const newestTool = compatibleTools[0];
    +  if (newestTool) return newestTool;
    +
    +  return yield* new IconExportToolResolutionError({
    +    reason: "not-found",
    +    designGeneration: DESIGN_GENERATION,
    +  });
    +});
    +
    +const renderIcon = Effect.fn("iconExport.renderIcon")(function* (
    +  toolPath: string,
    +  sourcePath: string,
    +  outputPath: string,
    +  platform: IconPlatform,
    +  size: number,
    +) {
    +  const fs = yield* FileSystem.FileSystem;
    +  const args = [
    +    sourcePath,
    +    "--export-image",
    +    "--output-file",
    +    outputPath,
    +    "--platform",
    +    platform,
    +    "--rendition",
    +    "Default",
    +    "--width",
    +    String(size),
    +    "--height",
    +    String(size),
    +    "--scale",
    +    "1",
    +    "--design-generation",
    +    String(DESIGN_GENERATION),
    +  ];
    +  const result = yield* runCommand(toolPath, args);
    +  if (result.exitCode !== 0) {
    +    return yield* new IconExportCommandFailedError({
    +      command: toolPath,
    +      argumentCount: args.length,
    +      exitCode: result.exitCode,
    +      sourcePath,
    +      size,
    +      ...(result.stdout.trim() ? { stdout: result.stdout.trim() } : {}),
    +      ...(result.stderr.trim() ? { stderr: result.stderr.trim() } : {}),
    +    });
    +  }
    +
    +  const contents = yield* fs.readFile(outputPath).pipe(
    +    Effect.mapError(
    +      (cause) =>
    +        new IconExportFileSystemError({
    +          operation: "read-file",
    +          path: outputPath,
    +          cause,
    +        }),
    +    ),
    +  );
    +  const buffer = Buffer.from(contents);
    +  const dimensions = yield* Effect.try({
    +    try: () => readPngDimensions(buffer),
    +    catch: (cause) =>
    +      new IconExportRenditionError({
    +        sourcePath,
    +        outputPath,
    +        expectedSize: size,
    +        cause,
    +      }),
    +  });
    +  if (dimensions.width !== size || dimensions.height !== size) {
    +    return yield* new IconExportRenditionError({
    +      sourcePath,
    +      outputPath,
    +      expectedSize: size,
    +      actualWidth: dimensions.width,
    +      actualHeight: dimensions.height,
    +    });
    +  }
    +  return buffer;
    +});
    +
    +const renderVariant = Effect.fn("iconExport.renderVariant")(function* (
    +  toolPath: string,
    +  repositoryRoot: string,
    +  temporaryDirectory: string,
    +  variant: IconVariant,
    +) {
    +  const fs = yield* FileSystem.FileSystem;
    +  const path = yield* Path.Path;
    +  const sourcePath = path.join(repositoryRoot, variant.source);
    +  const sourceExists = yield* fs.exists(sourcePath).pipe(
    +    Effect.mapError(
    +      (cause) =>
    +        new IconExportFileSystemError({
    +          operation: "check-path",
    +          path: sourcePath,
    +          cause,
    +        }),
    +    ),
    +  );
    +  if (!sourceExists) {
    +    return yield* new IconExportSourceMissingError({ sourcePath: variant.source });
    +  }
    +
    +  const renditionCache = new Map();
    +  const render = Effect.fn("iconExport.renderVariant.rendition")(function* (
    +    platform: IconPlatform,
    +    size: number,
    +  ) {
    +    const cacheKey = `${platform}-${size}`;
    +    const cached = renditionCache.get(cacheKey);
    +    if (cached) return cached;
    +
    +    const outputPath = path.join(temporaryDirectory, `${variant.label}-${platform}-${size}.png`);
    +    const contents = yield* renderIcon(toolPath, sourcePath, outputPath, platform, size);
    +    renditionCache.set(cacheKey, contents);
    +    return contents;
    +  });
    +
    +  const ios = yield* render("iOS", 1024);
    +  const icoRenditions = yield* Effect.forEach(
    +    WINDOWS_ICON_SIZES,
    +    (size) => render("iOS", size).pipe(Effect.map((contents) => ({ size, contents }))),
    +    { concurrency: 1 },
    +  );
    +  const ico = yield* Effect.try({
    +    try: () => encodePngIco(icoRenditions),
    +    catch: (cause) => new IconExportEncodingError({ variant: variant.label, cause }),
    +  });
    +
    +  return new Map([
    +    [variant.outputs.ios, ios],
    +    [variant.outputs.universal, ios],
    +    [variant.outputs.appleTouch, yield* render("iOS", 180)],
    +    [variant.outputs.favicon16, yield* render("iOS", 16)],
    +    [variant.outputs.favicon32, yield* render("iOS", 32)],
    +    [variant.outputs.faviconIco, ico],
    +    [variant.outputs.windowsIco, ico],
    +  ]);
    +});
    +
    +const logManualMacOsExportInstructions = Effect.fn("iconExport.logManualMacOsExportInstructions")(
    +  function* () {
    +    yield* Console.warn(
    +      [
    +        "macOS icons require Icon Composer's GUI-only pre-Tahoe preset and were not changed.",
    +        "Export each source with Platform: macOS pre-Tahoe, Appearance: Default, Size: 1024pt, Scale: 1×:",
    +        ...ICON_VARIANTS.map((variant) => `- ${variant.source} -> ${variant.outputs.macos}`),
    +        "See assets/README.md for the complete workflow.",
    +        "",
    +        "Copy/paste this prompt into Codex to perform the native exports:",
    +        "---",
    +        ...MACOS_EXPORT_CODEX_PROMPT,
    +        "---",
    +      ].join("\n"),
    +    );
    +  },
    +);
    +
    +const writeAtomically = Effect.fn("iconExport.writeAtomically")(function* (
    +  repositoryRoot: string,
    +  relativePath: string,
    +  contents: Buffer,
    +) {
    +  const fs = yield* FileSystem.FileSystem;
    +  const path = yield* Path.Path;
    +  const targetPath = path.join(repositoryRoot, relativePath);
    +  const targetDirectory = path.dirname(targetPath);
    +  yield* fs.makeDirectory(targetDirectory, { recursive: true }).pipe(
    +    Effect.mapError(
    +      (cause) =>
    +        new IconExportFileSystemError({
    +          operation: "make-directory",
    +          path: targetDirectory,
    +          cause,
    +        }),
    +    ),
    +  );
    +  const temporaryPath = yield* fs
    +    .makeTempFileScoped({
    +      directory: targetDirectory,
    +      prefix: ".t3-icon-export-",
    +      suffix: ".tmp",
    +    })
    +    .pipe(
    +      Effect.mapError(
    +        (cause) =>
    +          new IconExportFileSystemError({
    +            operation: "make-temp-file",
    +            path: targetDirectory,
    +            cause,
    +          }),
    +      ),
    +    );
    +  yield* fs.writeFile(temporaryPath, contents).pipe(
    +    Effect.mapError(
    +      (cause) =>
    +        new IconExportFileSystemError({
    +          operation: "write-file",
    +          path: temporaryPath,
    +          cause,
    +        }),
    +    ),
    +  );
    +  yield* fs.rename(temporaryPath, targetPath).pipe(
    +    Effect.mapError(
    +      (cause) =>
    +        new IconExportFileSystemError({
    +          operation: "rename-file",
    +          path: targetPath,
    +          cause,
    +        }),
    +    ),
    +  );
    +});
    +
    +const isCurrent = Effect.fn("iconExport.isCurrent")(function* (
    +  repositoryRoot: string,
    +  relativePath: string,
    +  expected: Buffer,
    +) {
    +  const fs = yield* FileSystem.FileSystem;
    +  const path = yield* Path.Path;
    +  const targetPath = path.join(repositoryRoot, relativePath);
    +  const exists = yield* fs.exists(targetPath).pipe(
    +    Effect.mapError(
    +      (cause) =>
    +        new IconExportFileSystemError({
    +          operation: "check-path",
    +          path: targetPath,
    +          cause,
    +        }),
    +    ),
    +  );
    +  if (!exists) return false;
    +
    +  const actual = yield* fs.readFile(targetPath).pipe(
    +    Effect.mapError(
    +      (cause) =>
    +        new IconExportFileSystemError({
    +          operation: "read-file",
    +          path: targetPath,
    +          cause,
    +        }),
    +    ),
    +  );
    +  return Buffer.from(actual).equals(expected);
    +});
    +
    +export const exportBrandIcons = Effect.fn("exportBrandIcons")(function* (checkOnly: boolean) {
    +  const fs = yield* FileSystem.FileSystem;
    +  const repositoryRoot = yield* RepositoryRoot;
    +  const tool = yield* resolveIconComposerTool();
    +  const temporaryDirectory = yield* fs
    +    .makeTempDirectoryScoped({
    +      prefix: "t3-icon-export-",
    +    })
    +    .pipe(
    +      Effect.mapError(
    +        (cause) =>
    +          new IconExportFileSystemError({
    +            operation: "make-temp-directory",
    +            path: "system temporary directory",
    +            cause,
    +          }),
    +      ),
    +    );
    +  yield* Console.log(
    +    `Exporting icons with Icon Composer ${tool.version}, design generation ${DESIGN_GENERATION}.`,
    +  );
    +
    +  const generated = new Map();
    +  for (const variant of ICON_VARIANTS) {
    +    yield* Console.log(`Rendering ${variant.label} from ${variant.source}...`);
    +    const variantAssets = yield* renderVariant(
    +      tool.path,
    +      repositoryRoot,
    +      temporaryDirectory,
    +      variant,
    +    );
    +    for (const [relativePath, contents] of variantAssets) {
    +      generated.set(relativePath, contents);
    +    }
    +  }
    +
    +  if (checkOnly) {
    +    const stale = yield* Effect.filter(
    +      [...generated.entries()],
    +      ([relativePath, contents]) =>
    +        isCurrent(repositoryRoot, relativePath, contents).pipe(Effect.map((current) => !current)),
    +      { concurrency: "unbounded" },
    +    );
    +    if (stale.length > 0) {
    +      return yield* new IconExportAssetsStaleError({
    +        paths: stale.map(([relativePath]) => relativePath),
    +      });
    +    }
    +    yield* Console.log(`All ${generated.size} generated icon assets are current.`);
    +    yield* logManualMacOsExportInstructions();
    +    return;
    +  }
    +
    +  yield* Effect.forEach(
    +    generated,
    +    ([relativePath, contents]) => writeAtomically(repositoryRoot, relativePath, contents),
    +    { concurrency: 1, discard: true },
    +  );
    +  yield* Console.log(`Updated ${generated.size} generated icon assets.`);
    +  yield* logManualMacOsExportInstructions();
    +});
    +
    +export const exportBrandIconsCommand = Command.make(
    +  "export-brand-icons",
    +  {
    +    check: Flag.boolean("check").pipe(
    +      Flag.withDescription("Verify generated icon assets without modifying files."),
    +      Flag.withDefault(false),
    +    ),
    +  },
    +  ({ check }) => exportBrandIcons(check).pipe(Effect.scoped),
    +).pipe(
    +  Command.withDescription(
    +    "Export development, preview, and production assets from Icon Composer projects.",
    +  ),
    +);
    +
    +if (import.meta.main) {
    +  Command.run(exportBrandIconsCommand, { version: "0.0.0" }).pipe(
    +    Effect.provide(NodeServices.layer),
    +    NodeRuntime.runMain,
    +  );
    +}
    diff --git a/scripts/lib/brand-assets.test.ts b/scripts/lib/brand-assets.test.ts
    index 8e1875e8d27..6ab3c6a3a29 100644
    --- a/scripts/lib/brand-assets.test.ts
    +++ b/scripts/lib/brand-assets.test.ts
    @@ -55,4 +55,19 @@ describe("brand-assets", () => {
         expect(resolveWebAssetBrandForChannel("latest")).toBe("production");
         expect(resolveWebAssetBrandForChannel("nightly")).toBe("nightly");
       });
    +
    +  it("keeps development, nightly, and production icon families separate", () => {
    +    expect([
    +      BRAND_ASSET_PATHS.developmentIconComposerProject,
    +      BRAND_ASSET_PATHS.nightlyIconComposerProject,
    +      BRAND_ASSET_PATHS.productionIconComposerProject,
    +    ]).toEqual([
    +      "assets/dev/app-icon.icon",
    +      "assets/nightly/app-icon.icon",
    +      "assets/prod/app-icon.icon",
    +    ]);
    +    expect(BRAND_ASSET_PATHS.developmentDesktopIconPng).toMatch(/^assets\/dev\/blueprint-/);
    +    expect(BRAND_ASSET_PATHS.nightlyMacIconPng).toMatch(/^assets\/nightly\/nightly-/);
    +    expect(BRAND_ASSET_PATHS.productionMacIconPng).toMatch(/^assets\/prod\/black-/);
    +  });
     });
    diff --git a/scripts/lib/brand-assets.ts b/scripts/lib/brand-assets.ts
    index 87b0cab219e..fe02c975ea3 100644
    --- a/scripts/lib/brand-assets.ts
    +++ b/scripts/lib/brand-assets.ts
    @@ -1,4 +1,10 @@
     export const BRAND_ASSET_PATHS = {
    +  developmentIconComposerProject: "assets/dev/app-icon.icon",
    +  developmentIosIconPng: "assets/dev/blueprint-ios-1024.png",
    +  developmentUniversalIconPng: "assets/dev/blueprint-universal-1024.png",
    +
    +  productionIconComposerProject: "assets/prod/app-icon.icon",
    +  productionIosIconPng: "assets/prod/black-ios-1024.png",
       productionMacIconPng: "assets/prod/black-macos-1024.png",
       productionLinuxIconPng: "assets/prod/black-universal-1024.png",
       productionWindowsIconIco: "assets/prod/t3-black-windows.ico",
    @@ -7,13 +13,15 @@ export const BRAND_ASSET_PATHS = {
       productionWebFavicon32Png: "assets/prod/t3-black-web-favicon-32x32.png",
       productionWebAppleTouchIconPng: "assets/prod/t3-black-web-apple-touch-180.png",
     
    -  nightlyMacIconPng: "assets/nightly/blueprint-macos-1024.png",
    -  nightlyLinuxIconPng: "assets/nightly/blueprint-universal-1024.png",
    -  nightlyWindowsIconIco: "assets/nightly/blueprint-windows.ico",
    -  nightlyWebFaviconIco: "assets/nightly/blueprint-web-favicon.ico",
    -  nightlyWebFavicon16Png: "assets/nightly/blueprint-web-favicon-16x16.png",
    -  nightlyWebFavicon32Png: "assets/nightly/blueprint-web-favicon-32x32.png",
    -  nightlyWebAppleTouchIconPng: "assets/nightly/blueprint-web-apple-touch-180.png",
    +  nightlyIconComposerProject: "assets/nightly/app-icon.icon",
    +  nightlyIosIconPng: "assets/nightly/nightly-ios-1024.png",
    +  nightlyMacIconPng: "assets/nightly/nightly-macos-1024.png",
    +  nightlyLinuxIconPng: "assets/nightly/nightly-universal-1024.png",
    +  nightlyWindowsIconIco: "assets/nightly/nightly-windows.ico",
    +  nightlyWebFaviconIco: "assets/nightly/nightly-web-favicon.ico",
    +  nightlyWebFavicon16Png: "assets/nightly/nightly-web-favicon-16x16.png",
    +  nightlyWebFavicon32Png: "assets/nightly/nightly-web-favicon-32x32.png",
    +  nightlyWebAppleTouchIconPng: "assets/nightly/nightly-web-apple-touch-180.png",
     
       developmentDesktopIconPng: "assets/dev/blueprint-macos-1024.png",
       developmentWindowsIconIco: "assets/dev/blueprint-windows.ico",
    diff --git a/scripts/lib/icon-export.test.ts b/scripts/lib/icon-export.test.ts
    new file mode 100644
    index 00000000000..3db6f22cbdb
    --- /dev/null
    +++ b/scripts/lib/icon-export.test.ts
    @@ -0,0 +1,47 @@
    +import { assert, describe, it } from "@effect/vitest";
    +
    +import { encodePngIco, readPngDimensions } from "./icon-export.ts";
    +
    +const pngHeader = (width: number, height: number) => {
    +  const contents = Buffer.alloc(24);
    +  Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(contents);
    +  contents.write("IHDR", 12, "ascii");
    +  contents.writeUInt32BE(width, 16);
    +  contents.writeUInt32BE(height, 20);
    +  return contents;
    +};
    +
    +describe("icon export", () => {
    +  it("reads dimensions from a PNG IHDR chunk", () => {
    +    assert.deepEqual(readPngDimensions(pngHeader(1024, 512)), { width: 1024, height: 512 });
    +  });
    +
    +  it("encodes PNG renditions into an ICO directory", () => {
    +    const small = pngHeader(16, 16);
    +    const large = pngHeader(256, 256);
    +    const ico = encodePngIco([
    +      { size: 16, contents: small },
    +      { size: 256, contents: large },
    +    ]);
    +
    +    assert.equal(ico.readUInt16LE(2), 1);
    +    assert.equal(ico.readUInt16LE(4), 2);
    +    assert.equal(ico.readUInt8(6), 16);
    +    assert.equal(ico.readUInt8(22), 0);
    +    assert.equal(ico.readUInt32LE(18), 38);
    +    assert.equal(ico.readUInt32LE(34), 38 + small.length);
    +    assert.deepEqual(ico.subarray(38, 38 + small.length), small);
    +    assert.deepEqual(ico.subarray(38 + small.length), large);
    +  });
    +
    +  it("rejects duplicate ICO rendition sizes", () => {
    +    assert.throws(
    +      () =>
    +        encodePngIco([
    +          { size: 32, contents: pngHeader(32, 32) },
    +          { size: 32, contents: pngHeader(32, 32) },
    +        ]),
    +      /provided more than once/,
    +    );
    +  });
    +});
    diff --git a/scripts/lib/icon-export.ts b/scripts/lib/icon-export.ts
    new file mode 100644
    index 00000000000..f48228a94fd
    --- /dev/null
    +++ b/scripts/lib/icon-export.ts
    @@ -0,0 +1,72 @@
    +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
    +
    +export const WINDOWS_ICON_SIZES = [16, 24, 32, 48, 64, 128, 256] as const;
    +
    +export interface PngIconImage {
    +  readonly size: number;
    +  readonly contents: Buffer;
    +}
    +
    +export function readPngDimensions(contents: Buffer): {
    +  readonly width: number;
    +  readonly height: number;
    +} {
    +  if (
    +    contents.length < 24 ||
    +    !contents.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE) ||
    +    contents.toString("ascii", 12, 16) !== "IHDR"
    +  ) {
    +    throw new Error("Icon Composer produced an invalid PNG.");
    +  }
    +
    +  return {
    +    width: contents.readUInt32BE(16),
    +    height: contents.readUInt32BE(20),
    +  };
    +}
    +
    +/** Encodes PNG renditions directly into a modern, multi-resolution ICO file. */
    +export function encodePngIco(images: ReadonlyArray): Buffer {
    +  if (images.length === 0) {
    +    throw new Error("An ICO file requires at least one PNG rendition.");
    +  }
    +
    +  const seenSizes = new Set();
    +  for (const image of images) {
    +    if (!Number.isInteger(image.size) || image.size < 1 || image.size > 256) {
    +      throw new Error(`ICO rendition size must be an integer from 1 to 256, got ${image.size}.`);
    +    }
    +    if (seenSizes.has(image.size)) {
    +      throw new Error(`ICO rendition size ${image.size} was provided more than once.`);
    +    }
    +    if (image.contents.length === 0) {
    +      throw new Error(`ICO rendition ${image.size}x${image.size} is empty.`);
    +    }
    +    seenSizes.add(image.size);
    +  }
    +
    +  const headerSize = 6;
    +  const directoryEntrySize = 16;
    +  const directorySize = directoryEntrySize * images.length;
    +  const header = Buffer.alloc(headerSize + directorySize);
    +  header.writeUInt16LE(0, 0);
    +  header.writeUInt16LE(1, 2);
    +  header.writeUInt16LE(images.length, 4);
    +
    +  let imageOffset = header.length;
    +  images.forEach((image, index) => {
    +    const entryOffset = headerSize + index * directoryEntrySize;
    +    const encodedSize = image.size === 256 ? 0 : image.size;
    +    header.writeUInt8(encodedSize, entryOffset);
    +    header.writeUInt8(encodedSize, entryOffset + 1);
    +    header.writeUInt8(0, entryOffset + 2);
    +    header.writeUInt8(0, entryOffset + 3);
    +    header.writeUInt16LE(1, entryOffset + 4);
    +    header.writeUInt16LE(32, entryOffset + 6);
    +    header.writeUInt32LE(image.contents.length, entryOffset + 8);
    +    header.writeUInt32LE(imageOffset, entryOffset + 12);
    +    imageOffset += image.contents.length;
    +  });
    +
    +  return Buffer.concat([header, ...images.map((image) => image.contents)]);
    +}
    diff --git a/scripts/mobile-showcase-environment.ts b/scripts/mobile-showcase-environment.ts
    new file mode 100644
    index 00000000000..c42239e427f
    --- /dev/null
    +++ b/scripts/mobile-showcase-environment.ts
    @@ -0,0 +1,569 @@
    +// @effect-diagnostics nodeBuiltinImport: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";
    +import * as NodeSqlite from "node:sqlite";
    +import * as NodeUtil from "node:util";
    +
    +const execFile = NodeUtil.promisify(NodeChildProcess.execFile);
    +
    +export const SHOWCASE_PROJECT_ID = "t3code";
    +export const SHOWCASE_THREAD_ID = "remote-command-center";
    +export const SHOWCASE_TERMINAL_ID = "term-1";
    +
    +export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const;
    +export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number];
    +
    +const PROJECTOR_NAMES = [
    +  "projection.projects",
    +  "projection.threads",
    +  "projection.thread-messages",
    +  "projection.thread-proposed-plans",
    +  "projection.thread-activities",
    +  "projection.thread-sessions",
    +  "projection.thread-turns",
    +  "projection.checkpoints",
    +  "projection.pending-approvals",
    +] as const;
    +
    +const MODEL_SELECTION = JSON.stringify({ instanceId: "codex", model: "gpt-5.4" });
    +const PROJECT_SCRIPTS = JSON.stringify([
    +  {
    +    id: "dev",
    +    name: "Dev",
    +    command: "pnpm dev",
    +    icon: "play",
    +    runOnWorktreeCreate: false,
    +  },
    +  {
    +    id: "test",
    +    name: "Tests",
    +    command: "pnpm test",
    +    icon: "test",
    +    runOnWorktreeCreate: false,
    +  },
    +]);
    +
    +export const SHOWCASE_TERMINAL_BUFFER = [
    +  "\u001b[38;5;75m~/Code/t3code\u001b[0m \u001b[38;5;212mfeat/remote-command-center\u001b[0m",
    +  "$ vp test run --changed",
    +  "",
    +  "  \u001b[38;5;117mt3code-mobile\u001b[0m       184 passed",
    +  "  \u001b[38;5;213mclient-runtime\u001b[0m      263 passed",
    +  "  \u001b[38;5;221mserver\u001b[0m              165 passed",
    +  "",
    +  "\u001b[32m✨ 612 tests passed\u001b[0m  ·  3 environments online",
    +  "",
    +  "\u001b[38;5;75m~/Code/t3code\u001b[0m \u001b[38;5;212mfeat/remote-command-center\u001b[0m $ ",
    +].join("\r\n");
    +
    +const BASE_ENVIRONMENT_PRESENCE = `export function environmentLabel(count: number): string {
    +  return \`${"${count}"} environments\`;
    +}
    +`;
    +
    +const UPDATED_ENVIRONMENT_PRESENCE = `const PULSE = ["✦", "✧", "·", "✧"] as const;
    +
    +export function environmentLabel(connected: number, total: number, frame: number): string {
    +  const pulse = PULSE[frame % PULSE.length];
    +  return \`${"${pulse} ${connected}/${total}"} ready\`;
    +}
    +`;
    +
    +const REMOTE_HANDOFF_CARD = `import { View, Text } from "react-native";
    +
    +export function RemoteHandoffCard(props: { machine: string; latencyMs: number }) {
    +  return (
    +    
    +      Ready on {props.machine}
    +      Handoff in {props.latencyMs}ms
    +    
    +  );
    +}
    +`;
    +
    +const PROJECT_FAVICONS = {
    +  t3code: `
    +  
    +  
    +`,
    +  react: `
    +  
    +  
    +  
    +`,
    +  linux: `
    +  
    +  
    +  
    +  
    +  
    +  
    +`,
    +} as const;
    +
    +export const SHOWCASE_PROJECTS = [
    +  {
    +    id: "t3code",
    +    title: "T3 Code",
    +    directory: "t3code",
    +    repositoryUrl: "https://github.com/pingdotgg/t3code.git",
    +    favicon: PROJECT_FAVICONS.t3code,
    +  },
    +  {
    +    id: "react",
    +    title: "React",
    +    directory: "react",
    +    repositoryUrl: "https://github.com/facebook/react.git",
    +    favicon: PROJECT_FAVICONS.react,
    +  },
    +  {
    +    id: "linux",
    +    title: "Linux",
    +    directory: "linux",
    +    repositoryUrl: "https://github.com/torvalds/linux.git",
    +    favicon: PROJECT_FAVICONS.linux,
    +  },
    +] as const;
    +
    +export const SHOWCASE_ENVIRONMENTS = [
    +  {
    +    id: "moonbase-terminal",
    +    label: "Moonbase Terminal",
    +    projectIds: ["t3code"],
    +  },
    +  {
    +    id: "suspense-station",
    +    label: "Suspense Station",
    +    projectIds: ["react"],
    +  },
    +  {
    +    id: "kernel-cabin",
    +    label: "Kernel Cabin",
    +    projectIds: ["linux"],
    +  },
    +] as const;
    +
    +export const SHOWCASE_THREADS = [
    +  {
    +    id: SHOWCASE_THREAD_ID,
    +    projectId: "t3code",
    +    title: "Make remote coding feel local ✦",
    +    branch: "feat/remote-command-center",
    +    minutesAgo: 3,
    +    request:
    +      "Give T3 Code a remote-first command center. Make three machines feel one tap away, keep agent work in sync, and make every handoff feel instant.",
    +    response:
    +      "T3 Code now treats every machine like it is right here in the room. ✦\n\n- Moonbase, Suspense Station, and Kernel Cabin stay live together\n- Terminal state follows you without losing a single line\n- Agent work remains perfectly in sync across devices\n- Handoffs land before your train of thought can wander\n\nI also ran the changed workspace: **612 tests passed**.",
    +  },
    +  {
    +    id: "pocket-command-center",
    +    projectId: "t3code",
    +    title: "Put the command center in your pocket",
    +    branch: "feat/pocket-command-center",
    +    minutesAgo: 21,
    +    state: "approval" as const,
    +    request: "Make switching between desktop, phone, and tablet feel like one continuous session.",
    +    response:
    +      "The handoff flow preserves the selected thread, terminal buffer, and working diff. The final motion treatment is ready for approval.",
    +  },
    +  {
    +    id: "buttery-suspense",
    +    projectId: "react",
    +    title: "Make Suspense transitions buttery",
    +    branch: "perf/buttery-suspense",
    +    minutesAgo: 12,
    +    state: "working" as const,
    +    request:
    +      "Trace the last few dropped frames in nested Suspense transitions and make them disappear.",
    +    response: null,
    +  },
    +  {
    +    id: "hydration-haikus",
    +    projectId: "react",
    +    title: "Turn hydration warnings into haikus",
    +    branch: "dev/hydration-haikus",
    +    minutesAgo: 44,
    +    request:
    +      "Keep hydration errors precise, but make the development copy unexpectedly delightful.",
    +    response:
    +      "The diagnostics still lead with the exact mismatch and component stack. A tiny optional haiku now closes the expanded explanation.",
    +  },
    +  {
    +    id: "beautiful-boot",
    +    projectId: "linux",
    +    title: "Make boot logs oddly beautiful",
    +    branch: "feat/beautiful-boot",
    +    minutesAgo: 34,
    +    state: "plan" as const,
    +    request:
    +      "Design a clearer boot timeline that remains useful over serial and never hides kernel detail.",
    +    response:
    +      "The plan groups milestones without changing the underlying log stream, preserves plain-text output, and adds zero work to the hot path.",
    +  },
    +  {
    +    id: "scheduler-breathe",
    +    projectId: "linux",
    +    title: "Let the scheduler breathe",
    +    branch: "perf/scheduler-breathe",
    +    minutesAgo: 76,
    +    request:
    +      "Find a calmer balancing strategy for bursty mixed workloads without hurting tail latency.",
    +    response:
    +      "The new heuristic reduces needless migrations during short bursts while preserving the existing latency guardrails.",
    +  },
    +] as const;
    +
    +function minutesBefore(now: number, minutes: number): string {
    +  return new Date(now - minutes * 60_000).toISOString();
    +}
    +
    +async function runGit(workspaceRoot: string, args: ReadonlyArray): Promise {
    +  await execFile("git", [...args], {
    +    cwd: workspaceRoot,
    +    env: {
    +      ...process.env,
    +      GIT_AUTHOR_NAME: "Alex Rivera",
    +      GIT_AUTHOR_EMAIL: "alex@lumen.test",
    +      GIT_COMMITTER_NAME: "Alex Rivera",
    +      GIT_COMMITTER_EMAIL: "alex@lumen.test",
    +    },
    +  });
    +}
    +
    +async function initializeRepository(input: {
    +  readonly workspaceRoot: string;
    +  readonly repositoryUrl: string;
    +  readonly commitMessage: string;
    +}): Promise {
    +  await runGit(input.workspaceRoot, ["init", "-b", "main"]);
    +  await runGit(input.workspaceRoot, ["remote", "add", "origin", input.repositoryUrl]);
    +  await runGit(input.workspaceRoot, ["add", "."]);
    +  await runGit(input.workspaceRoot, ["commit", "-m", input.commitMessage]);
    +}
    +
    +async function seedT3CodeWorkspace(workspaceRoot: string): Promise {
    +  await NodeFSP.mkdir(NodePath.join(workspaceRoot, "apps/mobile/src/features/home"), {
    +    recursive: true,
    +  });
    +  await NodeFSP.writeFile(
    +    NodePath.join(workspaceRoot, "package.json"),
    +    `${JSON.stringify({ name: "t3code", private: true, scripts: { test: "vp test" } }, null, 2)}\n`,
    +  );
    +  await NodeFSP.writeFile(NodePath.join(workspaceRoot, "favicon.svg"), PROJECT_FAVICONS.t3code);
    +  await NodeFSP.writeFile(
    +    NodePath.join(workspaceRoot, "apps/mobile/src/features/home/environmentPresence.ts"),
    +    BASE_ENVIRONMENT_PRESENCE,
    +  );
    +  await initializeRepository({
    +    workspaceRoot,
    +    repositoryUrl: "https://github.com/pingdotgg/t3code.git",
    +    commitMessage: "Show connected environments",
    +  });
    +  await runGit(workspaceRoot, ["checkout", "-b", "feat/remote-command-center"]);
    +  await NodeFSP.writeFile(
    +    NodePath.join(workspaceRoot, "apps/mobile/src/features/home/environmentPresence.ts"),
    +    UPDATED_ENVIRONMENT_PRESENCE,
    +  );
    +  await NodeFSP.writeFile(
    +    NodePath.join(workspaceRoot, "apps/mobile/src/features/home/RemoteHandoffCard.tsx"),
    +    REMOTE_HANDOFF_CARD,
    +  );
    +}
    +
    +async function seedCompanionWorkspace(input: {
    +  readonly workspaceRoot: string;
    +  readonly title: string;
    +  readonly repositoryUrl: string;
    +  readonly favicon: string;
    +}): Promise {
    +  await NodeFSP.mkdir(input.workspaceRoot, { recursive: true });
    +  await NodeFSP.writeFile(NodePath.join(input.workspaceRoot, "favicon.svg"), input.favicon);
    +  await NodeFSP.writeFile(
    +    NodePath.join(input.workspaceRoot, "README.md"),
    +    `# ${input.title}\n\nSeeded by the T3 Code mobile screenshot harness.\n`,
    +  );
    +  await initializeRepository({
    +    workspaceRoot: input.workspaceRoot,
    +    repositoryUrl: input.repositoryUrl,
    +    commitMessage: `Seed ${input.title} workspace`,
    +  });
    +}
    +
    +function insertThread(
    +  database: NodeSqlite.DatabaseSync,
    +  now: number,
    +  input: {
    +    readonly id: string;
    +    readonly projectId: string;
    +    readonly title: string;
    +    readonly branch: string;
    +    readonly minutesAgo: number;
    +    readonly state?: "working" | "approval" | "plan";
    +    readonly workspaceRoot: string;
    +  },
    +): void {
    +  const turnId = `${input.id}-turn`;
    +  const updatedAt = minutesBefore(now, input.minutesAgo);
    +  const isWorking = input.state === "working";
    +  database
    +    .prepare(
    +      `INSERT INTO projection_threads (
    +        thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode,
    +        branch, worktree_path, latest_turn_id, latest_user_message_at, pending_approval_count,
    +        pending_user_input_count, has_actionable_proposed_plan, created_at, updated_at,
    +        archived_at, deleted_at
    +      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, NULL, NULL)`,
    +    )
    +    .run(
    +      input.id,
    +      input.projectId,
    +      input.title,
    +      MODEL_SELECTION,
    +      "full-access",
    +      input.state === "plan" ? "plan" : "default",
    +      input.branch,
    +      input.workspaceRoot,
    +      turnId,
    +      minutesBefore(now, input.minutesAgo + 1),
    +      input.state === "approval" ? 1 : 0,
    +      input.state === "plan" ? 1 : 0,
    +      minutesBefore(now, input.minutesAgo + 120),
    +      updatedAt,
    +    );
    +  database
    +    .prepare(
    +      `INSERT INTO projection_turns (
    +        thread_id, turn_id, pending_message_id, assistant_message_id, state, requested_at,
    +        started_at, completed_at, checkpoint_turn_count, checkpoint_ref, checkpoint_status,
    +        checkpoint_files_json, source_proposed_plan_thread_id, source_proposed_plan_id
    +      ) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, NULL, NULL, NULL, '[]', NULL, NULL)`,
    +    )
    +    .run(
    +      input.id,
    +      turnId,
    +      isWorking ? null : `${input.id}-answer`,
    +      isWorking ? "running" : "completed",
    +      minutesBefore(now, input.minutesAgo + 2),
    +      minutesBefore(now, input.minutesAgo + 2),
    +      isWorking ? null : updatedAt,
    +    );
    +  database
    +    .prepare(
    +      `INSERT INTO projection_thread_sessions (
    +        thread_id, status, provider_name, provider_instance_id, provider_session_id,
    +        provider_thread_id, runtime_mode, active_turn_id, last_error, updated_at
    +      ) VALUES (?, ?, 'Codex', 'codex', NULL, NULL, 'full-access', ?, NULL, ?)`,
    +    )
    +    .run(input.id, isWorking ? "running" : "ready", isWorking ? turnId : null, updatedAt);
    +}
    +
    +function seedDatabase(
    +  dbPath: string,
    +  workspaceRoots: ReadonlyMap,
    +  projects: ReadonlyArray<(typeof SHOWCASE_PROJECTS)[number]>,
    +  threads: ReadonlyArray<(typeof SHOWCASE_THREADS)[number]>,
    +  now: number,
    +): void {
    +  const database = new NodeSqlite.DatabaseSync(dbPath);
    +  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",
    +    ]) {
    +      database.exec(`DELETE FROM ${table}`);
    +    }
    +    const insertProject = database.prepare(
    +      `INSERT INTO projection_projects (
    +          project_id, title, workspace_root, default_model_selection_json, scripts_json,
    +          created_at, updated_at, deleted_at
    +        ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL)`,
    +    );
    +    for (const [index, project] of projects.entries()) {
    +      const workspaceRoot = workspaceRoots.get(project.id);
    +      if (!workspaceRoot) throw new Error(`Missing workspace root for ${project.id}.`);
    +      const latestThreadMinutes = Math.min(
    +        ...threads
    +          .filter((thread) => thread.projectId === project.id)
    +          .map((thread) => thread.minutesAgo),
    +      );
    +      insertProject.run(
    +        project.id,
    +        project.title,
    +        workspaceRoot,
    +        MODEL_SELECTION,
    +        PROJECT_SCRIPTS,
    +        minutesBefore(now, 60 * 24 * (90 - index * 12)),
    +        minutesBefore(now, latestThreadMinutes),
    +      );
    +    }
    +
    +    for (const thread of threads) {
    +      const workspaceRoot = workspaceRoots.get(thread.projectId);
    +      if (!workspaceRoot) throw new Error(`Missing workspace root for ${thread.projectId}.`);
    +      insertThread(database, now, {
    +        ...thread,
    +        ...("state" in thread ? { state: thread.state } : {}),
    +        workspaceRoot,
    +      });
    +    }
    +
    +    const insertMessage = database.prepare(
    +      `INSERT INTO projection_thread_messages (
    +        message_id, thread_id, turn_id, role, text, is_streaming, attachments_json,
    +        created_at, updated_at
    +      ) VALUES (?, ?, ?, ?, ?, 0, NULL, ?, ?)`,
    +    );
    +    for (const thread of threads) {
    +      const turnId = `${thread.id}-turn`;
    +      const requestTime = minutesBefore(now, thread.minutesAgo + 5);
    +      insertMessage.run(
    +        `${thread.id}-request`,
    +        thread.id,
    +        turnId,
    +        "user",
    +        thread.request,
    +        requestTime,
    +        requestTime,
    +      );
    +      if (thread.response !== null) {
    +        const responseTime = minutesBefore(now, thread.minutesAgo);
    +        insertMessage.run(
    +          `${thread.id}-answer`,
    +          thread.id,
    +          turnId,
    +          "assistant",
    +          thread.response,
    +          responseTime,
    +          responseTime,
    +        );
    +      }
    +    }
    +
    +    const turnId = `${SHOWCASE_THREAD_ID}-turn`;
    +    const insertActivity = database.prepare(
    +      `INSERT INTO projection_thread_activities (
    +        activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at
    +      ) VALUES (?, ?, ?, 'tool', 'tool.completed', ?, ?, ?, ?)`,
    +    );
    +    insertActivity.run(
    +      "trace-remote-handoff",
    +      SHOWCASE_THREAD_ID,
    +      turnId,
    +      "Traced the remote handoff path",
    +      JSON.stringify({
    +        itemType: "command_execution",
    +        title: "Traced the remote handoff path",
    +        detail: "Three environments, one continuous workspace",
    +        status: "completed",
    +      }),
    +      1,
    +      minutesBefore(now, 8),
    +    );
    +    insertActivity.run(
    +      "sync-command-center",
    +      SHOWCASE_THREAD_ID,
    +      turnId,
    +      "Synced the command center",
    +      JSON.stringify({
    +        itemType: "file_change",
    +        title: "Synced the command center",
    +        detail: "2 files changed · instant handoffs · calm reconnects",
    +        status: "completed",
    +      }),
    +      2,
    +      minutesBefore(now, 6),
    +    );
    +    insertActivity.run(
    +      "run-changed-suite",
    +      SHOWCASE_THREAD_ID,
    +      turnId,
    +      "Ran the changed workspace",
    +      JSON.stringify({
    +        itemType: "command_execution",
    +        title: "Ran the changed workspace",
    +        detail: "612 tests passed · 3 environments online",
    +        status: "completed",
    +      }),
    +      3,
    +      minutesBefore(now, 4),
    +    );
    +
    +    for (const [index, projector] of PROJECTOR_NAMES.entries()) {
    +      database
    +        .prepare(
    +          "INSERT INTO projection_state (projector, last_applied_sequence, updated_at) VALUES (?, ?, ?)",
    +        )
    +        .run(projector, index + 1, minutesBefore(now, 1));
    +    }
    +    database.exec("COMMIT");
    +  } catch (error) {
    +    database.exec("ROLLBACK");
    +    throw error;
    +  } finally {
    +    database.close();
    +  }
    +}
    +
    +export async function seedShowcaseEnvironment(input: {
    +  readonly baseDir: string;
    +  readonly projectIds?: ReadonlyArray;
    +  readonly now?: number;
    +}): Promise<{ readonly dbPath: string; readonly workspaceRoot: string }> {
    +  const now = input.now ?? Date.now();
    +  const selectedProjectIds = new Set(
    +    input.projectIds ?? SHOWCASE_PROJECTS.map((project) => project.id),
    +  );
    +  const projects = SHOWCASE_PROJECTS.filter((project) => selectedProjectIds.has(project.id));
    +  if (projects.length === 0) throw new Error("At least one showcase project must be selected.");
    +  const threads = SHOWCASE_THREADS.filter((thread) => selectedProjectIds.has(thread.projectId));
    +  const workspaceBase = NodePath.join(input.baseDir, "workspace");
    +  const workspaceRoots = new Map(
    +    projects.map(
    +      (project) => [project.id, NodePath.join(workspaceBase, project.directory)] as const,
    +    ),
    +  );
    +  const primaryProject =
    +    projects.find((project) => project.id === SHOWCASE_PROJECT_ID) ?? projects[0];
    +  if (!primaryProject) throw new Error("The primary showcase workspace is not configured.");
    +  const workspaceRoot = workspaceRoots.get(primaryProject.id);
    +  if (!workspaceRoot) throw new Error("The primary showcase workspace is not configured.");
    +  const dbPath = NodePath.join(input.baseDir, "userdata", "state.sqlite");
    +  if (primaryProject.id === SHOWCASE_PROJECT_ID) {
    +    await seedT3CodeWorkspace(workspaceRoot);
    +  }
    +  await Promise.all(
    +    projects
    +      .filter((project) => project.id !== SHOWCASE_PROJECT_ID)
    +      .map(async (project) => {
    +        const projectWorkspaceRoot = workspaceRoots.get(project.id);
    +        if (!projectWorkspaceRoot) throw new Error(`Missing workspace root for ${project.id}.`);
    +        await seedCompanionWorkspace({
    +          workspaceRoot: projectWorkspaceRoot,
    +          title: project.title,
    +          repositoryUrl: project.repositoryUrl,
    +          favicon: project.favicon,
    +        });
    +      }),
    +  );
    +  seedDatabase(dbPath, workspaceRoots, projects, threads, now);
    +
    +  const terminalDirectory = NodePath.join(input.baseDir, "userdata", "logs", "terminals");
    +  if (selectedProjectIds.has(SHOWCASE_PROJECT_ID)) {
    +    const safeThreadId = Buffer.from(SHOWCASE_THREAD_ID).toString("base64url");
    +    await NodeFSP.mkdir(terminalDirectory, { recursive: true });
    +    await NodeFSP.writeFile(
    +      NodePath.join(terminalDirectory, `terminal_${safeThreadId}.log`),
    +      SHOWCASE_TERMINAL_BUFFER,
    +    );
    +  }
    +  return { dbPath, workspaceRoot };
    +}
    diff --git a/scripts/mobile-showcase.config.ts b/scripts/mobile-showcase.config.ts
    new file mode 100644
    index 00000000000..3237933bec8
    --- /dev/null
    +++ b/scripts/mobile-showcase.config.ts
    @@ -0,0 +1,206 @@
    +import { SHOWCASE_SCENES, type ShowcaseScene } from "./mobile-showcase-environment.ts";
    +
    +export { SHOWCASE_SCENES };
    +export type { ShowcaseScene };
    +
    +export type ShowcaseAppearance = "light" | "dark";
    +
    +export interface ShowcaseStoreAssetSpec {
    +  readonly store: "apple" | "google-play";
    +  /** Device directory relative to ShowcaseConfig.outputDirectory. */
    +  readonly directory: string;
    +  readonly width: number;
    +  readonly height: number;
    +  readonly minimumUploadCount: number;
    +  readonly maximumUploadCount: number;
    +  readonly maximumFileSizeBytes?: number;
    +}
    +
    +export interface ShowcaseIosDevice {
    +  readonly id: string;
    +  readonly platform: "ios";
    +  /** Exact name from `xcrun simctl list devices available`. */
    +  readonly simulator: string;
    +  /** Device type used to create a disposable simulator when the named one is absent. */
    +  readonly simulatorDeviceType?: string;
    +  /** Appearance used when the CLI does not pass --appearance. */
    +  readonly appearance: ShowcaseAppearance;
    +  readonly scenes: ReadonlyArray;
    +  readonly storeAsset: ShowcaseStoreAssetSpec;
    +}
    +
    +export interface ShowcaseAndroidDevice {
    +  readonly id: string;
    +  readonly platform: "android";
    +  /** Exact name from `emulator -list-avds`. */
    +  readonly avd: string;
    +  /** Appearance used when the CLI does not pass --appearance. */
    +  readonly appearance: ShowcaseAppearance;
    +  /** Native ABI used by the AVD, from its config.ini `abi.type`. */
    +  readonly abi?: "arm64-v8a" | "x86_64" | "x86" | "armeabi-v7a";
    +  readonly scenes: ReadonlyArray;
    +  /** Optional capture viewport. Omit to use the AVD's native size and density. */
    +  readonly viewport?: {
    +    readonly width: number;
    +    readonly height: number;
    +    readonly density?: number;
    +  };
    +  readonly storeAsset: ShowcaseStoreAssetSpec;
    +}
    +
    +export type ShowcaseDevice = ShowcaseIosDevice | ShowcaseAndroidDevice;
    +
    +export interface ShowcaseConfig {
    +  readonly outputDirectory: string;
    +  readonly metroPort: number;
    +  readonly settleDelayMs: number;
    +  readonly devices: ReadonlyArray;
    +}
    +
    +const ANDROID_ABIS = ["arm64-v8a", "x86_64", "x86", "armeabi-v7a"] as const;
    +
    +export function resolveShowcaseAndroidAbi(
    +  value: string | undefined,
    +): NonNullable {
    +  if (!value) return "arm64-v8a";
    +  if (ANDROID_ABIS.some((abi) => abi === value)) {
    +    return value as NonNullable;
    +  }
    +  throw new Error(
    +    `Unsupported T3_SHOWCASE_ANDROID_ABI '${value}'. Use ${ANDROID_ABIS.join(", ")}.`,
    +  );
    +}
    +
    +/**
    + * The defaults cover every App Store Connect and Google Play upload slot used
    + * by the mobile app. Edit this matrix (or pass --device / --scene) without
    + * changing the runner. Every target declares and validates its exact upload
    + * dimensions so SDK or emulator changes cannot silently produce invalid files.
    + */
    +const config: ShowcaseConfig = {
    +  outputDirectory: "artifacts/app-store/screenshots",
    +  // Dedicated port so the harness cannot attach to a normal mobile dev server
    +  // (or a second worktree) and capture the wrong bundle.
    +  metroPort: 8199,
    +  settleDelayMs: 2_500,
    +  devices: [
    +    {
    +      id: "iphone-6.9",
    +      platform: "ios",
    +      simulator: "iPhone 17 Pro Max",
    +      simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max",
    +      appearance: "dark",
    +      scenes: ["thread", "terminal", "review", "threads", "environments"],
    +      storeAsset: {
    +        store: "apple",
    +        directory: "apple/iphone-6.9",
    +        width: 1320,
    +        height: 2868,
    +        minimumUploadCount: 1,
    +        maximumUploadCount: 10,
    +      },
    +    },
    +    {
    +      id: "iphone-6.5",
    +      platform: "ios",
    +      simulator: "T3 Showcase iPhone 14 Plus",
    +      simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-14-Plus",
    +      appearance: "dark",
    +      scenes: ["thread", "terminal", "review", "threads", "environments"],
    +      storeAsset: {
    +        store: "apple",
    +        directory: "apple/iphone-6.5",
    +        width: 1284,
    +        height: 2778,
    +        minimumUploadCount: 1,
    +        maximumUploadCount: 10,
    +      },
    +    },
    +    {
    +      id: "ipad-13",
    +      platform: "ios",
    +      simulator: "iPad Pro 13-inch (M5)",
    +      simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5-16GB",
    +      appearance: "dark",
    +      scenes: ["thread", "terminal", "review", "threads", "environments"],
    +      storeAsset: {
    +        store: "apple",
    +        directory: "apple/ipad-13",
    +        width: 2064,
    +        height: 2752,
    +        minimumUploadCount: 1,
    +        maximumUploadCount: 10,
    +      },
    +    },
    +    {
    +      id: "pixel",
    +      platform: "android",
    +      avd: "Pixel_10_Pro",
    +      // Apple Silicon uses ARM64 locally; CI overrides this with x86_64 so its
    +      // Blacksmith Linux runner can use KVM acceleration.
    +      abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI),
    +      appearance: "dark",
    +      viewport: {
    +        width: 1080,
    +        height: 1920,
    +        density: 420,
    +      },
    +      scenes: ["thread", "terminal", "review", "threads", "environments"],
    +      storeAsset: {
    +        store: "google-play",
    +        directory: "google-play/phone",
    +        width: 1080,
    +        height: 1920,
    +        minimumUploadCount: 2,
    +        maximumUploadCount: 8,
    +        maximumFileSizeBytes: 8 * 1024 * 1024,
    +      },
    +    },
    +    {
    +      id: "android-tablet-7",
    +      platform: "android",
    +      avd: "Pixel_10_Pro",
    +      abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI),
    +      appearance: "dark",
    +      viewport: {
    +        width: 1080,
    +        height: 1920,
    +        density: 288,
    +      },
    +      scenes: ["thread", "terminal", "review", "threads", "environments"],
    +      storeAsset: {
    +        store: "google-play",
    +        directory: "google-play/tablet-7",
    +        width: 1080,
    +        height: 1920,
    +        minimumUploadCount: 4,
    +        maximumUploadCount: 8,
    +        maximumFileSizeBytes: 8 * 1024 * 1024,
    +      },
    +    },
    +    {
    +      id: "android-tablet-10",
    +      platform: "android",
    +      avd: "Pixel_10_Pro",
    +      abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI),
    +      appearance: "dark",
    +      viewport: {
    +        width: 1440,
    +        height: 2560,
    +        density: 288,
    +      },
    +      scenes: ["thread", "terminal", "review", "threads", "environments"],
    +      storeAsset: {
    +        store: "google-play",
    +        directory: "google-play/tablet-10",
    +        width: 1440,
    +        height: 2560,
    +        minimumUploadCount: 4,
    +        maximumUploadCount: 8,
    +        maximumFileSizeBytes: 8 * 1024 * 1024,
    +      },
    +    },
    +  ],
    +};
    +
    +export default config;
    diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts
    new file mode 100644
    index 00000000000..1eaefe5ad86
    --- /dev/null
    +++ b/scripts/mobile-showcase.test.ts
    @@ -0,0 +1,296 @@
    +import { assert, it } from "@effect/vitest";
    +import { PNG } from "pngjs";
    +
    +import showcaseConfig, {
    +  resolveShowcaseAndroidAbi,
    +  type ShowcaseConfig,
    +  type ShowcaseStoreAssetSpec,
    +} from "./mobile-showcase.config.ts";
    +import {
    +  SHOWCASE_ENVIRONMENTS,
    +  SHOWCASE_PROJECTS,
    +  SHOWCASE_THREADS,
    +} from "./mobile-showcase-environment.ts";
    +import {
    +  encodeAndroidPairingUrls,
    +  normalizeStorePng,
    +  parseShowcaseCliArgs,
    +  parsePairingCredentialOutput,
    +  planShowcaseCaptures,
    +  readPngDimensions,
    +  readPngMetadata,
    +  resolveAndroidSdkRoot,
    +  selectLanIpv4Address,
    +  showcaseCaptureDirectory,
    +  showcaseSceneUrl,
    +  validateStoreAsset,
    +  validateStoreAssetCount,
    +} from "./mobile-showcase.ts";
    +
    +const appleSpec: ShowcaseStoreAssetSpec = {
    +  store: "apple",
    +  directory: "apple/iphone-test",
    +  width: 1284,
    +  height: 2778,
    +  minimumUploadCount: 1,
    +  maximumUploadCount: 10,
    +};
    +
    +const googleSpec: ShowcaseStoreAssetSpec = {
    +  store: "google-play",
    +  directory: "google-play/phone",
    +  width: 1080,
    +  height: 1920,
    +  minimumUploadCount: 2,
    +  maximumUploadCount: 8,
    +  maximumFileSizeBytes: 8 * 1024 * 1024,
    +};
    +
    +const config: ShowcaseConfig = {
    +  outputDirectory: "artifacts",
    +  metroPort: 8199,
    +  settleDelayMs: 1,
    +  devices: [
    +    {
    +      id: "phone",
    +      platform: "ios",
    +      simulator: "iPhone Test",
    +      appearance: "dark",
    +      scenes: ["thread", "review"],
    +      storeAsset: appleSpec,
    +    },
    +    {
    +      id: "pixel",
    +      platform: "android",
    +      avd: "Pixel_Test",
    +      appearance: "light",
    +      scenes: ["thread", "terminal"],
    +      storeAsset: googleSpec,
    +    },
    +  ],
    +};
    +
    +it("parses repeatable capture filters", () => {
    +  const options = parseShowcaseCliArgs([
    +    "--platform",
    +    "ios",
    +    "--device",
    +    "phone",
    +    "--scene",
    +    "review",
    +    "--appearance",
    +    "both",
    +    "--skip-build",
    +  ]);
    +  assert.deepStrictEqual([...options.platforms], ["ios"]);
    +  assert.deepStrictEqual([...options.deviceIds], ["phone"]);
    +  assert.deepStrictEqual([...options.scenes], ["review"]);
    +  assert.deepStrictEqual([...options.appearances], ["light", "dark"]);
    +  assert.equal(options.skipBuild, true);
    +});
    +
    +it("rejects unsupported system appearances", () => {
    +  assert.throws(
    +    () => parseShowcaseCliArgs(["--appearance", "sepia"]),
    +    /Unsupported appearance 'sepia'/u,
    +  );
    +});
    +
    +it("parses validation-only mode", () => {
    +  assert.equal(parseShowcaseCliArgs(["--validate-only"]).validateOnly, true);
    +});
    +
    +it("selects an explicit CI Android ABI without changing the local default", () => {
    +  assert.equal(resolveShowcaseAndroidAbi(undefined), "arm64-v8a");
    +  assert.equal(resolveShowcaseAndroidAbi("x86_64"), "x86_64");
    +  assert.throws(() => resolveShowcaseAndroidAbi("mips"), /Unsupported T3_SHOWCASE_ANDROID_ABI/u);
    +});
    +
    +it("uses platform-correct default Android SDK roots", () => {
    +  assert.equal(
    +    resolveAndroidSdkRoot({ HOME: "/Users/showcase" }, "darwin"),
    +    "/Users/showcase/Library/Android/sdk",
    +  );
    +  assert.equal(
    +    resolveAndroidSdkRoot({ HOME: "/home/showcase" }, "linux"),
    +    "/home/showcase/Android/Sdk",
    +  );
    +  assert.equal(
    +    resolveAndroidSdkRoot(
    +      { HOME: "/home/showcase", ANDROID_SDK_ROOT: "/opt/android-sdk" },
    +      "linux",
    +    ),
    +    "/opt/android-sdk",
    +  );
    +});
    +
    +it("plans only scenes supported by each selected device", () => {
    +  const options = parseShowcaseCliArgs(["--platform", "all", "--scene", "terminal"]);
    +  const captures = planShowcaseCaptures(config, options);
    +  assert.deepStrictEqual(
    +    captures.map((capture) => ({
    +      id: capture.device.id,
    +      appearance: capture.appearance,
    +      scenes: capture.scenes,
    +    })),
    +    [{ id: "pixel", appearance: "light", scenes: ["terminal"] }],
    +  );
    +});
    +
    +it("expands both appearances into independent upload-ready directories", () => {
    +  const options = parseShowcaseCliArgs(["--device", "phone", "--appearance", "both"]);
    +  const captures = planShowcaseCaptures(config, options);
    +
    +  assert.deepStrictEqual(
    +    captures.map((capture) => ({
    +      appearance: capture.appearance,
    +      directory: showcaseCaptureDirectory("/captures", capture),
    +    })),
    +    [
    +      { appearance: "light", directory: "/captures/apple/iphone-test/light" },
    +      { appearance: "dark", directory: "/captures/apple/iphone-test/dark" },
    +    ],
    +  );
    +});
    +
    +it("rejects unknown devices instead of silently capturing another target", () => {
    +  const options = parseShowcaseCliArgs(["--device", "missing"]);
    +  assert.throws(() => planShowcaseCaptures(config, options), /Unknown device 'missing'/u);
    +});
    +
    +it("reads captured PNG dimensions from the IHDR header", () => {
    +  const bytes = new Uint8Array(26);
    +  bytes.set([137, 80, 78, 71, 13, 10, 26, 10]);
    +  const view = new DataView(bytes.buffer);
    +  view.setUint32(16, 1320);
    +  view.setUint32(20, 2868);
    +  view.setUint8(24, 8);
    +  view.setUint8(25, 2);
    +  assert.deepStrictEqual(readPngDimensions(bytes), { width: 1320, height: 2868 });
    +  assert.deepStrictEqual(readPngMetadata(bytes), {
    +    width: 1320,
    +    height: 2868,
    +    bitDepth: 8,
    +    colorType: 2,
    +    hasAlpha: false,
    +  });
    +});
    +
    +function rgbaPng(width: number, height: number): Buffer {
    +  const png = new PNG({ width, height });
    +  png.data.fill(255);
    +  return PNG.sync.write(png);
    +}
    +
    +it("converts simulator RGBA captures to upload-safe 24-bit RGB PNGs", () => {
    +  const normalized = normalizeStorePng(rgbaPng(2, 3));
    +  assert.deepStrictEqual(readPngMetadata(normalized), {
    +    width: 2,
    +    height: 3,
    +    bitDepth: 8,
    +    colorType: 2,
    +    hasAlpha: false,
    +  });
    +});
    +
    +it("validates exact Apple and Google Play upload assets", () => {
    +  const apple = normalizeStorePng(rgbaPng(appleSpec.width, appleSpec.height));
    +  const google = normalizeStorePng(rgbaPng(googleSpec.width, googleSpec.height));
    +  assert.equal(validateStoreAsset(appleSpec, apple).width, 1284);
    +  assert.equal(validateStoreAsset(googleSpec, google).height, 1920);
    +});
    +
    +it("rejects wrong dimensions and alpha-bearing PNGs", () => {
    +  const wrongSize = normalizeStorePng(rgbaPng(1242, 2688));
    +  assert.throws(() => validateStoreAsset(appleSpec, wrongSize), /requires 1284×2778/u);
    +  assert.throws(() => validateStoreAsset(appleSpec, rgbaPng(1284, 2778)), /without alpha/u);
    +});
    +
    +it("enforces store screenshot count limits", () => {
    +  assert.doesNotThrow(() => validateStoreAssetCount(googleSpec, 5, true));
    +  assert.throws(() => validateStoreAssetCount(googleSpec, 1, true), /requires at least 2/u);
    +  assert.throws(() => validateStoreAssetCount(googleSpec, 9, false), /allows at most 8/u);
    +});
    +
    +it("configures every default device with an exact upload-ready store target", () => {
    +  assert.deepStrictEqual(
    +    showcaseConfig.devices.map((device) => [
    +      device.id,
    +      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],
    +    ],
    +  );
    +});
    +
    +it("selects a reachable LAN IPv4 address", () => {
    +  assert.equal(
    +    selectLanIpv4Address([
    +      { address: "127.0.0.1", family: "IPv4", internal: true },
    +      { address: "fe80::1", family: "IPv6", internal: false },
    +      { address: "169.254.2.4", family: "IPv4", internal: false },
    +      { address: "192.168.1.80", family: "IPv4", internal: false },
    +    ]),
    +    "192.168.1.80",
    +  );
    +});
    +
    +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("thread", "environment-1"),
    +    "t3code-dev://threads/environment-1/remote-command-center",
    +  );
    +  assert.equal(
    +    showcaseSceneUrl("terminal", "environment-1"),
    +    "t3code-dev://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",
    +  );
    +});
    +
    +it("seeds a playful multi-environment project spectrum", () => {
    +  assert.deepStrictEqual(
    +    SHOWCASE_PROJECTS.map((project) => project.title),
    +    ["T3 Code", "React", "Linux"],
    +  );
    +  assert.deepStrictEqual(
    +    SHOWCASE_ENVIRONMENTS.map((environment) => environment.label),
    +    ["Moonbase Terminal", "Suspense Station", "Kernel Cabin"],
    +  );
    +  assert.equal(SHOWCASE_THREADS.length, 6);
    +  assert.equal(new Set(SHOWCASE_THREADS.map((thread) => thread.projectId)).size, 3);
    +  assert.equal(
    +    SHOWCASE_PROJECTS.every((project) => project.favicon.includes(" {
    +  assert.equal(
    +    parsePairingCredentialOutput('server log\n{\n  "credential": "PAIR-ME"\n}\n'),
    +    "PAIR-ME",
    +  );
    +});
    +
    +it("encodes Android pairing URLs without shell-sensitive JSON quotes", () => {
    +  const urls = ["http://10.0.2.2:65164/#token=ONE", "http://10.0.2.2:65198/#token=TWO"];
    +  const encoded = encodeAndroidPairingUrls(urls);
    +  assert.equal(encoded.startsWith("json-uri:"), true);
    +  assert.deepStrictEqual(JSON.parse(decodeURIComponent(encoded.slice("json-uri:".length))), urls);
    +  assert.equal(encoded.includes('"'), false);
    +});
    diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts
    new file mode 100644
    index 00000000000..6636b380abc
    --- /dev/null
    +++ b/scripts/mobile-showcase.ts
    @@ -0,0 +1,1344 @@
    +#!/usr/bin/env node
    +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off globalDate:off - Host-side simulator and emulator automation uses Node subprocess and timing APIs directly.
    +
    +import * as NodeChildProcess from "node:child_process";
    +import * as NodeFSP from "node:fs/promises";
    +import * as NodeNet from "node:net";
    +import * as NodeOS from "node:os";
    +import * as NodePath from "node:path";
    +import * as NodeProcess from "node:process";
    +import * as NodeURL from "node:url";
    +
    +import { PNG } from "pngjs";
    +
    +import showcaseConfig, {
    +  type ShowcaseAppearance,
    +  type ShowcaseAndroidDevice,
    +  type ShowcaseConfig,
    +  type ShowcaseDevice,
    +  type ShowcaseIosDevice,
    +  type ShowcaseStoreAssetSpec,
    +  SHOWCASE_SCENES,
    +  type ShowcaseScene,
    +} from "./mobile-showcase.config.ts";
    +import {
    +  SHOWCASE_ENVIRONMENTS,
    +  SHOWCASE_PROJECTS,
    +  SHOWCASE_TERMINAL_ID,
    +  SHOWCASE_THREAD_ID,
    +  seedShowcaseEnvironment,
    +} from "./mobile-showcase-environment.ts";
    +
    +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 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",
    +);
    +const ANDROID_APK_PATH = NodePath.join(
    +  MOBILE_ROOT,
    +  "android/app/build/outputs/apk/debug/app-debug.apk",
    +);
    +export function resolveAndroidSdkRoot(
    +  environment: Readonly>,
    +  platform: NodeJS.Platform = NodeProcess.platform,
    +): string {
    +  const configured = environment.ANDROID_HOME ?? environment.ANDROID_SDK_ROOT;
    +  if (configured) return configured;
    +  const home = environment.HOME ?? environment.USERPROFILE ?? "";
    +  return NodePath.join(home, platform === "darwin" ? "Library/Android/sdk" : "Android/Sdk");
    +}
    +
    +const ANDROID_SDK_ROOT = resolveAndroidSdkRoot(NodeProcess.env);
    +const MOBILE_BUILD_ENV = {
    +  ...NodeProcess.env,
    +  ANDROID_HOME: ANDROID_SDK_ROOT,
    +  APP_VARIANT: "development",
    +  EXPO_NO_GIT_STATUS: "1",
    +  JAVA_HOME:
    +    NodeProcess.env.JAVA_HOME ??
    +    (NodeProcess.platform === "darwin"
    +      ? "/Applications/Android Studio.app/Contents/jbr/Contents/Home"
    +      : undefined),
    +  NODE_ENV: "development",
    +};
    +
    +interface CliOptions {
    +  readonly platforms: ReadonlySet;
    +  readonly deviceIds: ReadonlySet;
    +  readonly scenes: ReadonlySet;
    +  readonly appearances: ReadonlySet;
    +  readonly skipBuild: boolean;
    +  readonly skipMetro: boolean;
    +  readonly keepRunning: boolean;
    +  readonly validateOnly: boolean;
    +  readonly list: boolean;
    +}
    +
    +export interface ShowcaseCapture {
    +  readonly device: ShowcaseDevice;
    +  readonly scenes: ReadonlyArray;
    +  readonly appearance: ShowcaseAppearance;
    +}
    +
    +interface IosCaptureCleanup {
    +  readonly udid: string;
    +  readonly startedByRunner: boolean;
    +  readonly createdByRunner: boolean;
    +}
    +
    +interface AndroidCaptureCleanup {
    +  readonly device: ShowcaseAndroidDevice;
    +  readonly serial: string;
    +  readonly startedByRunner: boolean;
    +}
    +
    +interface NetworkAddress {
    +  readonly address: string;
    +  readonly family: string;
    +  readonly internal: boolean;
    +}
    +
    +export function selectLanIpv4Address(addresses: ReadonlyArray): string | null {
    +  return (
    +    addresses.find(
    +      ({ address, family, internal }) =>
    +        family === "IPv4" && !internal && !address.startsWith("169.254."),
    +    )?.address ?? null
    +  );
    +}
    +
    +function lanIpv4Address(): string {
    +  const address = selectLanIpv4Address(
    +    Object.values(NodeOS.networkInterfaces()).flatMap((addresses) => addresses ?? []),
    +  );
    +  if (!address) {
    +    throw new Error("No LAN IPv4 address is available for the iOS Simulator to reach Metro.");
    +  }
    +  return address;
    +}
    +
    +export interface PngMetadata {
    +  readonly width: number;
    +  readonly height: number;
    +  readonly bitDepth: number;
    +  readonly colorType: number;
    +  readonly hasAlpha: boolean;
    +}
    +
    +export function readPngMetadata(bytes: Uint8Array): PngMetadata {
    +  const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10];
    +  if (bytes.byteLength < 26 || !pngSignature.every((value, index) => bytes[index] === value)) {
    +    throw new Error("Captured file is not a valid PNG.");
    +  }
    +  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
    +  const colorType = view.getUint8(25);
    +  return {
    +    width: view.getUint32(16),
    +    height: view.getUint32(20),
    +    bitDepth: view.getUint8(24),
    +    colorType,
    +    hasAlpha: colorType === 4 || colorType === 6,
    +  };
    +}
    +
    +export function readPngDimensions(bytes: Uint8Array): {
    +  readonly width: number;
    +  readonly height: number;
    +} {
    +  const { width, height } = readPngMetadata(bytes);
    +  return { width, height };
    +}
    +
    +export function normalizeStorePng(bytes: Uint8Array): Buffer {
    +  const png = PNG.sync.read(Buffer.from(bytes));
    +  return PNG.sync.write(png, {
    +    bitDepth: 8,
    +    colorType: 2,
    +    inputColorType: 6,
    +    inputHasAlpha: true,
    +  });
    +}
    +
    +export function validateStoreAsset(
    +  spec: ShowcaseStoreAssetSpec,
    +  bytes: Uint8Array,
    +  label = "Screenshot",
    +): PngMetadata {
    +  const metadata = readPngMetadata(bytes);
    +  if (metadata.width !== spec.width || metadata.height !== spec.height) {
    +    throw new Error(
    +      `${label} is ${metadata.width}×${metadata.height}; ${spec.store} requires ${spec.width}×${spec.height}.`,
    +    );
    +  }
    +  if (metadata.bitDepth !== 8 || metadata.colorType !== 2 || metadata.hasAlpha) {
    +    throw new Error(
    +      `${label} must be an 8-bit, 24-bit RGB PNG without alpha (found bit depth ${metadata.bitDepth}, color type ${metadata.colorType}).`,
    +    );
    +  }
    +  if (spec.maximumFileSizeBytes && bytes.byteLength > spec.maximumFileSizeBytes) {
    +    throw new Error(
    +      `${label} is ${bytes.byteLength} bytes; ${spec.store} allows at most ${spec.maximumFileSizeBytes} bytes.`,
    +    );
    +  }
    +  if (spec.store === "google-play") {
    +    const shortestSide = Math.min(metadata.width, metadata.height);
    +    const longestSide = Math.max(metadata.width, metadata.height);
    +    if (shortestSide < 320 || longestSide > 3_840 || longestSide > shortestSide * 2) {
    +      throw new Error(
    +        `${label} does not meet Google Play's 320–3,840 px bounds and 2:1 maximum aspect ratio.`,
    +      );
    +    }
    +    if (metadata.width * 16 !== metadata.height * 9) {
    +      throw new Error(`${label} must use Google Play's recommended portrait 9:16 aspect ratio.`);
    +    }
    +  }
    +  return metadata;
    +}
    +
    +export function validateStoreAssetCount(
    +  spec: ShowcaseStoreAssetSpec,
    +  count: number,
    +  requireMinimum: boolean,
    +): void {
    +  if (count > spec.maximumUploadCount) {
    +    throw new Error(
    +      `${spec.directory} contains ${count} screenshots; ${spec.store} allows at most ${spec.maximumUploadCount}.`,
    +    );
    +  }
    +  if (requireMinimum && count < spec.minimumUploadCount) {
    +    throw new Error(
    +      `${spec.directory} contains ${count} screenshots; ${spec.store} requires at least ${spec.minimumUploadCount}.`,
    +    );
    +  }
    +}
    +
    +export function showcaseCaptureDirectory(
    +  outputDirectory: string,
    +  capture: Pick,
    +): string {
    +  return NodePath.join(outputDirectory, capture.device.storeAsset.directory, capture.appearance);
    +}
    +
    +async function finalizeCapture(destination: string, device: ShowcaseDevice): Promise {
    +  const normalized = normalizeStorePng(await NodeFSP.readFile(destination));
    +  await NodeFSP.writeFile(destination, normalized);
    +  const metadata = validateStoreAsset(
    +    device.storeAsset,
    +    normalized,
    +    NodePath.basename(destination),
    +  );
    +  NodeProcess.stdout.write(
    +    `Captured ${NodePath.relative(REPO_ROOT, destination)} (${metadata.width}×${metadata.height}, 24-bit RGB, validated for ${device.storeAsset.store})\n`,
    +  );
    +}
    +
    +async function validateCaptureSet(
    +  capture: ShowcaseCapture,
    +  outputDirectory: string,
    +  requireMinimum: boolean,
    +): Promise {
    +  const directory = showcaseCaptureDirectory(outputDirectory, capture);
    +  const files = (await NodeFSP.readdir(directory)).filter((file) => file.endsWith(".png")).sort();
    +  const expectedFiles = capture.scenes.map((scene) => `${scene}.png`).sort();
    +  const missingFiles = expectedFiles.filter((file) => !files.includes(file));
    +  if (missingFiles.length > 0) {
    +    throw new Error(`${capture.device.id} is missing ${missingFiles.join(", ")} in ${directory}.`);
    +  }
    +  validateStoreAssetCount(capture.device.storeAsset, files.length, requireMinimum);
    +  for (const file of files) {
    +    const bytes = await NodeFSP.readFile(NodePath.join(directory, file));
    +    validateStoreAsset(capture.device.storeAsset, bytes, `${capture.device.id}/${file}`);
    +  }
    +  NodeProcess.stdout.write(
    +    `Validated ${files.length} upload-ready ${capture.device.storeAsset.store} screenshots in ${NodePath.relative(REPO_ROOT, directory)}/\n`,
    +  );
    +}
    +
    +function argumentValue(args: ReadonlyArray, index: number, flag: string): string {
    +  const value = args[index + 1];
    +  if (!value || value.startsWith("--")) {
    +    throw new Error(`${flag} requires a value.`);
    +  }
    +  return value;
    +}
    +
    +export function parseShowcaseCliArgs(args: ReadonlyArray): CliOptions {
    +  const platforms = new Set();
    +  const deviceIds = new Set();
    +  const scenes = new Set();
    +  const appearances = new Set();
    +  let skipBuild = false;
    +  let skipMetro = false;
    +  let keepRunning = false;
    +  let validateOnly = false;
    +  let list = false;
    +
    +  for (let index = 0; index < args.length; index += 1) {
    +    const argument = args[index];
    +    if (argument === "--platform") {
    +      const value = argumentValue(args, index, argument);
    +      if (value !== "ios" && value !== "android" && value !== "all") {
    +        throw new Error(`Unsupported platform '${value}'. Use ios, android, or all.`);
    +      }
    +      if (value === "all") {
    +        platforms.add("ios");
    +        platforms.add("android");
    +      } else {
    +        platforms.add(value);
    +      }
    +      index += 1;
    +    } else if (argument === "--device") {
    +      deviceIds.add(argumentValue(args, index, argument));
    +      index += 1;
    +    } else if (argument === "--scene") {
    +      const value = argumentValue(args, index, argument);
    +      if (!SHOWCASE_SCENES.includes(value as ShowcaseScene)) {
    +        throw new Error(`Unsupported scene '${value}'. Use ${SHOWCASE_SCENES.join(", ")}.`);
    +      }
    +      scenes.add(value as ShowcaseScene);
    +      index += 1;
    +    } else if (argument === "--appearance") {
    +      const value = argumentValue(args, index, argument);
    +      if (value !== "light" && value !== "dark" && value !== "both") {
    +        throw new Error(`Unsupported appearance '${value}'. Use light, dark, or both.`);
    +      }
    +      if (value === "both") {
    +        appearances.add("light");
    +        appearances.add("dark");
    +      } else {
    +        appearances.add(value);
    +      }
    +      index += 1;
    +    } else if (argument === "--skip-build") {
    +      skipBuild = true;
    +    } else if (argument === "--skip-metro") {
    +      skipMetro = true;
    +    } else if (argument === "--keep-running") {
    +      keepRunning = true;
    +    } else if (argument === "--validate-only") {
    +      validateOnly = true;
    +    } else if (argument === "--list") {
    +      list = true;
    +    } else if (argument === "--help" || argument === "-h") {
    +      list = true;
    +    } else {
    +      throw new Error(`Unknown option '${argument}'.`);
    +    }
    +  }
    +
    +  return {
    +    platforms,
    +    deviceIds,
    +    scenes,
    +    appearances,
    +    skipBuild,
    +    skipMetro,
    +    keepRunning,
    +    validateOnly,
    +    list,
    +  };
    +}
    +
    +export function planShowcaseCaptures(
    +  config: ShowcaseConfig,
    +  options: Pick,
    +): ReadonlyArray {
    +  const captures = config.devices
    +    .filter((device) => options.platforms.size === 0 || options.platforms.has(device.platform))
    +    .filter((device) => options.deviceIds.size === 0 || options.deviceIds.has(device.id))
    +    .flatMap((device) => {
    +      const appearances =
    +        options.appearances.size === 0 ? [device.appearance] : options.appearances;
    +      return [...appearances].map((appearance) => ({
    +        device,
    +        appearance,
    +        scenes:
    +          options.scenes.size === 0
    +            ? device.scenes
    +            : device.scenes.filter((scene) => options.scenes.has(scene)),
    +      }));
    +    })
    +    .filter((capture) => capture.scenes.length > 0);
    +
    +  const knownDeviceIds = new Set(config.devices.map((device) => device.id));
    +  for (const id of options.deviceIds) {
    +    if (!knownDeviceIds.has(id)) {
    +      throw new Error(`Unknown device '${id}'. Run with --list to see configured devices.`);
    +    }
    +  }
    +  if (captures.length === 0) {
    +    throw new Error("No captures match the selected platform, device, and scene filters.");
    +  }
    +  return captures;
    +}
    +
    +function printUsage(config: ShowcaseConfig): void {
    +  NodeProcess.stdout.write(`App screenshot showcase
    +
    +Usage:
    +  pnpm --filter @t3tools/mobile screenshots [options]
    +
    +Options:
    +  --platform ios|android|all  Capture one platform (repeatable)
    +  --device               Capture one configured device (repeatable)
    +  --scene              Capture one scene (repeatable)
    +  --appearance light|dark|both
    +                             Override the configured appearance
    +  --skip-build               Reuse the existing simulator app / debug APK
    +  --skip-metro               Reuse an already running showcase Metro server
    +  --keep-running             Leave devices and Metro running after capture
    +  --validate-only            Validate existing upload assets without capturing
    +  --list                     Print this help and the configured matrix
    +
    +Scenes: ${SHOWCASE_SCENES.join(", ")}
    +
    +Configured devices:
    +${config.devices
    +  .map((device) => {
    +    const target = device.platform === "ios" ? device.simulator : device.avd;
    +    return `  ${device.id.padEnd(18)} ${device.platform.padEnd(8)} ${target} -> ${device.storeAsset.directory}/{light|dark} (${device.storeAsset.width}×${device.storeAsset.height}, default ${device.appearance}) [${device.scenes.join(", ")}]`;
    +  })
    +  .join("\n")}
    +`);
    +}
    +
    +function spawnProcess(
    +  command: string,
    +  args: ReadonlyArray,
    +  options: NodeChildProcess.SpawnOptions = {},
    +): NodeChildProcess.ChildProcess {
    +  return NodeChildProcess.spawn(command, args, {
    +    cwd: REPO_ROOT,
    +    env: NodeProcess.env,
    +    stdio: "inherit",
    +    ...options,
    +  });
    +}
    +
    +async function runCommand(
    +  command: string,
    +  args: ReadonlyArray,
    +  options: NodeChildProcess.SpawnOptions = {},
    +): Promise {
    +  await new Promise((resolve, reject) => {
    +    const child = spawnProcess(command, args, options);
    +    child.once("error", reject);
    +    child.once("exit", (code, signal) => {
    +      if (code === 0) {
    +        resolve();
    +      } else {
    +        reject(
    +          new Error(
    +            `${command} ${args.join(" ")} failed ${signal ? `with signal ${signal}` : `with code ${String(code)}`}.`,
    +          ),
    +        );
    +      }
    +    });
    +  });
    +}
    +
    +async function commandOutput(
    +  command: string,
    +  args: ReadonlyArray,
    +  options: NodeChildProcess.ExecFileOptions = {},
    +): Promise {
    +  return await new Promise((resolve, reject) => {
    +    NodeChildProcess.execFile(
    +      command,
    +      [...args],
    +      { cwd: REPO_ROOT, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, ...options },
    +      (error, stdout) => {
    +        if (error) reject(error);
    +        else resolve(String(stdout));
    +      },
    +    );
    +  });
    +}
    +
    +function delay(milliseconds: number): Promise {
    +  return new Promise((resolve) => setTimeout(resolve, milliseconds));
    +}
    +
    +async function stopProcess(child: NodeChildProcess.ChildProcess): Promise {
    +  if (child.exitCode !== null || child.signalCode !== null) return;
    +
    +  const exited = new Promise((resolve) => {
    +    child.once("exit", () => resolve());
    +  });
    +  child.kill("SIGTERM");
    +  await Promise.race([exited, delay(5_000)]);
    +  if (child.exitCode !== null || child.signalCode !== null) return;
    +
    +  child.kill("SIGKILL");
    +  await Promise.race([exited, delay(1_000)]);
    +}
    +
    +async function waitForPort(port: number, label = "Process", timeoutMs = 60_000): Promise {
    +  const deadline = Date.now() + timeoutMs;
    +  while (Date.now() < deadline) {
    +    const open = await new Promise((resolve) => {
    +      const socket = NodeNet.createConnection({ host: "127.0.0.1", port });
    +      socket.once("connect", () => {
    +        socket.destroy();
    +        resolve(true);
    +      });
    +      socket.once("error", () => resolve(false));
    +      socket.setTimeout(500, () => {
    +        socket.destroy();
    +        resolve(false);
    +      });
    +    });
    +    if (open) return;
    +    await delay(500);
    +  }
    +  throw new Error(`${label} did not begin listening on port ${port} within ${timeoutMs}ms.`);
    +}
    +
    +async function reserveAvailablePort(): Promise {
    +  return await new Promise((resolve, reject) => {
    +    const server = NodeNet.createServer();
    +    server.once("error", reject);
    +    server.listen(0, "127.0.0.1", () => {
    +      const address = server.address();
    +      if (!address || typeof address === "string") {
    +        server.close();
    +        reject(new Error("Could not reserve a local port for the showcase environment."));
    +        return;
    +      }
    +      server.close((error) => (error ? reject(error) : resolve(address.port)));
    +    });
    +  });
    +}
    +
    +async function createShowcaseShell(baseDir: string): Promise {
    +  const shellPath = NodePath.join(baseDir, "showcase-shell");
    +  await NodeFSP.writeFile(
    +    shellPath,
    +    `#!/bin/sh
    +if [ "$1" = "-ilc" ] || [ "$1" = "-lic" ]; then
    +  exec /bin/sh -c "$2"
    +fi
    +exec /bin/cat
    +`,
    +    { mode: 0o755 },
    +  );
    +  return shellPath;
    +}
    +
    +async function createShowcaseLabelProbe(baseDir: string, label: string): Promise {
    +  const binDirectory = NodePath.join(baseDir, "showcase-bin");
    +  await NodeFSP.mkdir(binDirectory, { recursive: true });
    +  const probeScript = `#!/bin/sh
    +if [ "$1" = "--get" ] && [ "$2" = "ComputerName" ]; then
    +  printf '%s\\n' ${JSON.stringify(label)}
    +  exit 0
    +fi
    +if [ "$1" = "--pretty" ]; then
    +  printf '%s\\n' ${JSON.stringify(label)}
    +  exit 0
    +fi
    +exit 1
    +`;
    +  await Promise.all(
    +    ["scutil", "hostnamectl"].map((executable) =>
    +      NodeFSP.writeFile(NodePath.join(binDirectory, executable), probeScript, { mode: 0o755 }),
    +    ),
    +  );
    +  return binDirectory;
    +}
    +
    +function startShowcaseServer(
    +  baseDir: string,
    +  workspaceRoot: string,
    +  port: number,
    +  shellPath: string,
    +  labelProbeDirectory: string,
    +): NodeChildProcess.ChildProcess {
    +  return spawnProcess(
    +    "node",
    +    [
    +      "apps/server/src/bin.ts",
    +      "serve",
    +      "--host",
    +      SERVER_HOST,
    +      "--port",
    +      String(port),
    +      "--base-dir",
    +      baseDir,
    +      "--no-browser",
    +      "--log-level",
    +      "error",
    +      workspaceRoot,
    +    ],
    +    {
    +      env: {
    +        ...NodeProcess.env,
    +        PATH: `${labelProbeDirectory}:${NodeProcess.env.PATH ?? ""}`,
    +        SHELL: shellPath,
    +      },
    +    },
    +  );
    +}
    +
    +export function parsePairingCredentialOutput(output: string): string {
    +  const jsonStart = output.indexOf("{");
    +  const jsonEnd = output.lastIndexOf("}");
    +  if (jsonStart === -1 || jsonEnd < jsonStart) {
    +    throw new Error("Pairing credential command did not return JSON.");
    +  }
    +  const parsed = JSON.parse(output.slice(jsonStart, jsonEnd + 1)) as {
    +    readonly credential?: unknown;
    +  };
    +  if (typeof parsed.credential !== "string" || parsed.credential.length === 0) {
    +    throw new Error("Pairing credential command returned no credential.");
    +  }
    +  return parsed.credential;
    +}
    +
    +async function issuePairingCredential(baseDir: string): Promise {
    +  const output = await commandOutput(
    +    "node",
    +    ["apps/server/src/bin.ts", "auth", "pairing", "create", "--base-dir", baseDir, "--json"],
    +    { env: { ...NodeProcess.env, NO_COLOR: "1" } },
    +  );
    +  return parsePairingCredentialOutput(output);
    +}
    +
    +function buildShowcasePairingUrl(host: string, port: number, credential: string): string {
    +  const url = new URL(`http://${host}:${port}/`);
    +  url.hash = new URLSearchParams([["token", credential]]).toString();
    +  return url.toString();
    +}
    +
    +export function showcaseSceneUrl(scene: ShowcaseScene, environmentId: string): string {
    +  if (scene === "threads") return `${APP_SCHEME}://`;
    +  if (scene === "environments") return `${APP_SCHEME}://settings/environments`;
    +  const threadPath = `threads/${encodeURIComponent(environmentId)}/${SHOWCASE_THREAD_ID}`;
    +  if (scene === "thread") return `${APP_SCHEME}://${threadPath}`;
    +  if (scene === "terminal") {
    +    return `${APP_SCHEME}://${threadPath}/terminal?terminalId=${SHOWCASE_TERMINAL_ID}`;
    +  }
    +  return `${APP_SCHEME}://${threadPath}/review`;
    +}
    +
    +export function encodeAndroidPairingUrls(pairingUrls: ReadonlyArray): string {
    +  return `json-uri:${encodeURIComponent(JSON.stringify(pairingUrls))}`;
    +}
    +
    +function startMetro(config: ShowcaseConfig): NodeChildProcess.ChildProcess {
    +  return spawnProcess(
    +    "pnpm",
    +    ["exec", "expo", "start", "--dev-client", "--port", String(config.metroPort)],
    +    {
    +      cwd: MOBILE_ROOT,
    +      env: {
    +        ...MOBILE_BUILD_ENV,
    +        EXPO_PUBLIC_SHOWCASE: "1",
    +      },
    +    },
    +  );
    +}
    +
    +async function warmMetroBundle(
    +  platform: ShowcaseDevice["platform"],
    +  host: string,
    +  config: ShowcaseConfig,
    +): Promise {
    +  const url = `http://${host}:${config.metroPort}/apps/mobile/index.bundle?platform=${platform}&dev=true&minify=false`;
    +  await runCommand("curl", ["--fail", "--silent", "--show-error", "--output", "/dev/null", url]);
    +}
    +
    +async function buildIos(): Promise {
    +  const derivedData = NodePath.join(MOBILE_ROOT, ".showcase/ios-derived-data");
    +  await runCommand("pnpm", ["exec", "expo", "prebuild", "--clean", "--platform", "ios"], {
    +    cwd: MOBILE_ROOT,
    +    env: MOBILE_BUILD_ENV,
    +  });
    +  await runCommand(
    +    "xcodebuild",
    +    [
    +      "-workspace",
    +      NodePath.join(MOBILE_ROOT, "ios/T3CodeDev.xcworkspace"),
    +      "-scheme",
    +      "T3CodeDev",
    +      "-configuration",
    +      "Debug",
    +      "-sdk",
    +      "iphonesimulator",
    +      "-derivedDataPath",
    +      derivedData,
    +      "-quiet",
    +      `ARCHS=${IOS_SIMULATOR_ARCH}`,
    +      "ONLY_ACTIVE_ARCH=YES",
    +      "build",
    +    ],
    +    { cwd: MOBILE_ROOT, env: MOBILE_BUILD_ENV },
    +  );
    +  return IOS_APP_PATH;
    +}
    +
    +async function buildAndroid(abis: ReadonlyArray): Promise {
    +  await runCommand("pnpm", ["exec", "expo", "prebuild", "--clean", "--platform", "android"], {
    +    cwd: MOBILE_ROOT,
    +    env: MOBILE_BUILD_ENV,
    +  });
    +  await runCommand(
    +    "./gradlew",
    +    [
    +      "app:assembleDebug",
    +      ...(abis.length > 0 ? [`-PreactNativeArchitectures=${abis.join(",")}`] : []),
    +    ],
    +    {
    +      cwd: NodePath.join(MOBILE_ROOT, "android"),
    +      env: MOBILE_BUILD_ENV,
    +    },
    +  );
    +  return ANDROID_APK_PATH;
    +}
    +
    +async function existingArtifact(path: string): Promise {
    +  return await NodeFSP.access(path).then(
    +    () => path,
    +    () => null,
    +  );
    +}
    +
    +interface SimctlDevice {
    +  readonly name: string;
    +  readonly udid: string;
    +  readonly state: "Booted" | "Shutdown" | string;
    +  readonly isAvailable: boolean;
    +}
    +
    +async function findIosSimulator(name: string): Promise {
    +  const parsed = JSON.parse(
    +    await commandOutput("xcrun", ["simctl", "list", "devices", "available", "-j"]),
    +  ) as {
    +    readonly devices: Readonly>>;
    +  };
    +  const candidates = Object.entries(parsed.devices)
    +    .filter(([runtime]) => runtime.includes("iOS"))
    +    .flatMap(([, devices]) => devices)
    +    .filter((device) => device.isAvailable && device.name === name);
    +  return candidates.at(-1) ?? null;
    +}
    +
    +async function ensureIosSimulator(device: ShowcaseIosDevice): Promise<{
    +  readonly simulator: SimctlDevice;
    +  readonly createdByRunner: boolean;
    +}> {
    +  const existing = await findIosSimulator(device.simulator);
    +  if (existing) return { simulator: existing, createdByRunner: false };
    +  if (!device.simulatorDeviceType) {
    +    throw new Error(
    +      `iOS simulator '${device.simulator}' is not installed and has no simulatorDeviceType configured.`,
    +    );
    +  }
    +  const udid = (
    +    await commandOutput("xcrun", ["simctl", "create", device.simulator, device.simulatorDeviceType])
    +  ).trim();
    +  if (!udid) throw new Error(`Could not create iOS simulator '${device.simulator}'.`);
    +  return {
    +    simulator: {
    +      name: device.simulator,
    +      udid,
    +      state: "Shutdown",
    +      isAvailable: true,
    +    },
    +    createdByRunner: true,
    +  };
    +}
    +
    +async function normalizeIosSimulator(appearance: ShowcaseAppearance, udid: string): Promise {
    +  await runCommand("xcrun", ["simctl", "ui", udid, "appearance", appearance]);
    +  await runCommand("xcrun", [
    +    "simctl",
    +    "status_bar",
    +    udid,
    +    "override",
    +    "--time",
    +    "9:41",
    +    "--batteryState",
    +    "charged",
    +    "--batteryLevel",
    +    "100",
    +    "--wifiBars",
    +    "3",
    +    "--cellularBars",
    +    "4",
    +  ]);
    +}
    +
    +async function iosAppContainer(udid: string): Promise {
    +  return (
    +    await commandOutput("xcrun", ["simctl", "get_app_container", udid, ANDROID_PACKAGE, "data"])
    +  ).trim();
    +}
    +
    +async function waitForIosShowcaseScene(
    +  udid: string,
    +  scene: ShowcaseScene,
    +  timeoutMs = 90_000,
    +): Promise {
    +  const readyPath = NodePath.join(
    +    await iosAppContainer(udid),
    +    "Library/Caches",
    +    IOS_READY_FILENAME,
    +  );
    +  const deadline = Date.now() + timeoutMs;
    +  while (Date.now() < deadline) {
    +    const readyScene = await NodeFSP.readFile(readyPath, "utf8").catch(() => "");
    +    if (readyScene.trim() === scene) return;
    +    await delay(500);
    +  }
    +  throw new Error(`iOS showcase scene '${scene}' did not render within ${timeoutMs}ms.`);
    +}
    +
    +async function captureIos(
    +  capture: ShowcaseCapture & { readonly device: ShowcaseIosDevice },
    +  appPath: string | null,
    +  outputDirectory: string,
    +  config: ShowcaseConfig,
    +  metroHost: string,
    +  pairingUrls: ReadonlyArray,
    +  registerCleanup: (cleanup: IosCaptureCleanup) => void,
    +): Promise {
    +  const { simulator, createdByRunner } = await ensureIosSimulator(capture.device);
    +  const startedByRunner = simulator.state !== "Booted";
    +  registerCleanup({ udid: simulator.udid, startedByRunner, createdByRunner });
    +  if (!startedByRunner) {
    +    // Clear transient SpringBoard state (permission prompts, stale URL-open
    +    // confirmations, keyboards) without erasing the developer's simulator.
    +    await runCommand("xcrun", ["simctl", "shutdown", simulator.udid]);
    +  }
    +  await runCommand("xcrun", ["simctl", "boot", simulator.udid]);
    +  await runCommand("xcrun", ["simctl", "bootstatus", simulator.udid, "-b"]);
    +  await normalizeIosSimulator(capture.appearance, simulator.udid);
    +  if (appPath) {
    +    await runCommand("xcrun", ["simctl", "uninstall", simulator.udid, ANDROID_PACKAGE]).catch(
    +      () => undefined,
    +    );
    +    await runCommand("xcrun", ["simctl", "install", simulator.udid, appPath]);
    +  }
    +
    +  for (const [key, value] of [
    +    ["EXDevMenuIsOnboardingFinished", "true"],
    +    ["EXDevMenuShowFloatingActionButton", "false"],
    +    ["EXDevMenuShowsAtLaunch", "false"],
    +  ] as const) {
    +    await runCommand("xcrun", [
    +      "simctl",
    +      "spawn",
    +      simulator.udid,
    +      "defaults",
    +      "write",
    +      ANDROID_PACKAGE,
    +      key,
    +      "-bool",
    +      value,
    +    ]);
    +  }
    +
    +  const metroUrl = `http://${metroHost}:${config.metroPort}?disableOnboarding=1`;
    +  const scenePath = NodePath.join(
    +    await iosAppContainer(simulator.udid),
    +    "Library/Caches/T3ShowcaseScene",
    +  );
    +  const readyPath = NodePath.join(
    +    await iosAppContainer(simulator.udid),
    +    "Library/Caches",
    +    IOS_READY_FILENAME,
    +  );
    +  const firstScene = capture.scenes[0] ?? "threads";
    +  const launchShowcaseApp = async (terminateRunningProcess: boolean) => {
    +    await runCommand("xcrun", [
    +      "simctl",
    +      "launch",
    +      ...(terminateRunningProcess ? ["--terminate-running-process"] : []),
    +      simulator.udid,
    +      ANDROID_PACKAGE,
    +      "--initialUrl",
    +      metroUrl,
    +      "--showcasePairingUrl",
    +      JSON.stringify(pairingUrls),
    +      "--showcaseScene",
    +      firstScene,
    +    ]);
    +  };
    +  await NodeFSP.rm(readyPath, { force: true });
    +  await NodeFSP.writeFile(scenePath, firstScene);
    +  await launchShowcaseApp(false);
    +  for (const [sceneIndex, scene] of capture.scenes.entries()) {
    +    if (sceneIndex > 0) await NodeFSP.rm(readyPath, { force: true });
    +    await NodeFSP.writeFile(scenePath, scene);
    +    if (sceneIndex === 0) {
    +      for (let attempt = 0; attempt < 2; attempt += 1) {
    +        const isLastAttempt = attempt === 1;
    +        try {
    +          // A freshly installed Expo development build can spend well over 30s
    +          // applying an already-bundled update after it reaches 100%. Killing it
    +          // at that point sends the next capture back to the dev launcher.
    +          await waitForIosShowcaseScene(simulator.udid, scene, 120_000);
    +          break;
    +        } catch (error) {
    +          if (isLastAttempt) throw error;
    +          await launchShowcaseApp(true);
    +        }
    +      }
    +    } else {
    +      await waitForIosShowcaseScene(simulator.udid, scene);
    +    }
    +    await delay(scene === "review" ? Math.max(config.settleDelayMs, 8_000) : config.settleDelayMs);
    +    const destination = NodePath.join(
    +      showcaseCaptureDirectory(outputDirectory, capture),
    +      `${scene}.png`,
    +    );
    +    await runCommand("xcrun", ["simctl", "io", simulator.udid, "screenshot", destination]);
    +    await finalizeCapture(destination, capture.device);
    +  }
    +}
    +
    +function androidSdkTool(relativePath: string): string {
    +  return NodePath.join(ANDROID_SDK_ROOT, relativePath);
    +}
    +
    +async function adbOutput(serial: string, args: ReadonlyArray): Promise {
    +  return await commandOutput(androidSdkTool("platform-tools/adb"), ["-s", serial, ...args]);
    +}
    +
    +async function runAdb(serial: string, args: ReadonlyArray): Promise {
    +  await runCommand(androidSdkTool("platform-tools/adb"), ["-s", serial, ...args]);
    +}
    +
    +async function runningAndroidAvds(): Promise> {
    +  const adb = androidSdkTool("platform-tools/adb");
    +  const devices = (await commandOutput(adb, ["devices"]))
    +    .split("\n")
    +    .map((line) => line.trim().split(/\s+/u))
    +    .filter((parts) => parts[0]?.startsWith("emulator-") && parts[1] === "device")
    +    .map((parts) => parts[0] as string);
    +  const result = new Map();
    +  for (const serial of devices) {
    +    const avdName = (await adbOutput(serial, ["emu", "avd", "name"])).split("\n")[0]?.trim();
    +    if (avdName) result.set(avdName, serial);
    +  }
    +  return result;
    +}
    +
    +async function waitForAndroidSerial(avd: string, timeoutMs = 120_000): Promise {
    +  const deadline = Date.now() + timeoutMs;
    +  while (Date.now() < deadline) {
    +    const serial = (await runningAndroidAvds()).get(avd);
    +    if (serial) {
    +      await runAdb(serial, ["wait-for-device"]);
    +      const bootCompleted = (
    +        await adbOutput(serial, ["shell", "getprop", "sys.boot_completed"])
    +      ).trim();
    +      if (bootCompleted === "1") return serial;
    +    }
    +    await delay(1_000);
    +  }
    +  throw new Error(`Android AVD '${avd}' did not finish booting within ${timeoutMs}ms.`);
    +}
    +
    +async function normalizeAndroidEmulator(
    +  device: ShowcaseAndroidDevice,
    +  appearance: ShowcaseAppearance,
    +  serial: string,
    +): Promise {
    +  await runAdb(serial, ["shell", "settings", "put", "global", "window_animation_scale", "0"]);
    +  await runAdb(serial, ["shell", "settings", "put", "global", "transition_animation_scale", "0"]);
    +  await runAdb(serial, ["shell", "settings", "put", "global", "animator_duration_scale", "0"]);
    +  await runAdb(serial, ["shell", "cmd", "uimode", "night", appearance === "dark" ? "yes" : "no"]);
    +  await runAdb(serial, ["shell", "settings", "put", "system", "time_12_24", "12"]);
    +  await runAdb(serial, ["emu", "power", "capacity", "100"]);
    +  await runAdb(serial, ["shell", "settings", "put", "global", "sysui_demo_allowed", "1"]);
    +  await runAdb(serial, [
    +    "shell",
    +    "am",
    +    "broadcast",
    +    "-a",
    +    "com.android.systemui.demo",
    +    "-e",
    +    "command",
    +    "enter",
    +  ]);
    +  await runAdb(serial, [
    +    "shell",
    +    "am",
    +    "broadcast",
    +    "-a",
    +    "com.android.systemui.demo",
    +    "-e",
    +    "command",
    +    "clock",
    +    "-e",
    +    "hhmm",
    +    "0941",
    +  ]);
    +  await runAdb(serial, [
    +    "shell",
    +    "am",
    +    "broadcast",
    +    "-a",
    +    "com.android.systemui.demo",
    +    "-e",
    +    "command",
    +    "battery",
    +    "-e",
    +    "level",
    +    "100",
    +    "-e",
    +    "plugged",
    +    "false",
    +  ]);
    +  if (device.viewport) {
    +    await runAdb(serial, [
    +      "shell",
    +      "wm",
    +      "size",
    +      `${device.viewport.width}x${device.viewport.height}`,
    +    ]);
    +    if (device.viewport.density) {
    +      await runAdb(serial, ["shell", "wm", "density", String(device.viewport.density)]);
    +    }
    +  }
    +}
    +
    +async function waitForAndroidShowcaseScene(
    +  serial: string,
    +  scene: ShowcaseScene,
    +  timeoutMs = 90_000,
    +): Promise {
    +  const deadline = Date.now() + timeoutMs;
    +  while (Date.now() < deadline) {
    +    const readyScene = await adbOutput(serial, [
    +      "shell",
    +      "run-as",
    +      ANDROID_PACKAGE,
    +      "cat",
    +      "files/t3-showcase-ready",
    +    ]).catch(() => "");
    +    if (readyScene.trim() === scene) return;
    +    await delay(500);
    +  }
    +  throw new Error(`Android showcase scene '${scene}' did not render within ${timeoutMs}ms.`);
    +}
    +
    +async function writeAndroidShowcaseScene(serial: string, scene: ShowcaseScene): Promise {
    +  await runAdb(serial, [
    +    "shell",
    +    `run-as ${ANDROID_PACKAGE} sh -c 'mkdir -p files && rm -f files/t3-showcase-ready && printf %s ${scene} > files/t3-showcase-scene'`,
    +  ]);
    +}
    +
    +async function prepareAndroidShowcaseApp(serial: string): Promise {
    +  const preferences = `
    +
    +  
    +  
    +  
    +  
    +  
    +  
    +`;
    +  const encodedPreferences = Buffer.from(preferences).toString("base64");
    +  await runAdb(serial, [
    +    "shell",
    +    `run-as ${ANDROID_PACKAGE} sh -c 'mkdir -p shared_prefs && printf %s ${encodedPreferences} | base64 -d > shared_prefs/expo.modules.devmenu.sharedpreferences.xml'`,
    +  ]);
    +}
    +
    +async function captureAndroid(
    +  capture: ShowcaseCapture & { readonly device: ShowcaseAndroidDevice },
    +  apkPath: string | null,
    +  outputDirectory: string,
    +  config: ShowcaseConfig,
    +  pairingUrls: ReadonlyArray,
    +  registerCleanup: (cleanup: AndroidCaptureCleanup) => void,
    +): Promise {
    +  const running = await runningAndroidAvds();
    +  const existingSerial = running.get(capture.device.avd);
    +  const startedByRunner = !existingSerial;
    +  let launchedEmulator: NodeChildProcess.ChildProcess | null = null;
    +  if (startedByRunner) {
    +    const installedAvds = (await commandOutput(androidSdkTool("emulator/emulator"), ["-list-avds"]))
    +      .split("\n")
    +      .map((value) => value.trim());
    +    if (!installedAvds.includes(capture.device.avd)) {
    +      throw new Error(
    +        `Android AVD '${capture.device.avd}' is not installed. Run emulator -list-avds.`,
    +      );
    +    }
    +    launchedEmulator = spawnProcess(
    +      androidSdkTool("emulator/emulator"),
    +      ["-avd", capture.device.avd, "-no-snapshot-load", "-no-boot-anim"],
    +      { stdio: "ignore", detached: true },
    +    );
    +    launchedEmulator.unref();
    +  }
    +  const serial =
    +    existingSerial ??
    +    (await waitForAndroidSerial(capture.device.avd).catch(async (error: unknown) => {
    +      if (launchedEmulator) await stopProcess(launchedEmulator);
    +      throw error;
    +    }));
    +  registerCleanup({ device: capture.device, serial, startedByRunner });
    +  await normalizeAndroidEmulator(capture.device, capture.appearance, serial);
    +  if (apkPath) {
    +    await runAdb(serial, ["install", "-r", apkPath]);
    +  }
    +  await runAdb(serial, ["shell", "pm", "clear", ANDROID_PACKAGE]);
    +  await prepareAndroidShowcaseApp(serial);
    +  await runAdb(serial, ["reverse", `tcp:${config.metroPort}`, `tcp:${config.metroPort}`]);
    +  const metroUrl = encodeURIComponent(`http://127.0.0.1:${config.metroPort}?disableOnboarding=1`);
    +  const firstScene = capture.scenes[0] ?? "threads";
    +  await runAdb(serial, [
    +    "shell",
    +    "am",
    +    "start",
    +    "-W",
    +    "-a",
    +    "android.intent.action.VIEW",
    +    "-d",
    +    `${APP_SCHEME}://expo-development-client/?url=${metroUrl}`,
    +    "--es",
    +    "showcasePairingUrl",
    +    encodeAndroidPairingUrls(pairingUrls),
    +    "--es",
    +    "showcaseScene",
    +    firstScene,
    +    ANDROID_PACKAGE,
    +  ]);
    +  for (const [sceneIndex, scene] of capture.scenes.entries()) {
    +    if (sceneIndex > 0) await writeAndroidShowcaseScene(serial, scene);
    +    await waitForAndroidShowcaseScene(serial, scene);
    +    await delay(Math.max(config.settleDelayMs, scene === "review" ? 8_000 : 5_000));
    +    const destination = NodePath.join(
    +      showcaseCaptureDirectory(outputDirectory, capture),
    +      `${scene}.png`,
    +    );
    +    const png = await new Promise((resolve, reject) => {
    +      NodeChildProcess.execFile(
    +        androidSdkTool("platform-tools/adb"),
    +        ["-s", serial, "exec-out", "screencap", "-p"],
    +        { cwd: REPO_ROOT, encoding: "buffer", maxBuffer: 64 * 1024 * 1024 },
    +        (error, stdout) => {
    +          if (error) reject(error);
    +          else resolve(stdout);
    +        },
    +      );
    +    });
    +    await NodeFSP.writeFile(destination, png);
    +    await finalizeCapture(destination, capture.device);
    +  }
    +}
    +
    +async function cleanupAndroidViewport(
    +  device: ShowcaseAndroidDevice,
    +  serial: string,
    +): Promise {
    +  await runAdb(serial, [
    +    "shell",
    +    "am",
    +    "broadcast",
    +    "-a",
    +    "com.android.systemui.demo",
    +    "-e",
    +    "command",
    +    "exit",
    +  ]);
    +  if (!device.viewport) return;
    +  await runAdb(serial, ["shell", "wm", "size", "reset"]);
    +  if (device.viewport.density) {
    +    await runAdb(serial, ["shell", "wm", "density", "reset"]);
    +  }
    +}
    +
    +async function main(): Promise {
    +  const options = parseShowcaseCliArgs(NodeProcess.argv.slice(2));
    +  if (options.list) {
    +    printUsage(showcaseConfig);
    +    return;
    +  }
    +  const captures = planShowcaseCaptures(showcaseConfig, options);
    +  const outputDirectory = NodePath.resolve(REPO_ROOT, showcaseConfig.outputDirectory);
    +  if (options.validateOnly) {
    +    for (const capture of captures) {
    +      await validateCaptureSet(
    +        capture,
    +        outputDirectory,
    +        capture.scenes.length === capture.device.scenes.length,
    +      );
    +    }
    +    return;
    +  }
    +  const hasIos = captures.some((capture) => capture.device.platform === "ios");
    +  const hasAndroid = captures.some((capture) => capture.device.platform === "android");
    +  const metroHost = hasIos ? lanIpv4Address() : "127.0.0.1";
    +  await NodeFSP.mkdir(outputDirectory, { recursive: true });
    +  for (const capture of captures) {
    +    const directory = showcaseCaptureDirectory(outputDirectory, capture);
    +    await NodeFSP.rm(directory, { recursive: true, force: true });
    +    await NodeFSP.mkdir(directory, { recursive: true });
    +  }
    +
    +  const showcaseRootDir = await NodeFSP.mkdtemp(
    +    NodePath.join(NodeOS.tmpdir(), "t3-mobile-showcase-"),
    +  );
    +  const showcaseServers: NodeChildProcess.ChildProcess[] = [];
    +  const showcaseEnvironments: Array<{
    +    readonly baseDir: string;
    +    readonly environmentId: string;
    +    readonly label: string;
    +    readonly port: number;
    +  }> = [];
    +  let metro: NodeChildProcess.ChildProcess | null = null;
    +  const iosCleanups: IosCaptureCleanup[] = [];
    +  const androidCleanups: AndroidCaptureCleanup[] = [];
    +
    +  try {
    +    for (const environment of SHOWCASE_ENVIRONMENTS) {
    +      const projectId = environment.projectIds[0];
    +      const project = SHOWCASE_PROJECTS.find((candidate) => candidate.id === projectId);
    +      if (!project) throw new Error(`Showcase environment '${environment.id}' has no project.`);
    +
    +      const baseDir = NodePath.join(showcaseRootDir, "environments", environment.id);
    +      const workspaceRoot = NodePath.join(baseDir, "workspace", project.directory);
    +      const port = await reserveAvailablePort();
    +      await NodeFSP.mkdir(workspaceRoot, { recursive: true });
    +      const shellPath = await createShowcaseShell(baseDir);
    +      const labelProbeDirectory = await createShowcaseLabelProbe(baseDir, environment.label);
    +      const server = startShowcaseServer(
    +        baseDir,
    +        workspaceRoot,
    +        port,
    +        shellPath,
    +        labelProbeDirectory,
    +      );
    +      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.`);
    +      }
    +      showcaseEnvironments.push({ baseDir, environmentId, label: environment.label, port });
    +    }
    +
    +    if (!options.skipMetro) {
    +      metro = startMetro(showcaseConfig);
    +      await waitForPort(showcaseConfig.metroPort, "Metro");
    +      await Promise.all([
    +        hasIos ? warmMetroBundle("ios", metroHost, showcaseConfig) : Promise.resolve(),
    +        hasAndroid ? warmMetroBundle("android", "127.0.0.1", showcaseConfig) : Promise.resolve(),
    +      ]);
    +    }
    +
    +    const iosAppPath = hasIos
    +      ? options.skipBuild
    +        ? await existingArtifact(IOS_APP_PATH)
    +        : await buildIos()
    +      : null;
    +    const androidAbis = captures.flatMap((capture) =>
    +      capture.device.platform === "android" && capture.device.abi ? [capture.device.abi] : [],
    +    );
    +    const androidApkPath = hasAndroid
    +      ? options.skipBuild
    +        ? await existingArtifact(ANDROID_APK_PATH)
    +        : await buildAndroid([...new Set(androidAbis)])
    +      : null;
    +
    +    for (const capture of captures) {
    +      const pairingHost = capture.device.platform === "ios" ? "127.0.0.1" : "10.0.2.2";
    +      const pairingUrls = await Promise.all(
    +        showcaseEnvironments.map(async (environment) => {
    +          const credential = await issuePairingCredential(environment.baseDir);
    +          return buildShowcasePairingUrl(pairingHost, environment.port, credential);
    +        }),
    +      );
    +      if (capture.device.platform === "ios") {
    +        await captureIos(
    +          capture as ShowcaseCapture & { readonly device: ShowcaseIosDevice },
    +          iosAppPath,
    +          outputDirectory,
    +          showcaseConfig,
    +          metroHost,
    +          pairingUrls,
    +          (cleanup) => iosCleanups.push(cleanup),
    +        );
    +      } else {
    +        await captureAndroid(
    +          capture as ShowcaseCapture & { readonly device: ShowcaseAndroidDevice },
    +          androidApkPath,
    +          outputDirectory,
    +          showcaseConfig,
    +          pairingUrls,
    +          (cleanup) => androidCleanups.push(cleanup),
    +        );
    +      }
    +      await validateCaptureSet(
    +        capture,
    +        outputDirectory,
    +        capture.scenes.length === capture.device.scenes.length,
    +      );
    +    }
    +
    +    NodeProcess.stdout.write(
    +      `\nDone. Upload-ready screenshots are in ${NodePath.relative(REPO_ROOT, outputDirectory)}/apple/ and ${NodePath.relative(REPO_ROOT, outputDirectory)}/google-play/.\n`,
    +    );
    +    if (options.keepRunning) {
    +      const serverSummary = showcaseEnvironments
    +        .map((environment) => `${environment.label}:${environment.port}`)
    +        .join(", ");
    +      NodeProcess.stdout.write(
    +        `Showcase environments kept at ${showcaseRootDir} (${serverSummary}).\n`,
    +      );
    +    }
    +  } finally {
    +    if (options.keepRunning) {
    +      metro?.unref();
    +      for (const server of showcaseServers) server.unref();
    +    } else {
    +      for (const cleanup of androidCleanups) {
    +        await cleanupAndroidViewport(cleanup.device, cleanup.serial).catch(() => undefined);
    +        if (cleanup.startedByRunner) {
    +          await runAdb(cleanup.serial, ["emu", "kill"]).catch(() => undefined);
    +        }
    +      }
    +      for (const cleanup of iosCleanups) {
    +        if (cleanup.startedByRunner || cleanup.createdByRunner) {
    +          await runCommand("xcrun", ["simctl", "shutdown", cleanup.udid]).catch(() => undefined);
    +        }
    +        if (cleanup.createdByRunner) {
    +          await runCommand("xcrun", ["simctl", "delete", cleanup.udid]).catch(() => undefined);
    +        }
    +      }
    +      await Promise.all([
    +        ...(metro ? [stopProcess(metro)] : []),
    +        ...showcaseServers.map((server) => stopProcess(server)),
    +      ]);
    +      await NodeFSP.rm(showcaseRootDir, {
    +        recursive: true,
    +        force: true,
    +        maxRetries: 5,
    +        retryDelay: 100,
    +      });
    +    }
    +  }
    +}
    +
    +if (import.meta.main) {
    +  void main().catch((error: unknown) => {
    +    NodeProcess.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
    +    NodeProcess.exit(1);
    +  });
    +}
    diff --git a/scripts/package.json b/scripts/package.json
    index 510bb230b8c..629163d688e 100644
    --- a/scripts/package.json
    +++ b/scripts/package.json
    @@ -11,10 +11,12 @@
         "@t3tools/contracts": "workspace:*",
         "@t3tools/shared": "workspace:*",
         "effect": "catalog:",
    +    "pngjs": "7.0.0",
         "yaml": "catalog:"
       },
       "devDependencies": {
         "@effect/vitest": "catalog:",
    +    "@types/pngjs": "6.0.5",
         "vite-plus": "catalog:"
       }
     }