diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 2f9faba635e3..9df6050347e8 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -18,6 +18,7 @@ github:bil0000 github:binbandit github:Brechard github:btsouth +github:cestercian github:chrisdeeming github:chuks-qua github:cursoragent @@ -67,6 +68,7 @@ github:realAhmedRoach github:Rishet11 github:ryanrhughes github:saphid +github:scratchyone github:sethwebster github:shiroyasha9 github:shivamhwp @@ -80,3 +82,4 @@ github:vitalyiegorov github:Yash-Singh1 github:yashranaway github:Ymit24 +github:yordis diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index 5228b52965a7..2d97b1431960 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -78,7 +78,19 @@ jobs: working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: eas env:pull preview --non-interactive + run: | + eas env:pull preview --non-interactive + # EAS Update disables dotenv loading. Keep the downloaded native config + # in its process environment so Android build and OTA fingerprints match. + node --input-type=module <<'NODE' + import { appendFileSync, readFileSync } from "node:fs"; + import { parseEnv } from "node:util"; + const env = parseEnv(readFileSync(".env.local", "utf8")); + const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE; + if (googleServicesFile) { + appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`); + } + NODE - name: Deploy with fingerprint check if: steps.expo-token.outputs.present == 'true' diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index 0f9fa3dac13e..f75b752df5cf 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -76,6 +76,17 @@ jobs: echo "$vp_pnpm_bin" >> "$GITHUB_PATH" "$vp_pnpm_bin/pnpm" --version + - name: Install AXe + # Locks the simulator and answers the notification prompt for the + # agent-activity scene. + # Homebrew refuses third-party tap formulae until they are trusted; + # older releases have no trust command and install them as is. + run: | + brew tap cameroncooke/axe + if brew commands | grep -qx trust; then brew trust --formula cameroncooke/axe/axe; fi + brew install cameroncooke/axe/axe + axe --version + - name: Capture iOS showcase run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ac1d8be5a8d..9b4d7725aa39 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -259,13 +259,76 @@ jobs: - name: Typecheck run: vp run typecheck + # Keep tests on their own runners, using the same package split and server + # shards as CI, so the release check job does not spend its budget on tests. + test: + name: Release tests + needs: [preflight] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} + runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Ensure Electron runtime is installed + run: vp run --filter @t3tools/desktop ensure:electron + - uses: ./.github/actions/setup-apt-mirrors - name: Install browser secret helper build libraries - run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + run: | + sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config build-essential + + - name: Test + run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test + + test_server: + name: Release server tests ${{ matrix.shard }} + needs: [preflight] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} + runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3] + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 + with: + node-version-file: package.json + cache: true + run-install: true + # No Electron setup here: `t3` (apps/server) has no Electron dependency + # and none of its tests touch the runtime. Only the non-server `test` + # job, which covers @t3tools/desktop, needs the download. - name: Test - run: vp run test + run: vp run --filter t3 test --shard ${{ matrix.shard }}/${{ strategy.job-total }} relay_public_config: name: Resolve T3 Connect public config @@ -645,13 +708,15 @@ jobs: preflight, relay_public_config, quality, + test, + test_server, desktop_mac_arm64, desktop_linux_x64, desktop_linux_arm64, desktop_win_x64, desktop_win_arm64, ] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.outputs.relay_url != '' && needs.quality.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }} + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.outputs.relay_url != '' && needs.quality.result == 'success' && needs.test.result == 'success' && needs.test_server.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }} runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 15 permissions: diff --git a/AGENTS.md b/AGENTS.md index 337f2b0cda79..549660cb7a4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -159,6 +159,7 @@ Full glossary with file links: `docs/internals/glossary.md` ## Taste - Complexity belongs at the adapter boundary. Orchestration stays pure, UI stays dumb. +- `apps/web/src/components/ui` exports own their look. Pick a `variant` or `size`; do not restyle one with `className`. If none fits and the look is a generic concept, add a variant to the component; a look that belongs to one feature stays in that feature's own component, not in `components/ui`. Layout classes (width, flex, margin, position) belong on the parent. `shadcn/no-restyle` fails lint on violations. - Inferred types over annotations. `any` is the enemy. - Comments describe how a thing is used, and move when the code moves. To be used mostly to describe functions, not to annotate every line of behavior. - Our users drive agents all day and notice a dropped frame, a lying spinner, and a stale label. No continuously repainting animations; they peg the GPU on high-refresh displays. diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index d7ccfc43b185..c91127241968 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -427,6 +427,31 @@ describe("DesktopObservability", () => { ); }); + it.effect("exports kill switch warnings through the configured logger", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir, true, { + T3CODE_OTLP_LOGS_URL: "https://collector.example.com/v1/logs", + OTEL_SDK_DISABLED: "1", + }); + + yield* Effect.scoped( + Effect.void.pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + assert.include(requests[0]?.body ?? "", "OTEL_SDK_DISABLED=1 was read as false"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + ); + }); + it.effect("reads every signal endpoint from Settings when the environment names none", () => { const requests: Array = []; return Effect.gen(function* () { @@ -491,4 +516,33 @@ describe("DesktopObservability", () => { Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), ); }); + + it.effect("stops every export when the OpenTelemetry SDK is disabled", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir, true, { + OTEL_SDK_DISABLED: "true", + }); + yield* writeObservabilitySettings(environmentLayer, { + otlpTracesUrl: "https://settings.example.com/v1/traces", + otlpLogsUrl: "https://settings.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop log stays local when disabled").pipe( + Effect.withSpan("desktop-disabled-test"), + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + assert.lengthOf(requests, 0); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + ); + }); }); diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index d6cc42a41b29..811093a5a842 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -620,10 +620,6 @@ const telemetryLayer = Layer.unwrap( const environment = yield* DesktopEnvironment.DesktopEnvironment; const resolved = yield* resolveOtlpExport; - for (const warning of resolved.warnings) { - yield* Effect.logWarning(warning); - } - const otlpResource = otlpResourceFor(resolved.resource); // `Logger.layer` writes the whole logger set rather than adding to it, so @@ -718,7 +714,12 @@ const telemetryLayer = Layer.unwrap( // : { temporality: resolved.metrics.temporality }), // }).pipe(Layer.provide(serializationFor(resolved.metrics))); - return Layer.mergeAll(loggerLayer, tracerLayer); + // Logged once the loggers above are installed, so the warnings use them. + const otelWarningsLayer = Layer.effectDiscard( + Effect.forEach(resolved.warnings, (warning) => Effect.logWarning(warning)), + ); + + return otelWarningsLayer.pipe(Layer.provideMerge(Layer.mergeAll(loggerLayer, tracerLayer))); }), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index f78a34ac9ba6..cf9eb6b07064 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -1056,6 +1056,49 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("resolveWsl carries the kill switch into the distro", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + const previousWslEnv = process.env.WSLENV; + const previousDisabled = process.env.OTEL_SDK_DISABLED; + try { + delete process.env.WSLENV; + process.env.OTEL_SDK_DISABLED = "true"; + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); + + assert.equal(config.env.OTEL_SDK_DISABLED, "true"); + assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_SDK_DISABLED"); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), + getDistroIp: () => Option.some("172.27.0.99"), + }), + ), + Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), + ), + ), + ); + } finally { + restoreEnv("WSLENV", previousWslEnv); + restoreEnv("OTEL_SDK_DISABLED", previousDisabled); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("resolveWsl preserves existing WSLENV entries when forwarding backend secrets", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 508a5c296898..0c2cb2bb7d17 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -105,7 +105,7 @@ describe("ElectronProtocol", () => { ); assert.include( response.headers.get("content-security-policy") ?? "", - "connect-src 'self' http: https: ws: wss:", + "connect-src 'self' blob: http: https: ws: wss:", ); assert.include( response.headers.get("content-security-policy") ?? "", @@ -255,7 +255,14 @@ describe("ElectronProtocol", () => { "https://clerk.t3.codes", "https://challenges.cloudflare.com", ]); - assert.deepEqual(directives["connect-src"], ["'self'", "http:", "https:", "ws:", "wss:"]); + assert.deepEqual(directives["connect-src"], [ + "'self'", + "blob:", + "http:", + "https:", + "ws:", + "wss:", + ]); assert.deepEqual(directives["img-src"], [ "'self'", "t3code:", diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 96cd462bd156..c3cecf88b2e4 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -84,7 +84,8 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat // the build-configured Clerk, relay, and OTLP endpoints. Those environment // origins are not known when this response policy is created, so restrict // connections by the network schemes the client supports instead of by host. - const connectSources = ["'self'", "http:", "https:", "ws:", "wss:"]; + // GLTFLoader fetches embedded textures through blob URLs after parsing the model. + const connectSources = ["'self'", "blob:", "http:", "https:", "ws:", "wss:"]; return [ "default-src 'self'", diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 226793657848..e8a688c189a3 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -11,6 +11,7 @@ export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text"; export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; +export const TRACKPAD_SCROLL_END_CHANNEL = "desktop:trackpad-scroll-end"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; export const DESKTOP_APP_ACTIVATION_READY_CHANNEL = "desktop:app-activation-ready"; @@ -113,3 +114,5 @@ export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; export const MAC_PERMISSION_HELPER_CHANNEL = "desktop:mac-permission-helper"; export const CHECK_SYSTEM_PERMISSION_CHANNEL = "desktop:check-system-permission"; + +export const PREVIEW_RECORDING_INPUT_CHANNEL = "desktop:preview-recording-input"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 5fb7eff99fc6..36cf7c9abe15 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -30,11 +30,13 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import * as Option from "effect/Option"; import * as NodeURL from "node:url"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts"; import * as PreviewManager from "../../preview/Manager.ts"; +import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts"; import * as IpcChannels from "../channels.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; @@ -50,6 +52,9 @@ export const installPreviewEventForwarding = Effect.fn( yield* manager.subscribeRecordingFrames((frame) => electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame), ); + yield* manager.subscribeRecordingInputs((event) => + electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_INPUT_CHANNEL, event), + ); yield* manager.subscribePointerEvents((event) => electronWindow.sendAll(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event), ); @@ -180,11 +185,21 @@ export const cancelPickElement = tabMethod( "desktop.ipc.preview.cancelPickElement", (manager, tabId) => manager.cancelPickElement(tabId), ); -export const startRecording = tabMethod( - IpcChannels.PREVIEW_RECORDING_START_CHANNEL, - "desktop.ipc.preview.startRecording", - (manager, tabId) => manager.startRecording(tabId), -); +export const startRecording = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_RECORDING_START_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.startRecording")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + const store = yield* DesktopClientSettings.DesktopClientSettings; + const settings = yield* store.get; + const options = Option.map(settings, (value) => ({ + showKeyPresses: value.browserRecordingShowKeyPresses, + showMousePresses: value.browserRecordingShowMousePresses, + })); + yield* manager.startRecording(tabId, Option.getOrUndefined(options)); + }), +}); export const stopRecording = tabMethod( IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, "desktop.ipc.preview.stopRecording", diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index bef919be886c..0b4f5d8bfc18 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -2,6 +2,7 @@ import type { DesktopBridge, DroppedFileHandle, DesktopPreviewPointerEvent, + DesktopPreviewRecordingInputEvent, DesktopPreviewRecordingFrame, DesktopPreviewTabState, DesktopSnapShotEvent, @@ -79,6 +80,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.on(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); return () => ipcRenderer.removeListener(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); }, + onTrackpadScrollEnd: (listener) => { + const handler = () => listener(); + ipcRenderer.on(IpcChannels.TRACKPAD_SCROLL_END_CHANNEL, handler); + return () => ipcRenderer.removeListener(IpcChannels.TRACKPAD_SCROLL_END_CHANNEL, handler); + }, getSystemLocale: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); return typeof result === "string" ? result : null; @@ -339,6 +345,15 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL, { tabId }), }, recording: { + onInput: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, event: unknown) => { + if (typeof event !== "object" || event === null) return; + listener(event as DesktopPreviewRecordingInputEvent); + }; + ipcRenderer.on(IpcChannels.PREVIEW_RECORDING_INPUT_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(IpcChannels.PREVIEW_RECORDING_INPUT_CHANNEL, wrappedListener); + }, startScreencast: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId }), stopScreencast: (tabId) => diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts index cc7aac6cfd30..0420e755d92b 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts @@ -7,10 +7,6 @@ vi.mock("@napi-rs/keyring", () => { throw new Error("Cannot find native binding"); }); -it("loads browser import code without a keyring native binding", async () => { - await expect(import("./ChromiumKeys.ts")).resolves.toBeDefined(); -}); - it.effect("reports an unavailable keychain when the macOS binding cannot load", () => Effect.gen(function* () { const { ChromiumKeyError, resolveChromiumKeys } = yield* Effect.promise( diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index e63597b71efc..1a73bb30f29e 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -5,3 +5,8 @@ export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; export const HUMAN_INPUT_CHANNEL = "preview:human-input"; export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate"; +export const RECORDING_CURSOR_CHANNEL = "preview:recording-cursor"; +export const RECORDING_POINTER_CHANNEL = "preview:recording-pointer"; +export const RECORDING_KEY_CHANNEL = "preview:recording-key"; +export const RECORDING_INPUT_CHANNEL = "preview:recording-input"; +export const RECORDING_CONTROLLER_CHANNEL = "preview:recording-controller"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 7b76a1b8003a..304efeafefe9 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1,7 +1,10 @@ import * as NodeVM from "node:vm"; import { it as effectIt } from "@effect/vitest"; import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; -import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts"; +import type { + DesktopPreviewRecordingFrame, + DesktopPreviewRecordingInputEvent, +} from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; @@ -2885,6 +2888,165 @@ describe("PreviewManager", () => { ), ); + effectIt.effect( + "restores the native cursor when recording startup fails, then allows a retry", + () => + withManager((manager) => + Effect.gen(function* () { + const host = makeTestHostWebContents(); + host.executeJavaScript.mockResolvedValueOnce(false); + let cursorActive = false; + const cursorAtCapture: boolean[] = []; + const contents = Object.assign( + makeTestPreviewWebContents( + async () => { + cursorAtCapture.push(cursorActive); + return { + toJPEG: () => Buffer.from("frame"), + getSize: () => ({ width: 800, height: 600 }), + }; + }, + 42, + host, + ), + { + send: (channel: string, active: unknown) => { + if (channel === "preview:recording-cursor") cursorActive = active === true; + }, + }, + ); + fromId.mockReturnValue(contents as never); + yield* manager.createTab("tab_cursor"); + yield* manager.registerWebview("tab_cursor", 42); + const failed = yield* Effect.exit(manager.startRecording("tab_cursor")); + expect(Exit.isFailure(failed)).toBe(true); + expect(cursorAtCapture).toEqual([true]); + expect(cursorActive).toBe(false); + + yield* manager.startRecording("tab_cursor"); + expect(cursorAtCapture).toEqual([true, true]); + expect(cursorActive).toBe(true); + yield* manager.stopRecording("tab_cursor"); + expect(cursorActive).toBe(false); + }), + ), + ); + + effectIt.effect("restores the recording cursor after navigation only while recording", () => + withManager((manager) => + Effect.gen(function* () { + const listeners = new Map void>(); + let cursorActive = false; + let cursorUpdated: (() => void) | undefined; + let inputOptions: unknown; + const options = { showKeyPresses: true, showMousePresses: false }; + const contents = Object.assign( + makeTestPreviewWebContents(async () => ({ + toJPEG: () => Buffer.from("frame"), + getSize: () => ({ width: 800, height: 600 }), + })), + { + on: (event: string, listener: () => void) => listeners.set(event, listener), + send: (channel: string, active: unknown, recordingOptions: unknown) => { + if (channel !== "preview:recording-cursor") return; + cursorActive = active === true; + inputOptions = recordingOptions; + cursorUpdated?.(); + }, + }, + ); + fromId.mockReturnValue(contents as never); + yield* manager.createTab("tab_cursor_reload"); + yield* manager.registerWebview("tab_cursor_reload", 42); + yield* manager.startRecording("tab_cursor_reload", options); + for (const recording of [true, false]) { + if (!recording) yield* manager.stopRecording("tab_cursor_reload"); + // A new document has lost the previous preload's cursor overlay. + cursorActive = false; + const restored = new Promise((resolve) => { + cursorUpdated = resolve; + }); + listeners.get("dom-ready")?.(); + yield* Effect.promise(() => restored); + cursorUpdated = undefined; + expect(cursorActive).toBe(recording); + expect(inputOptions).toEqual(recording ? options : undefined); + } + }), + ), + ); + + effectIt.effect("gates recording decorations and isolates failed subscribers", () => + withManager((manager) => + Effect.gen(function* () { + const callbacks = new Map< + string, + (event: unknown, input: unknown) => Fiber.Fiber | undefined + >(); + const contents = Object.assign( + makeTestPreviewWebContents(async () => ({ + toJPEG: () => Buffer.from("frame"), + getSize: () => ({ width: 800, height: 600 }), + })), + { + ipc: { + on: ( + channel: string, + callback: (event: unknown, input: unknown) => Fiber.Fiber | undefined, + ) => callbacks.set(channel, callback), + off: vi.fn(), + }, + }, + ); + fromId.mockReturnValue(contents as never); + yield* manager.createTab("tab_recording_input"); + yield* manager.registerWebview("tab_recording_input", 42); + const received: DesktopPreviewRecordingInputEvent[] = []; + yield* manager.subscribeRecordingInputs(() => Effect.die("renderer unavailable")); + yield* manager.subscribeRecordingInputs((event) => + Effect.sync(() => { + received.push(event); + }), + ); + const send = (input: unknown) => + Effect.gen(function* () { + const fiber = callbacks.get("preview:recording-input")?.(null, input); + if (fiber) yield* Fiber.join(fiber); + }); + const key = { type: "key", label: "⌘C", held: true, width: 800 }; + const pointer = { + type: "pointer", + phase: "down", + x: 120, + y: 80, + width: 800, + height: 600, + }; + yield* send(key); + expect(received).toEqual([]); + yield* manager.startRecording("tab_recording_input", { + showKeyPresses: true, + showMousePresses: false, + }); + yield* send(key); + yield* send(pointer); + yield* send({ ...key, width: 0 }); + expect(received).toEqual([{ tabId: "tab_recording_input", input: key }]); + yield* manager.stopRecording("tab_recording_input"); + yield* send(key); + expect(received).toHaveLength(1); + yield* manager.startRecording("tab_recording_input", { + showKeyPresses: false, + showMousePresses: true, + }); + yield* send(key); + yield* send(pointer); + expect(received.at(-1)).toEqual({ tabId: "tab_recording_input", input: pointer }); + expect(received).toHaveLength(2); + }), + ), + ); + effectIt.effect("continues native recording when the source warmup fails", () => withManager((manager) => Effect.gen(function* () { @@ -3853,88 +4015,108 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("emits the resolved pointer target before dispatching an automation click", () => - withManager((manager) => - Effect.gen(function* () { - let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; - const activity: string[] = []; - const sendCommand = vi.fn(async (method: string, params?: Record) => { - if (method === "Runtime.evaluate") { - return { - result: { - value: { width: 800, height: 600 }, - }, - }; - } - if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") { - activity.push("mousePressed"); - humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 }); - } - return undefined; - }); - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoading: () => false, - isDevToolsOpened: () => false, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - off: vi.fn(), - ipc: { - on: vi.fn((channel: string, listener: typeof humanInput) => { - if (channel === "preview:human-input") humanInput = listener; - }), - off: vi.fn(), - }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand, + effectIt.effect( + "records the resolved pointer target before dispatching an automation click", + () => + withManager((manager) => + Effect.gen(function* () { + let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; + const activity: string[] = []; + const sendCommand = vi.fn(async (method: string, params?: Record) => { + if (method === "Runtime.evaluate") { + return { + result: { + value: { width: 800, height: 600 }, + }, + }; + } + if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") { + activity.push("mousePressed"); + humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 }); + } + return undefined; + }); + fromId.mockReturnValue({ + id: 42, + hostWebContents: makeTestHostWebContents(), + capturePage: vi.fn(async () => ({ toPNG: () => Buffer.from("frame") })), + setBackgroundThrottling: vi.fn(), + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), - }, - } as never); - - yield* manager.subscribePointerEvents((event) => - Effect.sync(() => { - activity.push(event.phase); - }), - ); - yield* manager.createTab("tab_1"); - yield* manager.registerWebview("tab_1", 42); - const click = yield* manager - .automationClick("tab_1", { x: 120, y: 80 }) - .pipe(Effect.forkChild({ startImmediately: true })); - yield* TestClock.adjust(200); - yield* Fiber.join(click); + ipc: { + on: vi.fn((channel: string, listener: typeof humanInput) => { + if (channel === "preview:human-input") humanInput = listener; + }), + off: vi.fn(), + }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never); - expect(activity).toEqual(["move", "click", "mousePressed"]); - expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { - type: "mousePressed", - x: 120, - y: 80, - button: "left", - clickCount: 1, - }); - expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { - type: "mouseReleased", - x: 120, - y: 80, - button: "left", - clickCount: 1, - }); - }), - ), + yield* manager.subscribePointerEvents((event) => + Effect.sync(() => { + activity.push(event.phase); + }), + ); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + yield* manager.startRecording("tab_1"); + const click = yield* manager + .automationClick("tab_1", { x: 120, y: 80 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(200); + yield* Fiber.join(click); + + expect(activity).toEqual(["move", "click", "mousePressed"]); + expect( + webviewSend.mock.calls + .filter(([channel]) => channel === "preview:recording-controller") + .map(([, controller]) => controller), + ).toEqual(["agent", "none"]); + + const recordedPointer = webviewSend.mock.calls + .filter(([channel]) => channel === "preview:recording-pointer") + .map(([, event]) => event); + expect(recordedPointer).toEqual([ + expect.objectContaining({ phase: "move", x: 120, y: 80 }), + expect.objectContaining({ phase: "click", x: 120, y: 80 }), + ]); + expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { + type: "mousePressed", + x: 120, + y: 80, + button: "left", + clickCount: 1, + }); + yield* manager.stopRecording("tab_1"); + expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { + type: "mouseReleased", + x: 120, + y: 80, + button: "left", + clickCount: 1, + }); + }), + ), ); effectIt.effect("types in background webviews and enables native key input", () => @@ -4230,6 +4412,9 @@ describe("PreviewManager", () => { }); fromId.mockReturnValue({ id: 42, + hostWebContents: makeTestHostWebContents(), + capturePage: vi.fn(async () => ({ toPNG: () => Buffer.from("frame") })), + setBackgroundThrottling: vi.fn(), isDestroyed: () => false, getType: () => "webview", getURL: () => "https://example.com", @@ -4263,6 +4448,7 @@ describe("PreviewManager", () => { yield* manager.createTab("tab_1"); yield* manager.registerWebview("tab_1", 42); + yield* manager.startRecording("tab_1"); const click = yield* manager .automationClick("tab_1", { x: 120, y: 80 }) @@ -4270,6 +4456,12 @@ describe("PreviewManager", () => { yield* TestClock.adjust(200); const exit = yield* Fiber.await(click); expect(Exit.isFailure(exit)).toBe(true); + expect( + webviewSend.mock.calls + .filter(([channel]) => channel === "preview:recording-controller") + .map(([, controller]) => controller), + ).toEqual(["agent", "human", "none"]); + yield* manager.stopRecording("tab_1"); if (Exit.isSuccess(exit)) return; const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); expect(error).toMatchObject({ diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index a2e34fe54736..468c47065315 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -6,7 +6,10 @@ * here). Single layer-scoped browser session partition. */ import * as NodeCrypto from "node:crypto"; -import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; +import { + DesktopPreviewRecordingInputSchema, + DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, +} from "@t3tools/contracts"; import type { DesktopPreviewAnnotationTheme, DesktopPreviewAutomationStatus, @@ -18,6 +21,7 @@ import type { PreviewAnnotationSubmissionResult, DesktopPreviewRecordingArtifact, DesktopPreviewRecordingFrame, + DesktopPreviewRecordingInputEvent, DesktopPreviewScreenshotArtifact, DesktopPreviewTabDefaults, PreviewAutomationClickInput, @@ -71,6 +75,11 @@ import { ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, MOUSE_NAVIGATE_CHANNEL, + RECORDING_CURSOR_CHANNEL, + RECORDING_POINTER_CHANNEL, + RECORDING_KEY_CHANNEL, + RECORDING_INPUT_CHANNEL, + RECORDING_CONTROLLER_CHANNEL, START_PICK_CHANNEL, } from "./GuestProtocol.ts"; import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; @@ -81,6 +90,7 @@ import { previewAutomationEditingCommandExpression, } from "./PreviewKeyboard.ts"; import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts"; +import { DEFAULT_RECORDING_INPUT_OPTIONS, type RecordingInputOptions } from "./RecordingInput.ts"; export type PreviewNavStatus = | { kind: "Idle" } @@ -437,6 +447,7 @@ interface ManagedListeners { type FrameCaptureConsumer = "picture-in-picture" | "recording"; interface FrameCaptureSession { + readonly recordingInputOptions?: RecordingInputOptions; readonly scope: Scope.Closeable | null; readonly consumers: ReadonlySet; readonly unthrottledWebContentsIds: ReadonlySet; @@ -485,6 +496,10 @@ interface BrowserDiagnostics { readonly requests: ReadonlyMap; } +const isRecordingInput = Schema.is(DesktopPreviewRecordingInputSchema); + +type RecordingInputListener = (event: DesktopPreviewRecordingInputEvent) => Effect.Effect; + type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect; interface ExpectedAgentInput { @@ -636,6 +651,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const attachedRef = yield* Ref.make>(new Map()); const listenersRef = yield* Ref.make>(new Set()); const pointerEventListenersRef = yield* Ref.make>(new Set()); + const recordingInputListenersRef = yield* Ref.make>( + new Set(), + ); const recordingFrameListenersRef = yield* Ref.make>( new Set(), ); @@ -821,6 +839,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return Effect.succeed([undefined, sessions] as const); } return setFrameCaptureWebContentsBackgroundThrottling(wc, false).pipe( + Effect.tap(() => + Effect.gen(function* () { + if (!current.consumers.has("recording")) return; + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + yield* attempt({ operation: "recording.cursor", tabId, webContentsId: wc.id }, () => + wc.send( + RECORDING_CURSOR_CHANNEL, + true, + current.recordingInputOptions, + tab?.controller, + ), + ); + }), + ), Effect.map( () => [ @@ -846,6 +878,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (!current || !current.consumers.has(consumer)) { return [undefined, sessions] as const; } + if (consumer === "recording") { + yield* Effect.forEach(current.unthrottledWebContentsIds, (id) => + attempt({ operation: "recording.cursor", tabId, webContentsId: id }, () => { + const contents = webContents.fromId(id); + if (contents && !contents.isDestroyed()) + contents.send(RECORDING_CURSOR_CHANNEL, false); + }).pipe(Effect.ignore), + ); + } const consumers = new Set(current.consumers); consumers.delete(consumer); if (consumers.size > 0) { @@ -896,7 +937,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const deliverEvent = ( - eventKind: "state-change" | "recording-frame" | "pointer-event", + eventKind: "state-change" | "recording-frame" | "recording-input" | "pointer-event", tabId: string, delivery: () => Effect.Effect, ) => @@ -933,6 +974,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const update = Effect.fn("PreviewManager.update")(function* ( tabId: string, patch: Partial, + humanPoint?: { readonly x: number; readonly y: number }, ) { const updatedAt = yield* currentIso; const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { @@ -950,7 +992,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function // can commit between the modify above and here, and republishing this // snapshot would roll the UI back to a value that writer will not send // again because it suppresses unchanged audibility. - if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value); + if (Option.isSome(next)) { + if (patch.controller !== undefined && next.value.webContentsId != null) { + const capture = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); + const webContentsId = next.value.webContentsId; + if (capture?.consumers.has("recording")) { + yield* attempt({ operation: "recording.controller", tabId }, () => { + const contents = webContents.fromId(webContentsId); + if (contents && !contents.isDestroyed()) + contents.send(RECORDING_CONTROLLER_CHANNEL, patch.controller, humanPoint); + }).pipe(Effect.ignore); + } + } + yield* emitIfCurrent(tabId, next.value); + } }); /** @@ -1728,6 +1783,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const sync = () => runFork(syncState(true)); const syncNavigation = () => runFork(syncState(false, true)); const syncInPageNavigation = () => runFork(syncState(false)); + const restoreRecordingCursor = () => + runFork( + Effect.gen(function* () { + const session = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); + if (!wc.isDestroyed()) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + wc.send( + RECORDING_CURSOR_CHANNEL, + session?.consumers.has("recording") ?? false, + session?.recordingInputOptions, + tab?.controller, + ); + } + }), + ); const navigationStarted = ( event: Electron.Event, ) => { @@ -1860,13 +1930,37 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function copy.set(tabId, (epochs.get(tabId) ?? 0) + 1); }), ); - yield* update(tabId, { controller: "human" }); + yield* update( + tabId, + { controller: "human" }, + isPreviewInputSignal(rawSignal) && rawSignal.kind === "pointer" + ? { x: rawSignal.x, y: rawSignal.y } + : undefined, + ); yield* Effect.sleep(750); const tabs = yield* SynchronizedRef.get(tabsRef); if (tabs.get(tabId)?.controller === "human") { yield* update(tabId, { controller: "none" }); } }); + const recordingInput = (_event: unknown, input: unknown) => { + if (!isRecordingInput(input)) return; + return runFork( + Effect.gen(function* () { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + const capture = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); + if (tab?.webContentsId !== wc.id || !capture?.consumers.has("recording")) return; + if (input.type === "key" && !capture.recordingInputOptions?.showKeyPresses) return; + if (input.type === "pointer" && !capture.recordingInputOptions?.showMousePresses) return; + const listeners = yield* Ref.get(recordingInputListenersRef); + yield* Effect.forEach( + listeners, + (listener) => deliverEvent("recording-input", tabId, () => listener({ tabId, input })), + { discard: true }, + ); + }), + ); + }; const humanInput = (_event: unknown, rawSignal?: unknown): void => { runFork(handleHumanInput(rawSignal)); }; @@ -1928,11 +2022,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("page-favicon-updated", faviconUpdated as never); wc.off("did-start-loading", sync); wc.off("did-stop-loading", sync); + wc.off("dom-ready", restoreRecordingCursor); wc.off("did-fail-load", failed as never); wc.off("audio-state-changed", audioStateChanged); wc.off("did-create-window", windowCreated); wc.off("before-input-event", beforeInput); wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); + wc.ipc.off(RECORDING_INPUT_CHANNEL, recordingInput); wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); }).pipe(Effect.ignore), ); @@ -1948,9 +2044,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("page-favicon-updated", faviconUpdated as never); wc.on("did-start-loading", sync); wc.on("did-stop-loading", sync); + wc.on("dom-ready", restoreRecordingCursor); wc.on("did-fail-load", failed as never); wc.on("audio-state-changed", audioStateChanged); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); + wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput); wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); wc.setWindowOpenHandler((details) => { if (previewWindowOpenAction(details) === "popup") { @@ -3405,7 +3503,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); }; - const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { + const startRecording = Effect.fn("PreviewManager.startRecording")(function* ( + tabId: string, + options: RecordingInputOptions = DEFAULT_RECORDING_INPUT_OPTIONS, + ) { if ((yield* Ref.get(closingTabIdsRef)).has(tabId)) { return yield* new PreviewTabNotFoundError({ tabId }); } @@ -3413,11 +3514,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function tabId, Effect.gen(function* () { yield* startFrameCapture(tabId, "recording"); + yield* SynchronizedRef.update(frameCaptureSessionsRef, (sessions) => + replaceMap(sessions, (copy) => { + const current = copy.get(tabId); + if (current) copy.set(tabId, { ...current, recordingInputOptions: options }); + }), + ); const wc = yield* requireWebContents(tabId); const requestWebContents = wc.hostWebContents; if (requestWebContents === null) { return yield* new PreviewMainWindowClosedError({ tabId }); } + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + yield* attempt({ operation: "recording.cursor", tabId, webContentsId: wc.id }, () => + wc.send(RECORDING_CURSOR_CHANNEL, true, options, tab?.controller), + ); yield* attemptPromise( { operation: "recording.warmSource", @@ -3720,6 +3831,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const emitPointerEvent = Effect.fn("PreviewManager.emitPointerEvent")(function* ( event: DesktopPreviewPointerEvent, ) { + const recording = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(event.tabId); + const tab = (yield* SynchronizedRef.get(tabsRef)).get(event.tabId); + const webContentsId = tab?.webContentsId; + if (recording?.consumers.has("recording") && webContentsId != null) { + yield* attempt({ operation: "recording.pointer", tabId: event.tabId }, () => { + const contents = webContents.fromId(webContentsId); + if (contents && !contents.isDestroyed()) contents.send(RECORDING_POINTER_CHANNEL, event); + }); + } const listeners = yield* Ref.get(pointerEventListenersRef); yield* Effect.forEach( listeners, @@ -4110,6 +4230,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const keySequence = makePreviewAutomationNativeKeySequence(input, { isMac: hostPlatform === "darwin", }); + const recording = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); + if (recording?.consumers.has("recording") && recording.recordingInputOptions?.showKeyPresses) { + yield* attempt({ operation: "recording.key", tabId, webContentsId: wc.id }, () => + wc.send(RECORDING_KEY_CHANNEL, { + key: keySequence.signal.key || input.key, + metaKey: input.modifiers?.includes("Meta") ?? false, + ctrlKey: input.modifiers?.includes("Control") ?? false, + altKey: input.modifiers?.includes("Alt") ?? false, + shiftKey: input.modifiers?.includes("Shift") ?? false, + }), + ); + } // CDP keyboard dispatch follows the embedder's focused renderer, and // WebContents.focus() is a no-op for webview guests. Native input targets // this guest's widget directly, so Enter cannot submit the host composer. @@ -4470,6 +4602,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function Ref.set(expectedAgentInputsRef, new Map()), Ref.set(pointerEventListenersRef, new Set()), Ref.set(recordingFrameListenersRef, new Set()), + Ref.set(recordingInputListenersRef, new Set()), ], { discard: true }, ); @@ -4513,6 +4646,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function stopRecording, subscribePointerEvents: (listener: PointerEventListener) => subscribe(pointerEventListenersRef, listener), + subscribeRecordingInputs: (listener: RecordingInputListener) => + subscribe(recordingInputListenersRef, listener), subscribeRecordingFrames: (listener: RecordingFrameListener) => subscribe(recordingFrameListenersRef, listener), subscribeStateChanges: (listener: Listener) => subscribe(listenersRef, listener), @@ -4877,7 +5012,10 @@ export class PreviewManager extends Context.Service< readonly copyArtifactToClipboard: (path: string) => Effect.Effect; readonly openPictureInPicture: (tabId: string) => Effect.Effect; readonly closePictureInPicture: (tabId: string) => Effect.Effect; - readonly startRecording: (tabId: string) => Effect.Effect; + readonly startRecording: ( + tabId: string, + options?: RecordingInputOptions, + ) => Effect.Effect; readonly stopRecording: (tabId: string) => Effect.Effect; readonly saveRecording: ( tabId: string, @@ -4918,6 +5056,9 @@ export class PreviewManager extends Context.Service< readonly subscribePointerEvents: ( listener: PointerEventListener, ) => Effect.Effect; + readonly subscribeRecordingInputs: ( + listener: RecordingInputListener, + ) => Effect.Effect; readonly subscribeRecordingFrames: ( listener: RecordingFrameListener, ) => Effect.Effect; @@ -5011,6 +5152,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { subscribeStateChanges: operations.subscribeStateChanges, subscribePointerEvents: operations.subscribePointerEvents, subscribeRecordingFrames: operations.subscribeRecordingFrames, + subscribeRecordingInputs: operations.subscribeRecordingInputs, }); }).pipe(Effect.withSpan("PreviewManager.make")); diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index 6155c4119ec8..78351e9d79c3 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -16,6 +16,8 @@ import type { import { resolveAnnotationSubmission } from "./AnnotationKeyboard.ts"; import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts"; +import { installRecordingCursor } from "./RecordingCursor.ts"; +import { DEFAULT_RECORDING_INPUT_OPTIONS } from "./RecordingInput.ts"; import { ANNOTATION_CAPTURED_CHANNEL, ANNOTATION_THEME_CHANNEL, @@ -23,6 +25,11 @@ import { ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, MOUSE_NAVIGATE_CHANNEL, + RECORDING_CURSOR_CHANNEL, + RECORDING_POINTER_CHANNEL, + RECORDING_KEY_CHANNEL, + RECORDING_INPUT_CHANNEL, + RECORDING_CONTROLLER_CHANNEL, START_PICK_CHANNEL, } from "./GuestProtocol.ts"; const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; @@ -35,6 +42,80 @@ const ELEMENT_CONTEXT_TIMEOUT_MS = 5_000; const CONTENT_LAYER_Z_INDEX = 1; const CHROME_LAYER_Z_INDEX = 10; +let recordingCursor: ReturnType | null = null; +ipcRenderer.on( + RECORDING_CURSOR_CHANNEL, + (_event, active: unknown, inputOptions: unknown, controller: unknown) => { + if (active === true) { + const options = + typeof inputOptions === "object" && inputOptions !== null + ? { + showKeyPresses: + "showKeyPresses" in inputOptions && inputOptions.showKeyPresses === true, + showMousePresses: + "showMousePresses" in inputOptions && inputOptions.showMousePresses === true, + } + : DEFAULT_RECORDING_INPUT_OPTIONS; + recordingCursor ??= installRecordingCursor(document, window, options, (input) => + ipcRenderer.send(RECORDING_INPUT_CHANNEL, input), + ); + recordingCursor.setTheme(annotationTheme); + if (controller === "agent" || controller === "human" || controller === "none") + recordingCursor.setController(controller); + } else { + recordingCursor?.dispose(); + recordingCursor = null; + } + }, +); +ipcRenderer.on(RECORDING_CONTROLLER_CHANNEL, (_event, controller: unknown, point: unknown) => { + const humanPoint = + typeof point === "object" && + point !== null && + "x" in point && + typeof point.x === "number" && + Number.isFinite(point.x) && + "y" in point && + typeof point.y === "number" && + Number.isFinite(point.y) + ? { x: point.x, y: point.y } + : undefined; + if (controller === "agent" || controller === "human" || controller === "none") + recordingCursor?.setController(controller, humanPoint); +}); +ipcRenderer.on(RECORDING_KEY_CHANNEL, (_event, input: unknown) => { + if ( + typeof input !== "object" || + input === null || + !("key" in input) || + typeof input.key !== "string" + ) + return; + recordingCursor?.keyPress({ + key: input.key, + metaKey: "metaKey" in input && input.metaKey === true, + ctrlKey: "ctrlKey" in input && input.ctrlKey === true, + altKey: "altKey" in input && input.altKey === true, + shiftKey: "shiftKey" in input && input.shiftKey === true, + }); +}); +ipcRenderer.on(RECORDING_POINTER_CHANNEL, (_event, point: unknown) => { + if ( + typeof point === "object" && + point !== null && + "x" in point && + typeof point.x === "number" && + Number.isFinite(point.x) && + "y" in point && + typeof point.y === "number" && + Number.isFinite(point.y) + ) + recordingCursor?.move( + { x: point.x, y: point.y }, + "phase" in point && point.phase === "click" ? "click" : "move", + ); +}); + type AnnotationTool = "select" | "marquee" | "draw" | "erase"; interface SelectedElement { @@ -1361,6 +1442,7 @@ ipcRenderer.on(START_PICK_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme }); ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => { annotationTheme = theme; + recordingCursor?.setTheme(theme); activeSession?.applyTheme(theme); }); ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false)); diff --git a/apps/desktop/src/preview/RecordingCursor.ts b/apps/desktop/src/preview/RecordingCursor.ts new file mode 100644 index 000000000000..70b0a43be43a --- /dev/null +++ b/apps/desktop/src/preview/RecordingCursor.ts @@ -0,0 +1,197 @@ +import type { + DesktopPreviewAnnotationTheme, + DesktopPreviewRecordingInput, +} from "@t3tools/contracts"; + +import { + DEFAULT_RECORDING_INPUT_OPTIONS, + recordingKeyLabel, + recordingKeysAreSensitive, + type RecordingInputOptions, + type RecordingKeyPress, +} from "./RecordingInput.ts"; + +/** + * Chromium's capture cursor uses native window bounds, which do not follow a + * webview's CSS placement or scale. Draw it in the guest's coordinate space + * while recording, and make the native cursor transparent to avoid two cursors. + */ +export function installRecordingCursor( + document: Document, + window: Window, + options: RecordingInputOptions = DEFAULT_RECORDING_INPUT_OPTIONS, + emit: (input: DesktopPreviewRecordingInput) => void = () => {}, +) { + const style = document.createElement("style"); + style.textContent = + "html, html * { cursor: none !important; } @media (prefers-reduced-motion: reduce) { [data-t3code-recording-agent-cursor] { transition: none !important; } }"; + const cursor = document.createElement("div"); + cursor.setAttribute("aria-hidden", "true"); + cursor.setAttribute("data-t3code-recording-cursor", ""); + cursor.style.cssText = + "position:fixed;left:0;top:0;width:16px;height:24px;pointer-events:none;z-index:2147483647;display:none;"; + cursor.innerHTML = + ''; + const agentCursor = document.createElement("div"); + agentCursor.setAttribute("aria-hidden", "true"); + agentCursor.setAttribute("data-t3code-recording-agent-cursor", ""); + agentCursor.style.cssText = + "position:fixed;left:0;top:0;width:20px;height:20px;pointer-events:none;z-index:2147483647;display:none;filter:drop-shadow(0 1px 2px #0003);transition:transform 150ms ease-out,opacity 150ms ease-out;"; + // Match the MousePointer2 icon used by the live AgentBrowserCursor. + agentCursor.innerHTML = + ''; + document.documentElement.append(style, cursor, agentCursor); + let controller: "human" | "agent" | "none" = "none"; + let humanPoint: { readonly x: number; readonly y: number } | null = null; + const drawHuman = () => { + if (!humanPoint) return; + cursor.style.transform = `translate(${humanPoint.x}px, ${humanPoint.y}px)`; + cursor.style.display = "block"; + }; + let agentActive = false; + let agentTimer: number | undefined; + const setController = ( + next: typeof controller, + point?: { readonly x: number; readonly y: number }, + ) => { + if (point) humanPoint = point; + controller = next; + if (next === "agent") cursor.style.display = "none"; + if (next === "human") drawHuman(); + if (!agentActive) agentCursor.style.opacity = next === "human" ? "0.18" : "0.35"; + }; + const setTheme = ( + theme: Pick | null, + ) => { + agentCursor.style.setProperty("--recording-cursor-primary", theme?.primary ?? "#2563eb"); + agentCursor.style.setProperty("--recording-cursor-background", theme?.background ?? "white"); + }; + let lastKeyLabel: string | null = null; + let pointerHeld = false; + let pointerFrame: number | undefined; + let pendingPointer: DesktopPreviewRecordingInput | undefined; + const cancelPendingPointer = () => { + if (pointerFrame !== undefined) window.cancelAnimationFrame(pointerFrame); + pointerFrame = undefined; + pendingPointer = undefined; + }; + const keyPress = (input: RecordingKeyPress, held = false) => { + if (!options.showKeyPresses) return; + const label = recordingKeysAreSensitive(document) + ? null + : recordingKeyLabel(input, /Mac/.test(window.navigator.platform)); + lastKeyLabel = label; + emit({ type: "key", label, held, width: window.innerWidth }); + }; + const pointer = ( + point: { readonly x: number; readonly y: number }, + phase: "move" | "down" | "up" | "click", + ) => { + if (!options.showMousePresses || (phase === "move" && !pointerHeld)) return; + if (phase === "down") pointerHeld = true; + if (phase === "up") pointerHeld = false; + const input: DesktopPreviewRecordingInput = { + type: "pointer", + phase, + ...point, + width: window.innerWidth, + height: window.innerHeight, + }; + if (phase === "move") { + pendingPointer = input; + pointerFrame ??= window.requestAnimationFrame(() => { + pointerFrame = undefined; + if (pendingPointer) emit(pendingPointer); + pendingPointer = undefined; + }); + } else { + cancelPendingPointer(); + emit(input); + } + }; + const move = ( + point: { readonly x: number; readonly y: number }, + phase: "move" | "click" = "move", + ) => { + agentCursor.style.transform = `translate(${point.x}px, ${point.y}px)`; + agentCursor.style.display = "block"; + agentCursor.style.opacity = "1"; + agentActive = true; + window.clearTimeout(agentTimer); + agentTimer = window.setTimeout(() => { + agentActive = false; + agentCursor.style.opacity = controller === "human" ? "0.18" : "0.35"; + }, 700); + pointer(point, phase); + }; + const moveHuman = (point: { readonly x: number; readonly y: number }) => { + if (controller === "agent") return; + humanPoint = point; + drawHuman(); + }; + const pointerMove = (event: PointerEvent) => { + if (event.pointerType === "touch") return; + const point = { x: event.clientX, y: event.clientY }; + moveHuman(point); + pointer(point, "move"); + }; + const pointerDown = (event: PointerEvent) => { + if (event.pointerType === "touch") return; + moveHuman({ x: event.clientX, y: event.clientY }); + pointer({ x: event.clientX, y: event.clientY }, "down"); + }; + const pointerUp = (event: PointerEvent) => { + if (event.pointerType !== "touch") pointer({ x: event.clientX, y: event.clientY }, "up"); + }; + const keyDown = (event: KeyboardEvent) => { + if (event.isComposing || event.repeat) return; + keyPress(event, true); + }; + const keyUp = () => { + if (!options.showKeyPresses) return; + emit({ + type: "key", + label: recordingKeysAreSensitive(document) ? null : lastKeyLabel, + held: false, + width: window.innerWidth, + }); + }; + const hide = () => { + cursor.style.display = "none"; + pointerHeld = false; + cancelPendingPointer(); + if (options.showKeyPresses || options.showMousePresses) emit({ type: "clear" }); + }; + const leave = (event: PointerEvent) => { + if (event.relatedTarget === null) hide(); + }; + window.addEventListener("pointermove", pointerMove, true); + window.addEventListener("pointerdown", pointerDown, true); + window.addEventListener("pointerup", pointerUp, true); + window.addEventListener("pointercancel", pointerUp, true); + window.addEventListener("keydown", keyDown, true); + window.addEventListener("keyup", keyUp, true); + window.addEventListener("pointerout", leave, true); + window.addEventListener("blur", hide); + return { + move, + keyPress, + setController, + setTheme, + dispose: () => { + window.removeEventListener("pointermove", pointerMove, true); + window.removeEventListener("pointerdown", pointerDown, true); + window.removeEventListener("pointerup", pointerUp, true); + window.removeEventListener("pointercancel", pointerUp, true); + window.removeEventListener("keydown", keyDown, true); + window.removeEventListener("keyup", keyUp, true); + window.removeEventListener("pointerout", leave, true); + window.removeEventListener("blur", hide); + cancelPendingPointer(); + window.clearTimeout(agentTimer); + cursor.remove(); + agentCursor.remove(); + style.remove(); + }, + }; +} diff --git a/apps/desktop/src/preview/RecordingInput.test.ts b/apps/desktop/src/preview/RecordingInput.test.ts new file mode 100644 index 000000000000..062685ec2934 --- /dev/null +++ b/apps/desktop/src/preview/RecordingInput.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vite-plus/test"; +import { recordingKeyLabel, recordingKeysAreSensitive } from "./RecordingInput.ts"; + +const key = ( + value: string, + modifiers: Partial<{ + metaKey: boolean; + ctrlKey: boolean; + altKey: boolean; + shiftKey: boolean; + }> = {}, +) => ({ + key: value, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + ...modifiers, +}); + +describe("recording key labels", () => { + it("formats macOS and other-platform shortcuts", () => { + expect(recordingKeyLabel(key("c", { metaKey: true }), true)).toBe("⌘C"); + expect(recordingKeyLabel(key("c", { ctrlKey: true }), false)).toBe("Ctrl + C"); + expect(recordingKeyLabel(key("Tab", { altKey: true, shiftKey: true }), true)).toBe("⌥⇧⇥"); + }); + it("shows held modifiers once and labels navigation keys", () => { + expect(recordingKeyLabel(key("Meta", { metaKey: true }), true)).toBe("⌘"); + expect(recordingKeyLabel(key("Shift", { shiftKey: true }), false)).toBe("Shift"); + expect(recordingKeyLabel(key("ArrowLeft"), true)).toBe("←"); + expect(recordingKeyLabel(key(" "), false)).toBe("Space"); + }); + it.each(["Dead", "Unidentified", "Process", ""])("excludes composition key %s", (value) => { + expect(recordingKeyLabel(key(value), true)).toBeNull(); + }); +}); + +describe("recording key privacy", () => { + const field = (type: string) => ({ tagName: "INPUT", getAttribute: () => type }); + const sensitive = (activeElement: unknown) => + recordingKeysAreSensitive({ activeElement } as Document); + it("excludes password fields and their shadow-root focus", () => { + expect(sensitive(field("password"))).toBe(true); + expect(sensitive({ shadowRoot: { activeElement: field("password") } })).toBe(true); + expect(sensitive(field("text"))).toBe(false); + }); + it("excludes iframe focus whose field cannot be inspected", () => { + expect(sensitive({ tagName: "IFRAME" })).toBe(true); + expect(sensitive({ tagName: "SECRET-FIELD" })).toBe(true); + }); +}); diff --git a/apps/desktop/src/preview/RecordingInput.ts b/apps/desktop/src/preview/RecordingInput.ts new file mode 100644 index 000000000000..a69102121d4e --- /dev/null +++ b/apps/desktop/src/preview/RecordingInput.ts @@ -0,0 +1,58 @@ +export interface RecordingInputOptions { + readonly showKeyPresses: boolean; + readonly showMousePresses: boolean; +} + +export const DEFAULT_RECORDING_INPUT_OPTIONS: RecordingInputOptions = { + showKeyPresses: false, + showMousePresses: false, +}; + +export interface RecordingKeyPress { + readonly key: string; + readonly metaKey: boolean; + readonly ctrlKey: boolean; + readonly altKey: boolean; + readonly shiftKey: boolean; +} + +/** Formats a single chord without duplicating a modifier pressed on its own. */ +export function recordingKeyLabel(input: RecordingKeyPress, isMac: boolean): string | null { + if (["Dead", "Process", "Unidentified", ""].includes(input.key)) return null; + const modifiers = [ + input.ctrlKey || input.key === "Control" ? (isMac ? "⌃" : "Ctrl") : null, + input.altKey || input.key === "Alt" ? (isMac ? "⌥" : "Alt") : null, + input.shiftKey || input.key === "Shift" ? (isMac ? "⇧" : "Shift") : null, + input.metaKey || input.key === "Meta" ? (isMac ? "⌘" : "Win") : null, + ].filter((value) => value !== null); + const labels: Record = { + Enter: "↵", + Tab: "⇥", + Backspace: "⌫", + Delete: "⌦", + Escape: "Esc", + ArrowUp: "↑", + ArrowDown: "↓", + ArrowLeft: "←", + ArrowRight: "→", + " ": "Space", + Space: "Space", + }; + if (!["Control", "Alt", "Shift", "Meta"].includes(input.key)) { + modifiers.push( + labels[input.key] ?? (input.key.length === 1 ? input.key.toUpperCase() : input.key), + ); + } + return modifiers.join(isMac ? "" : " + "); +} + +/** Unknown iframe or closed-shadow focus is excluded because its field type cannot be checked. */ +export function recordingKeysAreSensitive(document: Document): boolean { + let element = document.activeElement; + while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement; + return ( + element?.tagName === "IFRAME" || + element?.tagName.includes("-") === true || + element?.getAttribute("type")?.toLowerCase() === "password" + ); +} diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index c724bb396cd8..205a72a55265 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -29,6 +29,7 @@ beforeEach(() => { transitionCapturePageMock.mockReset().mockResolvedValue(undefined); transitionSnapshotMock.mockReset().mockResolvedValue(undefined); prepareCaptureRevealMock.mockReset(); + accessibilityListMock.mockReset().mockResolvedValue([]); }); const { @@ -39,6 +40,7 @@ const { accessibilityProcessCoolMock, accessibilityProcessReadMock, accessibilityByPidMock, + accessibilityListMock, accessibilityForegroundMock, accessibilityTrustedMock, allWindowsMock, @@ -87,6 +89,7 @@ const { } >(), accessibilityByPidMock: vi.fn(), + accessibilityListMock: vi.fn(), accessibilityForegroundMock: vi.fn(), accessibilityTrustedMock: vi.fn((_prompt = false) => true), allWindowsMock: vi.fn( @@ -430,6 +433,7 @@ import * as SnapShotAccessibility from "./SnapShotAccessibility.ts"; // Tests hand it this stand-in so the mocks above drive window lookups. const accessibilityApp = { byPid: accessibilityByPidMock, + list: accessibilityListMock, foreground: accessibilityForegroundMock, } as unknown as Parameters[0]; const readAccessibleWindowContext = ( @@ -2654,6 +2658,140 @@ it.each([ } }); +it("reads Flatpak app text from the AT-SPI proxy when the compositor PID misses", async () => { + vi.stubEnv("XDG_SESSION_TYPE", "wayland"); + const bounds = { x: 0, y: 0, width: 1_920, height: 1_048 }; + const tree = vi.fn(async () => ({ + name: "Issue — Zen Browser", + children: [{ name: "New Tab", children: [] }], + })); + const proxyWindow = { + role: "window", + name: "Issue — Zen Browser", + bounds, + tree, + children: async () => [], + }; + accessibilityByPidMock + .mockReset() + .mockRejectedValue( + new Error("XA11Y_SELECTOR_NOT_MATCHED: No element matched selector: application[pid=207651]"), + ); + accessibilityListMock.mockReset().mockResolvedValue([ + { + pid: 1, + children: async () => [ + { + role: "window", + name: "Files", + bounds, + tree: async () => ({ name: "Files", children: [] }), + }, + ], + }, + { pid: 207646, children: async () => [proxyWindow] }, + ]); + try { + assert.strictEqual( + await readAccessibleWindowText( + { + title: "Issue — Zen Browser", + bounds: { x: 0, y: 0, width: 1_920, height: 1_048 }, + owner: { processId: 207651 }, + }, + "linux", + "Issue — Zen Browser", + ), + "Issue — Zen Browser\nNew Tab", + ); + assert.deepEqual(accessibilityByPidMock.mock.calls, [[207651, { timeout: 0 }]]); + assert.lengthOf(accessibilityListMock.mock.calls, 1); + assert.lengthOf(tree.mock.calls, 1); + } finally { + accessibilityListMock.mockReset().mockResolvedValue([]); + vi.unstubAllEnvs(); + } +}); + +it("reads GTK4 app text from one unnamed PID-scoped window of the captured size", async () => { + vi.stubEnv("XDG_SESSION_TYPE", "wayland"); + const bounds = { x: 0, y: 0, width: 1_920, height: 1_048 }; + const tree = vi.fn(async () => ({ + name: "System Monitor", + children: [{ name: "CPU", children: [] }], + })); + accessibilityByPidMock.mockReset().mockResolvedValue({ + children: async () => [ + { + role: "group", + name: null, + bounds, + tree, + children: async () => [ + { role: "page_tab_list", name: "Processes", bounds, children: async () => [] }, + ], + }, + ], + }); + accessibilityListMock.mockReset().mockResolvedValue([]); + try { + assert.strictEqual( + await readAccessibleWindowText( + { + title: "System Monitor", + bounds: { x: 12, y: 48, width: 1_920, height: 1_048 }, + owner: { processId: 210600 }, + }, + "linux", + "System Monitor", + ), + "System Monitor\nCPU", + ); + assert.deepEqual(accessibilityByPidMock.mock.calls, [[210600, { timeout: 0 }]]); + assert.lengthOf(accessibilityListMock.mock.calls, 0); + assert.lengthOf(tree.mock.calls, 1); + } finally { + accessibilityListMock.mockReset().mockResolvedValue([]); + vi.unstubAllEnvs(); + } +}); + +it("does not guess GTK4 app text when two unnamed PID windows share the captured size", async () => { + vi.stubEnv("XDG_SESSION_TYPE", "wayland"); + const bounds = { x: 0, y: 0, width: 1_920, height: 1_048 }; + const tree = vi.fn(async () => ({ value: "Wrong window", children: [] })); + accessibilityByPidMock.mockReset().mockResolvedValue({ + children: async () => [ + { role: "group", name: null, bounds, tree }, + { role: "group", name: null, bounds: { ...bounds, x: 12 }, tree }, + ], + }); + accessibilityListMock.mockReset().mockResolvedValue([ + { + pid: 99, + children: async () => [{ role: "window", name: "System Monitor", bounds, tree }], + }, + ]); + try { + assert.isUndefined( + await readAccessibleWindowText( + { + title: "System Monitor", + bounds: { x: 12, y: 48, width: 1_920, height: 1_048 }, + owner: { processId: 210600 }, + }, + "linux", + "System Monitor", + ), + ); + assert.lengthOf(accessibilityListMock.mock.calls, 0); + assert.lengthOf(tree.mock.calls, 0); + } finally { + accessibilityListMock.mockReset().mockResolvedValue([]); + vi.unstubAllEnvs(); + } +}); + it.each([20, 1_350, 2_999])( "includes accessibility text as soon as a %d ms read completes", async (duration) => { diff --git a/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts b/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts index 1c638f2444de..ad7b2e03c495 100644 --- a/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts +++ b/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts @@ -11,7 +11,11 @@ const MAC_MODIFIER_PAIR_DEVICE_MASKS: Record["children"]>>[number]; + +async function windowsForPid( + App: AccessibilityApp, + processId: number, +): Promise { + return await App.byPid(processId, { timeout: 0 }) + .then((app) => app.children()) + .catch(() => []); +} + +/** Flatpak apps reach AT-SPI through xdg-dbus-proxy, so the compositor PID misses. */ +async function windowsFromAppList(App: AccessibilityApp): Promise { + const apps = await App.list().catch(() => []); + return (await Promise.all(apps.map((app) => app.children().catch(() => [])))).flat(); +} + async function readCapturedWindowAccessibility( App: AccessibilityApp, request: SnapShotAccessibilityRequest, @@ -105,17 +122,24 @@ async function readCapturedWindowAccessibility( ): Promise { const { active, platform, sourceTitle, imageSize } = request; const foreground = platform === "win32" ? await App.foreground({ timeout: 0 }) : undefined; - const windows = + const pidWindows = foreground !== undefined ? foreground.pid === active.owner.processId ? [foreground.asElement()] : [] - : await (await App.byPid(active.owner.processId, { timeout: 0 })).children(); + : await windowsForPid(App, active.owner.processId); const matchMode = isWaylandSession(platform, process.env) ? "wayland" : "screen-bounds"; + const captured = { + title: active.title, + sourceTitle, + bounds: active.bounds, + clientBounds: active.clientBounds, + }; const window = findAccessibleWindow( - windows, - { title: active.title, sourceTitle, bounds: active.bounds, clientBounds: active.clientBounds }, + pidWindows.length > 0 || foreground !== undefined ? pidWindows : await windowsFromAppList(App), + captured, matchMode, + { allowUntitledUniqueBounds: foreground === undefined && pidWindows.length > 0 }, ); if (!window) { onStarted(); diff --git a/apps/desktop/src/snapShot/snapShot.test.ts b/apps/desktop/src/snapShot/snapShot.test.ts index 6960718759aa..634c4653a5a6 100644 --- a/apps/desktop/src/snapShot/snapShot.test.ts +++ b/apps/desktop/src/snapShot/snapShot.test.ts @@ -464,6 +464,45 @@ describe("findAccessibleWindow", () => { expect(findAccessibleWindow(windows, { ...captured, title: "" }, "wayland")).toBeUndefined(); }); + it("accepts one PID-scoped untitled window whose bounds match", () => { + const windows = [{ name: null, bounds: captured.bounds }]; + + expect( + findAccessibleWindow(windows, captured, "wayland", { allowUntitledUniqueBounds: true }), + ).toBe(windows[0]); + expect(findAccessibleWindow(windows, captured, "wayland")).toBeUndefined(); + }); + + it("rejects ambiguous PID-scoped untitled windows even when bounds match", () => { + const windows = [ + { name: null, bounds: captured.bounds }, + { name: "", bounds: { ...captured.bounds, x: 0, y: 0 } }, + ]; + + expect( + findAccessibleWindow(windows, captured, "wayland", { allowUntitledUniqueBounds: true }), + ).toBeUndefined(); + }); + + it("does not fall back to a differently titled PID-scoped window with matching bounds", () => { + const windows = [{ name: "Preferences", bounds: captured.bounds }]; + + expect( + findAccessibleWindow(windows, captured, "wayland", { allowUntitledUniqueBounds: true }), + ).toBeUndefined(); + }); + + it("does not use unique bounds when a titled match is already ambiguous", () => { + const windows = [ + { name: "Editor", bounds: captured.bounds }, + { name: "Editor", bounds: { ...captured.bounds, x: 0, y: 0 } }, + ]; + + expect( + findAccessibleWindow(windows, captured, "wayland", { allowUntitledUniqueBounds: true }), + ).toBeUndefined(); + }); + it.each(["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])( "ignores a leading Wayland title spinner frame %s", (frame) => { diff --git a/apps/desktop/src/snapShot/snapShot.ts b/apps/desktop/src/snapShot/snapShot.ts index 35c240c6e09a..2cfa406d1781 100644 --- a/apps/desktop/src/snapShot/snapShot.ts +++ b/apps/desktop/src/snapShot/snapShot.ts @@ -480,6 +480,7 @@ export function findAccessibleWindow< readonly clientBounds?: WindowBounds | undefined; }, matchMode: "screen-bounds" | "wayland" = "screen-bounds", + options: { readonly allowUntitledUniqueBounds?: boolean } = {}, ): T | undefined { const normalizeTitle = (value: string) => { const title = value.trim(); @@ -489,7 +490,6 @@ export function findAccessibleWindow< const titles = new Set( [captured.title, captured.sourceTitle ?? ""].map(normalizeTitle).filter(Boolean), ); - if (titles.size === 0) return undefined; // Wayland accessibility providers can expose window size without a screen position. const boundsKeys = matchMode === "wayland" @@ -499,19 +499,31 @@ export function findAccessibleWindow< matchMode === "wayland" && captured.clientBounds ? [captured.bounds, captured.clientBounds] : [captured.bounds]; - const matches = windows.filter((window) => { + const matchesBounds = (window: T) => { const bounds = window.bounds; return ( - titles.has(normalizeTitle(window.name ?? "")) && bounds !== null && candidateBounds.some((candidate) => boundsKeys.every((key) => Math.abs(bounds[key] - candidate[key]) <= 2), ) ); - }); - if (matches.length === 1) return matches[0]; - const activeMatches = matches.filter((window) => safeProperty(() => window.active) === true); - return activeMatches.length === 1 ? activeMatches[0] : undefined; + }; + if (titles.size > 0) { + const matches = windows.filter( + (window) => titles.has(normalizeTitle(window.name ?? "")) && matchesBounds(window), + ); + if (matches.length === 1) return matches[0]; + const activeMatches = matches.filter((window) => safeProperty(() => window.active) === true); + if (activeMatches.length === 1) return activeMatches[0]; + if (matches.length > 1) return undefined; + } + // GTK4/libadwaita often exposes the frame as an unnamed group. A PID-scoped + // lookup can accept the one window whose bounds match; size-only guesses cannot. + if (!options.allowUntitledUniqueBounds) return undefined; + const boundsMatches = windows.filter( + (window) => normalizeTitle(window.name ?? "") === "" && matchesBounds(window), + ); + return boundsMatches.length === 1 ? boundsMatches[0] : undefined; } const ELECTRON_KEY_NAMES: Readonly> = { diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 78834a55690e..ab3405b34aa8 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -49,6 +49,7 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { MENU_ACTION_CHANNEL, SNAP_SHOT_EVENT_CHANNEL, + TRACKPAD_SCROLL_END_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL, } from "../ipc/channels.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; @@ -709,6 +710,30 @@ describe("DesktopWindow", () => { }), ); + it.effect("forwards native trackpad release to the renderer", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const send = vi.spyOn(fakeWindow.window.webContents, "send"); + 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 onInput = fakeWindow.webContentsListeners.get("input-event"); + if (!onInput) return yield* Effect.die("input-event listener was not registered"); + onInput({}, { type: "gestureScrollUpdate" }); + assert.notInclude( + send.mock.calls.map(([channel]) => channel), + TRACKPAD_SCROLL_END_CHANNEL, + ); + onInput({}, { type: "gestureScrollEnd" }); + assert.isTrue(send.mock.calls.some(([channel]) => channel === TRACKPAD_SCROLL_END_CHANNEL)); + }).pipe(Effect.provide(layer)); + }), + ); + // Chromium hands the main window's zoom level down to embedded preview // guests, so every app zoom has to put the preview browser back at its own // zoom or zooming the UI drags the previewed page with it. diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index b3964cb929c7..754de3caa727 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -22,6 +22,7 @@ import { MENU_ACTION_CHANNEL, QUIT_SHORTCUT_CHANNEL, SNAP_SHOT_EVENT_CHANNEL, + TRACKPAD_SCROLL_END_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL, } from "../ipc/channels.ts"; import * as PreviewManager from "../preview/Manager.ts"; @@ -661,6 +662,9 @@ export const make = Effect.gen(function* () { event.preventDefault(); } }); + window.webContents.on("input-event", (_event, input) => { + if (input.type === "gestureScrollEnd") window.webContents.send(TRACKPAD_SCROLL_END_CHANNEL); + }); window.on("page-title-updated", (event) => { event.preventDefault(); diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 11fb377145e3..52eae6db9777 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -214,7 +214,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: "1.2.1", + version: "1.3.1", runtimeVersion: { // Development manifests resolve on every launch, so avoid fingerprint's // expensive native-project calculation there. Preview and production stay diff --git a/apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz b/apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz deleted file mode 100644 index 8fafc3896813..000000000000 Binary files a/apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz and /dev/null differ diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt index 35086bbd75f9..00af98ac4b28 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt @@ -138,17 +138,7 @@ object AgentNotifications { // Match iOS foreground presentation. Consume suppressed alerts as well, // so a delivery retry cannot surface them after the app backgrounds. if (!ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { - val title = data["alert_title"].orEmpty().take(120) - // Grouped alerts list up to five 120-character thread titles. - val body = data["alert_body"].orEmpty().take(608) - val id = alertId.hashCode() - val notification = base(context, ALERT_CHANNEL) - .setContentTitle(title).setContentText(body) - .setStyle(NotificationCompat.BigTextStyle().bigText(body)) - .setAutoCancel(true) - .setContentIntent(contentIntent(context, scheme, data["alert_path"], id)) - .build() - manager(context).notify(ALERT_TAG, id, notification) + postAlert(context, scheme, data, alertId) } prefs.edit().remove("seenAlerts").putString( "seenAlertsOrdered", @@ -157,6 +147,36 @@ object AgentNotifications { } } + private fun postAlert( + context: Context, + scheme: String, + data: Map, + alertId: String + ) { + val title = data["alert_title"].orEmpty().take(120) + // Grouped alerts list up to five 120-character thread titles. + val body = data["alert_body"].orEmpty().take(608) + val id = alertId.hashCode() + val notification = base(context, ALERT_CHANNEL) + .setContentTitle(title).setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setAutoCancel(true) + .setContentIntent(contentIntent(context, scheme, data["alert_path"], id)) + .build() + manager(context).notify(ALERT_TAG, id, notification) + } + + /** + * Renders a relay-shaped payload without the registration, freshness and + * foreground checks, for the showcase capture's staged notifications. + */ + @Synchronized + fun showcase(context: Context, scheme: String, data: Map) { + channels(context) + data["alert_id"]?.let { postAlert(context, scheme, data, it) } + showActivity(context, scheme, data, data["active"] == "true", RUNNING_LIFETIME_MS) + } + private fun updateActivity( context: Context, prefs: SharedPreferences, diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt index 246db274a195..0a2d2b30589f 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt @@ -26,6 +26,10 @@ class T3AgentNotificationsModule : Module() { appContext.reactContext?.let { AgentNotifications.clear(it) } } + Function("showShowcaseActivity") { scheme: String, data: Map -> + appContext.reactContext?.let { AgentNotifications.showcase(it, scheme, data) } + } + Function("openLiveUpdateSettings") { val context = appContext.reactContext if (context == null || Build.VERSION.SDK_INT < 36) { diff --git a/apps/mobile/modules/t3-review-diff/android/build.gradle b/apps/mobile/modules/t3-review-diff/android/build.gradle index 22bb070b3b81..d360d1580f1d 100644 --- a/apps/mobile/modules/t3-review-diff/android/build.gradle +++ b/apps/mobile/modules/t3-review-diff/android/build.gradle @@ -8,6 +8,10 @@ android { namespace 'expo.modules.t3reviewdiff' compileSdk rootProject.ext.compileSdkVersion + testOptions { + unitTests.includeAndroidResources = true + } + defaultConfig { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion @@ -16,4 +20,12 @@ android { dependencies { implementation project(':expo-modules-core') + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.16.1' +} + +tasks.withType(Test).configureEach { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + } } 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 index 6782e6894d99..80d0410643ff 100644 --- 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 @@ -9,6 +9,7 @@ import android.graphics.Path import android.graphics.RectF import android.graphics.Shader import android.graphics.Typeface +import android.text.TextPaint import kotlin.math.max import kotlin.math.min @@ -177,6 +178,39 @@ internal class ReviewDiffCanvasDrawing(context: Context) { textPaint.isUnderlineText = fontStyle and 4 != 0 } + var codeLayouts = CodeLayoutCache() + + /** Capture paint on the UI thread; the decode worker owns the new cache until publication. */ + fun prepareRows( + tokens: Map>, + style: DiffStyle, + width: Int + ): (List) -> CodeLayoutCache { + configureCodePaint(theme.text, 0, style) + val paint = TextPaint(textPaint) + val colors = theme + val cache = codeLayouts.copyForPreparation() + val availableWidth = ( + width - style.changeBarWidthPx - style.gutterWidthPx - + style.codePaddingPx * 2f + ).toInt() + return { rows -> + cache.apply { layout(rows, tokens, paint, style, colors, availableWidth) } + } + } + + fun codeWrapLayout( + rows: List, + tokens: Map>, + style: DiffStyle, + width: Int + ): CodeWrapLayout { + configureCodePaint(theme.text, 0, style) + val availableWidth = width - style.changeBarWidthPx - style.gutterWidthPx - + style.codePaddingPx * 2f + return codeLayouts.layout(rows, tokens, textPaint, style, theme, availableWidth.toInt()) + } + fun lineNumberColor(change: String): Int = when (change) { "add" -> theme.addText "delete" -> theme.deleteText @@ -198,13 +232,17 @@ internal class ReviewDiffCanvasDrawing(context: Context) { } } + /** Highlights word diffs; [top]..[bottom] is the row's first visual line. */ + @Suppress("LongParameterList") fun drawWordDiffRanges( canvas: Canvas, row: DiffRow, codeX: Float, top: Int, - bottom: Int + bottom: Int, + lines: CodeLines ) { + if (lines.nativeLayout != null) return 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) @@ -213,14 +251,66 @@ internal class ReviewDiffCanvasDrawing(context: Context) { 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, - ) + // A wrapped row splits the highlight at each visual line boundary. + lines.starts.forEachIndexed { line, lineStart -> + val start = max(range.start, lineStart) + val end = min(range.end, lines.end(line, Int.MAX_VALUE)) + if (end <= start) return@forEachIndexed + val left = codeX + (start - lineStart) * characterWidth + val right = max(left + 2f * density, left + (end - start) * characterWidth) + val lineTop = highlightTop + line * lines.height + canvas.drawRoundRect( + RectF(left, lineTop, right, lineTop + highlightHeight), + 3f * density, + 3f * density, + backgroundPaint, + ) + } + } + } + + /** Draws a code row's text, or its syntax [tokens] when present, one visual line per start. */ + @Suppress("LongParameterList") + fun drawCode( + canvas: Canvas, + content: String, + tokens: List?, + codeX: Float, + baseline: Float, + style: DiffStyle, + lines: CodeLines + ) { + val nativeLayout = lines.nativeLayout + if (nativeLayout != null) { + canvas.save() + canvas.translate(codeX, baseline - nativeLayout.getLineBaseline(0)) + nativeLayout.draw(canvas) + canvas.restore() + return + } + val runs = if (tokens.isNullOrEmpty()) listOf(DiffToken(content, null, 0)) else tokens + var line = 0 + var x = codeX + var column = 0 + runs.forEach { run -> + configureCodePaint(run.color ?: theme.text, run.fontStyle, style) + var start = 0 + while (start < run.content.length) { + while (line + 1 < lines.starts.size && lines.starts[line + 1] <= column + start) { + line += 1 + x = codeX + } + val end = min(run.content.length, lines.end(line, Int.MAX_VALUE) - column) + val lineBaseline = baseline + line * lines.height + if (lineBaseline + textPaint.fontMetrics.descent >= canvas.clipBounds.top && + lineBaseline + textPaint.fontMetrics.ascent <= canvas.clipBounds.bottom + ) { + canvas.drawText(run.content, start, end, x, lineBaseline, textPaint) + x += textPaint.measureText(run.content, start, end) + } + start = end + } + column += run.content.length } } diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayout.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayout.kt new file mode 100644 index 000000000000..dc77a8467033 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayout.kt @@ -0,0 +1,169 @@ +package expo.modules.t3reviewdiff + +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Typeface +import android.text.Layout +import android.text.SpannableString +import android.text.Spanned +import android.text.StaticLayout +import android.text.TextPaint +import android.text.style.BackgroundColorSpan +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.text.style.UnderlineSpan +import kotlin.math.ceil +import kotlin.math.max + +/** Text layout is independent of comment heights and vertical row offsets. */ +internal class CodeLines( + val starts: IntArray, + val height: Int, + val nativeLayout: StaticLayout? = null +) { + fun end(line: Int, length: Int): Int = if (line + 1 < starts.size) starts[line + 1] else length + + fun firstHeight(base: Int): Int = max(base, nativeLayout?.getLineBottom(0) ?: 0) + + fun baseline(top: Int, bottom: Int, paint: Paint): Float = nativeLayout?.let { + top + (bottom - top - it.getLineBottom(0)) / 2f + it.getLineBaseline(0) + } ?: ((top + bottom - paint.fontMetrics.ascent - paint.fontMetrics.descent) / 2f) + + val extraHeight: Int + get() = nativeLayout?.let { it.height - it.getLineBottom(0) } ?: ((starts.size - 1) * height) +} + +internal class CodeWrapLayout( + val enabled: Boolean, + private val linesByRowId: Map +) { + fun lines(rowId: String): CodeLines = linesByRowId[rowId] ?: SINGLE_LINE + fun extraHeight(rowId: String): Int = lines(rowId).extraHeight + fun rowHeight(rowId: String, base: Int): Int = lines(rowId).let { + it.firstHeight(base) + + it.extraHeight + } + + companion object { + private val SINGLE_LINE = CodeLines(intArrayOf(0), 0) + val NONE = CodeWrapLayout(false, emptyMap()) + } +} + +/** ASCII is fixed-pitch; other text needs the same shaping for measurement and drawing. */ +internal fun createCodeLines(text: CharSequence, paint: TextPaint, width: Int): CodeLines { + val characterWidth = paint.measureText("M") + val lineHeight = ceil(paint.fontMetrics.run { descent - ascent }).toInt() + if (text.all { it in ' '..'~' }) { + val columns = max(1, (width / characterWidth).toInt()) + return CodeLines( + IntArray(max(1, (text.length + columns - 1) / columns)) { + it * columns + }, + lineHeight + ) + } + val layout = StaticLayout.Builder.obtain(text, 0, text.length, paint, max(1, width)) + .setAlignment(Layout.Alignment.ALIGN_NORMAL) + .setIncludePad(false) + .setBreakStrategy(Layout.BREAK_STRATEGY_SIMPLE) + .setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NONE) + .build() + return CodeLines(IntArray(layout.lineCount) { layout.getLineStart(it) }, lineHeight, layout) +} + +internal class CodeLayoutCache { + private data class Entry(val row: DiffRow, val tokens: List?, val lines: CodeLines) + private var entries = emptyMap() + private var previousStyle: DiffStyle? = null + private var previousTheme: DiffTheme? = null + private var previousWidth = 0 + + /** Entries are immutable; a worker can reuse them without changing the displayed cache. */ + fun copyForPreparation(): CodeLayoutCache = CodeLayoutCache().also { + it.entries = entries + it.previousStyle = previousStyle + it.previousTheme = previousTheme + it.previousWidth = previousWidth + } + + @Suppress("LongParameterList") + fun layout( + rows: List, + tokens: Map>, + paint: Paint, + style: DiffStyle, + theme: DiffTheme, + width: Int + ): CodeWrapLayout { + if (!style.wordWrap || width < paint.measureText("M")) { + entries = emptyMap() + return CodeWrapLayout.NONE + } + if (previousStyle != style || previousTheme != theme || previousWidth != width) { + entries = emptyMap() + previousStyle = style + previousTheme = theme + previousWidth = width + } + val next = HashMap() + val layouts = HashMap() + for (row in rows) { + if (row.kind != "line") continue + val rowTokens = tokens[row.id] + val cached = entries[row.id] + val entry = if (cached?.row == row && cached.tokens == rowTokens) { + cached + } else { + val text = styledCode(row, rowTokens, theme) + Entry(row, rowTokens, createCodeLines(text, TextPaint(paint), width)) + } + next[row.id] = entry + layouts[row.id] = entry.lines + } + entries = next + return CodeWrapLayout(true, layouts) + } + + private fun styledCode(row: DiffRow, tokens: List?, theme: DiffTheme): CharSequence { + // The ASCII path uses the existing token drawing and rounded highlight rectangles. + if (row.content.all { it in ' '..'~' }) return row.content + val text = SpannableString(row.content) + var offset = 0 + for (token in tokens.orEmpty()) { + val end = (offset + token.content.length).coerceAtMost(text.length) + if (end > offset) { + token.color?.let { + text.setSpan(ForegroundColorSpan(it), offset, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + val fontStyle = (if (token.fontStyle and 2 != 0) Typeface.BOLD else 0) or + (if (token.fontStyle and 1 != 0) Typeface.ITALIC else 0) + if (fontStyle != + 0 + ) { + text.setSpan(StyleSpan(fontStyle), offset, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + if (token.fontStyle and 4 != + 0 + ) { + text.setSpan(UnderlineSpan(), offset, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + offset = end + } + if (row.change == "add" || row.change == "delete") { + val bar = if (row.change == "add") theme.addBar else theme.deleteBar + val color = Color.argb(71, Color.red(bar), Color.green(bar), Color.blue(bar)) + for (range in row.wordDiffRanges) { + val start = range.start.coerceIn(0, text.length) + val end = range.end.coerceIn(start, text.length) + if (end > + start + ) { + text.setSpan(BackgroundColorSpan(color), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + } + return text + } +} 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 97e9f696db90..37fee1cb3613 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 @@ -143,10 +143,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont fun setRowsJson(value: String) { rowsDecodeGeneration += 1 val generation = rowsDecodeGeneration + val prepareLayout = canvasView.prepareRows() payloadDecodeExecutor.execute { val decodedRows = parseRows(value) + val codeLayouts = prepareLayout(decodedRows) post { if (generation != rowsDecodeGeneration) return@post + canvasView.useCodeLayouts(codeLayouts) rows = decodedRows lastVisibleFileId = null rebuildVisibleRows() @@ -460,7 +463,7 @@ internal data class DiffWordDiffRange( val end: Int ) -private data class DiffToken( +internal data class DiffToken( val content: String, val color: Int?, val fontStyle: Int @@ -543,6 +546,7 @@ internal data class DiffTheme( } internal data class DiffStyle( + val wordWrap: Boolean, val rowHeightPx: Float, val gutterWidthPx: Float, val codePaddingPx: Float, @@ -564,6 +568,7 @@ internal data class DiffStyle( ) { companion object { fun defaults(density: Float): DiffStyle = DiffStyle( + wordWrap = false, rowHeightPx = 20f * density, gutterWidthPx = 72f * density, codePaddingPx = 10f * density, @@ -587,6 +592,7 @@ internal data class DiffStyle( fun fromJson(value: String, fallback: DiffStyle, density: Float): DiffStyle = try { val json = JSONObject(value) DiffStyle( + wordWrap = json.optBoolean("wordWrap", fallback.wordWrap), rowHeightPx = json.floatDp("rowHeight", fallback.rowHeightPx, density), gutterWidthPx = json.floatDp("gutterWidth", fallback.gutterWidthPx, density), codePaddingPx = json.floatDp("codePadding", fallback.codePaddingPx, density), @@ -684,6 +690,8 @@ private class DiffCanvasView(context: Context) : View(context) { }, ) private var rowOffsets = intArrayOf(0) + + private var codeWrap = CodeWrapLayout.NONE private var verticalOffset = 0 private var horizontalOffset = 0 private val headerPathOffsetsByFileId = mutableMapOf() @@ -699,6 +707,7 @@ private class DiffCanvasView(context: Context) : View(context) { var tokensByRowId: Map> = emptyMap() set(value) { field = value + if (style.wordWrap) rebuildOffsets() invalidate() } var viewedFileIds: Set = emptySet() @@ -725,6 +734,7 @@ private class DiffCanvasView(context: Context) : View(context) { set(value) { field = value drawing.theme = value + if (style.wordWrap) rebuildOffsets() invalidate() } var style: DiffStyle = DiffStyle.defaults(density) @@ -742,6 +752,11 @@ private class DiffCanvasView(context: Context) : View(context) { var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null + fun prepareRows() = drawing.prepareRows(tokensByRowId, style, width) + fun useCodeLayouts(layouts: CodeLayoutCache) { + drawing.codeLayouts = layouts + } + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { setMeasuredDimension( MeasureSpec.getSize(widthMeasureSpec), @@ -751,6 +766,8 @@ private class DiffCanvasView(context: Context) : View(context) { override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { super.onSizeChanged(width, height, oldWidth, oldHeight) + // Wrapped rows take their height from the width, so a new width is a new layout. + if (style.wordWrap && width != oldWidth) layoutRows() setVerticalOffset(verticalOffset) setHorizontalOffset(horizontalOffset) clampHeaderPathOffsets() @@ -818,7 +835,8 @@ private class DiffCanvasView(context: Context) : View(context) { fun horizontalOffset(): Int = horizontalOffset - fun maxHorizontalOffset(): Int = max(0, contentWidthPx - width) + fun maxHorizontalOffset(): Int = + if (codeWrap.enabled) 0 else max(0, contentWidthPx - width) fun maxHorizontalOffset(target: HorizontalPanTarget): Int = if (target.kind == HorizontalPanKind.FILE_HEADER_PATH) { @@ -844,14 +862,20 @@ private class DiffCanvasView(context: Context) : View(context) { } private fun rebuildOffsets() { + layoutRows() + requestLayout() + invalidate() + } + + private fun layoutRows() { + codeWrap = drawing.codeWrapLayout(rows, tokensByRowId, style, width) rowOffsets = IntArray(rows.size + 1) rows.forEachIndexed { index, row -> rowOffsets[index + 1] = rowOffsets[index] + rowHeight(row) } setVerticalOffset(verticalOffset) + setHorizontalOffset(horizontalOffset) clampHeaderPathOffsets() - requestLayout() - invalidate() } private fun rowHeight(row: DiffRow): Int = when (row.kind) { @@ -862,6 +886,7 @@ private class DiffCanvasView(context: Context) : View(context) { } else { (124 * density).toInt() } + "line" -> codeWrap.rowHeight(row.id, style.rowHeightPx.toInt()) else -> style.rowHeightPx.toInt() }.coerceAtLeast(1) @@ -1191,23 +1216,17 @@ private class DiffCanvasView(context: Context) : View(context) { ) } - val tokens = tokensByRowId[row.id] + // Wrapped rows keep the line number and first code line in the first row-height band. + val lines = codeWrap.lines(row.id) + val firstLineBottom = top + lines.firstHeight(style.rowHeightPx.toInt()) drawScrollableCode(canvas, top, bottom) { codeX -> drawing.configureCodePaint(theme.text, 0, style) - drawing.drawWordDiffRanges(canvas, row, codeX, top, bottom) - if (tokens.isNullOrEmpty()) { - canvas.drawText(row.content, codeX, centeredBaseline(top, bottom, textPaint), textPaint) - } else { - var x = codeX - tokens.forEach { token -> - drawing.configureCodePaint(token.color ?: theme.text, token.fontStyle, style) - canvas.drawText(token.content, x, centeredBaseline(top, bottom, textPaint), textPaint) - x += textPaint.measureText(token.content) - } - } + drawing.drawWordDiffRanges(canvas, row, codeX, top, firstLineBottom, lines) + val baseline = lines.baseline(top, firstLineBottom, textPaint) + drawing.drawCode(canvas, row.content, tokensByRowId[row.id], codeX, baseline, style, lines) } - drawLineNumber(canvas, row, top, bottom) + drawLineNumber(canvas, row, top, firstLineBottom) } private fun drawLineNumber(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { diff --git a/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt b/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt new file mode 100644 index 000000000000..1f4234b7175f --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt @@ -0,0 +1,150 @@ +package expo.modules.t3reviewdiff + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Typeface +import android.text.Spanned +import android.text.TextPaint +import android.text.style.BackgroundColorSpan +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], manifest = Config.NONE) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ReviewDiffCodeLayoutTest { + private val paint = TextPaint().apply { + color = Color.WHITE + textSize = 24f + typeface = Typeface.MONOSPACE + } + + @Test + fun unicodeAndTabsFitWithoutSplittingClusters() { + val fixtures = listOf("漢字表示", "e\u0301", "👨‍👩‍👧‍👦", "مرحبا بالعالم ", "\tvalue ") + for (fixture in fixtures) { + val text = fixture.repeat(40) + for (width in listOf(180, 280, 420)) { + val layout = requireNotNull(createCodeLines(text, paint, width).nativeLayout) + assertInkFits(layout, width, fixture) + assertLinesFit(layout, fixture, width) + } + } + } + + private fun assertLinesFit(layout: android.text.StaticLayout, fixture: String, width: Int) { + val text = layout.text + for (line in 0 until layout.lineCount) { + if (!fixture.contains('\t')) { + assertTrue("$fixture line $line at $width", layout.getLineMax(line) <= width + 1) + } + val start = layout.getLineStart(line) + assertTrue(start == 0 || !Character.isLowSurrogate(text[start])) + if (fixture == "👨‍👩‍👧‍👦" || fixture == "e\u0301") { + assertEquals(0, start % fixture.length) + } + } + } + + private fun assertInkFits(layout: android.text.StaticLayout, width: Int, fixture: String) { + val bitmap = Bitmap.createBitmap(width + 40, layout.height, Bitmap.Config.ARGB_8888) + layout.draw(Canvas(bitmap)) + for (x in width + 1 until bitmap.width) { + for (y in 0 until bitmap.height) { + assertEquals("$fixture ink outside width $width", 0, Color.alpha(bitmap.getPixel(x, y))) + } + } + bitmap.recycle() + } + + @Test + fun asciiSegmentsCoverTheWholeLineAndFit() { + val text = "const value = 123; ".repeat(100) + val lines = createCodeLines(text, paint, 280) + val pieces = lines.starts.indices.map { + text.substring(lines.starts[it], lines.end(it, text.length)) + } + assertEquals(text, pieces.joinToString("")) + assertTrue(pieces.all { paint.measureText(it) <= 280 }) + } + + @Test + fun changingCommentHeightReusesCodeButWidthAndContentInvalidateIt() { + val cache = CodeLayoutCache() + val row = row("漢字".repeat(100)) + val comment = row.copy(kind = "comment", id = "comment", content = "", commentText = "Before") + val style = DiffStyle.defaults(1f).copy(wordWrap = true) + val theme = DiffTheme.fallback("light") + val first = cache.layout( + listOf(row, comment), + emptyMap(), + paint, + style, + theme, + 280 + ).lines(row.id) + val second = cache.layout( + listOf(row, comment.copy(commentText = "After")), + emptyMap(), + paint, + style, + theme, + 280, + ).lines(row.id) + assertSame(first, second) + val narrow = cache.layout(listOf(row), emptyMap(), paint, style, theme, 180).lines(row.id) + assertNotSame(first, narrow) + assertTrue(narrow.extraHeight > first.extraHeight) + val edited = cache.layout( + listOf(row.copy(content = "短い")), + emptyMap(), + paint, + style, + theme, + 180 + ).lines(row.id) + assertTrue(edited.extraHeight < narrow.extraHeight) + assertEquals( + 0, + cache.layout( + listOf(row), + emptyMap(), + paint, + style.copy(wordWrap = false), + theme, + 180 + ).extraHeight(row.id) + ) + } + + @Test + fun highlightsUseNativeTextRangesAndSurviveSyntaxArrival() { + val cache = CodeLayoutCache() + val row = row("漢字".repeat(30)).copy(wordDiffRanges = listOf(DiffWordDiffRange(3, 21))) + val style = DiffStyle.defaults(1f).copy(wordWrap = true) + val theme = DiffTheme.fallback("light") + val initial = cache.layout(listOf(row), emptyMap(), paint, style, theme, 180).lines(row.id) + val tokens = mapOf(row.id to listOf(DiffToken(row.content, 0xff008800.toInt(), 2))) + val highlighted = cache.layout(listOf(row), tokens, paint, style, theme, 180).lines(row.id) + assertNotSame(initial, highlighted) + val text = requireNotNull(highlighted.nativeLayout).text as Spanned + val span = text.getSpans(0, text.length, BackgroundColorSpan::class.java).single() + assertEquals(3, text.getSpanStart(span)) + assertEquals(21, text.getSpanEnd(span)) + } + + private fun row(content: String) = DiffRow( + kind = "line", id = "line", fileId = "file", filePath = "test.ts", previousPath = null, + changeType = "modified", additions = 1, deletions = 0, text = "", content = content, + change = "add", oldLineNumber = null, newLineNumber = 1, wordDiffRanges = emptyList(), + commentText = "", commentRangeLabel = "", commentSectionTitle = "", + ) +} diff --git a/apps/mobile/modules/t3-review-diff/ios/ReviewDiffCodeLayout.swift b/apps/mobile/modules/t3-review-diff/ios/ReviewDiffCodeLayout.swift new file mode 100644 index 000000000000..1d8b3e8ea8d9 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/ios/ReviewDiffCodeLayout.swift @@ -0,0 +1,123 @@ +import UIKit + +/// ASCII uses fixed-pitch columns. TextKit handles shaping, tabs, and Unicode highlights. +final class ReviewDiffCodeLayout: NSObject { + // Measurement reuses one engine; only recently drawn rows retain a full TextKit layout. + private static var measurer: ReviewDiffTextLayout { + let key = "T3ReviewDiff.textMeasurer" + if let layout = Thread.current.threadDictionary[key] as? ReviewDiffTextLayout { return layout } + let layout = ReviewDiffTextLayout() + Thread.current.threadDictionary[key] = layout + return layout + } + private static let drawnLayouts: NSCache = { + let cache = NSCache() + cache.countLimit = 128 + return cache + }() + let text: String + let starts: [Int] + let lineHeight: CGFloat + let firstLineHeight: CGFloat + let extraHeight: CGFloat + private let font: UIFont + private let width: CGFloat + private let characterWidth: CGFloat + let usesNativeLayout: Bool + + init(text: String, font: UIFont, width: CGFloat, characterWidth: CGFloat) { + self.text = text + self.font = font + self.width = width + self.characterWidth = characterWidth + lineHeight = ceil(font.lineHeight) + if text.utf8.allSatisfy({ $0 >= 32 && $0 <= 126 }) { + let columns = max(1, Int(width / characterWidth)) + starts = Array(stride(from: 0, to: max(1, text.utf8.count), by: columns)) + firstLineHeight = font.lineHeight + extraHeight = CGFloat(starts.count - 1) * lineHeight + usesNativeLayout = false + } else { + let layout = Self.measurer + layout.configure(text: text, font: font, width: width, characterWidth: characterWidth) + let manager = layout.manager + let container = layout.container + usesNativeLayout = true + starts = [0] + firstLineHeight = manager.numberOfGlyphs > 0 + ? manager.lineFragmentRect(forGlyphAt: 0, effectiveRange: nil).height : font.lineHeight + extraHeight = max(0, manager.usedRect(for: container).height - firstLineHeight) + } + } + + private func nativeLayout() -> ReviewDiffTextLayout { + if let cached = Self.drawnLayouts.object(forKey: self) { return cached } + let layout = ReviewDiffTextLayout() + layout.configure(text: text, font: font, width: width, characterWidth: characterWidth) + Self.drawnLayouts.setObject(layout, forKey: self) + return layout + } + + /// Only colors change when syntax tokens arrive; the measured text and font stay intact. + func decorate(text: NSAttributedString, highlights: [NSRange], color: UIColor, version: Int) { + guard usesNativeLayout else { return } + let layout = nativeLayout() + guard layout.decorationVersion != version else { return } + let storage = layout.storage + let fullRange = NSRange(location: 0, length: storage.length) + storage.beginEditing() + storage.removeAttribute(.foregroundColor, range: fullRange) + storage.removeAttribute(.backgroundColor, range: fullRange) + text.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: text.length)) { value, range, _ in + let intersection = NSIntersectionRange(range, fullRange) + if let value, intersection.length > 0 { + storage.addAttribute(.foregroundColor, value: value, range: intersection) + } + } + for range in highlights { + let intersection = NSIntersectionRange(range, fullRange) + if intersection.length > 0 { + storage.addAttribute(.backgroundColor, value: color, range: intersection) + } + } + storage.endEditing() + layout.decorationVersion = version + } + + func draw(at origin: CGPoint, clip: CGRect) { + guard usesNativeLayout else { return } + let layout = nativeLayout() + let manager = layout.manager + let container = layout.container + let visible = clip.offsetBy(dx: -origin.x, dy: -origin.y) + let range = manager.glyphRange(forBoundingRect: visible, in: container) + manager.drawBackground(forGlyphRange: range, at: origin) + manager.drawGlyphs(forGlyphRange: range, at: origin) + } +} + +private final class ReviewDiffTextLayout { + let storage = NSTextStorage() + let manager = NSLayoutManager() + let container = NSTextContainer(size: .zero) + var decorationVersion = -1 + + init() { + container.lineFragmentPadding = 0 + container.lineBreakMode = .byCharWrapping + manager.addTextContainer(container) + storage.addLayoutManager(manager) + } + + func configure(text: String, font: UIFont, width: CGFloat, characterWidth: CGFloat) { + let paragraph = NSMutableParagraphStyle() + paragraph.lineBreakMode = .byCharWrapping + paragraph.tabStops = [] + paragraph.defaultTabInterval = characterWidth * 4 + container.size = CGSize(width: max(1, width), height: .greatestFiniteMagnitude) + storage.setAttributedString(NSAttributedString(string: text, attributes: [ + .font: font, .ligature: 0, .paragraphStyle: paragraph, + ])) + manager.ensureLayout(for: container) + } +} diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift index 74111988f150..e2400e0a9393 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift @@ -137,6 +137,7 @@ private struct ReviewDiffNativeTheme { } private struct ReviewDiffNativeStylePayload: Decodable { + let wordWrap: Bool? let rowHeight: Double? let contentWidth: Double? let changeBarWidth: Double? @@ -170,6 +171,7 @@ private struct ReviewDiffNativeStylePayload: Decodable { } private struct ReviewDiffNativeStyle { + let wordWrap: Bool let rowHeight: CGFloat let contentWidth: CGFloat let changeBarWidth: CGFloat @@ -203,6 +205,7 @@ private struct ReviewDiffNativeStyle { static func resolve(_ payload: ReviewDiffNativeStylePayload?) -> ReviewDiffNativeStyle { ReviewDiffNativeStyle( + wordWrap: payload?.wordWrap ?? false, rowHeight: metric(payload?.rowHeight, fallback: 24), contentWidth: metric(payload?.contentWidth, fallback: 2800), changeBarWidth: nonNegativeMetric(payload?.changeBarWidth, fallback: 4), @@ -277,6 +280,7 @@ private struct ReviewDiffNativeStyle { func applyingOverrides(rowHeight: CGFloat?, contentWidth: CGFloat?) -> ReviewDiffNativeStyle { ReviewDiffNativeStyle( + wordWrap: wordWrap, rowHeight: rowHeight ?? self.rowHeight, contentWidth: contentWidth ?? self.contentWidth, changeBarWidth: changeBarWidth, @@ -434,16 +438,21 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { guard let self, generation == self.rowsDecodeGeneration else { return } - self.rows = decodedRows - self.contentView.rows = decodedRows - self.hasAppliedInitialRowIndex = false - self.lastVisibleFileId = nil - self.emitDebug("rows-decoded", [ - "rows": decodedRows.count, - "firstKind": decodedRows.first?.kind ?? "none", - ]) - self.updateContentMetrics() - self.applyPendingScrollIfNeeded() + self.contentView.prepareRows(decodedRows, on: self.payloadDecodeQueue, isCurrent: { [weak self] in + generation == self?.rowsDecodeGeneration + }, completion: { [weak self] in + guard let self, generation == self.rowsDecodeGeneration else { return } + self.rows = decodedRows + self.contentView.rows = decodedRows + self.hasAppliedInitialRowIndex = false + self.lastVisibleFileId = nil + self.emitDebug("rows-decoded", [ + "rows": decodedRows.count, + "firstKind": decodedRows.first?.kind ?? "none", + ]) + self.updateContentMetrics() + self.applyPendingScrollIfNeeded() + }) } } catch { let message = error.localizedDescription @@ -652,6 +661,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { private func updateContentMetrics() { let style = contentView.style + contentView.viewportWidth = bounds.width let height = max(bounds.height, contentView.contentHeight) let width = bounds.width scrollView.contentSize = CGSize(width: bounds.width, height: height) @@ -661,7 +671,6 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { width: max(width, 1), height: max(bounds.height, 1) ) - contentView.viewportWidth = bounds.width contentView.verticalOffset = scrollView.contentOffset.y contentView.invalidateVisibleViewport() contentView.setNeedsDisplay() @@ -929,6 +938,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { headerPathOffsetsByFileId.removeAll() activePanFileId = nil activePanKind = nil + codeDecorationVersion += 1 tokenAttributedStringsByRowId.removeAll() rebuildRowLayout() setNeedsDisplayForVisibleBounds() @@ -936,6 +946,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { } var tokensByRowId: [String: [ReviewDiffNativeToken]] = [:] { didSet { + codeDecorationVersion += 1 tokenAttributedStringsByRowId.removeAll() clampHorizontalOffsets() setNeedsDisplayForVisibleBounds() @@ -976,6 +987,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { } var style = ReviewDiffNativeStyle.resolve(nil) { didSet { + codeDecorationVersion += 1 tokenAttributedStringsByRowId.removeAll() rebuildRowLayout() clampHorizontalOffsets() @@ -984,6 +996,10 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { } var viewportWidth: CGFloat = 0 { didSet { + // Wrapped rows take their height from the width, so a new width is a new layout. + if style.wordWrap, viewportWidth != oldValue { + rebuildRowLayout() + } clampHorizontalOffsets() setNeedsDisplayForVisibleBounds() } @@ -992,6 +1008,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { var theme = ReviewDiffNativeTheme.resolve("light") { didSet { tokenColorsByHex.removeAll() + codeDecorationVersion += 1 tokenAttributedStringsByRowId.removeAll() setNeedsDisplayForVisibleBounds() } @@ -1004,6 +1021,13 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { private var tokenColorsByHex: [String: UIColor] = [:] private var tokenAttributedStringsByRowId: [String: NSAttributedString] = [:] private var codeCharacterWidth: CGFloat = 8 + /// Columns per visual line while word wrap is on; nil while code rows pan horizontally. + private var codeWrapColumns: Int? + /// Text geometry survives comment height changes; width, font, and content invalidate it. + private var codeLayoutsByRowId: [String: ReviewDiffCodeLayout] = [:] + private var codeLayoutWidth: CGFloat = 0 + private var codeLayoutFont: UIFont? + private var codeDecorationVersion = 0 private var panStartHorizontalOffset: CGFloat = 0 private var activePanFileId: String? private var activePanKind: ReviewDiffHorizontalPanKind? @@ -1029,6 +1053,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { stickyWidth + style.codePadding } + /// Height before word wrap. Laid-out rows use height(at:), which includes wrapped lines. private func height(for row: ReviewDiffNativeRow) -> CGFloat { if row.kind == "file" { return style.fileHeaderHeight @@ -1045,6 +1070,16 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { return style.rowHeight } + /// Laid-out height, including wrapped lines. Requires a layout built from the current rows. + private func height(at index: Int) -> CGFloat { + let nextOffset = index + 1 < rowOffsets.count ? rowOffsets[index + 1] : contentHeight + return nextOffset - rowOffsets[index] + } + + private var codeWrapLineHeight: CGFloat { + ceil(codeFont.lineHeight) + } + func frameForRow(at index: Int) -> CGRect? { guard rows.indices.contains(index), rowOffsets.indices.contains(index) else { return nil @@ -1054,43 +1089,110 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { x: 0, y: rowOffsets[index], width: max(viewportWidth, 1), - height: height(for: rows[index]) + height: height(at: index) ) } + /// Shape new content on the existing decode worker before publishing rows to the UI. + func prepareRows( + _ rows: [ReviewDiffNativeRow], + on queue: DispatchQueue, + isCurrent: @escaping () -> Bool, + completion: @escaping () -> Void + ) { + let font = codeFont + let width = viewportWidth - codeStartX - style.codePadding + let characterWidth = monospaceCharacterWidth(font: font) + guard style.wordWrap, width >= characterWidth, characterWidth > 0 else { + completion() + return + } + let cached = codeLayoutWidth == width && codeLayoutFont == font ? codeLayoutsByRowId : [:] + queue.async { [weak self] in + var layouts: [String: ReviewDiffCodeLayout] = [:] + for row in rows where row.kind == "line" { + guard let text = row.content else { continue } + if let previous = cached[row.id], previous.text == text { + layouts[row.id] = previous + } else { + layouts[row.id] = ReviewDiffCodeLayout(text: text, font: font, width: width, characterWidth: characterWidth) + } + } + DispatchQueue.main.async { [weak self] in + guard let self, isCurrent() else { return } + if self.codeFont != font || self.viewportWidth - self.codeStartX - self.style.codePadding != width { + self.prepareRows(rows, on: queue, isCurrent: isCurrent, completion: completion) + return + } + self.codeLayoutWidth = width + self.codeLayoutFont = font + self.codeLayoutsByRowId = layouts + completion() + } + } + } + private func rebuildRowLayout() { var nextOffsets: [CGFloat] = [] var nextFileHeaderRowIndices: [Int] = [] nextOffsets.reserveCapacity(rows.count) var maxColumnCountsByFileId: [String: Int] = [:] + var nextCodeLayouts: [String: ReviewDiffCodeLayout] = [:] var offset: CGFloat = 0 + let font = codeFont + let characterWidth = monospaceCharacterWidth(font: font) + let wrapAvailableWidth = viewportWidth - codeStartX - style.codePadding + let wrapColumns = style.wordWrap && characterWidth > 0 && wrapAvailableWidth >= characterWidth + ? Int(wrapAvailableWidth / characterWidth) + : nil + if codeLayoutWidth != wrapAvailableWidth || codeLayoutFont != font { + codeLayoutsByRowId.removeAll() + codeLayoutWidth = wrapAvailableWidth + codeLayoutFont = font + } for (index, row) in rows.enumerated() { nextOffsets.append(offset) if row.kind == "file" { nextFileHeaderRowIndices.append(index) } - offset += height(for: row) + var rowHeight = height(for: row) let fileId = resolvedFileId(for: row) switch row.kind { case "line": - maxColumnCountsByFileId[fileId] = max( - maxColumnCountsByFileId[fileId] ?? 0, - row.content?.count ?? 0 - ) + // UTF-16 columns match the word diff ranges and the segments drawCodeLines draws. + let columnCount = row.content?.utf16.count ?? 0 + maxColumnCountsByFileId[fileId] = max(maxColumnCountsByFileId[fileId] ?? 0, columnCount) + if wrapColumns != nil, let content = row.content { + let cached = codeLayoutsByRowId[row.id] + let layout: ReviewDiffCodeLayout + if let cached, cached.text == content { + layout = cached + } else { + layout = ReviewDiffCodeLayout( + text: content, font: font, width: wrapAvailableWidth, characterWidth: characterWidth + ) + } + nextCodeLayouts[row.id] = layout + if rowHeight > 0 { + rowHeight = max(rowHeight, layout.firstLineHeight) + layout.extraHeight + } + } case "hunk": maxColumnCountsByFileId[fileId] = max( maxColumnCountsByFileId[fileId] ?? 0, - row.text?.count ?? 0 + row.text?.utf16.count ?? 0 ) default: - continue + break } + offset += rowHeight } - let characterWidth = monospaceCharacterWidth(font: codeFont) codeCharacterWidth = characterWidth + codeWrapColumns = wrapColumns + codeLayoutsByRowId = nextCodeLayouts contentWidthsByFileId = maxColumnCountsByFileId.mapValues { maxColumnCount in let measuredWidth = ceil(CGFloat(maxColumnCount) * characterWidth) + style.codePadding * 2 return max(0, min(style.contentWidth, measuredWidth)) @@ -1498,7 +1600,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { while lowerBound <= upperBound { let midpoint = (lowerBound + upperBound) / 2 let rowStart = rowOffsets[midpoint] - let rowEnd = rowStart + height(for: rows[midpoint]) + let rowEnd = rowStart + height(at: midpoint) if absoluteY < rowStart { upperBound = midpoint - 1 @@ -1521,7 +1623,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { var upperBound = rows.count while lowerBound < upperBound { let midpoint = (lowerBound + upperBound) / 2 - let rowEnd = rowOffsets[midpoint] + height(for: rows[midpoint]) + let rowEnd = rowOffsets[midpoint] + height(at: midpoint) if rowEnd < absoluteY { lowerBound = midpoint + 1 @@ -1609,6 +1711,9 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { let row = rows.first(where: { resolvedFileId(for: $0) == target.fileId && $0.kind == "file" }) { return maxHeaderPathOffset(for: row) } + if codeWrapColumns != nil { + return 0 + } return max(0, contentWidth(for: target.fileId) - max(0, viewportWidth - codeStartX)) } @@ -1728,7 +1833,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { var drawnRowCount = 0 for rowIndex in firstRowIndex...lastRowIndex { let rowStart = rowOffsets[rowIndex] - let rowHeight = height(for: rows[rowIndex]) + let rowHeight = height(at: rowIndex) if rowHeight <= 0 { continue } @@ -1786,7 +1891,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { private func drawRow(_ row: ReviewDiffNativeRow, rowIndex: Int, context: CGContext) { let rowY = rowOffsets[rowIndex] - verticalOffset - let fullRect = CGRect(x: 0, y: rowY, width: max(bounds.width, viewportWidth), height: height(for: row)) + let fullRect = CGRect(x: 0, y: rowY, width: max(bounds.width, viewportWidth), height: height(at: rowIndex)) switch row.kind { case "file": @@ -2172,15 +2277,21 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { let horizontalOffset = horizontalOffset(for: fileId) let contentWidth = contentWidth(for: fileId) let change = row.change ?? "context" + // Wrapped rows keep the line number and first code line in the first row-height band. + let layout = codeLayoutsByRowId[row.id] + let firstLineRect = CGRect( + x: rect.minX, y: rect.minY, width: rect.width, + height: max(style.rowHeight, layout?.firstLineHeight ?? 0) + ) rowBackground(for: change).setFill() context.fill(rect) if change == "add" { theme.addBar.setFill() - context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: style.rowHeight)) + context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height)) } else if change == "delete" { drawDeleteStripes( - rect: CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: style.rowHeight), + rect: CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height), context: context ) } @@ -2193,7 +2304,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { "\(lineNumber)", rect: CGRect( x: style.changeBarWidth, - y: centeredTextY(in: rect, font: lineNumberFont), + y: centeredTextY(in: firstLineRect, font: lineNumberFont), width: style.gutterWidth - style.codePadding, height: lineNumberFont.lineHeight ), @@ -2203,28 +2314,85 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { } context.saveGState() - context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: style.rowHeight)) + context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: rect.height)) let codeTextRect = CGRect( x: codeStartX - horizontalOffset, - y: centeredTextY(in: rect, font: codeFont), + y: centeredTextY(in: firstLineRect, font: codeFont), width: contentWidth, height: codeFont.lineHeight ) - drawWordDiffRanges(row, rowRect: rect, context: context, horizontalOffset: horizontalOffset) + if let layout, layout.usesNativeLayout { + let text = tokensByRowId[row.id].map { + tokenAttributedString(rowId: row.id, tokens: $0, fallbackColor: theme.text, font: codeFont) + } ?? NSAttributedString(string: row.content ?? "", attributes: [.foregroundColor: theme.text]) + let highlights = (change == "add" || change == "delete") ? (row.wordDiffRanges ?? []) : [] + layout.decorate( + text: text, + highlights: highlights.filter { $0.start >= 0 && $0.end > $0.start }.map { + NSRange(location: $0.start, length: $0.end - $0.start) + }, + color: (change == "add" ? theme.addBar : theme.deleteBar).withAlphaComponent(0.28), + version: codeDecorationVersion + ) + layout.draw( + at: CGPoint(x: codeStartX, y: rect.minY + max(0, (firstLineRect.height - layout.firstLineHeight) / 2)), + clip: context.boundingBoxOfClipPath + ) + context.restoreGState() + return + } + let lineStarts = layout?.starts ?? [0] + drawWordDiffRanges( + row, + lineStarts: lineStarts, + firstLineRect: firstLineRect, + context: context, + horizontalOffset: horizontalOffset + ) if let tokens = tokensByRowId[row.id], !tokens.isEmpty { - drawTokenText( + let attributedText = tokenAttributedString( rowId: row.id, - tokens, - rect: codeTextRect, + tokens: tokens, fallbackColor: theme.text, font: codeFont ) + drawCodeLines(length: attributedText.length, lineStarts: lineStarts, firstLineRect: codeTextRect) { range, lineRect in + let segment = range.length == attributedText.length + ? attributedText + : attributedText.attributedSubstring(from: range) + segment.draw(in: lineRect) + } } else { - drawText(row.content ?? "", rect: codeTextRect, color: theme.text, font: codeFont) + let content = (row.content ?? "") as NSString + drawCodeLines(length: content.length, lineStarts: lineStarts, firstLineRect: codeTextRect) { range, lineRect in + let segment = range.length == content.length ? content as String : content.substring(with: range) + drawText(segment, rect: lineRect, color: theme.text, font: codeFont) + } } context.restoreGState() } + /// Draws the segment starting at each of the row's line starts on its own visual line. + private func drawCodeLines( + length: Int, + lineStarts: [Int], + firstLineRect: CGRect, + draw: (NSRange, CGRect) -> Void + ) { + var lineRect = firstLineRect + let clip = UIGraphicsGetCurrentContext()?.boundingBoxOfClipPath ?? bounds + let first = max(0, Int(floor((clip.minY - firstLineRect.minY) / codeWrapLineHeight))) + let last = min(lineStarts.count, Int(ceil((clip.maxY - firstLineRect.minY) / codeWrapLineHeight))) + guard first < last else { return } + lineRect.origin.y += CGFloat(first) * codeWrapLineHeight + for line in first.. start else { + continue + } + let highlightRect = CGRect( + x: codeStartX - horizontalOffset + CGFloat(start - lineStart) * codeCharacterWidth, + y: highlightY + CGFloat(line) * codeWrapLineHeight, + width: max(2, CGFloat(end - start) * codeCharacterWidth), + height: highlightHeight + ) + UIBezierPath(roundedRect: highlightRect, cornerRadius: 3).fill() + } } } @@ -2448,22 +2624,6 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { return (sample as NSString).size(withAttributes: attributes).width / CGFloat(sampleLength) } - private func drawTokenText( - rowId: String, - _ tokens: [ReviewDiffNativeToken], - rect: CGRect, - fallbackColor: UIColor, - font: UIFont - ) { - let attributedText = tokenAttributedString( - rowId: rowId, - tokens: tokens, - fallbackColor: fallbackColor, - font: font - ) - attributedText.draw(in: rect) - } - private func tokenAttributedString( rowId: String, tokens: [ReviewDiffNativeToken], diff --git a/apps/mobile/modules/t3-review-diff/tests/ios/main.swift b/apps/mobile/modules/t3-review-diff/tests/ios/main.swift new file mode 100644 index 000000000000..baeeaae77d9a --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/tests/ios/main.swift @@ -0,0 +1,60 @@ +import UIKit + +func check(_ passed: Bool, _ message: String = "Failed layout check") { + if !passed { + FileHandle.standardError.write(Data((message + "\n").utf8)) + exit(1) + } +} + +// Runs the production layout against UIKit through Mac Catalyst, without launching an app. +let font = UIFont.monospacedSystemFont(ofSize: 14, weight: .regular) +let characterWidth = ("M" as NSString).size(withAttributes: [.font: font]).width +let fixtures = ["漢字表示", "e\u{301}", "👨‍👩‍👧‍👦", "مرحبا بالعالم ", "\tvalue "] +var cases = 0 +for fixture in fixtures { + let text = String(repeating: fixture, count: 40) + var previousHeight = CGFloat.greatestFiniteMagnitude + for width: CGFloat in [180, 280, 420] { + let layout = ReviewDiffCodeLayout(text: text, font: font, width: width, characterWidth: characterWidth) + let height = layout.firstLineHeight + layout.extraHeight + check(height <= previousHeight, "Wider text must not require more height") + previousHeight = height + let fullRange = NSRange(location: 0, length: text.utf16.count) + let attributed = NSAttributedString(string: text, attributes: [.foregroundColor: UIColor.black]) + layout.decorate(text: attributed, highlights: [], color: .clear, version: 0) + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + format.opaque = false + format.preferredRange = .standard + let size = CGSize(width: width + 40, height: ceil(height)) + let image = UIGraphicsImageRenderer(size: size, format: format).image { _ in + layout.draw(at: .zero, clip: CGRect(origin: .zero, size: size)) + } + let bitmap = image.cgImage! + let data = bitmap.dataProvider!.data! + let bytes = CFDataGetBytePtr(data)! + // Render without a viewport clip so an overflowing glyph cannot hide behind clipping. + for y in 0.. ( - - {children} - + // A screen's layout replaces the navigator's screenLayout. + layout: ({ children, route }) => ( + + + {children} + + ), options: { gestureEnabled: true, @@ -777,6 +808,33 @@ const RootStackConfig = createNativeStackNavigator({ }, }); +function GuardedScreenLayout(props: { + readonly children: ReactNode; + readonly route: { readonly name: string; readonly params?: object | undefined }; +}) { + return ( + ( + + )} + > + {props.children} + + ); +} + +function ScreenRenderFallback(props: RenderFailureProps & { readonly routeName: string }) { + const navigation = useNavigation(); + const exit = navigation.canGoBack() + ? { label: "Go back", onPress: () => navigation.goBack() } + : props.routeName === "Home" + ? { label: "Open settings", onPress: () => navigation.navigate("SettingsSheet") } + : { label: "Return home", onPress: () => navigation.dispatch(StackActions.replace("Home")) }; + + return ; +} + export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navigator }) { const { width, height } = useWindowDimensions(); const usesWorkspaceFlowScreens = @@ -784,6 +842,7 @@ export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navig return ( { if (route.name !== "SettingsSheet" && route.name !== "NewTaskSheet") { return {}; diff --git a/apps/mobile/src/components/AppSymbol.ios.tsx b/apps/mobile/src/components/AppSymbol.ios.tsx index 8f3f5f8abf75..e58d2af7dbb2 100644 --- a/apps/mobile/src/components/AppSymbol.ios.tsx +++ b/apps/mobile/src/components/AppSymbol.ios.tsx @@ -1,3 +1,4 @@ +import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest"; import { SymbolView as ExpoSymbolView } from "expo-symbols"; import { withUniwind } from "uniwind"; import type { AppSymbolViewProps } from "./AppSymbol"; @@ -6,10 +7,24 @@ export type { SFSymbol } from "expo-symbols"; export type { AppSymbolName } from "./AppSymbol"; /** - * Keep the iOS implementation isolated from the Android Tabler fallback so - * Metro does not initialize the icon package when iOS renders SF Symbols. + * Use SF Symbols on iOS except for pull requests, which have no matching + * native glyph. Import only that Tabler icon to keep the bundle small. */ function AppSymbolView(props: AppSymbolViewProps) { + const name = typeof props.name === "string" ? props.name : props.name.ios; + if (name === "arrow.triangle.pull") { + return ( + + ); + } + return ; } diff --git a/apps/mobile/src/components/ConfirmDialog.types.ts b/apps/mobile/src/components/ConfirmDialog.types.ts new file mode 100644 index 000000000000..7a32787cc32d --- /dev/null +++ b/apps/mobile/src/components/ConfirmDialog.types.ts @@ -0,0 +1,18 @@ +export type ConfirmDialogRequest = { + readonly title: string; + readonly message?: string; + readonly cancelText?: string; + readonly confirmText: string; + readonly destructive?: boolean; + readonly onConfirm: () => void; + readonly onCancel?: () => void; +}; + +export type TextInputDialogRequest = { + readonly title: string; + readonly initialValue: string; + readonly cancelText?: string; + readonly confirmText: string; + readonly onConfirm: (value: string) => void; + readonly onCancel?: () => void; +}; diff --git a/apps/mobile/src/components/ConfirmDialogHost.tsx b/apps/mobile/src/components/ConfirmDialogHost.tsx index aa1653055b59..67e9d465c573 100644 --- a/apps/mobile/src/components/ConfirmDialogHost.tsx +++ b/apps/mobile/src/components/ConfirmDialogHost.tsx @@ -4,25 +4,9 @@ import { Platform, Modal, Pressable, TextInput, View } from "react-native"; import { cn } from "../lib/cn"; import { AppText } from "./AppText"; import { MaterialConfirmDialog } from "./MaterialConfirmDialog"; +import type { ConfirmDialogRequest, TextInputDialogRequest } from "./ConfirmDialog.types"; -export type ConfirmDialogRequest = { - readonly title: string; - readonly message?: string; - readonly cancelText?: string; - readonly confirmText: string; - readonly destructive?: boolean; - readonly onConfirm: () => void; - readonly onCancel?: () => void; -}; - -export type TextInputDialogRequest = { - readonly title: string; - readonly initialValue: string; - readonly cancelText?: string; - readonly confirmText: string; - readonly onConfirm: (value: string) => void; - readonly onCancel?: () => void; -}; +export type { ConfirmDialogRequest, TextInputDialogRequest } from "./ConfirmDialog.types"; type DialogRequest = | { readonly kind: "confirm"; readonly request: ConfirmDialogRequest } diff --git a/apps/mobile/src/components/FilePreview.ios.tsx b/apps/mobile/src/components/FilePreview.ios.tsx index e2bae6101b60..4f06319a6846 100644 --- a/apps/mobile/src/components/FilePreview.ios.tsx +++ b/apps/mobile/src/components/FilePreview.ios.tsx @@ -2,7 +2,7 @@ import { requireNativeModule } from "expo"; import { useEffect, useEffectEvent, useId } from "react"; import { Alert } from "react-native"; -import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types"; const NativeControls = requireNativeModule<{ presentFile( diff --git a/apps/mobile/src/components/FilePreview.tsx b/apps/mobile/src/components/FilePreview.tsx index 337699e8a61e..85286dbda891 100644 --- a/apps/mobile/src/components/FilePreview.tsx +++ b/apps/mobile/src/components/FilePreview.tsx @@ -3,7 +3,7 @@ import { Alert, Modal, Pressable, View } from "react-native"; import ImageViewing from "react-native-image-viewing"; import { openAttachmentInViewer } from "../lib/attachmentDownload"; -import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types"; import { MediaImagePreview } from "./MediaImagePreview"; import { AppText as Text } from "./AppText"; diff --git a/apps/mobile/src/components/FilePreviewModal.tsx b/apps/mobile/src/components/FilePreviewModal.tsx index 77dd5600bc15..0692109cacde 100644 --- a/apps/mobile/src/components/FilePreviewModal.tsx +++ b/apps/mobile/src/components/FilePreviewModal.tsx @@ -1,30 +1,13 @@ import { useIsFocused } from "@react-navigation/native"; -import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; import { useEffect, useEffectEvent, useState } from "react"; import { Alert, Keyboard } from "react-native"; -import type { FileBackedComposerAttachment } from "../lib/composerImages"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; -import type { MediaActionsSource } from "../lib/mediaActions"; import { useRefreshAssetUrl } from "../state/assets"; import { FilePreview } from "./FilePreview"; +import type { FilePreviewSource } from "./FilePreviewModal.types"; -export interface ResolvedFilePreviewSource { - readonly kind: "image" | "pdf" | "document"; - readonly mimeType?: string; - readonly uri: string; - readonly name?: string; - readonly sourceIdentifier?: string; - readonly srcFragment?: string; - readonly actionsSource?: MediaActionsSource; -} - -export type FilePreviewSource = Omit & - ( - | { readonly uri: string } - | { readonly attachment: FileBackedComposerAttachment } - | { readonly environmentId: EnvironmentId; readonly resource: AssetResource } - ); +export type { FilePreviewSource, ResolvedFilePreviewSource } from "./FilePreviewModal.types"; function ResolvedFilePreview(props: { readonly source: FilePreviewSource; diff --git a/apps/mobile/src/components/FilePreviewModal.types.ts b/apps/mobile/src/components/FilePreviewModal.types.ts new file mode 100644 index 000000000000..6d125125c80f --- /dev/null +++ b/apps/mobile/src/components/FilePreviewModal.types.ts @@ -0,0 +1,21 @@ +import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; + +import type { FileBackedComposerAttachment } from "../lib/composerImages"; +import type { MediaActionsSource } from "../lib/mediaActions"; + +export interface ResolvedFilePreviewSource { + readonly kind: "image" | "pdf" | "document"; + readonly mimeType?: string; + readonly uri: string; + readonly name?: string; + readonly sourceIdentifier?: string; + readonly srcFragment?: string; + readonly actionsSource?: MediaActionsSource; +} + +export type FilePreviewSource = Omit & + ( + | { readonly uri: string } + | { readonly attachment: FileBackedComposerAttachment } + | { readonly environmentId: EnvironmentId; readonly resource: AssetResource } + ); diff --git a/apps/mobile/src/components/MaterialConfirmDialog.tsx b/apps/mobile/src/components/MaterialConfirmDialog.tsx index c21ad042c39c..7154c7d716d8 100644 --- a/apps/mobile/src/components/MaterialConfirmDialog.tsx +++ b/apps/mobile/src/components/MaterialConfirmDialog.tsx @@ -1,4 +1,4 @@ -import type { ConfirmDialogRequest } from "./ConfirmDialogHost"; +import type { ConfirmDialogRequest } from "./ConfirmDialog.types"; export interface MaterialConfirmDialogProps { readonly request: Pick< diff --git a/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx b/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx index ceea91b24447..e941fc52eef1 100644 --- a/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx +++ b/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx @@ -2,7 +2,7 @@ import { SegmentedButton, SingleChoiceSegmentedButtonRow, Text } from "@expo/ui/ import { defaultMinSize, fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; -import type { SegmentedControlProps } from "./SegmentedControl"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; /** Compose content shared by screen controls and native dialogs, inside their existing Host. */ export function MaterialSegmentedButtons( diff --git a/apps/mobile/src/components/MaterialSegmentedControl.android.tsx b/apps/mobile/src/components/MaterialSegmentedControl.android.tsx index 16ec879821e1..76734e14d763 100644 --- a/apps/mobile/src/components/MaterialSegmentedControl.android.tsx +++ b/apps/mobile/src/components/MaterialSegmentedControl.android.tsx @@ -3,7 +3,7 @@ import { MaterialSegmentedButtons } from "./MaterialSegmentedButtons.android"; import { View } from "react-native"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; -import type { SegmentedControlProps } from "./SegmentedControl"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; export function MaterialSegmentedControl( props: SegmentedControlProps, diff --git a/apps/mobile/src/components/MaterialSegmentedControl.tsx b/apps/mobile/src/components/MaterialSegmentedControl.tsx index eca51d2d3f26..0935da6cd66c 100644 --- a/apps/mobile/src/components/MaterialSegmentedControl.tsx +++ b/apps/mobile/src/components/MaterialSegmentedControl.tsx @@ -1,4 +1,4 @@ -import type { SegmentedControlProps } from "./SegmentedControl"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; export function MaterialSegmentedControl( _props: SegmentedControlProps, diff --git a/apps/mobile/src/components/MaterialSwitch.android.tsx b/apps/mobile/src/components/MaterialSwitch.android.tsx index 2bc9d11130d7..f08fa78690f7 100644 --- a/apps/mobile/src/components/MaterialSwitch.android.tsx +++ b/apps/mobile/src/components/MaterialSwitch.android.tsx @@ -1,6 +1,6 @@ import { Host, Switch as ComposeSwitch } from "@expo/ui/jetpack-compose"; import { View } from "react-native"; -import type { ThemedSwitchProps } from "./ThemedSwitch"; +import type { ThemedSwitchProps } from "./MaterialSwitch.types"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; diff --git a/apps/mobile/src/components/MaterialSwitch.types.ts b/apps/mobile/src/components/MaterialSwitch.types.ts new file mode 100644 index 000000000000..657a2139a69e --- /dev/null +++ b/apps/mobile/src/components/MaterialSwitch.types.ts @@ -0,0 +1,12 @@ +import type { SwitchProps } from "react-native"; + +export type ThemedSwitchProps = Pick< + SwitchProps, + | "accessibilityHint" + | "accessibilityLabel" + | "disabled" + | "onValueChange" + | "style" + | "testID" + | "value" +>; diff --git a/apps/mobile/src/components/MediaImagePreview.tsx b/apps/mobile/src/components/MediaImagePreview.tsx index 03e317c296af..09bebcb28b66 100644 --- a/apps/mobile/src/components/MediaImagePreview.tsx +++ b/apps/mobile/src/components/MediaImagePreview.tsx @@ -6,7 +6,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useMediaActions } from "../lib/mediaActions"; import { AppText } from "./AppText"; import { SymbolView } from "./AppSymbol"; -import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types"; import { MediaActionsMenu } from "./MediaActionsMenu"; import { MediaSourceCaption } from "./MediaSourceCaption"; diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 932fc6779f20..e1d883a01026 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -18,7 +18,7 @@ import { hasLoadedProjectFavicon, markProjectFaviconFailed, markProjectFaviconLoaded, -} from "./projectFaviconCache"; +} from "../lib/projectFaviconRequests"; const EMPTY_FAVICON_URL = Atom.make(null); diff --git a/apps/mobile/src/components/RenderErrorBoundary.tsx b/apps/mobile/src/components/RenderErrorBoundary.tsx new file mode 100644 index 000000000000..9b71916d01e7 --- /dev/null +++ b/apps/mobile/src/components/RenderErrorBoundary.tsx @@ -0,0 +1,127 @@ +import { Component, type ReactNode } from "react"; +import { ScrollView, View } from "react-native"; + +import { AppText as Text } from "./AppText"; +import { MaterialButton } from "./MaterialButton"; +import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; + +interface RenderErrorBoundaryProps { + readonly children: ReactNode; + readonly resetKeys?: ReadonlyArray; + readonly renderFallback?: (props: RenderFailureProps) => ReactNode; +} + +interface RenderErrorBoundaryState { + readonly failed: boolean; + readonly error: unknown; + readonly componentStack?: string; + readonly resetKeys?: ReadonlyArray; +} + +export interface RenderFailureProps { + readonly details: string; + readonly retry: () => void; +} + +function errorDetails(error: unknown, componentStack?: string): string { + let description: string; + try { + description = error instanceof Error ? (error.stack ?? error.message) : String(error); + } catch { + description = "Unknown render error"; + } + return componentStack ? `${description}\nComponent stack:\n${componentStack}` : description; +} + +/** A failed subtree remounts on retry or when its identifying inputs change. */ +export class RenderErrorBoundary extends Component< + RenderErrorBoundaryProps, + RenderErrorBoundaryState +> { + override state: RenderErrorBoundaryState = { + failed: false, + error: null, + resetKeys: this.props.resetKeys, + }; + + static getDerivedStateFromProps( + { resetKeys }: RenderErrorBoundaryProps, + state: RenderErrorBoundaryState, + ): Partial | null { + if ( + resetKeys?.length !== state.resetKeys?.length || + resetKeys?.some((key, index) => !Object.is(key, state.resetKeys?.[index])) + ) { + return { failed: false, error: null, componentStack: undefined, resetKeys }; + } + return null; + } + + static getDerivedStateFromError(error: unknown): Partial { + return { failed: true, error }; + } + + override componentDidCatch(_error: unknown, info: { componentStack?: string }) { + this.setState({ componentStack: info.componentStack }); + } + + private readonly retry = () => { + this.setState({ failed: false, error: null, componentStack: undefined }); + }; + + override render() { + if (!this.state.failed) return this.props.children; + const fallback = + this.props.renderFallback ?? + ((props: RenderFailureProps) => ); + return fallback({ + details: errorDetails(this.state.error, this.state.componentStack), + retry: this.retry, + }); + } +} + +export function RenderFailureView( + props: RenderFailureProps & { + readonly title?: string; + readonly bottomInset?: number; + readonly exit?: { readonly label: string; readonly onPress: () => void }; + }, +) { + const title = props.title ?? "This screen couldn't be displayed"; + return ( + + + {title} + + + Try again. If it keeps happening, copy the details for a bug report. + + + {props.details.split("\n", 1)[0]?.slice(0, 300)} + + + + copyTextWithHaptic(props.details, { target: "error details" })} + fullWidth + /> + {props.exit ? ( + + ) : null} + + + ); +} diff --git a/apps/mobile/src/components/SegmentedControl.tsx b/apps/mobile/src/components/SegmentedControl.tsx index e1e61cda26df..8e4d6c3130bc 100644 --- a/apps/mobile/src/components/SegmentedControl.tsx +++ b/apps/mobile/src/components/SegmentedControl.tsx @@ -3,21 +3,9 @@ import Animated, { Easing, LinearTransition, ReduceMotion } from "react-native-r import { AppText as Text } from "./AppText"; import { cn } from "../lib/cn"; import { MaterialSegmentedControl } from "./MaterialSegmentedControl"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; -export interface SegmentedControlProps { - readonly options: readonly { - readonly value: Value; - readonly label: string; - readonly accessibilityLabel?: string; - }[]; - readonly selected: Value; - readonly onSelect: (value: Value) => void; - /** Compact sizing applies to the non-Material control. */ - readonly size?: "default" | "compact"; - /** "tab" for the view switcher; filters stay plain buttons. */ - readonly role?: "tab" | "button"; - readonly className?: string; -} +export type { SegmentedControlProps } from "./SegmentedControl.types"; export function SegmentedControl( props: SegmentedControlProps, diff --git a/apps/mobile/src/components/SegmentedControl.types.ts b/apps/mobile/src/components/SegmentedControl.types.ts new file mode 100644 index 000000000000..921dcdb1cbde --- /dev/null +++ b/apps/mobile/src/components/SegmentedControl.types.ts @@ -0,0 +1,14 @@ +export interface SegmentedControlProps { + readonly options: readonly { + readonly value: Value; + readonly label: string; + readonly accessibilityLabel?: string; + }[]; + readonly selected: Value; + readonly onSelect: (value: Value) => void; + /** Compact sizing applies to the non-Material control. */ + readonly size?: "default" | "compact"; + /** "tab" for the view switcher; filters stay plain buttons. */ + readonly role?: "tab" | "button"; + readonly className?: string; +} diff --git a/apps/mobile/src/components/ThemedSwitch.tsx b/apps/mobile/src/components/ThemedSwitch.tsx index f0cf8701e50c..d88ace54cb49 100644 --- a/apps/mobile/src/components/ThemedSwitch.tsx +++ b/apps/mobile/src/components/ThemedSwitch.tsx @@ -1,17 +1,9 @@ -import { Platform, Switch, type SwitchProps } from "react-native"; +import { Platform, Switch } from "react-native"; import { MaterialSwitch } from "./MaterialSwitch"; +import type { ThemedSwitchProps } from "./MaterialSwitch.types"; -export type ThemedSwitchProps = Pick< - SwitchProps, - | "accessibilityHint" - | "accessibilityLabel" - | "disabled" - | "onValueChange" - | "style" - | "testID" - | "value" ->; +export type { ThemedSwitchProps } from "./MaterialSwitch.types"; export function ThemedSwitch(props: ThemedSwitchProps) { if (Platform.OS === "android") { diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index c4a7cbdcce06..44a1d3114532 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -15,7 +15,10 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as MobileDatabase from "../persistence/mobile-database"; -import { attachProjectFaviconDatabase, projectFaviconCache } from "../lib/projectFaviconCache"; +import { + attachProjectFaviconDatabase, + projectFaviconDatabaseCache, +} from "../lib/projectFaviconDatabaseCache"; const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; // v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump @@ -130,7 +133,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { decode: decodeStoredShellSnapshot, select: (stored) => stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(), - }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate()))), + }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconDatabaseCache.hydrate()))), ), saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { const payload = yield* encodeStoredShellSnapshot({ @@ -241,7 +244,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { .pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))), ), clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) => - Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)).pipe( + Effect.promise(() => projectFaviconDatabaseCache.clearEnvironment(environmentId)).pipe( Effect.andThen(database.clearEnvironmentCache(environmentId)), Effect.mapError(mapDatabaseError("clear-environment")), ), diff --git a/apps/mobile/src/dependency-graph.test.ts b/apps/mobile/src/dependency-graph.test.ts new file mode 100644 index 000000000000..f4c953925bcc --- /dev/null +++ b/apps/mobile/src/dependency-graph.test.ts @@ -0,0 +1,288 @@ +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +/** + * Dependency-graph guards for the mobile source tree (audit #13). + * + * 1. No circular imports, checked once per platform the way Metro resolves + * modules (`..*` before `.native.*` before the + * generic file). A cycle can hide behind platform resolution: a `.tsx` + * base importing a component whose `.android.tsx` variant type-imports + * back into the base is acyclic on iOS but circular on Android. + * Dynamic `import("...")` calls are excluded from this rule on purpose: + * they are the deliberate async escape hatch (e.g. composer-draft cleanup + * reaching `lib/attachmentUpload`), and Metro resolves them after both + * modules have initialized, so they cannot create an initialization cycle. + * + * 2. Cross-layer edges are ceilinged, not yet banned. `state`, `lib`, + * `native`, and `components` still reach upward into `features` at known + * sites (the app composition root `lib/runtime.ts` legitimately wires + * feature layers). Ceilings count unique `from -> to` module pairs across + * all platforms, including dynamic imports, and may only shrink: when you + * remove one of these imports, lower the constant in the same PR. + * + * Runs in CI as part of the `Test` job (`vp run --filter '!t3' test` picks up + * the `@t3tools/mobile` package test task). + */ + +const SOURCE_ROOT = __dirname; + +/** Metro candidate order per platform (platform, then native, then generic). */ +const PLATFORM_EXTENSION_ORDER = { + android: [".android.ts", ".android.tsx", ".native.ts", ".native.tsx", ".ts", ".tsx"], + ios: [".ios.ts", ".ios.tsx", ".native.ts", ".native.tsx", ".ts", ".tsx"], +} as const; + +type Platform = keyof typeof PLATFORM_EXTENSION_ORDER; + +const PLATFORMS = Object.keys(PLATFORM_EXTENSION_ORDER) as ReadonlyArray; + +const isGraphFile = (filePath: string): boolean => + /\.tsx?$/.test(filePath) && + !filePath.includes(".test.") && + !filePath.includes("test-support") && + !filePath.endsWith(".d.ts"); + +/** Files Metro would not even bundle for the other platform. */ +function isRelevantForPlatform(filePath: string, platform: Platform): boolean { + const name = NodePath.basename(filePath); + if (platform === "android") { + return !name.includes(".ios."); + } + return !name.includes(".android."); +} + +function collectSourceFiles(dir: string, out: string[] = []): string[] { + for (const entry of NodeFS.readdirSync(dir)) { + const filePath = NodePath.join(dir, entry); + if (NodeFS.statSync(filePath).isDirectory()) { + collectSourceFiles(filePath, out); + } else if (isGraphFile(filePath)) { + out.push(filePath); + } + } + return out; +} + +function resolveRelative( + fromFile: string, + specifier: string, + extensionOrder: ReadonlyArray, +): string | null { + if (!specifier.startsWith(".")) { + return null; + } + const base = NodePath.resolve(NodePath.dirname(fromFile), specifier); + for (const ext of extensionOrder) { + for (const candidate of [base + ext, NodePath.join(base, `index${ext}`)]) { + try { + if (NodeFS.statSync(candidate).isFile()) { + const resolved = NodePath.resolve(candidate); + return resolved.startsWith(SOURCE_ROOT + NodePath.sep) ? resolved : null; + } + } catch { + // Candidate does not exist; try the next one. + } + } + } + return null; +} + +interface ParsedImport { + readonly specifier: string; + readonly isDynamic: boolean; +} + +function parseImports(source: string): ParsedImport[] { + const parsed: ParsedImport[] = []; + const staticRe = /(?:^|\n)\s*(?:import|export)[\s\S]*?from\s+["']([^"']+)["']/g; + const bareRe = /(?:^|\n)\s*import\s+["']([^"']+)["']/g; + const dynamicRe = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g; + for (const match of source.matchAll(staticRe)) { + parsed.push({ specifier: match[1]!, isDynamic: false }); + } + for (const match of source.matchAll(bareRe)) { + parsed.push({ specifier: match[1]!, isDynamic: false }); + } + for (const match of source.matchAll(dynamicRe)) { + parsed.push({ specifier: match[1]!, isDynamic: true }); + } + return parsed; +} + +type Layer = "state" | "lib" | "components" | "native" | "features" | "other"; + +function layerOf(relativePath: string): Layer { + const top = relativePath.split(NodePath.sep)[0]!; + if (top === "features") return "features"; + return top === "state" || top === "lib" || top === "components" || top === "native" + ? (top as Layer) + : "other"; +} + +interface PlatformGraph { + readonly platform: Platform; + readonly files: ReadonlyArray; + /** Static (type or value) import edges keyed by source file. */ + readonly staticEdges: ReadonlyMap>; + /** Unique cross-layer edges, static and dynamic, as "fromRel -> toRel". */ + readonly crossLayerEdges: ReadonlySet; +} + +function buildPlatformGraph(platform: Platform): PlatformGraph { + const extensionOrder = PLATFORM_EXTENSION_ORDER[platform]; + const files = collectSourceFiles(SOURCE_ROOT) + .filter((file) => isRelevantForPlatform(file, platform)) + .sort(); + const staticEdges = new Map(); + const crossLayerEdges = new Set(); + for (const file of files) { + const targets = new Set(); + for (const { specifier, isDynamic } of parseImports(NodeFS.readFileSync(file, "utf8"))) { + const resolved = resolveRelative(file, specifier, extensionOrder); + if (resolved === null || resolved === file) { + continue; + } + const from = NodePath.relative(SOURCE_ROOT, file); + const to = NodePath.relative(SOURCE_ROOT, resolved); + const fromLayer = layerOf(from); + const toLayer = layerOf(to); + const upward = + (fromLayer === "state" || + fromLayer === "lib" || + fromLayer === "components" || + fromLayer === "native") && + (toLayer === "features" || (fromLayer === "lib" && toLayer === "state")); + if (upward) { + crossLayerEdges.add(`${from} -> ${to}`); + } + if (!isDynamic) { + targets.add(resolved); + } + } + staticEdges.set(file, [...targets]); + } + return { platform, files, staticEdges, crossLayerEdges }; +} + +/** Tarjan strongly-connected components, iterative to bound stack depth. */ +function findCycles(graph: PlatformGraph): ReadonlyArray> { + const index = new Map(); + const low = new Map(); + const onStack = new Set(); + const stack: string[] = []; + const cycles: string[][] = []; + let nextIndex = 0; + + const enter = (node: string): void => { + index.set(node, nextIndex); + low.set(node, nextIndex); + nextIndex += 1; + stack.push(node); + onStack.add(node); + }; + + for (const root of graph.files) { + if (index.has(root)) continue; + const work: Array<[string, number]> = [[root, 0]]; + enter(root); + while (work.length > 0) { + const frame = work[work.length - 1]!; + const neighbors = graph.staticEdges.get(frame[0]) ?? []; + let advanced = false; + for (let i = frame[1]; i < neighbors.length; i += 1) { + const child = neighbors[i]!; + if (!graph.staticEdges.has(child)) continue; + if (!index.has(child)) { + work[work.length - 1] = [frame[0], i + 1]; + work.push([child, 0]); + enter(child); + advanced = true; + break; + } else if (onStack.has(child)) { + low.set(frame[0], Math.min(low.get(frame[0])!, index.get(child)!)); + } + } + if (advanced) continue; + work.pop(); + const parent = work[work.length - 1]; + if (parent) { + low.set(parent[0], Math.min(low.get(parent[0])!, low.get(frame[0])!)); + } + if (low.get(frame[0]) === index.get(frame[0])) { + const component: string[] = []; + let member: string; + do { + member = stack.pop()!; + onStack.delete(member); + component.push(member); + } while (member !== frame[0]); + if (component.length > 1) { + cycles.push(component.sort().map((file) => NodePath.relative(SOURCE_ROOT, file))); + } + } + } + } + return cycles; +} + +const graphs = PLATFORMS.map(buildPlatformGraph); + +/** Unique upward edge pairs across every platform, sorted for stable diffs. */ +function upwardEdges(): string[] { + const union = new Set(); + for (const graph of graphs) { + for (const edge of graph.crossLayerEdges) { + union.add(edge); + } + } + return [...union].sort(); +} + +describe("mobile dependency graph", () => { + it.each(PLATFORMS)("has no circular imports under %s resolution", (platform) => { + const graph = graphs.find((candidate) => candidate.platform === platform)!; + // The graph must see real files; a resolution regression here would make + // both rules vacuously pass. + expect(graph.files.length).toBeGreaterThan(500); + expect(findCycles(graph)).toEqual([]); + }); + + it("keeps upward imports from state/lib/components/native into features at the ceiling", () => { + const edges = upwardEdges(); + const edgesFor = (from: Layer, to: Layer): string[] => + edges.filter((edge) => { + const [source, target] = edge.split(" -> "); + return layerOf(source!) === from && layerOf(target!) === to; + }); + + const ceilings: ReadonlyArray = [ + // state -> features: thread ordering reaching the thread-list model, + // the incoming-share store, the connection controller hook, the + // terminal launch context, and the pending message feed. + // (legacy-plan-mode was pure model logic and moved into state/.) + ["state", "features", 6, "state must not add imports from features"], + // lib -> features: lib/runtime.ts is the app composition root and + // legitimately wires cloud/observability features; the appearance + // helpers and terminal preferences still need untangling. + ["lib", "features", 7, "lib must not add imports from features"], + // components -> features: mostly the appearance preferences provider + // and the layout toolbar bridges. + ["components", "features", 33, "components must not add imports from features"], + // native -> features: native glue reading appearance/keyboard/review features. + ["native", "features", 8, "native must not add imports from features"], + // lib -> state: attachment/session plumbing that predates the cycle + // cleanup; each remaining edge needs a real owner-side seam. + ["lib", "state", 11, "lib must not add imports from state"], + ]; + + for (const [from, to, ceiling, message] of ceilings) { + const layerEdges = edgesFor(from, to); + expect( + layerEdges.length, + `${message}. ${layerEdges.length} edges remain:\n${layerEdges.join("\n")}`, + ).toBeLessThanOrEqual(ceiling); + } + }); +}); diff --git a/apps/mobile/src/features/agent-awareness/androidNotifications.ts b/apps/mobile/src/features/agent-awareness/androidNotifications.ts index 9ffe586ecb81..a2a0945faaf1 100644 --- a/apps/mobile/src/features/agent-awareness/androidNotifications.ts +++ b/apps/mobile/src/features/agent-awareness/androidNotifications.ts @@ -6,6 +6,7 @@ interface AndroidAgentNotifications { configure(deviceId: string, userId: string, scheme: string, ongoingEnabled: boolean): void; clear(): void; openLiveUpdateSettings?(): boolean; + showShowcaseActivity?(scheme: string, data: Record): void; } const native = @@ -17,18 +18,24 @@ export function supportsAndroidAgentNotifications(): boolean { return typeof native?.configure === "function" && typeof native?.clear === "function"; } +function appScheme(): string { + const scheme = Constants.expoConfig?.scheme; + return (Array.isArray(scheme) ? scheme[0] : scheme) ?? "t3code"; +} + export function configureAndroidAgentNotifications( deviceId: string, userId: string, ongoingEnabled: boolean, ): void { - const scheme = Constants.expoConfig?.scheme; - native?.configure?.( - deviceId, - userId, - (Array.isArray(scheme) ? scheme[0] : scheme) ?? "t3code", - ongoingEnabled, - ); + native?.configure?.(deviceId, userId, appScheme(), ongoingEnabled); +} + +/** Posts a staged relay payload for the showcase capture; false when unsupported. */ +export function showAndroidShowcaseAgentActivity(data: Record): boolean { + if (!native?.showShowcaseActivity) return false; + native.showShowcaseActivity(appScheme(), data); + return true; } export function clearAndroidAgentNotifications(): void { diff --git a/apps/mobile/src/features/cloud/cloudDebugLog.ts b/apps/mobile/src/features/cloud/cloudDebugLog.ts index 840a3db55680..7e57b39b7bb8 100644 --- a/apps/mobile/src/features/cloud/cloudDebugLog.ts +++ b/apps/mobile/src/features/cloud/cloudDebugLog.ts @@ -1,18 +1,14 @@ +import { createDebugLogger } from "../../lib/debugLog"; + +const logger = createDebugLogger("cloud", { + enabledInDev: true, + legacyGlobalFlag: "__T3_CLOUD_DEBUG__", +}); + export function isCloudDebugEnabled(): boolean { - return ( - (typeof __DEV__ !== "undefined" && __DEV__) || - (typeof globalThis !== "undefined" && - (globalThis as { __T3_CLOUD_DEBUG__?: boolean }).__T3_CLOUD_DEBUG__ === true) - ); + return logger.isEnabled(); } export function cloudDebugLog(event: string, data?: Record): void { - if (!isCloudDebugEnabled()) { - return; - } - if (data) { - console.log(`[t3-cloud] ${event}`, data); - } else { - console.log(`[t3-cloud] ${event}`); - } + logger.log(event, data); } diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 2828392ba8f3..d362b78a9360 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -35,6 +35,7 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect interface CloudEnvironmentRowsProps { readonly connectedCloudEnvironments: ReadonlyArray; + readonly onOpenEnvironment?: (environmentId: EnvironmentId) => void; readonly onSetEnvironmentEnabled: (environmentId: EnvironmentId, enabled: boolean) => void; /** Long-press on a saved row. The callback owns the confirm. */ readonly onRemoveEnvironment: (environmentId: EnvironmentId) => void; @@ -124,26 +125,30 @@ function CloudEnvironmentRowsContent( ) : null} {hasCloudRows ? ( - - {props.connectedCloudEnvironments.map((environment, index) => ( + + {props.connectedCloudEnvironments.map((environment) => ( props.onSetEnvironmentEnabled(environment.environmentId, enabled) } onRemove={() => props.onRemoveEnvironment(environment.environmentId)} + onOpen={ + props.onOpenEnvironment + ? () => props.onOpenEnvironment?.(environment.environmentId) + : undefined + } errorExpanded={expandedErrorId === environment.environmentId} onToggleError={() => handleToggleCloudError(environment.environmentId)} /> ))} - {availableCloudEnvironments.map((environment, index) => ( + {availableCloudEnvironments.map((environment) => ( 0 || index !== 0} + showChevron={props.onOpenEnvironment !== undefined} onConnect={() => handleConnectCloudEnvironment(environment)} errorExpanded={expandedErrorId === environment.environment.environmentId} onToggleError={() => handleToggleCloudError(environment.environment.environmentId)} @@ -151,14 +156,14 @@ function CloudEnvironmentRowsContent( ))} ) : controller.relayDiscovery.isRefreshing ? ( - + Loading linked cloud environments. ) : controller.relayDiscovery.error ? null : ( - + No additional linked cloud environments. @@ -170,7 +175,7 @@ function CloudEnvironmentRowsContent( {discoveryAvailable && controller.relayDiscovery.error && !controller.relayDiscovery.isRefreshing ? ( - + Could not load T3 Connect environments @@ -202,10 +207,10 @@ function ConnectedCloudEnvironmentRow(props: { readonly environment: ConnectedEnvironmentSummary; /** Discovery's view of the server, for the glyph before the first connection. */ readonly descriptor: ExecutionEnvironmentDescriptor | undefined; - readonly borderTop: boolean; readonly errorExpanded: boolean; readonly onSetEnabled: (enabled: boolean) => void; readonly onRemove: () => void; + readonly onOpen?: (() => void) | undefined; readonly onToggleError: () => void; }) { const serverConfig = useAtomValue( @@ -222,10 +227,13 @@ function ConnectedCloudEnvironmentRow(props: { return ( void; readonly onToggleError: () => void; @@ -260,7 +268,7 @@ function CloudEnvironmentRow(props: { return ( + @@ -409,10 +412,16 @@ function CloudEnvironmentRowShell(props: { + {props.opensDetails || props.showChevron ? ( + + + + ) : null} ); } diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index cf90cd42a0ad..28b34fdec6d3 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -35,6 +35,7 @@ function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string export function ConnectionEnvironmentRow(props: { readonly environment: ConnectedEnvironmentSummary; readonly expanded: boolean; + readonly opensDetails?: boolean; readonly onToggle: () => void; readonly onReconnect: (environmentId: EnvironmentId) => void; readonly onRemove: (environmentId: EnvironmentId) => void; @@ -76,19 +77,22 @@ export function ConnectionEnvironmentRow(props: { }, [label, url, props]); return ( - + - - + - - {props.environment.displayUrl} - + {!props.environment.isRelayManaged && props.environment.displayUrl.trim() ? ( + + {props.environment.displayUrl} + + ) : null} {statusLabel ? ( props.onSetEnabled(props.environment.environmentId, next)} value={enabled} /> : null} - + - {[...catalog.entries.values()].map((entry, index) => { + {[...catalog.entries.values()].map((entry) => { const environmentId = entry.target.environmentId; const selected = gitHubRoutingPermissionFor(entry, permissions); const disabled = !catalog.isReady || saving || gitHubRoutingConnectionKey(entry) === null; return ( - + { setSaving(true); void update({ environmentId, permission: option.value }).then((result) => { diff --git a/apps/mobile/src/features/connection/LocalEnvironmentList.tsx b/apps/mobile/src/features/connection/LocalEnvironmentList.tsx index dca29e092f64..bec038390a9e 100644 --- a/apps/mobile/src/features/connection/LocalEnvironmentList.tsx +++ b/apps/mobile/src/features/connection/LocalEnvironmentList.tsx @@ -4,7 +4,6 @@ import { View } from "react-native"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; -import { cn } from "../../lib/cn"; import { ConnectionEnvironmentRow } from "./ConnectionEnvironmentRow"; type EnvironmentRowProps = ComponentProps; @@ -15,14 +14,20 @@ export function LocalEnvironmentList({ expandedId, onToggle, ...rowActions -}: Pick & { +}: Pick< + EnvironmentRowProps, + "onReconnect" | "onRemove" | "onSetEnabled" | "onUpdate" | "opensDetails" +> & { readonly environments: ReadonlyArray; readonly expandedId: EnvironmentId | null; readonly onToggle: (environmentId: EnvironmentId) => void; }) { if (environments.length === 0) { return ( - + - {environments.map((environment, index) => ( - + + {environments.map((environment) => ( + (null); const [inputConnected, setInputConnected] = useState(false); const [streamAttempt, setStreamAttempt] = useState(0); const [shuttingDown, setShuttingDown] = useState(false); + const retryHost = useAtomCommand(deviceEnvironment.list); const shutdown = useAtomCommand(deviceEnvironment.shutdown, { reportFailure: false }); const streamRef = useRef(null); const state = useEnvironmentQuery(deviceEnvironment.state({ environmentId, input: {} })); @@ -87,7 +93,7 @@ function DevicePreviewScreen({ ); useEffect(() => { const subscription = AppState.addEventListener("change", (state) => - setForeground(state === "active"), + setForeground(state !== "background"), ); return () => subscription.remove(); }, []); @@ -116,10 +122,58 @@ function DevicePreviewScreen({ }; const controls: ScreenHeaderMenuItem[] = [ + ...(state.data?.hosts + .filter( + (host) => + state.data?.supportsHostRetry && state.data.hostStatuses[host.id]?.status === "failed", + ) + .map((host) => ({ + id: `retry-${host.id}`, + title: `Retry ${host.label}`, + icon: "arrow.clockwise" as const, + onPress: () => { + void retryHost({ environmentId, input: { retryHostId: host.id } }); + }, + })) ?? []), + ...(state.data?.supportsToolInspection + ? [ + { + id: "check-device-tools", + title: "Check device tool versions", + icon: "arrow.clockwise" as const, + onPress: () => { + void retryHost({ environmentId, input: { inspectOnly: true } }); + }, + }, + ] + : []), + { + id: "device-tools", + title: "Device tool versions", + icon: "info.circle", + onPress: () => + Alert.alert( + "Device tool versions", + deviceToolUpdateOwnership + + "\n\n" + + deviceToolUpdatePolicy( + state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools, + ) + + "\n\n" + + deviceToolVersionLabels( + state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools, + ).join("\n") + + "\n" + + (state.data?.hosts.find((host) => host.id === preview?.session.hostId) + ?.toolInspectionError ?? + state.data?.hostStatuses[preview?.session.hostId ?? ""]?.detail ?? + ""), + ), + }, { id: "reload", title: "Reload stream", - icon: "arrow.clockwise", + icon: "arrow.clockwise" as const, disabled: !preview || shuttingDown, onPress: () => { setInputConnected(false); @@ -149,7 +203,7 @@ function DevicePreviewScreen({ { id: "rotate", title: "Rotate device", - icon: "arrow.clockwise", + icon: "arrow.clockwise" as const, disabled: !inputConnected, onPress: () => streamRef.current?.rotate(), }, diff --git a/apps/mobile/src/features/devices/DeviceStreamWebView.tsx b/apps/mobile/src/features/devices/DeviceStreamWebView.tsx index 9a5cef83ae3f..423fdb1c84a0 100644 --- a/apps/mobile/src/features/devices/DeviceStreamWebView.tsx +++ b/apps/mobile/src/features/devices/DeviceStreamWebView.tsx @@ -1,7 +1,19 @@ import deviceStreamScript from "@t3tools/mobile-device-stream"; -import { useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type Ref } from "react"; -import { Platform } from "react-native"; +import { + useEffect, + useEffectEvent, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, + type Ref, +} from "react"; +import { ActivityIndicator, Platform, Pressable, View } from "react-native"; import { WebView } from "react-native-webview"; +import type { DeviceStreamStatus } from "@t3tools/client-runtime/device/stream"; + +import { AppText } from "../../components/AppText"; import { deviceStreamDocument, @@ -27,6 +39,7 @@ export function DeviceStreamWebView({ ...props }: DeviceStreamConfiguration & NativeStreamBridge) { const [attempt, setAttempt] = useState(0); + const processRetried = useRef(false); const configuration = JSON.stringify({ access: props.access, platform: props.platform, @@ -41,7 +54,21 @@ export function DeviceStreamWebView({ background={props.colors.background} onUnauthorized={props.onUnauthorized} onInputConnected={props.onInputConnected} - onRetry={() => setAttempt((attempt) => attempt + 1)} + onRetry={() => { + processRetried.current = false; + setAttempt((attempt) => attempt + 1); + void props.onUnauthorized(); + }} + onStreaming={() => { + processRetried.current = false; + }} + onRecoverProcess={() => { + if (processRetried.current) return false; + processRetried.current = true; + setAttempt((attempt) => attempt + 1); + void props.onUnauthorized(); + return true; + }} /> ); } @@ -53,12 +80,38 @@ function DeviceStreamDocumentView({ onUnauthorized, onInputConnected, onRetry, + onStreaming, + onRecoverProcess, }: NativeStreamBridge & { readonly configuration: string; readonly background: string; readonly onRetry: () => void; + readonly onStreaming: () => void; + readonly onRecoverProcess: () => boolean; }) { const webView = useRef(null); + const active = useRef(true); + const failed = useRef(false); + const [status, setStatus] = useState("connecting"); + const [error, setError] = useState(null); + const [started, setStarted] = useState(false); + const fail = (message: string) => { + if (!active.current || failed.current) return; + failed.current = true; + void onInputConnected(false); + webView.current?.injectJavaScript("window.T3DeviceStream?.stop(); true;"); + setError(message); + setStatus("error"); + }; + // The shared transport owns video timeouts once the document acknowledges startup. + const bootstrapTimedOut = useEffectEvent(() => + fail("Device viewer could not start. Reconnect to try again."), + ); + useEffect(() => { + if (started) return; + const timer = setTimeout(bootstrapTimedOut, 15_000); + return () => clearTimeout(timer); + }, [started]); const source = useMemo( () => ({ html: deviceStreamDocument(configuration, deviceStreamScript), @@ -78,32 +131,80 @@ function DeviceStreamDocumentView({ appSwitcher: () => command("appSwitcher"), rotate: () => command("rotate"), })); + const resetInput = useEffectEvent(() => void onInputConnected(false)); useLayoutEffect(() => { + active.current = true; + resetInput(); const view = webView.current; - return () => view?.injectJavaScript("window.T3DeviceStream?.stop(); true;"); + return () => { + active.current = false; + view?.injectJavaScript("window.T3DeviceStream?.stop(); true;"); + }; }, []); + const processTerminated = () => { + if (!active.current || failed.current) return; + void onInputConnected(false); + if (!onRecoverProcess()) fail("Device viewer stopped. Reconnect to try again."); + }; return ( - void onInputConnected(false)} - onShouldStartLoadWithRequest={(request) => - request.url === "about:blank" || request.url === source.baseUrl - } - onMessage={(event) => { - const message = deviceStreamMessage(event.nativeEvent.data); - if (message?.type === "unauthorized") void onUnauthorized(); - else if (message?.type === "input") void onInputConnected(message.connected); - else if (message?.type === "retry") onRetry(); - }} - /> + + fail("Device viewer could not load. Reconnect to try again.")} + onHttpError={() => fail("Device viewer could not load. Reconnect to try again.")} + onContentProcessDidTerminate={processTerminated} + onRenderProcessGone={processTerminated} + onShouldStartLoadWithRequest={(request) => + request.url === "about:blank" || request.url === source.baseUrl + } + onMessage={(event) => { + if (!active.current || failed.current) return; + const message = deviceStreamMessage(event.nativeEvent.data); + if (message?.type === "unauthorized") void onUnauthorized(); + else if (message?.type === "input") void onInputConnected(message.connected); + else if (message?.type === "retry") onRetry(); + else if (message?.type === "status") { + setStarted(true); + if (message.status === "error") fail(message.detail ?? "Device stream failed."); + else { + setStatus(message.status); + if (message.status === "streaming") onStreaming(); + } + } + }} + /> + {status !== "streaming" ? ( + + {status === "connecting" ? : null} + + {status === "error" ? error : "Connecting to device..."} + + {status === "error" ? ( + + Reconnect + + ) : null} + + ) : null} + ); } diff --git a/apps/mobile/src/features/devices/device-stream-document.test.ts b/apps/mobile/src/features/devices/device-stream-document.test.ts index ffc08667a147..95aa6a9dad23 100644 --- a/apps/mobile/src/features/devices/device-stream-document.test.ts +++ b/apps/mobile/src/features/devices/device-stream-document.test.ts @@ -1,5 +1,5 @@ import * as NodeVM from "node:vm"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { deviceStreamDocument, deviceStreamMessage } from "./device-stream-document"; @@ -13,7 +13,7 @@ describe("native device stream document", () => { ); const script = html.match(/`; + const failure = `window.ReactNativeWebView.postMessage(JSON.stringify({type:"status",status:"error",detail:"Device viewer stopped unexpectedly."}));`; + return ``; } export function deviceStreamMessage(data: string) { @@ -36,6 +37,21 @@ export function deviceStreamMessage(data: string) { ) { return { type: message.type, connected: message.connected } as const; } + if ( + message.type === "status" && + "status" in message && + (message.status === "connecting" || + message.status === "streaming" || + message.status === "error") && + (!("detail" in message) || typeof message.detail === "string") + ) { + return { + type: message.type, + status: message.status, + detail: + "detail" in message && typeof message.detail === "string" ? message.detail : undefined, + } as const; + } } catch { // Ignore messages that are not part of the stream bridge. } diff --git a/apps/mobile/src/features/devices/device-stream.browser.test.ts b/apps/mobile/src/features/devices/device-stream.browser.test.ts new file mode 100644 index 000000000000..5aeefdac0203 --- /dev/null +++ b/apps/mobile/src/features/devices/device-stream.browser.test.ts @@ -0,0 +1,117 @@ +import { afterEach, expect, it, vi } from "vite-plus/test"; +import { start, stop } from "./device-stream.browser"; + +class Element extends EventTarget { + readonly style = {}; + naturalWidth = 0; + naturalHeight = 0; + src = ""; + readonly tag: string; + constructor(tag: string) { + super(); + this.tag = tag; + } + setAttribute() {} + removeAttribute(name: string) { + if (name === "src") this.src = ""; + } + append() {} +} + +async function setup() { + vi.useFakeTimers(); + const elements: Element[] = []; + vi.stubGlobal("document", { + documentElement: { style: {} }, + body: { style: {}, replaceChildren() {} }, + createElement: (tag: string) => { + const element = new Element(tag); + elements.push(element); + return element; + }, + }); + const postMessage = vi.fn(); + vi.stubGlobal("window", { ReactNativeWebView: { postMessage }, addEventListener() {} }); + vi.stubGlobal("fetch", () => Promise.resolve(new Response("prime"))); + const sockets: Socket[] = []; + class Socket { + static OPEN = 1; + readyState = 1; + onopen: (() => void) | null = null; + close = vi.fn(); + send = vi.fn(); + constructor() { + sockets.push(this); + } + } + vi.stubGlobal("WebSocket", Socket); + const configuration = { + platform: "ios" as const, + deviceId: "fixture-device", + access: { + httpBase: "https://device.test", + wsBase: "wss://device.test", + credentials: false, + query: {}, + }, + colors: { + background: "white", + foreground: "black", + muted: "gray", + buttonBackground: "gray", + buttonForeground: "black", + buttonBorder: "gray", + }, + }; + start(configuration); + await vi.advanceTimersByTimeAsync(0); + return { + configuration, + elements, + sockets, + messages: () => + postMessage.mock.calls.map( + ([message]) => JSON.parse(message as string) as { type: string; status?: string }, + ), + }; +} + +afterEach(() => { + stop(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +it("bridges shared first-frame readiness, image failure, and a successful fresh attempt to native", async () => { + const { elements, messages, sockets, configuration } = await setup(); + sockets[0]!.onopen?.(); + expect(messages()).not.toContainEqual({ type: "status", status: "streaming" }); + expect(messages()).toContainEqual({ type: "input", connected: true }); + const image = elements.find((element) => element.tag === "img")!; + image.naturalWidth = 400; + image.naturalHeight = 800; + await vi.advanceTimersByTimeAsync(250); + expect(messages()).toContainEqual({ type: "status", status: "streaming" }); + image.dispatchEvent(new Event("error")); + expect(messages()).toContainEqual({ + type: "status", + status: "error", + detail: "Could not receive the device stream. Reconnect to try again.", + }); + expect(messages()).toContainEqual({ type: "input", connected: false }); + expect(messages()).not.toContainEqual({ type: "unauthorized" }); + expect(image.src).toBe(""); + expect(sockets[0]!.close).toHaveBeenCalledOnce(); + start(configuration); + await vi.advanceTimersByTimeAsync(0); + const replacement = elements.findLast((element) => element.tag === "img")!; + replacement.naturalWidth = 400; + replacement.naturalHeight = 800; + replacement.dispatchEvent(new Event("load")); + expect(messages().at(-1)).toEqual({ type: "status", status: "streaming" }); + image.dispatchEvent(new Event("error")); + expect(sockets[1]!.close).not.toHaveBeenCalled(); + stop(); + expect(replacement.src).toBe(""); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/apps/mobile/src/features/devices/device-stream.browser.ts b/apps/mobile/src/features/devices/device-stream.browser.ts index efa81d9a043a..0b9e98a51b9f 100644 --- a/apps/mobile/src/features/devices/device-stream.browser.ts +++ b/apps/mobile/src/features/devices/device-stream.browser.ts @@ -12,13 +12,10 @@ declare global { } let activeClient: ReturnType | null = null; -let activeImage: HTMLImageElement | null = null; export function stop() { activeClient?.stop(); activeClient = null; - activeImage?.removeAttribute("src"); - activeImage = null; } export function command(button: "home" | "back" | "appSwitcher" | "rotate") { @@ -70,34 +67,6 @@ export function start(configuration: DeviceStreamConfiguration) { image.alt = ""; image.draggable = false; image.style.display = "none"; - const overlay = document.createElement("div"); - overlay.setAttribute("role", "status"); - Object.assign(overlay.style, { - position: "fixed", - inset: "0", - display: "flex", - flexDirection: "column", - alignItems: "center", - justifyContent: "center", - gap: "16px", - padding: "24px", - textAlign: "center", - background: colors.background, - }); - const detail = document.createElement("span"); - const retry = document.createElement("button"); - retry.textContent = "Retry"; - Object.assign(retry.style, { - padding: "12px 24px", - borderRadius: "20px", - border: `1px solid ${colors.buttonBorder}`, - background: colors.buttonBackground, - color: colors.buttonForeground, - font: "inherit", - display: "none", - }); - retry.addEventListener("click", () => post({ type: "retry" })); - overlay.append(detail, retry); const inputStatus = document.createElement("div"); inputStatus.setAttribute("role", "status"); inputStatus.textContent = "Reconnecting device controls..."; @@ -114,11 +83,17 @@ export function start(configuration: DeviceStreamConfiguration) { }); frame.append(canvas, image); container.append(frame); - document.body.replaceChildren(container, overlay, inputStatus); + document.body.replaceChildren(container, inputStatus); let pointerId: number | null = null; let inputConnected = false; let streaming = false; + const reportStatus = (status: "connecting" | "streaming" | "error", detail?: string) => { + if (activeClient !== client) return; + streaming = status === "streaming"; + inputStatus.style.display = streaming && !inputConnected ? "block" : "none"; + post({ type: "status", status, detail }); + }; const layout = (screen: DeviceScreenSize | null) => { const landscape = screen?.orientation === "landscape_left" || screen?.orientation === "landscape_right"; @@ -160,19 +135,11 @@ export function start(configuration: DeviceStreamConfiguration) { { ...configuration, preferMjpeg: platform === "ios" }, canvas, { - onStatus: (status, message) => { - streaming = status === "streaming"; - overlay.style.display = streaming ? "none" : "flex"; - inputStatus.style.display = streaming && !inputConnected ? "block" : "none"; - detail.textContent = - status === "error" ? (message ?? "Device stream failed.") : "Connecting to device..."; - retry.style.display = status === "error" ? "block" : "none"; - }, + onStatus: reportStatus, onScreen: layout, - onMjpegFallback: (url) => { + onMjpegFallback: () => { canvas.style.display = "none"; image.style.display = "block"; - image.src = url; }, onUnauthorized: unauthorized, onInputConnected: (connected) => { @@ -183,8 +150,7 @@ export function start(configuration: DeviceStreamConfiguration) { }, ); activeClient = client; - activeImage = image; - image.addEventListener("error", unauthorized); + client.setMjpegImage(image); const touch = (event: PointerEvent, phase: "begin" | "move" | "end") => { const rect = frame.getBoundingClientRect(); client.sendTouch( diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 06ef8f6ba4d9..06b6664431b5 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -417,7 +417,6 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { const { fileInspector, layout, showAuxiliaryPane } = useAdaptiveWorkspaceLayout(); const [searchQuery, setSearchQuery] = useState(""); const { themeAppearance: highlightTheme } = useAppearancePreferences(); - const headerColor = useUniwindTheme()["--color-header"]; const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( props.route.params, ); @@ -554,13 +553,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { ); - return Platform.OS === "android" ? ( - - {content} - - ) : ( - content - ); + return Platform.OS === "android" ? {content} : content; } export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index c3c906e3d515..dfe0c05743c2 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -147,7 +147,7 @@ export function ThreadFileNavigatorPane(props: { } > {Platform.OS === "android" ? ( diff --git a/apps/mobile/src/features/home/HomeHeader.android.tsx b/apps/mobile/src/features/home/HomeHeader.android.tsx index 78e87431b471..0c15bcc09ce7 100644 --- a/apps/mobile/src/features/home/HomeHeader.android.tsx +++ b/apps/mobile/src/features/home/HomeHeader.android.tsx @@ -1,13 +1,7 @@ import type { MenuAction } from "@react-native-menu/menu"; import { useCallback, useMemo } from "react"; import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { MaterialThreadListToolbar } from "./MaterialThreadListToolbar"; -import { - hasCustomHomeListOptions, - PROJECT_SORT_OPTIONS, - THREAD_SORT_OPTIONS, -} from "./home-list-options"; import type { HomeHeaderProps } from "./HomeHeader.types"; export type { HomeHeaderEnvironment } from "./HomeHeader.types"; @@ -17,13 +11,11 @@ function checkedMenuState(checked: boolean) { } export function HomeHeader(props: HomeHeaderProps) { - // Thread List v2 lays the list out in fixed creation order, so the - // sort/group filter controls would be silently ignored — hide them and - // key the "customized" icon state off the environment filter alone. - const threadListV2Enabled = useThreadListV2Enabled(); - const hasCustomListOptions = threadListV2Enabled - ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null - : hasCustomHomeListOptions(props); + // The list uses a fixed creation order and ignores sort/group options, so + // the filter menu only carries the filters and the "customized" icon state + // keys off those alone. + const hasCustomListOptions = + props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; const menuActions = useMemo( () => [ { @@ -62,38 +54,8 @@ export function HomeHeader(props: HomeHeaderProps) { ], }, ] satisfies MenuAction[])), - ...(threadListV2Enabled - ? [] - : ([ - { - id: "project-sort", - title: "Sort projects", - subactions: PROJECT_SORT_OPTIONS.map((option) => ({ - id: `project-sort:${option.value}`, - title: option.label, - state: checkedMenuState(props.projectSortOrder === option.value), - })), - }, - { - id: "thread-sort", - title: "Sort threads", - subactions: THREAD_SORT_OPTIONS.map((option) => ({ - id: `thread-sort:${option.value}`, - title: option.label, - state: checkedMenuState(props.threadSortOrder === option.value), - })), - }, - ] satisfies MenuAction[])), - ], - [ - props.environments, - props.projectSortOrder, - props.projects, - props.selectedEnvironmentId, - props.selectedProjectKey, - props.threadSortOrder, - threadListV2Enabled, ], + [props.environments, props.projects, props.selectedEnvironmentId, props.selectedProjectKey], ); const handleMenuAction = useCallback( (event: { nativeEvent: { event: string } }) => { @@ -126,20 +88,6 @@ export function HomeHeader(props: HomeHeaderProps) { } return; } - - const projectSort = PROJECT_SORT_OPTIONS.find( - (option) => id === `project-sort:${option.value}`, - ); - if (projectSort) { - props.onProjectSortOrderChange(projectSort.value); - return; - } - - const threadSort = THREAD_SORT_OPTIONS.find((option) => id === `thread-sort:${option.value}`); - if (threadSort) { - props.onThreadSortOrderChange(threadSort.value); - return; - } }, [props], ); diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 2f044862f703..e1cc7448eb59 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -2,7 +2,6 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac import { useCallback, useRef } from "react"; import type { SearchBarCommands } from "react-native-screens"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; -import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { @@ -10,11 +9,6 @@ import { NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; import { buildHomeListFilterMenu } from "./home-list-filter-menu"; -import { - hasCustomHomeListOptions, - PROJECT_SORT_OPTIONS, - THREAD_SORT_OPTIONS, -} from "./home-list-options"; import type { HomeHeaderProps } from "./HomeHeader.types"; export type { HomeHeaderEnvironment } from "./HomeHeader.types"; @@ -22,22 +16,17 @@ export type { HomeHeaderEnvironment } from "./HomeHeader.types"; export function HomeHeader(props: HomeHeaderProps) { const searchBarRef = useRef(null); const iconColor = useUniwindTheme()["--color-icon"]; - // Thread List v2 lays the list out in fixed creation order, so the - // sort/group filter controls would be silently ignored — hide them and - // key the "customized" icon state off the environment filter alone. - const threadListV2Enabled = useThreadListV2Enabled(); - const hasCustomListOptions = threadListV2Enabled - ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null - : hasCustomHomeListOptions(props); + // The list uses a fixed creation order and ignores sort/group options, so + // the filter menu only carries the filters and the "customized" icon state + // keys off those alone. + const hasCustomListOptions = + props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; const focusSearch = useCallback(() => { searchBarRef.current?.focus(); return searchBarRef.current !== null; }, []); useHardwareKeyboardCommand("focusSearch", focusSearch); - const filterMenu = buildHomeListFilterMenu({ - ...props, - listOrganization: !threadListV2Enabled, - }); + const filterMenu = buildHomeListFilterMenu(props); return ( <> @@ -100,7 +89,7 @@ export function HomeHeader(props: HomeHeaderProps) { {NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : ( ) : null} - - {threadListV2Enabled ? null : ( - - Sort projects - {PROJECT_SORT_OPTIONS.map((option) => ( - props.onProjectSortOrderChange(option.value)} - > - {option.label} - - ))} - - )} - - {threadListV2Enabled ? null : ( - - Sort threads - {THREAD_SORT_OPTIONS.map((option) => ( - props.onThreadSortOrderChange(option.value)} - > - {option.label} - - ))} - - )} void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectChange: (projectKey: string | null) => void; - readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; - readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onOpenEnvironments: () => void; readonly onOpenSettings: () => void; readonly onStartNewTask: () => void; diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 1ef8ea7b286b..c480d0be48f1 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -76,12 +76,8 @@ export function HomeRouteScreen() { () => new Set(environments.map((environment) => environment.environmentId)), [environments], ); - const { - options: listOptions, - setSelectedEnvironmentId, - setProjectSortOrder, - setThreadSortOrder, - } = useHomeListOptions(availableEnvironmentIds); + const { options: listOptions, setSelectedEnvironmentId } = + useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentId = listOptions.selectedEnvironmentId; const [selectedProjectKey, setSelectedProjectKey] = useState(null); const projectFilterOptions = useMemo( @@ -175,8 +171,6 @@ export function HomeRouteScreen() { searchQuery={searchQuery} selectedEnvironmentId={selectedEnvironmentId} selectedProjectKey={selectedProjectKey} - projectSortOrder={listOptions.projectSortOrder} - threadSortOrder={listOptions.threadSortOrder} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} onOpenEnvironments={() => @@ -191,10 +185,8 @@ export function HomeRouteScreen() { params: { screen: "Settings" }, }) } - onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} - onThreadSortOrderChange={setThreadSortOrder} /> navigation.navigate("NewTaskSheet", { screen: "NewTask" })} - onThreadSortOrderChange={setThreadSortOrder} pendingTasks={pendingTasks} projectGroupingMode={listOptions.projectGroupingMode} projects={projects} @@ -262,7 +252,6 @@ export function HomeRouteScreen() { selectedEnvironmentId={selectedEnvironmentId} selectedProjectKey={selectedProjectKey} threads={threads} - threadSortOrder={listOptions.threadSortOrder} /> diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index a01f5fbe606e..4fb442b94273 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1,10 +1,6 @@ import type { ThreadMoveDestination } from "../threads/threadOrder"; -import { createThreadMovePlanner } from "../threads/threadOrder"; -import { - LegendList, - type LegendListRef, - type LegendListRenderItemProps, -} from "@legendapp/list/react-native"; +import { computeThreadMoveAvailability } from "../threads/threadOrder"; +import { LegendList } from "@legendapp/list/react-native"; import { type EnvironmentProject, type EnvironmentThreadShell, @@ -17,13 +13,11 @@ import { type EnvironmentId, resolveEnvironmentMachineKind, type SidebarProjectGroupingMode, - type SidebarThreadSortOrder, } from "@t3tools/contracts"; -import { useAtomSet, useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/unstable/reactivity"; +import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, FlatList, Platform, View } from "react-native"; +import { ActivityIndicator, Platform, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -34,20 +28,12 @@ import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspace import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; -import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; -import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { usePendingThreadOrder } from "../../state/thread-order"; import { environmentServerConfigsAtom } from "../../state/server"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; -import { - PendingTaskListRow, - ThreadListGroupHeader, - ThreadListRow, - ThreadListShowMoreRow, -} from "../threads/thread-list-items"; import { ThreadListV2PendingRow, ThreadListV2Row, @@ -55,30 +41,20 @@ import { ThreadListV2ShowMoreRow, ThreadListV2SnoozedShelfHeader, } from "../threads/thread-list-v2-items"; -import { resolveThreadProviderInstance } from "../threads/thread-provider-instance"; +import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider-instance"; import { buildThreadListV2Items, getThreadListV2OrderedSection, buildThreadListV2ListItems, + threadListV2ListItemsAreEqual, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, type ThreadListV2ListItem, } from "../threads/threadListV2"; import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences"; import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; -import { - buildHomeListLayout, - DEFAULT_GROUP_DISPLAY_STATE, - EMPTY_HOME_LIST_LAYOUT, - homeListItemsAreEqual, - nextGroupDisplayState, - type HomeGroupDisplayAction, - type HomeGroupDisplayState, - type HomeListItem, -} from "./homeListItems"; import { buildHomeProjectScopes, - buildHomeThreadGroups, sortHomeProjectScopes, type HomeProjectSortOrder, } from "./homeThreadList"; @@ -100,13 +76,10 @@ interface HomeScreenProps { readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; - readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onSearchQueryChange: (query: string) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectChange: (projectKey: string | null) => void; - readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; - readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onAddConnection: () => void; readonly onOpenSettings: () => void; readonly onStartNewTask: () => void; @@ -137,7 +110,21 @@ interface HomeScreenProps { /* ─── Layout constants ───────────────────────────────────────────────── */ -const ESTIMATED_THREAD_ROW_HEIGHT = 72; +// v2 rows are mixed-height: settled slim rows run ~60dp, single-line cards +// measured ~74dp on device (252px on the Pixel 10 Pro screenshot), two-line +// cards ~94dp. The estimate seeds the recycler's initial container count, +// `ceil((scrollLength + 2 * INITIAL_DRAW_DISTANCE) / estimate)` with the +// initial draw distance capped at 50, so an estimate at or below the average +// row height starts the pool at or above the item count for the short lists +// that LegendList otherwise keeps pooling to exactly its item count — that is +// what stopped the dev-mode "no unused container available" warning on the +// seeded short-list device passes. It is a mitigation, not an elimination: +// after first layout the full drawDistance applies, and a sudden expansion +// past the pooled headroom (~25+ items appearing at once) still creates a +// container on demand with the dev-only warning one pass ahead of the +// measured-height pool expansion. The old tallest-card estimate (~92) fired +// that warning on every ordinary shelf expand, so the average wins. +const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72; const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44; /** * Top spacing between the list and the Android custom header. The Android @@ -220,15 +207,8 @@ function HomeTopContentSpacer() { /* ─── Main screen ────────────────────────────────────────────────────── */ export function HomeScreen(props: HomeScreenProps) { - const [groupDisplayStates, setGroupDisplayStates] = useState< - ReadonlyMap - >(() => new Map()); - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const threadListV2Enabled = useThreadListV2Enabled(); const queuedThreadKeys = useQueuedThreadKeys(); - const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); - const listRef = useRef(null); const insets = useSafeAreaInsets(); const iosBottomToolbarClearance = Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED @@ -263,42 +243,6 @@ export function HomeScreen(props: HomeScreenProps) { () => new Set(threadSearch.matches.map(threadSearchMatchKey)), [threadSearch.matches], ); - const effectiveGroupDisplayStates = useMemo(() => { - const next = new Map(groupDisplayStates); - if (!AsyncResult.isSuccess(preferencesResult)) { - return next; - } - for (const key of preferencesResult.value.collapsedProjectGroups ?? []) { - const existing = next.get(key); - next.set(key, { - ...(existing ?? DEFAULT_GROUP_DISPLAY_STATE), - collapsed: true, - }); - } - return next; - }, [groupDisplayStates, preferencesResult]); - const effectiveGroupDisplayStatesRef = useRef(effectiveGroupDisplayStates); - effectiveGroupDisplayStatesRef.current = effectiveGroupDisplayStates; - - const updateGroupDisplay = useCallback( - (key: string, action: HomeGroupDisplayAction) => { - const next = new Map(effectiveGroupDisplayStatesRef.current); - next.set(key, nextGroupDisplayState(next.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action)); - effectiveGroupDisplayStatesRef.current = next; - setGroupDisplayStates(next); - if (action === "toggle-collapsed") { - const collapsedProjectGroups: string[] = []; - for (const [groupKey, state] of next) { - if (state.collapsed) { - collapsedProjectGroups.push(groupKey); - } - } - savePreferences({ collapsedProjectGroups }); - } - }, - [savePreferences], - ); - const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { if (openSwipeableRef.current !== methods) { openSwipeableRef.current?.close(); @@ -330,112 +274,7 @@ export function HomeScreen(props: HomeScreenProps) { }), [props.projectGroupingMode, props.projects, props.selectedEnvironmentId], ); - const selectedProjectScope = useMemo( - () => - props.selectedProjectKey === null - ? null - : (projectScopes.find( - (scope) => - scope.key === props.selectedProjectKey || - scope.projectRefs.some( - (projectRef) => - scopedProjectKey(projectRef.environmentId, projectRef.projectId) === - props.selectedProjectKey, - ), - ) ?? null), - [projectScopes, props.selectedProjectKey], - ); - const selectedProjectRefKeys = useMemo( - () => - selectedProjectScope === null - ? null - : new Set( - selectedProjectScope.projectRefs.map((projectRef) => - scopedProjectKey(projectRef.environmentId, projectRef.projectId), - ), - ), - [selectedProjectScope], - ); - const scopedProjects = useMemo( - () => - threadListV2Enabled - ? [] - : selectedProjectRefKeys === null - ? props.projects - : props.projects.filter((project) => - selectedProjectRefKeys.has(scopedProjectKey(project.environmentId, project.id)), - ), - [threadListV2Enabled, props.projects, selectedProjectRefKeys], - ); - const scopedThreads = useMemo( - () => - threadListV2Enabled - ? [] - : selectedProjectRefKeys === null - ? props.threads - : props.threads.filter((thread) => - selectedProjectRefKeys.has(scopedProjectKey(thread.environmentId, thread.projectId)), - ), - [threadListV2Enabled, props.threads, selectedProjectRefKeys], - ); - const scopedPendingTasks = useMemo( - () => - threadListV2Enabled - ? [] - : selectedProjectRefKeys === null - ? props.pendingTasks - : props.pendingTasks.filter((pendingTask) => - selectedProjectRefKeys.has( - scopedProjectKey(pendingTask.environmentId, pendingTask.projectId), - ), - ), - [threadListV2Enabled, props.pendingTasks, selectedProjectRefKeys], - ); - - const projectGroups = useMemo( - () => - threadListV2Enabled - ? [] - : buildHomeThreadGroups({ - projects: scopedProjects, - threads: scopedThreads, - pendingTasks: scopedPendingTasks, - queuedThreadKeys, - environmentId: props.selectedEnvironmentId, - searchQuery: props.searchQuery, - matchedThreadKeys, - projectSortOrder: props.projectSortOrder, - threadSortOrder: props.threadSortOrder, - projectGroupingMode: props.projectGroupingMode, - }), - [ - threadListV2Enabled, - queuedThreadKeys, - props.projectGroupingMode, - props.projectSortOrder, - props.searchQuery, - props.selectedEnvironmentId, - props.threadSortOrder, - matchedThreadKeys, - scopedPendingTasks, - scopedProjects, - scopedThreads, - ], - ); - const hasSearchQuery = props.searchQuery.trim().length > 0; - const listLayout = useMemo( - () => - threadListV2Enabled - ? EMPTY_HOME_LIST_LAYOUT - : buildHomeListLayout({ - groups: projectGroups, - displayStates: effectiveGroupDisplayStates, - showAllThreads: hasSearchQuery, - }), - [threadListV2Enabled, projectGroups, effectiveGroupDisplayStates, hasSearchQuery], - ); - const projectByKey = useMemo(() => { const map = new Map(); for (const project of props.projects) { @@ -581,12 +420,11 @@ export function HomeScreen(props: HomeScreenProps) { const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useFocusEffect( useCallback(() => { - if (!threadListV2Enabled) return; // Refresh immediately on enable or focus because the previous value can be hours old. setNowMinute(new Date().toISOString().slice(0, 16)); const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); - }, [threadListV2Enabled]), + }, []), ); // Threads on servers without the settlement capability never classify as // settled (the user could neither un-settle nor pin them). @@ -655,12 +493,19 @@ export function HomeScreen(props: HomeScreenProps) { ), [serverConfigs], ); + // Reference-stable provider glyphs: a fresh object per render would break + // the memoized rows' props comparison on every parent render. + const resolveProviderInstance = useThreadRowProviderInstanceResolver(serverConfigs); const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); - const threadMovePlanners = useMemo(() => { - const sectionPlanner = (section: "pinned" | "active") => - createThreadMovePlanner({ + // Up/down menu availability for every card, computed once per section per + // rebuild (see computeThreadMoveAvailability): per-thread planner calls made + // list construction quadratic, and this list rebuilds on every minute tick. + const threadMoveAvailability = useMemo(() => { + const sectionAvailability = (section: "pinned" | "active") => + computeThreadMoveAvailability({ allThreads: props.threads, section, + pendingOrder, reorderableEnvironmentIds: new Set( [...serverConfigs].flatMap(([id, config]) => (section === "pinned" @@ -680,7 +525,7 @@ export function HomeScreen(props: HomeScreenProps) { queuedThreadKeys, }), }); - return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; + return new Map([...sectionAvailability("pinned"), ...sectionAvailability("active")]); }, [ serverConfigs, props.threads, @@ -692,16 +537,6 @@ export function HomeScreen(props: HomeScreenProps) { snoozeWakeTick, ]); const threadListV2Layout = useMemo(() => { - if (!threadListV2Enabled) - return { - items: [], - hiddenSettledCount: 0, - snoozedCount: 0, - snoozedShelfHeaderIndex: null, - settledCount: 0, - settledShelfHeaderIndex: null, - nextSnoozeWakeAt: null, - }; // Settled threads are live shells; archived threads keep their original // "hidden from lists" meaning. return buildThreadListV2Items({ @@ -734,7 +569,6 @@ export function HomeScreen(props: HomeScreenProps) { props.selectedEnvironmentId, props.threads, matchedThreadKeys, - threadListV2Enabled, v2ScopedProjectGroup, ]); // Re-partition the moment the earliest snooze expires (clamped to the @@ -783,21 +617,28 @@ export function HomeScreen(props: HomeScreenProps) { settledShelfExpanded, settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex, snoozeLabelNow: `${nowMinute}:00.000Z`, + snoozeEnvironmentIds, + queuedThreadKeys, + moveAvailability: threadMoveAvailability, + shelfPreferencesLoading: !shelfPreferencesLoaded, }), - [settledShelfExpanded, snoozedShelfExpanded, threadListV2Layout, v2PendingTasks], + [ + nowMinute, + queuedThreadKeys, + threadMoveAvailability, + settledShelfExpanded, + shelfPreferencesLoaded, + snoozedShelfExpanded, + snoozeEnvironmentIds, + threadListV2Layout, + v2PendingTasks, + ], ); - useThreadJumpShortcuts( - threadListV2Enabled ? threadListV2Items : listLayout.items, - props.onSelectThread, - ); + useThreadJumpShortcuts(threadListV2Items, props.onSelectThread); const renderV2Item = useCallback( - ({ item, index }: { readonly item: ThreadListV2ListItem; readonly index: number }) => { - const nextItem = threadListV2Items[index + 1]; - const showTrailingDivider = - nextItem?.type === "v2-thread" || - (nextItem?.type === "v2-pending" && !nextItem.showPendingDivider); + ({ item }: { readonly item: ThreadListV2ListItem }) => { if (item.type === "v2-pending") { const pendingScopeKey = scopedProjectKey( item.pendingTask.environmentId, @@ -816,7 +657,7 @@ export function HomeScreen(props: HomeScreenProps) { } environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)} showPendingDivider={item.showPendingDivider} - showTrailingDivider={showTrailingDivider} + showTrailingDivider={item.showTrailingDivider} onSelectPendingTask={props.onSelectPendingTask} onDeletePendingTask={props.onDeletePendingTask} /> @@ -826,7 +667,7 @@ export function HomeScreen(props: HomeScreenProps) { return ( @@ -836,33 +677,32 @@ export function HomeScreen(props: HomeScreenProps) { return ( ); } const thread = item.item.thread; - const movePlanner = item.item.pinned ? threadMovePlanners.pinned : threadMovePlanners.active; - const movedId = `${thread.environmentId}:${thread.id}`; return ( 1 ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) @@ -891,8 +731,8 @@ export function HomeScreen(props: HomeScreenProps) { ? pinReorderEnvironmentIds.has(thread.environmentId) : activeReorderEnvironmentIds.has(thread.environmentId) } - canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null} - canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null} + canMoveUp={item.canMoveUp} + canMoveDown={item.canMoveDown} onSnoozeThread={handleSnoozeThread} onUnsnoozeThread={handleUnsnoozeThread} onUnsettleThread={handleUnsettleThread} @@ -907,9 +747,6 @@ export function HomeScreen(props: HomeScreenProps) { [ handleDeleteThread, activeReorderEnvironmentIds, - threadMovePlanners, - pendingOrder, - queuedThreadKeys, handleMoveThread, handlePinThread, handleRegenerateThreadTitle, @@ -931,25 +768,23 @@ export function HomeScreen(props: HomeScreenProps) { props.onSelectThread, props.onNewThreadOnBranch, props.savedConnectionsById, - serverConfigs, - shelfPreferencesLoaded, + resolveProviderInstance, settlementEnvironmentIds, snoozeEnvironmentIds, - threadListV2Items, threadSearchMatchByKey, titleRegenerationEnvironmentIds, toggleSettledShelf, toggleSnoozedShelf, v2ProjectTitleByProjectKey, props.searchQuery, - nowMinute, ], ); const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []); - // FlatList treats a changed extraData identity as "re-render every visible - // row", so an inline object literal would invalidate all rows on every - // HomeScreen render. + // FlatList/LegendList treat a changed extraData identity as "re-render every + // visible row", so an inline object literal would invalidate all rows on + // every HomeScreen render — and the minute clock must stay out of it for + // the same reason: the clock text is precomputed per item instead. const v2ExtraData = useMemo( () => ({ projectByKey, @@ -957,7 +792,6 @@ export function HomeScreen(props: HomeScreenProps) { serverConfigs, savedConnectionsById: props.savedConnectionsById, searchQuery: props.searchQuery, - snoozePresetMinute: nowMinute, threadSearchMatchByKey, }), [ @@ -965,132 +799,18 @@ export function HomeScreen(props: HomeScreenProps) { props.searchQuery, props.savedConnectionsById, serverConfigs, - nowMinute, threadSearchMatchByKey, v2ProjectTitleByProjectKey, ], ); - const extraData = useMemo( - () => ({ - savedConnectionsById: props.savedConnectionsById, - searchQuery: props.searchQuery, - threadSearchMatchByKey, - }), - [props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey], - ); - - const renderItem = useCallback( - ({ item }: LegendListRenderItemProps) => { - switch (item.type) { - case "header": - return ( - - ); - case "pending-task": - return ( - - ); - case "thread": { - const thread = item.thread; - return ( - - ); - } - case "show-more": - return ( - - ); - } - }, - [ - handleSwipeableClose, - handleSwipeableWillOpen, - handleRegenerateThreadTitle, - handleRenameThread, - machineByEnvironmentId, - queuedThreadKeys, - props.onArchiveThread, - props.onDeletePendingTask, - props.onDeleteThread, - props.onNewThreadInProject, - props.onSelectPendingTask, - props.onSelectThread, - props.onNewThreadOnBranch, - props.searchQuery, - props.savedConnectionsById, - threadSearchMatchByKey, - titleRegenerationEnvironmentIds, - updateGroupDisplay, - ], - ); - - const keyExtractor = useCallback((item: HomeListItem) => item.key, []); - /* Empty states */ // The signal must ignore the search/environment filters: an active query // that matches nothing needs the in-list "No results" state, not the // full-page "No threads yet". Settled threads are unarchived live shells, - // so the v1 check already covers v2. + // so the archived-at check already covers the settled shelf. const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0; - const hasResults = threadListV2Enabled ? threadListV2Items.length > 0 : projectGroups.length > 0; const selectedEnvironmentLabel = props.selectedEnvironmentId === null ? null @@ -1106,7 +826,7 @@ export function HomeScreen(props: HomeScreenProps) { if (!hasAnyThreads) { return ( - + - ) : selectedProjectScope !== null ? ( + ) : v2ScopedProjectGroup !== null ? ( @@ -1178,92 +900,23 @@ export function HomeScreen(props: HomeScreenProps) { detail="Create a task to start a new coding session." variant={Platform.OS === "android" ? "plain" : undefined} /> - ) - ) : null; - // Use the v2 project scope for its empty state. Snoozed threads need no - // special empty state: their shelf header is a list row even while collapsed. - const v2ListEmpty = - hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? ( - - ) : v2ScopedProjectGroup !== null ? ( - - ) : ( - listEmpty ); - if ( - Platform.OS === "android" && - (threadListV2Enabled ? threadListV2Items.length === 0 : listLayout.items.length === 0) - ) { + if (Platform.OS === "android" && threadListV2Items.length === 0) { return ( - {threadListV2Enabled ? v2ListEmpty : listEmpty} - - - ); - } - - if (threadListV2Enabled) { - return ( - - - - 0 ? ( - - ) : null - } - ListEmptyComponent={v2ListEmpty} - style={{ flex: 1 }} - automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} - contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} - showsVerticalScrollIndicator={false} - keyboardDismissMode="on-drag" - keyboardShouldPersistTaps="handled" - {...scrollGateHandlers} - scrollEventThrottle={16} - contentContainerStyle={{ - paddingBottom: - Platform.OS === "ios" - ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance - : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? 148 : 88), - }} - /> - + {v2ListEmpty} ); } return ( - + - {/* Sticky headers are deliberately not wired up: LegendList's JS sticky - implementation mispositions pinned headers at mount under iOS - automatic content insets (headers render one nav-inset too low until - the first scroll event) and blanks non-pinned headers after - collapse/expand data changes. The flattened layout still exposes - `stickyHeaderIndices` if this gets revisited. */} + {/* Shared with the iPad sidebar: cells are reused across data + rebuilds and `itemsAreEqual` keeps a minute tick (or an unrelated + shell update) from re-rendering untouched rows. */} item.type} + itemsAreEqual={threadListV2ListItemsAreEqual} + estimatedItemSize={ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT} drawDistance={500} - estimatedItemSize={ESTIMATED_THREAD_ROW_HEIGHT} - extraData={extraData} - ListHeaderComponent={listHeader} - ListEmptyComponent={listEmpty} + recycleItems + extraData={v2ExtraData} + ListHeaderComponent={v2ListHeader} + ListFooterComponent={ + settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 ? ( + + ) : null + } + ListEmptyComponent={v2ListEmpty} style={{ flex: 1 }} - automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED} - contentInsetAdjustmentBehavior={NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never"} + automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} + contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} showsVerticalScrollIndicator={false} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" {...scrollGateHandlers} - recycleItems scrollEventThrottle={16} contentContainerStyle={{ - // Android reserves room for the floating new-task FAB - // (56 button + 16 gap + bottom inset). Pre-glass iOS shows a - // standard 44pt bottom toolbar that overlays the list and is not - // reflected in insets while contentInsetAdjustmentBehavior is - // "never". paddingBottom: Platform.OS === "ios" - ? Math.max(insets.bottom, 24) + 24 + iosBottomToolbarClearance + ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? 148 : 88), }} - scrollIndicatorInsets={ - Platform.OS === "ios" - ? { - bottom: Math.max(insets.bottom, 16) + 24 + iosBottomToolbarClearance, - top: 0, - } - : undefined - } /> diff --git a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx index 72671bc046d3..2d852f8f7be2 100644 --- a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx +++ b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx @@ -130,7 +130,7 @@ export function MaterialThreadListToolbar(props: { {(open) => ( diff --git a/apps/mobile/src/features/home/home-list-filter-menu.test.ts b/apps/mobile/src/features/home/home-list-filter-menu.test.ts index 99e3cb36c072..0febe8e31031 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.test.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.test.ts @@ -13,12 +13,8 @@ describe("buildHomeListFilterMenu", () => { ], selectedEnvironmentId: null, selectedProjectKey: "environment-1:project-1", - projectSortOrder: "updated_at", - threadSortOrder: "updated_at", onEnvironmentChange: vi.fn(), onProjectChange, - onProjectSortOrderChange: vi.fn(), - onThreadSortOrderChange: vi.fn(), }); const projectMenu = menu.items.find( diff --git a/apps/mobile/src/features/home/home-list-filter-menu.ts b/apps/mobile/src/features/home/home-list-filter-menu.ts index edd0176f8627..ac53e427d21f 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.ts @@ -1,7 +1,4 @@ -import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; - -import type { HomeProjectSortOrder } from "./homeThreadList"; -import { PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS } from "./home-list-options"; +import type { EnvironmentId } from "@t3tools/contracts"; export interface HomeListFilterMenuEnvironment { readonly environmentId: EnvironmentId; @@ -37,16 +34,8 @@ export function buildHomeListFilterMenu(props: { readonly projects: ReadonlyArray; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; - readonly projectSortOrder: HomeProjectSortOrder; - readonly threadSortOrder: SidebarThreadSortOrder; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectChange: (projectKey: string | null) => void; - readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; - readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; - /** False hides the sort/group submenus. Thread List v2 uses a fixed - creation-order layout, so offering those controls while it silently - ignores them would be a lie; the environment filter still applies. */ - readonly listOrganization?: boolean; }): HomeListFilterMenu { const items: Array = []; @@ -95,31 +84,6 @@ export function buildHomeListFilterMenu(props: { }); } - if (props.listOrganization !== false) { - items.push( - { - type: "submenu", - title: "Sort projects", - items: PROJECT_SORT_OPTIONS.map((option) => ({ - type: "action", - title: option.label, - state: props.projectSortOrder === option.value ? "on" : "off", - onPress: () => props.onProjectSortOrderChange(option.value), - })), - }, - { - type: "submenu", - title: "Sort threads", - items: THREAD_SORT_OPTIONS.map((option) => ({ - type: "action", - title: option.label, - state: props.threadSortOrder === option.value ? "on" : "off", - onPress: () => props.onThreadSortOrderChange(option.value), - })), - }, - ); - } - return { title: "Thread list options", items, diff --git a/apps/mobile/src/features/home/home-list-options.test.ts b/apps/mobile/src/features/home/home-list-options.test.ts deleted file mode 100644 index ac3893956ca7..000000000000 --- a/apps/mobile/src/features/home/home-list-options.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { - DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - DEFAULT_SIDEBAR_THREAD_SORT_ORDER, -} from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { hasCustomHomeListOptions, type HomeListOptions } from "./home-list-options"; - -const defaults: HomeListOptions = { - selectedEnvironmentId: null, - projectSortOrder: - DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" - ? "updated_at" - : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, -}; - -describe("home list options", () => { - it("recognizes default options", () => { - expect(hasCustomHomeListOptions(defaults)).toBe(false); - }); - - it("marks environment filters as customized", () => { - expect( - hasCustomHomeListOptions({ ...defaults, selectedEnvironmentId: "environment-1" as never }), - ).toBe(true); - expect( - hasCustomHomeListOptions({ ...defaults, selectedProjectKey: "environment-1:project-1" }), - ).toBe(true); - }); -}); diff --git a/apps/mobile/src/features/home/home-list-options.ts b/apps/mobile/src/features/home/home-list-options.ts index 14f842d73e2c..d280c05ee0b1 100644 --- a/apps/mobile/src/features/home/home-list-options.ts +++ b/apps/mobile/src/features/home/home-list-options.ts @@ -1,12 +1,5 @@ -import type { - EnvironmentId, - SidebarProjectGroupingMode, - SidebarThreadSortOrder, -} from "@t3tools/contracts"; -import { - DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - DEFAULT_SIDEBAR_THREAD_SORT_ORDER, -} from "@t3tools/contracts"; +import type { EnvironmentId, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import { DEFAULT_SIDEBAR_PROJECT_SORT_ORDER } from "@t3tools/contracts"; import { createContext, createElement, @@ -24,29 +17,12 @@ import type { HomeProjectSortOrder } from "./homeThreadList"; export interface HomeListOptions { readonly selectedEnvironmentId: EnvironmentId | null; readonly projectSortOrder: HomeProjectSortOrder; - readonly threadSortOrder: SidebarThreadSortOrder; } export interface ResolvedHomeListOptions extends HomeListOptions { readonly projectGroupingMode: SidebarProjectGroupingMode; } -export const PROJECT_SORT_OPTIONS: ReadonlyArray<{ - readonly value: HomeProjectSortOrder; - readonly label: string; -}> = [ - { value: "updated_at", label: "Last user message" }, - { value: "created_at", label: "Created at" }, -]; - -export const THREAD_SORT_OPTIONS: ReadonlyArray<{ - readonly value: SidebarThreadSortOrder; - readonly label: string; -}> = [ - { value: "updated_at", label: "Last user message" }, - { value: "created_at", label: "Created at" }, -]; - function defaultHomeListOptions(): HomeListOptions { return { selectedEnvironmentId: null, @@ -54,7 +30,6 @@ function defaultHomeListOptions(): HomeListOptions { DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" ? "updated_at" : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, }; } @@ -81,23 +56,6 @@ export function HomeListOptionsProvider({ return createElement(HomeListOptionsContext, { value }, children); } -export function hasCustomHomeListOptions( - options: HomeListOptions & { - readonly selectedProjectKey?: string | null; - }, -): boolean { - const defaultProjectSortOrder = - DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" - ? "updated_at" - : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER; - return ( - options.selectedEnvironmentId !== null || - (options.selectedProjectKey !== null && options.selectedProjectKey !== undefined) || - options.projectSortOrder !== defaultProjectSortOrder || - options.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER - ); -} - export function useHomeListOptions(availableEnvironmentIds: ReadonlySet) { const shared = useContext(HomeListOptionsContext); const [localOptions, setLocalOptions] = useState(defaultHomeListOptions); @@ -123,13 +81,9 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet { setOptions((current) => ({ ...current, projectSortOrder: value })); }, []); - const setThreadSortOrder = useCallback((value: SidebarThreadSortOrder) => { - setOptions((current) => ({ ...current, threadSortOrder: value })); - }, []); return { options: resolvedOptions, setSelectedEnvironmentId, setProjectSortOrder, - setThreadSortOrder, } as const; } diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts deleted file mode 100644 index d1e3315fd59a..000000000000 --- a/apps/mobile/src/features/home/homeListItems.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import type { - EnvironmentProject, - EnvironmentThreadShell, -} from "@t3tools/client-runtime/state/shell"; -import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - buildHomeListLayout, - DEFAULT_GROUP_DISPLAY_STATE, - HOME_INITIAL_VISIBLE_THREADS, - HOME_SHOW_MORE_STEP, - nextGroupDisplayState, - type HomeGroupDisplayState, - type HomeListItem, -} from "./homeListItems"; -import type { HomeThreadGroup } from "./homeThreadList"; -import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts"; - -const environmentId = EnvironmentId.make("environment-1"); - -function makeProject(id: string, title: string): EnvironmentProject { - return { - environmentId, - id: ProjectId.make(id), - title, - workspaceRoot: `/workspaces/${id}`, - repositoryIdentity: null, - defaultModelSelection: null, - scripts: [], - createdAt: "2026-06-01T00:00:00.000Z", - updatedAt: "2026-06-01T00:00:00.000Z", - }; -} - -function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell { - return { - environmentId, - id: ThreadId.make(id), - projectId, - title: `Thread ${id}`, - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - pullRequests: [], - latestTurn: null, - createdAt: "2026-06-01T00:00:00.000Z", - updatedAt: "2026-06-01T00:00:00.000Z", - archivedAt: null, - settledOverride: null, - settledAt: null, - session: null, - latestUserMessageAt: null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - }; -} - -function makeGroup(key: string, threadCount: number): HomeThreadGroup { - const project = makeProject(key, key); - const threads = Array.from({ length: threadCount }, (_, index) => - makeThread(`${key}-thread-${index}`, project.id), - ); - return { - key, - title: key, - representative: project, - projects: [project], - pendingTasks: [], - threads, - // All threads inside the recency window, so the baseline stays at the - // initial page size and the pagination expectations below hold. - recentThreads: threads, - newThreadTarget: project, - }; -} - -function itemTypes(items: ReadonlyArray): string[] { - return items.map((item) => item.type); -} - -function displayStates( - entries: Record, -): ReadonlyMap { - return new Map(Object.entries(entries)); -} - -describe("threadJumpTarget", () => { - it("numbers only displayed threads across groups, skipping collapsed groups and pagination rows", () => { - const layout = buildHomeListLayout({ - groups: [makeGroup("collapsed", 3), makeGroup("alpha", 8), makeGroup("beta", 3)], - displayStates: displayStates({ collapsed: { collapsed: true, visibleCount: 6 } }), - }); - expect(threadJumpTarget(layout.items, "thread.jump.1")?.id).toBe("alpha-thread-0"); - expect(threadJumpTarget(layout.items, "thread.jump.7")?.id).toBe("beta-thread-0"); - expect(threadJumpTarget(layout.items, "thread.jump.9")?.id).toBe("beta-thread-2"); - }); - - it("ignores missing positions and unrelated commands", () => { - const layout = buildHomeListLayout({ - groups: [makeGroup("alpha", 1)], - displayStates: displayStates({}), - }); - expect(threadJumpTarget(layout.items, "thread.jump.2")).toBeNull(); - expect(threadJumpTarget([], "thread.jump.1")).toBeNull(); - expect(threadJumpTarget(layout.items, "commandPalette")).toBeNull(); - }); -}); - -describe("buildHomeListLayout", () => { - it("renders a header plus all threads for a small group without a show-more row", () => { - const layout = buildHomeListLayout({ - groups: [makeGroup("alpha", 3)], - displayStates: displayStates({}), - }); - - expect(itemTypes(layout.items)).toEqual(["header", "thread", "thread", "thread"]); - expect(layout.stickyHeaderIndices).toEqual([0]); - expect(layout.items.at(-1)).toMatchObject({ type: "thread", isLast: true }); - }); - - it("limits large groups to the initial visible count with a show-more row", () => { - const layout = buildHomeListLayout({ - groups: [makeGroup("alpha", 133)], - displayStates: displayStates({}), - }); - - const threadItems = layout.items.filter((item) => item.type === "thread"); - expect(threadItems).toHaveLength(HOME_INITIAL_VISIBLE_THREADS); - expect(layout.items.at(-1)).toMatchObject({ - type: "show-more", - groupKey: "alpha", - hiddenCount: 133 - HOME_INITIAL_VISIBLE_THREADS, - canShowLess: false, - }); - // The show-more row takes over the last slot, so no thread is marked last. - expect(threadItems.every((item) => item.type === "thread" && !item.isLast)).toBe(true); - }); - - it("reveals more threads per show-more step and offers show-less when exhausted", () => { - const group = makeGroup("alpha", 20); - - const expandedOnce = buildHomeListLayout({ - groups: [group], - displayStates: displayStates({ - alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"), - }), - }); - expect(expandedOnce.items.filter((item) => item.type === "thread")).toHaveLength( - HOME_INITIAL_VISIBLE_THREADS + HOME_SHOW_MORE_STEP, - ); - expect(expandedOnce.items.at(-1)).toMatchObject({ - type: "show-more", - hiddenCount: 4, - canShowLess: true, - }); - - const fullyExpanded = buildHomeListLayout({ - groups: [group], - displayStates: displayStates({ - alpha: nextGroupDisplayState( - nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"), - "show-more", - ), - }), - }); - expect(fullyExpanded.items.filter((item) => item.type === "thread")).toHaveLength(20); - expect(fullyExpanded.items.at(-1)).toMatchObject({ - type: "show-more", - hiddenCount: 0, - canShowLess: true, - }); - - const reset = nextGroupDisplayState( - nextGroupDisplayState( - nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"), - "show-more", - ), - "show-less", - ); - expect(reset.visibleCount).toBe(HOME_INITIAL_VISIBLE_THREADS); - }); - - it("offers show-less after expanding a stale group whose baseline is below the page size", () => { - // Stale project: 10 threads total but only 3 within the recency window. - const project = makeProject("stale", "stale"); - const threads = Array.from({ length: 10 }, (_, index) => - makeThread(`stale-thread-${index}`, project.id), - ); - const group: HomeThreadGroup = { - key: "stale", - title: "stale", - representative: project, - projects: [project], - pendingTasks: [], - threads, - recentThreads: threads.slice(0, 3), - newThreadTarget: project, - }; - - const collapsedToRecent = buildHomeListLayout({ - groups: [group], - displayStates: displayStates({}), - }); - expect(collapsedToRecent.items.filter((item) => item.type === "thread")).toHaveLength(3); - expect(collapsedToRecent.items.at(-1)).toMatchObject({ - type: "show-more", - hiddenCount: 7, - canShowLess: false, - }); - - const expanded = buildHomeListLayout({ - groups: [group], - displayStates: displayStates({ - stale: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"), - }), - }); - expect(expanded.items.filter((item) => item.type === "thread")).toHaveLength(10); - expect(expanded.items.at(-1)).toMatchObject({ - type: "show-more", - hiddenCount: 0, - canShowLess: true, - }); - }); - - it("hides threads and the show-more row for collapsed groups", () => { - const layout = buildHomeListLayout({ - groups: [makeGroup("alpha", 12), makeGroup("beta", 2)], - displayStates: displayStates({ - alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "toggle-collapsed"), - }), - }); - - expect(itemTypes(layout.items)).toEqual(["header", "header", "thread", "thread"]); - expect(layout.items[0]).toMatchObject({ type: "header", collapsed: true, isFirst: true }); - expect(layout.items[1]).toMatchObject({ type: "header", collapsed: false, isFirst: false }); - expect(layout.stickyHeaderIndices).toEqual([0, 1]); - }); - - it("suspends collapse and pagination while searching", () => { - const layout = buildHomeListLayout({ - groups: [makeGroup("alpha", 12)], - displayStates: displayStates({ - alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "toggle-collapsed"), - }), - showAllThreads: true, - }); - - expect(layout.items.filter((item) => item.type === "thread")).toHaveLength(12); - expect(layout.items.some((item) => item.type === "show-more")).toBe(false); - }); - - it("keeps sticky indices aligned across multiple expanded groups", () => { - const layout = buildHomeListLayout({ - groups: [makeGroup("alpha", 8), makeGroup("beta", 1)], - displayStates: displayStates({}), - }); - - // header + 6 threads + show-more = 8 items, so beta's header is index 8. - expect(layout.stickyHeaderIndices).toEqual([0, 8]); - expect(layout.items[8]).toMatchObject({ type: "header", isFirst: false }); - }); -}); diff --git a/apps/mobile/src/features/home/homeListItems.ts b/apps/mobile/src/features/home/homeListItems.ts deleted file mode 100644 index 910ddb5896b7..000000000000 --- a/apps/mobile/src/features/home/homeListItems.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; - -import type { PendingNewTask } from "../../state/use-pending-new-tasks"; -import type { HomeThreadGroup } from "./homeThreadList"; - -/** Threads shown per project before the "Show more" affordance appears. */ -export const HOME_INITIAL_VISIBLE_THREADS = 6; -/** Additional threads revealed per "Show more" tap. */ -export const HOME_SHOW_MORE_STEP = 10; - -export interface HomeGroupDisplayState { - readonly collapsed: boolean; - /** How many threads are currently revealed (clamped to the group size). */ - readonly visibleCount: number; -} - -export const DEFAULT_GROUP_DISPLAY_STATE: HomeGroupDisplayState = { - collapsed: false, - visibleCount: HOME_INITIAL_VISIBLE_THREADS, -}; - -export interface HomeHeaderListItem { - readonly type: "header"; - readonly key: string; - readonly group: HomeThreadGroup; - readonly collapsed: boolean; - readonly isFirst: boolean; -} - -export interface HomeThreadListItem { - readonly type: "thread"; - readonly key: string; - readonly thread: EnvironmentThreadShell; - readonly isLast: boolean; -} - -export interface HomePendingTaskListItem { - readonly type: "pending-task"; - readonly key: string; - readonly pendingTask: PendingNewTask; - readonly isLast: boolean; -} - -export interface HomeShowMoreListItem { - readonly type: "show-more"; - readonly key: string; - readonly groupKey: string; - /** Threads still hidden. 0 means the group is fully expanded. */ - readonly hiddenCount: number; - /** Whether more than the initial count is revealed, so "Show less" applies. */ - readonly canShowLess: boolean; -} - -export type HomeListItem = - | HomeHeaderListItem - | HomePendingTaskListItem - | HomeThreadListItem - | HomeShowMoreListItem; - -export interface HomeListLayout { - readonly items: ReadonlyArray; - readonly stickyHeaderIndices: ReadonlyArray; -} - -export const EMPTY_HOME_LIST_LAYOUT: HomeListLayout = { items: [], stickyHeaderIndices: [] }; - -export type HomeGroupDisplayAction = "toggle-collapsed" | "show-more" | "show-less"; - -export function nextGroupDisplayState( - current: HomeGroupDisplayState, - action: HomeGroupDisplayAction, -): HomeGroupDisplayState { - switch (action) { - case "toggle-collapsed": - return { ...current, collapsed: !current.collapsed }; - case "show-more": - return { ...current, visibleCount: current.visibleCount + HOME_SHOW_MORE_STEP }; - case "show-less": - return { ...current, visibleCount: HOME_INITIAL_VISIBLE_THREADS }; - } -} - -/** - * Structural equality for list items. Item objects are rebuilt on every - * collapse/show-more toggle; without this the lists would consider every - * mounted row changed and re-render all of them (each carrying a swipeable + - * a vcs-status subscription). Group/thread references are stable across - * toggles. - */ -export function homeListItemsAreEqual(previous: HomeListItem, item: HomeListItem): boolean { - switch (item.type) { - case "header": - return ( - previous.type === "header" && - previous.group === item.group && - previous.collapsed === item.collapsed && - previous.isFirst === item.isFirst - ); - case "pending-task": - return ( - previous.type === "pending-task" && - previous.pendingTask === item.pendingTask && - previous.isLast === item.isLast - ); - case "thread": - return ( - previous.type === "thread" && - previous.thread === item.thread && - previous.isLast === item.isLast - ); - case "show-more": - return ( - previous.type === "show-more" && - previous.groupKey === item.groupKey && - previous.hiddenCount === item.hiddenCount && - previous.canShowLess === item.canShowLess - ); - } -} - -export function buildHomeListLayout(input: { - readonly groups: ReadonlyArray; - readonly displayStates: ReadonlyMap; - /** - * When searching, pagination is suspended so every match stays visible. - */ - readonly showAllThreads?: boolean; -}): HomeListLayout { - const items: HomeListItem[] = []; - const stickyHeaderIndices: number[] = []; - - for (const [groupIndex, group] of input.groups.entries()) { - const display = input.displayStates.get(group.key) ?? DEFAULT_GROUP_DISPLAY_STATE; - const collapsed = display.collapsed && input.showAllThreads !== true; - - stickyHeaderIndices.push(items.length); - items.push({ - type: "header", - key: `header:${group.key}`, - group, - collapsed, - isFirst: groupIndex === 0, - }); - - if (collapsed) { - continue; - } - - const totalCount = group.threads.length; - // Default to the group's recent-activity window (last few days, or a small - // fallback for stale projects), capped at the initial page size. Until the - // user taps "Show more", older threads stay hidden to save vertical space; - // "Show less" resets visibleCount to the initial constant, which lands back - // here at the recency baseline. - const baselineCount = Math.min( - group.recentThreads.length, - HOME_INITIAL_VISIBLE_THREADS, - totalCount, - ); - const visibleCount = input.showAllThreads - ? totalCount - : Math.min( - display.visibleCount > HOME_INITIAL_VISIBLE_THREADS - ? display.visibleCount - : baselineCount, - totalCount, - ); - const visibleThreads = group.threads.slice(0, visibleCount); - const hiddenCount = totalCount - visibleCount; - const hasShowMoreRow = !input.showAllThreads && totalCount > baselineCount; - - // Pending (unsent) tasks lead the group and are never paginated away. - for (const [pendingIndex, pendingTask] of group.pendingTasks.entries()) { - items.push({ - type: "pending-task", - key: pendingTask.key, - pendingTask, - isLast: - pendingIndex === group.pendingTasks.length - 1 && - visibleThreads.length === 0 && - !hasShowMoreRow, - }); - } - - for (const [threadIndex, thread] of visibleThreads.entries()) { - items.push({ - type: "thread", - key: `thread:${thread.environmentId}:${thread.id}`, - thread, - isLast: threadIndex === visibleThreads.length - 1 && !hasShowMoreRow, - }); - } - - if (hasShowMoreRow) { - items.push({ - type: "show-more", - key: `show-more:${group.key}`, - groupKey: group.key, - hiddenCount, - // Compare against the group's own baseline, not the global page size: - // stale projects start below HOME_INITIAL_VISIBLE_THREADS, and "Show - // less" must be offered as soon as anything beyond the baseline shows. - canShowLess: visibleCount > baselineCount, - }); - } - } - - return { items, stickyHeaderIndices }; -} diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index 0f7b14bf9365..98b32edfd748 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -2,15 +2,10 @@ import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; -import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { - buildHomeProjectScopes, - buildHomeThreadGroups, - sortHomeProjectScopes, -} from "./homeThreadList"; +import { buildHomeProjectScopes, sortHomeProjectScopes } from "./homeThreadList"; function makeProject( input: Partial & Pick, @@ -52,27 +47,7 @@ function makeThread( }; } -const NOW = Date.parse("2026-06-29T00:00:00.000Z"); - -function buildGroups( - projects: ReadonlyArray, - threads: ReadonlyArray, - overrides: Partial[0]> = {}, -) { - return buildHomeThreadGroups({ - projects, - threads, - environmentId: null, - searchQuery: "", - projectSortOrder: "updated_at", - threadSortOrder: "updated_at", - projectGroupingMode: "repository", - now: NOW, - ...overrides, - }); -} - -describe("buildHomeThreadGroups", () => { +describe("home project scopes", () => { it("builds one v2 scope for the same repository across environments", () => { const localEnvironmentId = EnvironmentId.make("environment-local"); const remoteEnvironmentId = EnvironmentId.make("environment-remote"); @@ -116,70 +91,6 @@ describe("buildHomeThreadGroups", () => { ); }); - it("routes stale duplicate project refs through the canonical repository group", () => { - const localEnvironmentId = EnvironmentId.make("environment-local"); - const remoteEnvironmentId = EnvironmentId.make("environment-remote"); - const repositoryIdentity = { - canonicalKey: "github.com/pingdotgg/t3code", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:pingdotgg/t3code.git", - }, - }; - const local = makeProject({ - id: ProjectId.make("project-local"), - environmentId: localEnvironmentId, - title: "t3code", - workspaceRoot: "/workspaces/t3code", - repositoryIdentity, - }); - const stale = makeProject({ - environmentId: remoteEnvironmentId, - id: ProjectId.make("project-stale"), - title: "t3code", - workspaceRoot: "/remote/t3code", - updatedAt: "2026-06-01T00:00:00.000Z", - }); - const canonicalRemote = makeProject({ - environmentId: remoteEnvironmentId, - id: ProjectId.make("project-canonical-remote"), - title: "t3code", - workspaceRoot: "/remote/t3code/", - repositoryIdentity, - updatedAt: "2026-06-02T00:00:00.000Z", - }); - const projects = [local, stale, canonicalRemote]; - const staleThread = makeThread({ - environmentId: remoteEnvironmentId, - id: ThreadId.make("thread-stale-project-ref"), - projectId: stale.id, - title: "Still visible", - updatedAt: "2026-06-03T00:00:00.000Z", - }); - - const scopes = buildHomeProjectScopes({ - projects, - environmentId: null, - projectGroupingMode: "repository", - }); - const groups = buildGroups(projects, [staleThread]); - - expect(scopes).toHaveLength(1); - expect(scopes[0]?.projects.map((project) => project.id)).toEqual([ - local.id, - canonicalRemote.id, - ]); - expect(scopes[0]?.projectRefs.map((projectRef) => projectRef.projectId)).toEqual([ - local.id, - stale.id, - canonicalRemote.id, - ]); - expect(groups).toHaveLength(1); - expect(groups[0]?.threads.map((thread) => thread.id)).toEqual([staleThread.id]); - expect(groups[0]?.newThreadTarget?.id).toBe(canonicalRemote.id); - }); - it("keeps repository identity from an older duplicate when the freshness winner lacks it", () => { const localEnvironmentId = EnvironmentId.make("environment-local"); const remoteEnvironmentId = EnvironmentId.make("environment-remote"); @@ -375,410 +286,4 @@ describe("buildHomeThreadGroups", () => { }), ).toHaveLength(2); }); - - it("uses the physical project title for a singleton scope", () => { - const project = makeProject({ - environmentId: EnvironmentId.make("environment-1"), - id: ProjectId.make("project-1"), - title: "local-worktree-name", - repositoryIdentity: { - canonicalKey: "github.com/pingdotgg/t3code", - displayName: "codething-mvp", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:pingdotgg/t3code.git", - }, - }, - }); - - const scopes = buildHomeProjectScopes({ - projects: [project], - environmentId: null, - projectGroupingMode: "repository", - }); - const groups = buildGroups( - [project], - [ - makeThread({ - environmentId: project.environmentId, - id: ThreadId.make("thread-1"), - projectId: project.id, - title: "Thread", - }), - ], - ); - - expect(scopes[0]?.title).toBe("local-worktree-name"); - expect(groups[0]?.title).toBe("local-worktree-name"); - }); - - it("sorts the newest thread first regardless of snapshot order", () => { - const environmentId = EnvironmentId.make("environment-1"); - const project = makeProject({ - environmentId, - id: ProjectId.make("project-1"), - title: "T3 Code", - }); - const threads = [ - makeThread({ - environmentId, - id: ThreadId.make("thread-old"), - projectId: project.id, - title: "Older thread", - updatedAt: "2026-06-02T00:00:00.000Z", - }), - makeThread({ - environmentId, - id: ThreadId.make("thread-new"), - projectId: project.id, - title: "Newer thread", - updatedAt: "2026-06-03T00:00:00.000Z", - }), - ]; - - expect(buildGroups([project], threads)[0]?.threads.map((thread) => thread.id)).toEqual([ - "thread-new", - "thread-old", - ]); - }); - - it("supports independent project and thread creation-time sorting", () => { - const environmentId = EnvironmentId.make("environment-1"); - const olderProject = makeProject({ - environmentId, - id: ProjectId.make("project-older"), - title: "Older project", - }); - const newerProject = makeProject({ - environmentId, - id: ProjectId.make("project-newer"), - title: "Newer project", - }); - const threads = [ - makeThread({ - environmentId, - id: ThreadId.make("old-created"), - projectId: olderProject.id, - title: "Updated recently", - createdAt: "2026-06-01T00:00:00.000Z", - updatedAt: "2026-06-05T00:00:00.000Z", - }), - makeThread({ - environmentId, - id: ThreadId.make("new-created"), - projectId: olderProject.id, - title: "Created recently", - createdAt: "2026-06-04T00:00:00.000Z", - updatedAt: "2026-06-04T00:00:00.000Z", - }), - makeThread({ - environmentId, - id: ThreadId.make("newest-project-thread"), - projectId: newerProject.id, - title: "Newest project", - createdAt: "2026-06-06T00:00:00.000Z", - }), - ]; - - const groups = buildGroups([olderProject, newerProject], threads, { - projectSortOrder: "created_at", - threadSortOrder: "created_at", - projectGroupingMode: "separate", - }); - - expect(groups.map((group) => group.representative.id)).toEqual([ - "project-newer", - "project-older", - ]); - expect(groups[1]?.threads.map((thread) => thread.id)).toEqual(["new-created", "old-created"]); - }); - - it("filters both projects and threads to one environment", () => { - const localEnvironmentId = EnvironmentId.make("environment-local"); - const remoteEnvironmentId = EnvironmentId.make("environment-remote"); - const projects = [ - makeProject({ - environmentId: localEnvironmentId, - id: ProjectId.make("project-local"), - title: "Local", - }), - makeProject({ - environmentId: remoteEnvironmentId, - id: ProjectId.make("project-remote"), - title: "Remote", - }), - ]; - const threads = projects.map((project) => - makeThread({ - environmentId: project.environmentId, - id: ThreadId.make(`thread-${project.id}`), - projectId: project.id, - title: project.title, - }), - ); - - const groups = buildGroups(projects, threads, { environmentId: remoteEnvironmentId }); - - expect(groups).toHaveLength(1); - expect(groups[0]?.representative.environmentId).toBe(remoteEnvironmentId); - expect(groups[0]?.threads.map((thread) => thread.environmentId)).toEqual([remoteEnvironmentId]); - }); - - it("matches web repository, repository-path, and separate grouping modes", () => { - const environmentId = EnvironmentId.make("environment-1"); - const repositoryIdentity = { - canonicalKey: "github.com/t3tools/t3code", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:t3tools/t3code.git", - }, - provider: "github", - owner: "t3tools", - name: "t3code", - displayName: "T3 Code", - rootPath: "/workspaces/t3code", - }; - const projects = [ - makeProject({ - environmentId, - id: ProjectId.make("project-web"), - title: "Web", - workspaceRoot: "/workspaces/t3code/apps/web", - repositoryIdentity, - }), - makeProject({ - environmentId, - id: ProjectId.make("project-mobile"), - title: "Mobile", - workspaceRoot: "/workspaces/t3code/apps/mobile", - repositoryIdentity, - }), - ]; - const threads = projects.map((project) => - makeThread({ - environmentId, - id: ThreadId.make(`thread-${project.id}`), - projectId: project.id, - title: project.title, - }), - ); - - expect(buildGroups(projects, threads, { projectGroupingMode: "repository" })).toHaveLength(1); - expect( - buildGroups(projects, threads, { projectGroupingMode: "repository_path" }).map( - (group) => group.title, - ), - ).toEqual(["Mobile", "Web"]); - expect( - buildGroups(projects, threads, { projectGroupingMode: "separate" }).map( - (group) => group.title, - ), - ).toEqual(["Mobile", "Web"]); - }); - - it("default view shows only threads from the last 5 days", () => { - const environmentId = EnvironmentId.make("environment-1"); - const project = makeProject({ - environmentId, - id: ProjectId.make("project-1"), - title: "T3 Code", - }); - const threads = [ - makeThread({ - environmentId, - id: ThreadId.make("recent-1"), - projectId: project.id, - title: "Today", - updatedAt: "2026-06-28T00:00:00.000Z", - }), - makeThread({ - environmentId, - id: ThreadId.make("recent-2"), - projectId: project.id, - title: "Within window", - updatedAt: "2026-06-25T00:00:00.000Z", - }), - makeThread({ - environmentId, - id: ThreadId.make("old"), - projectId: project.id, - title: "Two weeks ago", - updatedAt: "2026-06-14T00:00:00.000Z", - }), - ]; - - const group = buildGroups([project], threads)[0]; - // Default view trims to recent threads... - expect(group?.recentThreads.map((thread) => thread.id)).toEqual(["recent-1", "recent-2"]); - // ...while full history stays available for the expanded view. - expect(group?.threads.map((thread) => thread.id)).toEqual(["recent-1", "recent-2", "old"]); - }); - - it("keeps an old thread in the default view while a message waits in its outbox", () => { - const environmentId = EnvironmentId.make("environment-1"); - const project = makeProject({ - environmentId, - id: ProjectId.make("project-1"), - title: "T3 Code", - }); - const threads = [ - makeThread({ - environmentId, - id: ThreadId.make("recent"), - projectId: project.id, - title: "Today", - updatedAt: "2026-06-28T00:00:00.000Z", - }), - makeThread({ - environmentId, - id: ThreadId.make("old-queued"), - projectId: project.id, - title: "Two weeks ago, follow-up queued offline", - updatedAt: "2026-06-14T00:00:00.000Z", - }), - makeThread({ - environmentId, - id: ThreadId.make("old"), - projectId: project.id, - title: "Two weeks ago", - updatedAt: "2026-06-13T00:00:00.000Z", - }), - ]; - - const group = buildGroups([project], threads, { - queuedThreadKeys: new Set([`${environmentId}:old-queued`]), - })[0]; - expect(group?.recentThreads.map((thread) => thread.id)).toEqual(["recent", "old-queued"]); - }); - - it("falls back to the most recent 3 threads when none are within 5 days", () => { - const environmentId = EnvironmentId.make("environment-1"); - const project = makeProject({ - environmentId, - id: ProjectId.make("project-1"), - title: "T3 Code", - }); - const threads = ["2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04", "2026-06-05"].map( - (day, index) => - makeThread({ - environmentId, - id: ThreadId.make(`thread-${index}`), - projectId: project.id, - title: `Thread ${index}`, - updatedAt: `${day}T00:00:00.000Z`, - }), - ); - - const group = buildGroups([project], threads)[0]; - expect(group?.recentThreads.map((thread) => thread.id)).toEqual([ - "thread-4", - "thread-3", - "thread-2", - ]); - expect(group?.threads).toHaveLength(5); - }); - - it("does not apply the recency window while searching", () => { - const environmentId = EnvironmentId.make("environment-1"); - const project = makeProject({ - environmentId, - id: ProjectId.make("project-1"), - title: "T3 Code", - }); - const threads = ["2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04", "2026-06-05"].map( - (day, index) => - makeThread({ - environmentId, - id: ThreadId.make(`thread-${index}`), - projectId: project.id, - title: `Thread ${index}`, - updatedAt: `${day}T00:00:00.000Z`, - }), - ); - - const group = buildGroups([project], threads, { searchQuery: "T3 Code" })[0]; - // Search reaches the full history rather than the 3-thread fallback. - expect(group?.recentThreads).toHaveLength(5); - expect(group?.recentThreads.map((thread) => thread.id)).toEqual( - group?.threads.map((thread) => thread.id), - ); - }); - - it("includes a thread matched by message content", () => { - const environmentId = EnvironmentId.make("environment-1"); - const project = makeProject({ - environmentId, - id: ProjectId.make("project-1"), - title: "T3 Code", - }); - const thread = makeThread({ - environmentId, - id: ThreadId.make("thread-content"), - projectId: project.id, - title: "Unrelated title", - }); - - const groups = buildGroups([project], [thread], { - searchQuery: "relay reconnect", - matchedThreadKeys: new Set([ - threadSearchMatchKey({ - environmentId, - threadId: thread.id, - }), - ]), - }); - - expect(groups[0]?.threads.map((candidate) => candidate.id)).toEqual(["thread-content"]); - }); - - it("targets quick new threads at the group member with the newest thread", () => { - const laptopEnv = EnvironmentId.make("environment-laptop"); - const desktopEnv = EnvironmentId.make("environment-desktop"); - const repositoryIdentity = { - canonicalKey: "github.com/pingdotgg/t3code", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:pingdotgg/t3code.git", - }, - }; - const laptopProject = makeProject({ - environmentId: laptopEnv, - id: ProjectId.make("project-laptop"), - title: "t3code", - repositoryIdentity, - }); - const desktopProject = makeProject({ - environmentId: desktopEnv, - id: ProjectId.make("project-desktop"), - title: "t3code", - repositoryIdentity, - }); - const threads = [ - makeThread({ - environmentId: laptopEnv, - id: ThreadId.make("thread-laptop"), - projectId: laptopProject.id, - title: "Older laptop thread", - updatedAt: "2026-06-27T00:00:00.000Z", - }), - makeThread({ - environmentId: desktopEnv, - id: ThreadId.make("thread-desktop"), - projectId: desktopProject.id, - title: "Newest desktop thread", - updatedAt: "2026-06-28T00:00:00.000Z", - }), - ]; - - // Aggregated into one group by repository; the quick new-thread target - // must follow the newest thread (desktop), not the arbitrary first member. - const groups = buildGroups([laptopProject, desktopProject], threads); - expect(groups).toHaveLength(1); - expect(groups[0]?.projects).toHaveLength(2); - expect(groups[0]?.newThreadTarget?.environmentId).toBe(desktopEnv); - expect(groups[0]?.newThreadTarget?.id).toBe(desktopProject.id); - }); }); diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 895c73bbe266..0c85972e43e5 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -1,31 +1,22 @@ -import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; -import { - buildProjectGroups, - derivePhysicalProjectKey, - deriveProjectGroupLabel, -} from "@t3tools/client-runtime/state/project-grouping"; +import { buildProjectGroups } from "@t3tools/client-runtime/state/project-grouping"; import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import { getThreadSortTimestamp, - sortThreads, toSortableTimestamp, } from "@t3tools/client-runtime/state/thread-sort"; -import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import type { EnvironmentId, ScopedProjectRef, SidebarProjectGroupingMode, SidebarProjectSortOrder, - SidebarThreadSortOrder, } from "@t3tools/contracts"; import * as Arr from "effect/Array"; -import * as Option from "effect/Option"; import * as Order from "effect/Order"; -import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; +import { scopedProjectKey } from "../../lib/scopedEntities"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; export type HomeProjectSortOrder = Exclude; @@ -133,260 +124,3 @@ export function sortHomeProjectScopes(input: { ), ); } - -/** - * Default home view only surfaces threads active within this window, to keep the - * screen compact while keeping recent work visible. - */ -const RECENT_THREAD_WINDOW_MS = 5 * 24 * 60 * 60 * 1000; -/** Fallback when a project has no threads inside the recency window. */ -const RECENT_THREAD_FALLBACK_COUNT = 3; - -export interface HomeThreadGroup { - readonly key: string; - readonly title: string; - readonly representative: EnvironmentProject; - readonly projects: ReadonlyArray; - readonly pendingTasks: ReadonlyArray; - /** Full sorted thread history for the group (revealed when expanded / searching). */ - readonly threads: ReadonlyArray; - /** Subset shown by default: threads from the last few days, or the most recent few. */ - readonly recentThreads: ReadonlyArray; - /** - * Where a quick "new thread in this project" should land. For aggregated - * groups (same repo on several machines) this is the member that owns the - * group's most recent thread — the machine the user last worked on — rather - * than the arbitrary first member; the draft's computer picker covers - * switching from there. Null only for synthetic pending-project groups, - * whose single "project" is a placeholder built from queued-task metadata. - */ - readonly newThreadTarget: EnvironmentProject | null; -} - -interface MutableHomeThreadGroup { - readonly key: string; - readonly projects: EnvironmentProject[]; - readonly pendingTasks: PendingNewTask[]; - readonly threads: EnvironmentThreadShell[]; -} - -function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOrder): number { - const latestThread = group.threads.reduce( - (latest, thread) => Math.max(latest, getThreadSortTimestamp(thread, sortOrder)), - Number.NEGATIVE_INFINITY, - ); - return group.pendingTasks.reduce((latest, pendingTask) => { - const timestamp = Date.parse(pendingTask.createdAt); - return Number.isNaN(timestamp) ? latest : Math.max(latest, timestamp); - }, latestThread); -} - -/** - * Trims a group's threads to recent activity for the default home view. - * `sortedThreads` must already be ordered newest-first for `threadSortOrder`. - * Keeps threads within {@link RECENT_THREAD_WINDOW_MS}; when none qualify, keeps - * the most recent {@link RECENT_THREAD_FALLBACK_COUNT} so a project never vanishes. - */ -function selectRecentThreads( - sortedThreads: ReadonlyArray, - threadSortOrder: SidebarThreadSortOrder, - now: number, - queuedThreadKeys: ReadonlySet | undefined, -): ReadonlyArray { - const cutoff = now - RECENT_THREAD_WINDOW_MS; - // A thread with a message waiting in the outbox has work the user is - // waiting on, however old its last activity; it never trims away. - const recent = sortedThreads.filter( - (thread) => - getThreadSortTimestamp(thread, threadSortOrder) >= cutoff || - queuedThreadKeys?.has(scopedThreadKey(thread.environmentId, thread.id)) === true, - ); - return recent.length > 0 ? recent : sortedThreads.slice(0, RECENT_THREAD_FALLBACK_COUNT); -} - -export function buildHomeThreadGroups(input: { - readonly projects: ReadonlyArray; - readonly threads: ReadonlyArray; - readonly pendingTasks?: ReadonlyArray; - /** Thread keys with a message waiting in the outbox; kept in the default view. */ - readonly queuedThreadKeys?: ReadonlySet; - readonly environmentId: EnvironmentId | null; - readonly searchQuery: string; - readonly matchedThreadKeys?: ReadonlySet; - readonly projectSortOrder: HomeProjectSortOrder; - readonly threadSortOrder: SidebarThreadSortOrder; - readonly projectGroupingMode: SidebarProjectGroupingMode; - /** Current time used for the recency window; defaults to now. Injectable for tests. */ - readonly now?: number; -}): ReadonlyArray { - const now = input.now ?? Date.now(); - const groups = new Map(); - const groupTitleByKey = new Map(); - const groupKeyByProjectKey = new Map(); - - for (const scope of buildHomeProjectScopes(input)) { - groupTitleByKey.set(scope.key, scope.title); - groups.set(scope.key, { - key: scope.key, - projects: [...scope.projects], - pendingTasks: [], - threads: [], - }); - for (const projectRef of scope.projectRefs) { - groupKeyByProjectKey.set( - scopedProjectKey(projectRef.environmentId, projectRef.projectId), - scope.key, - ); - } - } - - for (const pendingTask of input.pendingTasks ?? []) { - if (input.environmentId !== null && pendingTask.environmentId !== input.environmentId) { - continue; - } - - const physicalKey = scopedProjectKey(pendingTask.environmentId, pendingTask.projectId); - let groupKey = groupKeyByProjectKey.get(physicalKey); - if (!groupKey) { - // The project shell is not loaded (environment offline / project gone). - // A queued task must stay visible and deletable regardless, so build a - // standalone group from the metadata snapshotted at enqueue time. - groupKey = `pending-project:${physicalKey}`; - groupKeyByProjectKey.set(physicalKey, groupKey); - groups.set(groupKey, { - key: groupKey, - projects: [ - { - environmentId: pendingTask.environmentId, - id: pendingTask.projectId, - title: pendingTask.projectTitle ?? "Unknown project", - workspaceRoot: pendingTask.projectCwd ?? String(pendingTask.projectId), - repositoryIdentity: null, - defaultModelSelection: null, - scripts: [], - createdAt: pendingTask.createdAt, - updatedAt: pendingTask.createdAt, - }, - ], - pendingTasks: [], - threads: [], - }); - } - groups.get(groupKey)?.pendingTasks.push(pendingTask); - } - - for (const thread of input.threads) { - if (thread.archivedAt !== null) { - continue; - } - if (input.environmentId !== null && thread.environmentId !== input.environmentId) { - continue; - } - - const physicalKey = scopedProjectKey(thread.environmentId, thread.projectId); - const groupKey = groupKeyByProjectKey.get(physicalKey); - if (!groupKey) { - continue; - } - groups.get(groupKey)?.threads.push(thread); - } - - const query = input.searchQuery.trim().toLocaleLowerCase(); - const result: HomeThreadGroup[] = []; - - for (const group of groups.values()) { - const representative = group.projects[0]; - if (!representative || (group.threads.length === 0 && group.pendingTasks.length === 0)) { - continue; - } - - const title = - groupTitleByKey.get(group.key) ?? - deriveProjectGroupLabel({ representative, members: group.projects }); - const groupMatches = - query.length === 0 || - title.toLocaleLowerCase().includes(query) || - group.projects.some((project) => project.title.toLocaleLowerCase().includes(query)); - const matchingThreads = groupMatches - ? group.threads - : group.threads.filter( - (thread) => - thread.title.toLocaleLowerCase().includes(query) || - threadPullRequestSearchTerms(thread).some((term) => - term.toLocaleLowerCase().includes(query), - ) || - input.matchedThreadKeys?.has( - threadSearchMatchKey({ - environmentId: thread.environmentId, - threadId: thread.id, - }), - ) === true, - ); - const matchingPendingTasks = groupMatches - ? group.pendingTasks - : group.pendingTasks.filter((pendingTask) => - pendingTask.title.toLocaleLowerCase().includes(query), - ); - - if (matchingThreads.length === 0 && matchingPendingTasks.length === 0) { - continue; - } - - const sortedThreads = sortThreads(matchingThreads, input.threadSortOrder); - // An active search should reach the full history, so the recency window - // only trims the default (no-query) view. - const recentThreads = - query.length === 0 - ? selectRecentThreads(sortedThreads, input.threadSortOrder, now, input.queuedThreadKeys) - : sortedThreads; - - // A stale project id still resolves to the canonical member with the same - // environment/path, so quick creation follows the machine with the newest activity. - const lastActiveProject = Arr.head(sortedThreads).pipe( - Option.flatMap((thread) => - Arr.findFirst( - input.projects, - (project) => - project.environmentId === thread.environmentId && project.id === thread.projectId, - ), - ), - Option.flatMap((threadProject) => - Arr.findFirst( - group.projects, - (project) => - derivePhysicalProjectKey(project) === derivePhysicalProjectKey(threadProject), - ), - ), - Option.getOrNull, - ); - - result.push({ - key: group.key, - title, - representative, - projects: group.projects, - pendingTasks: matchingPendingTasks, - threads: sortedThreads, - recentThreads, - newThreadTarget: group.key.startsWith("pending-project:") - ? null - : (lastActiveProject ?? representative), - }); - } - - return Arr.sort( - result, - Order.mapInput( - Order.Struct({ - timestamp: Order.flip(Order.Number), - title: Order.String, - key: Order.String, - }), - (group: HomeThreadGroup) => ({ - timestamp: groupSortTimestamp(group, input.projectSortOrder), - title: group.title, - key: group.key, - }), - ), - ); -} diff --git a/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts b/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts index b76d2f8a90bf..45b6ccb13d17 100644 --- a/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts +++ b/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts @@ -2,23 +2,19 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; import { useCallback } from "react"; -import type { HomeListItem } from "../home/homeListItems"; import type { ThreadListV2ListItem } from "../threads/threadListV2"; import { useHardwareKeyboardCommand, type HardwareKeyboardCommand, } from "./hardwareKeyboardCommands"; -type ThreadShortcutListItem = - | HomeListItem - | ThreadListV2ListItem - | { readonly type: "v2-show-more" }; +type ThreadShortcutListItem = ThreadListV2ListItem | { readonly type: "v2-show-more" }; export function threadJumpIndex(command: HardwareKeyboardCommand) { return THREAD_JUMP_KEYBINDING_COMMANDS.findIndex((candidate) => candidate === command); } -/** Uses the rendered list so filters, collapsed groups and shelves keep their order. */ +/** Uses the rendered list so filters and shelves keep their order. */ export function threadJumpTarget( items: ReadonlyArray, command: HardwareKeyboardCommand, @@ -26,8 +22,7 @@ export function threadJumpTarget( let index = threadJumpIndex(command); if (index < 0) return null; for (const item of items) { - const thread = - item.type === "thread" ? item.thread : item.type === "v2-thread" ? item.item.thread : null; + const thread = item.type === "v2-thread" ? item.item.thread : null; if (thread !== null && index-- === 0) return thread; } return null; diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 6c6133c7ef13..60df38b30357 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -57,6 +57,7 @@ import { import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; import { HomeListOptionsProvider } from "../home/home-list-options"; import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; +import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; import { WorkspaceContentWidthContext } from "./workspace-content-width"; @@ -580,21 +581,31 @@ function AdaptiveWorkspaceLayoutContent( style={sidebarAnimatedStyle} > - - - + ( + + )} + > + + + + ) : null} @@ -625,6 +636,7 @@ function AdaptiveWorkspaceLayoutContent( ; /** * When false the pane animates closed but keeps its content mounted for the @@ -139,10 +141,23 @@ export function WorkspaceInspectorPane(props: { style={inspectorStyle} > - {props.renderInspector?.()} + ( + + )} + > + + ) : null} ); } + +// The render callback must run inside the boundary's child, not while its +// parent constructs the boundary element. +function InspectorRenderer(props: { readonly render?: () => ReactNode }) { + return <>{props.render?.()}; +} diff --git a/apps/mobile/src/features/review/ReviewCommentCard.tsx b/apps/mobile/src/features/review/ReviewCommentCard.tsx index ff348e1f2a97..5be4e87cba40 100644 --- a/apps/mobile/src/features/review/ReviewCommentCard.tsx +++ b/apps/mobile/src/features/review/ReviewCommentCard.tsx @@ -110,8 +110,9 @@ export const ReviewCommentCard = memo(function ReviewCommentCard(props: { () => JSON.stringify(nativeReviewDiffTheme), [nativeReviewDiffTheme], ); + // The card's height is sized from its row count, so its snippet stays unwrapped. const nativeStyleJson = useMemo( - () => JSON.stringify(nativeReviewDiffStyle), + () => JSON.stringify({ ...nativeReviewDiffStyle, wordWrap: false }), [nativeReviewDiffStyle], ); const nativeDiffHeight = useMemo( diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 2fac5376e5b1..08888dd469d2 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -735,7 +735,7 @@ export function ReviewSheet(props: ReviewSheetProps) { /> - + {showConnectionNotice ? ( channel.toString(16).padStart(2, "0")).join("")}`; } -export function createNativeReviewDiffStyle(codeSurface: ResolvedMobileCodeSurface) { +/** `wordWrap` wraps line rows at the view width instead of panning them horizontally. */ +export function createNativeReviewDiffStyle( + codeSurface: ResolvedMobileCodeSurface, + wordWrap: boolean, +) { return { + wordWrap, rowHeight: codeSurface.rowHeight, contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH, changeBarWidth: 4, diff --git a/apps/mobile/src/features/review/reviewDiffHighlightScheduler.test.ts b/apps/mobile/src/features/review/reviewDiffHighlightScheduler.test.ts new file mode 100644 index 000000000000..3792e49f54c7 --- /dev/null +++ b/apps/mobile/src/features/review/reviewDiffHighlightScheduler.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { createReviewDiffHighlightScheduler } from "./reviewDiffHighlightScheduler"; + +describe("review diff highlighting while scrolling", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("keeps requesting new rows during gradual scrolling through a large diff", () => { + const request = vi.fn(); + const scheduler = createReviewDiffHighlightScheduler(request); + for (let firstRowIndex = 1; firstRowIndex <= 1_674; firstRowIndex++) { + scheduler.update({ firstRowIndex, lastRowIndex: firstRowIndex + 80 }); + vi.advanceTimersByTime(16); + } + expect(request.mock.calls.length).toBeGreaterThan(100); + vi.advanceTimersByTime(150); + expect(request).toHaveBeenLastCalledWith({ firstRowIndex: 1_674, lastRowIndex: 1_754 }); + }); + + it("highlights the settled viewport even below the movement threshold", () => { + const request = vi.fn(); + const scheduler = createReviewDiffHighlightScheduler(request); + scheduler.update({ firstRowIndex: 2, lastRowIndex: 82 }); + vi.advanceTimersByTime(100); + scheduler.update({ firstRowIndex: 3, lastRowIndex: 83 }); + vi.advanceTimersByTime(100); + expect(request).not.toHaveBeenCalled(); + vi.advanceTimersByTime(50); + expect(request).toHaveBeenCalledExactlyOnceWith({ firstRowIndex: 3, lastRowIndex: 83 }); + }); + + it("does not let repeated draw events starve the settled refresh", () => { + const request = vi.fn(); + const scheduler = createReviewDiffHighlightScheduler(request); + for (let i = 0; i < 10; i++) { + scheduler.update({ firstRowIndex: 1, lastRowIndex: 81 }); + vi.advanceTimersByTime(30); + } + expect(request).toHaveBeenCalledExactlyOnceWith({ firstRowIndex: 1, lastRowIndex: 81 }); + }); + + it("requests large jumps and reverse scrolling immediately without stale timers", () => { + const request = vi.fn(); + const scheduler = createReviewDiffHighlightScheduler(request); + scheduler.update({ firstRowIndex: 1, lastRowIndex: 81 }); + scheduler.update({ firstRowIndex: 1_000, lastRowIndex: 1_080 }); + scheduler.update({ firstRowIndex: 0, lastRowIndex: 80 }); + vi.runAllTimers(); + expect(request.mock.calls).toEqual([ + [{ firstRowIndex: 1_000, lastRowIndex: 1_080 }], + [{ firstRowIndex: 0, lastRowIndex: 80 }], + ]); + }); + + it("cancels pending work on disposal and resets the range for a new diff", () => { + const request = vi.fn(); + const scheduler = createReviewDiffHighlightScheduler(request); + scheduler.update({ firstRowIndex: 1, lastRowIndex: 81 }); + scheduler.cancel(); + vi.runAllTimers(); + expect(request).not.toHaveBeenCalled(); + scheduler.update({ firstRowIndex: 1_000, lastRowIndex: 1_080 }); + request.mockClear(); + scheduler.update({ firstRowIndex: 1_001, lastRowIndex: 1_081 }); + scheduler.reset(); + vi.runAllTimers(); + expect(request).not.toHaveBeenCalled(); + scheduler.update({ firstRowIndex: 1, lastRowIndex: 81 }); + expect(request).not.toHaveBeenCalled(); + vi.advanceTimersByTime(150); + expect(request).toHaveBeenCalledExactlyOnceWith({ firstRowIndex: 1, lastRowIndex: 81 }); + }); +}); diff --git a/apps/mobile/src/features/review/reviewDiffHighlightScheduler.ts b/apps/mobile/src/features/review/reviewDiffHighlightScheduler.ts new file mode 100644 index 000000000000..4cededa52954 --- /dev/null +++ b/apps/mobile/src/features/review/reviewDiffHighlightScheduler.ts @@ -0,0 +1,51 @@ +export interface NativeReviewVisibleRange { + readonly firstRowIndex: number; + readonly lastRowIndex: number; +} + +export function createReviewDiffHighlightScheduler( + request: (range: NativeReviewVisibleRange) => void, +) { + let requestedRange: NativeReviewVisibleRange = { firstRowIndex: 0, lastRowIndex: 80 }; + let visibleRange = requestedRange; + let timer: ReturnType | undefined; + + const cancel = () => { + clearTimeout(timer); + timer = undefined; + }; + const flush = () => { + cancel(); + requestedRange = visibleRange; + request(visibleRange); + }; + + return { + update(nextRange: NativeReviewVisibleRange) { + if ( + nextRange.firstRowIndex === visibleRange.firstRowIndex && + nextRange.lastRowIndex === visibleRange.lastRowIndex + ) { + return; + } + visibleRange = nextRange; + cancel(); + // Accumulate small scroll events relative to the last request, not each other. + const movedRows = + Math.abs(nextRange.firstRowIndex - requestedRange.firstRowIndex) + + Math.abs(nextRange.lastRowIndex - requestedRange.lastRowIndex); + if (movedRows >= 20) { + flush(); + } else if (movedRows > 0) { + // Cover the final viewport even when scrolling stops below the threshold. + timer = setTimeout(flush, 150); + } + }, + reset() { + cancel(); + requestedRange = { firstRowIndex: 0, lastRowIndex: 80 }; + visibleRange = requestedRange; + }, + cancel, + }; +} diff --git a/apps/mobile/src/features/review/reviewDiffRendering.tsx b/apps/mobile/src/features/review/reviewDiffRendering.tsx index d00cf2be4daf..818f5ab8af47 100644 --- a/apps/mobile/src/features/review/reviewDiffRendering.tsx +++ b/apps/mobile/src/features/review/reviewDiffRendering.tsx @@ -4,7 +4,7 @@ import { cn } from "../../lib/cn"; import { MOBILE_CODE_SURFACE } from "../../lib/typography"; import type { ReviewRenderableLineRow } from "./reviewModel"; -import type { ReviewHighlightedToken } from "./shikiReviewHighlighter"; +import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; export const REVIEW_MONO_FONT_FAMILY = Platform.select({ ios: "ui-monospace", diff --git a/apps/mobile/src/features/review/reviewHighlightedToken.types.ts b/apps/mobile/src/features/review/reviewHighlightedToken.types.ts new file mode 100644 index 000000000000..58418a32cc7c --- /dev/null +++ b/apps/mobile/src/features/review/reviewHighlightedToken.types.ts @@ -0,0 +1,6 @@ +export interface ReviewHighlightedToken { + content: string; + readonly color: string | null; + readonly fontStyle: number | null; + readonly diffHighlight?: boolean; +} diff --git a/apps/mobile/src/features/review/reviewWordDiffs.ts b/apps/mobile/src/features/review/reviewWordDiffs.ts index 34ac9bc7a746..8dd258a296cc 100644 --- a/apps/mobile/src/features/review/reviewWordDiffs.ts +++ b/apps/mobile/src/features/review/reviewWordDiffs.ts @@ -1,6 +1,6 @@ import { diffWordsWithSpace } from "diff"; -import type { ReviewHighlightedToken } from "./shikiReviewHighlighter"; +import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; interface ReviewDiffOperation { readonly value: string; diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index 3050f1f67ee8..9b69c8d552ea 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -35,12 +35,9 @@ export class ReviewHighlighterEngineInitializationError extends Schema.TaggedErr } } -export interface ReviewHighlightedToken { - content: string; - readonly color: string | null; - readonly fontStyle: number | null; - readonly diffHighlight?: boolean; -} +import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; + +export type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; const SHIKI_THEME_NAME_BY_SCHEME = { light: "github-light-default", diff --git a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts index 35f06c263666..61a205f9d917 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { highlightNativeReviewDiffVisibleRows, @@ -8,10 +8,10 @@ import { import type { NativeReviewDiffRow } from "../diffs/nativeReviewDiffSurface"; import type { NativeReviewDiffFile } from "../diffs/nativeReviewDiffTypes"; -interface NativeReviewVisibleRange { - readonly firstRowIndex: number; - readonly lastRowIndex: number; -} +import { + createReviewDiffHighlightScheduler, + type NativeReviewVisibleRange, +} from "./reviewDiffHighlightScheduler"; function createEmptyTokenPatch(resetKey: string): string { return JSON.stringify({ resetKey, tokensByRowId: {} }); @@ -43,23 +43,22 @@ export function useNativeReviewDiffHighlighting(input: { }) { const { enabled, files, resetKey, rows, scheme } = input; const highlightedRowIdsRef = useRef>(new Set()); - const visibleRangeRef = useRef({ + const [visibleRange, setVisibleRange] = useState({ firstRowIndex: 0, lastRowIndex: 80, }); const visibleChunkIndexRef = useRef(0); const [tokensPatchJson, setTokensPatchJson] = useState(() => createEmptyTokenPatch(resetKey)); - const [visibleHighlightRequest, setVisibleHighlightRequest] = useState(0); + const [scheduler] = useState(() => createReviewDiffHighlightScheduler(setVisibleRange)); useEffect(() => { + scheduler.reset(); highlightedRowIdsRef.current = new Set(); visibleChunkIndexRef.current = 0; - visibleRangeRef.current = { firstRowIndex: 0, lastRowIndex: 80 }; + setVisibleRange({ firstRowIndex: 0, lastRowIndex: 80 }); setTokensPatchJson(createEmptyTokenPatch(resetKey)); - if (enabled && rows.length > 0) { - setVisibleHighlightRequest((request) => request + 1); - } - }, [enabled, resetKey, rows.length]); + return () => scheduler.cancel(); + }, [enabled, resetKey, rows.length, scheduler]); useEffect(() => { if (!enabled || rows.length === 0) { @@ -67,7 +66,7 @@ export function useNativeReviewDiffHighlighting(input: { } const abortController = new AbortController(); - const requestRange = visibleRangeRef.current; + const requestRange = visibleRange; const engine: NativeReviewDiffHighlightEngine = "native"; void (async () => { @@ -119,22 +118,10 @@ export function useNativeReviewDiffHighlighting(input: { })(); return () => abortController.abort(); - }, [enabled, files, resetKey, rows, scheme, visibleHighlightRequest]); - - const updateVisibleRange = useCallback((nextRange: NativeReviewVisibleRange) => { - const previousRange = visibleRangeRef.current; - const movedRows = - Math.abs(nextRange.firstRowIndex - previousRange.firstRowIndex) + - Math.abs(nextRange.lastRowIndex - previousRange.lastRowIndex); - - visibleRangeRef.current = nextRange; - if (movedRows >= 20) { - setVisibleHighlightRequest((request) => request + 1); - } - }, []); + }, [enabled, files, resetKey, rows, scheme, visibleRange]); return { tokensPatchJson, - updateVisibleRange, + updateVisibleRange: scheduler.update, }; } diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx new file mode 100644 index 000000000000..be334da900ab --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx @@ -0,0 +1,341 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { StaticScreenProps } from "@react-navigation/native"; +import type { EnvironmentId, ServerProvider } from "@t3tools/contracts"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useEffect, useRef, useState } from "react"; +import { Alert, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppText as Text } from "../../components/AppText"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { ScreenScrollView } from "../../components/ScreenScrollView"; +import { serverEnvironment } from "../../state/server"; +import { environmentSession } from "../../state/session"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useRemoteConnections } from "../../state/use-remote-environment-registry"; +import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; +import { SettingsActionRow } from "./components/SettingsActionRow"; +import { SettingsScreen } from "./components/SettingsScreen"; +import { SettingsSection } from "./components/SettingsSection"; +import { + canMaintainEnvironment, + canUpdateEnvironmentProvider, + findEnvironmentUpdate, + supportsEnvironmentUpdate, +} from "./environment-maintenance"; + +export function SettingsEnvironmentDetailRouteScreen({ + route, +}: StaticScreenProps<{ + readonly environmentId: EnvironmentId; +}>) { + // Key local request state to the host even when navigation reuses this screen. + return ( + + ); +} + +function EnvironmentDetail({ environmentId }: { readonly environmentId: EnvironmentId }) { + const insets = useSafeAreaInsets(); + const connections = useRemoteConnections(); + const environment = connections.connectedEnvironments.find( + (entry) => entry.environmentId === environmentId, + ); + const config = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const session = useAtomValue(environmentSession.sessionStateValueAtom(environmentId)); + const sessionResult = useAtomValue(environmentSession.sessionStateAtom(environmentId)); + const updateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId)); + const updateServer = useAtomCommand(serverEnvironment.updateServer); + const updateProvider = useAtomCommand(serverEnvironment.updateProvider); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders); + const [connectionExpanded, setConnectionExpanded] = useState(false); + const [pending, setPending] = useState(null); + const pendingRef = useRef(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [release, setRelease] = useState<{ + fromVersion: string; + targetVersion: string | null; + } | null>(null); + const checkController = useRef(null); + useEffect(() => () => checkController.current?.abort(), []); + + const connected = environment?.isEnabled === true && environment.connectionState === "connected"; + const allowed = + !AsyncResult.isFailure(sessionResult) && canMaintainEnvironment(session, connected); + const running = updateState.status === "running"; + const providerBusy = + config?.providers.some( + (provider) => + provider.updateState?.status === "running" || provider.updateState?.status === "queued", + ) ?? false; + const disabled = !allowed || pending !== null || running || providerBusy; + const version = config?.environment.serverVersion; + const checkedRelease = release?.fromVersion === version ? release : null; + const capabilities = config?.environment.capabilities; + + async function run(label: string, action: () => Promise) { + if (pendingRef.current) return; + pendingRef.current = true; + setPending(label); + setError(null); + setNotice(null); + try { + await action(); + } catch (cause) { + setError( + cause instanceof Error ? cause.message : "The action could not be completed. Try again.", + ); + } finally { + pendingRef.current = false; + setPending(null); + } + } + + function requestServerUpdate() { + const targetVersion = checkedRelease?.targetVersion; + if (disabled || !targetVersion || !capabilities || !supportsEnvironmentUpdate(capabilities)) + return; + Alert.alert( + `Update ${environment?.environmentLabel ?? "environment"}?`, + `Install T3 Code ${targetVersion}. ${capabilities.serverSelfUpdate === "desktop-managed" ? "The desktop app will close and relaunch." : "The server will restart and reconnect."} Running threads may be interrupted.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Update", + onPress: () => + void run("server", async () => { + const result = await updateServer({ + environmentId, + input: { + targetVersion, + ...(capabilities.serverUpdateThreadContinuation && + config?.settings.continueThreadsAfterServerUpdate + ? { continueRunningThreads: true } + : {}), + }, + }); + if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result); + setRelease(null); + setNotice(`Updated to ${result.value.targetVersion}.`); + }), + }, + ], + ); + } + + function requestProviderUpdate(provider: ServerProvider) { + if (disabled || !canUpdateEnvironmentProvider(provider)) return; + void run(provider.instanceId, async () => { + const result = await updateProvider({ + environmentId, + input: { + provider: provider.driver, + instanceId: provider.instanceId, + }, + }); + if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result); + }); + } + + return ( + + + {!environment ? ( + + This environment is no longer saved on this device. + + ) : ( + <> + + setConnectionExpanded((value) => !value)} + onReconnect={connections.onReconnectEnvironment} + onRemove={connections.onRemoveEnvironmentPress} + onSetEnabled={connections.onSetEnvironmentEnabled} + onUpdate={connections.onUpdateEnvironment} + /> + + {!connected ? ( + + Connect this environment to manage it. + + ) : !allowed ? ( + + {AsyncResult.isFailure(sessionResult) + ? "Could not verify your permissions. Reconnect to try again." + : session === null + ? "Checking permissions…" + : "This connection does not have permission to manage the environment."} + + ) : null} + {error ? ( + + {error} + + ) : null} + {notice ? {notice} : null} + {config ? ( + <> + + + Version {version} + {running ? ( + + {updateState.stage === "resuming" + ? "Restarting and reconnecting…" + : "Downloading update…"} + + ) : updateState.status === "failed" ? ( + + {updateState.message} + + ) : null} + {checkedRelease ? ( + + {checkedRelease.targetVersion + ? `Version ${checkedRelease.targetVersion} is available.` + : "You are up to date."} + + ) : null} + {!supportsEnvironmentUpdate(config.environment.capabilities) ? ( + + {capabilities?.serverSelfUpdate === "desktop-managed" + ? "Update the desktop app on this machine." + : "Update and restart T3 Code on this machine."} + + ) : null} + + { + if (disabled) return; + void run("check", async () => { + const controller = new AbortController(); + checkController.current = controller; + const timeout = setTimeout(() => controller.abort(), 20_000); + try { + const targetVersion = await findEnvironmentUpdate( + config.environment.serverVersion, + controller.signal, + ); + setRelease({ + fromVersion: config.environment.serverVersion, + targetVersion, + }); + } finally { + clearTimeout(timeout); + checkController.current = null; + } + }); + }} + /> + {checkedRelease?.targetVersion && + supportsEnvironmentUpdate(config.environment.capabilities) ? ( + + ) : null} + + + { + if (disabled) return; + void run("refresh", async () => { + const result = await refreshProviders({ environmentId, input: {} }); + if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result); + setNotice("Provider status refreshed."); + }); + }} + /> + {config.providers + .filter((provider) => provider.enabled) + .map((provider) => ( + + + + + + {provider.displayName ?? provider.driver} + + + + {provider.installed + ? (provider.version ?? "Version unknown") + : "Not installed"} + {provider.versionAdvisory?.latestVersion + ? ` · Latest ${provider.versionAdvisory.latestVersion}` + : ""} + + {provider.updateState && provider.updateState.status !== "idle" ? ( + + {provider.updateState.message ?? + `Update ${provider.updateState.status}`} + + ) : null} + {provider.compatibilityAdvisory?.message ? ( + + {provider.compatibilityAdvisory.message} + + ) : null} + {provider.unavailableReason || provider.message ? ( + + {provider.unavailableReason ?? provider.message} + + ) : null} + {provider.versionAdvisory?.status === "behind_latest" && + !provider.versionAdvisory.canUpdate ? ( + + Update this provider on the environment's machine. + + ) : null} + + {canUpdateEnvironmentProvider(provider) ? ( + requestProviderUpdate(provider)} + /> + ) : null} + + ))} + + + ) : null} + + )} + + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index 7ca791afec6d..003178a03aca 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -47,7 +47,6 @@ export function SettingsEnvironmentsRouteScreen() { const connectedCloudEnvironments = SHOWCASE_ENABLED ? SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS : environmentSections.connectedCloudEnvironments; - const [expandedId, setExpandedId] = useState(null); const headerIconColor = useUniwindTheme()["--color-icon"]; const relaySession = useAtomValue(managedRelaySessionAtom); const refreshRelayEnvironments = useAtomCommand( @@ -68,9 +67,15 @@ export function SettingsEnvironmentsRouteScreen() { } } - const handleToggle = useCallback((environmentId: EnvironmentId) => { - setExpandedId((prev) => (prev === environmentId ? null : environmentId)); - }, []); + const openEnvironment = useCallback( + (environmentId: EnvironmentId) => { + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironmentDetail", params: { environmentId } }, + }); + }, + [navigation], + ); const handleUpdateEnvironment = useCallback( ( environmentId: EnvironmentId, @@ -159,8 +164,9 @@ export function SettingsEnvironmentsRouteScreen() { > = { }; const PAGE_PROJECT_KEYS: Record = { - "new-threads": ["defaultThreadEnvMode", "defaultRuntimeMode"], + "new-threads": ["defaultThreadEnvMode", "worktreeSubmodules", "defaultRuntimeMode"], "source-control": ["defaultAutoPull", "newWorktreesStartFromOrigin"], "agent-behavior": ["responseStreamingMode", "enableAgentBrowserAccess"], maintenance: ["continueThreadsAfterServerUpdate"], }; +const SUBMODULE_CHOICES: ReadonlyArray<{ + readonly mode: WorktreeSubmodules | null; + readonly label: string; + readonly description: string; +}> = [ + // Only offered at environment scope; a project falls back through "Use defaults". + { + mode: null, + label: "Inherit", + description: "Use the repository's t3.json, or initialize recursively.", + }, + { mode: "recursive", label: "Recursive", description: "Initialize nested submodules too." }, + { + mode: "top-level", + label: "Top level only", + description: "Skip submodules declared inside other submodules.", + }, + { mode: "none", label: "Skip", description: "Leave submodules empty for a setup script." }, +]; + const WORKSPACE_CHOICES: ReadonlyArray<{ - readonly mode: ThreadEnvMode; + readonly mode: ThreadEnvMode | null; readonly label: string; readonly description: string; }> = [ + // Only offered at environment scope; a project falls back through "Use defaults". + { + mode: null, + label: "Inherit", + description: "Use the repository's t3.json, or the current checkout.", + }, { mode: "local", label: "Current checkout", @@ -106,6 +135,7 @@ export function SettingsEnvironmentMaintenanceRouteScreen() { function ServerSettingsDetail(props: { readonly page: SettingsPage }) { const insets = useSafeAreaInsets(); + const navigation = useNavigation(); const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter(); const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey); const projectSelected = selectedProjectKey !== null; @@ -126,6 +156,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { const value = reference.settings[key]; return displayTargets.every((entry) => entry.settings[key] === value) ? value : null; }; + // `uniform` folds a real null into "mixed"; nullable keys need the distinction. + const isMixed = (key: keyof ServerSettings) => + reference === null || + displayTargets.some((entry) => entry.settings[key] !== reference.settings[key]); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { label: "environment settings update", reportFailure: true, @@ -219,23 +253,53 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { ) : null } > - {WORKSPACE_CHOICES.map((choice, index) => ( + {WORKSPACE_CHOICES.filter( + (choice) => choice.mode !== null || !projectSelected, + ).map((choice, index) => ( 0} disabled={disabledFor("defaultThreadEnvMode")} onPress={() => write({ defaultThreadEnvMode: choice.mode })} /> ))} + + ) : null + } + > + {SUBMODULE_CHOICES.filter( + (choice) => choice.mode !== null || !projectSelected, + ).map((choice, index) => ( + 0} + disabled={disabledFor("worktreeSubmodules")} + onPress={() => write({ worktreeSubmodules: choice.mode })} + /> + ))} + - write({ enableProviderUpdateChecks: value })} - /> - + <> + {!projectSelected ? ( + + {selectedTargets.map((target) => ( + + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { + screen: "SettingsEnvironmentDetail", + params: { environmentId: target.environmentId }, + }, + }) + } + /> + ))} + + ) : null} + write({ continueThreadsAfterServerUpdate: value })} + value={uniform("enableProviderUpdateChecks")} + disabled={disabledFor("enableProviderUpdateChecks")} + onValueChange={(value) => write({ enableProviderUpdateChecks: value })} /> - - + + + write({ continueThreadsAfterServerUpdate: value }) + } + /> + + + ) : null} )} diff --git a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx index bd8ec3252155..e209ab0b6ce5 100644 --- a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx @@ -3,17 +3,15 @@ import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollVie import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import { useRef, useState } from "react"; -import { Platform, Pressable, View } from "react-native"; +import { Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; import { AppText as Text } from "../../components/AppText"; -import { cn } from "../../lib/cn"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; -import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; @@ -182,21 +180,9 @@ function AutoSettleSettingsRows() { } /> {afterDays !== null ? ( - - - - Inactive days - + + + Inactive days - savePreferences({ legacyThreadListEnabled: value })} - /> ( ( createNativeSourceStyle(codeSurface), [codeSurface]); const nativeReviewDiffStyle = useMemo( - () => createNativeReviewDiffStyle(codeSurface), - [codeSurface], + () => createNativeReviewDiffStyle(codeSurface, appearance.codeWordBreak), + [appearance.codeWordBreak, codeSurface], ); return { diff --git a/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx b/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx index 49fb98e49976..85cb4764e25a 100644 --- a/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx +++ b/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx @@ -17,6 +17,7 @@ export function SettingsSwitchRow( subtitle={props.subtitle} > vi.unstubAllGlobals()); + +describe("environment maintenance access", () => { + it("requires a connected authenticated session with operate permission", () => { + const session = { + authenticated: true, + auth: { + policy: "remote-reachable" as const, + bootstrapMethods: [], + sessionMethods: [], + sessionCookieName: "session", + }, + scopes: [AuthOrchestrationOperateScope], + }; + expect(canMaintainEnvironment(session, true)).toBe(true); + expect(canMaintainEnvironment(session, false)).toBe(false); + expect(canMaintainEnvironment({ ...session, authenticated: false }, true)).toBe(false); + expect(canMaintainEnvironment({ ...session, scopes: [] }, true)).toBe(false); + const { scopes: _, ...legacy } = session; + expect(canMaintainEnvironment(legacy, true)).toBe(false); + expect(canMaintainEnvironment(null, true)).toBe(false); + }); + + it("requires remote desktop update support for desktop hosts", () => { + expect(supportsEnvironmentUpdate({})).toBe(false); + expect(supportsEnvironmentUpdate({ serverSelfUpdate: "respawn" })).toBe(true); + expect(supportsEnvironmentUpdate({ serverSelfUpdate: "desktop-managed" })).toBe(false); + expect( + supportsEnvironmentUpdate({ serverSelfUpdate: "desktop-managed", desktopAppUpdate: true }), + ).toBe(true); + }); + + it("excludes unavailable, manual, busy, and incompatible provider updates", () => { + expect(canUpdateEnvironmentProvider(provider)).toBe(true); + expect(canUpdateEnvironmentProvider({ ...provider, installed: false })).toBe(false); + expect(canUpdateEnvironmentProvider({ ...provider, availability: "unavailable" })).toBe(false); + expect(canUpdateEnvironmentProvider({ ...provider, versionAdvisory: undefined })).toBe(false); + for (const latestVersionStatus of ["broken", "unsupported"] as const) { + expect( + canUpdateEnvironmentProvider({ + ...provider, + compatibilityAdvisory: { + status: "supported", + latestVersionStatus, + message: null, + recommendedVersion: null, + recommendedRange: null, + }, + }), + ).toBe(false); + } + for (const status of ["queued", "running"] as const) { + expect( + canUpdateEnvironmentProvider({ + ...provider, + updateState: { + status, + startedAt: null, + finishedAt: null, + message: null, + output: null, + }, + }), + ).toBe(false); + } + expect( + canUpdateEnvironmentProvider({ + ...provider, + versionAdvisory: { + ...provider.versionAdvisory!, + canUpdate: false, + }, + }), + ).toBe(false); + }); +}); + +describe("environment release checks", () => { + const signal = new AbortController().signal; + + it("keeps stable hosts on stable releases and ignores drafts", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockImplementation(async () => + Response.json([ + { tag_name: "v2.0.0-nightly.20260923.1" }, + { tag_name: "v1.2.0", draft: true }, + { tag_name: "v1.1.0" }, + ]), + ), + ); + expect(await findEnvironmentUpdate("1.0.0", signal)).toBe("1.1.0"); + expect(await findEnvironmentUpdate("1.1.0", signal)).toBeNull(); + expect(await findEnvironmentUpdate("1.3.0", signal)).toBeNull(); + }); + + it("walks release pages to find the host's channel", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + Response.json(Array.from({ length: 100 }, () => ({ tag_name: "v2.0.0" }))), + ) + .mockResolvedValueOnce(Response.json([{ tag_name: "v1.0.0-preview.20260923.2" }])); + vi.stubGlobal("fetch", fetchMock); + expect(await findEnvironmentUpdate("1.0.0-preview.20260923.1", signal)).toBe( + "1.0.0-preview.20260923.2", + ); + expect(fetchMock.mock.calls[1]?.[0]).toContain("page=2"); + expect(fetchMock.mock.calls[1]?.[1]).toEqual({ signal }); + }); + + it("reports failed checks instead of claiming the server is current", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 403 }))); + await expect(findEnvironmentUpdate("1.0.0", signal)).rejects.toThrow("403"); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json([]))); + await expect(findEnvironmentUpdate("1.0.0", signal)).rejects.toThrow("No stable release"); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ error: "invalid" }))); + await expect(findEnvironmentUpdate("1.0.0", signal)).rejects.toThrow(); + }); +}); diff --git a/apps/mobile/src/features/settings/environment-maintenance.ts b/apps/mobile/src/features/settings/environment-maintenance.ts new file mode 100644 index 000000000000..f441066d7174 --- /dev/null +++ b/apps/mobile/src/features/settings/environment-maintenance.ts @@ -0,0 +1,68 @@ +import { + AuthOrchestrationOperateScope, + type AuthSessionState, + type ExecutionEnvironmentCapabilities, + type ServerProvider, +} from "@t3tools/contracts"; +import { + cliReleaseChannelOf, + cliReleaseIndexPageUrl, + newestCliReleaseVersion, +} from "@t3tools/shared/cliRelease"; +import { compareSemverVersions } from "@t3tools/shared/semver"; +import * as Schema from "effect/Schema"; + +export function canMaintainEnvironment(session: AuthSessionState | null, connected: boolean) { + return ( + connected && + session?.authenticated === true && + session.scopes?.includes(AuthOrchestrationOperateScope) === true + ); +} + +export function supportsEnvironmentUpdate( + capabilities: Pick, +) { + return ( + capabilities.serverSelfUpdate !== undefined && + (capabilities.serverSelfUpdate !== "desktop-managed" || capabilities.desktopAppUpdate === true) + ); +} + +export function canUpdateEnvironmentProvider(provider: ServerProvider) { + const compatibility = provider.compatibilityAdvisory?.latestVersionStatus; + return ( + provider.installed && + provider.availability !== "unavailable" && + provider.versionAdvisory?.status === "behind_latest" && + provider.versionAdvisory.canUpdate && + provider.versionAdvisory.latestVersion !== null && + compatibility !== "broken" && + compatibility !== "unsupported" && + provider.updateState?.status !== "running" && + provider.updateState?.status !== "queued" + ); +} + +const Releases = Schema.Array( + Schema.Struct({ + tag_name: Schema.String, + draft: Schema.optionalKey(Schema.Boolean), + }), +); +const decodeReleases = Schema.decodeUnknownSync(Releases); + +/** Preserve the host's release channel and never offer a downgrade. */ +export async function findEnvironmentUpdate(currentVersion: string, signal: AbortSignal) { + const channel = cliReleaseChannelOf(currentVersion); + for (let page = 1; ; page++) { + const response = await fetch(cliReleaseIndexPageUrl(page), { signal }); + if (!response.ok) throw new Error(`Could not check releases (${response.status}). Try again.`); + const releases = decodeReleases(await response.json()); + const version = newestCliReleaseVersion(releases, channel); + if (version !== undefined) { + return compareSemverVersions(version, currentVersion) > 0 ? version : null; + } + if (releases.length < 100) throw new Error(`No ${channel} release was found.`); + } +} diff --git a/apps/mobile/src/features/settings/settings-scoped-server.test.ts b/apps/mobile/src/features/settings/settings-scoped-server.test.ts index dd589cd2038c..185fb07e602f 100644 --- a/apps/mobile/src/features/settings/settings-scoped-server.test.ts +++ b/apps/mobile/src/features/settings/settings-scoped-server.test.ts @@ -69,6 +69,39 @@ describe("mobile project settings scope", () => { expect(secondSettings.responseStreamingMode).toBe("token"); }); + it("removes a project override when a picker sends null for a key that cannot store it", () => { + const settings: ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [firstProject]: { defaultThreadEnvMode: "worktree", defaultAutoPull: true }, + }, + }; + const targets = resolveMobileSettingsTargets( + [environment(firstId, settings)], + [{ environmentId: firstId, id: firstProject }], + ); + expect(planMobileScopedSettingsPatch(targets, true, { defaultThreadEnvMode: null })).toEqual([ + { + environmentId: firstId, + patch: { projectSettingsOverrides: { [firstProject]: { defaultAutoPull: true } } }, + }, + ]); + expect(planMobileScopedSettingsPatch(targets, true, { defaultModelSelection: null })).toEqual([ + { + environmentId: firstId, + patch: { + projectSettingsOverrides: { + [firstProject]: { + defaultThreadEnvMode: "worktree", + defaultAutoPull: true, + defaultModelSelection: null, + }, + }, + }, + }, + ]); + }); + it("resets only the selected page's override and rejects environment-wide writes", () => { const settings: ServerSettings = { ...DEFAULT_SERVER_SETTINGS, diff --git a/apps/mobile/src/features/settings/settings-scoped-server.ts b/apps/mobile/src/features/settings/settings-scoped-server.ts index 53cc44a7ee1c..01020a3eb221 100644 --- a/apps/mobile/src/features/settings/settings-scoped-server.ts +++ b/apps/mobile/src/features/settings/settings-scoped-server.ts @@ -1,4 +1,5 @@ import { + isNullableProjectSettingsOverride, PROJECT_SCOPED_SERVER_SETTING_KEYS, type EnvironmentId, type ProjectId, @@ -69,7 +70,19 @@ export function planMobileScopedSettingsPatch( continue; const current = target.environment.serverConfig.settings.projectSettingsOverrides[target.projectId] ?? {}; - const next = { ...current, ...patch }; + const next: Record = { ...current }; + for (const [key, value] of Object.entries(patch)) { + // A picker's "Inherit" sends null; for keys whose override cannot + // store null that means remove the override. + if ( + value === null && + !isNullableProjectSettingsOverride(key as ProjectScopedServerSettingKey) + ) { + delete next[key]; + } else { + next[key] = value; + } + } const overrides = writes.get(target.environment.environmentId) ?? {}; overrides[target.projectId] = next; writes.set(target.environment.environmentId, overrides); diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx index 557c3b190359..716f4edd4166 100644 --- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -29,6 +29,7 @@ import { buildShowcasePendingTasks, SHOWCASE_PENDING_TASK_DEFINITIONS, } from "./showcasePendingTasks"; +import { buildShowcaseAgentActivity } from "./showcaseAgentActivity"; import { retryShowcaseOperation } from "./showcaseRetry"; import { clearShowcaseRenderSignal, @@ -36,6 +37,7 @@ import { isShowcaseNativeContentReady, subscribeToShowcaseRenderSignal, } from "./showcaseRenderSignal"; +import { stageShowcaseAgentActivity } from "./stageShowcaseAgentActivity"; const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; const SHOWCASE_THREAD_ID = "remote-command-center"; @@ -75,7 +77,14 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) const [themeRequestSettled, setThemeRequestSettled] = useState(false); const [readyScene, setReadyScene] = useState(null); const [orientationSettled, setOrientationSettled] = useState(false); + const [agentActivityStaged, setAgentActivityStaged] = useState(false); const requestedSceneRef = useRef(null); + // Staging reads the latest entities without restarting on every shell + // update, which would re-enter a permission prompt that is still open. + const entitiesRef = useRef({ threads, projects }); + useEffect(() => { + entitiesRef.current = { threads, projects }; + }, [projects, threads]); const renderSignal = useSyncExternalStore( subscribeToShowcaseRenderSignal, getShowcaseRenderSignal, @@ -109,6 +118,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) } let cancelled = false; + let lastOutcome: string | null = null; void retryShowcaseOperation(async () => applyNativeShowcaseOrientation(orientation), { isCancelled: () => cancelled, }).then((applied) => { @@ -129,6 +139,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) // A native draw belongs only to the scene request that produced it. In // particular, revisiting review must wait for its newly mounted surface. clearShowcaseRenderSignal(); + setAgentActivityStaged(false); setRequestedScene(value); }; readRequestedScene(); @@ -176,7 +187,11 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) }; }, [connectPairingUrl, pairingUrls]); - const scene = sceneFromPathname(props.pathname); + const routeScene = sceneFromPathname(props.pathname); + // Agent activity is captured over the thread list: the runner locks the + // simulator or opens the notification shade on top of it. + const scene = + requestedScene === "agent-activity" && routeScene === "threads" ? "agent-activity" : routeScene; const hasServerFixture = workspace.state.hasReadyEnvironment && workspace.environments.length >= 3 && @@ -228,6 +243,11 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) navigation.dispatch(StackActions.popToTop()); return; } + // Follows the environments scene, whose settings sheet popToTop leaves open. + if (requestedScene === "agent-activity") { + navigation.dispatch(CommonActions.reset({ index: 0, routes: [{ name: "Home" }] })); + return; + } const routes: ShowcaseResetRoute[] = [{ name: "Home" }]; if (requestedScene === "environments") { routes.push({ @@ -264,6 +284,39 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) ); }, [hasFixture, navigation, requestedScene, scene, showcaseThread]); + useEffect(() => { + if (!SHOWCASE_ENABLED || scene !== "agent-activity" || !hasFixture || agentActivityStaged) { + return; + } + let cancelled = false; + let lastOutcome: string | null = null; + void retryShowcaseOperation( + async () => { + const now = Date.now(); + const { threads: latestThreads, projects: latestProjects } = entitiesRef.current; + const activity = buildShowcaseAgentActivity(latestThreads, latestProjects, now); + const outcome = + activity === null + ? "fixture threads not loaded" + : await stageShowcaseAgentActivity(activity, now); + if (outcome === true) return true; + // Surfaces in the runner's Metro output when the scene never turns ready. + if (outcome !== lastOutcome) + console.warn(`[showcase] agent activity not staged: ${outcome}`); + lastOutcome = outcome; + return false; + }, + // The first attempt waits on the notification permission prompt until + // the runner answers it. + { isCancelled: () => cancelled, attemptTimeoutMs: 60_000 }, + ).then((staged) => { + if (!cancelled && staged) setAgentActivityStaged(true); + }); + return () => { + cancelled = true; + }; + }, [agentActivityStaged, hasFixture, scene]); + useEffect(() => { if ( !SHOWCASE_ENABLED || @@ -276,6 +329,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) !orientationSettled || // Likewise for the palette: an early screenshot shows the default theme. !themeApplied || + (scene === "agent-activity" && !agentActivityStaged) || !isShowcaseNativeContentReady({ scene, themeId, renderSignal }) ) { setReadyScene(null); @@ -298,7 +352,16 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) if (renderFrame !== null) cancelAnimationFrame(renderFrame); if (readyFrame !== null) cancelAnimationFrame(readyFrame); }; - }, [hasFixture, orientationSettled, renderSignal, requestedScene, scene, themeApplied, themeId]); + }, [ + agentActivityStaged, + hasFixture, + orientationSettled, + renderSignal, + requestedScene, + scene, + themeApplied, + themeId, + ]); if (!SHOWCASE_ENABLED || readyScene === null) return null; diff --git a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts index 11618291932d..33839b2fe473 100644 --- a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts +++ b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts @@ -2,7 +2,14 @@ import { requireOptionalNativeModule } from "expo"; import { MOBILE_THEME_IDS, type MobileThemeId } from "../../lib/mobileTheme"; -export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const; +export const SHOWCASE_SCENES = [ + "threads", + "thread", + "terminal", + "review", + "environments", + "agent-activity", +] as const; export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; export type ShowcaseOrientation = "portrait" | "landscape"; diff --git a/apps/mobile/src/features/showcase/showcaseAgentActivity.test.ts b/apps/mobile/src/features/showcase/showcaseAgentActivity.test.ts new file mode 100644 index 000000000000..94aaa2fc5e9f --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseAgentActivity.test.ts @@ -0,0 +1,93 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; + +import { + buildShowcaseAgentActivity, + SHOWCASE_AGENT_ACTIVITY_ROWS, + showcaseAndroidActivityData, +} from "./showcaseAgentActivity"; + +const NOW = Date.parse("2026-07-16T09:00:00.000Z"); + +const project = (environmentId: string, id: string, title: string) => + ({ + environmentId: EnvironmentId.make(environmentId), + id: ProjectId.make(id), + title, + }) as EnvironmentProject; + +const thread = (environmentId: string, id: string, projectId: string, title: string) => + ({ + environmentId: EnvironmentId.make(environmentId), + id: ThreadId.make(id), + projectId: ProjectId.make(projectId), + title, + }) as EnvironmentThreadShell; + +const projects = [ + project("moonbase-terminal", "t3code", "T3 Code"), + project("suspense-station", "react", "React"), + project("kernel-cabin", "linux", "Linux"), +]; + +const threads = [ + thread("moonbase-terminal", "remote-command-center", "t3code", "Make remote coding feel local"), + thread( + "moonbase-terminal", + "pocket-command-center", + "t3code", + "Put the command center in your pocket", + ), + thread("suspense-station", "buttery-suspense", "react", "Make Suspense transitions buttery"), + thread("kernel-cabin", "beautiful-boot", "linux", "Make boot logs oddly beautiful"), +]; + +it("waits until every staged thread and its project have loaded", () => { + assert.isNull(buildShowcaseAgentActivity(threads.slice(1), projects, NOW)); + assert.isNull(buildShowcaseAgentActivity(threads, projects.slice(0, 2), NOW)); +}); + +it("stages relay-shaped rows against the seeded threads", () => { + const activity = buildShowcaseAgentActivity(threads, projects, NOW); + assert.isNotNull(activity); + if (!activity) return; + + assert.strictEqual(activity.activeCount, 3); + assert.deepStrictEqual( + activity.activities.map((row) => [row.threadId, row.status, row.projectTitle, row.updatedAt]), + [ + ["pocket-command-center", "Approval", "T3 Code", "2026-07-16T08:59:00.000Z"], + ["beautiful-boot", "Input", "Linux", "2026-07-16T08:56:00.000Z"], + ["buttery-suspense", "Working", "React", "2026-07-16T08:58:00.000Z"], + ["remote-command-center", "Done", "T3 Code", "2026-07-16T08:57:00.000Z"], + ], + ); + assert.strictEqual( + activity.activities[0]?.deepLink, + "/threads/moonbase-terminal/pocket-command-center", + ); + assert.strictEqual(activity.activities.length, SHOWCASE_AGENT_ACTIVITY_ROWS.length); +}); + +it("encodes the Android Live Update and its alert like a relay push", () => { + const activity = buildShowcaseAgentActivity(threads, projects, NOW); + assert.isNotNull(activity); + if (!activity) return; + const data = showcaseAndroidActivityData(activity, NOW); + + assert.strictEqual(data.active, "true"); + assert.strictEqual(data.activity_chip, "Review"); + assert.strictEqual(data.activity_title, "3 active agents · 2 need attention"); + assert.strictEqual( + data.activity_line_0, + "Approval\tPut the command center in your pocket\tT3 Code", + ); + assert.strictEqual(data.activity_line_3, "Done\tMake remote coding feel local\tT3 Code"); + assert.strictEqual(data.alert_title, "Put the command center in your pocket"); + assert.strictEqual(data.alert_body, "Approval: T3 Code"); + assert.strictEqual(data.alert_path, "/threads/moonbase-terminal/pocket-command-center"); +}); diff --git a/apps/mobile/src/features/showcase/showcaseAgentActivity.ts b/apps/mobile/src/features/showcase/showcaseAgentActivity.ts new file mode 100644 index 000000000000..736c672dff0c --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseAgentActivity.ts @@ -0,0 +1,138 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; + +import type { + AgentActivityPhase, + AgentActivityProps, + AgentActivityRowProps, +} from "../../widgets/AgentActivity"; + +/** + * Agent work shown by the agent-activity scene. The rows point at + * seeded showcase threads so the copy matches the thread list, but the phases + * are staged: the relay that normally derives them is not part of the capture. + * Rows follow the relay's order: attention first, then running, then finished. + */ +export const SHOWCASE_AGENT_ACTIVITY_ROWS = [ + { threadId: "pocket-command-center", phase: "waiting_for_approval", minutesAgo: 1 }, + { threadId: "beautiful-boot", phase: "waiting_for_input", minutesAgo: 4 }, + { threadId: "buttery-suspense", phase: "running", minutesAgo: 2 }, + { threadId: "remote-command-center", phase: "completed", minutesAgo: 3 }, +] as const satisfies ReadonlyArray<{ + readonly threadId: string; + readonly phase: AgentActivityPhase; + readonly minutesAgo: number; +}>; + +// Matches the relay's row wording (AgentActivityPublisher.statusForPhase). +const STATUS_BY_PHASE: Record = { + starting: "Connecting", + running: "Working", + waiting_for_approval: "Approval", + waiting_for_input: "Input", + stale: "Waiting", + completed: "Done", + failed: "Failed", +}; + +const ACTIVE_PHASES: ReadonlySet = new Set([ + "starting", + "running", + "waiting_for_approval", + "waiting_for_input", +]); + +/** Returns null until every staged thread and its project have loaded. */ +export function buildShowcaseAgentActivity( + threads: ReadonlyArray, + projects: ReadonlyArray, + now: number, +): AgentActivityProps | null { + const rows: AgentActivityRowProps[] = []; + for (const definition of SHOWCASE_AGENT_ACTIVITY_ROWS) { + const thread = threads.find((candidate) => String(candidate.id) === definition.threadId); + const project = thread + ? projects.find( + (candidate) => + candidate.environmentId === thread.environmentId && candidate.id === thread.projectId, + ) + : undefined; + if (!thread || !project) return null; + const environmentId = String(thread.environmentId); + rows.push({ + environmentId, + threadId: definition.threadId, + projectTitle: project.title, + threadTitle: thread.title, + modelTitle: "", + phase: definition.phase, + status: STATUS_BY_PHASE[definition.phase], + updatedAt: new Date(now - definition.minutesAgo * 60_000).toISOString(), + deepLink: `/threads/${encodeURIComponent(environmentId)}/${encodeURIComponent(definition.threadId)}`, + }); + } + return { + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount: rows.filter((row) => ACTIVE_PHASES.has(row.phase)).length, + updatedAt: new Date(now).toISOString(), + activities: rows, + }; +} + +/** The alert the relay sends when the hero row starts waiting on the user. */ +export function showcaseAgentAlert(activity: AgentActivityProps) { + const row = activity.activities[0]; + if (!row) return null; + return { + title: row.threadTitle, + body: `${row.status}: ${row.projectTitle}`, + path: row.deepLink, + environmentId: row.environmentId, + threadId: row.threadId, + }; +} + +/** + * The FCM data map Android's native receiver renders, mirroring the relay's + * androidActivityData so the shade shows exactly what a real push produces. + */ +export function showcaseAndroidActivityData( + activity: AgentActivityProps, + now: number, +): Record { + const attentionCount = activity.activities.filter( + (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", + ).length; + const hero = activity.activities[0]; + const alert = showcaseAgentAlert(activity); + return { + t3_kind: "agent_activity", + updated_at: String(now), + active: String(activity.activeCount > 0), + activity_chip: attentionCount > 0 ? "Review" : "Active", + activity_title: `${activity.activeCount} active agents · ${attentionCount} need attention`, + activity_phase: hero?.phase ?? "", + activity_active_count: String(activity.activeCount), + activity_attention_count: String(attentionCount), + activity_body: hero ? `${hero.status}: ${hero.threadTitle} · ${hero.projectTitle}` : "", + ...Object.fromEntries( + activity.activities.map((row, index) => [ + `activity_line_${index}`, + [row.status, row.threadTitle, row.projectTitle].join("\t"), + ]), + ), + activity_path: hero?.deepLink ?? "/", + activity_expires_at: String(now + 2 * 60 * 60_000), + ...(alert + ? { + alert_id: "showcase-alert", + alert_title: alert.title, + alert_body: alert.body, + alert_path: alert.path, + } + : {}), + }; +} diff --git a/apps/mobile/src/features/showcase/stageShowcaseAgentActivity.ts b/apps/mobile/src/features/showcase/stageShowcaseAgentActivity.ts new file mode 100644 index 000000000000..6b1f61815b37 --- /dev/null +++ b/apps/mobile/src/features/showcase/stageShowcaseAgentActivity.ts @@ -0,0 +1,42 @@ +import * as Notifications from "expo-notifications"; +import { Platform } from "react-native"; + +import type { AgentActivityProps } from "../../widgets/AgentActivity"; +import { + getAgentLiveActivities, + startAgentLiveActivity, +} from "../agent-awareness/agentLiveActivity"; +import { showAndroidShowcaseAgentActivity } from "../agent-awareness/androidNotifications"; +import { showcaseAndroidActivityData } from "./showcaseAgentActivity"; + +/** + * Puts the staged agent activity on screen for the capture runner, which then + * locks the simulator (iOS) or opens the notification shade (Android). + * Resolves true once shown, otherwise the reason it could not be, so the + * caller can retry and report. + */ +export async function stageShowcaseAgentActivity( + activity: AgentActivityProps, + now: number, +): Promise { + // The runner answers the iOS prompt and pre-grants Android's, so this only + // settles the permission the runner's alert delivery depends on. + const permission = await Notifications.requestPermissionsAsync({ + ios: { allowAlert: true, allowBadge: true, allowSound: true }, + }); + if (!permission.granted) return `notification permission ${permission.status}`; + + if (Platform.OS === "android") { + return ( + showAndroidShowcaseAgentActivity(showcaseAndroidActivityData(activity, now)) || + "native showShowcaseActivity missing" + ); + } + if (Platform.OS !== "ios") return `unsupported platform ${Platform.OS}`; + + // A retried or revisited scene must not stack a second card. + await Promise.all(getAgentLiveActivities().map((existing) => existing.end("immediate"))); + // ActivityKit only starts activities while the app is foreground, which + // holds here: the runner locks the device after the scene reports ready. + return startAgentLiveActivity(activity) !== null || "Live Activity did not start"; +} diff --git a/apps/mobile/src/features/terminal/terminalDebugLog.ts b/apps/mobile/src/features/terminal/terminalDebugLog.ts index eb11419b330c..31c8497915c3 100644 --- a/apps/mobile/src/features/terminal/terminalDebugLog.ts +++ b/apps/mobile/src/features/terminal/terminalDebugLog.ts @@ -1,24 +1,21 @@ +import { createDebugLogger } from "../../lib/debugLog"; + /** * Debug logging for the mobile terminal pipeline. Prefix: `[t3-terminal]`. * - * Enabled when `__DEV__` is true, or set `globalThis.__T3_TERMINAL_DEBUG__ = true` in a JS - * debugger / Metro console to trace release/TestFlight builds. + * Enabled when `__DEV__` is true, or set `globalThis.__T3_TERMINAL_DEBUG__` + * (or the shared `globalThis.__T3_DEBUG__` filter) in a JS debugger / Metro + * console to trace release/TestFlight builds. */ +const logger = createDebugLogger("terminal", { + enabledInDev: true, + legacyGlobalFlag: "__T3_TERMINAL_DEBUG__", +}); + export function isTerminalDebugEnabled(): boolean { - return ( - (typeof __DEV__ !== "undefined" && __DEV__) || - (typeof globalThis !== "undefined" && - (globalThis as { __T3_TERMINAL_DEBUG__?: boolean }).__T3_TERMINAL_DEBUG__ === true) - ); + return logger.isEnabled(); } export function terminalDebugLog(message: string, data?: Record): void { - if (!isTerminalDebugEnabled()) { - return; - } - if (data !== undefined) { - console.log(`[t3-terminal] ${message}`, data); - } else { - console.log(`[t3-terminal] ${message}`); - } + logger.log(message, data); } diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index 7edb07f4e3d8..b30175b5b0de 100644 --- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -402,7 +402,7 @@ export function NewTaskBranchPickerRouteScreen() { const branchContent = flow.filteredBranches.length === 0 ? ( in ) : null} + {selectedProviderStatus?.compatibilityAdvisory?.message && + (selectedProviderStatus.compatibilityAdvisory.status === "unsupported" || + selectedProviderStatus.compatibilityAdvisory.status === "broken") ? ( + + {selectedProviderStatus.compatibilityAdvisory.message} + + ) : null} {modelUnavailable ? ( Model unavailable. Open model settings. diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index ad0872ea9116..845d67483c83 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -70,6 +70,7 @@ import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerAttachment } from "../../lib/composerImages"; +import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { editPendingThreadMessage } from "../../state/edit-pending-thread-message"; @@ -892,39 +893,51 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread : "absolute inset-0 bg-screen" } /> - + resetKeys={[props.threadCwd]} + renderFallback={(fallback) => ( + + )} + > + + ) : ( diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index bf957ad54195..160392fdd5d9 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1,5 +1,5 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; -import { createThreadMovePlanner } from "./threadOrder"; +import { computeThreadMoveAvailability } from "./threadOrder"; import type { EnvironmentProject, EnvironmentThreadShell, @@ -29,7 +29,6 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useProjects, useThreadShells } from "../../state/entities"; import { useThreadSearch } from "../../state/queries"; -import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; import { usePendingThreadOrder } from "../../state/thread-order"; import { environmentServerConfigsAtom } from "../../state/server"; @@ -39,24 +38,9 @@ import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; -import { - hasCustomHomeListOptions, - PROJECT_SORT_OPTIONS, - THREAD_SORT_OPTIONS, - useHomeListOptions, -} from "../home/home-list-options"; +import { useHomeListOptions } from "../home/home-list-options"; import { buildHomeListFilterMenu } from "../home/home-list-filter-menu"; -import { - buildHomeListLayout, - DEFAULT_GROUP_DISPLAY_STATE, - EMPTY_HOME_LIST_LAYOUT, - homeListItemsAreEqual, - nextGroupDisplayState, - type HomeGroupDisplayAction, - type HomeGroupDisplayState, - type HomeListItem, -} from "../home/homeListItems"; -import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThreadList"; +import { buildHomeProjectScopes } from "../home/homeThreadList"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; import { useThreadListActions } from "../home/useThreadListActions"; @@ -71,12 +55,6 @@ import { useMaterialFabScroll } from "../home/MaterialFabScrollContext"; import { SidebarFilterButton } from "./sidebar-filter-button"; import { createSidebarHeaderItems } from "./sidebar-native-header-items"; import { SidebarNavigationShell } from "./sidebar-navigation-shell"; -import { - PendingTaskListRow, - ThreadListGroupHeader, - ThreadListRow, - ThreadListShowMoreRow, -} from "./thread-list-items"; import { ThreadListV2PendingRow, ThreadListV2Row, @@ -84,21 +62,21 @@ import { ThreadListV2ShowMoreRow, ThreadListV2SnoozedShelfHeader, } from "./thread-list-v2-items"; -import { resolveThreadProviderInstance } from "./thread-provider-instance"; +import { useThreadRowProviderInstanceResolver } from "./thread-provider-instance"; import { buildThreadListV2Items, getThreadListV2OrderedSection, buildThreadListV2ListItems, + isThreadListV2ListItem, + threadListV2ListItemsAreEqual, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, type ThreadListV2ListItem, } from "./threadListV2"; -/** The sidebar list serves both lists: v1 grouped items or, when the Thread - List v2 beta is on, flat v2 rows with queued tasks spliced in, and a settled - "Show more" pager. */ +/** The sidebar list: flat v2 rows with queued tasks spliced in, plus a + settled "Show more" pager row. */ type SidebarListItem = - | HomeListItem | ThreadListV2ListItem | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number }; @@ -176,7 +154,6 @@ function ThreadNavigationSidebarPane( renameThread, regenerateThreadTitle, } = useThreadListActions(); - const threadListV2Enabled = useThreadListV2Enabled(); const pendingTasks = usePendingNewTasks(); const queuedThreadKeys = useQueuedThreadKeys(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); @@ -194,8 +171,7 @@ function ThreadNavigationSidebarPane( () => new Set(environments.map((environment) => environment.environmentId)), [environments], ); - const { options, setSelectedEnvironmentId, setProjectSortOrder, setThreadSortOrder } = - useHomeListOptions(availableEnvironmentIds); + const { options, setSelectedEnvironmentId } = useHomeListOptions(availableEnvironmentIds); const searchEnvironmentIds = useMemo( () => options.selectedEnvironmentId === null @@ -284,93 +260,6 @@ function ThreadNavigationSidebarPane( ), [selectedProjectScope], ); - const scopedProjects = useMemo( - () => - threadListV2Enabled - ? [] - : selectedProjectRefs === null - ? projects - : projects.filter((project) => - selectedProjectRefs.has(scopedProjectKey(project.environmentId, project.id)), - ), - [threadListV2Enabled, projects, selectedProjectRefs], - ); - const scopedThreads = useMemo( - () => - threadListV2Enabled - ? [] - : selectedProjectRefs === null - ? threads - : threads.filter((thread) => - selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId)), - ), - [threadListV2Enabled, selectedProjectRefs, threads], - ); - const scopedPendingTasks = useMemo( - () => - threadListV2Enabled - ? [] - : selectedProjectRefs === null - ? pendingTasks - : pendingTasks.filter((pendingTask) => - selectedProjectRefs.has( - scopedProjectKey(pendingTask.environmentId, pendingTask.projectId), - ), - ), - [threadListV2Enabled, pendingTasks, selectedProjectRefs], - ); - const groups = useMemo( - () => - threadListV2Enabled - ? [] - : buildHomeThreadGroups({ - projects: scopedProjects, - threads: scopedThreads, - pendingTasks: scopedPendingTasks, - queuedThreadKeys, - environmentId: options.selectedEnvironmentId, - searchQuery: props.searchQuery, - matchedThreadKeys, - projectSortOrder: options.projectSortOrder, - threadSortOrder: options.threadSortOrder, - projectGroupingMode: options.projectGroupingMode, - }), - [ - threadListV2Enabled, - queuedThreadKeys, - matchedThreadKeys, - options, - props.searchQuery, - scopedPendingTasks, - scopedProjects, - scopedThreads, - ], - ); - const [groupDisplayStates, setGroupDisplayStates] = useState< - ReadonlyMap - >(() => new Map()); - const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => { - setGroupDisplayStates((previous) => { - const next = new Map(previous); - next.set( - key, - nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action), - ); - return next; - }); - }, []); - const hasSearchQuery = props.searchQuery.trim().length > 0; - const listLayout = useMemo( - () => - threadListV2Enabled - ? EMPTY_HOME_LIST_LAYOUT - : buildHomeListLayout({ - groups, - displayStates: groupDisplayStates, - showAllThreads: hasSearchQuery, - }), - [threadListV2Enabled, groups, groupDisplayStates, hasSearchQuery], - ); const projectByKey = useMemo(() => { const map = new Map(); for (const project of projects) { @@ -410,12 +299,11 @@ function ThreadNavigationSidebarPane( // thread reappears immediately instead of on the next minute tick. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useEffect(() => { - if (!threadListV2Enabled) return; // Refresh immediately because the mount-time value can be hours old. setNowMinute(new Date().toISOString().slice(0, 16)); const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); - }, [threadListV2Enabled]); + }, []); // Threads on servers without the settlement capability never classify as // settled (the user could neither un-settle nor pin them). const serverConfigs = useAtomValue(environmentServerConfigsAtom); @@ -483,12 +371,19 @@ function ThreadNavigationSidebarPane( ), [serverConfigs], ); + // Reference-stable provider glyphs: a fresh object per render would break + // the memoized rows' props comparison on every parent render. + const resolveProviderInstance = useThreadRowProviderInstanceResolver(serverConfigs); const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); - const threadMovePlanners = useMemo(() => { - const sectionPlanner = (section: "pinned" | "active") => - createThreadMovePlanner({ + // Up/down menu availability for every card, computed once per section per + // rebuild (see computeThreadMoveAvailability): per-thread planner calls made + // list construction quadratic, and this list rebuilds on every minute tick. + const threadMoveAvailability = useMemo(() => { + const sectionAvailability = (section: "pinned" | "active") => + computeThreadMoveAvailability({ allThreads: threads, section, + pendingOrder, reorderableEnvironmentIds: new Set( [...serverConfigs].flatMap(([id, config]) => (section === "pinned" @@ -508,7 +403,7 @@ function ThreadNavigationSidebarPane( queuedThreadKeys, }), }); - return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; + return new Map([...sectionAvailability("pinned"), ...sectionAvailability("active")]); }, [ serverConfigs, threads, @@ -520,16 +415,6 @@ function ThreadNavigationSidebarPane( snoozeWakeTick, ]); const threadListV2Layout = useMemo(() => { - if (!threadListV2Enabled) - return { - items: [], - hiddenSettledCount: 0, - snoozedCount: 0, - snoozedShelfHeaderIndex: null, - settledCount: 0, - settledShelfHeaderIndex: null, - nextSnoozeWakeAt: null, - }; return buildThreadListV2Items({ pendingOrder, threads: threads.filter((thread) => thread.archivedAt === null), @@ -560,7 +445,6 @@ function ThreadNavigationSidebarPane( settledVisibleCount, settlementEnvironmentIds, snoozeEnvironmentIds, - threadListV2Enabled, threads, selectedProjectScope, ]); @@ -579,7 +463,6 @@ function ThreadNavigationSidebarPane( // range) the boundary string is identical and the chain would die. }, [nextSnoozeWakeAt, snoozeWakeTick]); const listItems = useMemo(() => { - if (!threadListV2Enabled) return listLayout.items; // Queued offline tasks are not thread shells, so the v2 item builder // never sees them; the shared splice puts them below the active block // (mirrors the compact Home v2 list) where they stay visible and @@ -607,6 +490,10 @@ function ThreadNavigationSidebarPane( settledShelfExpanded, settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex, snoozeLabelNow: `${nowMinute}:00.000Z`, + snoozeEnvironmentIds, + queuedThreadKeys, + moveAvailability: threadMoveAvailability, + shelfPreferencesLoading: !shelfPreferencesLoaded, }); if (settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0) { items.push({ @@ -617,15 +504,17 @@ function ThreadNavigationSidebarPane( } return items; }, [ - listLayout.items, nowMinute, options.selectedEnvironmentId, pendingTasks, props.searchQuery, + queuedThreadKeys, + threadMoveAvailability, selectedProjectRefs, settledShelfExpanded, + shelfPreferencesLoaded, snoozedShelfExpanded, - threadListV2Enabled, + snoozeEnvironmentIds, threadListV2Layout, ]); const listMenuActions = useMemo( @@ -671,33 +560,8 @@ function ThreadNavigationSidebarPane( ], }, ] satisfies MenuAction[])), - // v2 lays the list out in fixed creation order — offering sort/group - // controls it silently ignores would be a lie. Environment still - // scopes the v2 partition, so it stays. - ...(threadListV2Enabled - ? [] - : ([ - { - id: "project-sort", - title: "Sort projects", - subactions: PROJECT_SORT_OPTIONS.map((option) => ({ - id: `project-sort:${option.value}`, - title: option.label, - state: options.projectSortOrder === option.value ? "on" : "off", - })), - }, - { - id: "thread-sort", - title: "Sort threads", - subactions: THREAD_SORT_OPTIONS.map((option) => ({ - id: `thread-sort:${option.value}`, - title: option.label, - state: options.threadSortOrder === option.value ? "on" : "off", - })), - }, - ] satisfies MenuAction[])), ], - [environments, options, projectFilterOptions, selectedProjectKey, threadListV2Enabled], + [environments, options, projectFilterOptions, selectedProjectKey], ); const handleListMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { @@ -724,28 +588,8 @@ function ThreadNavigationSidebarPane( } return; } - const projectSort = PROJECT_SORT_OPTIONS.find( - (option) => `project-sort:${option.value}` === event, - ); - if (projectSort) { - setProjectSortOrder(projectSort.value); - return; - } - const threadSort = THREAD_SORT_OPTIONS.find( - (option) => `thread-sort:${option.value}` === event, - ); - if (threadSort) { - setThreadSortOrder(threadSort.value); - return; - } }, - [ - environments, - projectFilterOptions, - setProjectSortOrder, - setSelectedEnvironmentId, - setThreadSortOrder, - ], + [environments, projectFilterOptions, setSelectedEnvironmentId], ); const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState(null); @@ -789,9 +633,11 @@ function ThreadNavigationSidebarPane( onScroll: onMaterialFabScroll, onScrollBeginDrag: handleScrollBeginDrag, }); - // Project shells load after the first rows draw, so the maps they feed have - // to bust the recycler's memoization — otherwise a row keeps the blank - // favicon and fallback title it was first rendered with. + // The sticky header's project shells and search maps feed row props, so + // they have to bust the recycler's memoization — otherwise a row keeps the + // blank favicon and fallback title it was first rendered with. The minute + // clock deliberately stays out: its per-row text lives on the items, so a + // tick only re-renders rows whose displayed text actually moved. const listExtraData = useMemo( () => ({ selectedThreadKey: props.selectedThreadKey ?? "", @@ -799,7 +645,6 @@ function ThreadNavigationSidebarPane( projectTitleByProjectKey, savedConnectionsById, serverConfigs, - snoozePresetMinute: nowMinute, threadSearchMatchByKey, }), [ @@ -808,53 +653,19 @@ function ThreadNavigationSidebarPane( projectTitleByProjectKey, savedConnectionsById, serverConfigs, - nowMinute, threadSearchMatchByKey, ], ); useThreadJumpShortcuts(listItems, handleSelectThread); const sidebarItemsAreEqual = useCallback( (previous: SidebarListItem, item: SidebarListItem): boolean => { - if (previous.type === "v2-thread" && item.type === "v2-thread") { - return ( - previous.key === item.key && - previous.item.thread === item.item.thread && - previous.item.variant === item.item.variant && - previous.item.snoozed === item.item.snoozed && - previous.item.pinned === item.item.pinned && - previous.snoozeWakeLabelText === item.snoozeWakeLabelText - ); + if (isThreadListV2ListItem(previous) && isThreadListV2ListItem(item)) { + return threadListV2ListItemsAreEqual(previous, item); } if (previous.type === "v2-show-more" && item.type === "v2-show-more") { return previous.hiddenCount === item.hiddenCount; } - if (previous.type === "v2-pending" && item.type === "v2-pending") { - return ( - previous.pendingTask === item.pendingTask && - previous.showPendingDivider === item.showPendingDivider - ); - } - if (previous.type === "v2-snoozed-shelf" && item.type === "v2-snoozed-shelf") { - return previous.count === item.count && previous.expanded === item.expanded; - } - if (previous.type === "v2-settled-shelf" && item.type === "v2-settled-shelf") { - return previous.count === item.count && previous.expanded === item.expanded; - } - if ( - previous.type === "v2-thread" || - previous.type === "v2-show-more" || - previous.type === "v2-pending" || - previous.type === "v2-snoozed-shelf" || - previous.type === "v2-settled-shelf" || - item.type === "v2-thread" || - item.type === "v2-show-more" || - item.type === "v2-pending" || - item.type === "v2-snoozed-shelf" || - item.type === "v2-settled-shelf" - ) { - return false; - } - return homeListItemsAreEqual(previous, item); + return false; }, [], ); @@ -904,24 +715,29 @@ function ThreadNavigationSidebarPane( } case "v2-thread": { const thread = item.item.thread; - const movePlanner = item.item.pinned - ? threadMovePlanners.pinned - : threadMovePlanners.active; - const movedId = `${thread.environmentId}:${thread.id}`; const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId); + // Intentional difference from Home: the sidebar never passes + // `showTrailingDivider` because its rows render no Home-style row + // hairline at all — card rows carry tonal containers in this pane + // (the hairline branch is !sidebarPane-only) and slim rows have no + // hairline branch. The stamp still rides the shared list items + // because Home's boundary suppression consumes it; the sidebar's + // only cost is the occasional divider-only equality invalidation, + // which re-renders identically. return ( 1 ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) @@ -955,8 +771,8 @@ function ThreadNavigationSidebarPane( ? pinReorderEnvironmentIds.has(thread.environmentId) : activeReorderEnvironmentIds.has(thread.environmentId) } - canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null} - canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null} + canMoveUp={item.canMoveUp} + canMoveDown={item.canMoveDown} onSnoozeThread={snoozeThread} onUnsnoozeThread={unsnoozeThread} onUnsettleThread={unsettleThread} @@ -973,7 +789,7 @@ function ThreadNavigationSidebarPane( return ( ); - case "header": - return ( - - ); - case "pending-task": - return ( - - ); - case "thread": { - const thread = item.thread; - return ( - - ); - } - case "show-more": - return ( - - ); } }, [ archiveThread, activeReorderEnvironmentIds, - threadMovePlanners, - pendingOrder, - queuedThreadKeys, confirmDeletePendingTask, confirmDeleteThread, handleSelectThread, @@ -1098,15 +833,13 @@ function ThreadNavigationSidebarPane( projectTitleByProjectKey, regenerateThreadTitle, renameThread, + threadSearchMatchByKey, props.onNewThreadInProject, props.onNewThreadOnBranch, props.searchQuery, props.selectedThreadKey, props.width, savedConnectionsById, - serverConfigs, - shelfPreferencesLoaded, - threadSearchMatchByKey, titleRegenerationEnvironmentIds, settleThread, settlementEnvironmentIds, @@ -1114,20 +847,17 @@ function ThreadNavigationSidebarPane( sidebarScrollGesture, snoozeEnvironmentIds, snoozeThread, - nowMinute, + resolveProviderInstance, toggleSettledShelf, toggleSnoozedShelf, unpinThread, unsettleThread, unsnoozeThread, - updateGroupDisplay, ], ); - // v2 ignores the sort/group options, so only the environment filter can - // light the "customized" state while the beta is on. - const filterCustomized = threadListV2Enabled - ? options.selectedEnvironmentId !== null || selectedProjectKey !== null - : hasCustomHomeListOptions({ ...options, selectedProjectKey }); + // The list ignores sort/group options, so only the environment and project + // filters can light the "customized" state. + const filterCustomized = options.selectedEnvironmentId !== null || selectedProjectKey !== null; const filterIcon = filterCustomized ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle"; @@ -1138,24 +868,10 @@ function ThreadNavigationSidebarPane( projects: projectFilterOptions, selectedEnvironmentId: options.selectedEnvironmentId, selectedProjectKey, - projectSortOrder: options.projectSortOrder, - threadSortOrder: options.threadSortOrder, onEnvironmentChange: setSelectedEnvironmentId, onProjectChange: setSelectedProjectKey, - onProjectSortOrderChange: setProjectSortOrder, - onThreadSortOrderChange: setThreadSortOrder, - listOrganization: !threadListV2Enabled, }), - [ - environments, - options, - projectFilterOptions, - selectedProjectKey, - setProjectSortOrder, - setSelectedEnvironmentId, - setThreadSortOrder, - threadListV2Enabled, - ], + [environments, options, projectFilterOptions, selectedProjectKey, setSelectedEnvironmentId], ); const nativeHeaderItems = useMemo( () => @@ -1365,10 +1081,7 @@ function ThreadNavigationSidebarPane( /> - + diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index e5bfc1590cf0..4fb22d152496 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -606,10 +606,19 @@ function ThreadRouteContent( Files={FilesInspector} Git={GitInspector} mode={inspectorMode} + resetKeys={[routeThreadIdentity, selectedThreadCwd]} Route={props.renderInspector ? RouteInspector : undefined} /> ), - [FilesInspector, GitInspector, RouteInspector, inspectorMode, props.renderInspector], + [ + FilesInspector, + GitInspector, + RouteInspector, + inspectorMode, + props.renderInspector, + routeThreadIdentity, + selectedThreadCwd, + ], ); const activeInspectorRenderer = inspectorMode === null ? undefined : renderInspectorStack; // Hand the inspector to the workspace so it renders beside the navigator, @@ -947,18 +956,7 @@ function ThreadRouteContent( <> - + {content} @@ -185,9 +184,8 @@ function DisclosureRow(props: { diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index 7ca5085965f5..d16b72d13d81 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -63,7 +63,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { return ( {Platform.OS === "android" ? ( @@ -83,31 +83,19 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { ) : null} - - + + New branch - - + + New worktree {Platform.OS === "android" ? ( @@ -157,7 +133,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { onChangeText={setWorktreeBaseBranch} placeholder="main" accessibilityLabel="Worktree base branch" - className={Platform.OS === "android" ? "rounded-xl bg-sheet-solid" : "rounded-[18px]"} + className="android:rounded-xl android:bg-sheet-solid ios:rounded-[18px]" /> {Platform.OS === "android" ? ( New branch @@ -167,7 +143,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { onChangeText={setWorktreeBranchName} placeholder="feature/mobile-thread" accessibilityLabel="Worktree branch name" - className={Platform.OS === "android" ? "rounded-xl bg-sheet-solid" : "rounded-[18px]"} + className="android:rounded-xl android:bg-sheet-solid ios:rounded-[18px]" /> - + Existing branches {branchesLoading ? ( - + Loading branches... ) : null} {!branchesLoading && availableBranches.length === 0 ? ( - + No local branches found. ) : null} @@ -236,16 +196,10 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { ) : null} - + {branch.name} {subtitle} diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index 7f70a39d9372..19d77a327def 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -72,7 +72,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { return ( {Platform.OS === "android" ? ( @@ -92,35 +92,21 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { ) : null} - - + + Branch - + {gitStatus.data?.refName ?? "(detached HEAD)"} @@ -131,20 +117,10 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { ) : null} - + - + Files @@ -154,39 +130,19 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { {!allSelected && isEditingFiles ? ( setExcludedFiles(new Set())} > - + Reset ) : null} setIsEditingFiles((current) => !current)} > - + {isEditingFiles ? "Done" : "Edit"} @@ -226,15 +182,10 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { - - + + Commit message - - + + void runCommitAction(true)} /> - + {Platform.OS === "android" ? ( @@ -130,49 +130,31 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { - + {Platform.OS !== "android" ? ( Confirm ) : null} - + {copy?.title ?? "Run action on default branch?"} - + {copy?.description ?? "Choose how to continue."} {sheetMenuItems.map(({ item, disabledReason }, index) => ( @@ -320,11 +316,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { {linkedPrChains.map((chain) => ( {chain.layers.length > 1 ? ( @@ -483,13 +475,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { ) : null} {isInspector ? ( - + {Platform.OS === "android" ? ( {refreshMenu} ) : ( diff --git a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx index daf494cb79cc..c358466e22d2 100644 --- a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx +++ b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx @@ -71,13 +71,7 @@ export function SheetActionButton(props: { export function MetaCard(props: { readonly label: string; readonly value: string }) { return ( - + {props.label} @@ -97,21 +91,11 @@ export function SheetListRow(props: { }) { return ( - + - + {props.title} {props.subtitle ? ( diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 5c74e52cfac1..4d069bca9d05 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -20,10 +20,6 @@ import { } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; -import { - isDefaultThreadEnvModeSettled, - resolveDefaultThreadEnvMode, -} from "@t3tools/shared/threadEnvMode"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; @@ -93,7 +89,7 @@ import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; import { resolvePendingTaskInteractionMode, resolveProviderInteractionMode, -} from "./legacy-plan-mode"; +} from "../../state/legacy-plan-mode"; import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled"; import { resolveNewTaskBranchWorktreePath, @@ -431,38 +427,35 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { : null, ); const t3ProjectFileData = t3ProjectFileQuery.data as ProjectReadFileResult | null; - const t3ProjectFileDefaultMode = useMemo(() => { - if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null; - return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null; - }, [t3ProjectFileData]); - // Environment settings with the project's overrides applied; the - // aggregate's own legacy fields still count until the server folds them. + const t3ProjectFile = useMemo( + () => + t3ProjectFileData === null || t3ProjectFileData.truncated + ? null + : parseT3ProjectFile(t3ProjectFileData.contents), + [t3ProjectFileData], + ); + // Environment settings with the project's overrides and its t3.json + // applied; the aggregate's own legacy fields still count until the server + // folds them. const projectSettings = useMemo( () => resolveProjectSettings( selectedEnvironmentServerConfig?.settings ?? DEFAULT_SERVER_SETTINGS, selectedProject?.id ?? null, selectedProject, + t3ProjectFile, ), - [selectedEnvironmentServerConfig?.settings, selectedProject], + [selectedEnvironmentServerConfig?.settings, selectedProject, t3ProjectFile], ); - const projectThreadEnvMode = - projectSettings.sources.defaultThreadEnvMode === "project" - ? projectSettings.settings.defaultThreadEnvMode - : undefined; - const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({ - projectSetting: projectThreadEnvMode, - projectFile: t3ProjectFileDefaultMode, - globalDefault: projectSettings.settings.defaultThreadEnvMode, - }); - // While unsettled the resolved default is provisional. Nothing may write - // it into the draft during that window (the auto-branch effect does), or - // the frozen interim value beats the t3.json default once it loads. - const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({ - explicitMode: selectedProjectDraft.workspaceSelection?.mode, - projectSetting: projectThreadEnvMode, - projectFilePending: t3ProjectFileQuery.isPending, - }); + const defaultWorkspaceMode: WorkspaceMode = projectSettings.settings.defaultThreadEnvMode; + // While the file read is pending and nothing above it decided, the + // resolved default is provisional. Nothing may write it into the draft + // during that window (the auto-branch effect does), or the frozen interim + // value beats the t3.json default once it loads. + const defaultWorkspaceModeSettled = + selectedProjectDraft.workspaceSelection?.mode !== undefined || + projectSettings.sources.defaultThreadEnvMode !== "environment" || + !t3ProjectFileQuery.isPending; const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; @@ -873,12 +866,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { useEffect(() => { if ( + !selectedProjectDraftKey || !defaultWorkspaceModeSettled || workspaceMode !== "worktree" || selectedBranchName !== null ) { return; } + // The draft screen writes a thread's branch and worktree into the draft in + // the same commit this effect runs, so the rendered selection above can be + // stale. Re-read the draft before replacing it. + const live = getComposerDraftSnapshot(selectedProjectDraftKey).workspaceSelection; + if (live && (live.mode !== "worktree" || live.branch !== null)) { + return; + } // The default may only exist as origin/ (isRemote), which // availableBranches filters out — search the unfiltered refs for it. const preferredBranch = @@ -894,6 +895,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { defaultWorkspaceModeSettled, selectBranch, selectedBranchName, + selectedProjectDraftKey, workspaceMode, ]); diff --git a/apps/mobile/src/features/threads/sidebar-native-header-items.ts b/apps/mobile/src/features/threads/sidebar-native-header-items.ts index b80fffda057d..1f9297f47258 100644 --- a/apps/mobile/src/features/threads/sidebar-native-header-items.ts +++ b/apps/mobile/src/features/threads/sidebar-native-header-items.ts @@ -45,7 +45,7 @@ export function createSidebarHeaderItems(input: { withNativeGlassHeaderItem({ type: "menu", label: "", - accessibilityLabel: "Filter and sort threads", + accessibilityLabel: "Filter threads", icon: sfSymbolIcon(input.filterIcon), menu: { title: input.filterMenu.title, diff --git a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx index 9b41ed4e362a..5a1601b0e5ae 100644 --- a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx +++ b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx @@ -1,6 +1,8 @@ import { useEffect, useState, type ComponentType, type ReactNode } from "react"; import { View } from "react-native"; +import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; + export type ThreadInspectorMode = "route" | "git" | "files"; const INSPECTOR_PREWARM_DELAY_MS = 350; @@ -8,6 +10,7 @@ const INSPECTOR_PREWARM_DELAY_MS = 350; function InspectorContentPane(props: { readonly children: ReactNode; readonly mounted: boolean; + readonly resetKeys: readonly [string | null, string | null]; readonly visible: boolean; }) { if (!props.mounted) { @@ -27,7 +30,14 @@ function InspectorContentPane(props: { zIndex: props.visible ? 1 : 0, }} > - {props.children} + ( + + )} + > + {props.children} + ); } @@ -36,6 +46,7 @@ export function ThreadInspectorContentStack(props: { readonly Files: ComponentType; readonly Git: ComponentType; readonly mode: ThreadInspectorMode; + readonly resetKeys: readonly [string | null, string | null]; readonly Route?: ComponentType; }) { const [mountedModes, setMountedModes] = useState>( @@ -78,12 +89,14 @@ export function ThreadInspectorContentStack(props: { @@ -91,6 +104,7 @@ export function ThreadInspectorContentStack(props: { {Route ? ( diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx deleted file mode 100644 index 04d30a26a901..000000000000 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ /dev/null @@ -1,849 +0,0 @@ -import type { - EnvironmentProject, - EnvironmentThreadShell, -} from "@t3tools/client-runtime/state/shell"; -import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; -import type { EnvironmentMachineKind } from "@t3tools/contracts"; -import type { MenuAction } from "@react-native-menu/menu"; -import { SymbolView } from "../../components/AppSymbol"; -import { memo, useCallback, useMemo, type ComponentProps } from "react"; -import { Platform, Pressable, useWindowDimensions, View } from "react-native"; -import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; -import Svg, { Circle, Path } from "react-native-svg"; - -import { RowPressable } from "../../components/RowPressable"; -import { AppText as Text } from "../../components/AppText"; -import { ControlPillMenu } from "../../components/ControlPill"; -import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; -import { ProjectFavicon } from "../../components/ProjectFavicon"; -import { cn } from "../../lib/cn"; -import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; -import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; -import { relativeTime } from "../../lib/time"; -import { useUniwindTheme } from "../../lib/useUniwindTheme"; -import type { PendingNewTask } from "../../state/use-pending-new-tasks"; -import { useThreadPr, type ThreadPrPresentation } from "../../state/use-thread-pr"; -import type { HomeGroupDisplayAction } from "../home/homeListItems"; -import { ThreadSwipeable } from "../home/thread-swipe-actions"; -import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; -import { QueuedMessageIcon } from "./queued-message-icon"; -import { resolveThreadStatus } from "./threadPresentation"; -import { ThreadSearchMatchExcerpt } from "./thread-search-match"; - -/** - * Shared presentation for the thread lists: the compact (phone) Home list and - * the iPad sidebar render the SAME items — group headers with collapse, - * thread rows with status/PR/subtitle, and show-more rows — differing only in - * metrics and chrome via `variant`. - */ -export type ThreadListVariant = "compact" | "sidebar"; - -/** Left inset that aligns compact secondary rows with the title column. */ -export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET; -const SIDEBAR_ROW_RADIUS = 12; - -function pullRequestTintColor( - pr: Pick, - colorScheme: "light" | "dark", -) { - const dark = colorScheme === "dark"; - if (pr.state === "open" && pr.isDraft === true) { - return dark ? "#a1a1aa" : "#71717a"; - } - switch (pr.state) { - case "open": - return dark ? "#34d399" : "#059669"; - case "merged": - return dark ? "#a78bfa" : "#7c3aed"; - case "closed": - if (pr.kind === "stack" || pr.others > 0) { - return dark ? "#fb7185" : "#e11d48"; - } - return dark ? "#a1a1aa" : "#71717a"; - case null: - return dark ? "#a1a1aa" : "#71717a"; - } -} - -function PullRequestIcon(props: { readonly size: number; readonly color: string }) { - return ( - - - - - - - ); -} - -/* ─── Project group header ───────────────────────────────────────────── */ - -export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: { - readonly variant: ThreadListVariant; - readonly project: EnvironmentProject; - readonly title: string; - readonly threadCount: number; - readonly collapsed: boolean; - readonly isFirst: boolean; - readonly groupKey: string; - readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void; - /** Project a quick new thread should target; null hides the button. */ - readonly newThreadTarget?: EnvironmentProject | null; - readonly onNewThread?: (project: EnvironmentProject) => void; -}) { - const { groupKey, onGroupAction, onNewThread } = props; - const newThreadTarget = props.newThreadTarget ?? null; - const compact = props.variant === "compact"; - const handleToggle = useCallback( - () => onGroupAction(groupKey, "toggle-collapsed"), - [groupKey, onGroupAction], - ); - const handleNewThread = useCallback(() => { - if (newThreadTarget) { - onNewThread?.(newThreadTarget); - } - }, [newThreadTarget, onNewThread]); - const showNewThreadButton = onNewThread !== undefined && newThreadTarget !== null; - - // The new-thread button is a SIBLING of the collapse toggle, not a child: - // nested touchables are unreachable to VoiceOver/TalkBack (the parent - // swallows focus). Row padding lives on the container (explicit styles — - // dynamic padding classes on Pressable did not apply reliably) so both - // children share one centerline; hitSlop restores the padded tap area. - const verticalHitSlop = { top: props.isFirst ? 8 : 24, bottom: 12 }; - return ( - - - - - {props.title} - - - {props.threadCount} - - - {showNewThreadButton ? ( - ({ opacity: pressed ? 0.5 : 1, paddingLeft: 12 })} - > - - - ) : null} - - ); -}); - -/* ─── Show more / show less row ──────────────────────────────────────── */ - -export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props: { - readonly variant: ThreadListVariant; - readonly hiddenCount: number; - readonly canShowLess: boolean; - readonly groupKey: string; - readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void; -}) { - const showsMore = props.hiddenCount > 0; - const compact = props.variant === "compact"; - const { groupKey, onGroupAction } = props; - const handleShowMore = useCallback( - () => onGroupAction(groupKey, "show-more"), - [groupKey, onGroupAction], - ); - const handleShowLess = useCallback( - () => onGroupAction(groupKey, "show-less"), - [groupKey, onGroupAction], - ); - - const button = (label: string, icon: "chevron.down" | "chevron.up", onPress: () => void) => ( - ({ - opacity: pressed ? 0.6 : 1, - paddingHorizontal: compact ? 14 : 12, - paddingVertical: compact ? 7 : 6, - borderCurve: "continuous", - })} - > - - - - {label} - - - - ); - - return ( - - {showsMore ? button("Show more", "chevron.down", handleShowMore) : null} - {props.canShowLess ? button("Show less", "chevron.up", handleShowLess) : null} - - ); -}); - -/* ─── Pending task row ───────────────────────────────────────────────── */ - -const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ - { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, -]; - -const DRAFT_TASK_MENU_ACTIONS: MenuAction[] = [ - { id: "delete", title: "Discard", image: "trash", attributes: { destructive: true } }, -]; - -/** - * Unsent work: a task queued in the outbox for its environment to reconnect, - * or a draft still sitting in the project's new-task composer. Tapping - * reopens the composer with everything prefilled; the row disappears once - * the work is sent and the real thread arrives. The two kinds differ in what - * happens next, so the pill and icon say which one this is: a queued task - * sends itself, a draft waits for the user. - */ -export const PendingTaskListRow = memo(function PendingTaskListRow(props: { - readonly variant: ThreadListVariant; - readonly pendingTask: PendingNewTask; - readonly environmentLabel: string | null; - readonly environmentMachine?: EnvironmentMachineKind; - readonly isLast: boolean; - readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; - readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; -}) { - const compact = props.variant === "compact"; - - const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; - const isDraft = pendingTask.kind === "draft"; - const timestamp = isDraft ? null : relativeTime(pendingTask.createdAt); - // The pill only has room for one word, so what happens next goes in the - // subtitle: a queued task sends itself, a draft waits for the user. - const subtitleParts = [ - isDraft ? null : "Sends on reconnect", - props.environmentLabel, - pendingTask.branch, - ].filter((part): part is string => Boolean(part)); - - const handleMenuAction = useCallback( - ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { - if (nativeEvent.event === "delete") onDeletePendingTask(pendingTask); - }, - [onDeletePendingTask, pendingTask], - ); - - const statusPill = isDraft ? ( - - Draft - - ) : ( - - - Pending - - - ); - - const subtitleRow = - subtitleParts.length > 0 ? ( - - - {props.environmentLabel && props.environmentMachine ? ( - - ) : null} - - {subtitleParts.join(" · ")} - - - ) : null; - - const accessibilityHint = isDraft - ? "Opens the draft in the new task composer" - : "Sends when the environment reconnects. Opens the task for editing"; - - const rowContent = compact ? ( - onSelectPendingTask(pendingTask)} - > - - - - - {pendingTask.title} - - - {statusPill} - {timestamp !== null ? ( - - {timestamp} - - ) : null} - - - - {subtitleRow} - - - - ) : ( - onSelectPendingTask(pendingTask)} - style={{ - borderRadius: SIDEBAR_ROW_RADIUS, - minHeight: 64, - justifyContent: "center", - paddingHorizontal: 12, - paddingVertical: 10, - }} - > - - - - {pendingTask.title} - - - {statusPill} - {timestamp !== null ? ( - - {timestamp} - - ) : null} - - - {subtitleRow} - - - ); - - return ( - - {rowContent} - - ); -}); - -/* ─── Thread row ─────────────────────────────────────────────────────── */ - -const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [ - { id: "archive", title: "Archive", image: "archivebox" }, - { id: "rename", title: "Rename", image: "square.and.pencil" }, - { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, -]; - -export const ThreadListRow = memo(function ThreadListRow(props: { - readonly variant: ThreadListVariant; - readonly thread: EnvironmentThreadShell; - readonly environmentLabel: string | null; - readonly environmentMachine?: EnvironmentMachineKind; - /** A message for this thread is waiting in the outbox. */ - readonly hasQueuedMessages?: boolean; - readonly searchMatch?: EnvironmentThreadSearchMatch; - readonly searchQuery?: string; - readonly isLast: boolean; - /** Sidebar only: the thread currently open in the detail pane. */ - readonly selected?: boolean; - /** Defaults to window width minus compact margins. */ - readonly fullSwipeWidth?: number; - readonly onSelectThread: (thread: EnvironmentThreadShell) => void; - readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; - readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; - readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void; - readonly onRenameThread: (thread: EnvironmentThreadShell) => void; - readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; - readonly titleRegenerationSupported: boolean; - readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; - readonly onSwipeableClose: (methods: SwipeableMethods) => void; - readonly simultaneousSwipeGesture?: ComponentProps< - typeof ThreadSwipeable - >["simultaneousWithExternalGesture"]; -}) { - const { width: windowWidth } = useWindowDimensions(); - const { themeAppearance: colorScheme } = useAppearancePreferences(); - const compact = props.variant === "compact"; - const selected = props.selected === true; - const visuallySelected = selected && (!compact || Platform.OS === "android"); - const theme = useUniwindTheme(); - const screenColor = theme["--color-screen"]; - const drawerColor = theme["--color-drawer"]; - const selectedBackgroundColor = theme["--color-thread-selected"]; - - const { - thread, - onSelectThread, - onArchiveThread, - onDeleteThread, - onRenameThread, - onRegenerateThreadTitle, - onNewThreadOnBranch, - } = props; - const status = resolveThreadStatus(thread); - const pr = useThreadPr(thread); - const timestamp = relativeTime( - thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, - ); - const threadAccessibilityLabel = [ - thread.title, - pr?.accessibilityLabel, - props.hasQueuedMessages ? "messages queued to send" : null, - ] - .filter(Boolean) - .join(", "); - const subtitleParts = [props.environmentLabel, thread.branch].filter((part): part is string => - Boolean(part), - ); - - const backgroundColor = compact ? screenColor : drawerColor; - - const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); - const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); - const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]); - const handleRegenerateTitle = useCallback( - () => onRegenerateThreadTitle(thread), - [onRegenerateThreadTitle, thread], - ); - const menuActions = useMemo( - () => [ - ...(thread.branch - ? [ - { - id: "new-thread-on-branch", - title: - Platform.OS === "ios" ? "New thread on branch" : `New thread on ${thread.branch}`, - image: "square.and.pencil", - }, - ] - : []), - THREAD_ROW_MENU_ACTIONS[0]!, - THREAD_ROW_MENU_ACTIONS[1]!, - { id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" }, - ...buildThreadTitleRegenerationMenuItems({ - supported: props.titleRegenerationSupported, - isRegenerating: thread.titleRegeneration != null, - }), - THREAD_ROW_MENU_ACTIONS[2]!, - ], - [props.titleRegenerationSupported, thread.branch, thread.titleRegeneration], - ); - const primaryAction = useMemo( - () => ({ - accessibilityLabel: `Archive ${thread.title}`, - icon: "archivebox" as const, - label: "Archive", - onPress: handleArchive, - }), - [handleArchive, thread.title], - ); - const handleMenuAction = useCallback( - ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { - if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread); - if (nativeEvent.event === "archive") handleArchive(); - if (nativeEvent.event === "rename") handleRename(); - if (nativeEvent.event === "copy-thread-id") { - copyTextWithHaptic(thread.id, { target: "thread-id" }); - } - if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); - if (nativeEvent.event === "delete") handleDelete(); - }, - [handleArchive, handleDelete, handleRegenerateTitle, handleRename, onNewThreadOnBranch, thread], - ); - - const statusPill = status ? ( - - {status.label} - - ) : null; - - const subtitleRow = - subtitleParts.length > 0 || pr !== null ? ( - - {subtitleParts.length > 0 ? ( - <> - {props.environmentLabel && props.environmentMachine ? ( - - ) : null} - - {subtitleParts.join(" · ")} - - - ) : null} - {pr !== null ? ( - - {pr.kind === "stack" ? ( - - ) : ( - - )} - - {pr.label} - - - ) : null} - - ) : null; - - const rowContent = (close: () => void) => - compact ? ( - { - close(); - onSelectThread(thread); - }} - > - - - - - {thread.title} - - - {props.hasQueuedMessages ? ( - - ) : null} - {statusPill} - - {timestamp} - - - - - {props.searchMatch ? ( - - ) : null} - {subtitleRow} - - - - ) : ( - { - close(); - onSelectThread(thread); - }} - style={{ - backgroundColor: visuallySelected ? selectedBackgroundColor : backgroundColor, - borderRadius: Platform.OS === "android" ? 20 : SIDEBAR_ROW_RADIUS, - minHeight: 64, - justifyContent: "center", - paddingHorizontal: 12, - paddingVertical: 10, - }} - > - - - - {thread.title} - - - {props.hasQueuedMessages ? ( - - ) : null} - {statusPill} - - {timestamp} - - - - {props.searchMatch ? ( - - ) : null} - {subtitleRow} - - - ); - - return ( - - {(close) => ( - // Messages-style row actions on long-press. iOS: a real - // UIContextMenuInteraction with the row as the zoom preview (needs the - // patched @react-native-menu, see - // patches/@react-native-menu__menu@2.0.0.patch — in long-press mode the - // interaction is hosted by the component view and the underlying - // UIButton passes touches through, so row taps keep working). Android: - // ControlPillMenu injects onLongPress into the row and anchors the - // token-styled dropdown to it; taps and swipes are untouched. - - {rowContent(close)} - - )} - - ); -}); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 797781d16a4c..43c5de09c726 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -18,7 +18,6 @@ import type { import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; import type { EnvironmentMachineKind } from "@t3tools/contracts"; import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; -import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; import type { MenuAction } from "@react-native-menu/menu"; import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; import { Alert, Pressable, useWindowDimensions, View } from "react-native"; @@ -33,7 +32,6 @@ import { ProviderInstanceIcon } from "../../components/ProviderIcon"; import type { ThreadRowProviderInstance } from "./thread-provider-instance"; import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; -import { relativeTime } from "../../lib/time"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; @@ -69,10 +67,6 @@ const STATUS_LABEL_BY_STATUS: Partial< failed: { label: "Failed", className: "text-danger-foreground" }, }; -function threadTimeLabel(thread: EnvironmentThreadShell): string { - return relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt); -} - // Menus keep lifecycle and title regeneration together. Archive keeps its // own surface (thread screen / settings) rather than crowding v2 rows. const CARD_MENU_ACTIONS: MenuAction[] = [ @@ -453,8 +447,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { /** Preformatted against the parent minute tick so this memoized row's countdown keeps moving. */ readonly snoozeWakeLabelText?: string; - /** Parent minute tick passed as a prop so this memoized row refreshes its - native snooze menu while mounted. */ + /** Preformatted against the parent clock (row order timestamp: settle stamp + on settled rows, latest activity otherwise). Blank while a status label + or the wake countdown owns that slot. Precomputed per row — not via the + list's extraData — so the minute tick re-renders only rows whose + displayed text moved. */ + readonly timeLabel: string; + /** Parent minute tick carried on the row's list item, present only when the + row's menu offers snooze presets, so those menus refresh while mounted + without invalidating every other row. */ readonly snoozePresetMinute: string; readonly project: EnvironmentProject | null; readonly projectTitle?: string; @@ -549,13 +550,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const status = resolveThreadListV2Status(thread); const statusLabel = STATUS_LABEL_BY_STATUS[status]; - // Settled rows label by the same stamp they sort by, so order and label - // can't disagree. updatedAt is always present, so the resolver never - // returns null here. - const settledTimestamp = - variant === "slim" && !snoozedRow ? resolveSettledThreadTimestamp(thread) : null; - const timeLabel = - settledTimestamp !== null ? relativeTime(settledTimestamp) : threadTimeLabel(thread); + // The timestamp is precomputed on the list item (same stamps the settled + // tail sorts by) so a minute tick only re-renders rows that draw it. + const timeLabel = props.timeLabel; const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]); @@ -565,6 +562,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ); const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]); const [customSnoozeOpen, setCustomSnoozeOpen] = useState(false); + // A recycled cell reassigns this mounted row to a different thread without + // remounting it, and the render closure stops running while list equality + // says the item is unchanged — so any row-local UI state must be dismissed + // when the identity under it changes. Without this, a custom snooze sheet + // opened for one thread survives the thread's removal/reorder and its + // submit snoozes whichever thread the cell was reassigned to. (ThreadSwipeable + // enforces the same contract on the swipe layer with its resetKey.) + const rowIdentity = `${thread.environmentId}:${thread.id}`; + const [boundIdentity, setBoundIdentity] = useState(rowIdentity); + if (boundIdentity !== rowIdentity) { + setBoundIdentity(rowIdentity); + setCustomSnoozeOpen(false); + } const handleSnooze = useCallback( (snoozedUntil: string) => onSnoozeThread(thread, snoozedUntil), [onSnoozeThread, thread], @@ -758,6 +768,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { handleUnsettle, handleUnsnooze, snoozePresets, + setCustomSnoozeOpen, ], ); const primaryAction = useMemo(() => { diff --git a/apps/mobile/src/features/threads/thread-provider-instance.test.ts b/apps/mobile/src/features/threads/thread-provider-instance.test.ts index 2afef9759070..4912bf2b61ba 100644 --- a/apps/mobile/src/features/threads/thread-provider-instance.test.ts +++ b/apps/mobile/src/features/threads/thread-provider-instance.test.ts @@ -8,7 +8,10 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { resolveThreadProviderInstance } from "./thread-provider-instance"; +import { + createThreadRowProviderInstanceResolver, + resolveThreadProviderInstance, +} from "./thread-provider-instance"; function makeConfig( providers: ReadonlyArray<{ @@ -96,3 +99,53 @@ describe("resolveThreadProviderInstance", () => { expect(resolveThreadProviderInstance(serverConfigs, thread)?.showBadge).toBe(false); }); }); + +describe("createThreadRowProviderInstanceResolver", () => { + const environmentId = EnvironmentId.make("environment-a"); + const serverConfigs = new Map([ + [ + environmentId, + makeConfig([ + { instanceId: "codex", driver: "codex", displayName: "Codex" }, + { instanceId: "codex_work", driver: "codex", displayName: "Codex" }, + ]), + ], + ]); + + it("hands out the same reference for repeated lookups of one instance", () => { + const resolve = createThreadRowProviderInstanceResolver(serverConfigs); + const first = resolve(makeThread(environmentId, "codex")); + const second = resolve(makeThread(environmentId, "codex")); + // Memoized rows compare props by reference: a fresh object per call would + // re-render every row on every parent render (minute tick included). + expect(first).not.toBeNull(); + expect(second).toBe(first); + }); + + it("distinguishes instances of the same driver", () => { + const resolve = createThreadRowProviderInstanceResolver(serverConfigs); + const personal = resolve(makeThread(environmentId, "codex")); + const work = resolve(makeThread(environmentId, "codex_work")); + expect(personal).not.toBeNull(); + expect(work).not.toBeNull(); + expect(work).not.toBe(personal); + expect(work?.displayName).toBe("Codex Work"); + }); + + it("hands out a new identity when the server-config generation changes", () => { + const before = createThreadRowProviderInstanceResolver(serverConfigs); + const nextConfigs = new Map([ + [environmentId, makeConfig([{ instanceId: "codex", driver: "codex" }])], + ]); + const after = createThreadRowProviderInstanceResolver(nextConfigs); + expect(after(makeThread(environmentId, "codex"))).not.toBe( + before(makeThread(environmentId, "codex")), + ); + }); + + it("resolves unknown instances to null without throwing", () => { + const resolve = createThreadRowProviderInstanceResolver(serverConfigs); + expect(resolve(makeThread(environmentId, "ghost"))).toBeNull(); + expect(resolve(makeThread(environmentId, "ghost"))).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-provider-instance.ts b/apps/mobile/src/features/threads/thread-provider-instance.ts index 29dbe8828b66..cd07f636820c 100644 --- a/apps/mobile/src/features/threads/thread-provider-instance.ts +++ b/apps/mobile/src/features/threads/thread-provider-instance.ts @@ -1,3 +1,5 @@ +import { useMemo } from "react"; + import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { normalizeProviderAccentColor, @@ -40,3 +42,33 @@ export function resolveThreadProviderInstance( ), }; } + +/** + * Builds a resolver handing out reference-stable `ThreadRowProviderInstance` + * objects. `resolveThreadProviderInstance` builds a fresh object per call, + * which breaks the memoized row's props comparison on every parent render — + * the result only depends on (environment, instance id), so one cache per + * server-config generation keeps each row's `providerInstance` prop stable + * until the instance behind the row actually changes. + */ +export function createThreadRowProviderInstanceResolver( + serverConfigs: ReadonlyMap, +): (thread: EnvironmentThreadShell) => ThreadRowProviderInstance | null { + const cache = new Map(); + return (thread) => { + const instanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const cacheKey = `${thread.environmentId}|${instanceId ?? ""}`; + const cached = cache.get(cacheKey); + if (cached !== undefined) return cached; + const resolved = resolveThreadProviderInstance(serverConfigs, thread); + cache.set(cacheKey, resolved); + return resolved; + }; +} + +/** List-scoped wrapper: one cache per server-config generation. */ +export function useThreadRowProviderInstanceResolver( + serverConfigs: ReadonlyMap, +): (thread: EnvironmentThreadShell) => ThreadRowProviderInstance | null { + return useMemo(() => createThreadRowProviderInstanceResolver(serverConfigs), [serverConfigs]); +} diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index afc00ef9ab40..4bbcf2233a42 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -6,6 +6,7 @@ import { threadDropLifecycle, reconcilePendingThreadOrder, type PendingThreadOrder, + type ThreadMoveAvailability, } from "./threadOrder"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; @@ -19,7 +20,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts"; @@ -27,12 +28,14 @@ import { buildThreadListV2Items, buildThreadListV2ListItems, getThreadListV2OrderedSection, - resolveThreadListV2Enabled, + isThreadListV2ListItem, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, resolveThreadListV2SwipeActions, sortThreadsForListV2, + threadListV2ListItemsAreEqual, + type ThreadListV2ListItem, } from "./threadListV2"; const environmentId = EnvironmentId.make("environment-1"); @@ -120,29 +123,6 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => { }); }); -describe("resolveThreadListV2Enabled", () => { - it("defaults on when the device has never chosen", () => { - expect( - resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: true }), - ).toBe(true); - }); - - it("honors an explicit legacy opt-in", () => { - expect(resolveThreadListV2Enabled({ legacyPreference: true, preferencesLoaded: true })).toBe( - false, - ); - expect(resolveThreadListV2Enabled({ legacyPreference: false, preferencesLoaded: true })).toBe( - true, - ); - }); - - it("holds the default while preferences are still loading so the list does not remount", () => { - expect( - resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: false }), - ).toBe(true); - }); -}); - describe("resolveThreadListV2Status", () => { it("prioritizes approval over a running session", () => { const thread = makeThread({ @@ -1482,3 +1462,551 @@ describe("cross-section thread drops", () => { ).toEqual({ pin: false, unpin: false, unsettle: false, unsnooze: false }); }); }); + +/* ─── Recycled-list equality + per-row clock scoping ─────────────────── */ + +const BASE_MS = Date.parse(NOW); +const isoAt = (ms: number) => new Date(ms).toISOString(); +const MINUTE_MS = 60_000; + +function runningSession(threadId: string) { + return { + threadId: ThreadId.make(threadId), + status: "running" as const, + providerName: "Codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access" as const, + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }; +} + +function buildTickThreads() { + return { + ready: makeThread({ + id: ThreadId.make("tick-ready"), + title: "tick ready", + latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS), + }), + approval: makeThread({ + id: ThreadId.make("tick-approval"), + title: "tick approval", + hasPendingApprovals: true, + latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS), + }), + settled: makeThread({ + id: ThreadId.make("tick-settled"), + title: "tick settled", + settledOverride: "settled", + settledAt: isoAt(BASE_MS - 3 * 24 * 60 * MINUTE_MS), + }), + snoozed: makeThread({ + id: ThreadId.make("tick-snoozed"), + title: "tick snoozed", + snoozedAt: isoAt(BASE_MS - MINUTE_MS), + snoozedUntil: isoAt(BASE_MS + 2 * 60 * MINUTE_MS), + }), + }; +} + +function buildTickList( + threads: ReadonlyArray, + clockMs: number, + pendingTasks: ReadonlyArray, + options?: { + readonly snoozeEnvironmentIds?: ReadonlySet; + readonly queuedThreadKeys?: ReadonlySet; + readonly moveAvailability?: ReadonlyMap; + readonly shelfPreferencesLoading?: boolean; + }, +): ThreadListV2ListItem[] { + const now = isoAt(clockMs); + const layout = buildThreadListV2Items({ + threads, + environmentId: null, + searchQuery: "", + now, + snoozedShelfExpanded: true, + }); + return buildThreadListV2ListItems({ + items: layout.items, + pendingTasks, + snoozedCount: layout.snoozedCount, + snoozedShelfExpanded: true, + snoozedShelfHeaderIndex: layout.snoozedShelfHeaderIndex, + settledCount: layout.settledCount, + settledShelfHeaderIndex: layout.settledShelfHeaderIndex, + snoozeLabelNow: now, + ...(options?.snoozeEnvironmentIds + ? { snoozeEnvironmentIds: options.snoozeEnvironmentIds } + : {}), + ...(options?.queuedThreadKeys ? { queuedThreadKeys: options.queuedThreadKeys } : {}), + ...(options?.moveAvailability ? { moveAvailability: options.moveAvailability } : {}), + ...(options?.shelfPreferencesLoading !== undefined + ? { shelfPreferencesLoading: options.shelfPreferencesLoading } + : {}), + }); +} + +function itemsByThreadKey(items: ReadonlyArray) { + const byKey = new Map(); + for (const item of items) byKey.set(item.key, item); + return byKey; +} + +describe("threadListV2ListItemsAreEqual", () => { + const thread = makeThread({ + id: ThreadId.make("eq"), + title: "eq", + latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS), + }); + const layout = buildThreadListV2Items({ + threads: [thread], + environmentId: null, + searchQuery: "", + now: NOW, + }); + // One queued task object shared across builds: identity, not content, is + // what the row equality compares (mirrors the store's stable references). + const queued = makePendingTask("eq-queued"); + const build = () => + buildThreadListV2ListItems({ + items: layout.items, + pendingTasks: [queued], + snoozeLabelNow: NOW, + }); + + it("treats rebuilt wrappers over identical rows as equal", () => { + const first = build(); + const second = build(); + expect(first.length).toBe(second.length); + for (let index = 0; index < first.length; index += 1) { + expect(first[index]).not.toBe(second[index]); + expect(threadListV2ListItemsAreEqual(first[index]!, second[index]!)).toBe(true); + } + }); + + it("notices a replaced thread shell", () => { + const replacement = makeThread({ id: ThreadId.make("eq"), title: "renamed" }); + const rebuilt = buildThreadListV2Items({ + threads: [replacement], + environmentId: null, + searchQuery: "", + now: NOW, + }); + const next = buildThreadListV2ListItems({ + items: rebuilt.items, + pendingTasks: [], + snoozeLabelNow: NOW, + }); + const previousThread = build().find((item) => item.type === "v2-thread")!; + const nextThread = next.find((item) => item.type === "v2-thread")!; + expect(threadListV2ListItemsAreEqual(previousThread, nextThread)).toBe(false); + }); + + it("notices a changed wake countdown label", () => { + const snoozedLayout = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("wake"), + title: "wake", + snoozedAt: NOW, + snoozedUntil: isoAt(BASE_MS + 61 * MINUTE_MS), + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + snoozedShelfExpanded: true, + }); + const earlier = buildThreadListV2ListItems({ + items: snoozedLayout.items, + pendingTasks: [], + snoozedShelfExpanded: true, + snoozeLabelNow: NOW, + }); + const later = buildThreadListV2ListItems({ + items: snoozedLayout.items, + pendingTasks: [], + snoozedShelfExpanded: true, + snoozeLabelNow: isoAt(BASE_MS + MINUTE_MS), + }); + const rows = (items: ThreadListV2ListItem[]) => { + const row = items.find((item) => item.key === `v2-thread:${environmentId}:wake`); + expect(row?.type).toBe("v2-thread"); + if (row?.type !== "v2-thread") throw new Error("missing snoozed row"); + return row; + }; + expect(rows(earlier).snoozeWakeLabelText).toBe("2h"); + expect(rows(later).snoozeWakeLabelText).toBe("1h"); + expect(threadListV2ListItemsAreEqual(rows(earlier), rows(later))).toBe(false); + }); + + it("notices shelf count, expansion, and loading-disabled changes", () => { + const shelf = { + type: "v2-settled-shelf", + key: "v2-settled-shelf", + count: 2, + expanded: true, + disabled: false, + } as const; + expect(threadListV2ListItemsAreEqual(shelf, { ...shelf })).toBe(true); + expect(threadListV2ListItemsAreEqual(shelf, { ...shelf, count: 3 })).toBe(false); + expect(threadListV2ListItemsAreEqual(shelf, { ...shelf, expanded: false })).toBe(false); + // A recycled cell ignores the render closure, so the shelf header's + // preference-loading disabled state has to ride on the item too. + expect(threadListV2ListItemsAreEqual(shelf, { ...shelf, disabled: true })).toBe(false); + }); + + it("treats different item kinds as unequal", () => { + const built = build(); + const threadItem = built.find((item) => item.type === "v2-thread")!; + const pendingItem = built.find((item) => item.type === "v2-pending")!; + expect(threadListV2ListItemsAreEqual(threadItem, pendingItem)).toBe(false); + }); + + it("notices a trailing-divider flip caused by a neighbour change", () => { + const threadA = makeThread({ id: ThreadId.make("flip-a"), title: "flip a" }); + const threadB = makeThread({ id: ThreadId.make("flip-b"), title: "flip b" }); + const bare = buildThreadListV2ListItems({ + items: buildThreadListV2Items({ + threads: [threadA, threadB], + environmentId: null, + searchQuery: "", + now: NOW, + }).items, + pendingTasks: [], + snoozeLabelNow: NOW, + }); + // The same shells with B settled: A now sits above the Settled section + // rule instead of another row, so A's hairline must flip through the + // recycled equality — its own shell reference never changed. + const settledB = makeThread({ + id: ThreadId.make("flip-b"), + title: "flip b", + settledOverride: "settled", + settledAt: NOW, + }); + const withSettled = buildThreadListV2ListItems({ + items: buildThreadListV2Items({ + threads: [threadA, settledB], + environmentId: null, + searchQuery: "", + now: NOW, + }).items, + pendingTasks: [], + settledCount: 1, + settledShelfHeaderIndex: 1, + snoozeLabelNow: NOW, + }); + const firstA = bare[0]!; + const secondA = withSettled[0]!; + expect(firstA.type === "v2-thread" && firstA.showTrailingDivider).toBe(true); + expect(secondA.type === "v2-thread" && secondA.showTrailingDivider).toBe(false); + expect(threadListV2ListItemsAreEqual(firstA, secondA)).toBe(false); + }); +}); + +describe("isThreadListV2ListItem", () => { + it("narrows the v2 kinds and rejects the legacy discriminators", () => { + expect(isThreadListV2ListItem({ type: "v2-thread" })).toBe(true); + expect(isThreadListV2ListItem({ type: "v2-pending" })).toBe(true); + expect(isThreadListV2ListItem({ type: "v2-snoozed-shelf" })).toBe(true); + expect(isThreadListV2ListItem({ type: "v2-settled-shelf" })).toBe(true); + expect(isThreadListV2ListItem({ type: "thread" })).toBe(false); + expect(isThreadListV2ListItem({ type: "v2-show-more" })).toBe(false); + }); +}); + +describe("buildThreadListV2ListItems clock scoping", () => { + const allEnvironments = new Set([environmentId]); + const tickQueued = () => [makePendingTask("tick-queued")]; + + it("carries the snooze menu clock on every row whose swipe menu offers presets", () => { + const threads = buildTickThreads(); + const items = buildTickList(Object.values(threads), BASE_MS, tickQueued(), { + snoozeEnvironmentIds: allEnvironments, + }); + const byKey = itemsByThreadKey(items); + const ready = byKey.get(`v2-thread:${environmentId}:tick-ready`)!; + const approval = byKey.get(`v2-thread:${environmentId}:tick-approval`)!; + const settled = byKey.get(`v2-thread:${environmentId}:tick-settled`)!; + const snoozed = byKey.get(`v2-thread:${environmentId}:tick-snoozed`)!; + expect(ready.type === "v2-thread" && ready.snoozePresetMinute).toBe(NOW); + // The swipe-revealed snooze action exists on slim rows too (the variant + // only swaps the primary action), so settled rows need the fresh clock. + expect(settled.type === "v2-thread" && settled.snoozePresetMinute).toBe(NOW); + // Approval rows are never snoozable; snoozed rows only offer Wake. + expect(approval.type === "v2-thread" && approval.snoozePresetMinute).toBeUndefined(); + expect(snoozed.type === "v2-thread" && snoozed.snoozePresetMinute).toBeUndefined(); + }); + + it("keeps the snooze menu clock off rows on servers without the capability", () => { + const threads = buildTickThreads(); + const items = buildTickList([threads.ready], BASE_MS, [], { + snoozeEnvironmentIds: new Set(), + }); + const ready = items.find((item) => item.type === "v2-thread")!; + expect(ready.type === "v2-thread" && ready.snoozePresetMinute).toBeUndefined(); + }); + + it("blanks the precomputed time for rows that render a label instead", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(BASE_MS); + const working = makeThread({ + id: ThreadId.make("tick-working"), + title: "tick working", + latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS), + session: runningSession("tick-working"), + }); + const threads = buildTickThreads(); + const items = buildTickList( + [threads.ready, working, threads.settled, threads.snoozed], + BASE_MS, + tickQueued(), + ); + const byKey = itemsByThreadKey(items); + const label = (key: string) => { + const item = byKey.get(key)!; + return item.type === "v2-thread" ? item.timeLabel : ""; + }; + // Ready cards and settled/snoozed slim rows draw a time; the wake + // countdown outranks the time on snoozed rows; cards with a status + // label never draw one. + expect(label(`v2-thread:${environmentId}:tick-ready`)).toBe("5m"); + expect(label(`v2-thread:${environmentId}:tick-working`)).toBe(""); + expect(label(`v2-thread:${environmentId}:tick-settled`)).toBe("3d"); + expect(label(`v2-thread:${environmentId}:tick-snoozed`)).toBe(""); + const snoozed = byKey.get(`v2-thread:${environmentId}:tick-snoozed`)!; + expect(snoozed.type === "v2-thread" && snoozed.snoozeWakeLabelText).toBe("2h"); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("thread list v2 minute tick invalidation", () => { + it("only invalidates rows whose clock-driven content moved", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(BASE_MS); + const threads = buildTickThreads(); + const shellOrder = [threads.ready, threads.approval, threads.settled, threads.snoozed]; + // One queued-task reference shared by both builds: the store hands the + // list the same pending-task objects between rebuilds. + const pendingTasks = [makePendingTask("tick-queued")]; + const atStart = buildTickList(shellOrder, BASE_MS, pendingTasks); + vi.setSystemTime(BASE_MS + MINUTE_MS); + const atNextMinute = buildTickList(shellOrder, BASE_MS + MINUTE_MS, pendingTasks); + + expect(atStart.length).toBe(atNextMinute.length); + const invalidated: string[] = []; + for (let index = 0; index < atStart.length; index += 1) { + if (!threadListV2ListItemsAreEqual(atStart[index]!, atNextMinute[index]!)) { + invalidated.push(atStart[index]!.key); + } + } + // The ready row draws a minute-granular time and carries the snooze + // menu, and the settled slim row's swipe-revealed snooze menu shows + // preset times too, so both rows' menu content moved. Every other row + // — the approval card (status label, never snoozable), the snoozed + // shelf row ("2h" unchanged, Wake only), the shelf headers, and the + // queued row — survives the tick untouched. + expect(invalidated).toEqual([ + `v2-thread:${environmentId}:tick-ready`, + `v2-thread:${environmentId}:tick-settled`, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps hour-granularity rows stable across a minute tick when they carry no snooze menu", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(BASE_MS); + const staleReady = makeThread({ + id: ThreadId.make("tick-stale"), + title: "tick stale", + latestUserMessageAt: isoAt(BASE_MS - 3 * 60 * MINUTE_MS), + }); + const atStart = buildTickList([staleReady], BASE_MS, [], { + snoozeEnvironmentIds: new Set(), + }); + vi.setSystemTime(BASE_MS + MINUTE_MS); + const atNextMinute = buildTickList([staleReady], BASE_MS + MINUTE_MS, [], { + snoozeEnvironmentIds: new Set(), + }); + expect(threadListV2ListItemsAreEqual(atStart[0]!, atNextMinute[0]!)).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("re-renders the snoozed countdown row when the wake label advances", () => { + vi.useFakeTimers(); + try { + const wakeAt = BASE_MS + 120 * MINUTE_MS; + const snoozed = makeThread({ + id: ThreadId.make("tick-wake"), + title: "tick wake", + snoozedAt: isoAt(BASE_MS - MINUTE_MS), + snoozedUntil: isoAt(wakeAt), + }); + const wakeKey = `v2-thread:${environmentId}:tick-wake`; + const wakeRow = (items: ThreadListV2ListItem[]) => itemsByThreadKey(items).get(wakeKey)!; + vi.setSystemTime(BASE_MS); + const atStart = buildTickList([snoozed], BASE_MS, []); + vi.setSystemTime(BASE_MS + MINUTE_MS); + const stillTwoHours = buildTickList([snoozed], BASE_MS + MINUTE_MS, []); + expect(threadListV2ListItemsAreEqual(wakeRow(atStart), wakeRow(stillTwoHours))).toBe(true); + // Minutes round up, so the countdown holds "2h" until the remaining + // time drops to the hour boundary — and only that row flips when it + // finally moves to minute granularity. + vi.setSystemTime(BASE_MS + 61 * MINUTE_MS); + const oneHour = buildTickList([snoozed], BASE_MS + 61 * MINUTE_MS, []); + const oneHourRow = wakeRow(oneHour); + expect(oneHourRow.type).toBe("v2-thread"); + expect(oneHourRow.type === "v2-thread" && oneHourRow.snoozeWakeLabelText).toBe("59m"); + expect(threadListV2ListItemsAreEqual(wakeRow(stillTwoHours), wakeRow(oneHour))).toBe(false); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("buildThreadListV2ListItems trailing dividers", () => { + it("follows the final neighbour order, not the pre-splice blocks", () => { + const activeA = makeThread({ id: ThreadId.make("div-a"), title: "a" }); + const activeB = makeThread({ id: ThreadId.make("div-b"), title: "b" }); + const layout = buildThreadListV2Items({ + threads: [activeA, activeB], + environmentId: null, + searchQuery: "", + now: NOW, + }); + const items = buildThreadListV2ListItems({ + items: layout.items, + pendingTasks: [makePendingTask("div-q1"), makePendingTask("div-q2")], + snoozeLabelNow: NOW, + }); + const dividers = items.map((item) => + item.type === "v2-thread" || item.type === "v2-pending" ? item.showTrailingDivider : "n/a", + ); + // thread A | thread B | queued 1 | queued 2: consecutive threads keep + // their hairlines, the row before the Unsent section rule loses its own, + // queued rows divide each other, and the last row has nothing under it. + expect(dividers).toEqual([true, false, true, false]); + }); +}); + +describe("buildThreadListV2ListItems row-state stamps", () => { + const readyThread = makeThread({ + id: ThreadId.make("stamp-ready"), + title: "stamp ready", + latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS), + }); + const settledThread = makeThread({ + id: ThreadId.make("stamp-settled"), + title: "stamp settled", + settledOverride: "settled", + settledAt: isoAt(BASE_MS - 3 * 24 * 60 * MINUTE_MS), + }); + const allEnvironments = new Set([environmentId]); + + it("stamps queued outbox messages onto the matching row and notices removal", () => { + // An outbox write never touches the thread shell, so the queued icon has + // to ride on the item for the recycled cell to ever update it. + const queued = buildTickList([readyThread, settledThread], BASE_MS, [], { + queuedThreadKeys: new Set([`${environmentId}:stamp-ready`]), + snoozeEnvironmentIds: allEnvironments, + }); + const plain = buildTickList([readyThread, settledThread], BASE_MS, [], { + queuedThreadKeys: new Set(), + snoozeEnvironmentIds: allEnvironments, + }); + const byKey = (items: ThreadListV2ListItem[]) => itemsByThreadKey(items); + const readyQueued = byKey(queued).get(`v2-thread:${environmentId}:stamp-ready`)!; + const readyPlain = byKey(plain).get(`v2-thread:${environmentId}:stamp-ready`)!; + const settledQueued = byKey(queued).get(`v2-thread:${environmentId}:stamp-settled`)!; + const settledPlain = byKey(plain).get(`v2-thread:${environmentId}:stamp-settled`)!; + expect(readyQueued.type === "v2-thread" && readyQueued.hasQueuedMessages).toBe(true); + expect(readyPlain.type === "v2-thread" && readyPlain.hasQueuedMessages).toBe(false); + expect(threadListV2ListItemsAreEqual(readyQueued, readyPlain)).toBe(false); + // The neighbour row is untouched by the outbox change. + expect(threadListV2ListItemsAreEqual(settledQueued, settledPlain)).toBe(true); + }); + + it("notices move-availability changes on card rows without a shell update", () => { + const permissive = new Map([ + [`${environmentId}:stamp-ready`, { canMoveUp: true, canMoveDown: true }], + [`${environmentId}:stamp-settled`, { canMoveUp: true, canMoveDown: true }], + ]); + const blocked = new Map([ + [`${environmentId}:stamp-settled`, { canMoveUp: true, canMoveDown: true }], + ]); + const open = buildTickList([readyThread, settledThread], BASE_MS, [], { + moveAvailability: permissive, + snoozeEnvironmentIds: allEnvironments, + }); + const closed = buildTickList([readyThread, settledThread], BASE_MS, [], { + moveAvailability: blocked, + snoozeEnvironmentIds: allEnvironments, + }); + const readyOpen = itemsByThreadKey(open).get(`v2-thread:${environmentId}:stamp-ready`)!; + const readyClosed = itemsByThreadKey(closed).get(`v2-thread:${environmentId}:stamp-ready`)!; + expect(readyOpen.type === "v2-thread" && readyOpen.canMoveUp).toBe(true); + expect(readyClosed.type === "v2-thread" && readyClosed.canMoveUp).toBe(false); + expect(threadListV2ListItemsAreEqual(readyOpen, readyClosed)).toBe(false); + // Slim rows never carry the move actions, so availability is inert there. + const settledOpen = itemsByThreadKey(open).get(`v2-thread:${environmentId}:stamp-settled`)!; + const settledClosed = itemsByThreadKey(closed).get(`v2-thread:${environmentId}:stamp-settled`)!; + expect(settledOpen.type === "v2-thread" && settledOpen.canMoveUp).toBe(false); + expect(threadListV2ListItemsAreEqual(settledOpen, settledClosed)).toBe(true); + }); + + it("keeps the settled slim row's swipe snooze menu fresh across a tick", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(BASE_MS); + const atStart = buildTickList([settledThread], BASE_MS, [], { + snoozeEnvironmentIds: allEnvironments, + }); + vi.setSystemTime(BASE_MS + MINUTE_MS); + const atNextMinute = buildTickList([settledThread], BASE_MS + MINUTE_MS, [], { + snoozeEnvironmentIds: allEnvironments, + }); + const rowAtStart = atStart.find((item) => item.type === "v2-thread")!; + const rowAtNext = atNextMinute.find((item) => item.type === "v2-thread")!; + // The swipe-revealed secondary action carries the snooze preset menu on + // slim rows; its minute clock must move, or the displayed wake times + // drift while the row is recycled-stable. + expect(rowAtStart.type === "v2-thread" && rowAtStart.item.variant).toBe("slim"); + expect(rowAtStart.type === "v2-thread" && rowAtStart.snoozePresetMinute).toBe(isoAt(BASE_MS)); + expect(rowAtNext.type === "v2-thread" && rowAtNext.snoozePresetMinute).toBe( + isoAt(BASE_MS + MINUTE_MS), + ); + expect(threadListV2ListItemsAreEqual(rowAtStart, rowAtNext)).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("stamps the shelf loading-disabled state so recycled headers refresh", () => { + const loading = buildTickList([settledThread], BASE_MS, [], { + shelfPreferencesLoading: true, + snoozeEnvironmentIds: allEnvironments, + }); + const loaded = buildTickList([settledThread], BASE_MS, [], { + shelfPreferencesLoading: false, + snoozeEnvironmentIds: allEnvironments, + }); + const shelfLoading = loading.find((item) => item.type === "v2-settled-shelf")!; + const shelfLoaded = loaded.find((item) => item.type === "v2-settled-shelf")!; + expect(shelfLoading.type === "v2-settled-shelf" && shelfLoading.disabled).toBe(true); + expect(shelfLoaded.type === "v2-settled-shelf" && shelfLoaded.disabled).toBe(false); + expect(threadListV2ListItemsAreEqual(shelfLoading, shelfLoaded)).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index e1cb8e9ece7f..9a876c899ac4 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -1,5 +1,6 @@ import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import { + canSnooze, effectiveSnoozed, hasQueuedTurnStart, QUEUED_TURN_START_GRACE_MS, @@ -16,6 +17,9 @@ import { } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import type { ThreadMoveAvailability } from "./threadOrder"; + +import { relativeTime } from "../../lib/time"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { @@ -112,26 +116,6 @@ export function resolveThreadListV2SnoozeGateExpiryMs( export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; -/** - * The flat Thread List v2 is the default on every app variant; the Settings → - * Legacy toggle opts a device back into the grouped legacy list. Preferences - * persist as sparse patches, so `undefined` genuinely means "never chosen". - * - * `preferencesLoaded` guards the startup window: preferences load - * asynchronously, and rendering one list before the stored choice arrives would - * remount the whole thing a tick later. While loading, hold the default — that - * is where every device without an explicit legacy opt-in lands anyway. - */ -export function resolveThreadListV2Enabled(input: { - readonly legacyPreference: boolean | undefined; - readonly preferencesLoaded: boolean; -}): boolean { - if (!input.preferencesLoaded) { - return true; - } - return input.legacyPreference !== true; -} - export function resolveThreadListV2Status( thread: Pick, ): ThreadListV2Status { @@ -244,6 +228,28 @@ export interface ThreadListV2ThreadListItem { readonly item: ThreadListV2Item; /** Precomputed so recycled-list equality can see a minute-tick change. */ readonly snoozeWakeLabelText: string | undefined; + /** Row timestamp precomputed against the parent clock so the recycler's + equality only sees a change on rows that actually draw a time. Blank + while the row renders a status label or the wake countdown instead. */ + readonly timeLabel: string; + /** Minute clock feeding the row's native snooze menu, carried only on rows + whose menu holds snooze presets (capability-gated cards the user may + snooze). Keeping it out of the list's `extraData` means the minute tick + re-renders just these rows instead of every visible one. */ + readonly snoozePresetMinute: string | undefined; + /** Inset hairline drawn under the row. Precomputed from the final order so + a neighbour change (e.g. the queued block appearing) updates the row + through recycled-list equality instead of leaving a stale divider. */ + readonly showTrailingDivider: boolean; + /** A message for this thread is waiting in the outbox. Carried on the item + so an outbox write (which never touches the thread shell) reaches the + row through recycled-list equality instead of leaving a stale icon. */ + readonly hasQueuedMessages: boolean; + /** Move up/down availability in the row's card section. Carried on the item + for the same reason: a reorder in flight (or its commit) changes menu + availability without changing any shell. */ + readonly canMoveUp: boolean; + readonly canMoveDown: boolean; } export interface ThreadListV2PendingListItem { @@ -252,6 +258,9 @@ export interface ThreadListV2PendingListItem { readonly pendingTask: PendingNewTask; /** First queued row after the active block draws the PENDING divider. */ readonly showPendingDivider: boolean; + /** Same rule as the thread rows: a hairline unless the next row carries its + own section rule or none follows. */ + readonly showTrailingDivider: boolean; } export interface ThreadListV2SnoozedShelfListItem { @@ -259,6 +268,11 @@ export interface ThreadListV2SnoozedShelfListItem { readonly key: "v2-snoozed-shelf"; readonly count: number; readonly expanded: boolean; + /** Shelf preferences still loading: the toggle is disabled until they + arrive. Carried on the item because a recycled cell ignores the render + closure — without it the header would stay visibly disabled (or enabled + too early) after the preference load lands. */ + readonly disabled: boolean; } export interface ThreadListV2SettledShelfListItem { @@ -266,6 +280,8 @@ export interface ThreadListV2SettledShelfListItem { readonly key: "v2-settled-shelf"; readonly count: number; readonly expanded: boolean; + /** See the snoozed shelf header's field. */ + readonly disabled: boolean; } export type ThreadListV2ListItem = @@ -274,6 +290,90 @@ export type ThreadListV2ListItem = | ThreadListV2SnoozedShelfListItem | ThreadListV2SettledShelfListItem; +/** Narrows a wider list-item union (e.g. the sidebar's legacy + v2 mix) to + the v2 item kinds the shared equality understands. */ +export function isThreadListV2ListItem(value: { + readonly type: string; +}): value is ThreadListV2ListItem { + return ( + value.type === "v2-thread" || + value.type === "v2-pending" || + value.type === "v2-snoozed-shelf" || + value.type === "v2-settled-shelf" + ); +} + +/** Recycled-list equality for the flat v2 list (Home + iPad sidebar). + Item objects are rebuilt on every minute tick and every partition run; + without this the lists would consider every mounted row changed and + re-render all of them (each carrying a swipeable + a PR subscription). + The clock-derived fields are per-row precomputed text, so a minute tick + only flips equality on rows whose visible text (or snooze menu) actually + moved. Thread references are stable across rebuilds. */ +export function threadListV2ListItemsAreEqual( + previous: ThreadListV2ListItem, + item: ThreadListV2ListItem, +): boolean { + switch (item.type) { + case "v2-thread": + return ( + previous.type === "v2-thread" && + previous.key === item.key && + previous.item.thread === item.item.thread && + previous.item.variant === item.item.variant && + previous.item.snoozed === item.item.snoozed && + previous.item.pinned === item.item.pinned && + previous.snoozeWakeLabelText === item.snoozeWakeLabelText && + previous.timeLabel === item.timeLabel && + previous.snoozePresetMinute === item.snoozePresetMinute && + previous.showTrailingDivider === item.showTrailingDivider && + previous.hasQueuedMessages === item.hasQueuedMessages && + previous.canMoveUp === item.canMoveUp && + previous.canMoveDown === item.canMoveDown + ); + case "v2-pending": + return ( + previous.type === "v2-pending" && + previous.key === item.key && + previous.pendingTask === item.pendingTask && + previous.showPendingDivider === item.showPendingDivider && + previous.showTrailingDivider === item.showTrailingDivider + ); + case "v2-snoozed-shelf": + return ( + previous.type === "v2-snoozed-shelf" && + previous.count === item.count && + previous.expanded === item.expanded && + previous.disabled === item.disabled + ); + case "v2-settled-shelf": + return ( + previous.type === "v2-settled-shelf" && + previous.count === item.count && + previous.expanded === item.expanded && + previous.disabled === item.disabled + ); + } +} + +/** The timestamp a row renders when it shows no status label: the settle + stamp on settled slim rows, otherwise the latest activity. Blank for + status-labelled cards and snoozed rows with a wake countdown — those + never draw a time, so their minute tick must not invalidate the cell. */ +function resolveThreadListV2ItemTimeLabel( + item: ThreadListV2Item, + showSnoozeWakeLabel: boolean, +): string { + const { thread, variant, snoozed } = item; + if (showSnoozeWakeLabel) return ""; + if (variant === "card" && resolveThreadListV2Status(thread) !== "ready") return ""; + const settledTimestamp = + variant === "slim" && !snoozed ? resolveSettledThreadTimestamp(thread) : null; + return relativeTime( + settledTimestamp ?? thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, + ); +} + /** * Builds the shared mobile order: active → pending → snoozed shelf → settled. * Pending tasks are waiting rather than asking, and parked work remains @@ -289,21 +389,64 @@ export function buildThreadListV2ListItems(input: { readonly settledShelfExpanded?: boolean; readonly settledShelfHeaderIndex?: number | null; readonly snoozeLabelNow?: string; + /** Environments whose server supports thread.snooze. Rows on other + environments never carry the minute clock that feeds the snooze menu. + Absent = no gating (tests). */ + readonly snoozeEnvironmentIds?: ReadonlySet; + /** Thread keys (`environmentId:threadId`) with a message waiting in the + outbox; stamped onto the matching rows as `hasQueuedMessages`. */ + readonly queuedThreadKeys?: ReadonlySet; + /** Menu availability for Move up/down, keyed by `environmentId:threadId` + (only consulted for card rows — slim menus omit the moves). Produced by + `computeThreadMoveAvailability` in one pass per section; a recycled cell + ignores the render closure, so the stamps ride on the item. Absent = + never available (tests). */ + readonly moveAvailability?: ReadonlyMap; + /** True while the shelf expansion preferences are still loading; stamped + onto both shelf headers so the disabled state reaches recycled cells. */ + readonly shelfPreferencesLoading?: boolean; }): ThreadListV2ListItem[] { - const threadItems = input.items.map((item): ThreadListV2ListItem => ({ - type: "v2-thread", - key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`, - item, - snoozeWakeLabelText: + const threadItems = input.items.map((item): ThreadListV2ListItem => { + const snoozeWakeLabelText = item.snoozed && item.thread.snoozedUntil != null && input.snoozeLabelNow !== undefined ? snoozeWakeLabel(item.thread.snoozedUntil, { now: input.snoozeLabelNow }) - : undefined, - })); + : undefined; + // The minute clock belongs on the item, not the list's extraData, so the + // recycler's equality can confine the per-minute re-render to rows whose + // snooze menu actually shows preset times. The swipe-revealed snooze menu + // exists on slim rows too (the variant only swaps the primary action), + // so the gate follows actual snooze availability, not the variant. + const snoozePresetMinute = + !item.snoozed && + input.snoozeLabelNow !== undefined && + (input.snoozeEnvironmentIds?.has(item.thread.environmentId) ?? true) && + canSnooze(item.thread, { now: input.snoozeLabelNow }) + ? input.snoozeLabelNow + : undefined; + const move = + item.variant === "card" + ? input.moveAvailability?.get(`${item.thread.environmentId}:${item.thread.id}`) + : undefined; + return { + type: "v2-thread", + key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`, + item, + snoozeWakeLabelText, + timeLabel: resolveThreadListV2ItemTimeLabel(item, snoozeWakeLabelText !== undefined), + snoozePresetMinute, + showTrailingDivider: false, + hasQueuedMessages: + input.queuedThreadKeys?.has(`${item.thread.environmentId}:${item.thread.id}`) === true, + canMoveUp: move?.canMoveUp === true, + canMoveDown: move?.canMoveDown === true, + }; + }); const pendingItems = input.pendingTasks.map((pendingTask, index): ThreadListV2ListItem => ({ type: "v2-pending", key: `v2-${pendingTask.key}`, pendingTask, showPendingDivider: index === 0, + showTrailingDivider: false, })); const snoozedCount = input.snoozedCount ?? 0; const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null; @@ -312,12 +455,14 @@ export function buildThreadListV2ListItems(input: { const activeEnd = snoozedShelfHeaderIndex ?? settledShelfHeaderIndex ?? threadItems.length; const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length; const result: ThreadListV2ListItem[] = [...threadItems.slice(0, activeEnd), ...pendingItems]; + const shelfDisabled = input.shelfPreferencesLoading === true; if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) { result.push({ type: "v2-snoozed-shelf", key: "v2-snoozed-shelf", count: snoozedCount, expanded: input.snoozedShelfExpanded === true, + disabled: shelfDisabled, }); result.push(...threadItems.slice(snoozedShelfHeaderIndex, snoozedEnd)); } @@ -327,10 +472,21 @@ export function buildThreadListV2ListItems(input: { key: "v2-settled-shelf", count: settledCount, expanded: input.settledShelfExpanded !== false, + disabled: shelfDisabled, }); result.push(...threadItems.slice(settledShelfHeaderIndex)); } - return result; + // Hairlines depend on the final neighbour, so they are stamped after the + // splice: a recycled cell only re-renders when its divider actually flips. + return result.map((entry, index) => { + if (entry.type !== "v2-thread" && entry.type !== "v2-pending") return entry; + const next = result[index + 1]; + const showTrailingDivider = + next?.type === "v2-thread" || (next?.type === "v2-pending" && !next.showPendingDivider); + return showTrailingDivider === entry.showTrailingDivider + ? entry + : { ...entry, showTrailingDivider }; + }); } /** diff --git a/apps/mobile/src/features/threads/threadOrder.ts b/apps/mobile/src/features/threads/threadOrder.ts index c71c1040bf40..570251f4a985 100644 --- a/apps/mobile/src/features/threads/threadOrder.ts +++ b/apps/mobile/src/features/threads/threadOrder.ts @@ -1,5 +1,9 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; +import { + generateSpreadPinOrderKeys, + pinOrderKeyBetween, + planPinnedReorder, +} from "@t3tools/client-runtime/state/thread-sort"; import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentId } from "@t3tools/contracts"; @@ -42,7 +46,7 @@ export function threadOrderAfterMove( return result; } -type OrderRow = Pick< +export type OrderRow = Pick< EnvironmentThreadShell, | "id" | "environmentId" @@ -106,6 +110,143 @@ export function createThreadMovePlanner(input: { }; } +export interface ThreadMoveAvailability { + readonly canMoveUp: boolean; + readonly canMoveDown: boolean; +} + +/** + * Batch form of "call `createThreadMovePlanner` once per card": one pass over + * the section answers up/down availability for every ordered row, so list + * construction stays linear instead of one full planner probe (array copies, + * hidden-key rescans) per row on the minute-tick rebuild path. The mirror of + * the planner's plan rules lives in the body below; the reference-parity test + * pins them row-by-row, including adversarial keys and hidden reservations. + */ +export function computeThreadMoveAvailability(input: { + readonly ordered: readonly OrderRow[]; + readonly allThreads?: readonly OrderRow[]; + readonly section: PendingThreadOrder["section"]; + readonly reorderableEnvironmentIds: ReadonlySet; + readonly pendingOrder?: PendingThreadOrder | null; +}): Map { + const result = new Map(); + // A reorder in flight locks the whole list until its receipt lands. + if (input.pendingOrder != null) return result; + const rows = input.ordered; + const orderedIds = rows.map(rowId); + const indexById = new Map(orderedIds.map((id, index) => [id, index] as const)); + const keysById = new Map( + (input.allThreads ?? input.ordered).map( + (row) => [rowId(row), rowOrder(row, input.section).key] as const, + ), + ); + const writableIds = new Set( + (input.allThreads ?? input.ordered) + .filter((row) => input.reorderableEnvironmentIds.has(row.environmentId)) + .map(rowId), + ); + const visibleIds = new Set(orderedIds); + const reservedKeys = new Set( + [...keysById].flatMap(([id, key]) => (!visibleIds.has(id) && key != null ? [key] : [])), + ); + // Mirror of `planPinnedReorder` for adjacent swaps, hoisted so every row is + // answered in O(1) amortized instead of one planner probe per row: + // + // Fast path (both neighbors keyed): the fresh key between the landing + // neighbors, walking forward while hidden reserved keys block it. The walk + // depends only on the neighbor key pair - each adjacency is probed by at + // most two rows (down of the left member, up of the right member) - so the + // memo keeps even a fully adversarial reserved-key layout linear per build. + // + // Rewrite path (keyless/unusable neighbor, or key space exhausted): fresh + // spread keys for every row; only positions whose current key differs get + // written. A swap permutes two rows without changing the multiset, so the + // assignment set differs from the unswapped baseline at at most those two + // positions, and mismatch/writability tallies computed once per section + // answer each row with a constant-size delta. + const midpoints = new Map(); + const fastPathKey = (beforeKey: string | null, afterKey: string | null): string | null => { + const memoKey = `${beforeKey ?? ""}\u0000${afterKey ?? ""}`; + const cached = midpoints.get(memoKey); + if (cached !== undefined) return cached; + let key = pinOrderKeyBetween(beforeKey, afterKey); + while (key !== null && reservedKeys.has(key)) key = pinOrderKeyBetween(key, afterKey); + midpoints.set(memoKey, key); + return key; + }; + const spreadKeys = generateSpreadPinOrderKeys(orderedIds.length + reservedKeys.size) + .filter((key) => !reservedKeys.has(key)) + .slice(0, orderedIds.length); + const currentKeys = orderedIds.map((id) => keysById.get(id) ?? null); + const writableRow = orderedIds.map((id) => writableIds.has(id)); + let baselineWrites = 0; + let baselineUnwritableWrites = 0; + for (let position = 0; position < orderedIds.length; position += 1) { + if (currentKeys[position] === spreadKeys[position]) continue; + baselineWrites += 1; + if (!writableRow[position]) baselineUnwritableWrites += 1; + } + // `movedId` swaps with its neighbor; ids at the two swapped positions change, + // so only their tally contributions are recomputed. + const rewriteViable = (index: number): boolean => { + let writes = baselineWrites; + let unwritableWrites = baselineUnwritableWrites; + for (const position of [index, index + 1]) { + const other = position === index ? index + 1 : index; + if (currentKeys[position] !== spreadKeys[position]) { + writes -= 1; + if (!writableRow[position]) unwritableWrites -= 1; + } + // After the swap this position holds the row that was at `other`. + if (currentKeys[other] !== spreadKeys[position]) { + writes += 1; + if (!writableRow[other]) unwritableWrites += 1; + } + } + return writes > 0 && unwritableWrites === 0; + }; + for (const row of rows) { + const movedId = rowId(row); + const denied = { canMoveUp: false, canMoveDown: false }; + if (!writableIds.has(movedId)) { + result.set(movedId, denied); + continue; + } + const index = indexById.get(movedId); + if (index === undefined) { + result.set(movedId, denied); + continue; + } + const adjacentAvailable = (towardUp: boolean): boolean => { + const shifted = index + (towardUp ? -1 : 1); + if (shifted < 0 || shifted >= orderedIds.length) return false; + // The swap exchanges the row with its neighbor; afterwards the moved row + // sits at `shifted` between `beforeIndex` and `afterIndex` of the OLD + // order: moving up it lands between old(index-2) and old(index-1), + // moving down between old(index+1) and old(index+2). + const beforeIndex = towardUp ? index - 2 : index + 1; + const afterIndex = towardUp ? index - 1 : index + 2; + const beforeId = beforeIndex < 0 ? null : (orderedIds[beforeIndex] ?? null); + const afterId = afterIndex >= orderedIds.length ? null : (orderedIds[afterIndex] ?? null); + const beforeKey = beforeId === null ? null : (keysById.get(beforeId) ?? null); + const afterKey = afterId === null ? null : (keysById.get(afterId) ?? null); + if ((beforeId === null || beforeKey != null) && (afterId === null || afterKey != null)) { + const key = fastPathKey(beforeKey, afterKey); + // A fresh key is a single-write plan for the (writable) moved row. + if (key !== null) return true; + } + // Keyless neighbor or exhausted key space: the section rewrite runs. + return rewriteViable(Math.min(index, shifted)); + }; + result.set(movedId, { + canMoveUp: adjacentAvailable(true), + canMoveDown: adjacentAvailable(false), + }); + } + return result; +} + export function createPendingThreadOrder(input: { readonly section: PendingThreadOrder["section"]; readonly ordered: readonly OrderRow[]; diff --git a/apps/mobile/src/features/threads/threadOrderAvailability.test.ts b/apps/mobile/src/features/threads/threadOrderAvailability.test.ts new file mode 100644 index 000000000000..765eff4cf61e --- /dev/null +++ b/apps/mobile/src/features/threads/threadOrderAvailability.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + generateSpreadPinOrderKeys, + pinOrderKeyBetween, +} from "@t3tools/client-runtime/state/thread-sort"; +import type { EnvironmentId } from "@t3tools/contracts"; + +import { + computeThreadMoveAvailability, + createThreadMovePlanner, + type OrderRow, +} from "./threadOrder"; + +// The batch availability computation must answer exactly what the reference +// per-move planner answers; these tests randomize sections that stress every +// branch (keyless rows, hidden keys, non-writable rows, adversarial keys). + +function makeRow(id: string, environmentId: string, key: string | null, pinned: boolean): OrderRow { + return { + id: id as OrderRow["id"], + environmentId: environmentId as OrderRow["environmentId"], + pinOrderKey: pinned ? key : null, + activeOrderKey: pinned ? null : key, + createdAt: "2026-01-01T00:00:00.000Z", + unsettledAt: "2026-01-01T00:00:00.000Z", + pinnedAt: pinned ? "2026-01-01T00:00:00.000Z" : null, + }; +} + +function makeRng(seed: number) { + let state = seed >>> 0; + return () => { + state = (state * 1_664_525 + 1_013_904_223) >>> 0; + return state / 0x1_0000_0000; + }; +} + +const KEY_POOL = [ + "a", + "b", + "ba", + "bb", + "c", + "d", + "ca", + "cb", + "da", + "db", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + null, +]; + +function referenceAvailability( + ordered: readonly OrderRow[], + allThreads: readonly OrderRow[], + writable: ReadonlySet, +) { + const planner = createThreadMovePlanner({ + ordered, + allThreads, + section: "pinned", + reorderableEnvironmentIds: writable, + }); + const answers = new Map(); + for (const row of ordered) { + const movedId = `${row.environmentId}:${row.id}`; + answers.set(movedId, { + canMoveUp: planner(movedId, "up") !== null, + canMoveDown: planner(movedId, "down") !== null, + }); + } + return answers; +} + +function randomCase(rng: () => number) { + const rowCount = 1 + Math.floor(rng() * 9); + // Two environments; "writable" env vs. one lacking the reorder capability. + const rows: OrderRow[] = []; + for (let index = 0; index < rowCount; index += 1) { + const environment = rng() < 0.75 ? "env-w" : "env-x"; + const key = KEY_POOL[Math.floor(rng() * KEY_POOL.length)] ?? null; + // Ids with colons: a `${environmentId}:${id}` string is not splittable + // back into its parts, so batch and planner must agree even here. + const id = rng() < 0.4 ? `t:${index}` : `t${index}`; + rows.push(makeRow(id, environment, key, true)); + } + // Hidden rows (in allThreads, not in the visible ordered section) may hold + // keys that collide with fast-path midpoints. + const hidden: OrderRow[] = []; + const hiddenCount = Math.floor(rng() * 4); + for (let index = 0; index < hiddenCount; index += 1) { + const key = KEY_POOL[Math.floor(rng() * KEY_POOL.length)] ?? null; + const id = rng() < 0.4 ? `h:${index}` : `h${index}`; + hidden.push(makeRow(id, rng() < 0.75 ? "env-w" : "env-x", key, true)); + } + return { ordered: rows, allThreads: [...rows, ...hidden] }; +} + +const WRITABLE = new Set(["env-w" as EnvironmentId]); + +describe("computeThreadMoveAvailability matches the reference planner", () => { + it("agrees across randomized sections (keys, holes, non-writable rows, hidden keys)", () => { + for (let seed = 1; seed <= 4_000; seed += 1) { + const rng = makeRng(seed); + const { ordered, allThreads } = randomCase(rng); + const batch = computeThreadMoveAvailability({ + ordered, + allThreads, + section: "pinned", + reorderableEnvironmentIds: WRITABLE, + }); + const reference = referenceAvailability(ordered, allThreads, WRITABLE); + for (const [id, answer] of reference) { + expect( + batch.get(id) ?? { canMoveUp: false, canMoveDown: false }, + `seed ${seed} row ${id} ordered=${ordered + .map((row) => `${row.id}:${row.pinOrderKey ?? "-"}:${row.environmentId}`) + .join(",")}`, + ).toEqual(answer); + } + } + }); + + it("locks every row while a pending reorder is in flight", () => { + const rows = [makeRow("t0", "env-w", "a", true), makeRow("t1", "env-w", "c", true)]; + const pending = { + section: "pinned" as const, + orderedIds: ["env-w:t1", "env-w:t0"], + before: new Map(), + assignments: new Map(), + confirmed: new Set(), + commandsComplete: false, + }; + const batch = computeThreadMoveAvailability({ + ordered: rows, + section: "pinned", + reorderableEnvironmentIds: WRITABLE, + pendingOrder: pending, + }); + expect(batch.size).toBe(0); + }); + + it("allows the fast-path walk past reserved keys and rewrites that skip matching keys (auditor cases)", () => { + // Reported parity case: keys ["f","gn",null], middle row NOT writable. + // The rewrite assigns the middle row the key it already holds, so the + // diff never writes it and the first row's down-move stays available. + const rows = [makeRow("t0", "env-w", "f", true), makeRow("t1", "env-x", "gn", true)]; + const withThird = [...rows, makeRow("t2", "env-w", null, true)]; + const batch = computeThreadMoveAvailability({ + ordered: withThird, + allThreads: withThird, + section: "pinned", + reorderableEnvironmentIds: WRITABLE, + }); + const reference = referenceAvailability(withThird, withThird, WRITABLE); + expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference)); + expect(batch.get("env-w:t0")).toEqual({ canMoveUp: false, canMoveDown: true }); + }); + + it("keeps moves available for ids containing colons (composite-id parsing)", () => { + // The reported case: environment `env`, ids `thread:1`/`thread:2`. Splitting + // the composite id at the last colon yields `env:thread` and falsely locks + // both rows; writability must come from the row's own environmentId. + const rows = [makeRow("thread:1", "env", "a", true), makeRow("thread:2", "env", "c", true)]; + const batch = computeThreadMoveAvailability({ + ordered: rows, + section: "pinned", + reorderableEnvironmentIds: new Set(["env" as EnvironmentId]), + }); + const reference = referenceAvailability( + rows, + rows, + new Set(["env" as EnvironmentId]), + ); + expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference)); + expect(batch.get("env:thread:1")).toEqual({ canMoveUp: false, canMoveDown: true }); + expect(batch.get("env:thread:2")).toEqual({ canMoveUp: true, canMoveDown: false }); + }); + + it("denies single-row sections on both directions", () => { + const rows = [makeRow("t0", "env-w", "a", true)]; + const batch = computeThreadMoveAvailability({ + ordered: rows, + section: "pinned", + reorderableEnvironmentIds: WRITABLE, + }); + expect(batch.get("env-w:t0")).toEqual({ canMoveUp: false, canMoveDown: false }); + }); + + it("denies rows whose section has non-writable neighbours when the fast path fails", () => { + // Keyless neighbors force the section-rewrite fallback; a non-writable + // neighbor makes the rewrite illegal for every row in the section. + const rows = [ + makeRow("t0", "env-w", null, true), + makeRow("t1", "env-x", "c", true), + makeRow("t2", "env-w", "d", true), + ]; + const batch = computeThreadMoveAvailability({ + ordered: rows, + allThreads: rows, + section: "pinned", + reorderableEnvironmentIds: WRITABLE, + }); + const reference = referenceAvailability(rows, rows, WRITABLE); + expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference)); + // Sanity: the middle (non-writable) row is denied on both sides. + expect(batch.get("env-x:t1")).toEqual({ canMoveUp: false, canMoveDown: false }); + }); + + it("agrees on a section where every adjacency midpoint is a hidden reserved key", () => { + // The worst case for reserved-key collisions: one hidden row holds the + // exact midpoint key of every adjacent visible pair, so every probe walks. + const visibleKeys = generateSpreadPinOrderKeys(24); + const hiddenKeys = visibleKeys + .slice(0, -1) + .map((key, index) => pinOrderKeyBetween(key, visibleKeys[index + 1]!)) + .filter((key): key is string => key != null); + const ordered = visibleKeys.map((key, index) => makeRow(`v${index}`, "env-w", key, true)); + const hidden = hiddenKeys.map((key, index) => makeRow(`h${index}`, "env-w", key, true)); + const allThreads = [...ordered, ...hidden]; + const batch = computeThreadMoveAvailability({ + ordered, + allThreads, + section: "pinned", + reorderableEnvironmentIds: WRITABLE, + }); + const reference = referenceAvailability(ordered, allThreads, WRITABLE); + expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference)); + }); + + it("agrees on an adversarial section of consecutive single-char keys", () => { + // Every midpoint between consecutive one-char keys is unrepresentable, so + // no row may claim a fast-path plan. + const rows = ["a", "b", "c", "d"].map((key, index) => makeRow(`t${index}`, "env-w", key, true)); + const batch = computeThreadMoveAvailability({ + ordered: rows, + allThreads: rows, + section: "pinned", + reorderableEnvironmentIds: WRITABLE, + }); + const reference = referenceAvailability(rows, rows, WRITABLE); + expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference)); + }); +}); diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts deleted file mode 100644 index b5f286937e0c..000000000000 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { StatusTone } from "../../components/StatusPill"; -import type { OrchestrationLatestTurn, OrchestrationSession } from "@t3tools/contracts"; -import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; - -export type ThreadStatusKind = - | "pending-approval" - | "awaiting-input" - | "working" - | "connecting" - | "error" - | "plan-ready"; - -export interface ThreadStatusPresentation extends StatusTone { - readonly kind: ThreadStatusKind; - /** Whether the indicator represents in-flight activity. */ - readonly pulse: boolean; -} - -function isLatestTurnSettled( - latestTurn: OrchestrationLatestTurn | null, - session: OrchestrationSession | null, -): boolean { - if (!latestTurn?.startedAt) return false; - if (!latestTurn.completedAt) return false; - if (!session) return true; - return session.status !== "running"; -} - -/** - * Resolves the user-facing status of a thread, in priority order. Returns - * `null` for quiescent threads so rows stay free of "Idle"-style noise. - * Mirrors `resolveThreadStatusPill` in apps/web/src/components/Sidebar.logic.ts. - */ -export function resolveThreadStatus( - thread: EnvironmentThreadShell, -): ThreadStatusPresentation | null { - if (thread.hasPendingApprovals) { - return { - kind: "pending-approval", - label: "Needs Approval", - pillClassName: "bg-warning", - textClassName: "text-warning-foreground", - pulse: false, - }; - } - - if (thread.hasPendingUserInput) { - return { - kind: "awaiting-input", - label: "Awaiting Input", - pillClassName: "bg-adaptive-indigo-500-a12-a16", - textClassName: "text-adaptive-indigo-600-300", - pulse: false, - }; - } - - if (thread.session?.status === "running") { - return { - kind: "working", - label: "Working", - pillClassName: "bg-adaptive-sky-500-a12-a16", - textClassName: "text-adaptive-sky-600-400", - pulse: true, - }; - } - - if (thread.session?.status === "starting") { - return { - kind: "connecting", - label: "Connecting", - pillClassName: "bg-adaptive-sky-500-a12-a16", - textClassName: "text-adaptive-sky-600-400", - pulse: true, - }; - } - - if (thread.session?.status === "error" || thread.latestTurn?.state === "error") { - return { - kind: "error", - label: "Error", - pillClassName: "bg-danger", - textClassName: "text-danger-foreground", - pulse: false, - }; - } - - const hasPlanReadyPrompt = - thread.interactionMode === "plan" && - isLatestTurnSettled(thread.latestTurn, thread.session) && - thread.hasActionableProposedPlan; - if (hasPlanReadyPrompt) { - return { - kind: "plan-ready", - label: "Plan Ready", - pillClassName: "bg-adaptive-violet-500-a12-a16", - textClassName: "text-adaptive-violet-600-400", - pulse: false, - }; - } - - return null; -} diff --git a/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts index 61c4fb65cdc9..684049155a9e 100644 --- a/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts +++ b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import { mobilePreferencesAtom } from "../../state/preferences"; -import { resolveLegacyPlanModeEnabled } from "./legacy-plan-mode"; +import { resolveLegacyPlanModeEnabled } from "../../state/legacy-plan-mode"; /** * Mobile preferences are device-local, matching the desktop client setting. diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts deleted file mode 100644 index 2672942c2d36..000000000000 --- a/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/unstable/reactivity"; - -import { mobilePreferencesAtom } from "../../state/preferences"; -import { resolveThreadListV2Enabled } from "./threadListV2"; - -/** - * Resolved Thread List v2 state: on unless the device opted into the legacy - * grouped list (Settings → Legacy). Every consumer must read through this - * rather than the raw preference, which is undefined until explicitly chosen. - */ -export function useThreadListV2Enabled(): boolean { - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const loaded = AsyncResult.isSuccess(preferencesResult); - return resolveThreadListV2Enabled({ - legacyPreference: loaded ? preferencesResult.value.legacyThreadListEnabled : undefined, - preferencesLoaded: loaded, - }); -} diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index d1bec8cbc140..7494ac9b34bd 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -267,20 +267,20 @@ export function UsageRouteScreen() { <> {/* Period and metric together: neither applies to Limits, and both change every number below, so they share one bar. */} - + {merged.duplicateSources.length > 0 ? ( diff --git a/apps/mobile/src/lib/attachmentUpload.test.ts b/apps/mobile/src/lib/attachmentUpload.test.ts index 2c6b27864432..df68a2de3019 100644 --- a/apps/mobile/src/lib/attachmentUpload.test.ts +++ b/apps/mobile/src/lib/attachmentUpload.test.ts @@ -32,7 +32,7 @@ vi.mock("../state/atom-registry", () => ({ })); // The real read lease and cleanup are covered by the composer ownership suite. -vi.mock("../state/use-composer-drafts", () => ({ +vi.mock("./composerAttachmentPreviewRetention", () => ({ retainComposerAttachmentFileForPreview: () => () => {}, })); diff --git a/apps/mobile/src/lib/attachmentUpload.ts b/apps/mobile/src/lib/attachmentUpload.ts index 10f4fdb7c6c7..1bce3e26774f 100644 --- a/apps/mobile/src/lib/attachmentUpload.ts +++ b/apps/mobile/src/lib/attachmentUpload.ts @@ -20,8 +20,8 @@ import { appAtomRegistry } from "../state/atom-registry"; import { assetEnvironment } from "../state/assets"; import { attachmentEnvironment } from "../state/attachments"; import { environmentSession } from "../state/session"; -import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; +import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention"; import { isComposerImageAttachment, isFileBackedComposerAttachment, diff --git a/apps/mobile/src/lib/composerAttachmentPreviewRetention.ts b/apps/mobile/src/lib/composerAttachmentPreviewRetention.ts new file mode 100644 index 000000000000..ca9044f32dc6 --- /dev/null +++ b/apps/mobile/src/lib/composerAttachmentPreviewRetention.ts @@ -0,0 +1,28 @@ +import type { FileBackedComposerAttachment } from "./composerImages"; +import { retainComposerAttachmentFile } from "./composerAttachmentFiles"; + +/** + * Preview retention for saved composer attachment copies. + * + * The durable owners of an attachment file (composer drafts, queued outbox + * messages) live in state, so this module cannot reach the ownership-cleanup + * sweep directly. Composer draft state registers the owner-side cleanup hook + * at module load; until then a release has nothing to retry, because no draft + * store has loaded yet and there is nothing to clean. + */ +type UnusedAttachmentHandler = (attachment: FileBackedComposerAttachment) => void; + +let onAttachmentUnused: UnusedAttachmentHandler | null = null; + +export function registerComposerAttachmentUnusedHandler(handler: UnusedAttachmentHandler): void { + onAttachmentUnused = handler; +} + +/** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */ +export function retainComposerAttachmentFileForPreview( + attachment: FileBackedComposerAttachment, +): () => void { + return retainComposerAttachmentFile(attachment.fileUri, () => { + onAttachmentUnused?.(attachment); + }); +} diff --git a/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts b/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts index 8bf67456ce53..79be3bbe8e2f 100644 --- a/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts +++ b/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts @@ -105,6 +105,31 @@ describe("composer attachment upload queue", () => { queue.dispose(); }); + it("retries a failed attachment when the worker restores requests after reconnect", async () => { + let states: Readonly> = {}; + const upload = vi.fn().mockRejectedValueOnce(new Error("Disconnected")).mockResolvedValue(true); + const queue = createComposerAttachmentUploadQueue({ + upload, + onChange: (next) => { + states = next; + }, + }); + const local = request("failed-upload"); + const key = composerAttachmentUploadKey(environmentId, local.attachment.id); + queue.sync([local]); + await queue.settled(); + expect(states[key]?.status).toBe("failed"); + queue.sync([local]); + await queue.settled(); + expect(upload).toHaveBeenCalledTimes(1); + queue.sync([]); + queue.sync([local]); + await queue.settled(); + expect(upload).toHaveBeenCalledTimes(2); + expect(states[key]?.status).toBe("ready"); + queue.dispose(); + }); + it("ignores a late completion after removal or environment switch", async () => { const gate = Promise.withResolvers(); const started = Promise.withResolvers(); diff --git a/apps/mobile/src/lib/composerFiles.test.ts b/apps/mobile/src/lib/composerFiles.test.ts index 962be35013f2..a99c2c6064e7 100644 --- a/apps/mobile/src/lib/composerFiles.test.ts +++ b/apps/mobile/src/lib/composerFiles.test.ts @@ -477,10 +477,13 @@ describe("composer file attachments", () => { it("applies the remaining attachment slots to photos and videos together", async () => { mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image, video] }); - const result = await pickComposerMedia({ existingCount: 7, maxVideoBytes: 50 * 1024 * 1024 }); + const result = await pickComposerMedia({ + existingCount: 99, + maxVideoBytes: 50 * 1024 * 1024, + }); expect(result.attachments).toEqual([expect.objectContaining({ type: "image" })]); - expect(result.error).toBe("You can attach up to 8 attachments per message."); + expect(result.error).toBe("You can attach up to 100 attachments per message."); expect(mocks.pickMedia).toHaveBeenCalledWith(expect.objectContaining({ selectionLimit: 1 })); expect(mocks.copy).not.toHaveBeenCalled(); }); @@ -625,9 +628,9 @@ describe("composer file attachments", () => { }); it("does not open the picker when the draft has no remaining attachment slots", async () => { - await expect(pickComposerFiles({ existingCount: 8 })).resolves.toEqual({ + await expect(pickComposerFiles({ existingCount: 100 })).resolves.toEqual({ files: [], - error: "You can attach up to 8 files per message.", + error: "You can attach up to 100 files per message.", }); expect(mocks.pickFile).not.toHaveBeenCalled(); @@ -838,7 +841,7 @@ describe("composer file attachments", () => { ], }); - const result = await pickComposerFiles({ existingCount: 7, maxBytes: 1024 * 1024 }); + const result = await pickComposerFiles({ existingCount: 99, maxBytes: 1024 * 1024 }); expect(result.files.map((file) => file.name)).toEqual(["report.pdf"]); }); diff --git a/apps/mobile/src/lib/debugLog.ts b/apps/mobile/src/lib/debugLog.ts new file mode 100644 index 000000000000..447973503333 --- /dev/null +++ b/apps/mobile/src/lib/debugLog.ts @@ -0,0 +1,64 @@ +/** + * Namespaced, filterable debug logging shared by mobile subsystems. + * + * Ordinary, expected conditions — a queued send failing while the device is + * offline, for example — go through a debug logger instead of `console.warn` + * so warning output stays reserved for failures someone can act on. Output + * uses `console.log` with a `[t3-]` prefix, matching the existing + * cloud and terminal debug logs. (client-runtime cannot host this: its + * tooling bans `console.*` in favor of Effect logging.) + * + * A logger is silent in every build, including development, unless enabled. + * Toggle it from a JS debugger or the Metro console, including on release/TestFlight builds: + * - `globalThis.__T3_DEBUG__ = true` enables every namespace; + * - `globalThis.__T3_DEBUG__ = ["thread-outbox"]` enables only listed ones. + * + * Subsystems whose traces are useful by default in development (`__DEV__`) + * opt in with `enabledInDev`; `legacyGlobalFlag` keeps an older + * subsystem-specific global (e.g. `__T3_CLOUD_DEBUG__`) working. + */ + +export interface DebugLogger { + readonly isEnabled: () => boolean; + readonly log: (event: string, data?: Record) => void; +} + +export interface DebugLoggerOptions { + /** Log whenever `__DEV__` is true, without the global filter. Defaults to false. */ + readonly enabledInDev?: boolean; + /** Name of a legacy subsystem-specific global boolean, e.g. `"__T3_CLOUD_DEBUG__"`. */ + readonly legacyGlobalFlag?: string; +} + +function globalValue(name: string): unknown { + return typeof globalThis === "undefined" + ? undefined + : (globalThis as Record)[name]; +} + +export function createDebugLogger( + namespace: string, + options: DebugLoggerOptions = {}, +): DebugLogger { + const isEnabled = () => { + if (options.enabledInDev === true && typeof __DEV__ !== "undefined" && __DEV__) { + return true; + } + if (options.legacyGlobalFlag !== undefined && globalValue(options.legacyGlobalFlag) === true) { + return true; + } + const filter = globalValue("__T3_DEBUG__"); + return filter === true || (Array.isArray(filter) && filter.includes(namespace)); + }; + const log = (event: string, data?: Record) => { + if (!isEnabled()) { + return; + } + if (data === undefined) { + console.log(`[t3-${namespace}] ${event}`); + } else { + console.log(`[t3-${namespace}] ${event}`, data); + } + }; + return { isEnabled, log }; +} diff --git a/apps/mobile/src/lib/localAttachmentPreview.test.ts b/apps/mobile/src/lib/localAttachmentPreview.test.ts index 3693caa41185..602d5aada56b 100644 --- a/apps/mobile/src/lib/localAttachmentPreview.test.ts +++ b/apps/mobile/src/lib/localAttachmentPreview.test.ts @@ -6,7 +6,7 @@ const mocks = vi.hoisted(() => ({ exists: vi.fn(), })); -vi.mock("../state/use-composer-drafts", () => ({ +vi.mock("./composerAttachmentPreviewRetention", () => ({ retainComposerAttachmentFileForPreview: mocks.retain, })); vi.mock("./attachmentDownload", () => ({ shareLocalAttachment: mocks.share })); diff --git a/apps/mobile/src/lib/localAttachmentPreview.ts b/apps/mobile/src/lib/localAttachmentPreview.ts index 0d90f7210bab..171fc26c0566 100644 --- a/apps/mobile/src/lib/localAttachmentPreview.ts +++ b/apps/mobile/src/lib/localAttachmentPreview.ts @@ -3,7 +3,7 @@ import { videoMimeType } from "@t3tools/shared/video"; import type { FileBackedComposerAttachment } from "./composerImages"; import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload"; -import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; +import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention"; /** Retains the draft original for preview and gives each outgoing share its own lease. */ export async function loadLocalAttachmentPreview( diff --git a/apps/mobile/src/lib/projectFaviconCache.test.ts b/apps/mobile/src/lib/projectFaviconDatabaseCache.test.ts similarity index 97% rename from apps/mobile/src/lib/projectFaviconCache.test.ts rename to apps/mobile/src/lib/projectFaviconDatabaseCache.test.ts index adde56fbf0b1..1e7344a684c3 100644 --- a/apps/mobile/src/lib/projectFaviconCache.test.ts +++ b/apps/mobile/src/lib/projectFaviconDatabaseCache.test.ts @@ -27,7 +27,7 @@ vi.mock("expo-file-system", () => ({ }, })); -import { downscaleProjectFavicon } from "./projectFaviconCache"; +import { downscaleProjectFavicon } from "./projectFaviconDatabaseCache"; const png = "iVBORw0KGgoAAAAA"; const image = { url: "https://remote/icon.png" }; diff --git a/apps/mobile/src/lib/projectFaviconCache.ts b/apps/mobile/src/lib/projectFaviconDatabaseCache.ts similarity index 90% rename from apps/mobile/src/lib/projectFaviconCache.ts rename to apps/mobile/src/lib/projectFaviconDatabaseCache.ts index 26a6d848d11d..f24cb2b394f7 100644 --- a/apps/mobile/src/lib/projectFaviconCache.ts +++ b/apps/mobile/src/lib/projectFaviconDatabaseCache.ts @@ -1,5 +1,5 @@ import { - createProjectFaviconCache, + createProjectFaviconCache as createSharedProjectFaviconCache, createProjectFaviconImageLoader, PROJECT_FAVICON_MAX_DATA_URL_LENGTH, PROJECT_FAVICON_THUMBNAIL_SIZE, @@ -78,8 +78,13 @@ export async function downscaleProjectFavicon( throw new Error("Project icon thumbnail exceeds the cache limit."); } -/** Rows live in `client_cache` so Settings → Client storage counts and clears them. */ -export const projectFaviconCache = createProjectFaviconCache({ +/** + * The database-backed project favicon cache. Named apart from the shared + * `createProjectFaviconCache` factory it calls and from the in-flight request + * registry in `projectFaviconRequests`. Rows live in `client_cache` so + * Settings → Client storage counts and clears them. + */ +export const projectFaviconDatabaseCache = createSharedProjectFaviconCache({ storage: { list: () => runDatabase((database) => diff --git a/apps/mobile/src/components/projectFaviconCache.test.ts b/apps/mobile/src/lib/projectFaviconRequests.test.ts similarity index 98% rename from apps/mobile/src/components/projectFaviconCache.test.ts rename to apps/mobile/src/lib/projectFaviconRequests.test.ts index d0582a8b5f5b..540a45473926 100644 --- a/apps/mobile/src/components/projectFaviconCache.test.ts +++ b/apps/mobile/src/lib/projectFaviconRequests.test.ts @@ -6,9 +6,9 @@ import { hasLoadedProjectFavicon, markProjectFaviconFailed, markProjectFaviconLoaded, -} from "./projectFaviconCache"; +} from "./projectFaviconRequests"; -describe("project favicon cache", () => { +describe("project favicon requests", () => { it("ignores callbacks from a superseded URL", () => { const cacheKey = "environment-1:/workspace:v1-favicon.svg"; const expiredUrl = "https://environment.example/api/assets/expired/v1-favicon.svg"; diff --git a/apps/mobile/src/components/projectFaviconCache.ts b/apps/mobile/src/lib/projectFaviconRequests.ts similarity index 87% rename from apps/mobile/src/components/projectFaviconCache.ts rename to apps/mobile/src/lib/projectFaviconRequests.ts index da77d7613f2d..202142d175e1 100644 --- a/apps/mobile/src/components/projectFaviconCache.ts +++ b/apps/mobile/src/lib/projectFaviconRequests.ts @@ -1,3 +1,11 @@ +/** + * Request bookkeeping behind `ProjectFavicon`: tracks the favicon URLs with + * mounted views per cache key, keeps one of them current, and remembers which + * keys have already loaded so superseded or already-shown loads settle without + * churn. This is not request coalescing — each mount still issues its own + * load — and it is not the cache; persistence lives in + * `projectFaviconDatabaseCache`. + */ export interface ProjectFaviconRequest { readonly cacheKey: string; readonly faviconUrl: string; diff --git a/apps/mobile/src/lib/uniwind-platform-variants.fixture.cjs b/apps/mobile/src/lib/uniwind-platform-variants.fixture.cjs new file mode 100644 index 000000000000..8d8017f28f83 --- /dev/null +++ b/apps/mobile/src/lib/uniwind-platform-variants.fixture.cjs @@ -0,0 +1,183 @@ +"use strict"; + +// Fixture for uniwind-platform-variants.test.ts. Run as a plain Node process +// (`node uniwind-platform-variants.fixture.cjs `) so it executes the +// real compiler with no test-runner transforms in the way. Prints one JSON +// object to stdout describing what each platform bundle compiled. +// +// It reproduces what the Metro transformer does for `global.css`: compile the +// Tailwind entry, then run uniwind's CSS processor and stylesheet serializer +// once per platform. Loading `dist/common` needs a small custom loader because +// uniwind declares `"type": "module"` over that CommonJS output and its +// internal `@/...` import alias only exists at uniwind's own build time; the +// loader hands unmodified file bytes to V8 and resolves those aliases the same +// way, so this runs the exact compiled code the Metro transformer bundles. +const fs = require("node:fs"); +const path = require("node:path"); +const { createRequire } = require("node:module"); + +function createUniwindCompilerLoader(uniwindPackageJson) { + const uniwindRoot = path.dirname(fs.realpathSync(uniwindPackageJson)); + const uniwindRequire = createRequire(path.join(uniwindRoot, "package.json")); + const cache = new Map(); + + const load = (absoluteFileWithoutExtension) => { + const resolved = [ + absoluteFileWithoutExtension, + `${absoluteFileWithoutExtension}.js`, + path.join(absoluteFileWithoutExtension, "index.js"), + ].find((candidate) => { + try { + return fs.statSync(candidate).isFile(); + } catch { + return false; + } + }); + if (resolved === undefined) { + throw new Error(`Cannot resolve uniwind module ${absoluteFileWithoutExtension}`); + } + const cached = cache.get(resolved); + if (cached !== undefined) return cached; + + const cjsModule = { exports: {} }; + + const localRequire = (request) => { + if (request.startsWith("@/")) { + return load(path.join(uniwindRoot, "dist/common", request.slice(2))); + } + if (request.startsWith(".")) { + return load(path.resolve(path.dirname(resolved), request)); + } + // uniwind's own dependencies (lightningcss, culori, ...) resolve through + // the package's dependency tree, like they do inside the Metro bundle. + return uniwindRequire(request); + }; + + const code = fs.readFileSync(resolved, "utf8"); + new Function("require", "module", "exports", code)(localRequire, cjsModule, cjsModule.exports); + cache.set(resolved, cjsModule.exports); + return cjsModule.exports; + }; + + const loadBundler = (request) => load(path.join(uniwindRoot, "dist/common", request)); + return { loadBundler, uniwindRequire, uniwindRoot }; +} + +async function main() { + const fixtureDir = fs.realpathSync(path.resolve(process.argv[2])); + const uniwindPackageJson = require.resolve("uniwind/package.json", { + paths: [fixtureDir], + }); + const { loadBundler, uniwindRequire, uniwindRoot } = + createUniwindCompilerLoader(uniwindPackageJson); + const { ProcessorBuilder } = loadBundler("bundler/css-processor/processor.js"); + const { addMetaToStylesTemplate } = loadBundler( + "bundler/css-processor/addMetaToStylesTemplate.js", + ); + const { compileNativeCSS } = loadBundler("bundler/css-compiler/compileNativeCSS.js"); + + const { compile } = uniwindRequire("@tailwindcss/node"); + const { Scanner } = uniwindRequire("@tailwindcss/oxide"); + const compiler = await compile(fs.readFileSync(path.join(fixtureDir, "global.css"), "utf8"), { + base: fixtureDir, + onDependency: () => {}, + }); + const scanner = new Scanner({ + sources: [...compiler.sources, { negated: false, pattern: "**/*.tsx", base: fixtureDir }], + }); + const tailwindCSS = await compiler.build(scanner.scan()); + + const platforms = {}; + // Every ios:/android: utility class Tailwind generated for the fixture. + const compiledGuardedClasses = {}; + for (const match of tailwindCSS.matchAll(/\.(ios|android)\\:([^{ ,]+) \{/g)) { + compiledGuardedClasses[`${match[1]}:${match[2]}`.replace(/\\(.)/g, "$1")] = true; + } + const platformBlock = (name) => + new RegExp(`@media ${name} \\{([\\s\\S]*?)\\n \\}`).exec(tailwindCSS)?.[1] ?? ""; + const utilityCountIn = (block) => (block.match(/^\s+\.[^ ]+ \{/gm) ?? []).length; + const tailwindChecks = { + iosBlocks: (tailwindCSS.match(/@media ios \{/g) ?? []).length, + androidBlocks: (tailwindCSS.match(/@media android \{/g) ?? []).length, + iosUtilities: utilityCountIn(platformBlock("ios")), + androidUtilities: utilityCountIn(platformBlock("android")), + }; + for (const platform of ["ios", "android"]) { + // Same config the app's withUniwindConfig produces (polyfills.rem: 14). + const bundlerConfig = { platform, themes: ["light", "dark"], polyfills: { rem: 14 } }; + const processor = new ProcessorBuilder(bundlerConfig); + processor.transform(tailwindCSS); + const compiled = addMetaToStylesTemplate(processor, platform); + const payload = compileNativeCSS(bundlerConfig, tailwindCSS); + const styles = {}; + for (const [className, entries] of Object.entries(compiled)) { + styles[className] = entries.map( + ({ native, minWidth, maxWidth, active, focus, disabled, dataAttributes }) => ({ + native, + minWidth, + maxWidth, + active: active ?? null, + focus: focus ?? null, + disabled: disabled ?? null, + dataAttributes: dataAttributes ?? null, + }), + ); + } + platforms[platform] = { + styles, + payloadIncludesAllCompiled: Object.keys(compiled).every((className) => + payload.includes(`"${className}"`), + ), + // A utility dropped from this platform's stylesheet must not appear + // anywhere in the payload the bundle embeds for this platform. + payloadLeaks: Object.keys(compiledGuardedClasses) + .filter((className) => compiled[className] === undefined) + .filter((className) => payload.includes(`"${className}"`)), + }; + } + + // Exercise the real Metro transformer entry point (the shipped + // `dist/metro/transformer.cjs`, not a copy) with a stubbed downstream worker, + // to check what the global.css virtual module actually becomes — including + // the native styles fingerprint that lets dev reloads skip reinitializing. + const transformerPath = path.join(uniwindRoot, "dist/metro/transformer.cjs"); + let reinitCode = ""; + const Module = require("node:module"); + const origLoad = Module._load; + Module._load = function (request, ...rest) { + if (request === "metro-transform-worker") { + return { + transform: async (_config, _root, _file, data) => { + reinitCode = data.toString(); + return { output: [{ data: {} }] }; + }, + }; + } + return origLoad.call(this, request, ...rest); + }; + process.chdir(fixtureDir); + try { + const { transform } = require(transformerPath); + await transform( + { uniwind: { cssEntryFile: "./global.css", isExpoProject: false } }, + fixtureDir, + "global.css", + Buffer.from(""), + { platform: "ios", type: "module" }, + ); + } finally { + Module._load = origLoad; + } + const transformerCheck = { + reinitPayload: reinitCode.includes("Uniwind.__reinit(rt =>"), + fingerprintArg: /, '[0-9a-f]{64}'\);\s*$/.test(reinitCode), + themesArg: reinitCode.includes("['light', 'dark']"), + }; + + process.stdout.write(JSON.stringify({ tailwindChecks, transformerCheck, platforms })); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/apps/mobile/src/lib/uniwind-platform-variants.test.ts b/apps/mobile/src/lib/uniwind-platform-variants.test.ts new file mode 100644 index 000000000000..12fa86b33c08 --- /dev/null +++ b/apps/mobile/src/lib/uniwind-platform-variants.test.ts @@ -0,0 +1,253 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import { beforeAll, describe, expect, it } from "vite-plus/test"; + +// Regression guard for the uniwind platform-variant leak (audit #15 / #13161): +// Tailwind groups every `ios:` (or `android:`) utility into one shared +// `@media ios { ... }` block, and uniwind's CSS processor used to drop the +// block's media queries after its first nested rule. Everything past the first +// utility compiled unguarded and shipped to both platforms. These tests run +// the installed (patched) uniwind compiler over real Tailwind output, per +// platform, and assert what each bundle receives. The compiler itself runs in +// a plain Node child process (uniwind-platform-variants.fixture.cjs) so no +// test-runner module transforms sit between the test and the shipped code. + +interface CompiledStyle { + native: boolean; + minWidth: number; + maxWidth: number; + active: boolean | null; + focus: boolean | null; + disabled: boolean | null; + dataAttributes: Record | null; +} + +interface FixtureOutput { + tailwindChecks: { + iosBlocks: number; + androidBlocks: number; + iosUtilities: number; + androidUtilities: number; + }; + transformerCheck: { + reinitPayload: boolean; + fingerprintArg: boolean; + themesArg: boolean; + }; + platforms: Record< + string, + { + styles: Record; + payloadIncludesAllCompiled: boolean; + payloadLeaks: string[]; + } + >; +} + +// Mirrors the classNames the audits flagged: multiple platform utilities per +// block (only the first one used to keep its guard), a base utility overridden +// by an `ios:` variant (NewTaskDraftScreen), and opposing `ios:`/`android:` +// font families (worktree-setup-card). +const FIXTURE_SOURCE = `export const Probe = () => ( +
+); +`; + +const IOS_CLASSES = ["ios:flex-1", "ios:pt-[72px]", "ios:font-[family-name:Menlo]"]; +const ANDROID_CLASSES = ["android:shrink", "android:grow-0", "android:font-mono"]; +const SHARED_CLASSES = ["flex-1", "pt-12"]; +const RESPONSIVE_CLASSES = ["sm:p-6", "sm:text-lg"]; + +const runFixture = (globalCss: string, probeSource: string): FixtureOutput => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "uniwind-platform-")); + try { + // So `@import "tailwindcss"` and `@import "uniwind"` resolve like in the app. + NodeFS.symlinkSync( + NodeFS.realpathSync(new URL("../../node_modules", import.meta.url)), + NodePath.join(tempDir, "node_modules"), + "dir", + ); + NodeFS.writeFileSync(NodePath.join(tempDir, "global.css"), globalCss); + NodeFS.writeFileSync(NodePath.join(tempDir, "Probe.tsx"), probeSource); + + const fixture = NodeURL.fileURLToPath( + new URL("./uniwind-platform-variants.fixture.cjs", import.meta.url), + ); + const stdout = NodeChildProcess.execFileSync(process.execPath, [fixture, tempDir], { + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + timeout: 60_000, + }); + // The fixture's compiler may log warnings to stdout before the JSON payload. + const start = stdout.indexOf('{"tailwindChecks"'); + if (start === -1) { + throw new Error(`Fixture produced no JSON:\n${stdout}`); + } + return JSON.parse(stdout.slice(start)) as FixtureOutput; + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } +}; + +describe("uniwind platform variants compile per platform", () => { + let output: FixtureOutput; + + beforeAll(() => { + output = runFixture('@import "tailwindcss";\n@import "uniwind";\n', FIXTURE_SOURCE); + }, 60_000); + + it("compiles the fixture utilities into shared platform blocks", () => { + // Without this grouping the leak could not happen and the assertions below + // would prove nothing. + expect(output.tailwindChecks).toEqual({ + iosBlocks: 1, + androidBlocks: 1, + iosUtilities: 3, + androidUtilities: 3, + }); + }); + + it("keeps ios: utilities out of the android bundle and guards them on ios", () => { + for (const className of IOS_CLASSES) { + expect( + output.platforms.android?.styles[className], + `android has ${className}`, + ).toBeUndefined(); + expect(output.platforms.ios?.styles[className], `ios lacks ${className}`).toBeDefined(); + expect(output.platforms.ios?.styles[className]?.every((style) => style.native)).toBe(true); + } + }); + + it("keeps android: utilities out of the ios bundle and guards them on android", () => { + for (const className of ANDROID_CLASSES) { + expect(output.platforms.ios?.styles[className], `ios has ${className}`).toBeUndefined(); + expect( + output.platforms.android?.styles[className], + `android lacks ${className}`, + ).toBeDefined(); + expect(output.platforms.android?.styles[className]?.every((style) => style.native)).toBe( + true, + ); + } + }); + + it("keeps shared and responsive utilities usable on both platforms", () => { + for (const className of [...SHARED_CLASSES, ...RESPONSIVE_CLASSES]) { + for (const platform of ["ios", "android"]) { + const styles = output.platforms[platform]?.styles[className]; + expect(styles, `${platform} lacks ${className}`).toBeDefined(); + expect(styles?.every((style) => !style.native)).toBe(true); + } + } + // The leak hid a second regression: only the first utility of a width + // block kept its breakpoint, so the second applied at every screen size. + for (const platform of ["ios", "android"]) { + for (const className of RESPONSIVE_CLASSES) { + const style = output.platforms[platform]?.styles[className]?.at(-1); + expect(style?.minWidth, `${className} lost its breakpoint`).toBeGreaterThan(0); + } + } + }); + + it("serializes every compiled style into the platform payload and leaks nothing", () => { + for (const platform of ["ios", "android"]) { + expect(output.platforms[platform]?.payloadIncludesAllCompiled).toBe(true); + // A platform utility dropped from this platform's stylesheet must not + // survive anywhere in the payload this platform bundles. + expect(output.platforms[platform]?.payloadLeaks, `${platform} payload leaks`).toEqual([]); + } + }); +}); + +// The media-query fix must not lose the surrounding rule context: Tailwind +// also emits media rules *nested inside a class rule* (`@utility` bodies with +// `@variant`, nested breakpoints), and those declarations belong to the +// enclosing class. These expectations match the pre-fix compiler output for +// the same inputs, captured against the unpatched package. +describe("uniwind keeps media rules nested inside class rules attached to the class", () => { + let nested: FixtureOutput; + + beforeAll(() => { + nested = runFixture( + [ + '@import "tailwindcss";', + '@import "uniwind";', + "", + ".container-x {", + " width: 100%;", + " @media (width >= 40rem) {", + " max-width: 40rem;", + " }", + "}", + "", + "@utility foo-x {", + " padding: 2px;", + " @variant ios {", + " padding: 3px;", + " }", + "}", + "", + ].join("\n"), + 'export const Probe = () =>
;\n', + ); + }, 60_000); + + it("keeps the nested breakpoint entry of a nested media rule", () => { + for (const platform of ["ios", "android"]) { + const entries = nested.platforms[platform]?.styles["container-x"]; + expect(entries, `${platform} lacks container-x`).toBeDefined(); + expect(entries?.some((style) => !style.native && style.minWidth === 0)).toBe(true); + expect(entries?.some((style) => !style.native && style.minWidth > 0)).toBe(true); + } + }); + + it("keeps @variant declarations inside @utility on their platform", () => { + const iosEntries = nested.platforms.ios?.styles["foo-x"]; + expect(iosEntries?.some((style) => !style.native)).toBe(true); + expect(iosEntries?.some((style) => style.native)).toBe(true); + + const androidEntries = nested.platforms.android?.styles["foo-x"]; + expect(androidEntries?.some((style) => !style.native)).toBe(true); + expect(androidEntries?.some((style) => style.native)).toBe(false); + }); +}); + +// The patch file carries three independent uniwind fixes (state/data selector +// variants, the Metro native-styles fingerprint, and this media-query scoping +// fix). Regenerating it for one fix must not silently drop the others — this +// suite exercises the shipped transformer and compiler for the other two. +describe("uniwind patch keeps pre-existing selector and transformer behavior", () => { + let output: FixtureOutput; + + beforeAll(() => { + output = runFixture( + '@import "tailwindcss";\n@import "uniwind";\n', + 'export const Probe = () =>
;\n', + ); + }, 60_000); + + it("keeps state and data variants conditioned, and rejects unsupported compounds", () => { + for (const platform of ["ios", "android"]) { + const styles = output.platforms[platform]?.styles; + expect(styles?.["active:opacity-50"]?.every((style) => style.active === true)).toBe(true); + expect(styles?.["disabled:opacity-50"]?.every((style) => style.disabled === true)).toBe(true); + expect(styles?.["data-x:underline"]?.every((style) => style.dataAttributes?.["data-x"])).toBe( + true, + ); + // `[aria-disabled="true"]` is not expressible at runtime; emitting it + // unconditionally (the pre-#9355 behavior) styles the element always. + expect(styles).not.toHaveProperty("aria-disabled:text-red-500"); + } + }); + + it("emits the global.css virtual module with a native styles fingerprint", () => { + expect(output.transformerCheck).toEqual({ + reinitPayload: true, + fingerprintArg: true, + themesArg: true, + }); + }); +}); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 508630b1e581..9514efdff424 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -33,14 +33,6 @@ export interface Preferences { /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; - /** - * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has - * no client-settings sync, so the legacy grouped thread list is opted into - * per device. Deliberately a fresh key (was `threadListV2Enabled`, an - * opt-out): sanitizing drops the old key, so every device resets to the - * default flat list — see `resolveThreadListV2Enabled`. - */ - readonly legacyThreadListEnabled?: boolean; /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ readonly planModeEnabled?: boolean; /** Model favorites belong to this device, like the web client setting. */ @@ -107,7 +99,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { composerEnterBehavior?: ComposerEnterBehavior; projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; - legacyThreadListEnabled?: boolean; planModeEnabled?: boolean; modelFavorites?: Preferences["modelFavorites"]; threadListSettledShelfExpanded?: boolean; @@ -176,9 +167,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.projectGroupingMode = parsed.projectGroupingMode; } - if (typeof parsed.legacyThreadListEnabled === "boolean") { - preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; - } if (typeof parsed.planModeEnabled === "boolean") { preferences.planModeEnabled = parsed.planModeEnabled; } diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 15cbd1d9a89f..b2fb759c5680 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -16,7 +16,7 @@ import { useCallback } from "react"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; -import { projectFaviconCache } from "../lib/projectFaviconCache"; +import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache"; import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; import { environmentSession, usePreparedConnection } from "./session"; import { useAtomQueryRunner } from "./use-atom-query-runner"; @@ -26,7 +26,7 @@ export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ - imageCache: projectFaviconCache, + imageCache: projectFaviconDatabaseCache, createUrl: assetEnvironment.createUrl, preparedConnection: environmentSession.preparedConnectionValueAtom, }); diff --git a/apps/mobile/src/state/client-cache-state.ts b/apps/mobile/src/state/client-cache-state.ts index c210c54f2fdd..0060e6ab0789 100644 --- a/apps/mobile/src/state/client-cache-state.ts +++ b/apps/mobile/src/state/client-cache-state.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import { Atom } from "effect/unstable/reactivity"; import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; -import { projectFaviconCache } from "../lib/projectFaviconCache"; +import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache"; import * as Runtime from "../lib/runtime"; export interface EnvironmentClientCacheSummary { @@ -74,8 +74,8 @@ export const clearClientCacheAtom = clientCacheRuntime .fn((scope: ClientCacheClearScope, get) => Effect.promise(() => scope.type === "all" - ? projectFaviconCache.clearAll() - : projectFaviconCache.clearEnvironment(scope.environmentId), + ? projectFaviconDatabaseCache.clearAll() + : projectFaviconDatabaseCache.clearEnvironment(scope.environmentId), ).pipe( Effect.andThen(MobileDatabase), Effect.flatMap((database) => diff --git a/apps/mobile/src/state/composer-attachment-uploads.ts b/apps/mobile/src/state/composer-attachment-uploads.ts index 352367c09a15..dec31f9ce039 100644 --- a/apps/mobile/src/state/composer-attachment-uploads.ts +++ b/apps/mobile/src/state/composer-attachment-uploads.ts @@ -4,6 +4,7 @@ import { Atom } from "effect/unstable/reactivity"; import { useEffect, useRef } from "react"; import { prepareTurnAttachments } from "../lib/attachmentUpload"; +import { retainComposerAttachmentFileForPreview } from "../lib/composerAttachmentPreviewRetention"; import { isFileBackedComposerAttachment } from "../lib/composerImages"; import { composerAttachmentUploadKey, @@ -20,7 +21,6 @@ import { composerDraftsAtom, ensureComposerDraftsLoaded, flushComposerDrafts, - retainComposerAttachmentFileForPreview, setComposerDraftAttachmentUpload, } from "./use-composer-drafts"; import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; diff --git a/apps/mobile/src/features/threads/legacy-plan-mode.test.ts b/apps/mobile/src/state/legacy-plan-mode.test.ts similarity index 100% rename from apps/mobile/src/features/threads/legacy-plan-mode.test.ts rename to apps/mobile/src/state/legacy-plan-mode.test.ts diff --git a/apps/mobile/src/features/threads/legacy-plan-mode.ts b/apps/mobile/src/state/legacy-plan-mode.ts similarity index 100% rename from apps/mobile/src/features/threads/legacy-plan-mode.ts rename to apps/mobile/src/state/legacy-plan-mode.ts diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 3d2231fb3ac3..c53dc34e7df4 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -26,7 +26,7 @@ import * as Schema from "effect/Schema"; import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; -import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; +import { resolveProviderInteractionMode } from "./legacy-plan-mode"; // Keep current writes until a compatible native baseline includes the v4 reader. const THREAD_OUTBOX_SCHEMA_VERSION = 3; diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 6da72860009e..fa6b2f303f70 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -179,7 +179,6 @@ import { removeComposerDraftsForEnvironment, replaceComposerDraftAttachments, resetComposerDraftsLoadState, - retainComposerAttachmentFileForPreview, restoreComposerDraftSnapshotState, restoreCloudComposerDrafts, retargetNewTaskDraft, @@ -194,6 +193,7 @@ import { undoComposerDraftMerge, undoComposerDraftMergeState, } from "./use-composer-drafts"; +import { retainComposerAttachmentFileForPreview } from "../lib/composerAttachmentPreviewRetention"; const DRAFT: ComposerDraft = { text: "hello", @@ -676,7 +676,7 @@ describe("mobile composer drafts", () => { return undefined; }); const key = "environment-1:replace-file"; - const files = Array.from({ length: 8 }, (_, index) => ({ + const files = Array.from({ length: 100 }, (_, index) => ({ id: `file-${index}`, type: "file" as const, name: `notes-${index}.txt`, @@ -687,8 +687,8 @@ describe("mobile composer drafts", () => { appendComposerDraftAttachments(key, files, { appendReference: true }); const firstLink = "[notes-0.txt](t3-context://v1/file/file-0)"; const insertion = captureComposerDraftInsertion(key, { start: 0, end: firstLink.length }); - expect(countComposerDraftAttachmentsAfterSelection(key, insertion)).toBe(7); - expect(getComposerDraftAfterSelection(key, insertion).context?.records).toHaveLength(7); + expect(countComposerDraftAttachmentsAfterSelection(key, insertion)).toBe(99); + expect(getComposerDraftAfterSelection(key, insertion).context?.records).toHaveLength(99); const replacement = { ...files[0]!, id: "replacement", fileUri: "file:///replacement.txt" }; if (kind === "attachment") { expect( @@ -716,7 +716,7 @@ describe("mobile composer drafts", () => { insertion, ), ).toBe(true); - expect(getComposerDraftSnapshot(key).attachments).toHaveLength(8); + expect(getComposerDraftSnapshot(key).attachments).toHaveLength(100); expect(getComposerDraftSnapshot(key).context?.records).toContainEqual(record); expect(getComposerDraftSnapshot(key).text).toBe( `${formatComposerContextReference(record)}${insertion.text.slice(firstLink.length)}`, @@ -730,7 +730,7 @@ describe("mobile composer drafts", () => { } const draft = getComposerDraftSnapshot(key); expect(draft.attachments.map((file) => file.id)).not.toContain("file-0"); - expect(draft.attachments.slice(0, 7)).toEqual(files.slice(1)); + expect(draft.attachments.slice(0, 99)).toEqual(files.slice(1)); expect(draft.context?.records.some((record) => record.contextId === "file-0")).toBe(false); await cleanup.promise; expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(files[0]!.fileUri); @@ -746,7 +746,7 @@ describe("mobile composer drafts", () => { return undefined; }); const key = "environment-1:concurrent-import"; - const files = Array.from({ length: 8 }, (_, index) => ({ + const files = Array.from({ length: 100 }, (_, index) => ({ id: `existing-${index}`, type: "file" as const, name: `notes-${index}.txt`, @@ -906,7 +906,7 @@ describe("mobile composer drafts", () => { fileUri: `file:///documents/t3-composer-attachments/${id}.mov`, }); const draftKey = "new-task:environment-1:project-cap"; - const existing = Array.from({ length: 7 }, (_, index) => makeAttachment(`held-${index}`)); + const existing = Array.from({ length: 99 }, (_, index) => makeAttachment(`held-${index}`)); appAtomRegistry.set(composerDraftsAtom, { [draftKey]: { text: "send this", attachments: existing }, }); @@ -918,7 +918,7 @@ describe("mobile composer drafts", () => { expect(rejected).toBe(1); const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey]; - expect(draft?.attachments).toHaveLength(8); + expect(draft?.attachments).toHaveLength(100); expect(draft?.attachments.at(-1)?.id).toBe("incoming-1"); await cleanup.promise; expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledExactlyOnceWith( @@ -932,7 +932,7 @@ describe("mobile composer drafts", () => { { allowOverflow: true }, ); expect(overflowRejected).toBe(0); - expect(appAtomRegistry.get(composerDraftsAtom)[draftKey]?.attachments).toHaveLength(9); + expect(appAtomRegistry.get(composerDraftsAtom)[draftKey]?.attachments).toHaveLength(101); }); it("keeps shared attachment files until every draft releases them", async () => { @@ -2270,7 +2270,7 @@ describe("mobile composer drafts", () => { previewUri: "data:image/png;base64,YWJj", }); const existingImage = image("existing"); - const sharedImages = Array.from({ length: 8 }, (_, index) => image(`shared-${index}`)); + const sharedImages = Array.from({ length: 100 }, (_, index) => image(`shared-${index}`)); const merged = mergeComposerDraftContentState( { [draftKey]: { text: "", attachments: [existingImage] } }, @@ -2278,9 +2278,9 @@ describe("mobile composer drafts", () => { { text: "", attachments: sharedImages }, ); - expect(merged[draftKey]?.attachments).toHaveLength(8); + expect(merged[draftKey]?.attachments).toHaveLength(100); expect(merged[draftKey]?.attachments[0]).toEqual(existingImage); - expect(merged[draftKey]?.attachments.at(-1)?.id).toBe("shared-6"); + expect(merged[draftKey]?.attachments.at(-1)?.id).toBe("shared-98"); }); it("restores the exact draft captured before an interrupted share import", () => { diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 734199dcef5e..de886d54fe91 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -35,8 +35,11 @@ import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; import { composerAttachmentFileReferenceKey, isComposerAttachmentFileRetained, - retainComposerAttachmentFile, } from "../lib/composerAttachmentFiles"; +import { + registerComposerAttachmentUnusedHandler, + retainComposerAttachmentFileForPreview, +} from "../lib/composerAttachmentPreviewRetention"; import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages"; import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; import { appAtomRegistry } from "./atom-registry"; @@ -957,14 +960,14 @@ export function scheduleUnusedComposerAttachmentCleanup( }); } -/** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */ -export function retainComposerAttachmentFileForPreview( - attachment: FileBackedComposerAttachment, -): () => void { - return retainComposerAttachmentFile(attachment.fileUri, () => { - scheduleUnusedComposerAttachmentCleanup([attachment]); - }); -} +/** + * Owner-side cleanup hook for the shared preview-retention helper: releasing + * the last preview/upload lease retries the unused-file sweep. Registered here + * because this module owns the draft and outbox references the sweep reads. + */ +registerComposerAttachmentUnusedHandler((attachment) => { + scheduleUnusedComposerAttachmentCleanup([attachment]); +}); function schedulePersistComposerState(): void { if (persistTimer !== null) { diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index d21af7d79cc3..051fe543ea2b 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -30,7 +30,7 @@ import { uuidv4 } from "../lib/uuid"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; import { isModelSelectionUnavailable } from "../lib/modelOptions"; -import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; +import { resolveProviderInteractionMode } from "./legacy-plan-mode"; import { convertPastedImagesToAttachments, createPastedTextComposerAttachment, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 1147e9fd47af..022c0ad6addb 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 { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; import { Alert } from "react-native"; +import { createDebugLogger } from "../lib/debugLog"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; import { serializeComposerMessageForServer, uploadedComposerContext } from "../lib/composerContext"; @@ -55,6 +56,7 @@ import { type QueuedThreadCreation, type QueuedThreadMessage, type ThreadOutboxCommandStage, + type ThreadOutboxFailureAction, } from "./thread-outbox-model"; import { environmentThreadShells, threadEnvironment } from "./threads"; import { @@ -82,6 +84,92 @@ import { useRemoteConnectionStatus, } from "./use-remote-environment-registry"; +// Ordinary offline behavior (a socket dropping mid-request, a retryable +// attachment upload failure) must not spam `console.warn` on every backoff +// retry; it goes to the filterable `[t3-thread-outbox]` debug log instead. +// Failures the server decided stay on `console.warn`. +const threadOutboxDebug = createDebugLogger("thread-outbox"); + +/** + * On the queued-request path (settings sync, startTurn) the RPC client + * reports ordinary transport drops as the raw socket/worker reason tags, and + * reserves `RpcClientDefect` for client-side protocol violations and decoding + * failures — unlike the shared config-subscription stream, which + * deliberately re-wraps transport causes under that tag. Defects still retry, + * but they are not ordinary offline behavior and must not hide behind the + * offline debug log. + */ +function isRpcClientDecodeDefect(error: unknown): boolean { + if ( + typeof error !== "object" || + error === null || + !("_tag" in error) || + error._tag !== "RpcClientError" + ) { + return false; + } + const reason: unknown = (error as { readonly reason?: unknown }).reason; + return ( + typeof reason === "object" && + reason !== null && + "_tag" in reason && + reason._tag === "RpcClientDefect" + ); +} + +function isOrdinaryThreadOutboxTransportFailure(error: unknown): boolean { + return shouldRetryThreadOutboxDelivery(error) && !isRpcClientDecodeDefect(error); +} + +/** + * Logs one queued-message delivery failure and returns the retry-or-restore + * decision for the caller. Ordinary transport retries — what an offline + * device or a flapping socket produces on every backoff attempt — go to the + * debug log. Server-decided failures warn. Settings-sync failures always + * resolve to a retry even when the server rejected the command, so the + * error, not the resolved action, must decide the log level there; routing + * every retry to debug could hide a permanently rejected update forever. + */ +function logThreadOutboxDeliveryFailure(input: { + readonly stage: ThreadOutboxCommandStage; + readonly error: unknown; + readonly interrupted: boolean; + readonly context: Record; +}): ThreadOutboxFailureAction { + const action = resolveThreadOutboxFailureAction({ + stage: input.stage, + error: input.error, + interrupted: input.interrupted, + }); + const details = { ...input.context, stage: input.stage, action }; + const ordinaryTransportRetry = + action === "retry" && + !isRpcClientDecodeDefect(input.error) && + (input.interrupted || + input.stage !== "settings-sync" || + shouldRetryThreadOutboxDelivery(input.error)); + if (ordinaryTransportRetry) { + threadOutboxDebug.log("queued message delivery failed", details); + } else { + console.warn("[thread-outbox] queued message delivery failed", details); + } + return action; +} + +/** Attachment uploads retry like delivery: transport failures are ordinary offline noise. */ +function logThreadOutboxUploadFailure(queuedMessage: QueuedThreadMessage, error: unknown): void { + const context = { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + }; + if (isOrdinaryThreadOutboxTransportFailure(error)) { + threadOutboxDebug.log("attachment upload failed; retrying", { ...context, error }); + } else { + console.warn("[thread-outbox] failed to upload attachments", { ...context, error }); + } +} + function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void { appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId); } @@ -215,14 +303,13 @@ export async function completeQueuedMessageDelivery( ); if (!removed) { forgetAcknowledgedThreadMessage(queuedMessage); - console.warn( - "[thread-outbox] delivered message was edited before cleanup; keeping the newer message", - { - environmentId: queuedMessage.environmentId, - threadId: queuedMessage.threadId, - messageId: queuedMessage.messageId, - }, - ); + // Losing the cleanup race to a user edit is an expected outcome the + // caller handles by keeping the newer message; it is not a warning. + threadOutboxDebug.log("delivered message was edited before cleanup", { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + }); return "edited"; } return "removed"; @@ -667,18 +754,16 @@ export function useThreadOutboxDrain(): void { return null; } const error = Cause.squash(commandResult.cause); - const action = resolveThreadOutboxFailureAction({ + const action = logThreadOutboxDeliveryFailure({ stage, error, interrupted: Cause.hasInterruptsOnly(commandResult.cause), - }); - console.warn("[thread-outbox] queued message delivery failed", { - environmentId: queuedMessage.environmentId, - threadId: queuedMessage.threadId, - messageId: queuedMessage.messageId, - stage, - cause: commandResult.cause, - action, + context: { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + cause: commandResult.cause, + }, }); return { action, @@ -772,7 +857,7 @@ export function useThreadOutboxDrain(): void { return true; } } catch (error) { - console.warn("[thread-outbox] failed to upload attachments", error); + logThreadOutboxUploadFailure(queuedMessage, error); if (!shouldRetryThreadOutboxDelivery(error)) { return restoreQueuedMessage( queuedMessage, @@ -900,7 +985,7 @@ export function useThreadOutboxDrain(): void { return true; } } catch (error) { - console.warn("[thread-outbox] failed to upload attachments", error); + logThreadOutboxUploadFailure(queuedMessage, error); if (!shouldRetryThreadOutboxDelivery(error)) { return restoreQueuedMessage( queuedMessage, diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 7262239577b4..2c747fc71310 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -8,7 +8,11 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import { RPC_REQUIRED_SCOPES, requiredScopeForRpcMethod } from "./RpcAuthorization.ts"; +import { + RPC_REQUIRED_SCOPES, + requiredScopeForRpcMethod, + requiredScopeForDeviceList, +} from "./RpcAuthorization.ts"; describe("RPC authorization scopes", () => { it("declares exactly one scope for every RPC in the server group", () => { @@ -71,3 +75,17 @@ describe("RPC authorization scopes", () => { } }); }); + +it("requires operate permission for host retry while preserving read-only listing", () => { + expect(requiredScopeForDeviceList({})).toBe(AuthOrchestrationReadScope); + expect(requiredScopeForDeviceList({ retryHostId: "remote-host" })).toBe( + AuthOrchestrationOperateScope, + ); +}); + +it("requires operate permission for tool updates even alongside a read-only check", () => { + expect(requiredScopeForDeviceList({ updateTool: "agent", inspectOnly: true })).toBe( + AuthOrchestrationOperateScope, + ); + expect(requiredScopeForDeviceList({ updateTool: "hub" })).toBe(AuthOrchestrationOperateScope); +}); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 20f2893b8335..8ab1520a6f34 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -1,4 +1,5 @@ import { + type DeviceListInput, AuthAccessReadScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, @@ -36,6 +37,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.providerAuthStart]: AuthOrchestrationOperateScope, [WS_METHODS.providerConsumeResetCredit]: AuthOrchestrationOperateScope, [WS_METHODS.providerAuthComplete]: AuthOrchestrationOperateScope, + [WS_METHODS.providerAuthRespond]: AuthOrchestrationOperateScope, [WS_METHODS.providerAuthCancel]: AuthOrchestrationOperateScope, [WS_METHODS.providerAuthLogout]: AuthOrchestrationOperateScope, [WS_METHODS.providerAuthSubscribe]: AuthOrchestrationOperateScope, @@ -180,3 +182,9 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope } return requiredScope; } + +/** Retrying can install or restart tools even though ordinary listing is readable. */ +export const requiredScopeForDeviceList = (input: DeviceListInput): AuthEnvironmentScope => + input.retryHostId || input.updateTool + ? AuthOrchestrationOperateScope + : AuthOrchestrationReadScope; diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 3095a3a20b19..fc8fe914d797 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -15,6 +15,7 @@ import { } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; @@ -50,7 +51,6 @@ import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; -import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import packageJson from "../package.json" with { type: "json" }; diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 02958e1c9e69..c5e892974474 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -18,9 +18,9 @@ import { type DesktopBackendBootstrap as DesktopBackendBootstrapValue, } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { deriveServerPaths } from "../config.ts"; -import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { resolveServerConfig } from "./config.ts"; const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) => @@ -903,6 +903,109 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); + it.effect("zeroes an endpoint stored in Settings when the SDK is disabled", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-otel-off-" }); + const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); + yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); + yield* fs.writeFileString( + derivedPaths.settingsPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + `${JSON.stringify({ + observability: { + otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpLogsUrl: "http://localhost:4318/v1/logs", + }, + })}\n`, + ); + + const resolved = yield* resolveServerConfig( + { + mode: Option.some("desktop"), + port: Option.some(4888), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { OTEL_SDK_DISABLED: "true" } })), + NetService.layer, + ), + ), + ); + + // The switch beats every source, including an endpoint stored in Settings. + expect(resolved.otlpTracesUrl).toBeUndefined(); + expect(resolved.otlpMetricsUrl).toBeUndefined(); + expect(resolved.otlpLogsUrl).toBeUndefined(); + expect(resolved.otelEnvironment.disabled).toBe(true); + }), + ); + + it.effect("lets T3CODE_OTEL_SDK_DISABLED=false override an ambient OTEL_SDK_DISABLED=true", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-otel-on-" }); + const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); + yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); + yield* fs.writeFileString( + derivedPaths.settingsPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + `${JSON.stringify({ + observability: { + otlpTracesUrl: "http://localhost:4318/v1/traces", + }, + })}\n`, + ); + + const resolved = yield* resolveServerConfig( + { + mode: Option.some("desktop"), + port: Option.some(4888), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { T3CODE_OTEL_SDK_DISABLED: "false", OTEL_SDK_DISABLED: "true" }, + }), + ), + NetService.layer, + ), + ), + ); + + expect(resolved.otelEnvironment.disabled).toBe(false); + expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); + }), + ); + it.effect("does not let a blank bootstrap endpoint hide the stored one", () => Effect.gen(function* () { // The desktop sends the envelope whether or not it resolved an endpoint, @@ -1284,7 +1387,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); - it.effect("reads the OTLP protocol from env", () => + it.effect("gives every signal the protocol named without one", () => Effect.gen(function* () { const { join } = yield* Path.Path; const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-otlp-protocol-base"); @@ -1316,7 +1419,11 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { ), ); - expect(resolved.otlpTracesExport.protocol).toBe("http/protobuf"); + expect([ + resolved.otlpTracesExport.protocol, + resolved.otlpMetricsExport.protocol, + resolved.otlpLogsExport.protocol, + ]).toEqual(["http/protobuf", "http/protobuf", "http/protobuf"]); }), ); diff --git a/apps/server/src/device/DeviceHost.ts b/apps/server/src/device/DeviceHost.ts index a947f8dc9e68..14537985b9b8 100644 --- a/apps/server/src/device/DeviceHost.ts +++ b/apps/server/src/device/DeviceHost.ts @@ -80,6 +80,7 @@ export class DeviceHost extends Context.Service< { readonly id: DeviceHostId; readonly summary: Effect.Effect; + readonly inspect?: Effect.Effect; readonly platformAvailability: ( platform: DevicePlatform, ) => Effect.Effect; @@ -88,11 +89,11 @@ export class DeviceHost extends Context.Service< * concurrent callers share one start, and a ready host returns immediately. */ readonly ensureReady: ( - onPhase: (phase: "installing" | "starting") => Effect.Effect, + onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, ) => Effect.Effect; /** Installs and starts agent-device after the user grants agent access. */ readonly ensureAgentReady: ( - onPhase: (phase: "installing" | "starting") => Effect.Effect, + onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, ) => Effect.Effect< DeviceHostAgentReady, DeviceHostError | DeviceHostTimeoutError | NodeRuntimeUnavailableError diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index f1ed9a7f0253..7a81d757c0ac 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -2,15 +2,18 @@ import { describe, expect, it } from "@effect/vitest"; import { DEFAULT_SERVER_SETTINGS, DeviceId, + DeviceOperationError, LOCAL_DEVICE_HOST_ID, ThreadId, type DeviceServiceState, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerSettingsService } from "../serverSettings.ts"; @@ -19,6 +22,8 @@ import { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime"; import { type DeviceService, makeWithHosts, stateStream } from "./DeviceService.ts"; +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + const baseState: DeviceServiceState = { hosts: [], hostStatus: "idle", @@ -62,7 +67,9 @@ const fixture = Effect.fn("fixture")(function* ( onBoot: Effect.Effect = Effect.void, bootError?: string, failListAfterShutdown = false, - runtimeFailure?: NodeRuntimeUnavailableError, + runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError, + inspectError = false, + installTool?: Parameters[3], ) { const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); const starts: string[] = []; @@ -78,6 +85,17 @@ const fixture = Effect.fn("fixture")(function* ( run: () => Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }), }; const host: DeviceHost.DeviceHost["Service"] = { + ...(inspectError + ? { + inspect: Effect.fail( + new DeviceHost.DeviceHostError({ + hostId: LOCAL_DEVICE_HOST_ID, + step: "probe", + cause: new Error("offline"), + }), + ), + } + : {}), id: LOCAL_DEVICE_HOST_ID, summary: Effect.succeed({ id: LOCAL_DEVICE_HOST_ID, @@ -92,7 +110,7 @@ const fixture = Effect.fn("fixture")(function* ( Effect.gen(function* () { if (runtimeFailure) return yield* runtimeFailure; starts.push("start"); - yield* onPhase("starting"); + yield* onPhase("installing", "Updating device hub from 0.9.0 to 0.10.1…"); return ready; }), ensureAgentReady: (onPhase) => @@ -113,7 +131,12 @@ const fixture = Effect.fn("fixture")(function* ( starts.push("stop"); }), }; - const service = yield* makeWithHosts(new Map([[host.id, host]])).pipe( + const service = yield* makeWithHosts( + new Map([[host.id, host]]), + undefined, + undefined, + installTool, + ).pipe( Effect.provideService(DeviceHost.DeviceHost, host), Effect.provideService( ServerSettingsService, @@ -378,3 +401,333 @@ it.effect("keeps shutdown successful when subsequent discovery fails", () => expect(state.devices.find((device) => device.id === session.deviceId)?.booted).toBe(false); }).pipe(Effect.scoped), ); + +it.effect.each(["shutdown", "close"] as const)( + "%s releases iOS capture so reopening uses a fresh session", + (operation) => + Effect.gen(function* () { + const deviceId = DeviceId.make("11111111-1111-1111-1111-111111111111"); + const threadId = ThreadId.make("capture-recovery"); + let booted = true; + let capture: number | null = null; + let generation = 0; + const ready: DeviceHost.DeviceHostReady = { + nodePath: process.execPath, + hub: { origin: "http://device.test" }, + helpers: { serveSimAxSettings: null, serveSimCli: null }, + run: () => Effect.succeed({ code: 0, stdout: "", stderr: "" }), + }; + const host: DeviceHost.DeviceHost["Service"] = { + id: LOCAL_DEVICE_HOST_ID, + summary: Effect.succeed({ + id: LOCAL_DEVICE_HOST_ID, + kind: "local", + label: "Simulator host", + platforms: [{ platform: "ios", available: true }], + hubInstalled: true, + agentDeviceInstalled: false, + }), + platformAvailability: (platform) => Effect.succeed({ platform, available: true }), + ensureReady: () => Effect.succeed(ready), + ensureAgentReady: () => Effect.die("Agent access is not used in this test"), + current: Effect.succeed(ready), + stopAgent: Effect.void, + stop: Effect.void, + }; + const http = HttpClient.make((request) => + Effect.sync(() => { + const path = new URL(request.url).pathname; + if (path === "/api/devices") { + return HttpClientResponse.fromWeb( + request, + Response.json({ + emulators: [], + simulators: [ + { + id: deviceId, + name: "iPhone", + platform: "ios", + version: "26", + physical: false, + booted, + }, + ], + }), + ); + } + if (path === "/vendor/serve-sim/grid/api/start") capture ??= ++generation; + else if (path === "/vendor/serve-sim/grid/api/shutdown") { + if (request.body._tag !== "Uint8Array") throw new Error("Missing shutdown body"); + expect(decodeJson(new TextDecoder().decode(request.body.body))).toEqual({ + udid: deviceId, + }); + capture = null; + booted = false; + } else if (path === "/api/devices/shutdown") { + // This route powers off without releasing serve-sim's cached capture. + booted = false; + } else if (path === "/api/devices/boot") booted = true; + else throw new Error(`Unexpected hub path: ${path}`); + return HttpClientResponse.fromWeb(request, Response.json({ ok: true, id: deviceId })); + }), + ); + const service = yield* makeWithHosts(new Map([[host.id, host]])).pipe( + Effect.provideService(HttpClient.HttpClient, http), + ); + const input = { threadId, deviceId, platform: "ios" as const }; + yield* service.open(input); + expect(capture).toBe(1); + if (operation === "shutdown") yield* service.shutdown(input); + else yield* service.close({ threadId, deviceId, shutdown: true }); + expect(capture).toBeNull(); + expect((yield* service.state).sessions).toEqual([]); + yield* service.open(input); + expect(capture).toBe(2); + expect((yield* service.state).sessions).toHaveLength(1); + }).pipe( + Effect.provide(ServerSettingsService.layerTest({ enableDeviceSupport: true })), + Effect.scoped, + ), +); + +it.effect.each([ + { hubReports: "off", outcome: "succeeds" }, + { hubReports: "booted", outcome: "fails" }, + { hubReports: "missing", outcome: "fails" }, +] as const)( + "iOS shutdown $outcome when serve-sim rejects it and the hub reports the simulator $hubReports", + ({ hubReports, outcome }) => + Effect.gen(function* () { + const deviceId = DeviceId.make("22222222-2222-2222-2222-222222222222"); + const paths: string[] = []; + // The device list is stale until shutdown re-reads it from the hub. + let listed: "booted" | "off" | "missing" = "booted"; + const ready: DeviceHost.DeviceHostReady = { + nodePath: process.execPath, + hub: { origin: "http://device.test" }, + helpers: { serveSimAxSettings: null, serveSimCli: null }, + run: () => Effect.succeed({ code: 0, stdout: "", stderr: "" }), + }; + const host: DeviceHost.DeviceHost["Service"] = { + id: LOCAL_DEVICE_HOST_ID, + summary: Effect.succeed({ + id: LOCAL_DEVICE_HOST_ID, + kind: "local", + label: "Simulator host", + platforms: [{ platform: "ios", available: true }], + hubInstalled: true, + agentDeviceInstalled: false, + }), + platformAvailability: (platform) => Effect.succeed({ platform, available: true }), + ensureReady: () => Effect.succeed(ready), + ensureAgentReady: () => Effect.die("Agent access is not used in this test"), + current: Effect.succeed(ready), + stopAgent: Effect.void, + stop: Effect.void, + }; + const http = HttpClient.make((request) => + Effect.sync(() => { + const path = new URL(request.url).pathname; + paths.push(path); + if (path === "/api/devices") { + return HttpClientResponse.fromWeb( + request, + Response.json({ + emulators: [], + simulators: + listed === "missing" + ? [] + : [ + { + id: deviceId, + name: "iPhone", + platform: "ios", + version: "26", + physical: false, + booted: listed === "booted", + }, + ], + // A partial listing still decodes; it must not read as "off". + errors: listed === "missing" ? [{ message: "simctl list failed" }] : [], + }), + ); + } + if (path === "/vendor/serve-sim/grid/api/shutdown") { + // serve-sim runs `simctl shutdown` bare and returns its failure as-is. + listed = hubReports; + return HttpClientResponse.fromWeb( + request, + Response.json( + { ok: false, error: "Unable to shutdown device in current state: Shutdown" }, + { status: 500 }, + ), + ); + } + throw new Error(`Unexpected hub path: ${path}`); + }), + ); + const service = yield* makeWithHosts(new Map([[host.id, host]])).pipe( + Effect.provideService(HttpClient.HttpClient, http), + ); + yield* service.list; + const exit = yield* Effect.exit(service.shutdown({ deviceId, platform: "ios" })); + expect(paths.filter((path) => path.endsWith("shutdown"))).toEqual([ + "/vendor/serve-sim/grid/api/shutdown", + ]); + if (outcome === "succeeds") { + expect(Exit.isSuccess(exit)).toBe(true); + expect( + (yield* service.state).devices.find((device) => device.id === deviceId)?.booted, + ).toBe(false); + } else { + expect(Exit.isFailure(exit)).toBe(true); + expect( + (yield* service.state).devices.find((device) => device.id === deviceId)?.booted, + ).toBe(true); + } + }).pipe( + Effect.provide(ServerSettingsService.layerTest({ enableDeviceSupport: true })), + Effect.scoped, + ), +); + +it.effect("retry keeps device and agent consent unchanged", () => + Effect.gen(function* () { + const { service, starts, agentStarts } = yield* fixture(); + yield* service.retryHost(LOCAL_DEVICE_HOST_ID); + expect(starts).toEqual([]); + expect(agentStarts).toEqual([]); + yield* service.configure({ enabled: true }); + yield* service.retryHost(LOCAL_DEVICE_HOST_ID); + expect(agentStarts).toEqual([]); + yield* service.configure({ agentAccessEnabled: true }); + const before = agentStarts.length; + yield* service.retryHost(LOCAL_DEVICE_HOST_ID); + expect(agentStarts.length).toBe(before + 1); + }).pipe(Effect.scoped), +); + +it.effect("publishes update detail for the correct host", () => + Effect.gen(function* () { + const { service } = yield* fixture(); + const changes = yield* service.subscribe; + yield* service.configure({ enabled: true }); + const states = yield* PubSub.takeAll(changes); + expect( + states.some( + (state) => state.hostStatuses.local?.detail === "Updating device hub from 0.9.0 to 0.10.1…", + ), + ).toBe(true); + }).pipe(Effect.scoped), +); + +it.effect("host retry exposes actionable failure without internal IDs or diagnostics", () => + Effect.gen(function* () { + const { service, settings } = yield* fixture( + Effect.void, + undefined, + false, + new DeviceHost.DeviceHostError({ + hostId: LOCAL_DEVICE_HOST_ID, + step: "probe", + cause: "private diagnostics", + }), + ); + yield* Ref.update(settings, (current) => ({ ...current, enableDeviceSupport: true })); + const state = yield* service.retryHost(LOCAL_DEVICE_HOST_ID); + expect(state.supportsHostRetry).toBe(true); + expect(state.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ + status: "failed", + detail: "Could not connect to this host over SSH.", + }); + }).pipe(Effect.scoped), +); + +it.effect("version discovery does not grant consent or start device tools", () => + Effect.gen(function* () { + const { service, starts, agentStarts, requests } = yield* fixture(); + const state = yield* service.inspect; + expect(state.supportsToolInspection).toBe(true); + expect(state.hostStatus).toBe("disabled"); + expect(state.hosts).toHaveLength(1); + expect(starts).toEqual([]); + expect(agentStarts).toEqual([]); + expect(requests).toEqual([]); + }).pipe(Effect.scoped), +); + +it.effect("failed read-only discovery preserves lifecycle status and installed inventory", () => + Effect.gen(function* () { + const { service, starts } = yield* fixture(Effect.void, undefined, false, undefined, true); + const state = yield* service.inspect; + expect(state.supportsToolInspection).toBe(true); + expect(state.hostStatus).toBe("disabled"); + expect(state.hosts[0]?.hubInstalled).toBe(true); + expect(state.hosts[0]?.toolInspectionError).toContain("Reconnect the host"); + expect(starts).toEqual([]); + }).pipe(Effect.scoped), +); + +it.effect( + "manual updates install only the selected tool without enabling access or starting helpers", + () => + Effect.gen(function* () { + const installed: string[] = []; + const { service, starts, agentStarts, requests } = yield* fixture( + Effect.void, + undefined, + false, + undefined, + false, + (tool) => + Effect.sync(() => { + installed.push(tool); + }), + ); + const before = yield* service.state; + const state = yield* service.updateTool("agent"); + expect(installed).toEqual(["agent"]); + expect(state.supportsToolUpdate).toBe(true); + expect(state.hostStatus).toBe(before.hostStatus); + expect(state.agentAccessEnabled).toBe(before.agentAccessEnabled); + expect(state.revision).toBeGreaterThan(before.revision); + expect(starts).toEqual([]); + expect(agentStarts).toEqual([]); + expect(requests).toEqual([]); + yield* service.updateTool("hub"); + expect(installed).toEqual(["agent", "hub"]); + }).pipe(Effect.scoped), +); + +it.effect("failed manual installation leaves lifecycle state unchanged and can be retried", () => + Effect.gen(function* () { + let attempts = 0; + const { service, starts, agentStarts } = yield* fixture( + Effect.void, + undefined, + false, + undefined, + false, + () => + Effect.suspend(() => + ++attempts === 1 + ? Effect.fail( + new DeviceOperationError({ + operation: "update device tool", + reason: "command_failed", + cause: new Error("offline"), + }), + ) + : Effect.void, + ), + ); + const before = yield* service.state; + const result = yield* service.updateTool("agent").pipe(Effect.result); + expect(result._tag).toBe("Failure"); + expect(yield* service.state).toEqual(before); + yield* service.updateTool("agent"); + expect(attempts).toBe(2); + expect(starts).toEqual([]); + expect(agentStarts).toEqual([]); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 30e1f18c0497..2435fbca34ab 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -38,7 +38,7 @@ import { import * as FileSystem from "effect/FileSystem"; import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; import * as Path from "effect/Path"; -import { ensureAgentDevice } from "./DeviceToolchain.ts"; +import { ensureAgentDevice, ensureDeviceHub } from "./DeviceToolchain.ts"; import * as ServerConfig from "../config.ts"; import { agentDeviceConfigPath, @@ -126,6 +126,9 @@ export class DeviceService extends Context.Service< ) => Effect.Effect; /** Refreshes devices only after device support has been enabled. */ readonly list: Effect.Effect; + readonly updateTool: (tool: "hub" | "agent") => Effect.Effect; + readonly inspect: Effect.Effect; + readonly retryHost: (hostId: DeviceHostId) => Effect.Effect; readonly open: (input: DeviceOpenInput) => Effect.Effect; readonly close: (input: DeviceCloseInput) => Effect.Effect; readonly shutdown: (input: DeviceShutdownInput) => Effect.Effect; @@ -180,6 +183,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* reason: "Agent configuration is unavailable in this device service.", }), ), + installTool?: (tool: "hub" | "agent") => Effect.Effect, ) { const settings = yield* ServerSettings.ServerSettingsService; const lifecycleLock = yield* Semaphore.make(1); @@ -202,6 +206,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* let publishedHosts = new Map(hosts); const stateRef = yield* SynchronizedRef.make({ state: { + supportsHostRetry: true, + supportsToolUpdate: installTool !== undefined, + supportsToolInspection: true, hosts: initialHosts, hostStatus: initialSettings.enabled ? "idle" : "disabled", hostStatuses: {}, @@ -257,7 +264,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }); } const ready = yield* host - .ensureReady((status) => setHostStatus(host.id, { status }).pipe(Effect.asVoid)) + .ensureReady((status, detail) => + setHostStatus(host.id, { status, detail }).pipe(Effect.asVoid), + ) .pipe( Effect.tapError((error) => setHostStatus(host.id, { status: "failed", detail: error.message }), @@ -269,7 +278,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* reason: error._tag === "NodeRuntimeUnavailableError" ? nodeRuntimeUnavailableMessage("Local device support") - : `Device host ${error.hostId} failed while ${error.step}.`, + : error.step === "probe" + ? "Could not connect to this host over SSH." + : `Device support failed during ${error.step}.`, cause: error, }), ), @@ -308,7 +319,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available)) return null; const ready = yield* host - .ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid)) + .ensureAgentReady((phase, detail) => + setHostStatus(host.id, { status: phase, detail }).pipe(Effect.asVoid), + ) .pipe( Effect.tapError((error) => setHostStatus(host.id, { status: "failed", detail: error.message }), @@ -321,8 +334,10 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* error._tag === "NodeRuntimeUnavailableError" ? nodeRuntimeUnavailableMessage("Local device support") : error._tag === "DeviceHostTimeoutError" - ? `Device host ${error.hostId} did not start agent tools within ${error.timeoutMs} ms.` - : `Device host ${error.hostId} failed while ${error.step}.`, + ? `Agent tools did not start within ${error.timeoutMs} ms.` + : error.step === "probe" + ? "Could not connect to this host over SSH." + : `Device support failed during ${error.step}.`, cause: error, }), ), @@ -444,7 +459,10 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* if (ready) yield* refresh(ready); }).pipe( Effect.catch((error) => - setHostStatus(host.id, { status: "failed", detail: error.message }), + setHostStatus(host.id, { + status: "failed", + detail: error._tag === "DeviceHostUnavailableError" ? error.reason : error.message, + }), ), ), { concurrency: 4 }, @@ -452,6 +470,58 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* return (yield* SynchronizedRef.get(stateRef)).state; }).pipe(Effect.withSpan("DeviceService.list")); + const inspect = Effect.gen(function* () { + yield* Effect.forEach( + hosts.values(), + (host) => + Effect.gen(function* () { + const result = yield* (host.inspect ?? host.summary).pipe(Effect.result); + if (hosts.get(host.id) !== host) return; + if (result._tag === "Failure") { + yield* publish((state) => ({ + ...state, + hosts: state.hosts.map((value) => + value.id === host.id + ? { + ...value, + toolInspectionError: + "Cannot check versions. Reconnect the host and check again. Installed tools have not been changed.", + } + : value, + ), + })); + return; + } + yield* publish((state) => ({ + ...state, + hosts: state.hosts.map((value) => (value.id === host.id ? result.success : value)), + })); + }), + { concurrency: 4 }, + ); + return (yield* SynchronizedRef.get(stateRef)).state; + }); + + const retryHost: DeviceService["Service"]["retryHost"] = Effect.fn("DeviceService.retryHost")( + function* (hostId) { + yield* resolveHost(hostId); + if (!(yield* readDeviceSettings).enabled) return (yield* SynchronizedRef.get(stateRef)).state; + yield* Effect.gen(function* () { + const ready = + (yield* agentReadinessIfSupported(hostId)) ?? (yield* readinessIfSupported(hostId)); + if (ready) yield* refresh(ready); + }).pipe( + Effect.catch((error) => + setHostStatus(hostId, { + status: "failed", + detail: error._tag === "DeviceHostUnavailableError" ? error.reason : error.message, + }), + ), + ); + return (yield* SynchronizedRef.get(stateRef)).state; + }, + ); + const configure: DeviceService["Service"]["configure"] = Effect.fn("DeviceService.configure")( function* (input) { const currentSettings = yield* readDeviceSettings; @@ -670,25 +740,44 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* platform: DevicePlatform, ) { const ready = yield* readiness(hostId); - yield* HttpClientRequest.post(`${ready.hub.origin}/api/devices/shutdown`).pipe( - HttpClientRequest.bodyJson({ platform, id: deviceId }), - Effect.mapError( - (cause) => - new DeviceOperationError({ operation: "shutdown", reason: "invalid_payload", cause }), - ), - Effect.flatMap((request) => hubJson(request, HubActionResult, "shutdown")), - Effect.flatMap((result) => - result.ok - ? Effect.void - : Effect.fail( - new DeviceOperationError({ - operation: "shutdown", - reason: "hub_rejected", - cause: result, - }), + const postShutdown = (path: string, body: Record) => + HttpClientRequest.post(`${ready.hub.origin}${path}`).pipe( + HttpClientRequest.bodyJson(body), + Effect.mapError( + (cause) => + new DeviceOperationError({ operation: "shutdown", reason: "invalid_payload", cause }), + ), + Effect.flatMap((request) => hubJson(request, HubActionResult, "shutdown")), + Effect.flatMap((result) => + result.ok + ? Effect.void + : Effect.fail( + new DeviceOperationError({ + operation: "shutdown", + reason: "hub_rejected", + cause: result, + }), + ), + ), + ); + // serve-sim's shutdown closes its in-process capture session before it runs + // `simctl shutdown`; the hub's generic shutdown can leave that session cached + // across a reboot. serve-sim runs simctl bare, though, so a simulator that is + // already off fails there. Accept that failure only when the hub confirms + // the simulator is off; a failure on a running one still surfaces. + yield* platform === "ios" + ? postShutdown(`${vendorPrefix("ios")}/grid/api/shutdown`, { udid: deviceId }).pipe( + Effect.catch((cause) => + fetchDevices(ready).pipe( + Effect.flatMap(({ devices }) => + devices.find((device) => device.id === deviceId)?.booted === false + ? Effect.logInfo("iOS simulator was already shut down", { deviceId }) + : Effect.fail(cause), + ), ), - ), - ); + ), + ) + : postShutdown("/api/devices/shutdown", { platform, id: deviceId }); yield* publish((state) => ({ ...state, devices: state.devices.map((device) => @@ -813,6 +902,23 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* return { ...DeviceService.of({ testHost, + updateTool: (tool) => + lifecycleLock.withPermit( + Effect.gen(function* () { + if (!installTool) + return yield* Effect.fail( + new DeviceOperationError({ + operation: "update device tool", + reason: "request_failed", + cause: new Error("Tool installation is unavailable in this device service."), + }), + ); + yield* installTool(tool); + return yield* inspect; + }), + ), + retryHost, + inspect, agentCli: Effect.fail( new DeviceHostUnavailableError({ hostId: LOCAL_DEVICE_HOST_ID, @@ -933,6 +1039,20 @@ export const make = Effect.gen(function* () { ), ), configureAgent, + (tool) => + (tool === "hub" ? ensureDeviceHub(config.baseDir) : ensureAgentDevice(config.baseDir)).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.mapError( + (cause) => + new DeviceOperationError({ + operation: "update device tool", + reason: "command_failed", + cause, + }), + ), + ), ); const hostContext = yield* Effect.context>>(); @@ -1023,7 +1143,7 @@ export const make = Effect.gen(function* () { return { ...service, agentCli: resolveNodeExecutable("Device automation").pipe( - Effect.flatMap(() => ensureAgentDevice(config.baseDir)), + Effect.andThen(ensureAgentDevice(config.baseDir)), Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.provideService(ProcessRunner.ProcessRunner, runner), diff --git a/apps/server/src/device/DeviceToolchain.test.ts b/apps/server/src/device/DeviceToolchain.test.ts index 9f70ca91a8e1..d7812a92b48e 100644 --- a/apps/server/src/device/DeviceToolchain.test.ts +++ b/apps/server/src/device/DeviceToolchain.test.ts @@ -1,3 +1,4 @@ +import * as PlatformError from "effect/PlatformError"; import { expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; @@ -6,7 +7,12 @@ import * as Path from "effect/Path"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ProcessRunner from "../processRunner.ts"; -import { ensureDeviceHub, isDeviceHubInstalled } from "./DeviceToolchain.ts"; +import { + deviceToolVersions, + DEVICE_HUB_VERSION, + ensureDeviceHub, + isDeviceHubInstalled, +} from "./DeviceToolchain.ts"; it.effect("failed installation cleans staging and exposes only a safe failure message", () => Effect.gen(function* () { @@ -37,3 +43,57 @@ it.effect("failed installation cleans staging and exposes only a safe failure me expect(yield* fs.readDirectory(path.join(baseDir, "tools", "expo-device-hub"))).toEqual([]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +it.effect("inventory reports only completed versions without installing the required version", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* fs.makeTempDirectoryScoped(); + for (const [version, sentinel] of [ + ["0.9.0", "0.9.0"], + [DEVICE_HUB_VERSION, "wrong"], + [".staging-123", ".staging-123"], + ]) { + const dir = path.join(base, "tools", "expo-device-hub", version!); + yield* fs.makeDirectory(path.join(dir, "node_modules/expo-device-hub/dist/server"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(dir, "node_modules/expo-device-hub/dist/server/cli.mjs"), + "", + ); + yield* fs.writeFileString(path.join(dir, ".install-complete"), sentinel!); + } + const tools = yield* deviceToolVersions(base); + expect(tools?.hub).toEqual({ + requiredVersion: DEVICE_HUB_VERSION, + installedVersions: ["0.9.0"], + runningVersion: null, + }); + expect(tools?.agent.installedVersions).toEqual([]); + expect(yield* isDeviceHubInstalled(base)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("unreadable inventory stays unknown instead of reporting no installs", () => + Effect.gen(function* () { + const tools = yield* deviceToolVersions("/unreadable"); + expect(tools).toBeUndefined(); + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readDirectory: () => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readDirectory", + description: "denied", + }), + ), + }), + ), + Effect.provide(NodeServices.layer), + ), +); diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index 4d59cb462383..e8e7d5cae46a 100644 --- a/apps/server/src/device/DeviceToolchain.ts +++ b/apps/server/src/device/DeviceToolchain.ts @@ -1,3 +1,4 @@ +import type { DeviceToolVersions } from "@t3tools/contracts"; /** * Pinned installs of the two external tools device support is built on. * @@ -25,9 +26,9 @@ import * as Semaphore from "effect/Semaphore"; import * as ProcessRunner from "../processRunner.ts"; const DEVICE_HUB_PACKAGE = "expo-device-hub"; -export const DEVICE_HUB_VERSION = "0.9.0"; +export const DEVICE_HUB_VERSION = "0.10.1"; const AGENT_DEVICE_PACKAGE = "agent-device"; -export const AGENT_DEVICE_VERSION = "0.20.10"; +export const AGENT_DEVICE_VERSION = "0.21.12"; const INSTALL_TIMEOUT = Duration.minutes(10); const installLock = Semaphore.makeUnsafe(1); @@ -221,3 +222,46 @@ export const isDeviceHubInstalled = (baseDir: string) => export const isAgentDeviceInstalled = (baseDir: string) => isToolInstalled(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice); + +/** Read completed installs without downloading or starting either tool. */ +export const deviceToolVersions = Effect.fn("DeviceToolchain.versions")(function* ( + baseDir: string, + running: { hub?: string; agent?: string } = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const inspect = Effect.fn("DeviceToolchain.inspect")(function* (spec: ToolSpec) { + const directory = path.join(baseDir, "tools", spec.name); + const names = yield* fs.readDirectory(directory).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed([]), + ), + ); + const versions = yield* Effect.filter(names, (version) => + /^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version) + ? Effect.gen(function* () { + const paths = toolPaths(path, baseDir, { ...spec, version }); + const sentinel = yield* fs.readFileString(paths.sentinelPath).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed(null), + ), + ); + return sentinel?.trim() === version && (yield* fs.exists(paths.entryPath)); + }) + : Effect.succeed(false), + ); + return { + requiredVersion: spec.version, + installedVersions: versions.sort(), + runningVersion: (spec.name === DEVICE_HUB_PACKAGE ? running.hub : running.agent) ?? null, + }; + }); + return yield* Effect.gen(function* () { + return { + hub: yield* inspect(HUB_SPEC), + agent: yield* inspect(AGENT_DEVICE_SPEC), + } satisfies DeviceToolVersions; + }).pipe(Effect.orElseSucceed(() => undefined)); +}); diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index b7341e04f8ad..e704d28bbc34 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -1,3 +1,5 @@ +import { pruneLocalDeviceTools } from "./deviceToolMaintenance.ts"; +import { deviceToolInstallMessage } from "@t3tools/contracts"; /** * The device host that is this machine. * @@ -31,6 +33,7 @@ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -50,6 +53,8 @@ import { ensureDeviceHub, isAgentDeviceInstalled, isDeviceHubInstalled, + deviceToolVersions, + DEVICE_HUB_VERSION, } from "./DeviceToolchain.ts"; const HUB_READY_TIMEOUT_MS = 30_000; @@ -76,6 +81,7 @@ const AgentDeviceDaemonFile = Schema.Struct({ httpPort: Schema.Int, token: Schema.String, pid: Schema.optional(Schema.Int), + version: Schema.optional(Schema.String), }); const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile)); @@ -232,7 +238,26 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { Effect.provideService(Path.Path, path), ), ]); + const running = yield* Ref.get(runningRef); + const daemon = running?.agentDevice + ? yield* readDaemonFile().pipe(Effect.option) + : Option.none(); + const hubAlive = running + ? yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false)) + : false; + const agentAlive = + Option.isSome(daemon) && daemon.value.pid ? yield* isProcessAlive(daemon.value.pid) : false; + const tools = yield* deviceToolVersions(config.baseDir, { + ...(hubAlive ? { hub: DEVICE_HUB_VERSION } : {}), + ...(agentAlive && Option.isSome(daemon) && daemon.value.version + ? { agent: daemon.value.version } + : {}), + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); return { + tools, id: hostId, kind: "local", label: "This machine", @@ -533,7 +558,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { let agentToolRef: { readonly entryPath: string; readonly nodePath: string } | null = null; const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* ( - onPhase: (phase: "installing" | "starting") => Effect.Effect, + onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, ): Effect.fn.Return { const running = yield* Ref.get(runningRef); if (running) { @@ -550,7 +575,10 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ); - if (!installed) yield* onPhase("installing"); + if (!installed) { + const inventory = yield* summary; + yield* onPhase("installing", deviceToolInstallMessage("device hub", inventory.tools?.hub)); + } const hubTool = yield* ensureDeviceHub(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), @@ -566,6 +594,11 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { ); yield* onPhase("starting"); const hub = yield* spawnHub(hubTool, nodePath); + yield* pruneLocalDeviceTools(config.baseDir, nodePath, "hub").pipe( + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.ignore, + ); const candidate = helperPaths(hubTool); const [axExists, cliExists] = yield* Effect.all([ fs.exists(candidate.serveSimAxSettings).pipe(Effect.orElseSucceed(() => false)), @@ -605,7 +638,13 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ); - if (!installed) yield* onPhase("installing"); + if (!installed) { + const inventory = yield* summary; + yield* onPhase( + "installing", + deviceToolInstallMessage("agent tools", inventory.tools?.agent), + ); + } const agentTool = yield* ensureAgentDevice(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), @@ -622,6 +661,11 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { agentToolRef = { entryPath: agentTool.entryPath, nodePath: running.hub.nodePath }; yield* onPhase("starting"); const agentDevice = yield* startAgentDeviceDaemon(agentTool, running.hub.nodePath); + yield* pruneLocalDeviceTools(config.baseDir, running.hub.nodePath, "agent").pipe( + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.ignore, + ); const next = { ...running, agentDevice }; yield* Ref.set(runningRef, next); return { ...toReady(next), agentDevice }; diff --git a/apps/server/src/device/SshDeviceHost.test.ts b/apps/server/src/device/SshDeviceHost.test.ts index d968d3383fae..71a15190ddc8 100644 --- a/apps/server/src/device/SshDeviceHost.test.ts +++ b/apps/server/src/device/SshDeviceHost.test.ts @@ -20,6 +20,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ const fs = yield* FileSystem.FileSystem; const home = yield* fs.makeTempDirectoryScoped(); const modes: string[] = []; + const owners: string[] = []; let forwards = 0; let failForward = true; let rejectConfig = true; @@ -62,6 +63,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ ); const mode = /const mode = "([^"]+)"/.exec(script)?.[1] ?? ""; modes.push(mode); + owners.push(/const owner = "([^"]+)"/.exec(script)?.[1] ?? ""); output = JSON.stringify({ nodePath: "/node", platforms: [{ platform: "ios", available: true }], @@ -110,6 +112,12 @@ it.effect("preserves installed status after probes and cleans failed agent activ ), ); yield* host.ensureReady(() => Effect.void); + yield* SshDeviceHost.probe({ id: "test", label: "Test", target: "test.example" }).pipe( + Effect.provide(ServerConfig.layerTest(home, home)), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + expect(new Set(owners).size).toBe(1); + expect(owners[0]).toMatch(/^[a-f0-9]{24}$/); expect(forwards).toBe(1); expect(modes.filter((mode) => mode === "start")).toHaveLength(2); yield* host.platformAvailability("ios"); diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts index 4ccda0fdefe9..4ed6d740215c 100644 --- a/apps/server/src/device/SshDeviceHost.ts +++ b/apps/server/src/device/SshDeviceHost.ts @@ -2,6 +2,8 @@ import * as NodeCrypto from "node:crypto"; import { type DeviceHostSummary, DevicePlatformAvailability, + DeviceToolVersions, + deviceToolInstallMessage, type SshDeviceHostConfig, } from "@t3tools/contracts"; import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command"; @@ -24,6 +26,7 @@ import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./s const Probe = Schema.Struct({ nodePath: Schema.String, + tools: Schema.optional(DeviceToolVersions), platforms: Schema.Array(DevicePlatformAvailability), }); const Started = Schema.Struct({ @@ -70,8 +73,23 @@ const bootstrap = ( ), ); -export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDeviceHostConfig) { - const result = yield* bootstrap(config, "probe", "probe"); +const ownerFor = Effect.fn("SshDeviceHost.ownerFor")(function* (hostId: string) { + const fs = yield* FileSystem.FileSystem; + const server = yield* ServerConfig.ServerConfig; + const environmentId = yield* fs + .readFileString(server.environmentIdPath) + .pipe(Effect.orElseSucceed(() => server.stateDir)); + return NodeCrypto.createHash("sha256") + .update(`${environmentId}\0${server.stateDir}\0${hostId}`) + .digest("hex") + .slice(0, 24); +}); + +export const probe = Effect.fn("SshDeviceHost.probe")(function* ( + config: SshDeviceHostConfig, + owner?: string, +) { + const result = yield* bootstrap(config, owner ?? (yield* ownerFor(config.id)), "probe"); const value = yield* decodeProbe(result.stdout.trim()).pipe( Effect.mapError( (cause) => @@ -82,8 +100,11 @@ export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDevi id: config.id, label: config.label, kind: "ssh", - hubInstalled: false, - agentDeviceInstalled: false, + tools: value.tools, + hubInstalled: + value.tools?.hub.installedVersions.includes(value.tools.hub.requiredVersion) ?? false, + agentDeviceInstalled: + value.tools?.agent.installedVersions.includes(value.tools.agent.requiredVersion) ?? false, platforms: value.platforms, } satisfies DeviceHostSummary; }); @@ -106,23 +127,21 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const parentScope = yield* Scope.Scope; const ssh = yield* resolveSshCommand; - const environmentId = yield* fs - .readFileString(server.environmentIdPath) - .pipe(Effect.orElseSucceed(() => server.stateDir)); - const owner = NodeCrypto.createHash("sha256") - .update(`${environmentId}\0${server.stateDir}\0${config.id}`) - .digest("hex") - .slice(0, 24); + const owner = yield* ownerFor(config.id); const provide = ( effect: Effect.Effect< A, E, - FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | ServerConfig.ServerConfig >, ) => effect.pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), + Effect.provideService(ServerConfig.ServerConfig, server), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const lock = yield* Semaphore.make(1); @@ -183,6 +202,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( summary = { ...summary, platforms: remote.platforms, + tools: remote.tools, hubInstalled: true, agentDeviceInstalled: wantsAgent || summary.agentDeviceInstalled, }; @@ -351,8 +371,13 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( Effect.gen(function* () { stopped = false; if (ready) return ready; - summary = yield* provide(probe(config)); - yield* onPhase("installing"); + summary = yield* provide(probe(config, owner)); + yield* onPhase( + summary.hubInstalled ? "starting" : "installing", + summary.hubInstalled + ? undefined + : deviceToolInstallMessage("device hub", summary.tools?.hub), + ); return yield* connect().pipe( Effect.tapError(() => connectionScope ? Scope.close(connectionScope, Exit.void) : Effect.void, @@ -399,10 +424,25 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( return { id: config.id, summary: Effect.sync(() => summary), + inspect: provide(probe(config, owner)).pipe( + Effect.tap((value) => + Effect.sync(() => { + summary = value; + }), + ), + ), current: Effect.sync(() => ready), ensureReady, ensureAgentReady: (onPhase) => - onPhase("installing").pipe( + ensureReady(onPhase).pipe( + Effect.flatMap(() => + onPhase( + summary.agentDeviceInstalled ? "starting" : "installing", + summary.agentDeviceInstalled + ? undefined + : deviceToolInstallMessage("agent tools", summary.tools?.agent), + ), + ), Effect.flatMap(() => changeAgent(true)), Effect.flatMap((value) => value?.agentDevice @@ -419,7 +459,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( stopAgent: changeAgent(false).pipe(Effect.asVoid, Effect.ignore), stop, platformAvailability: (platform) => - provide(probe(config)).pipe( + provide(probe(config, owner)).pipe( Effect.map((value) => { summary = { ...summary, platforms: value.platforms }; return value.platforms.find((p) => p.platform === platform)!; diff --git a/apps/server/src/device/deviceToolMaintenance.test.ts b/apps/server/src/device/deviceToolMaintenance.test.ts new file mode 100644 index 000000000000..4c46be11bba4 --- /dev/null +++ b/apps/server/src/device/deviceToolMaintenance.test.ts @@ -0,0 +1,159 @@ +import * as Effect from "effect/Effect"; +import * as NodePathLayer from "@effect/platform-node/NodePath"; +import * as ProcessRunner from "../processRunner.ts"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +// @effect-diagnostics nodeBuiltinImport:off - tests the same standalone script used by local and SSH hosts. +import { describe, expect, it } from "@effect/vitest"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeUtil from "node:util"; +import { pruneLocalDeviceTools, deviceToolMaintenanceScript } from "./deviceToolMaintenance.ts"; + +const exec = NodeUtil.promisify(NodeChildProcess.execFile); + +describe.each([false, true])("device tool cleanup, flat=%s", (flat) => { + it("keeps current, previous, active and incomplete installs, pruning unused completed versions", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tool-cleanup-")); + const name = "expo-device-hub"; + const directory = (version: string) => + flat ? NodePath.join(root, `${name}@${version}`) : NodePath.join(root, name, version); + try { + for (const version of ["0.1.0", "0.2.0", "0.3.0", "0.4.0", "0.5.0", "0.6.0"]) { + await NodeFSP.mkdir(directory(version), { recursive: true }); + if (version === "0.5.0") continue; + const sentinel = NodePath.join(directory(version), ".install-complete"); + await NodeFSP.writeFile(sentinel, version); + await NodeFSP.utimes( + sentinel, + Number(version.split(".")[1]), + Number(version.split(".")[1]), + ); + } + const script = + deviceToolMaintenanceScript + + ` +(async () => { + const root = ${JSON.stringify(root)}; + await pruneTools(root, [['${name}', '0.6.0']], ${flat}); +})().catch(error => { console.error(error); process.exitCode = 1; });`; + await exec(process.execPath, [ + "-e", + script, + NodePath.join(directory("0.2.0"), "active-helper.cjs"), + ]); + await expect(NodeFSP.stat(directory("0.1.0"))).rejects.toThrow(); + await expect(NodeFSP.stat(directory("0.3.0"))).rejects.toThrow(); + for (const version of ["0.2.0", "0.4.0", "0.5.0", "0.6.0"]) + expect((await NodeFSP.stat(directory(version))).isDirectory()).toBe(true); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); + + it("keeps every install when the process scan fails", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tool-scan-")); + try { + for (const version of ["0.1.0", "0.2.0", "0.3.0"]) { + const dir = flat + ? NodePath.join(root, `expo-device-hub@${version}`) + : NodePath.join(root, "expo-device-hub", version); + await NodeFSP.mkdir(dir, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(dir, ".install-complete"), version); + } + await exec(process.execPath, [ + "-e", + deviceToolMaintenanceScript + + ` + require('node:child_process').spawnSync = () => ({ status: 1, stdout: '' }); + pruneTools(${JSON.stringify(root)}, [['expo-device-hub', '0.3.0']], ${flat}).catch(() => process.exitCode = 1); + `, + ]); + const parent = flat ? root : NodePath.join(root, "expo-device-hub"); + expect((await NodeFSP.readdir(parent)).length).toBe(3); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); + + it("does not prune before the required version has completed installation", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tool-cleanup-")); + try { + const dir = flat + ? NodePath.join(root, "expo-device-hub@0.1.0") + : NodePath.join(root, "expo-device-hub/0.1.0"); + await NodeFSP.mkdir(dir, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(dir, ".install-complete"), "0.1.0"); + await exec(process.execPath, [ + "-e", + deviceToolMaintenanceScript + + `pruneTools(${JSON.stringify(root)}, [['expo-device-hub','0.6.0']], ${flat}).catch(() => process.exitCode = 1);`, + ]); + expect((await NodeFSP.stat(dir)).isDirectory()).toBe(true); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); +}); + +it.effect("maintenance failures retain safe context and the original process result", () => + Effect.gen(function* () { + const output = { + code: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr: "private child diagnostics", + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + for (const [operation, run] of [["prune", pruneLocalDeviceTools]] as const) { + const error = yield* run("/tools", process.execPath, "hub").pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { run: () => Effect.succeed(output) }), + Effect.flip, + ); + expect(error).toMatchObject({ + _tag: "DeviceToolMaintenanceError", + operation, + tool: "hub", + exitCode: 1, + cause: output, + }); + expect(error.message).toBe(`Device tool ${operation} failed for hub (exit code 1).`); + expect(error.message).not.toContain(output.stderr); + } + }).pipe(Effect.provide(NodePathLayer.layer)), +); + +it("serializes competing maintenance processes after reclaiming a stale lock", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tool-contention-")); + try { + const lock = NodePath.join(root, ".maintenance-lock"); + await NodeFSP.mkdir(lock); + await NodeFSP.writeFile( + NodePath.join(lock, "stale-owner.json"), + JSON.stringify({ pid: 2147483647, identity: "dead" }), + ); + const script = + deviceToolMaintenanceScript + + ` +(async () => { + const root = ${JSON.stringify(root)}; + const marker = maintenancePath.join(root, 'critical-section'); + for (let attempt = 0; attempt < 8; attempt++) await withToolMaintenance(root, () => { + maintenanceFs.writeFileSync(marker, String(process.pid), { flag: 'wx' }); + for (let check = 0; check < 100; check++) { + if (maintenanceFs.readFileSync(marker, 'utf8') !== String(process.pid)) throw Error('Overlapping maintenance'); + } + maintenanceFs.unlinkSync(marker); + }); +})().catch(error => { console.error(error); process.exitCode = 1; });`; + await Promise.all(Array.from({ length: 6 }, () => exec(process.execPath, ["-e", script]))); + await expect(NodeFSP.stat(lock)).rejects.toThrow(); + expect(await NodeFSP.readdir(root)).toEqual([]); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } +}); diff --git a/apps/server/src/device/deviceToolMaintenance.ts b/apps/server/src/device/deviceToolMaintenance.ts new file mode 100644 index 000000000000..43cc64e8faca --- /dev/null +++ b/apps/server/src/device/deviceToolMaintenance.ts @@ -0,0 +1,148 @@ +// @effect-diagnostics preferSchemaOverJson:off - JSON string literals safely embed paths and arguments in generated JavaScript. +import * as Schema from "effect/Schema"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; +import * as ProcessRunner from "../processRunner.ts"; +import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts"; + +/** Shared with the SSH bootstrap. Cleanup runs only after successful startup. */ +export const deviceToolMaintenanceScript = String.raw` +const maintenanceFs = require('node:fs'); +const maintenancePath = require('node:path'); +const maintenanceAlive = pid => { + try { process.kill(pid, 0); return true; } + catch (error) { return error.code !== 'ESRCH'; } +}; +async function withToolMaintenance(root, operation) { + maintenanceFs.mkdirSync(root, { recursive: true }); + const lock = maintenancePath.join(root, '.maintenance-lock'); + const nonce = require('node:crypto').randomUUID(); + const ownerFile = process.pid + '.' + nonce + '.json'; + const candidate = lock + '.' + nonce; + const holder = { pid: process.pid }; + const deadline = Date.now() + 30000; + const removeEmptyLock = () => { + try { maintenanceFs.rmdirSync(lock); } + catch (error) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST', 'EPERM'].includes(error.code)) throw error; } + }; + maintenanceFs.mkdirSync(candidate); + try { + maintenanceFs.writeFileSync(maintenancePath.join(candidate, ownerFile), JSON.stringify(holder)); + while (true) { + try { + // Publish a populated directory atomically; rename cannot replace another populated lock. + maintenanceFs.renameSync(candidate, lock); + break; + } + catch (error) { + if (!['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES'].includes(error.code)) throw error; + let files = []; + try { files = maintenanceFs.readdirSync(lock); } catch (error) { if (error.code !== 'ENOENT') throw error; } + if (files.length === 1) { + const previousFile = maintenancePath.join(lock, files[0]); + let previous; + try { previous = JSON.parse(maintenanceFs.readFileSync(previousFile, 'utf8')); } catch {} + if (Number.isSafeInteger(previous?.pid) && previous.pid > 0 && !maintenanceAlive(previous.pid)) { + // The unique filename belongs only to that owner. Never unlink a replacement owner's file. + try { maintenanceFs.unlinkSync(previousFile); } catch (error) { if (error.code !== 'ENOENT') throw error; } + } + } + // A concurrent acquirer publishes its owner file with the directory, so this cannot remove it. + removeEmptyLock(); + if (Date.now() >= deadline) throw Error('Device tool maintenance is locked. Retry when the other operation finishes.'); + await new Promise(resolve => setTimeout(resolve, 50)); + } + } + try { return operation(); } + finally { + maintenanceFs.unlinkSync(maintenancePath.join(lock, ownerFile)); + removeEmptyLock(); + } + } finally { + maintenanceFs.rmSync(candidate, { recursive: true, force: true }); + } +} +function pruneTools(root, specs, flat) { + return withToolMaintenance(root, () => { + // Keep installs used by any running helper, including older T3 releases. + const scan = process.platform === 'win32' + ? require('node:child_process').spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine'], { encoding: 'utf8', timeout: 10000 }) + : require('node:child_process').spawnSync('ps', ['-ax', '-o', 'command='], { encoding: 'utf8', timeout: 10000 }); + if (scan.status !== 0 || !scan.stdout) return; + for (const [name, required] of specs) { + const parent = flat ? root : maintenancePath.join(root, name); + let names; + try { names = maintenanceFs.readdirSync(parent); } catch { continue; } + const completed = []; + for (const item of names) { + const version = flat ? (item.startsWith(name + '@') ? item.slice(name.length + 1) : '') : item; + if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version)) continue; + const directory = maintenancePath.join(parent, item); + try { + if (!maintenanceFs.lstatSync(directory).isDirectory()) continue; + if (maintenanceFs.readFileSync(maintenancePath.join(directory, '.install-complete'), 'utf8').trim() !== version) continue; + completed.push({ version, directory, modified: maintenanceFs.statSync(maintenancePath.join(directory, '.install-complete')).mtimeMs }); + } catch {} + } + // Never prune until the required install has completed. Retain the last other successful install. + if (!completed.some(value => value.version === required)) continue; + const previous = completed.filter(value => value.version !== required).sort((a, b) => b.modified - a.modified || b.version.localeCompare(a.version, 'en', { numeric: true }))[0]?.version; + for (const { version, directory } of completed) { + if (version === required || version === previous || scan.stdout.includes(directory + maintenancePath.sep)) continue; + maintenanceFs.rmSync(directory, { recursive: true, force: true }); + } + } + }); +} +`; + +class DeviceToolMaintenanceError extends Schema.TaggedError()( + "DeviceToolMaintenanceError", + { + operation: Schema.Literal("prune"), + tool: Schema.Literals(["hub", "agent"]), + exitCode: Schema.NullOr(Schema.Int), + cause: Schema.Defect(), + }, +) { + override get message() { + return `Device tool ${this.operation} failed for ${this.tool} (exit code ${this.exitCode ?? "unknown"}).`; + } +} + +const runMaintenance = Effect.fn("DeviceToolchain.maintenance")(function* ( + nodePath: string, + script: string, + operation: "prune", + tool: "hub" | "agent", +) { + const runner = yield* ProcessRunner.ProcessRunner; + const result = yield* runner.run({ + command: nodePath, + args: [ + "-e", + deviceToolMaintenanceScript + + "\n" + + script + + ".catch(error => { console.error(error.message); process.exitCode = 1; });", + ], + }); + if (result.code !== 0) + return yield* Effect.fail( + new DeviceToolMaintenanceError({ operation, tool, exitCode: result.code, cause: result }), + ); +}); + +export const pruneLocalDeviceTools = Effect.fn("DeviceToolchain.prune")(function* ( + baseDir: string, + nodePath: string, + tool: "hub" | "agent", +) { + const path = yield* Path.Path; + yield* runMaintenance( + nodePath, + `pruneTools(${JSON.stringify(path.join(baseDir, "tools"))}, ${JSON.stringify(tool === "hub" ? [["expo-device-hub", DEVICE_HUB_VERSION]] : [["agent-device", AGENT_DEVICE_VERSION]])}, false)`, + "prune", + tool, + ); +}); diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index eadd85fd047d..8d9bc77d891c 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -83,15 +83,16 @@ const state=process.env.AGENT_DEVICE_STATE_DIR || args[args.indexOf('--state-dir const file=path.join(state,'daemon.json'); if(args[0]==='daemon') { const data=JSON.parse(fs.readFileSync(file,'utf8')); fs.writeFileSync(path.join(state,'stopped-agent'),String(data.pid)); try {process.kill(data.pid,'SIGTERM')} catch {} } else if(args[0]==='serve') { const server=http.createServer((req,res)=>{res.statusCode=fs.existsSync(path.join(state,'unhealthy-agent-'+process.pid))?503:200;res.end('ok');}); server.listen(0,'127.0.0.1',()=>{fs.writeFileSync(file,JSON.stringify({httpPort:server.address().port,pid:process.pid,token:'test'}));process.send?.('ready');process.disconnect?.();}); } -else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:true,stdio:['ignore','ignore','ignore','ipc'],env:process.env});await new Promise((resolve,reject)=>{child.once('message',resolve);child.once('error',reject);});child.unref(); } +else { const child=spawn(process.execPath,[path.join(path.dirname(process.argv[1]),'daemon.mjs'),'serve'],{detached:true,stdio:['ignore','ignore','ignore','ipc'],env:process.env});await new Promise((resolve,reject)=>{child.once('message',resolve);child.once('error',reject);});child.unref(); } `, ); + await NodeFSP.copyFile(agent, NodePath.join(NodePath.dirname(agent), "daemon.mjs")); const nextHubVersion = DEVICE_HUB_VERSION + "-upgrade"; const nextAgentVersion = AGENT_DEVICE_VERSION + "-upgrade"; let invocation = 0; const invoke = async ( owner: string, - mode: "start" | "agent-start" | "stop-agent" | "stop", + mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop", upgraded = false, ) => { const file = NodePath.join(home, `${owner}-${mode}-${invocation++}.cjs`); @@ -107,6 +108,11 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr }); return result.stdout ? JSON.parse(result.stdout) : null; }; + const inventory = await invoke("one", "probe"); + expect(inventory.tools.hub.installedVersions).toEqual([DEVICE_HUB_VERSION]); + expect(inventory.tools.hub.runningVersion).toBeNull(); + expect(inventory.tools.agent.installedVersions).toEqual([AGENT_DEVICE_VERSION]); + await expect(NodeFSP.stat(NodePath.join(root, "hosts/one/hub.json"))).rejects.toThrow(); const template = NodePath.join(home, "hub-template"); await NodeFSP.cp(hubDir, template, { recursive: true }); await NodeFSP.rm(NodePath.join(hubDir, ".install-complete")); @@ -114,11 +120,14 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr await NodeFSP.symlink("2147483647:exited-installer", installLock); await NodeFSP.writeFile( NodePath.join(bin, "npm"), - `#!${process.execPath}\nconst fs=require('node:fs');const args=process.argv.slice(2);fs.cpSync(${JSON.stringify(template)},args[args.indexOf('--prefix')+1],{recursive:true});`, + `#!${process.execPath}\nconst fs=require('node:fs');const args=process.argv.slice(2);if(args[0]==='--version'){console.log('10.0.0');process.exit(0);}fs.cpSync(${JSON.stringify(template)},args[args.indexOf('--prefix')+1],{recursive:true});`, { mode: 0o755 }, ); await NodeFSP.mkdir(NodePath.join(root, "hosts/one"), { recursive: true }); await NodeFSP.writeFile(NodePath.join(root, "hosts/one/fail-start-once"), ""); + // Unavailable advisory bookkeeping must not prevent either helper from starting. + await NodeFSP.writeFile(NodePath.join(root, "tools/.maintenance-lock"), "blocked"); + await NodeFSP.writeFile(NodePath.join(root, "tools/.users"), "unwritable lease directory"); try { const [manual, concurrent] = await Promise.all([ invoke("one", "start"), @@ -135,6 +144,9 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr ]); expect(concurrentAgent.hubPort).toBe(first.hubPort); expect(concurrentAgent.daemonPort).toBe(first.daemonPort); + const running = await invoke("one", "probe"); + expect(running.tools.hub.runningVersion).toBe(DEVICE_HUB_VERSION); + expect(running.tools.agent.runningVersion).toBe(AGENT_DEVICE_VERSION); const second = await invoke("two", "agent-start"); const reused = await invoke("one", "agent-start"); expect(reused.hubPort).toBe(first.hubPort); diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts index bbdb828c1a74..de412b5873d5 100644 --- a/apps/server/src/device/sshDeviceScript.ts +++ b/apps/server/src/device/sshDeviceScript.ts @@ -1,3 +1,4 @@ +import { deviceToolMaintenanceScript } from "./deviceToolMaintenance.ts"; import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts"; export const quoteRemoteArg = (value: string) => `'${value.replaceAll("'", "'\"'\"'")}'`; @@ -28,6 +29,7 @@ const mode = ${JSON.stringify(mode)}; const hubVersion = ${JSON.stringify(DEVICE_HUB_VERSION)}; const agentVersion = ${JSON.stringify(AGENT_DEVICE_VERSION)}; ` + + deviceToolMaintenanceScript + String.raw` const fs = require('node:fs'); const path = require('node:path'); @@ -39,6 +41,35 @@ const state = path.join(root, 'hosts', owner); const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', timeout: 30000, ...options }); const read = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } }; const write = (file, value) => { const tmp = file + '.' + process.pid; fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 }); fs.renameSync(tmp, file); }; +const toolVersions = (name, requiredVersion, entry, record) => { + const directory = path.join(root, 'tools'); + const prefix = name + '@'; + let names = []; + try { names = fs.readdirSync(directory); } catch (error) { if (error.code !== 'ENOENT') return null; } + let unreadable = false; + const installedVersions = names.filter(name => name.startsWith(prefix)).map(name => name.slice(prefix.length)).filter(version => { + if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version)) return false; + const dir = path.join(directory, prefix + version); + try { return fs.readFileSync(path.join(dir, '.install-complete'), 'utf8').trim() === version && fs.existsSync(path.join(dir, 'node_modules', name, entry)); } catch (error) { if (error.code !== 'ENOENT') unreadable = true; return false; } + }).sort(); + if (unreadable) return null; + let runningVersion = null; + if (record?.entryPath && record?.pid) { + const command = run('ps', ['-p', String(record.pid), '-o', 'command=']).stdout || ''; + runningVersion = installedVersions.find(version => { + const install = path.join(directory, prefix + version); + return record.entryPath === path.join(install, 'node_modules', name, entry) && command.includes(install + path.sep); + }) ?? null; + } + return { requiredVersion, installedVersions, runningVersion }; +}; +const versions = () => { + const result = { + hub: toolVersions('expo-device-hub', hubVersion, 'dist/server/cli.mjs', read(path.join(state, 'hub.json'))), + agent: toolVersions('agent-device', agentVersion, 'bin/agent-device.mjs', { ...read(path.join(state, 'agent.json')), ...read(path.join(state, 'daemon.json')) }), + }; + return result.hub && result.agent ? result : undefined; +}; const stopHub = hub => { if (!hub || hub.owner !== owner) return; const command = run('ps', ['-p', String(hub.pid), '-o', 'command=']).stdout || ''; @@ -111,7 +142,7 @@ async function install(name, version, entry) { if (mode === 'probe') { if (Number(process.versions.node.split('.')[0]) < 22) throw Error('Node 22 or newer is required on the device host.'); if (run('npm', ['--version']).status !== 0) throw Error('npm is missing from the non-interactive SSH PATH.'); - console.log(JSON.stringify({ nodePath: process.execPath, platforms })); return; + console.log(JSON.stringify({ nodePath: process.execPath, platforms, tools: versions() })); return; } fs.mkdirSync(state, { recursive: true, mode: 0o700 }); // Serialize starts and stops for this environment/host owner, including agent startup. @@ -185,7 +216,8 @@ async function install(name, version, entry) { } const vendor = path.resolve(path.dirname(hubEntry), '../../vendor/serve-sim/dist'); const optional = file => fs.existsSync(file) ? file : null; - console.log(JSON.stringify({ nodePath: process.execPath, platforms, hubPort: hub.port, ...agentResult, + await pruneTools(path.join(root, 'tools'), [['expo-device-hub', hubVersion], ...(mode === 'agent-start' ? [['agent-device', agentVersion]] : [])], true).catch(() => {}); + console.log(JSON.stringify({ nodePath: process.execPath, platforms, tools: versions(), hubPort: hub.port, ...agentResult, helpers: { serveSimAxSettings: optional(path.join(vendor, 'simax/serve-sim-ax-settings')), serveSimCli: optional(path.join(vendor, 'serve-sim.js')) } })); } finally { releaseHost(); } })().catch(error => { console.error(error.message); process.exitCode = 1; }); diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 71ac8a98cdbb..07231c473c55 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -10,6 +10,8 @@ import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; + import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { PUBLISH_AGENT_ACTIVITY_SECRET, @@ -18,7 +20,6 @@ import { } from "../cloud/config.ts"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "./ServerEnvironment.ts"; -import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; const isServerEnvironmentIdPersistenceError = Schema.is( ServerEnvironment.ServerEnvironmentIdPersistenceError, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 6837d849d779..6a1a8c1510fa 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -15,6 +15,7 @@ import * as PlatformError from "effect/PlatformError"; import * as References from "effect/References"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/unstable/process"; import { expect } from "vite-plus/test"; import type { @@ -1308,7 +1309,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off - "contributor:feature/deleted-fork-branch": JSON.stringify([ + "feature/deleted-fork-branch": JSON.stringify([ { number: 218, title: "Deleted fork branch PR", @@ -1338,8 +1339,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-04-05T15:00:00.000Z", }); expect( - ghCalls.some((call) => call.includes("--head contributor:feature/deleted-fork-branch")), + ghCalls.some((call) => call.includes("--head feature/deleted-fork-branch --state all")), ).toBe(true); + expect(ghCalls.some((call) => call.includes("--head contributor:"))).toBe(false); }), ); @@ -1490,6 +1492,61 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("branch PR lookup rechecks open PRs every minute and settled answers less often", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + for (const branch of ["feature/open-pr", "feature/merged-pr", "feature/no-pr"]) { + yield* runGit(repoDir, ["checkout", "-b", branch, "main"]); + yield* runGit(repoDir, ["push", "-u", "origin", branch]); + } + const pullRequest = (number: number, headRefName: string, state: string) => ({ + number, + title: headRefName, + url: `https://github.com/pingdotgg/codething-mvp/pull/${number}`, + baseRefName: "main", + headRefName, + state, + updatedAt: "2026-04-07T15:00:00Z", + }); + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + "feature/open-pr": encodeCliJson([pullRequest(301, "feature/open-pr", "OPEN")]), + "feature/merged-pr": encodeCliJson([pullRequest(302, "feature/merged-pr", "MERGED")]), + }, + }, + }); + const lookupAll = Effect.forEach( + ["feature/open-pr", "feature/merged-pr", "feature/no-pr"], + (branch) => manager.branchPullRequest({ cwd: repoDir, branch }), + ); + const prListCalls = () => ghCalls.filter((call) => call.startsWith("pr list ")); + + yield* lookupAll; + expect(prListCalls()).toHaveLength(3); + + yield* TestClock.adjust("61 seconds"); + yield* lookupAll; + expect(prListCalls()).toHaveLength(4); + expect(prListCalls().at(-1)).toContain("--head feature/open-pr"); + + // Just inside the 5-minute window only the open PR is asked again. + yield* TestClock.adjust("238 seconds"); + yield* lookupAll; + expect(prListCalls()).toHaveLength(5); + expect(prListCalls().at(-1)).toContain("--head feature/open-pr"); + + // Just past it the settled answers expire too. + yield* TestClock.adjust("2 seconds"); + yield* lookupAll; + expect(prListCalls()).toHaveLength(7); + expect(prListCalls().slice(-2).join("\n")).not.toContain("--head feature/open-pr"); + }), + ); + it.effect("branch PR lookup propagates provider failures", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1720,8 +1777,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListByHeadSelector: { - "alice:feature": output, - "fork:feature": output, feature: output, }, }, @@ -1888,10 +1943,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { @@ -1927,7 +1978,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( - "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head statemachine --state all --limit 100 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, @@ -1959,7 +2010,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ghScenario: { prListByHeadSelector: { // @effect-diagnostics-next-line preferSchemaOverJson:off - "contributor:main": JSON.stringify([ + main: JSON.stringify([ { number: 777, title: "Fork PR from main", @@ -1993,7 +2044,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( - "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head main --state all --limit 100 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, @@ -2058,34 +2109,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-04-01T10:00:00Z", }, ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "pingdotgg:effect-atom": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "my-org/upstream:effect-atom": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "pingdotgg:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - state: "OPEN", - updatedAt: "2026-04-01T10:00:00Z", - }, - ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "my-org/upstream:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - state: "OPEN", - updatedAt: "2026-04-01T10:00:00Z", - }, - ]), }, }, }); @@ -2296,7 +2319,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off - "contributor:feature/fork-plain": JSON.stringify([ + "feature/fork-plain": JSON.stringify([ { number: 89, title: "Fork PR pushed without -u", @@ -2316,9 +2339,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const status = yield* manager.status({ cwd: repoDir }); expect(status.pr?.number).toBe(89); - expect(ghCalls.some((call) => call.includes("--head contributor:feature/fork-plain"))).toBe( - true, - ); + expect(ghCalls.some((call) => call.includes("--head feature/fork-plain"))).toBe(true); + expect(ghCalls.some((call) => call.includes("--head contributor:"))).toBe(false); expect(ghCalls.some((call) => call.includes("--head main"))).toBe(false); }), ); @@ -2353,7 +2375,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off - "contributor:feature/fork-settle": JSON.stringify([ + "feature/fork-settle": JSON.stringify([ { number: 91, title: "Fork PR to settle", @@ -3514,7 +3536,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ); it.effect( - "returns existing cross-repo PR metadata using the fork owner selector", + "returns existing cross-repo PR metadata found under the bare branch name", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -3533,8 +3555,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { @@ -3566,7 +3586,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.number).toBe(142); expect( ghCalls.some((call) => - call.includes("pr list --head octocat:statemachine --state open --limit 1"), + call.includes("pr list --head statemachine --state open --limit 100"), ), ).toBe(true); expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); @@ -3632,30 +3652,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { headRefName: "upstream/effect-atom", }, ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "pingdotgg:effect-atom": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "my-org/upstream:effect-atom": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "pingdotgg:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - }, - ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "my-org/upstream:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - }, - ]), }, }, }); @@ -3675,7 +3671,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ); it.effect( - "prefers owner-qualified selectors before bare branch names for cross-repo PRs", + "picks the fork PR among same-named branches from other repositories", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -3707,9 +3703,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { baseRefName: "main", headRefName: "statemachine", }, - ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "octocat:statemachine": JSON.stringify([ { number: 142, title: "Existing fork PR", @@ -3726,8 +3719,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, }, ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "fork-seed:statemachine": JSON.stringify([]), }, }, }); @@ -3739,11 +3730,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.status).toBe("opened_existing"); expect(result.pr.number).toBe(142); - - const ownerSelectorCallIndex = ghCalls.findIndex((call) => - call.includes("pr list --head octocat:statemachine --state open --limit 1"), - ); - expect(ownerSelectorCallIndex).toBeGreaterThanOrEqual(0); + expect( + ghCalls.some((call) => /--head [^ ]*:/u.test(call) && call.startsWith("pr list")), + ).toBe(false); expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); }), 12_000, @@ -3772,7 +3761,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ghScenario: { prListByHeadSelector: { // @effect-diagnostics-next-line preferSchemaOverJson:off - "octocat:statemachine": JSON.stringify([ + statemachine: JSON.stringify([ { number: 142, title: "Existing fork PR", @@ -3790,11 +3779,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, ]), // @effect-diagnostics-next-line preferSchemaOverJson:off - "fork-seed:statemachine": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off "t3code/pr-142/statemachine": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - statemachine: JSON.stringify([]), }, }, }); @@ -3807,10 +3792,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.status).toBe("opened_existing"); expect(result.pr.number).toBe(142); - const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 1")); + const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 100")); expect(openLookupCalls).toHaveLength(1); expect(openLookupCalls[0]).toContain( - "pr list --head octocat:statemachine --state open --limit 1", + "pr list --head statemachine --state open --limit 100", ); }), 12_000, @@ -3835,12 +3820,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequenceByHeadSelector: { - "octocat:statemachine": [ + statemachine: [ `[{"number":41,"title":"Ambiguous fork PR","url":"https://github.com/pingdotgg/codething-mvp/pull/41","baseRefName":"main","headRefName":"statemachine","state":"OPEN"}]`, `[{"number":142,"title":"Add stacked git actions","url":"https://github.com/pingdotgg/codething-mvp/pull/142","baseRefName":"main","headRefName":"statemachine","state":"OPEN","isCrossRepository":true,"headRepository":{"nameWithOwner":"octocat/codething-mvp"},"headRepositoryOwner":{"login":"octocat"}}]`, ], - "fork-seed:statemachine": ["[]"], - statemachine: ["[]"], }, }, }); @@ -4071,7 +4054,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - it.effect("generates PR content against the remote base when the local base is stale", () => + it.effect("generates PR content from branch changes when the remote base advances", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -4103,7 +4086,15 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "feature/remote-base"]); yield* runGit(repoDir, ["config", "branch.feature/remote-base.gh-merge-base", "main"]); + NodeFS.writeFileSync(NodePath.join(peerDir, "later-main.txt"), "unrelated\n"); + yield* runGit(peerDir, ["add", "later-main.txt"]); + yield* runGit(peerDir, ["commit", "-m", "Later main commit"]); + yield* runGit(peerDir, ["push", "origin", "main"]); + yield* runGit(repoDir, ["fetch", "origin"]); + let generatedCommitSummary = ""; + let generatedDiffSummary = ""; + let generatedDiffPatch = ""; const { manager } = yield* makeManager({ ghScenario: { prListSequence: ["[]", "[]"], @@ -4111,6 +4102,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { textGeneration: { generatePrContent: (input) => { generatedCommitSummary = input.commitSummary; + generatedDiffSummary = input.diffSummary; + generatedDiffPatch = input.diffPatch; return Effect.succeed({ title: "Feature PR", body: "Feature body" }); }, }, @@ -4124,6 +4117,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.status).toBe("created"); expect(generatedCommitSummary).toContain("Feature commit"); expect(generatedCommitSummary).not.toContain("Remote base commit"); + expect(generatedCommitSummary).not.toContain("Later main commit"); + expect(generatedDiffSummary).toContain("feature.txt"); + expect(generatedDiffSummary).not.toContain("later-main.txt"); + expect(generatedDiffPatch).toContain("feature.txt"); + expect(generatedDiffPatch).not.toContain("later-main.txt"); }), ); @@ -4224,7 +4222,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequenceByHeadSelector: { - "octocat:statemachine": [ + statemachine: [ // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off @@ -4246,10 +4244,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, ]), ], - // @effect-diagnostics-next-line preferSchemaOverJson:off - "fork-seed:statemachine": [JSON.stringify([])], - // @effect-diagnostics-next-line preferSchemaOverJson:off - statemachine: [JSON.stringify([])], }, }, }); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 033cc192ccfe..43d674662d61 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -32,6 +32,7 @@ import { ModelSelection, type ProjectId, SourceControlProviderError, + type SourceControlProviderKind, type SourceControlWritingStyleSettings, type ThreadId, } from "@t3tools/contracts"; @@ -144,6 +145,12 @@ const STATUS_RESULT_CACHE_CAPACITY = 2_048; // exponentially via prLookupFailureTtl, so throttling pressure still drops // under 429s instead of amplifying it. const PR_LOOKUP_CACHE_TTL = Duration.seconds(60); +// Answers without an open PR ("no PR yet", merged, closed) only change when +// someone opens a PR, and the paths that do that in-app (turn end, push, +// create PR, user refresh) bypass this cache. Re-asking every minute for each +// idle branch was the bulk of background GitHub quota use, so these wait out +// a longer TTL and a PR opened outside the app shows up within minutes. +const PR_LOOKUP_NO_OPEN_PR_CACHE_TTL = Duration.minutes(5); const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20); const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15); const PR_LOOKUP_CACHE_CAPACITY = 2_048; @@ -588,6 +595,26 @@ function parseCustomCommitMessage(raw: string): { subject: string; body: string }; } +// Without the owner selector, a bare branch name also lists same-named +// branches on other forks (`main`, `patch-1`), so GitHub probes ask for a full +// page and let matchesBranchHeadContext pick the right head. gh fetches up to +// 100 in one request, and GitHub prices a first:100 connection like first:1. +const GITHUB_HEAD_BRANCH_PROBE_LIMIT = 100; + +// `gh pr list --head` filters on the head ref name alone and accepts anything, so an +// `owner:branch` or `remote:branch` selector silently lists zero pull requests +// while spending a GraphQL call. Git branch names cannot contain ":", and the +// bare head branch is always among the selectors, so GitHub probes skip them and +// leave the owner check to matchesBranchHeadContext. +function probeableHeadSelectors( + providerKind: SourceControlProviderKind, + headSelectors: ReadonlyArray, +): ReadonlyArray { + return providerKind === "github" + ? headSelectors.filter((selector) => !selector.includes(":")) + : headSelectors; +} + function appendUnique(values: string[], next: string | null | undefined): void { const trimmed = next?.trim() ?? ""; if (trimmed.length === 0 || values.includes(trimmed)) { @@ -1105,7 +1132,9 @@ export const make = Effect.gen(function* () { timeToLive: (exit, key) => { if (Exit.isSuccess(exit)) { prLookupFailureStreakByKey.delete(key); - return PR_LOOKUP_CACHE_TTL; + return exit.value.latest?.state === "open" + ? PR_LOOKUP_CACHE_TTL + : PR_LOOKUP_NO_OPEN_PR_CACHE_TTL; } return nextPrLookupFailureTtl(key); }, @@ -1596,12 +1625,14 @@ export const make = Effect.gen(function* () { | "isCrossRepository" >, ) { - for (const headSelector of headContext.headSelectors) { - const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ + const provider = yield* sourceControlProvider(cwd); + const headSelectors = probeableHeadSelectors(provider.kind, headContext.headSelectors); + for (const headSelector of headSelectors) { + const pullRequests = yield* provider.listChangeRequests({ cwd, headSelector, state: "open", - limit: 1, + limit: provider.kind === "github" ? GITHUB_HEAD_BRANCH_PROBE_LIMIT : 1, }); const normalizedPullRequests = pullRequests.map(toPullRequestInfo); @@ -1626,12 +1657,13 @@ export const make = Effect.gen(function* () { ) { const parsedByNumber = new Map(); - for (const headSelector of headContext.headSelectors) { - const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ + const provider = yield* sourceControlProvider(cwd); + for (const headSelector of probeableHeadSelectors(provider.kind, headContext.headSelectors)) { + const pullRequests = yield* provider.listChangeRequests({ cwd, headSelector, state: "all", - limit: 20, + limit: provider.kind === "github" ? GITHUB_HEAD_BRANCH_PROBE_LIMIT : 20, }); for (const pr of pullRequests.map(toPullRequestInfo)) { @@ -2543,11 +2575,20 @@ export const make = Effect.gen(function* () { }); } - const worktree = yield* gitCore.createWorktree({ - cwd: input.cwd, - refName: localPullRequestBranch, - path: null, - }); + const worktree = yield* gitCore.createWorktree( + { + cwd: input.cwd, + refName: localPullRequestBranch, + path: null, + }, + { + // Best effort: a settings read failure falls back to the checkout's t3.json. + submodules: yield* projectSettingsFor(input).pipe( + Effect.map((settings) => settings.worktreeSubmodules), + Effect.orElseSucceed(() => null), + ), + }, + ); yield* ensureExistingWorktreeUpstream(worktree.worktree.path); yield* maybeRunSetupScript(worktree.worktree.path); diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index ef552f9b04d0..7a8ed739d734 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -685,6 +685,7 @@ it.effect("pins a provider session to its initial host despite later focus chang environmentId: scope.environmentId, connectionId: "connection-stale", focused: true, + liveTabs: [{ threadId: scope.threadId, tabId: PreviewTabId.make("stale-tab") }], }); expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( "second", @@ -725,6 +726,130 @@ it.effect("pins a provider session to its initial host despite later focus chang ), ); +it.effect("prefers the live tab owner for new sessions without moving existing leases", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const connections = new Map(); + for (const clientId of ["owner", "other"]) { + const requests = requestsFrom( + yield* broker.connect(makeHost({ clientId })), + (connectionId) => connections.set(clientId, connectionId), + ); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + clientId, + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: clientId, + }), + ).pipe(Effect.forkScoped); + } + yield* Effect.yieldNow; + yield* broker.focusHost({ + clientId: "owner", + environmentId: scope.environmentId, + connectionId: connections.get("owner")!, + focused: false, + liveTabs: [ + { threadId: scope.threadId, tabId: PreviewTabId.make("signed-in"), visible: true }, + ], + }); + yield* broker.focusHost({ + clientId: "other", + environmentId: scope.environmentId, + connectionId: connections.get("other")!, + focused: true, + liveTabs: [ + { threadId: scope.threadId, tabId: PreviewTabId.make("signed-in"), visible: false }, + { + threadId: ThreadId.make("another-thread"), + tabId: PreviewTabId.make("different-tab"), + visible: true, + }, + ], + }); + expect(yield* broker.invoke({ scope, operation: "evaluate", input: {} })).toBe( + "owner", + ); + expect( + yield* broker.invoke({ + scope: { ...scope, providerSessionId: "explicit-owner" }, + tabId: PreviewTabId.make("signed-in"), + operation: "snapshot", + input: {}, + }), + ).toBe("owner"); + expect( + yield* broker.invoke({ + scope: { ...scope, providerSessionId: "other-tab" }, + tabId: PreviewTabId.make("different-tab"), + operation: "evaluate", + input: {}, + }), + ).toBe("other"); + + yield* broker.focusHost({ + clientId: "owner", + environmentId: scope.environmentId, + connectionId: connections.get("owner")!, + focused: false, + liveTabs: [], + }); + expect(yield* broker.invoke({ scope, operation: "evaluate", input: {} })).toBe( + "owner", + ); + expect( + yield* broker.invoke({ + scope: { ...scope, providerSessionId: "after-tab-closed" }, + operation: "evaluate", + input: {}, + }), + ).toBe("other"); + }), + ), +); + +it.effect("prefers a focused host over unrelated extra capabilities for a new session", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + let focusedConnectionId = ""; + for (const [clientId, supportedOperations] of [ + ["focused", ["status"]], + ["background", ["status", "resize"]], + ] as const) { + const requests = requestsFrom( + yield* broker.connect(makeHost({ clientId, supportedOperations })), + (connectionId) => { + if (clientId === "focused") focusedConnectionId = connectionId; + }, + ); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + clientId, + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: clientId, + }), + ).pipe(Effect.forkScoped); + } + yield* Effect.yieldNow; + yield* broker.focusHost({ + clientId: "focused", + environmentId: scope.environmentId, + connectionId: focusedConnectionId, + focused: true, + }); + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( + "focused", + ); + }), + ), +); + it.effect("does not route new operations to legacy hosts that did not advertise support", () => Effect.scoped( Effect.gen(function* () { @@ -921,6 +1046,7 @@ it.effect("fails over a pinned provider session only after its host disconnects" environmentId: scope.environmentId, connectionId: firstConnectionId, focused: true, + liveTabs: [{ threadId: scope.threadId, tabId: firstTabId }], }); expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({ host: "first", diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 527f71f5915d..65e3064f49a5 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -73,6 +73,7 @@ interface ClientConnection { readonly environmentId: PreviewAutomationHost["environmentId"]; readonly supportedOperations: ReadonlySet; readonly focused: boolean; + readonly liveTabs: NonNullable; readonly focusOrder: number; readonly queue: Queue.Queue; } @@ -376,6 +377,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { environmentId: host.environmentId, supportedOperations: new Set(host.supportedOperations ?? PREVIEW_AUTOMATION_V1_OPERATIONS), focused: false, + liveTabs: [], focusOrder: 0, queue, }; @@ -432,6 +434,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { clients.set(host.clientId, { ...currentHost, focused: host.focused, + liveTabs: host.liveTabs ?? currentHost.liveTabs, focusOrder: host.focused ? focusSequence : currentHost.focusOrder, }); return { ...current, clients, focusSequence }; @@ -492,6 +495,13 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { // operation is not silently moved to a newer client: the caller gets a // capability failure and can deliberately start a fresh provider // session. A dead lease is pruned above and may fail over. + const ownsTargetTab = (host: ClientConnection, visibleOnly = false) => + host.liveTabs.some( + (tab) => + tab.threadId === input.scope.threadId && + (!visibleOnly || tab.visible === true) && + (input.tabId === undefined || tab.tabId === input.tabId), + ); const connection = hasLiveAssignment && supportsOperation(assignedConnection, input.operation) ? assignedConnection @@ -505,7 +515,8 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { ) .sort( (left, right) => - right.supportedOperations.size - left.supportedOperations.size || + Number(ownsTargetTab(right, true)) - Number(ownsTargetTab(left, true)) || + Number(ownsTargetTab(right)) - Number(ownsTargetTab(left)) || Number(right.focused) - Number(left.focused) || right.focusOrder - left.focusOrder, )[0]; diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts index 985dcc0ea5a8..3627ceed46f2 100644 --- a/apps/server/src/observability/Layers/Observability.ts +++ b/apps/server/src/observability/Layers/Observability.ts @@ -24,10 +24,6 @@ export const ObservabilityLive = Layer.unwrap( const attribution = yield* ResourceAttribution.ResourceAttribution; const otel = config.otelEnvironment; - for (const warning of otel.warnings) { - yield* Effect.logWarning(warning); - } - // One variable can decline every signal, and saying so three times reads // like three separate problems. const declined = new Set( @@ -35,9 +31,6 @@ export const ObservabilityLive = Layer.unwrap( (reason) => reason !== undefined, ), ); - for (const reason of declined) { - yield* Effect.logWarning(reason); - } // Each signal builds its own serializer, so the wire format travels with // the endpoint that asked for it rather than with this process. @@ -114,6 +107,15 @@ export const ObservabilityLive = Layer.unwrap( temporality: config.otlpMetricsExport.temporality, }).pipe(Layer.provide(serializationFor(config.otlpMetricsExport))); - return Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer); + // Logged once the server's loggers are installed, so the warnings use them. + const otelWarningsLayer = Layer.effectDiscard( + Effect.forEach([...otel.warnings, ...declined], (warning) => Effect.logWarning(warning)), + ); + + return otelWarningsLayer.pipe( + Layer.provideMerge( + Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer), + ), + ); }), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 9bc701af0837..c7d9417bc75b 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -2637,11 +2637,14 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); expect(harness.pruneWorktrees).toHaveBeenCalledWith({ cwd: "/tmp/provider-project" }); - expect(harness.createWorktree).toHaveBeenCalledWith({ - cwd: "/tmp/provider-project", - refName: "feature/restore", - path: worktreePath, - }); + expect(harness.createWorktree).toHaveBeenCalledWith( + { + cwd: "/tmp/provider-project", + refName: "feature/restore", + path: worktreePath, + }, + { submodules: null }, + ); expect(harness.createWorktree.mock.invocationCallOrder[0]).toBeLessThan( harness.startSession.mock.invocationCallOrder[0]!, ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 80cf2a1f4761..bdf4fe8e69d9 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -36,6 +36,7 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; import { + ProviderAdapterProcessError, ProviderAdapterRequestError, ProviderAdapterValidationError, ProviderWorkspaceMissingError, @@ -64,6 +65,7 @@ import { import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError); const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); const isProviderWorkspaceMissingError = Schema.is(ProviderWorkspaceMissingError); @@ -373,6 +375,9 @@ const make = Effect.gen(function* () { if (isProviderAdapterRequestError(failReason?.error)) { return failReason.error.detail; } + if (isProviderAdapterProcessError(failReason?.error)) { + return failReason.error.detail; + } if (isProviderAdapterValidationError(failReason?.error)) { return failReason.error.issue; } @@ -496,8 +501,16 @@ const make = Effect.gen(function* () { }); // A directory deleted without `git worktree remove` leaves an admin entry // that makes `git worktree add` refuse the path; prune clears it. + // Best effort like the rest of this recovery: a settings read failure + // falls back to the checkout's t3.json. + const submodules = yield* projectSettingsForThread(thread.id).pipe( + Effect.map((settings) => settings.worktreeSubmodules), + Effect.orElseSucceed(() => null), + ); yield* gitWorkflow.pruneWorktrees({ cwd }).pipe( - Effect.andThen(gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath })), + Effect.andThen( + gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath }, { submodules }), + ), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) diff --git a/apps/server/src/orchestration/Layers/ThreadBootstrap.ts b/apps/server/src/orchestration/Layers/ThreadBootstrap.ts index b73090961428..10a4ef617e80 100644 --- a/apps/server/src/orchestration/Layers/ThreadBootstrap.ts +++ b/apps/server/src/orchestration/Layers/ThreadBootstrap.ts @@ -2,26 +2,31 @@ import { CommandId, EventId, OrchestrationDispatchCommandError, + type ProjectId, type ThreadId, WORKTREE_SETUP_ACTIVITY_KIND, worktreeSetupActivityId, type WorktreeSetupSnapshot, } from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; import * as ProjectSetupScriptRunner from "../../project/ProjectSetupScriptRunner.ts"; import * as WorktreeSetupTracker from "../../project/WorktreeSetupTracker.ts"; +import * as ServerSettings from "../../serverSettings.ts"; import * as TerminalManager from "../../terminal/Manager.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import { ThreadBootstrapService, type ThreadBootstrapShape } from "../Services/ThreadBootstrap.ts"; @@ -75,6 +80,8 @@ const makeThreadBootstrap = Effect.gen(function* () { const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const threadDeletionReactor = yield* ThreadDeletionReactor; const crypto = yield* Crypto.Crypto; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const toDispatchCommandError = (cause: unknown, fallbackMessage: string) => isOrchestrationDispatchCommandError(cause) @@ -186,6 +193,34 @@ const makeThreadBootstrap = Effect.gen(function* () { }); }; + // Project setting > environment setting; null when neither is set so + // the driver reads the freshly created checkout's own t3.json (the + // branch being checked out may declare something the project root does + // not). Settings that fail to load fall through the same way. + const resolveBootstrapWorktreeSubmodules = Effect.fnUntraced(function* (input: { + readonly threadId: ThreadId; + readonly projectId: ProjectId | null; + }) { + const settings = yield* serverSettings.getSettings.pipe(Effect.orElseSucceed(() => null)); + if (!settings) return null; + // A worktree can also be prepared for an existing thread, whose + // project is only known through its shell. + const resolvedProjectId = + input.projectId ?? + (yield* projectionSnapshotQuery.getThreadShellById(input.threadId).pipe( + Effect.map((thread) => Option.getOrNull(thread)?.projectId ?? null), + Effect.orElseSucceed(() => null), + )); + const project = + resolvedProjectId === null + ? null + : yield* projectionSnapshotQuery.getProjectShellById(resolvedProjectId).pipe( + Effect.map(Option.getOrNull), + Effect.orElseSucceed(() => null), + ); + return resolveProjectSettings(settings, resolvedProjectId, project).settings.worktreeSubmodules; + }); + const dispatchBootstrapTurnStart: ThreadBootstrapShape["dispatchBootstrapTurnStart"] = ( command, options, @@ -625,6 +660,10 @@ const makeThreadBootstrap = Effect.gen(function* () { } yield* worktreeSetupTracker.stageStatus(threadId, "checkout", "running"); let checkoutTotal: number | null = null; + const submodules = yield* resolveBootstrapWorktreeSubmodules({ + threadId, + projectId: targetProjectId ?? null, + }); const worktree = yield* gitWorkflow.createWorktree( { cwd: prepareWorktree.projectCwd, @@ -634,6 +673,7 @@ const makeThreadBootstrap = Effect.gen(function* () { path: null, }, { + submodules, progress: { // Git has registered the directory at this point, so a // cancel during the submodule step can still remove it. @@ -663,6 +703,13 @@ const makeThreadBootstrap = Effect.gen(function* () { worktreeSetupTracker.stageStatus(threadId, "submodules", "running"), ), ), + onSubmodulesDisabled: ({ source }) => + worktreeSetupTracker.stageStatus( + threadId, + "submodules", + "skipped", + `disabled in ${source}`, + ), onSubmoduleLine: (line) => { const submodulePath = /Submodule path '([^']+)'/.exec(line)?.[1]; return submodulePath === undefined diff --git a/apps/server/src/orchestration/Normalizer.attachments.test.ts b/apps/server/src/orchestration/Normalizer.attachments.test.ts index 51c70d7401ae..d44ec58e6a95 100644 --- a/apps/server/src/orchestration/Normalizer.attachments.test.ts +++ b/apps/server/src/orchestration/Normalizer.attachments.test.ts @@ -13,6 +13,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -59,6 +60,53 @@ function turnStartCommand(input: { } describe("normalizeDispatchCommand attachments", () => { + it.effect("accepts 100 inline images and rejects 101 before writing files", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const attachments = Array.from({ length: 100 }, () => ({ + dataUrl: "data:image/png;base64,cGl4ZWxz", + sizeBytes: 6, + })); + const rejected = yield* normalizeDispatchCommand( + turnStartCommand({ attachments: [...attachments, attachments[0]!] }), + ).pipe(Effect.flip); + expect(rejected.message).toContain("up to 100"); + expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]); + const accepted = yield* normalizeDispatchCommand(turnStartCommand({ attachments })); + if (accepted.type !== "thread.turn.start") throw new Error("Wrong command"); + expect(accepted.message.attachments).toHaveLength(100); + expect(NodeFS.readdirSync(config.attachmentsDir)).toHaveLength(100); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects decoded image overflow before writing it and removes earlier files", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + let writtenBytes = 0; + const dataUrl = `data:image/png;base64,${Buffer.alloc(10 * 1024 * 1024).toString("base64")}`; + const command = turnStartCommand({ + attachments: [ + ...Array.from({ length: 8 }, () => ({ dataUrl, sizeBytes: 1 })), + { dataUrl: "data:image/png;base64,YQ==", sizeBytes: 0 }, + ], + }); + const error = yield* normalizeDispatchCommand(command).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + writeFile: (path, data, options) => { + writtenBytes += data.byteLength; + return fileSystem.writeFile(path, data, options); + }, + }), + Effect.flip, + ); + expect(error.message).toContain("80 MiB"); + expect(writtenBytes).toBe(80 * 1024 * 1024); + expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("rejects duplicate client ids before persisting attachments", () => Effect.gen(function* () { const error = yield* normalizeDispatchCommand( @@ -464,25 +512,25 @@ describe("question attachments", () => { answers: { first: "", second: "" }, createdAt: "2026-08-01T00:00:00.000Z", attachmentsByQuestionId: { - first: Array.from({ length: 4 }, () => attachment), - second: Array.from({ length: 5 }, () => attachment), + first: Array.from({ length: 50 }, () => attachment), + second: Array.from({ length: 51 }, () => attachment), }, }; const failure = yield* normalizeDispatchCommand(command).pipe(Effect.flip); - expect(failure.message).toContain("up to 8"); + expect(failure.message).toContain("up to 100"); expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([`${id}.txt`]); const accepted = { ...command, attachmentsByQuestionId: { ...command.attachmentsByQuestionId, - second: Array.from({ length: 4 }, () => attachment), + second: Array.from({ length: 50 }, () => attachment), }, }; const normalized = yield* normalizeDispatchCommand(accepted); if (normalized.type !== "thread.user-input.respond") throw new Error("Wrong command"); const attachments = Object.values(normalized.attachmentsByQuestionId!).flat(); - expect(attachments).toHaveLength(8); - expect(new Set(attachments.map((item) => item.id)).size).toBe(8); + expect(attachments).toHaveLength(100); + expect(new Set(attachments.map((item) => item.id)).size).toBe(100); for (const item of attachments) { expect(item.name).toBe(attachment.name); expect( diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index bef58adc0581..d4959f39af59 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -5,7 +5,7 @@ import * as Path from "effect/Path"; import { type ClientOrchestrationCommand, type UserInputAttachments, - PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + getProviderAttachmentLimitError, type IsoDateTime, type OrchestrationCommand, OrchestrationDispatchCommandError, @@ -142,13 +142,9 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => canonicalCommand.type === "thread.turn.start" ? canonicalCommand.message.attachments : Object.values(canonicalCommand.attachmentsByQuestionId ?? {}).flat(); - if ( - canonicalCommand.type === "thread.user-input.respond" && - attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS - ) { - return yield* new OrchestrationDispatchCommandError({ - message: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per question response.`, - }); + const attachmentLimitError = getProviderAttachmentLimitError(attachments); + if (attachmentLimitError) { + return yield* new OrchestrationDispatchCommandError({ message: attachmentLimitError }); } if (canonicalCommand.type === "thread.turn.start") { const clientAttachmentIds = new Set(); @@ -163,11 +159,12 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => } } const claimedAttachmentPaths: string[] = []; + const attachmentsWithDecodedSizes = [...attachments]; // Context records bind to attachments by the id the client knew; they follow the rename. const finalAttachmentIdByClientId = new Map(); const normalizedAttachments = yield* Effect.forEach( attachments, - (attachment) => + (attachment, index) => Effect.gen(function* () { if (!("dataUrl" in attachment)) { const claim = planAttachmentClaim({ @@ -259,6 +256,11 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => sizeBytes: bytes.byteLength, ...(attachment.source ? { source: attachment.source } : {}), }; + attachmentsWithDecodedSizes[index] = persistedAttachment; + const decodedLimitError = getProviderAttachmentLimitError(attachmentsWithDecodedSizes); + if (decodedLimitError) { + return yield* new OrchestrationDispatchCommandError({ message: decodedLimitError }); + } const attachmentPath = resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, @@ -286,6 +288,7 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => }), ), ); + claimedAttachmentPaths.push(attachmentPath); if (attachment.id !== undefined) { finalAttachmentIdByClientId.set(attachment.id, attachmentId); } diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.ts b/apps/server/src/orchestration/PullRequestSyncReactor.ts index 35819d9e7430..319ea46ad64b 100644 --- a/apps/server/src/orchestration/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration/PullRequestSyncReactor.ts @@ -303,7 +303,9 @@ export const make = Effect.gen(function* () { Effect.catchCause(logSkipped("pull request sync skipped", { key })), ) : Effect.void, - { concurrency: 8, discard: true }, + // As wide as one batched summary read, so the sweep's reads on a host arrive together and + // GitHub answers them in one request rather than one `gh pr view` apiece. + { concurrency: 25, discard: true }, ); }); diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 314c0f87488f..07afbaf05e6a 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -475,6 +475,49 @@ describe("ThreadSettlementReactor", () => { }), ), ); + it.effect("skips the branch recheck when a terminal link would settle nothing", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const previous = { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 1, + url: "https://example.test/owner/repository/pull/1", + }; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("resumed-manual", { + branch: "main", + linkedPullRequest: previous, + latestUserMessageAt: "2026-08-28T00:00:00.000Z", + }), + ], + [makeProject()], + ), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: true, + }, + branchPullRequest: () => Effect.succeed(makeBranchPullRequest("open")), + pullRequestSummary: (input) => + Effect.succeed({ + ...makePullRequestSummary({ ...input, state: "merged" }), + mergedAt: "2026-08-27T00:00:00.000Z", + }), + }); + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); it.effect("uses saved PRs without settling resumed threads or branches with newer PRs", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 0dec28a7df64..ff6f995df46f 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -209,9 +209,30 @@ export const make = Effect.gen(function* () { }; const groups = Map.groupBy(lookupCandidates, lookupKey); + const wouldSettle = Effect.fn("ThreadSettlementReactor.wouldSettle")(function* ( + group: ReadonlyArray<(typeof candidates)[number]>, + pullRequest: SettlementPullRequest, + ) { + const currentSettings = yield* settingsService.getSettings; + const decisionNow = DateTime.formatIso(yield* DateTime.now); + return group.some((thread) => { + const { settings } = resolveProjectSettings(currentSettings, thread.projectId); + return ( + resolveAutoSettlementAt({ + thread, + pullRequest, + now: decisionNow, + autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, + autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, + }) !== null + ); + }); + }); + const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* ( - thread: (typeof candidates)[number], + group: ReadonlyArray<(typeof candidates)[number]>, ) { + const thread = group[0]!; const reference = thread.linkedPullRequest ?? thread.branchPullRequest; if (reference != null) { const matchesMerge = @@ -236,10 +257,21 @@ export const make = Effect.gen(function* () { }, { recoverTransientFailure: false }, ); + const terminal = { + state: summary.state, + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, + } satisfies SettlementPullRequest; const cwd = lookupCwdByThreadId.get(thread.id); if (summary.state !== "open" && thread.branch !== null && cwd !== undefined) { // A reused branch can already have a new open PR while discovery // is replacing its old link. Do not let settlement win that race. + // Only pay for the uncached lookup when this sweep would otherwise + // settle: a terminal link that settles nothing (resumed thread, + // settle-on-merge off) would re-query the host every minute. A + // group that becomes eligible after this check waits for the next + // sweep rather than settling on the unverified link. + if (!(yield* wouldSettle(group, terminal))) return undefined; const current = yield* git.branchPullRequest( { cwd, branch: thread.branch }, { refresh: true }, @@ -253,11 +285,7 @@ export const make = Effect.gen(function* () { return current; } } - return { - state: summary.state, - closedAt: summary.closedAt ?? null, - mergedAt: summary.mergedAt ?? null, - } satisfies SettlementPullRequest; + return terminal; } if (thread.branch === null) return null; const cwd = lookupCwdByThreadId.get(thread.id); @@ -271,7 +299,8 @@ export const make = Effect.gen(function* () { groups.values(), (group) => Effect.gen(function* () { - const pullRequest = yield* pullRequestFor(group[0]!); + const pullRequest = yield* pullRequestFor(group); + if (pullRequest === undefined) return; yield* Effect.forEach(group, (thread) => settleThread(thread, pullRequest), { discard: true, }); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 52f3feb0e316..ad2ed15342d7 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -140,7 +140,10 @@ export const ClaudeDriver: ProviderDriver = { Effect.provideService(Path.Path, path), ), ); - const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig); + const continuationGroupKey = yield* makeClaudeContinuationGroupKey( + effectiveConfig, + processEnv, + ); const stampIdentity = withInstanceIdentity({ instanceId, driverKind: DRIVER_KIND, @@ -176,7 +179,11 @@ export const ClaudeDriver: ProviderDriver = { Effect.provideService(Path.Path, path), ), }); - const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); + const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey( + effectiveConfig, + cwd, + processEnv, + ); // Start the TTL-gated refresh without delaying provider readiness. The // next check observes a remote manifest after the background fetch lands. @@ -260,6 +267,7 @@ export const ClaudeDriver: ProviderDriver = { accentColor, enabled, snapshot, + invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache), snapshotForCwd, adapter, textGeneration, diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index 33b11237547e..fb1caf753f86 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -15,13 +15,20 @@ import { it.layer(NodeServices.layer)("ClaudeHome", (it) => { describe("Claude home resolution", () => { - it.effect("uses the process home when no Claude home override is configured", () => + it.effect("treats empty, ~/.claude, and the expanded default as the same Claude home", () => Effect.gen(function* () { const path = yield* Path.Path; - const resolved = path.resolve(NodeOS.homedir()); + const resolved = path.resolve(path.join(NodeOS.homedir(), ".claude")); expect(yield* resolveClaudeHomePath({ homePath: "" })).toBe(resolved); + expect(yield* resolveClaudeHomePath({ homePath: "~/.claude" })).toBe(resolved); + expect(yield* resolveClaudeHomePath({ homePath: resolved })).toBe(resolved); expect(yield* makeClaudeEnvironment({ homePath: "" })).toBe(process.env); + + const key = `claude:home:${resolved}`; + expect(yield* makeClaudeContinuationGroupKey({ homePath: "" })).toBe(key); + expect(yield* makeClaudeContinuationGroupKey({ homePath: "~/.claude" })).toBe(key); + expect(yield* makeClaudeContinuationGroupKey({ homePath: resolved })).toBe(key); }), ); @@ -40,6 +47,24 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { }), ); + it.effect("uses inherited CLAUDE_CONFIG_DIR when homePath is empty", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const inherited = path.resolve("/tmp/claude-inherited"); + const environment = { CLAUDE_CONFIG_DIR: inherited }; + + expect(yield* resolveClaudeHomePath({ homePath: "" }, environment)).toBe(inherited); + expect(yield* makeClaudeContinuationGroupKey({ homePath: "" }, environment)).toBe( + `claude:home:${inherited}`, + ); + + const explicit = path.resolve(NodeOS.homedir(), ".claude-work"); + expect(yield* resolveClaudeHomePath({ homePath: "~/.claude-work" }, environment)).toBe( + explicit, + ); + }), + ); + it("points the signed-out hint at the configured Claude home", () => { expect(claudeSignedOutMessage({ configDir: undefined, cwd: "/synthetic" })).toContain( "run `claude auth login`", @@ -59,16 +84,5 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { 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; - const resolved = path.resolve(NodeOS.homedir()); - - expect(yield* makeClaudeContinuationGroupKey({ homePath: "" })).toBe( - `claude:home:${resolved}`, - ); - }), - ); }); }); diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index bbd005a1e00b..70699ca669e8 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -9,12 +9,28 @@ import { expandHomePath } from "../../pathExpansion.ts"; const quotePath = Schema.encodeSync(Schema.fromJsonString(Schema.String)); +/** + * Resolve the Claude config directory the CLI would use: the instance's + * `homePath` (exported as `CLAUDE_CONFIG_DIR`), then an inherited + * `CLAUDE_CONFIG_DIR`, then Claude's default `~/.claude`. Empty must not + * fall back to bare `$HOME` — that leftover from the old HOME override + * produced a different continuation group than an explicit `~/.claude`. + */ export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function* ( config: Pick, + environment?: NodeJS.ProcessEnv, ): Effect.fn.Return { const path = yield* Path.Path; const homePath = config.homePath.trim(); - return path.resolve(homePath.length > 0 ? expandHomePath(homePath) : NodeOS.homedir()); + if (homePath.length > 0) { + return path.resolve(expandHomePath(homePath)); + } + // Inherited env vars are not shell-expanded, so a literal `~` stays literal. + const inherited = environment?.CLAUDE_CONFIG_DIR?.trim() ?? ""; + if (inherited.length > 0) { + return path.resolve(inherited); + } + return path.resolve(path.join(NodeOS.homedir(), ".claude")); }); export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function* ( @@ -38,8 +54,11 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function }); export const makeClaudeContinuationGroupKey = Effect.fn("makeClaudeContinuationGroupKey")( - function* (config: Pick): Effect.fn.Return { - const resolvedHomePath = yield* resolveClaudeHomePath(config); + function* ( + config: Pick, + environment?: NodeJS.ProcessEnv, + ): Effect.fn.Return { + const resolvedHomePath = yield* resolveClaudeHomePath(config, environment); return `claude:home:${resolvedHomePath}`; }, ); @@ -48,8 +67,9 @@ export const makeClaudeCapabilitiesCacheKey = Effect.fn("makeClaudeCapabilitiesC function* ( config: Pick, cwd?: string, + environment?: NodeJS.ProcessEnv, ): Effect.fn.Return { - const resolvedHomePath = yield* resolveClaudeHomePath(config); + const resolvedHomePath = yield* resolveClaudeHomePath(config, environment); return `${config.binaryPath}\0${resolvedHomePath}\0${cwd ?? ""}`; }, ); diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 961a8f4d8a77..7c7e68d41918 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -139,11 +139,11 @@ export const CursorDriver: ProviderDriver = { const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); - const discoverModels = yield* makeCursorModelDiscovery(effectiveConfig, processEnv); + const modelDiscovery = yield* makeCursorModelDiscovery(effectiveConfig, processEnv); const checkProvider = checkCursorProviderStatus( effectiveConfig, processEnv, - discoverModels, + modelDiscovery.discover, ).pipe( Effect.flatMap((snapshot) => effectiveConfig.enabled && snapshot.installed && snapshot.auth.status === "authenticated" @@ -222,6 +222,7 @@ export const CursorDriver: ProviderDriver = { accentColor, enabled, snapshot, + invalidateCaches: modelDiscovery.invalidate, snapshotForCwd: (cwd) => !effectiveConfig.enabled ? snapshot.getSnapshot diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 4c3ad4b04551..6b6aeff17f35 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -543,6 +543,49 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }), ); + it.effect("surfaces cursor-agent cli.json schema stderr instead of a closed-session error", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const workspace = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-cli-json-")), + ); + const wrapperPath = writeFakeCli({ + directory: workspace, + name: "fake-cursor-agent", + source: [ + "process.stderr.write(`Invalid project config at ${process.cwd()}/.cursor/cli.json: schema validation failed. [`", + " + JSON.stringify({", + ' code: "unrecognized_keys",', + ' keys: ["approvalMode", "sandbox"],', + " path: [],", + " message: \"Unrecognized key(s) in object: 'approvalMode', 'sandbox'\",", + ' }) + "]\\n");', + "process.exit(1);", + ].join("\n"), + }); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + const error = yield* adapter + .startSession({ + threadId: ThreadId.make("cursor-cli-json-schema"), + provider: ProviderDriverKind.make("cursor"), + cwd: workspace, + runtimeMode: "full-access", + }) + .pipe(Effect.flip); + + assert.equal(error._tag, "ProviderAdapterProcessError"); + assert.include(error.message, "cli.json"); + assert.include(error.message, "Unrecognized key"); + assert.notInclude(error.message, "adapter thread is closed"); + if (error._tag === "ProviderAdapterProcessError") { + assert.include(error.detail, "approvalMode"); + assert.include(error.detail, "sandbox"); + } + }), + ); + it.effect("maps app plan mode onto the ACP plan session mode", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 19cfd22c6964..47826fcc8704 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -742,7 +742,7 @@ describe("discoverCursorModelsViaAcp", () => { apiEndpoint: "", customModels: [], }; - const discover = yield* makeCursorModelDiscovery(settings, { + const { discover, invalidate } = yield* makeCursorModelDiscovery(settings, { ...process.env, T3_ACP_REQUEST_LOG_PATH: requestLogPath, }); @@ -755,6 +755,10 @@ describe("discoverCursorModelsViaAcp", () => { yield* fileSystem.writeFileString(requestLogPath, ""); expect(yield* discover(about)).toEqual(first); expect(yield* fileSystem.readFileString(requestLogPath)).toBe(""); + yield* invalidate; + expect(yield* discover(about)).toEqual(first); + expect(yield* fileSystem.readFileString(requestLogPath)).toContain("initialize"); + yield* fileSystem.writeFileString(requestLogPath, ""); yield* discover({ ...about, version: "2026.08.12" }); expect(yield* fileSystem.readFileString(requestLogPath)).toContain("initialize"); yield* fileSystem.writeFileString(requestLogPath, ""); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 6a8b93c88e90..dfaa8ffe338e 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -678,8 +678,11 @@ export const makeCursorModelDiscovery = Effect.fn("makeCursorModelDiscovery")(fu Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero, }, ); - return (about: Pick) => - Cache.get(cache, JSON.stringify([about.version, about.auth])); + return { + discover: (about: Pick) => + Cache.get(cache, JSON.stringify([about.version, about.auth])), + invalidate: Cache.invalidateAll(cache), + }; }); function getCursorFallbackModels( diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 96d6b10d3839..601917d35864 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -7155,27 +7155,27 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const values = Map.prototype.values; yield* Effect.acquireRelease( Effect.sync(() => - vi - .spyOn(Map.prototype, "values") - .mockImplementation(function (this: Map) { - const iterator = values.call(this); - const next = iterator.next.bind(iterator); - iterator.next = () => { - const result = next(); - const value: unknown = result.value; - if ( - typeof value === "object" && - value !== null && - "id" in value && - typeof value.id === "string" && - value.id.startsWith("history-part-") - ) { - visitedHistoryParts += 1; - } - return result; - }; - return iterator; - }), + vi.spyOn(Map.prototype, "values").mockImplementation(function ( + this: Map, + ) { + const iterator = values.call(this); + const next = iterator.next.bind(iterator); + iterator.next = () => { + const result = next(); + const value: unknown = result.value; + if ( + typeof value === "object" && + value !== null && + "id" in value && + typeof value.id === "string" && + value.id.startsWith("history-part-") + ) { + visitedHistoryParts += 1; + } + return result; + }; + return iterator; + }), ), (spy) => Effect.sync(() => spy.mockRestore()), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 6f3818ce60e1..323b721670c0 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -1,15 +1,21 @@ import { defaultInstanceIdForDriver, ProviderDriverKind, + ThreadId, type ServerProvider, } from "@t3tools/contracts"; import { it, assert, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Stream from "effect/Stream"; +import * as ProviderAuthFlow from "../ProviderAuthFlow.ts"; + import type * as ClaudeAdapter from "../Services/ClaudeAdapter.ts"; import type * as CodexAdapter from "../Services/CodexAdapter.ts"; import type * as CursorAdapter from "../Services/CursorAdapter.ts"; @@ -187,3 +193,95 @@ it.layer(layer)("ProviderAdapterRegistryLive", (it) => { ]); })); }); + +it.effect("blocks shared credential session startup and preserves guarded adapter identity", () => + Effect.gen(function* () { + const target = fakeInstances[0]!; + const peer = fakeInstances[1]!; + const auth = yield* ProviderAuthFlow.make({ + instanceId: target.instanceId, + credentialBinding: { owner: "t3", key: "shared-auth" }, + methods: Effect.succeed([ + { id: "browser", name: "Browser", description: null, type: "agent" }, + ]), + authenticate: () => Effect.never, + logout: Effect.void, + }); + const peerAuth = yield* ProviderAuthFlow.make({ + instanceId: peer.instanceId, + credentialBinding: { owner: "t3", key: "shared-auth" }, + methods: Effect.succeed([]), + authenticate: () => Effect.void, + logout: Effect.void, + }); + const session = { + threadId: ThreadId.make("new-session"), + provider: peer.driverKind, + providerInstanceId: peer.instanceId, + status: "ready" as const, + runtimeMode: "approval-required" as const, + createdAt: "2026-09-21T00:00:00.000Z", + updatedAt: "2026-09-21T00:00:00.000Z", + }; + const start = vi.fn(() => Effect.succeed(session)); + const instances = [ + { ...target, auth }, + { ...peer, auth: peerAuth, adapter: { ...peer.adapter, startSession: start } }, + ]; + const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry.pipe( + Effect.provide( + ProviderAdapterRegistryLayer.ProviderAdapterRegistryLive.pipe( + Layer.provide( + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + getInstance: (id) => + Effect.succeed(instances.find((instance) => instance.instanceId === id)), + listInstances: Effect.succeed(instances), + }), + ), + ), + ), + ); + const guarded = yield* registry.getByInstance(peer.instanceId); + assert.strictEqual(yield* registry.getByInstance(peer.instanceId), guarded); + const flow = yield* auth.start("owner"); + const error = yield* guarded + .startSession({ + threadId: session.threadId, + providerInstanceId: peer.instanceId, + runtimeMode: "approval-required", + }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "ProviderAdapterValidationError"); + assert.strictEqual(start.mock.calls.length, 0); + yield* auth.cancel("owner", flow.flowId!); + assert.deepStrictEqual( + yield* guarded.startSession({ + threadId: session.threadId, + providerInstanceId: peer.instanceId, + runtimeMode: "approval-required", + }), + session, + ); + assert.strictEqual(start.mock.calls.length, 1); + const entered = yield* Deferred.make(); + const stopped = yield* Deferred.make(); + start.mockImplementation(() => + Effect.gen(function* () { + yield* Deferred.succeed(entered, undefined); + return yield* Effect.never; + }).pipe(Effect.ensuring(Deferred.succeed(stopped, undefined))), + ); + const startup = yield* guarded + .startSession({ + threadId: session.threadId, + providerInstanceId: peer.instanceId, + runtimeMode: "approval-required", + }) + .pipe(Effect.forkChild); + yield* Deferred.await(entered); + // Signing out through another instance must drain its peer's startup too. + yield* auth.logout(Effect.void); + yield* Deferred.await(stopped); + assert.strictEqual(Exit.isFailure(yield* Fiber.await(startup)), true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 9e8e3c5d1f90..5d70ee0d3d75 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -15,19 +15,89 @@ * * @module ProviderAdapterRegistryLive */ -import { ProviderInstanceId } from "@t3tools/contracts"; +import { ProviderInstanceId, ProviderSetupError, type ProviderSession } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; -import { ProviderUnsupportedError } from "../Errors.ts"; +import { + ProviderUnsupportedError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderAdapterRegistry, type ProviderAdapterRegistryShape, } from "../Services/ProviderAdapterRegistry.ts"; +import type { ProviderInstance } from "../ProviderDriver.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; + +const isSetupError = Schema.is(ProviderSetupError); + const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(function* () { const registry = yield* ProviderInstanceRegistry; + // Stable identity keeps ProviderService's event subscriptions attached once. + const guarded = new WeakMap>(); + const guard = (instance: ProviderInstance) => { + const auth = instance.auth; + if (!auth || (!auth.withAccess && !auth.isChangingCredentials && !auth.credentialBinding)) + return instance.adapter; + const cached = guarded.get(instance); + if (cached) return cached; + const adapter: ProviderAdapterShape = { + ...instance.adapter, + startSession: (input) => { + const start = Effect.gen(function* () { + const binding = auth.credentialBinding; + const related = binding + ? (yield* registry.listInstances).filter( + (peer) => + peer.auth?.credentialBinding?.key === binding.key && + peer.auth.credentialBinding.owner === binding.owner, + ) + : [instance]; + for (const peer of related) { + if (peer.auth?.isChangingCredentials && (yield* peer.auth.isChangingCredentials)) { + return yield* new ProviderSetupError({ + instanceId: instance.instanceId, + operation: "session", + detail: "Provider sign-in is changing. Try again after it finishes.", + }); + } + } + let admitted: Effect.Effect< + ProviderSession, + ProviderAdapterError | ProviderSetupError, + Scope.Scope + > = instance.adapter.startSession(input); + // Every shared owner holds the startup scope. A credential change + // interrupts admitted startup before it can escape the session drain. + for (const peer of related) { + if (peer.auth?.withAccess) admitted = peer.auth.withAccess(admitted); + } + return yield* Effect.scoped(admitted); + }); + // Adapters own established session lifetimes. This scope guards startup; + // ProviderAuthService drains routed sessions before changing credentials. + return start.pipe( + Effect.mapError((cause) => + isSetupError(cause) + ? new ProviderAdapterValidationError({ + provider: instance.driverKind, + operation: "startSession", + issue: cause.detail, + }) + : cause, + ), + ); + }, + }; + guarded.set(instance, adapter); + return adapter; + }; const getByInstance: ProviderAdapterRegistryShape["getByInstance"] = (instanceId) => registry.getInstance(instanceId).pipe( @@ -38,7 +108,7 @@ const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(fun provider: instanceId, }), ) - : Effect.succeed(instance.adapter), + : Effect.succeed(guard(instance)), ), ); diff --git a/apps/server/src/provider/Layers/ProviderAuthService.test.ts b/apps/server/src/provider/Layers/ProviderAuthService.test.ts index fb961b710486..8430ad3e1319 100644 --- a/apps/server/src/provider/Layers/ProviderAuthService.test.ts +++ b/apps/server/src/provider/Layers/ProviderAuthService.test.ts @@ -112,6 +112,14 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* directoryError?: ProviderSessionDirectoryPersistenceError; stopError?: ProviderServiceError; logoutError?: ProviderSetupError; + sharedCredentials?: boolean; + sharedBusy?: boolean; + sharedBusyEffect?: Effect.Effect; + responds?: boolean; + beforeLogout?: Effect.Effect; + beforeStop?: Effect.Effect; + beforeListSessions?: Effect.Effect; + onLookup?: Effect.Effect; } = {}, ) { const actions: string[] = []; @@ -138,6 +146,18 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* }); const auth: ProviderAuthController = { + ...(input.sharedCredentials + ? { credentialBinding: { owner: "provider" as const, key: "shared" } } + : {}), + ...(input.responds + ? { + respond: Effect.fn(function* (ownerSessionId, request) { + yield* checkOwner(ownerSessionId, request.flowId, "respond"); + actions.push(`respond:${request.response.type}`); + return state; + }), + } + : {}), start: Effect.fn(function* (ownerSessionId, stopSessions) { gateClosed = true; actions.push("close-gate"); @@ -169,6 +189,7 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* actions.push("close-gate"); yield* stopSessions; if (input.logoutError) return yield* input.logoutError; + yield* input.beforeLogout ?? Effect.void; actions.push("native-logout"); state = idle; flowOwner = undefined; @@ -181,13 +202,34 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* const instances = [ makeInstance({ instanceId, enabled: input.enabled ?? true, auth }), makeInstance({ instanceId: unsupportedInstanceId, enabled: true }), + ...(input.sharedCredentials + ? [ + makeInstance({ + instanceId: otherInstanceId, + enabled: true, + auth: { + ...auth, + isChangingCredentials: + input.sharedBusyEffect ?? Effect.succeed(input.sharedBusy ?? false), + invalidate: Effect.sync(() => { + actions.push("invalidate-shared"); + }), + }, + }), + ] + : []), ]; const service = yield* makeProviderAuthService.pipe( Effect.provide( Layer.mergeAll( Layer.mock(ProviderInstanceRegistry)({ getInstance: (id) => - Effect.succeed(instances.find((instance) => instance.instanceId === id)), + Effect.gen(function* () { + const found = instances.find((instance) => instance.instanceId === id); + yield* input.onLookup ?? Effect.void; + return found; + }), + listInstances: Effect.succeed(instances), subscribeChanges: PubSub.subscribe(registryChanges), }), Layer.mock(ProviderSessionDirectory)({ @@ -202,26 +244,44 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* }), Layer.mock(ProviderService)({ listSessions: () => - Effect.sync(() => { + Effect.gen(function* () { assert.isTrue(gateClosed); actions.push("list-sessions"); + yield* input.beforeListSessions ?? Effect.void; return [...sessions.values()]; }), stopSession: ({ threadId }) => - Effect.suspend(() => { + Effect.gen(function* () { assert.isTrue(gateClosed); actions.push(`stop:${threadId}`); - if (input.stopError) return Effect.fail(input.stopError); + yield* input.beforeStop ?? Effect.void; + if (input.stopError) return yield* input.stopError; sessions.delete(threadId); const binding = bindings.get(threadId); if (binding) bindings.set(threadId, { ...binding, status: "stopped" }); - return Effect.void; }), }), ), ), ); - return { service, actions, sessions, bindings }; + return { + service, + actions, + sessions, + bindings, + auth, + addInstance: (instance: ProviderInstance) => instances.push(instance), + replaceInstance: (replacement: ProviderInstance) => { + const index = instances.findIndex( + (instance) => instance.instanceId === replacement.instanceId, + ); + assert.isAtLeast(index, 0); + instances[index] = replacement; + }, + replaceAuth: (next: ProviderAuthController) => { + instances[0] = makeInstance({ instanceId, enabled: input.enabled ?? true, auth: next }); + }, + }; }); const makeStreamingController = Effect.fn("ProviderAuthService.test.makeStreamingController")( @@ -305,6 +365,56 @@ const observeAuth = Effect.fn("ProviderAuthService.test.observeAuth")(function* }); describe("ProviderAuthService", () => { + it.effect( + "stops sessions sharing credentials and invalidates their processes before logout", + () => + Effect.gen(function* () { + const { service, actions, sessions } = yield* makeHarness({ + sharedCredentials: true, + sessions: [ + makeSession("target"), + makeSession("shared", otherInstanceId), + makeSession("unrelated", unsupportedInstanceId), + ], + }); + yield* service.logout({ instanceId }); + assert.deepStrictEqual([...sessions.keys()], [ThreadId.make("unrelated")]); + assert.isBelow(actions.indexOf("stop:shared"), actions.indexOf("invalidate-shared")); + assert.isBelow(actions.indexOf("invalidate-shared"), actions.indexOf("native-logout")); + }), + ); + it.effect("rejects overlapping changes to a shared sign-in", () => + Effect.gen(function* () { + const { service, actions } = yield* makeHarness({ + sharedCredentials: true, + sharedBusy: true, + }); + for (const task of [service.start({ instanceId }, owner), service.logout({ instanceId })]) { + const error = yield* task.pipe(Effect.flip); + assert.include(error.detail, "shared sign-in"); + } + assert.deepStrictEqual(actions, []); + }), + ); + it.effect("routes typed interactions to the flow owner and rejects unsupported controllers", () => + Effect.gen(function* () { + const { service, actions } = yield* makeHarness({ responds: true }); + yield* service.start({ instanceId }, owner); + const request = { + instanceId, + flowId, + interactionId: "consent", + response: { type: "browser" as const, action: "accept" as const }, + }; + const rejected = yield* service.respond(request, otherOwner).pipe(Effect.flip); + assert.strictEqual(rejected.operation, "respond"); + yield* service.respond(request, owner); + assert.strictEqual(actions.at(-1), "respond:browser"); + const unsupported = yield* makeHarness(); + const error = yield* unsupported.service.respond(request, owner).pipe(Effect.flip); + assert.include(error.detail, "does not accept"); + }), + ); it.effect("stops routed sessions before sign-in, including for a disabled instance", () => Effect.gen(function* () { const { service, actions, sessions } = yield* makeHarness({ @@ -606,3 +716,200 @@ describe("ProviderAuthService", () => { }), ); }); + +it.effect("queued logout prompts resolve the current controller after provider replacement", () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const lookup = yield* Deferred.make(); + let observeLookup = false; + const harness = yield* makeHarness({ + beforeLogout: Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ), + onLookup: Effect.suspend(() => + observeLookup ? Deferred.succeed(lookup, undefined).pipe(Effect.asVoid) : Effect.void, + ), + }); + const first = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); + yield* Deferred.await(entered); + observeLookup = true; + const queued = yield* harness.service + .tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }) + .pipe(Effect.forkChild); + yield* Deferred.await(lookup); + let replacementLoggedOut = false; + harness.replaceAuth({ + ...harness.auth, + logout: (stopSessions) => + stopSessions.pipe( + Effect.andThen( + Effect.sync(() => { + replacementLoggedOut = true; + return idleAuthState; + }), + ), + ), + }); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(first); + assert.isTrue(yield* Fiber.join(queued)); + assert.isTrue(replacementLoggedOut); + assert.strictEqual(harness.actions.filter((action) => action === "native-logout").length, 1); + }).pipe(Effect.scoped), +); + +it.effect.each(["start", "logout", "prompt"] as const)( + "%s uses the same credential controller for shared exclusion and mutation during replacement", + (action) => + Effect.gen(function* () { + const checked = yield* Deferred.make(); + const continueCheck = yield* Deferred.make(); + const replacementPeerId = ProviderInstanceId.make("replacement-shared-peer"); + const harness = yield* makeHarness({ + sharedCredentials: true, + sharedBusyEffect: Deferred.succeed(checked, undefined).pipe( + Effect.andThen(Deferred.await(continueCheck)), + Effect.as(false), + ), + sessions: [ + makeSession("old-shared", otherInstanceId), + makeSession("replacement-shared", replacementPeerId), + ], + }); + harness.addInstance( + makeInstance({ + instanceId: replacementPeerId, + enabled: true, + auth: { + ...harness.auth, + credentialBinding: { owner: "provider", key: "replacement-binding" }, + isChangingCredentials: Effect.succeed(true), + invalidate: Effect.die( + "The unrelated replacement credential binding must stay intact.", + ), + }, + }), + ); + const operation = + action === "start" + ? harness.service.start({ instanceId }, owner) + : action === "logout" + ? harness.service.logout({ instanceId }) + : harness.service.tryHandlePromptCommand({ + instanceId, + text: "/logout", + hasAttachments: false, + }); + const running = yield* operation.pipe(Effect.forkChild); + yield* Deferred.await(checked); + let replacementMutations = 0; + harness.replaceAuth({ + ...harness.auth, + credentialBinding: { owner: "provider", key: "replacement-binding" }, + start: () => + Effect.sync(() => { + replacementMutations++; + return waitingAuthState; + }), + logout: () => + Effect.sync(() => { + replacementMutations++; + return idleAuthState; + }), + }); + yield* Deferred.succeed(continueCheck, undefined); + yield* Fiber.join(running); + assert.equal(replacementMutations, 0); + assert.include(harness.actions, action === "start" ? "start-sign-in" : "native-logout"); + assert.isFalse(harness.sessions.has(ThreadId.make("old-shared"))); + assert.isTrue(harness.sessions.has(ThreadId.make("replacement-shared"))); + const blocked = yield* Effect.flip(harness.service.logout({ instanceId })); + assert.include(blocked.detail, "shared sign-in"); + assert.equal(replacementMutations, 0); + }).pipe(Effect.scoped), +); + +it.effect.each([ + { owner: "provider" as const, key: "different-binding" }, + { owner: "t3" as const, key: "shared" }, +])( + "does not invalidate a peer that switches credential binding during session draining %#", + (binding) => + Effect.gen(function* () { + const draining = yield* Deferred.make(); + const continueDrain = yield* Deferred.make(); + const harness = yield* makeHarness({ + sharedCredentials: true, + sessions: [makeSession("shared-draining", otherInstanceId)], + beforeStop: Deferred.succeed(draining, undefined).pipe( + Effect.andThen(Deferred.await(continueDrain)), + ), + }); + const logout = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); + yield* Deferred.await(draining); + let replacementInvalidated = false; + harness.replaceInstance( + makeInstance({ + instanceId: otherInstanceId, + enabled: true, + auth: { + ...harness.auth, + credentialBinding: binding, + invalidate: Effect.sync(() => { + replacementInvalidated = true; + }), + }, + }), + ); + yield* Deferred.succeed(continueDrain, undefined); + yield* Fiber.join(logout); + assert.isFalse(replacementInvalidated); + assert.isFalse(harness.sessions.has(ThreadId.make("shared-draining"))); + assert.include(harness.actions, "native-logout"); + }).pipe(Effect.scoped), +); + +it.effect.each(["selection", "drain"] as const)( + "preserves replacement peer sessions when credentials change during session %s", + (phase) => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const proceed = yield* Deferred.make(); + const block = Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(proceed)), + ); + const harness = yield* makeHarness({ + sharedCredentials: true, + sessions: [ + makeSession("target-draining"), + makeSession("peer-replacement", otherInstanceId), + ], + bindings: [ + makeBinding("target-draining", "running"), + makeBinding("peer-persisted", "running", otherInstanceId), + ], + ...(phase === "selection" ? { beforeListSessions: block } : { beforeStop: block }), + }); + const logout = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); + yield* Deferred.await(entered); + harness.replaceInstance( + makeInstance({ + instanceId: otherInstanceId, + enabled: true, + auth: { + ...harness.auth, + credentialBinding: { owner: "provider", key: "replacement-credentials" }, + invalidate: Effect.die("The replacement peer's credentials must not be invalidated."), + }, + }), + ); + yield* Deferred.succeed(proceed, undefined); + yield* Fiber.join(logout); + assert.isTrue(harness.sessions.has(ThreadId.make("peer-replacement"))); + assert.notInclude(harness.actions, "stop:peer-replacement"); + assert.notInclude(harness.actions, "stop:peer-persisted"); + assert.isFalse(harness.sessions.has(ThreadId.make("target-draining"))); + assert.include(harness.actions, "native-logout"); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/provider/Layers/ProviderAuthService.ts b/apps/server/src/provider/Layers/ProviderAuthService.ts index 3b69c5082528..cec2a5878880 100644 --- a/apps/server/src/provider/Layers/ProviderAuthService.ts +++ b/apps/server/src/provider/Layers/ProviderAuthService.ts @@ -2,8 +2,9 @@ import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts" import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; +import * as Semaphore from "effect/Semaphore"; -import { ProviderAuthService } from "../Services/ProviderAuthService.ts"; +import * as ProviderAuthService from "../Services/ProviderAuthService.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderService } from "../Services/ProviderService.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; @@ -12,6 +13,7 @@ export const makeProviderAuthService = Effect.gen(function* () { const registry = yield* ProviderInstanceRegistry; const providers = yield* ProviderService; const directory = yield* ProviderSessionDirectory; + const credentialChanges = yield* Semaphore.make(1); const getController = Effect.fn("ProviderAuthService.getController")(function* ( instanceId: ProviderInstanceId, @@ -30,9 +32,25 @@ export const makeProviderAuthService = Effect.gen(function* () { return instance.auth; }); + // Native sessions may still belong to the previous provider after the + // selected model changes. Read session bindings, not the selected model, + // when invalidating credentials for sign-in or sign-out. const stopSessions = Effect.fn("ProviderAuthService.stopSessions")(function* ( instanceId: ProviderInstanceId, + binding: ProviderAuthService.ProviderAuthController["credentialBinding"], ) { + const affectedIds = new Set([ + instanceId, + ...(binding === undefined + ? [] + : (yield* registry.listInstances) + .filter( + (instance) => + instance.auth?.credentialBinding?.key === binding.key && + instance.auth.credentialBinding.owner === binding.owner, + ) + .map((instance) => instance.instanceId)), + ]); const bindings = yield* directory.listBindings().pipe( Effect.mapError( () => @@ -44,39 +62,111 @@ export const makeProviderAuthService = Effect.gen(function* () { ), ); const sessions = yield* providers.listSessions(); - const threadIds = new Set( - bindings - .filter( - (binding) => binding.providerInstanceId === instanceId && binding.status !== "stopped", - ) - .map((binding) => binding.threadId), + const sessionsToStop = new Map( + bindings.flatMap((session) => + session.providerInstanceId !== undefined && + affectedIds.has(session.providerInstanceId) && + session.status !== "stopped" + ? [[session.threadId, session.providerInstanceId] as const] + : [], + ), ); for (const session of sessions) { - if (session.providerInstanceId === instanceId) { - threadIds.add(session.threadId); + if (session.providerInstanceId !== undefined && affectedIds.has(session.providerInstanceId)) { + sessionsToStop.set(session.threadId, session.providerInstanceId); } } yield* Effect.forEach( - threadIds, - (threadId) => - providers.stopSession({ threadId }).pipe( - Effect.mapError( - () => - new ProviderSetupError({ - instanceId, - operation: "stopSessions", - detail: "Could not stop all sessions for this provider. Try again.", - }), - ), - ), + sessionsToStop, + ([threadId, sessionInstanceId]) => + Effect.gen(function* () { + if (sessionInstanceId !== instanceId) { + const current = yield* registry.getInstance(sessionInstanceId); + if ( + !binding || + current?.auth?.credentialBinding?.key !== binding.key || + current.auth.credentialBinding.owner !== binding.owner + ) + return; + } + yield* providers.stopSession({ threadId }).pipe( + Effect.mapError( + () => + new ProviderSetupError({ + instanceId, + operation: "stopSessions", + detail: "Could not stop all sessions for this provider. Try again.", + }), + ), + ); + }), { discard: true }, ); + if (binding) { + yield* Effect.forEach( + (yield* registry.listInstances).filter( + (instance) => + instance.instanceId !== instanceId && + affectedIds.has(instance.instanceId) && + instance.auth?.credentialBinding?.key === binding.key && + instance.auth.credentialBinding.owner === binding.owner, + ), + (instance) => instance.auth?.invalidate ?? Effect.void, + { discard: true }, + ); + } }); - return ProviderAuthService.of({ + const checkSharedBinding = Effect.fnUntraced(function* ( + instanceId: ProviderInstanceId, + operation: "start" | "logout", + auth: ProviderAuthService.ProviderAuthController, + ) { + const binding = auth.credentialBinding; + if (!binding) return; + const instances = yield* registry.listInstances; + for (const instance of instances) { + if ( + instance.instanceId !== instanceId && + instance.auth?.credentialBinding?.key === binding.key && + instance.auth.credentialBinding.owner === binding.owner && + instance.auth.isChangingCredentials && + (yield* instance.auth.isChangingCredentials) + ) { + return yield* new ProviderSetupError({ + instanceId, + operation, + detail: + "Another provider instance is changing this shared sign-in. Finish or cancel it first.", + }); + } + } + }); + + return ProviderAuthService.ProviderAuthService.of({ start: Effect.fn("ProviderAuthService.start")(function* (input, ownerSessionId) { - const auth = yield* getController(input.instanceId, "start"); - return yield* auth.start(ownerSessionId, stopSessions(input.instanceId)); + return yield* credentialChanges.withPermit( + Effect.gen(function* () { + const auth = yield* getController(input.instanceId, "start"); + yield* checkSharedBinding(input.instanceId, "start", auth); + return yield* auth.start( + ownerSessionId, + stopSessions(input.instanceId, auth.credentialBinding), + input.methodId, + ); + }), + ); + }), + respond: Effect.fn("ProviderAuthService.respond")(function* (input, ownerSessionId) { + const auth = yield* getController(input.instanceId, "respond"); + if (!auth.respond) { + return yield* new ProviderSetupError({ + instanceId: input.instanceId, + operation: "respond", + detail: "This provider does not accept this sign-in interaction.", + }); + } + return yield* auth.respond(ownerSessionId, input); }), complete: Effect.fn("ProviderAuthService.complete")(function* (input, ownerSessionId) { const auth = yield* getController(input.instanceId, "complete"); @@ -87,8 +177,13 @@ export const makeProviderAuthService = Effect.gen(function* () { return yield* auth.cancel(ownerSessionId, input.flowId); }), logout: Effect.fn("ProviderAuthService.logout")(function* (input) { - const auth = yield* getController(input.instanceId, "logout"); - return yield* auth.logout(stopSessions(input.instanceId)); + return yield* credentialChanges.withPermit( + Effect.gen(function* () { + const auth = yield* getController(input.instanceId, "logout"); + yield* checkSharedBinding(input.instanceId, "logout", auth); + return yield* auth.logout(stopSessions(input.instanceId, auth.credentialBinding)); + }), + ); }), subscribe: (input, ownerSessionId) => Effect.gen(function* () { @@ -110,11 +205,21 @@ export const makeProviderAuthService = Effect.gen(function* () { if (!instance?.auth?.isLogoutPrompt?.(input.text, input.hasAttachments)) { return false; } - yield* instance.auth.logout(stopSessions(input.instanceId)); - return true; + return yield* credentialChanges.withPermit( + Effect.gen(function* () { + const auth = yield* getController(input.instanceId, "logout"); + if (!auth.isLogoutPrompt?.(input.text, input.hasAttachments)) return false; + yield* checkSharedBinding(input.instanceId, "logout", auth); + yield* auth.logout(stopSessions(input.instanceId, auth.credentialBinding)); + return true; + }), + ); }, ), }); }); -export const ProviderAuthServiceLive = Layer.effect(ProviderAuthService, makeProviderAuthService); +export const ProviderAuthServiceLive = Layer.effect( + ProviderAuthService.ProviderAuthService, + makeProviderAuthService, +); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 2c15b9ada162..ecf2a109e01b 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -38,6 +38,7 @@ import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { AntigravityInstallation } from "../AntigravityInstallation.ts"; import * as ModelManifest from "../ModelManifest.ts"; +import { applyProviderCompatibility } from "../providerCompatibility.ts"; import * as CodexResetCredit from "./codexResetCredit.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; @@ -80,13 +81,20 @@ process.env.T3CODE_CURSOR_ENABLED = "1"; const encoder = new TextEncoder(); const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); +const withBundledCompatibility = (snapshot: ServerProvider) => + applyProviderCompatibility( + snapshot, + undefined, + ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, + ); +// Provider metadata checks use a bundled manifest and stubbed HTTP. const TestHttpClientLive = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), ), -); +).pipe(Layer.provideMerge(ModelManifest.layerTest)); function claudeCredentialHttpLayer(respond: () => Response) { return Layer.succeed( @@ -2056,7 +2064,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.deepStrictEqual( recoveredProviders.find((provider) => provider.instanceId === codexInstanceId), - codexProvider, + withBundledCompatibility(codexProvider), ); yield* Ref.set(catalogSnapshot, changedCatalogProvider); @@ -2068,7 +2076,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.deepStrictEqual( changedProviders.find((provider) => provider.instanceId === codexInstanceId), - codexProvider, + withBundledCompatibility(codexProvider), ); }).pipe(Effect.provide(runtimeServices)); @@ -2185,10 +2193,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Fiber.join(persisted); const cachedProvider = yield* readProviderStatusCache(filePath); - assert.deepStrictEqual(cachedProvider, { - ...refreshedProvider, - models: [...initialProvider.models], - }); + assert.deepStrictEqual( + cachedProvider, + withBundledCompatibility({ + ...refreshedProvider, + models: [...initialProvider.models], + }), + ); }).pipe(Effect.provide(runtimeServices)); }), ); @@ -2406,10 +2417,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Effect.gen(function* () { const registry = yield* ProviderRegistry.ProviderRegistry; - assert.deepStrictEqual(yield* registry.getProviders, [cachedProvider]); - assert.deepStrictEqual(yield* registry.refresh(codexDriver), [cachedProvider]); + assert.deepStrictEqual(yield* registry.getProviders, [ + withBundledCompatibility(cachedProvider), + ]); + assert.deepStrictEqual(yield* registry.refresh(codexDriver), [ + withBundledCompatibility(cachedProvider), + ]); assert.deepStrictEqual(yield* registry.refreshInstance(codexInstanceId), [ - cachedProvider, + withBundledCompatibility(cachedProvider), ]); }).pipe(Effect.provide(runtimeServices)); }), @@ -2520,7 +2535,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Effect.gen(function* () { const registry = yield* ProviderRegistry.ProviderRegistry; - assert.deepStrictEqual(yield* registry.getProviders, [codexProvider]); + assert.deepStrictEqual(yield* registry.getProviders, [ + withBundledCompatibility(codexProvider), + ]); yield* Ref.set(failNextList, true); yield* PubSub.publish(changes, undefined); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 856d4a1e7b13..7b1faf206cb2 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -41,6 +41,8 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; +import * as ModelManifest from "../ModelManifest.ts"; +import { applyProviderCompatibility } from "../providerCompatibility.ts"; import { ServerConfig } from "../../config.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderSecretResolver } from "../Services/ProviderSecretResolver.ts"; @@ -285,10 +287,11 @@ export const ProviderRegistryLive = Layer.effect( Effect.gen(function* () { const instanceRegistry = yield* ProviderInstanceRegistry; const secretResolver = yield* ProviderSecretResolver; + const manifestService = yield* ModelManifest.ModelManifest; // The layer's own scope. `syncLiveSources` forks per-instance // subscription fibres into it; when a refresh drives that sync, the // fibres have to land here rather than in the caller's request scope. - const layerScope = yield* Scope.Scope; + const serviceScope = yield* Effect.scope; const config = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -367,7 +370,19 @@ export const ProviderRegistryLive = Layer.effect( ), ), ); - const providersRef = yield* Ref.make>(cachedProviders); + const initialManifest = yield* manifestService.current; + const classifyCompatibility = ( + provider: ServerProvider, + manifest: ModelManifest.ModelManifestData, + ) => + applyProviderCompatibility( + provider, + manifest.compatibility, + ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, + ); + const providersRef = yield* Ref.make>( + cachedProviders.map((provider) => classifyCompatibility(provider, initialManifest)), + ); const workspaceRefreshesRef = yield* Ref.make< ReadonlyMap> >(new Map()); @@ -435,6 +450,7 @@ export const ProviderRegistryLive = Layer.effect( readonly replace?: boolean; }, ) { + const manifest = yield* manifestService.current; const nextProvidersWithUpdateState = yield* Effect.forEach( nextProviders, applyProviderUpdateState, @@ -461,7 +477,11 @@ export const ProviderRegistryLive = Layer.effect( ); } - const providers = orderProviderSnapshots([...mergedProviders.values()]); + const providers = orderProviderSnapshots( + [...mergedProviders.values()].map((provider) => + classifyCompatibility(provider, manifest), + ), + ); const providersToPersist = providers.filter((provider) => updatedKeys.has(snapshotInstanceKey(provider)), ); @@ -484,13 +504,24 @@ export const ProviderRegistryLive = Layer.effect( return providers; }); + const compatibilityRefreshRunning = yield* Ref.make(false); const syncProvider = Effect.fn("syncProvider")(function* ( provider: ServerProvider, options?: { readonly publish?: boolean; }, ) { - return yield* upsertProviders([provider], options); + const providers = yield* upsertProviders([provider], options); + // Reclassify the current read model after fetching. Never republish the + // probe captured before the fetch: a newer health result may have landed. + if (!(yield* Ref.getAndSet(compatibilityRefreshRunning, true))) { + yield* manifestService.refresh.pipe( + Effect.andThen(upsertProviders([], { persist: false })), + Effect.ensuring(Ref.set(compatibilityRefreshRunning, false)), + Effect.forkIn(serviceScope), + ); + } + return providers; }); const setProviderMaintenanceActionState = Effect.fn("setProviderMaintenanceActionState")( @@ -789,7 +820,7 @@ export const ProviderRegistryLive = Layer.effect( // Adopt the replacement instances now rather than waiting on the // registry's change tick, so the refresh that triggered the rebuild // probes the new process instead of the one it just closed. - yield* syncLiveSources.pipe(Effect.provideService(Scope.Scope, layerScope)); + yield* syncLiveSources.pipe(Effect.provideService(Scope.Scope, serviceScope)); } }); diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index 0a2ca28feb73..77e06530f2e5 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -342,6 +342,84 @@ const serviceLayers = (input: { ); describe("ModelManifest service", () => { + it.live("explicit refresh bypasses fresh memory and disk caches", () => { + let fetchCount = 0; + const updated: ModelManifestData = { + ...REMOTE_MANIFEST, + currentModels: { codex: ["gpt-reloaded"] }, + }; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST); + assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 1); + + const rebooted = yield* make; + assert.deepStrictEqual(yield* rebooted.refresh, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 1); + assert.deepStrictEqual(yield* rebooted.forceRefresh, updated); + assert.strictEqual(fetchCount, 2); + assert.deepStrictEqual(yield* rebooted.current, updated); + assert.deepStrictEqual(yield* (yield* make).current, updated); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-force-refresh-test", + response: () => Response.json(fetchCount++ === 0 ? REMOTE_MANIFEST : updated), + }), + ), + ); + }); + + it.live("explicit refresh retries immediately after failure and preserves last-good data", () => { + let fetchCount = 0; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST); + assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); + assert.deepStrictEqual(yield* service.current, REMOTE_MANIFEST); + assert.deepStrictEqual(yield* (yield* make).current, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 2); + assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 3); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-force-retry-test", + response: () => + fetchCount++ === 1 + ? new Response(null, { status: 503 }) + : Response.json(REMOTE_MANIFEST), + }), + ), + ); + }); + + it.live("explicit refresh bypasses the retry delay after an initial failure", () => { + let fetchCount = 0; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + assert.strictEqual(fetchCount, 1); + assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 2); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-force-initial-retry-test", + response: () => + fetchCount++ === 0 + ? new Response(null, { status: 503 }) + : Response.json(REMOTE_MANIFEST), + }), + ), + ); + }); + it.live("prefers a fetched manifest over the bundle and caches it to disk", () => Effect.gen(function* () { const service = yield* make; @@ -457,6 +535,7 @@ describe("ModelManifest service", () => { ), ); assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + assert.deepStrictEqual(yield* service.forceRefresh, BUNDLED_MODEL_MANIFEST); assert.strictEqual(fetchCount, 0); }).pipe( Effect.scoped, @@ -470,3 +549,43 @@ describe("ModelManifest service", () => { ), ); }); + +it.effect("caches valid compatibility policies and keeps them after a malformed refresh", () => { + const remote: ModelManifestData = { + ...REMOTE_MANIFEST, + compatibility: [ + { + driver: "codex", + t3CodeRange: ">=0.0.42", + recommendedVersion: "2.0.0", + ranges: [{ range: "=2.0.0", status: "supported" }], + }, + ], + }; + let invalid = false; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual((yield* service.refresh).compatibility, remote.compatibility); + invalid = true; + yield* TestClock.adjust("1 hour"); + assert.deepStrictEqual((yield* service.refresh).compatibility, remote.compatibility); + const rebooted = yield* make; + assert.deepStrictEqual((yield* rebooted.current).compatibility, remote.compatibility); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-compatibility-test", + response: () => + Response.json( + invalid + ? { + ...remote, + compatibility: [{ ...remote.compatibility![0], recommendedVersion: "3.0.0" }], + } + : remote, + ), + }), + ), + ); +}); diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index f5ad15dd8586..b698ed6c0c82 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -34,6 +34,7 @@ import { ServerConfig } from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; import bundledManifestJson from "./model-manifest.json" with { type: "json" }; +import { ProviderCompatibilityPolicy } from "./providerCompatibility.ts"; import type { ServerProviderDraft } from "./providerSnapshot.ts"; const MODEL_MANIFEST_URL = @@ -89,6 +90,7 @@ const ModelManifestEnvelopeSchema = Schema.Struct({ * files still decode; they count as older than any dated bundle. */ updatedAt: Schema.optional(Schema.String), + compatibility: Schema.optional(Schema.Array(ProviderCompatibilityPolicy)), currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)), providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)), }); @@ -315,6 +317,8 @@ export class ModelManifest extends Context.Service< readonly current: Effect.Effect; /** Manifest after a TTL-gated remote refresh; never fails. */ readonly refresh: Effect.Effect; + /** Explicit refresh bypasses freshness and retry timers, retaining last-good data. */ + readonly forceRefresh: Effect.Effect; /** Forks `refresh` into the service's own scope. Drivers call this from * provider checks: the fetch is process-shared state, so it must survive * the teardown of whichever instance happened to trigger it. */ @@ -326,6 +330,7 @@ export class ModelManifest extends Context.Service< const BundledOnlyModelManifest: ModelManifest["Service"] = { current: Effect.succeed(BUNDLED_MODEL_MANIFEST), refresh: Effect.succeed(BUNDLED_MODEL_MANIFEST), + forceRefresh: Effect.succeed(BUNDLED_MODEL_MANIFEST), refreshInBackground: Effect.void, }; @@ -369,7 +374,7 @@ export const make = Effect.gen(function* () { }), ); - const refresh = Effect.fn("ModelManifest.refresh")(function* () { + const refresh = Effect.fn("ModelManifest.refresh")(function* (force = false) { yield* ensureDiskCacheLoaded; const now = yield* Clock.currentTimeMillis; // A timestamp in the future means the wall clock moved backwards (the @@ -377,8 +382,8 @@ export const make = Effect.gen(function* () { // it as expired: the refetch rewrites both timestamps and self-heals. const isWithin = (sinceMs: number | null, windowMs: number) => sinceMs !== null && now >= sinceMs && now - sinceMs < windowMs; - if (isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest; - if (isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest; + if (!force && isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest; + if (!force && isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest; // The same switch that gates provider CLI update checks. It stops network // fetches only: a manifest already cached on disk from an earlier fetch @@ -413,6 +418,7 @@ export const make = Effect.gen(function* () { return ModelManifest.of({ current: ensureDiskCacheLoaded.pipe(Effect.map(() => manifest)), refresh: guardedRefresh, + forceRefresh: refreshSemaphore.withPermits(1)(refresh(true)), refreshInBackground: Effect.forkIn(guardedRefresh, serviceScope).pipe(Effect.asVoid), }); }); diff --git a/apps/server/src/provider/ProviderAuthFlow.test.ts b/apps/server/src/provider/ProviderAuthFlow.test.ts new file mode 100644 index 000000000000..d12b6967c82e --- /dev/null +++ b/apps/server/src/provider/ProviderAuthFlow.test.ts @@ -0,0 +1,520 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + ProviderInstanceId, + ProviderSetupError, + type ProviderAuthInteraction, + type ProviderAuthResponse, + type ProviderAuthState, +} from "@t3tools/contracts"; +import * as Fiber from "effect/Fiber"; +import * as Exit from "effect/Exit"; +import * as Scope from "effect/Scope"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ProviderAuthFlow from "./ProviderAuthFlow.ts"; + +const instanceId = ProviderInstanceId.make("auth-flow-test"); +const method = { id: "browser", name: "Browser", description: null, type: "agent" as const }; + +it.effect("distinguishes pending method discovery from an agent with no sign-in methods", () => + Effect.gen(function* () { + const discovered = yield* Deferred.make>(); + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "provider", key: "shared-agent" }, + methods: Deferred.await(discovered), + authenticate: () => Effect.void, + logout: Effect.void, + }); + const pending = yield* controller + .subscribe("owner") + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.isUndefined(pending.methods); + yield* Deferred.succeed(discovered, []); + const ready = yield* controller.subscribe("owner").pipe( + Stream.filter((state) => state.methods !== undefined), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.deepEqual(ready.methods, []); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +const makeHarness = Effect.gen(function* () { + const approved = yield* Deferred.make(); + const verified = yield* Deferred.make(); + const started = yield* Deferred.make(); + let attempts = 0; + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "provider", key: "shared-agent" }, + methods: Effect.succeed([method]), + authenticate: (_, context) => + Effect.gen(function* () { + attempts++; + yield* context.setInteraction( + { + type: "browser", + id: "consent", + url: "https://example.com/login", + requiresConsent: true, + }, + (response) => + response.type === "browser" && response.action === "accept" + ? Deferred.succeed(approved, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(approved); + yield* context.verifying; + yield* Deferred.await(verified); + }), + logout: Effect.void, + }); + const phase = (phase: ProviderAuthState["phase"], owner = "owner") => + controller.subscribe(owner).pipe( + Stream.filter((state) => state.phase === phase), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + return { controller, phase, started, verified, attempts: () => attempts }; +}); + +it.effect("requires owner consent and provider verification before success", () => + Effect.gen(function* () { + const { controller, phase, started, verified, attempts } = yield* makeHarness; + const state = yield* controller.start("owner"); + yield* Deferred.await(started); + const response = { + instanceId, + flowId: state.flowId!, + interactionId: "consent", + response: { type: "browser" as const, action: "accept" as const }, + }; + const other = yield* phase("waiting", "other"); + assert.isNull(other.interaction); + assert.isNull(other.flowId); + assert.isTrue( + (yield* controller.respond!("other", response).pipe(Effect.result))._tag === "Failure", + ); + assert.isTrue( + (yield* controller.respond!("owner", { ...response, interactionId: "stale" }).pipe( + Effect.result, + ))._tag === "Failure", + ); + yield* controller.start("owner"); + assert.strictEqual(attempts(), 1); + yield* controller.respond!("owner", response); + yield* phase("verifying"); + yield* Deferred.succeed(verified, undefined); + assert.strictEqual((yield* phase("succeeded")).phase, "succeeded"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("cancellation rejects late consent and permits another login", () => + Effect.gen(function* () { + const { controller, started, phase } = yield* makeHarness; + const state = yield* controller.start("owner"); + yield* Deferred.await(started); + yield* controller.cancel("owner", state.flowId!); + assert.strictEqual((yield* phase("cancelled")).phase, "cancelled"); + assert.isTrue( + (yield* controller.respond!("owner", { + instanceId, + flowId: state.flowId!, + interactionId: "consent", + response: { type: "browser", action: "accept" }, + }).pipe(Effect.result))._tag === "Failure", + ); + const next = yield* controller.start("other"); + assert.notStrictEqual(next.flowId, state.flowId); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("expires pending login and rejects its response", () => + Effect.gen(function* () { + const { controller, started, phase } = yield* makeHarness; + const state = yield* controller.start("owner"); + yield* Deferred.await(started); + yield* TestClock.adjust(300_001); + assert.include((yield* phase("failed")).message ?? "", "expired"); + assert.isTrue( + (yield* controller.respond!("owner", { + instanceId, + flowId: state.flowId!, + interactionId: "consent", + response: { type: "browser", action: "accept" }, + }).pipe(Effect.result))._tag === "Failure", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("closes admitted provider processes and blocks new ones while login is pending", () => + Effect.gen(function* () { + const { controller, started } = yield* makeHarness; + let closed = false; + yield* controller.withAccess!( + Effect.addFinalizer(() => + Effect.sync(() => { + closed = true; + }), + ), + ); + yield* controller.start("owner"); + yield* Deferred.await(started); + assert.isTrue(closed); + assert.isTrue( + (yield* controller.withAccess!(Effect.void).pipe(Effect.result))._tag === "Failure", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("shared credential invalidation closes admitted processes before permitting reuse", () => + Effect.gen(function* () { + const { controller } = yield* makeHarness; + let closed = false; + yield* controller.withAccess!( + Effect.addFinalizer(() => + Effect.sync(() => { + closed = true; + }), + ), + ); + yield* controller.invalidate!; + assert.isTrue(closed); + assert.strictEqual(yield* controller.withAccess!(Effect.succeed("new process")), "new process"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect.each([ + { + interaction: { type: "terminal", id: "terminal", output: "Log in" }, + response: { type: "terminal", data: "input", size: { cols: 80, rows: 24 } }, + }, + { + interaction: { + type: "credentials", + id: "credentials", + fields: [{ name: "token", label: "Token", secret: true }], + }, + response: { type: "credentials", values: { token: "private-token" } }, + }, +] satisfies ReadonlyArray<{ + interaction: ProviderAuthInteraction; + response: ProviderAuthResponse; +}>)( + "keeps $interaction.type responses private and waits for adapter verification", + ({ interaction, response }) => + Effect.gen(function* () { + const received = yield* Deferred.make(); + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "t3", key: "binding" }, + methods: Effect.succeed([method]), + authenticate: (_, context) => + Effect.gen(function* () { + yield* context.setInteraction(interaction, (response) => + Deferred.succeed(received, response).pipe(Effect.asVoid), + ); + yield* Deferred.await(received); + yield* context.verifying; + return yield* Effect.never; + }), + logout: Effect.void, + }); + const state = yield* controller.start("owner"); + const pending = yield* controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "waiting"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.deepStrictEqual(pending.interaction, interaction); + const hidden = yield* controller + .subscribe("other") + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.isNull(hidden.interaction); + yield* controller.respond!("owner", { + instanceId, + flowId: state.flowId!, + interactionId: interaction.id, + response, + }); + assert.deepStrictEqual(yield* Deferred.await(received), response); + const verifying = yield* controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "verifying"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.isNull(verifying.interaction); + assert.notInclude(verifying.message ?? "", "private-token"); + yield* controller.cancel("owner", state.flowId!); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("shows a device code only to its owner without treating it as authenticated", () => + Effect.gen(function* () { + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "provider", key: "device" }, + methods: Effect.succeed([method]), + authenticate: (_, context) => + context + .setInteraction({ + type: "deviceCode", + id: "code", + url: "https://example.com/device", + userCode: "ABCD-EFGH", + }) + .pipe(Effect.andThen(Effect.never)), + logout: Effect.void, + }); + const start = yield* controller.start("owner"); + const waiting = yield* controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "waiting"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.strictEqual(waiting.interaction?.type, "deviceCode"); + assert.strictEqual(waiting.authorizationUrl, "https://example.com/device"); + const other = yield* controller + .subscribe("other") + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.isNull(other.interaction); + assert.isNull(other.authorizationUrl); + yield* controller.cancel("owner", start.flowId!); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect.each([ + { + failure: Effect.fail( + new ProviderSetupError({ + instanceId, + operation: "authenticate", + detail: "This account cannot sign in. Choose another account.", + }), + ), + message: "This account cannot sign in. Choose another account.", + }, + { + failure: Effect.die(new Error("private-token in native diagnostics")), + message: "Sign-in failed. Start again.", + }, +])("publishes only safe authentication failure text %#", ({ failure, message }) => + Effect.gen(function* () { + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "t3", key: "failure" }, + methods: Effect.succeed([method]), + authenticate: () => failure, + logout: Effect.void, + }); + yield* controller.start("owner"); + const failed = yield* controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "failed"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.strictEqual(failed.message, message); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +const makeBlockingResponseHarness = Effect.gen(function* () { + const entered = yield* Deferred.make(); + const cleanupStarted = yield* Deferred.make(); + const cleanupReleased = yield* Deferred.make(); + const cleanupFinished = yield* Deferred.make(); + const authenticationFinished = yield* Deferred.make(); + let responses = 0; + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "t3", key: "blocked-response" }, + methods: Effect.succeed([method]), + authenticate: (_, context) => + Effect.gen(function* () { + yield* context.setInteraction( + { + type: "browser", + id: "blocked", + url: "https://example.com/login", + requiresConsent: true, + }, + () => + Effect.gen(function* () { + responses++; + yield* Deferred.succeed(entered, undefined); + return yield* Effect.never; + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + yield* Deferred.succeed(cleanupStarted, undefined); + yield* Deferred.await(cleanupReleased); + yield* Deferred.succeed(cleanupFinished, undefined); + }), + ), + ), + ); + yield* Deferred.await(authenticationFinished); + }), + logout: Effect.void, + }); + const start = yield* controller.start("owner"); + yield* controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "waiting"), + Stream.runHead, + ); + const input = { + instanceId, + flowId: start.flowId!, + interactionId: "blocked", + response: { type: "browser" as const, action: "accept" as const }, + }; + const response = yield* controller.respond!("owner", input).pipe(Effect.exit, Effect.forkChild); + yield* Deferred.await(entered); + return { + controller, + input, + response, + cleanupStarted, + cleanupReleased, + cleanupFinished, + authenticationFinished, + responses: () => responses, + }; +}); + +it.effect.each(["cancel", "logout"] as const)( + "%s interrupts and awaits a blocked adapter response before admitting another sign-in", + (action) => + Effect.gen(function* () { + const harness = yield* makeBlockingResponseHarness; + const duplicate = yield* Effect.flip(harness.controller.respond!("owner", harness.input)); + assert.include(duplicate.detail, "already in progress"); + assert.equal(harness.responses(), 1); + const stopping = yield* ( + action === "cancel" + ? harness.controller.cancel("owner", harness.input.flowId) + : harness.controller.logout(Effect.void) + ).pipe(Effect.forkChild); + yield* Deferred.await(harness.cleanupStarted); + assert.isTrue(yield* harness.controller.isChangingCredentials!); + assert.isUndefined(stopping.pollUnsafe()); + const premature = yield* Effect.flip(harness.controller.start("other")); + assert.include(premature.detail, "in progress"); + yield* Deferred.succeed(harness.cleanupReleased, undefined); + yield* Fiber.join(stopping); + assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); + assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); + assert.isFalse(yield* harness.controller.isChangingCredentials!); + assert.notEqual((yield* harness.controller.start("other")).flowId, harness.input.flowId); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("timeout interrupts and drains a blocked response before publishing failure", () => + Effect.gen(function* () { + const harness = yield* makeBlockingResponseHarness; + yield* TestClock.adjust(300_001); + yield* Deferred.await(harness.cleanupStarted); + assert.isTrue(yield* harness.controller.isChangingCredentials!); + const duringCleanup = yield* harness.controller + .subscribe("owner") + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.notEqual(duringCleanup.phase, "failed"); + yield* Deferred.succeed(harness.cleanupReleased, undefined); + const failed = yield* harness.controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "failed"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.include(failed.message ?? "", "expired"); + assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); + assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect( + "successful authentication drains a still-running response before admitting provider access", + () => + Effect.gen(function* () { + const harness = yield* makeBlockingResponseHarness; + yield* Deferred.succeed(harness.authenticationFinished, undefined); + yield* Deferred.await(harness.cleanupStarted); + const denied = yield* Effect.flip(harness.controller.withAccess!(Effect.void)); + assert.include(denied.detail, "changing"); + yield* Deferred.succeed(harness.cleanupReleased, undefined); + const succeeded = yield* harness.controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "succeeded"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.equal(succeeded.phase, "succeeded"); + assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); + assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); + yield* harness.controller.withAccess!(Effect.void); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("closing the controller scope interrupts and drains its adapter response", () => + Effect.gen(function* () { + const scope = yield* Scope.make(); + const harness = yield* makeBlockingResponseHarness.pipe( + Effect.provideService(Scope.Scope, scope), + ); + const closing = yield* Scope.close(scope, Exit.void).pipe(Effect.forkChild); + yield* Deferred.await(harness.cleanupStarted); + assert.isUndefined(closing.pollUnsafe()); + yield* Deferred.succeed(harness.cleanupReleased, undefined); + yield* Fiber.join(closing); + assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); + assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("a failed method refresh keeps the last discovered methods", () => + Effect.gen(function* () { + let fail = false; + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "provider", key: "shared-agent" }, + methods: Effect.suspend(() => + fail + ? Effect.fail( + new ProviderSetupError({ instanceId, operation: "status", detail: "interrupted" }), + ) + : Effect.succeed([method]), + ), + authenticate: () => Effect.void, + logout: Effect.void, + }); + yield* controller.refreshMethods!; + fail = true; + yield* controller.refreshMethods!; + const state = yield* controller + .subscribe("owner") + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.deepEqual(state.methods, [method]); + assert.strictEqual(state.message, "interrupted"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("rebuilding a controller leaves sessions it admitted to their owners", () => + Effect.gen(function* () { + const scope = yield* Scope.make(); + const { controller } = yield* makeHarness.pipe(Effect.provideService(Scope.Scope, scope)); + let closed = false; + yield* controller.withAccess!( + Effect.addFinalizer(() => + Effect.sync(() => { + closed = true; + }), + ), + ); + yield* Scope.close(scope, Exit.void); + assert.isFalse(closed); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/ProviderAuthFlow.ts b/apps/server/src/provider/ProviderAuthFlow.ts new file mode 100644 index 000000000000..f01ce92e3fb5 --- /dev/null +++ b/apps/server/src/provider/ProviderAuthFlow.ts @@ -0,0 +1,463 @@ +import { + ProviderSetupError, + type ProviderAuthInteraction, + type ProviderAuthMethod, + type ProviderAuthResponse, + type ProviderAuthState, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Clock from "effect/Clock"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import type * as ProviderAuthService from "./Services/ProviderAuthService.ts"; + +export interface ProviderAuthFlowContext { + readonly flowId: string; + readonly setInteraction: ( + interaction: ProviderAuthInteraction, + respond?: (response: ProviderAuthResponse) => Effect.Effect, + ) => Effect.Effect; + readonly verifying: Effect.Effect; +} + +const isSetupError = Schema.is(ProviderSetupError); + +const failureMessage = (cause: Cause.Cause) => { + const error = Cause.findErrorOption(cause); + return Option.isSome(error) && isSetupError(error.value) + ? error.value.detail + : "Sign-in failed. Start again."; +}; + +interface Flow { + readonly id: string; + readonly owner: string; + readonly expiresAt: number; + fiber?: Fiber.Fiber; + responseFiber?: Fiber.Fiber; + respond: + | ((response: ProviderAuthResponse) => Effect.Effect) + | undefined; +} + +/** Adapters do login and credential handling; this owns client consent and flow lifetime. */ +export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { + readonly instanceId: ProviderInstanceId; + readonly credentialBinding: NonNullable< + ProviderAuthService.ProviderAuthController["credentialBinding"] + >; + readonly methods: Effect.Effect, ProviderSetupError>; + readonly defaultMethodId?: string; + /** Fail with ProviderSetupError containing safe text for the user, never native token data. */ + readonly authenticate: ( + methodId: string, + context: ProviderAuthFlowContext, + ) => Effect.Effect; + readonly logout: Effect.Effect; + readonly timeoutMs?: number; +}) { + const scope = yield* Scope.Scope; + const crypto = yield* Crypto.Crypto; + const lock = yield* Semaphore.make(1); + const timeoutMs = options.timeoutMs ?? 300_000; + const empty: ProviderAuthState = { + instanceId: options.instanceId, + phase: "idle", + flowId: null, + authorizationUrl: null, + expiresAt: null, + message: null, + interaction: null, + credentialOwner: options.credentialBinding.owner, + }; + const snapshot = yield* SubscriptionRef.make({ owner: null as string | null, state: empty }); + let active: Flow | undefined; + let operation: "idle" | "auth" | "stopping" | "closed" = "idle"; + const sessions = new Set(); + const stopOwnedSessions = Effect.suspend(() => + Effect.forEach(Array.from(sessions), (session) => Scope.close(session, Exit.void), { + discard: true, + concurrency: "unbounded", + }), + ); + const publish = (flow: Flow, patch: Partial) => + Effect.suspend(() => + active === flow + ? SubscriptionRef.update(snapshot, (current) => ({ + owner: flow.owner, + state: { ...current.state, ...patch }, + })) + : Effect.void, + ); + + const requireFlow = Effect.fnUntraced(function* (owner: string, id: string) { + if ( + operation !== "auth" || + !active || + active.owner !== owner || + active.id !== id || + (yield* Clock.currentTimeMillis) >= active.expiresAt + ) { + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "respond", + detail: "This sign-in is no longer active in this client.", + }); + } + return active; + }); + + // Method discovery does not start a sign-in flow. + const refreshMethods = options.methods.pipe( + Effect.flatMap((methods) => + SubscriptionRef.update(snapshot, (current) => ({ + ...current, + state: { ...current.state, methods }, + })), + ), + // Keep the last known methods when a refresh fails or is interrupted. + Effect.catch((error) => + SubscriptionRef.update(snapshot, (current) => ({ + ...current, + state: { ...current.state, methods: current.state.methods ?? [], message: error.detail }, + })), + ), + ); + yield* refreshMethods.pipe(Effect.forkIn(scope)); + + const controller: ProviderAuthService.ProviderAuthController = { + credentialBinding: options.credentialBinding, + refreshMethods, + invalidate: lock.withPermit( + Effect.gen(function* () { + if (operation !== "idle") return; + yield* stopOwnedSessions; + yield* SubscriptionRef.set(snapshot, { + owner: null, + state: { + ...empty, + methods: snapshot.value.state.methods ?? [], + message: "This provider's shared sign-in changed.", + }, + }); + }), + ), + isChangingCredentials: Effect.sync(() => operation !== "idle"), + withAccess: (task) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const parent = yield* Scope.Scope; + const child = yield* lock.withPermit( + Effect.gen(function* () { + if (operation !== "idle") + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "session", + detail: "Provider sign-in is changing. Try again after it finishes.", + }); + const child = yield* Scope.make(); + sessions.add(child); + yield* Scope.addFinalizer( + child, + Effect.sync(() => { + sessions.delete(child); + }), + ); + yield* Scope.addFinalizer(parent, Scope.close(child, Exit.void)); + return child; + }), + ); + const fiber = yield* restore(task).pipe( + Effect.provideService(Scope.Scope, child), + Effect.forkIn(child), + ); + return yield* restore(Fiber.await(fiber)).pipe( + Effect.flatMap((result) => result), + Effect.onExit((result) => + Exit.isFailure(result) ? Scope.close(child, Exit.void) : Effect.void, + ), + ); + }), + ), + start: (owner, stopSessions = Effect.void, selectedMethodId) => + lock.withPermit( + Effect.gen(function* () { + if (operation === "auth" && active?.owner === owner) return snapshot.value.state; + if (operation !== "idle") + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: "Provider setup is already in progress.", + }); + const id = yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + () => + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: "Could not start sign-in. Try again.", + }), + ), + ); + const flow: Flow = { + id, + owner, + expiresAt: (yield* Clock.currentTimeMillis) + timeoutMs, + respond: undefined, + }; + active = flow; + operation = "auth"; + const state: ProviderAuthState = { + ...empty, + methods: snapshot.value.state.methods ?? [], + phase: "starting", + flowId: id, + expiresAt: DateTime.formatIso(DateTime.makeUnsafe(flow.expiresAt)), + message: "Starting sign-in.", + }; + yield* SubscriptionRef.set(snapshot, { owner, state }); + flow.fiber = yield* Effect.gen(function* () { + const methods = yield* options.methods; + yield* publish(flow, { methods }); + const methodId = selectedMethodId ?? options.defaultMethodId ?? methods[0]?.id; + if (!methodId || !methods.some((method) => method.id === methodId)) + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: "The provider did not advertise this sign-in method.", + }); + yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions)); + yield* options.authenticate(methodId, { + flowId: id, + setInteraction: (interaction, respond) => + Effect.gen(function* () { + if (active !== flow) return; + flow.respond = respond; + yield* publish(flow, { + phase: "waiting", + interaction, + authorizationUrl: + interaction.type === "browser" || interaction.type === "deviceCode" + ? interaction.url + : null, + message: "Complete sign-in to continue.", + }); + }), + verifying: Effect.gen(function* () { + flow.respond = undefined; + yield* publish(flow, { + phase: "verifying", + interaction: null, + authorizationUrl: null, + message: "Checking provider sign-in.", + }); + }), + }); + }).pipe( + Effect.scoped, + Effect.timeoutOrElse({ + duration: timeoutMs, + orElse: () => + Effect.fail( + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: "Sign-in expired. Start again.", + }), + ), + }), + Effect.exit, + Effect.flatMap((result) => + Effect.gen(function* () { + const finishing = yield* lock.withPermit( + Effect.sync(() => { + if (active !== flow) return false; + operation = "stopping"; + return true; + }), + ); + if (!finishing) return; + if (flow.responseFiber) yield* Fiber.interrupt(flow.responseFiber); + yield* lock.withPermit( + Effect.gen(function* () { + if (active !== flow) return; + yield* publish(flow, { + phase: Exit.isSuccess(result) ? "succeeded" : "failed", + interaction: null, + authorizationUrl: null, + expiresAt: null, + message: Exit.isSuccess(result) + ? "Sign-in complete." + : failureMessage(result.cause), + }); + active = undefined; + operation = "idle"; + }), + ); + }), + ), + Effect.interruptible, + Effect.forkIn(scope), + ); + return state; + }).pipe(Effect.uninterruptible), + ), + respond: (owner, input) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const { flow, fiber, ready } = yield* lock.withPermit( + Effect.gen(function* () { + const flow = yield* requireFlow(owner, input.flowId); + const interaction = snapshot.value.state.interaction; + if ( + !interaction || + interaction.id !== input.interactionId || + interaction.type !== input.response.type || + !flow.respond + ) + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "respond", + detail: "This sign-in interaction is no longer available.", + }); + if (flow.responseFiber) + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "respond", + detail: "A sign-in response is already in progress.", + }); + const ready = yield* Deferred.make(); + const callback = flow.respond; + const fiber = yield* Deferred.await(ready).pipe( + Effect.andThen(Effect.suspend(() => callback(input.response))), + Effect.interruptible, + Effect.forkIn(scope), + ); + flow.responseFiber = fiber; + return { flow, fiber, ready }; + }), + ); + yield* Deferred.succeed(ready, undefined); + yield* restore(Fiber.join(fiber)).pipe( + Effect.onInterrupt(() => Fiber.interrupt(fiber).pipe(Effect.asVoid)), + Effect.ensuring( + lock.withPermit( + Effect.sync(() => { + if (flow.responseFiber === fiber) delete flow.responseFiber; + }), + ), + ), + ); + return snapshot.value.state; + }), + ), + complete: () => + Effect.fail( + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "complete", + detail: "This provider does not accept a pasted redirect URL.", + }), + ), + cancel: (owner, id) => + Effect.gen(function* () { + const flow = yield* lock.withPermit( + Effect.gen(function* () { + const flow = yield* requireFlow(owner, id); + yield* publish(flow, { + phase: "cancelled", + interaction: null, + authorizationUrl: null, + expiresAt: null, + message: "Sign-in cancelled.", + }); + active = undefined; + operation = "stopping"; + return flow; + }), + ); + if (flow.responseFiber) yield* Fiber.interrupt(flow.responseFiber); + if (flow.fiber) yield* Fiber.interrupt(flow.fiber); + operation = "idle"; + return snapshot.value.state; + }).pipe(Effect.uninterruptible), + logout: (stopSessions) => + Effect.gen(function* () { + const flow = yield* lock.withPermit( + Effect.gen(function* () { + if (operation !== "idle" && operation !== "auth") + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "logout", + detail: "Provider setup is already stopping.", + }); + operation = "stopping"; + const flow = active; + active = undefined; + return flow; + }), + ); + const result = yield* Effect.gen(function* () { + if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber); + if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); + yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions)); + yield* options.logout; + }).pipe(Effect.exit); + const state: ProviderAuthState = { + ...empty, + methods: snapshot.value.state.methods ?? [], + phase: Exit.isSuccess(result) ? "idle" : "failed", + message: Exit.isSuccess(result) ? "Signed out." : "Could not sign out. Try again.", + }; + yield* lock.withPermit( + Effect.gen(function* () { + yield* SubscriptionRef.set(snapshot, { owner: null, state }); + operation = "idle"; + }), + ); + if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause); + return state; + }).pipe(Effect.uninterruptible), + subscribe: (owner) => + SubscriptionRef.changes(snapshot).pipe( + Stream.map((current) => + current.owner === null || current.owner === owner + ? current.state + : { + ...current.state, + flowId: null, + authorizationUrl: null, + interaction: null, + expiresAt: null, + message: active + ? "Sign-in is in progress in another client." + : current.state.message, + }, + ), + ), + }; + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + operation = "closed"; + const flow = active; + active = undefined; + if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber); + if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); + // Settings edits rebuild instances; admitted sessions (including those of + // peers sharing this binding) end with their own scopes, not this one. + }), + ); + return controller; +}); diff --git a/apps/server/src/provider/ProviderCredentialStore.test.ts b/apps/server/src/provider/ProviderCredentialStore.test.ts new file mode 100644 index 000000000000..0a5ad906e126 --- /dev/null +++ b/apps/server/src/provider/ProviderCredentialStore.test.ts @@ -0,0 +1,49 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; + +it.effect("isolates provider bindings and preserves opaque credentials", () => + Effect.gen(function* () { + const data = new Map(); + const secretStore = ServerSecretStore.of({ + get: (name) => Effect.sync(() => Option.fromUndefinedOr(data.get(name))), + set: (name, value) => + Effect.sync(() => { + data.set(name, value); + }), + remove: (name) => + Effect.sync(() => { + data.delete(name); + }), + create: () => Effect.die("unused"), + getOrCreateRandom: () => Effect.die("unused"), + }); + const a = yield* ProviderCredentialStore.make("cursor", "../../personal").pipe( + Effect.provideService(ServerSecretStore, secretStore), + ); + const b = yield* ProviderCredentialStore.make("cursor", "work").pipe( + Effect.provideService(ServerSecretStore, secretStore), + ); + const c = yield* ProviderCredentialStore.make("other", "../../personal").pipe( + Effect.provideService(ServerSecretStore, secretStore), + ); + const bytes = Uint8Array.from([0, 255, 128, 1]); + yield* a.set(bytes); + assert.deepStrictEqual(Option.getOrThrow(yield* a.get), bytes); + assert.isTrue(Option.isNone(yield* b.get)); + assert.isTrue(Option.isNone(yield* c.get)); + assert.isFalse(a.binding.key.includes("/")); + const long = yield* ProviderCredentialStore.make("a".repeat(64), "b".repeat(64)).pipe( + Effect.provideService(ServerSecretStore, secretStore), + ); + assert.isBelow(long.binding.key.length, 255); + const delimiter = yield* ProviderCredentialStore.make("cur", "sor../../personal").pipe( + Effect.provideService(ServerSecretStore, secretStore), + ); + assert.notStrictEqual(delimiter.binding.key, a.binding.key); + yield* a.remove; + assert.isTrue(Option.isNone(yield* a.get)); + }), +); diff --git a/apps/server/src/provider/ProviderCredentialStore.ts b/apps/server/src/provider/ProviderCredentialStore.ts new file mode 100644 index 000000000000..259b441654c9 --- /dev/null +++ b/apps/server/src/provider/ProviderCredentialStore.ts @@ -0,0 +1,21 @@ +import * as NodeCrypto from "node:crypto"; +import * as Effect from "effect/Effect"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; + +/** A provider binding stores opaque bytes; only its adapter decodes or refreshes them. */ +export const make = Effect.fn("ProviderCredentialStore.make")(function* ( + driver: string, + bindingId: string, +) { + const secrets = yield* ServerSecretStore.ServerSecretStore; + // Hash the tuple so arbitrary bindings cannot escape or exceed a filename. + const key = `provider-auth-${NodeCrypto.createHash("sha256") + .update(`${driver.length}:${driver}${bindingId}`) + .digest("hex")}`; + return { + binding: { owner: "t3" as const, key }, + get: secrets.get(key), + set: (credentials: Uint8Array) => secrets.set(key, credentials), + remove: secrets.remove(key), + }; +}); diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index a5c092233052..059a2508e40f 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -74,6 +74,8 @@ export interface ProviderInstance { readonly snapshot: ServerProviderShape; readonly snapshotForCwd?: (cwd: string) => Effect.Effect; readonly refreshModels?: () => Effect.Effect; + /** Invalidate T3-owned discovery caches before an explicit provider refresh. */ + readonly invalidateCaches?: Effect.Effect; /** * Redeem one banked rate-limit reset credit on the signed-in account, then * re-probe so the snapshot reflects the cleared windows. Account-level, diff --git a/apps/server/src/provider/Services/ProviderAuthService.ts b/apps/server/src/provider/Services/ProviderAuthService.ts index ea89edf78af5..93fd39fb3e3f 100644 --- a/apps/server/src/provider/Services/ProviderAuthService.ts +++ b/apps/server/src/provider/Services/ProviderAuthService.ts @@ -1,12 +1,28 @@ -import type { ProviderAuthState, ProviderInstanceId, ProviderSetupError } from "@t3tools/contracts"; +import type { + ProviderAuthRespondInput, + ProviderAuthStartInput, + ProviderAuthState, + ProviderInstanceId, + ProviderSetupError, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; +import type * as Scope from "effect/Scope"; export interface ProviderAuthController { + /** Equal keys mean these instances share credentials on this environment. */ + readonly credentialBinding?: { readonly owner: "provider" | "t3"; readonly key: string }; + readonly isChangingCredentials?: Effect.Effect; + readonly invalidate?: Effect.Effect; + readonly refreshMethods?: Effect.Effect; + readonly withAccess?: ( + task: Effect.Effect, + ) => Effect.Effect; readonly start: ( ownerSessionId: string, stopSessions?: Effect.Effect, + methodId?: string, ) => Effect.Effect; readonly complete: ( ownerSessionId: string, @@ -16,6 +32,10 @@ export interface ProviderAuthController { ownerSessionId: string, flowId: string, ) => Effect.Effect; + readonly respond?: ( + ownerSessionId: string, + input: ProviderAuthRespondInput, + ) => Effect.Effect; /** The controller closes process admission before it stops routed sessions. */ readonly logout: ( stopSessions: Effect.Effect, @@ -30,13 +50,17 @@ interface ProviderAuthTarget { export interface ProviderAuthServiceShape { readonly start: ( - input: ProviderAuthTarget, + input: ProviderAuthStartInput, ownerSessionId: string, ) => Effect.Effect; readonly complete: ( input: ProviderAuthTarget & { readonly flowId: string; readonly callbackUrl: string }, ownerSessionId: string, ) => Effect.Effect; + readonly respond: ( + input: ProviderAuthRespondInput, + ownerSessionId: string, + ) => Effect.Effect; readonly cancel: ( input: ProviderAuthTarget & { readonly flowId: string }, ownerSessionId: string, diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.test.ts b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts index 0aebe0ca6d82..60f971edb499 100644 --- a/apps/server/src/provider/acp/AcpAdapterSupport.test.ts +++ b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts @@ -25,4 +25,40 @@ describe("AcpAdapterSupport", () => { expect(error._tag).toBe("ProviderAdapterRequestError"); expect(error.message).toContain("Invalid params"); }); + + it("maps ACP process exits without stderr to a process error instead of a closed session", () => { + const error = mapAcpToAdapterError( + ProviderDriverKind.make("cursor"), + "thread-1" as never, + "session/start", + new EffectAcpErrors.AcpProcessExitedError({ code: 1 }), + ); + + expect(error._tag).toBe("ProviderAdapterProcessError"); + expect(error.message).not.toContain("adapter thread is closed"); + if (error._tag === "ProviderAdapterProcessError") { + expect(error.detail).toBe("ACP process exited with code 1"); + } + }); + + it("maps ACP process exits to a process error whose detail includes stderr", () => { + const error = mapAcpToAdapterError( + ProviderDriverKind.make("cursor"), + "thread-1" as never, + "session/start", + new EffectAcpErrors.AcpProcessExitedError({ + code: 1, + stderr: + "Invalid project config at ~/.cursor/cli.json: schema validation failed. Unrecognized key(s): 'approvalMode', 'sandbox'", + }), + ); + + expect(error._tag).toBe("ProviderAdapterProcessError"); + expect(error.message).toContain("cli.json"); + expect(error.message).toContain("Unrecognized key"); + expect(error.message).not.toContain("adapter thread is closed"); + if (error._tag === "ProviderAdapterProcessError") { + expect(error.detail).toContain("Unrecognized key(s): 'approvalMode', 'sandbox'"); + } + }); }); diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.ts b/apps/server/src/provider/acp/AcpAdapterSupport.ts index cde110e6dd99..b90ce88e83dc 100644 --- a/apps/server/src/provider/acp/AcpAdapterSupport.ts +++ b/apps/server/src/provider/acp/AcpAdapterSupport.ts @@ -7,8 +7,8 @@ import * as Schema from "effect/Schema"; import * as EffectAcpErrors from "effect-acp/errors"; import { + ProviderAdapterProcessError, ProviderAdapterRequestError, - ProviderAdapterSessionClosedError, type ProviderAdapterError, } from "../Errors.ts"; const isAcpProcessExitedError = Schema.is(EffectAcpErrors.AcpProcessExitedError); @@ -21,9 +21,10 @@ export function mapAcpToAdapterError( error: EffectAcpErrors.AcpError, ): ProviderAdapterError { if (isAcpProcessExitedError(error)) { - return new ProviderAdapterSessionClosedError({ + return new ProviderAdapterProcessError({ provider, threadId, + detail: error.message, cause: error, }); } diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 57323e2675e0..45a5cadb9a31 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -368,6 +368,26 @@ describe("AcpSessionRuntime", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("attaches child stderr when the ACP process exits before initialize", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.make({ + ...mockRuntimeOptions, + spawn: { + command: process.execPath, + args: [ + "-e", + "process.stderr.write(\"Invalid project config at /tmp/project/.cursor/cli.json: schema validation failed. Unrecognized key(s): 'approvalMode', 'sandbox'\\n\"); process.exit(1);", + ], + }, + }); + const error = yield* runtime.start().pipe(Effect.flip); + expect(error._tag).toBe("AcpProcessExitedError"); + expect(error.message).toContain("cli.json"); + expect(error.message).toContain("Unrecognized key"); + expect(error.message).not.toContain("ACP process exited with code 1\nACP process exited"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("drains large stderr output and keeps auth-sized logging chunks", () => Effect.gen(function* () { const lengths: Array = []; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 730f917852f8..8c9382fd755c 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -23,6 +23,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import type * as EffectAcpProtocol from "effect-acp/protocol"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { appendAcpStderrTail, sanitizeAcpStderrExcerpt } from "./AcpStderr.ts"; import { collectSessionConfigOptionValues, decideToolCallUpdateEmission, @@ -364,6 +365,8 @@ export const make = ( ); const stoppingRef = yield* Ref.make(false); const stderrFailure = yield* Deferred.make(); + const stderrTailRef = yield* Ref.make(""); + const stderrDrained = yield* Deferred.make(); const runtimeClosed = yield* Deferred.make(); const promptSerializationSemaphore = yield* Semaphore.make(1); const promptDispatchSemaphore = yield* Semaphore.make(1); @@ -429,22 +432,45 @@ export const make = ( } }); + const enrichProcessExitWithStderr = ( + error: EffectAcpErrors.AcpError, + ): Effect.Effect => + error._tag !== "AcpProcessExitedError" || (error.stderr?.trim().length ?? 0) > 0 + ? Effect.succeed(error) + : Deferred.await(stderrDrained).pipe( + Effect.timeout("250 millis"), + Effect.ignore, + Effect.andThen(Ref.get(stderrTailRef)), + Effect.map((tail) => { + const stderr = sanitizeAcpStderrExcerpt(tail); + return stderr.length === 0 + ? error + : new EffectAcpErrors.AcpProcessExitedError({ + ...(error.code !== undefined ? { code: error.code } : {}), + ...(error.pid !== undefined ? { pid: error.pid } : {}), + stderr, + ...(error.cause !== undefined ? { cause: error.cause } : {}), + }); + }), + ); + const recordTermination = Effect.fn("AcpSessionRuntime.recordTermination")(function* ( error: EffectAcpErrors.AcpError, ) { if (yield* Ref.get(stoppingRef)) { return; } + const enriched = yield* enrichProcessExitWithStderr(error); const firstTermination = yield* Ref.modify(terminationErrorRef, (current) => Option.isSome(current) ? ([false, current] as const) - : ([true, Option.some(error)] as const), + : ([true, Option.some(enriched)] as const), ); if (!firstTermination) { return; } yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); - yield* Queue.offer(eventQueue, { _tag: "ConnectionTerminated", error }); + yield* Queue.offer(eventQueue, { _tag: "ConnectionTerminated", error: enriched }); }); const logRequest = (event: AcpSessionRequestLogEvent) => @@ -461,6 +487,9 @@ export const make = ( ? Effect.raceFirst(effect, Deferred.await(stderrFailure)) : effect ).pipe( + Effect.catch((error) => + enrichProcessExitWithStderr(error).pipe(Effect.flatMap(Effect.fail)), + ), Effect.tap((result) => logRequest({ method, @@ -508,10 +537,10 @@ export const make = ( yield* child.stderr.pipe( Stream.decodeText(), Stream.runForEach((chunk) => - (options.onStderr - ? options.onStderr(chunk.slice(-maxStderrChunkLength)) - : Effect.void - ).pipe( + Ref.update(stderrTailRef, (current) => appendAcpStderrTail(current, chunk)).pipe( + Effect.andThen( + options.onStderr ? options.onStderr(chunk.slice(-maxStderrChunkLength)) : Effect.void, + ), Effect.catch((error) => Effect.gen(function* () { yield* Deferred.fail(stderrFailure, error); @@ -521,6 +550,7 @@ export const make = ( ), ), ), + Effect.ensuring(Deferred.succeed(stderrDrained, undefined)), Effect.ignore, Effect.forkIn(runtimeScope), ); diff --git a/apps/server/src/provider/acp/AcpStderr.test.ts b/apps/server/src/provider/acp/AcpStderr.test.ts new file mode 100644 index 000000000000..e88b59bdb0e1 --- /dev/null +++ b/apps/server/src/provider/acp/AcpStderr.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + ACP_STDERR_TAIL_MAX_CHARS, + appendAcpStderrTail, + sanitizeAcpStderrExcerpt, +} from "./AcpStderr.ts"; + +describe("AcpStderr", () => { + it("keeps a bounded tail of stderr chunks", () => { + const prefix = "x".repeat(ACP_STDERR_TAIL_MAX_CHARS); + expect(appendAcpStderrTail(prefix, "abc")).toBe(`${prefix.slice(3)}abc`); + }); + + it("redacts home paths, pairing URLs, and tokens from stderr excerpts", () => { + const excerpt = sanitizeAcpStderrExcerpt( + [ + "Invalid project config at /Users/ada/.cursor/cli.json", + "Authorization: Bearer secret-token-value", + "Visit http://localhost:5733/pair#token=ABCDEF for pairing", + "key=sk-abcdefghijklmnopqrstuv", + ].join("\n"), + { HOME: "/Users/ada" }, + ); + + expect(excerpt).toContain("Invalid project config at ~/.cursor/cli.json"); + expect(excerpt).toContain("Bearer [redacted]"); + expect(excerpt).toContain("[pairing-url]"); + expect(excerpt).toContain("[redacted]"); + expect(excerpt).not.toContain("secret-token-value"); + expect(excerpt).not.toContain("ABCDEF"); + expect(excerpt).not.toContain("sk-abcdefghijklmnopqrstuv"); + }); + + it("redacts hyphenated OpenAI project keys and header credentials", () => { + const excerpt = sanitizeAcpStderrExcerpt( + [ + "openai=sk-proj-abcdefghijklmnopqrstuvwxyz012345", + "svc=sk-svcacct-abcdefghijklmnopqrstuvwxyz012345", + "anthropic=sk-ant-api03-abcdefghijklmnopqrstuvwxyz012345", + "Authorization: Basic dXNlcjpwYXNz", + "x-api-key: ant-api-key-value", + ].join("\n"), + ); + + expect(excerpt).toContain("[redacted]"); + expect(excerpt).toContain("Authorization: Basic [redacted]"); + expect(excerpt).toContain("x-api-key: [redacted]"); + expect(excerpt).not.toContain("sk-proj-"); + expect(excerpt).not.toContain("sk-svcacct-"); + expect(excerpt).not.toContain("sk-ant-api03-"); + expect(excerpt).not.toContain("abcdefghijklmnopqrstuvwxyz012345"); + expect(excerpt).not.toContain("dXNlcjpwYXNz"); + expect(excerpt).not.toContain("ant-api-key-value"); + }); +}); diff --git a/apps/server/src/provider/acp/AcpStderr.ts b/apps/server/src/provider/acp/AcpStderr.ts new file mode 100644 index 000000000000..52f68bb4c19a --- /dev/null +++ b/apps/server/src/provider/acp/AcpStderr.ts @@ -0,0 +1,38 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The excerpt sanitizer masks the home directory, which only the Node os module can resolve. +import * as NodeOS from "node:os"; + +/** Last few KiB of ACP child stderr kept for startup / exit diagnostics. */ +export const ACP_STDERR_TAIL_MAX_CHARS = 4_096; + +const PAIRING_URL_PATTERN = /https?:\/\/[^\s]*\/pair#[^\s]*/gi; +const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9._\-+=/]+/gi; +const BASIC_AUTH_PATTERN = /\bAuthorization:\s*Basic\s+\S+/gi; +const API_KEY_HEADER_PATTERN = /\bx-api-key:\s*\S+/gi; +const SECRET_TOKEN_PATTERN = + /\b(?:sk-[A-Za-z0-9][A-Za-z0-9-]{7,}|ghp_[A-Za-z0-9]+|xox[a-zA-Z]-[A-Za-z0-9-]+)\b/g; + +export function appendAcpStderrTail(current: string, chunk: string): string { + const next = `${current}${chunk}`; + return next.length <= ACP_STDERR_TAIL_MAX_CHARS ? next : next.slice(-ACP_STDERR_TAIL_MAX_CHARS); +} + +/** Bounded, redacted excerpt safe to put on user-facing adapter errors. */ +export function sanitizeAcpStderrExcerpt( + text: string, + environment: NodeJS.ProcessEnv = process.env, +): string { + let result = text.replaceAll("\0", ""); + const homes = [environment.HOME, environment.USERPROFILE, NodeOS.homedir()].filter( + (value): value is string => typeof value === "string" && value.length > 1, + ); + for (const home of new Set(homes)) { + result = result.split(home).join("~"); + } + result = result + .replace(PAIRING_URL_PATTERN, "[pairing-url]") + .replace(BEARER_TOKEN_PATTERN, "Bearer [redacted]") + .replace(BASIC_AUTH_PATTERN, "Authorization: Basic [redacted]") + .replace(API_KEY_HEADER_PATTERN, "x-api-key: [redacted]") + .replace(SECRET_TOKEN_PATTERN, "[redacted]"); + return result.trim(); +} diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index efc22ad0a3d0..69e51bcb019a 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,16 +1,69 @@ { "version": 1, - "updatedAt": "2026-09-12T00:41:55Z", + "updatedAt": "2026-09-24T00:11:29Z", + "compatibility": [ + { + "driver": "codex", + "t3CodeRange": ">=0.0.42", + "recommendedRange": ">=0.129.0", + "ranges": [ + { "range": ">=0.129.0", "status": "supported" }, + { "range": "<0.129.0", "status": "broken" } + ] + }, + { + "driver": "claudeAgent", + "t3CodeRange": ">=0.0.42", + "recommendedRange": ">=2.1.280", + "ranges": [ + { "range": ">=2.1.280", "status": "supported" }, + { "range": ">=2.1.111 <2.1.280", "status": "graceful" }, + { "range": "<2.1.111", "status": "unsupported" } + ] + }, + { + "driver": "cursor", + "t3CodeRange": ">=0.0.42", + "recommendedRange": ">=2026.05.09", + "ranges": [ + { "range": ">=2026.05.09", "status": "supported" }, + { "range": "<2026.05.09", "status": "unknown" } + ] + }, + { + "driver": "grok", + "t3CodeRange": ">=0.0.42", + "recommendedRange": ">=1.0.13", + "ranges": [ + { "range": ">=1.0.13", "status": "supported" }, + { "range": "<1.0.13", "status": "unknown" } + ] + }, + { + "driver": "opencode", + "t3CodeRange": ">=0.0.42", + "recommendedRange": ">=1.14.19", + "ranges": [ + { "range": ">=1.14.19", "status": "supported" }, + { "range": "<1.14.19", "status": "broken" } + ] + }, + { + "driver": "antigravity", + "t3CodeRange": ">=0.0.42", + "recommendedRange": "=1.1.1", + "ranges": [{ "range": "=1.1.1", "status": "supported" }] + } + ], "currentModels": { "codex": [ "gpt-6-astra", - "gpt-5.6-luna", - "gpt-5.6-terra", - "gpt-5.6-sol", + "gpt-6-luna", + "gpt-6-sol", "gpt-daybreak-blue-latest", "gpt-daybreak-red-latest" ], - "claudeAgent": ["claude-fable-5-1", "claude-opus-5", "claude-sonnet-5"], + "claudeAgent": ["claude-fable-5-1", "claude-opus-5-5", "claude-sonnet-5"], "antigravity": ["gemini-3.8-flash-high", "gemini-3.8-flash-medium", "gemini-3.8-flash-low"] }, "providers": { @@ -96,6 +149,48 @@ } } }, + "opus-5-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium", "isDefault": true }, + { "id": "high", "label": "High" }, + { "id": "xhigh", "label": "Extra High" }, + { "id": "max", "label": "Max" }, + { + "id": "ultracode", + "label": "Ultracode", + "description": "xhigh effort plus multi-agent workflow orchestration" + }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { "id": "200k", "label": "200k" }, + { "id": "1m", "label": "1M", "isDefault": true } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultracode": "xhigh", "ultrathink": null }, + "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, + "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + } + } + }, "opus-5": { "capabilities": { "optionDescriptors": [ @@ -554,6 +649,15 @@ } }, "models": [ + { + "slug": "claude-opus-5-5", + "name": "Claude Opus 5.5", + "aliases": ["opus-5.5", "claude-opus-5.5"], + "status": "current", + "badge": "new", + "profile": "opus-5-5", + "adapter": { "claudeCode": { "minVersion": "2.1.280" } } + }, { "slug": "claude-fable-5-1", "name": "Claude Fable 5.1", diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts new file mode 100644 index 000000000000..ed3e606126ae --- /dev/null +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -0,0 +1,325 @@ +import { assert, describe, it } from "@effect/vitest"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as ServerConfig from "../config.ts"; +import * as ModelManifest from "./ModelManifest.ts"; +import { ProviderRegistryLive } from "./Layers/ProviderRegistry.ts"; +import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; +import { ProviderInstanceRegistry } from "./Services/ProviderInstanceRegistry.ts"; +import type { ProviderInstance } from "./ProviderDriver.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; +import { BUILT_IN_DRIVERS } from "./builtInDrivers.ts"; +import * as Schema from "effect/Schema"; +import { + applyProviderCompatibility, + ProviderCompatibilityPolicy, + resolveProviderCompatibility, +} from "./providerCompatibility.ts"; +import { ProviderSecretResolverPassthroughLayer } from "./Services/ProviderSecretResolver.ts"; + +const driver = ProviderDriverKind.make("codex"); +const policy: ProviderCompatibilityPolicy = { + driver, + t3CodeRange: ">=0.0.42 <0.1.0", + recommendedVersion: "2.0.0", + recommendedRange: ">=2.0.0 <3.0.0", + ranges: [ + { range: "<1.0.0", status: "broken" }, + { range: ">=1.0.0 <1.5.0", status: "unsupported" }, + { range: ">=1.5.0 <2.0.0", status: "graceful" }, + { range: ">=2.0.0 <3.0.0", status: "supported" }, + ], +}; +const provider: ServerProvider = { + driver, + instanceId: ProviderInstanceId.make("codex-work"), + enabled: true, + installed: true, + version: "0.9.0", + status: "error", + message: "Authentication failed", + checkedAt: "2026-09-22T00:00:00Z", + auth: { status: "unauthenticated" }, + models: [], + skills: [], + slashCommands: [], +}; + +describe("provider compatibility", () => { + it("bundles a compatibility policy for every built-in harness", () => { + for (const builtIn of BUILT_IN_DRIVERS) { + assert.isDefined( + resolveProviderCompatibility( + ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, + builtIn.driverKind, + null, + ), + `Missing bundled compatibility policy for ${builtIn.driverKind}`, + ); + } + }); + + it("compares Cursor build dates without treating semver prereleases as stable", () => { + const cursor = ProviderDriverKind.make("cursor"); + const cursorPolicy: ProviderCompatibilityPolicy = { + driver: cursor, + t3CodeRange: policy.t3CodeRange, + ranges: [ + { range: "<2026.05.09", status: "unsupported" }, + { range: ">=2026.05.09", status: "supported" }, + ], + }; + for (const [version, expected] of [ + ["2026.05.08-a1b2c3d", "unsupported"], + ["2026.05.09-a1b2c3d", "supported"], + ["2026.09.22-f2b0fcd", "supported"], + ["2026.05.09", "supported"], + ["2026.05.09-beta.1", "unknown"], + ] as const) { + assert.strictEqual( + resolveProviderCompatibility([cursorPolicy], cursor, version)?.status, + expected, + ); + } + assert.strictEqual( + resolveProviderCompatibility([policy], driver, "2.0.0-a1b2c3d")?.status, + "unknown", + ); + }); + + it("recognizes Antigravity semver release tags while keeping dated candidates unknown", () => { + const antigravity = ProviderDriverKind.make("antigravity"); + const taggedPolicy = { + ...policy, + driver: antigravity, + ranges: [{ range: "=2.0.0", status: "supported" as const }], + }; + for (const [version, expected] of [ + ["agy_acp_server_2.0.0", "supported"], + ["2.0.0", "supported"], + ["agy_acp_server_2.0.1", "unknown"], + ["agy_acp_server_2.0.0-beta.1", "unknown"], + ["agy_acp_server_20260818_01_RC01", "unknown"], + ] as const) { + assert.strictEqual( + resolveProviderCompatibility([taggedPolicy], antigravity, version)?.status, + expected, + ); + } + }); + + it("classifies boundaries and treats unlisted versions and release tags as unknown", () => { + for (const [version, expected] of [ + ["0.9.9", "broken"], + ["1.0.0", "unsupported"], + ["1.5.0", "graceful"], + ["2.0.0", "supported"], + ["v2.0.0", "supported"], + ["3.0.0", "unknown"], + ["2.0.0-beta.1", "unknown"], + ["agy_acp_server_20260818_01_RC01", "unknown"], + [null, "unknown"], + ] as const) { + assert.strictEqual(resolveProviderCompatibility([policy], driver, version)?.status, expected); + } + assert.isUndefined(resolveProviderCompatibility([policy], driver, "0.9.0", "0.1.0")); + }); + + it("supports every driver without inventing policies for uncovered adapters", () => { + for (const kind of [ + "codex", + "claudeAgent", + "cursor", + "grok", + "opencode", + "antigravity", + "customDriver", + ]) { + const adapter = ProviderDriverKind.make(kind); + assert.strictEqual( + resolveProviderCompatibility([{ ...policy, driver: adapter }], adapter, "2.0.0")?.status, + "supported", + ); + assert.isUndefined(resolveProviderCompatibility([], adapter, "2.0.0")); + } + }); + + it("relaxes remote policy without losing probe errors, and falls back when a policy is omitted", () => { + const broken = applyProviderCompatibility(provider, [], [policy]); + assert.strictEqual(broken.compatibilityAdvisory?.status, "broken"); + const relaxed = { ...policy, ranges: [{ range: ">=0.0.0", status: "supported" as const }] }; + const supported = applyProviderCompatibility(broken, [relaxed], [policy]); + assert.strictEqual(supported.compatibilityAdvisory?.status, "supported"); + assert.strictEqual(supported.status, "error"); + assert.strictEqual(supported.message, "Authentication failed"); + assert.strictEqual( + applyProviderCompatibility(supported, [{ ...policy, t3CodeRange: ">=9.0.0" }], [policy]) + .compatibilityAdvisory?.status, + "broken", + ); + const removed = applyProviderCompatibility(supported, [], []); + assert.isUndefined(removed.compatibilityAdvisory); + assert.strictEqual(removed.status, "error"); + assert.strictEqual(removed.message, "Authentication failed"); + assert.isUndefined( + applyProviderCompatibility({ ...broken, enabled: false }, [], [policy]).compatibilityAdvisory, + ); + assert.isUndefined( + applyProviderCompatibility({ ...broken, installed: false }, [], [policy]) + .compatibilityAdvisory, + ); + }); + + it("rejects invalid ranges and recommendations outside the first supported match", () => { + const decode = Schema.decodeUnknownSync(ProviderCompatibilityPolicy); + assert.doesNotThrow(() => decode(policy)); + const prefixed = decode({ + ...policy, + t3CodeRange: ">=v0.0.42 =v2.0 =2.0.0 garbage", status: "supported" }] }, + { ...policy, recommendedVersion: "2.0.0; echo unsafe" }, + { ...policy, ranges: [{ range: ">=0.0.0", status: "broken" }, ...policy.ranges] }, + ]) + assert.throws(() => decode(invalid)); + }); +}); + +it.effect("a remote policy refresh preserves a newer health result on the registry stream", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const health = yield* Ref.make({ + ...provider, + status: "ready" as ServerProvider["status"], + message: "Healthy", + }); + const manifest = yield* Ref.make({ + version: 1, + currentModels: {}, + compatibility: [policy], + }); + const instance: ProviderInstance = { + instanceId: provider.instanceId, + driverKind: driver, + enabled: true, + displayName: undefined, + continuationIdentity: { driverKind: driver, continuationKey: "test-codex" }, + snapshot: { + getSnapshot: Ref.get(health), + refresh: Ref.get(health), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ provider: driver, packageName: null }), + ), + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }; + const refresh = Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen( + Ref.updateAndGet(manifest, (current) => ({ + ...current, + compatibility: [ + { ...policy, ranges: [{ range: ">=0.0.0", status: "supported" as const }] }, + ], + })), + ), + ); + const dependencies = Layer.mergeAll( + Layer.succeed(ModelManifest.ModelManifest, { + current: Ref.get(manifest), + refresh, + forceRefresh: refresh, + refreshInBackground: Effect.void, + }), + Layer.succeed(ProviderInstanceRegistry, { + getInstance: (id) => Effect.succeed(id === instance.instanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + rebuildInstanceWhen: () => Effect.succeed(false), + listEnvironments: Effect.succeed(new Map()), + }), + ProviderSecretResolverPassthroughLayer, + ServerConfig.layerTest(process.cwd(), { prefix: "compatibility-registry-test" }).pipe( + Layer.provideMerge(NodeServices.layer), + ), + ); + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + yield* Deferred.await(started); + assert.strictEqual( + (yield* registry.getProviders)[0]?.compatibilityAdvisory?.status, + "broken", + ); + yield* Ref.set(health, provider); + const latestHealth = yield* registry.refreshInstance(provider.instanceId); + assert.strictEqual(latestHealth[0]?.message, "Authentication failed"); + const supported = yield* Stream.toPull( + registry.streamChanges.pipe( + Stream.filter((snapshots) => snapshots[0]?.compatibilityAdvisory?.status === "supported"), + ), + ); + const subscribed = yield* supported.pipe(Effect.forkScoped({ startImmediately: true })); + yield* Deferred.succeed(release, undefined); + const updated = (yield* Fiber.join(subscribed))[0]?.[0]; + assert.strictEqual(updated?.compatibilityAdvisory?.status, "supported"); + assert.strictEqual(updated?.status, "error"); + assert.strictEqual(updated?.message, "Authentication failed"); + }).pipe(Effect.provide(ProviderRegistryLive.pipe(Layer.provide(dependencies)))); + }).pipe(Effect.scoped), +); + +it("recomputes latest-version compatibility without losing the underlying update advisory", () => { + const snapshot: ServerProvider = { + ...provider, + version: "2.0.0", + versionAdvisory: { + status: "behind_latest", + currentVersion: "2.0.0", + latestVersion: "4.0.0", + canUpdate: true, + updateCommand: "npm install -g fixture@latest", + checkedAt: provider.checkedAt, + message: null, + }, + }; + const blockedPolicy: ProviderCompatibilityPolicy = { + ...policy, + ranges: [...policy.ranges, { range: ">=3.0.0", status: "broken" }], + }; + const blocked = applyProviderCompatibility(snapshot, [blockedPolicy], []); + assert.strictEqual(blocked.compatibilityAdvisory?.latestVersionStatus, "broken"); + const relaxed = applyProviderCompatibility( + blocked, + [{ ...policy, ranges: [{ range: ">=2.0.0", status: "supported" }] }], + [], + ); + assert.strictEqual(relaxed.compatibilityAdvisory?.latestVersionStatus, "supported"); + assert.deepStrictEqual(relaxed.versionAdvisory, snapshot.versionAdvisory); +}); diff --git a/apps/server/src/provider/providerCompatibility.ts b/apps/server/src/provider/providerCompatibility.ts new file mode 100644 index 000000000000..b4ce66f87603 --- /dev/null +++ b/apps/server/src/provider/providerCompatibility.ts @@ -0,0 +1,127 @@ +import { + TrimmedNonEmptyString, + ServerProviderCompatibilityStatus, + type ProviderDriverKind, + type ServerProvider, + type ServerProviderCompatibilityAdvisory, +} from "@t3tools/contracts"; +import { satisfiesSemverRange } from "@t3tools/shared/semver"; +import * as Schema from "effect/Schema"; +import packageJson from "../../package.json" with { type: "json" }; + +// Deliberately uses the shared CLI gate syntax: comparator groups joined by ||. +// Prereleases and unrecognized release tags remain unknown. +const StableVersion = TrimmedNonEmptyString.pipe( + Schema.check(Schema.makeFilter((value) => /^\d+\.\d+\.\d+$/.test(value))), +); +const VersionRange = TrimmedNonEmptyString.pipe( + Schema.check( + Schema.makeFilter((value) => + value.split("||").every((group) => { + const tokens = group.trim().split(/\s+/); + return tokens.every((token) => /^(?:\^|>=|>|<=|<|=)?v?\d+(?:\.\d+){0,2}$/.test(token)); + }), + ), + ), +); +const Policy = Schema.Struct({ + driver: TrimmedNonEmptyString, + t3CodeRange: VersionRange, + recommendedRange: Schema.optionalKey(VersionRange), + recommendedVersion: Schema.optionalKey(StableVersion), + ranges: Schema.Array( + Schema.Struct({ + range: VersionRange, + status: ServerProviderCompatibilityStatus, + }), + ), +}); + +export const ProviderCompatibilityPolicy = Policy.pipe( + Schema.check( + Schema.makeFilter( + (policy) => { + const version = policy.recommendedVersion; + if (version === undefined) return true; + return ( + (policy.recommendedRange === undefined || + satisfiesSemverRange(version, policy.recommendedRange)) && + policy.ranges.find((entry) => satisfiesSemverRange(version, entry.range))?.status === + "supported" + ); + }, + { expected: "a recommended version in a supported range" }, + ), + ), +); +export type ProviderCompatibilityPolicy = typeof ProviderCompatibilityPolicy.Type; + +export function resolveProviderCompatibility( + policies: ReadonlyArray | undefined, + driver: ProviderDriverKind, + version: string | null, + t3CodeVersion = packageJson.version, +): ServerProviderCompatibilityAdvisory | undefined { + const policy = policies?.find( + (entry) => entry.driver === driver && satisfiesSemverRange(t3CodeVersion, entry.t3CodeRange), + ); + if (!policy) return undefined; + const unprefixed = version?.replace(/^v/, ""); + // Cursor appends a build hash to its date; Google's ACP runtime uses a release prefix. + // Strip only these driver-specific forms, keeping semver prereleases unknown. + const stable = + driver === "cursor" + ? unprefixed?.replace(/^(\d{4}\.\d{2}\.\d{2})-[a-f0-9]+$/, "$1") + : driver === "antigravity" + ? unprefixed?.replace(/^agy_acp_server_(\d+\.\d+\.\d+)$/, "$1") + : unprefixed; + const status = + stable && /^\d+\.\d+\.\d+$/.test(stable) + ? (policy.ranges.find((entry) => satisfiesSemverRange(stable, entry.range))?.status ?? + "unknown") + : "unknown"; + const message = + status === "broken" + ? "This provider version is known to be incompatible with this T3 Code release." + : status === "unsupported" + ? "This provider version is outside the supported range for this T3 Code release." + : status === "graceful" + ? "This provider version has limited compatibility with this T3 Code release." + : null; + const recommendedVersion = policy.recommendedVersion ?? null; + const recommendedRange = policy.recommendedRange ?? null; + const recommendation = recommendedVersion ?? recommendedRange; + return { + status, + message: message && recommendation ? `${message} Use ${recommendation}.` : message, + recommendedVersion, + recommendedRange, + }; +} + +/** A remote policy replaces its matching bundled policy; omission keeps the bundle. */ +export function applyProviderCompatibility( + snapshot: ServerProvider, + policies: ReadonlyArray | undefined, + fallback: ReadonlyArray | undefined, +): ServerProvider { + const { compatibilityAdvisory: _previous, ...base } = snapshot; + if (!snapshot.enabled || !snapshot.installed) return base; + const advisory = + resolveProviderCompatibility(policies, snapshot.driver, snapshot.version) ?? + resolveProviderCompatibility(fallback, snapshot.driver, snapshot.version); + const latestVersion = snapshot.versionAdvisory?.latestVersion; + const latestAdvisory = latestVersion + ? (resolveProviderCompatibility(policies, snapshot.driver, latestVersion) ?? + resolveProviderCompatibility(fallback, snapshot.driver, latestVersion)) + : undefined; + return advisory + ? { + ...base, + compatibilityAdvisory: { + ...advisory, + ...(latestAdvisory ? { latestVersionStatus: latestAdvisory.status } : {}), + }, + } + : base; +} diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 3e0810f51b7a..50aee2b08181 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -15,6 +15,7 @@ import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { createProviderVersionAdvisory, + makeTargetedProviderUpdateAction, enrichProviderSnapshotWithVersionAdvisory, homebrewOwnershipFromCommandPath, makeCachedProviderMaintenanceResolution, @@ -786,3 +787,45 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }), ); }); + +it("pins only owned package-manager installs and preserves their execution context", () => { + const capabilities = makeProviderMaintenanceCapabilities({ + provider: driver("codex"), + packageName: "@openai/codex", + updateExecutable: "npm", + updateLockKey: "npm-global:/opt/node", + updateArgs: [ + "install", + "-g", + "--prefix", + "/opt/node", + "--allow-scripts=@openai/codex", + "@openai/codex@latest", + ], + env: { PATH: "/opt/node/bin" }, + }); + const pinned = makeTargetedProviderUpdateAction(capabilities, "2.0.0"); + expect(pinned).toMatchObject({ + executable: "npm", + lockKey: capabilities.update?.lockKey, + env: capabilities.update?.env, + args: [ + "install", + "-g", + "--prefix", + "/opt/node", + "--allow-scripts=@openai/codex", + "@openai/codex@2.0.0", + ], + }); + expect(pinned?.command).toContain("@openai/codex@2.0.0"); + for (const lockKey of ["codex-native", "homebrew", "manual"]) { + expect( + makeTargetedProviderUpdateAction( + { ...capabilities, update: { ...capabilities.update!, lockKey } }, + "2.0.0", + ), + ).toBeNull(); + } + expect(makeTargetedProviderUpdateAction(capabilities, "2.0.0; rm -rf /")).toBeNull(); +}); diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 6a11b22462ee..ace445b03060 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -186,6 +186,33 @@ export function makeProviderMaintenanceCapabilities(input: { }; } +/** Pin only package-manager actions we own, preserving prefix, scripts, env and lock. */ +export function makeTargetedProviderUpdateAction( + capabilities: ProviderMaintenanceCapabilities, + version: string, +): ProviderMaintenanceCommandAction | null { + if (!/^\d+\.\d+\.\d+$/.test(version)) return null; + const update = capabilities.update; + const packageName = capabilities.packageName; + if (!update || !packageName) return null; + if (!/^(?:npm-global:|bun-global$|pnpm-global$|vite-plus-global$)/.test(update.lockKey)) + return null; + const packageIndex = update.args.findIndex( + (arg) => arg === `${packageName}@latest` || arg === packageName, + ); + if (packageIndex < 0) return null; + const args = update.args.map((arg, index) => + index === packageIndex ? `${packageName}@${version}` : arg, + ); + const previous = update.args[packageIndex]!; + const commandIndex = update.command.lastIndexOf(previous); + const command = + commandIndex < 0 + ? update.command + : `${update.command.slice(0, commandIndex)}${packageName}@${version}${update.command.slice(commandIndex + previous.length)}`; + return { ...update, args, command }; +} + export function makeManualOnlyProviderMaintenanceCapabilities(input: { readonly provider: ProviderDriverKind; readonly packageName: string | null; @@ -627,6 +654,7 @@ export function createProviderVersionAdvisory(input: { latestVersion, updateCommand: capabilities.update?.command ?? null, canUpdate: capabilities.update !== null, + canInstallVersion: makeTargetedProviderUpdateAction(capabilities, "0.0.0") !== null, checkedAt: input.checkedAt ?? null, message: advisory.message, }; diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 19af22c882ae..998c301cb1fb 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -21,6 +21,7 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; +import * as ModelManifest from "./ModelManifest.ts"; import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; import { makeProviderMaintenanceCapabilities, @@ -208,13 +209,28 @@ function makeRegistry( }); } -const makeTestRunner = (registry: ProviderRegistryShape) => +const makeTestRunner = ( + registry: ProviderRegistryShape, + // Generic updater fixtures use synthetic versions. Keep their compatibility + // unknown so real harness minimums do not bypass the command under test. + manifest: ModelManifest.ModelManifestData = { + version: 1, + currentModels: {}, + compatibility: [{ driver: CODEX_DRIVER, t3CodeRange: ">=0.0.42", ranges: [] }], + }, +) => Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( Effect.provide( ProviderMaintenanceRunner.layer.pipe( Layer.provide( Layer.mergeAll( Layer.succeed(ProviderRegistry, registry), + Layer.succeed(ModelManifest.ModelManifest, { + current: Effect.succeed(manifest), + refresh: Effect.succeed(manifest), + forceRefresh: Effect.succeed(manifest), + refreshInBackground: Effect.void, + }), // Fresh per runner so a version cached by one test cannot leak into another. Layer.sync(ProviderVersionCache, () => new Map()), ), @@ -743,7 +759,7 @@ describe("providerMaintenanceRunner", () => { Effect.provide( Layer.mergeAll( NonWindowsPlatform, - latestVersionHttpClient("0.0.0"), + latestVersionHttpClient("2.0.0"), mockSpawnerLayer((_command, args) => { calls.push(args.join(" ")); if (calls.length === 1) { @@ -911,3 +927,92 @@ describe("providerMaintenanceRunner", () => { ); }); }); + +it.effect("refuses incompatible latest versions and unapproved or unpinnable targets", () => { + const calls: string[] = []; + const manifest: ModelManifest.ModelManifestData = { + version: 1, + currentModels: {}, + compatibility: [ + { + driver: "codex", + t3CodeRange: ">=0.0.42", + recommendedVersion: "2.0.0", + ranges: [ + { range: "=2.0.0", status: "supported" }, + { range: ">2.0.0", status: "broken" }, + ], + }, + ], + }; + return Effect.gen(function* () { + const { registry, providersRef } = yield* makeRegistry(); + const pinnedCapabilities = makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateExecutable: "npm", + updateArgs: ["install", "-g", "@openai/codex@latest"], + updateLockKey: "npm-global:/fixture", + }); + const updater = yield* makeTestRunner( + { + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => Effect.succeed(pinnedCapabilities), + }, + manifest, + ); + for (const targetVersion of [undefined, "", "1.0.0", "2.0.0; echo unsafe"]) { + const result = yield* updater.updateProvider({ + provider: CODEX_DRIVER, + ...(targetVersion !== undefined ? { targetVersion } : {}), + }); + assert.strictEqual(result.providers[0]?.updateState?.status, "failed"); + } + assert.deepStrictEqual(calls, []); + yield* Ref.update(providersRef, (providers) => + providers.map((entry) => ({ ...entry, version: "2.0.0" })), + ); + const installed = yield* updater.updateProvider({ + provider: CODEX_DRIVER, + targetVersion: "2.0.0", + }); + assert.deepStrictEqual(calls, ["install -g @openai/codex@2.0.0"]); + assert.strictEqual(installed.providers[0]?.updateState?.status, "succeeded"); + yield* Ref.update(providersRef, (providers) => + providers.map((entry) => ({ ...entry, version: "1.0.0" })), + ); + const unchanged = yield* updater.updateProvider({ + provider: CODEX_DRIVER, + targetVersion: "2.0.0", + }); + assert.strictEqual(unchanged.providers[0]?.updateState?.status, "unchanged"); + const nativeUpdater = yield* makeTestRunner( + { + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed({ + ...pinnedCapabilities, + update: { ...pinnedCapabilities.update!, lockKey: "codex-native" }, + }), + }, + manifest, + ); + const refused = yield* nativeUpdater.updateProvider({ + provider: CODEX_DRIVER, + targetVersion: "2.0.0", + }); + assert.strictEqual(refused.providers[0]?.updateState?.status, "failed"); + assert.strictEqual(calls.length, 2); + }).pipe( + Effect.provide( + Layer.mergeAll( + NonWindowsPlatform, + latestVersionHttpClient("3.0.0"), + mockSpawnerLayer((_command, args) => { + calls.push(args.join(" ")); + return { stdout: "installed" }; + }), + ), + ), + ); +}); diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 121e5aeab7d1..17be8eb087f6 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -21,10 +21,14 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as ModelManifest from "./ModelManifest.ts"; +import { resolveProviderCompatibility } from "./providerCompatibility.ts"; import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts"; import { enrichProviderSnapshotWithVersionAdvisory, + makeTargetedProviderUpdateAction, + resolveLatestProviderVersion, type ProviderMaintenanceCommandAction, ProviderVersionCache, } from "./providerMaintenance.ts"; @@ -51,6 +55,7 @@ export interface ProviderMaintenanceRunnerShape { | { readonly provider: ProviderDriverKind; readonly instanceId?: ProviderInstanceId | undefined; + readonly targetVersion?: string | undefined; }, ) => Effect.Effect; } @@ -214,6 +219,7 @@ function makeUpdateState(input: { /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const providerRegistry = yield* ProviderRegistry; + const manifestService = yield* ModelManifest.ModelManifest; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const versionCache = yield* ProviderVersionCache; @@ -310,6 +316,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { typeof target === "string" ? defaultInstanceIdForDriver(provider) : (target.instanceId ?? defaultInstanceIdForDriver(provider)); + const targetVersion = typeof target === "string" ? undefined : target.targetVersion; const targetKey = `instance:${instanceId}`; const capabilities = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( instanceId, @@ -376,7 +383,44 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ); } - const result = yield* runMaintenanceCommand(fresh.update); + const manifest = yield* manifestService.current; + const candidateVersion = + targetVersion ?? + (yield* resolveLatestProviderVersion(fresh).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderVersionCache, versionCache), + )); + const advisory = + resolveProviderCompatibility(manifest.compatibility, provider, candidateVersion) ?? + resolveProviderCompatibility( + ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, + provider, + candidateVersion, + ); + const command = + targetVersion !== undefined + ? makeTargetedProviderUpdateAction(fresh, targetVersion) + : fresh.update; + const rejected = + targetVersion !== undefined + ? !command || + advisory?.recommendedVersion !== targetVersion || + advisory.status !== "supported" + : advisory?.status === "broken" || advisory?.status === "unsupported"; + if (rejected || !command) { + return yield* finish( + makeUpdateState({ + status: "failed", + startedAt, + finishedAt: yield* nowIso, + message: + targetVersion !== undefined + ? "This version is no longer recommended or this installer cannot install a specific version. Refresh provider settings." + : "The latest provider version is incompatible with this T3 Code release. Review provider settings.", + }), + ); + } + const result = yield* runMaintenanceCommand(command); const finishedAt = yield* nowIso; if (result.timedOut || result.exitCode !== 0) { return yield* finish( @@ -407,10 +451,15 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { // Cursor's `about` probe can fail transiently on a healthy binary. const couldNotVerify = verifiedProviders.length === 0 || - verifiedProviders.some((verifiedProvider) => !isStillInstalled(verifiedProvider)); - const stillOutdated = verifiedProviders.some((verifiedProvider) => - isOutdatedProvider(verifiedProvider), - ); + verifiedProviders.some( + (verifiedProvider) => + !isStillInstalled(verifiedProvider) || + (targetVersion !== undefined && + verifiedProvider.version?.replace(/^v/, "") !== targetVersion), + ); + const stillOutdated = + targetVersion === undefined && + verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider)); return yield* finish( makeUpdateState({ status: couldNotVerify || stillOutdated ? "unchanged" : "succeeded", diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index a026e06019ee..48eb39e93835 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -452,77 +452,137 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); - it.effect("reads linked pull request status with the overview fields in one request", () => + it.effect("reads linked pull requests on one host together, filed back by position", () => Effect.gen(function* () { + const node = (number: number) => ({ + number, + title: `Pull request ${number}`, + url: `https://github.com/acme/web/pull/${number}`, + author: { __typename: "User", login: "octocat", name: "Octo Cat", avatarUrl: null }, + baseRefName: "main", + headRefName: `feat/${number}`, + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + reviewDecision: null, + latestReviews: { nodes: [{ state: "APPROVED", author: { login: "reviewer" } }] }, + additions: 12, + deletions: 3, + changedFiles: 2, + updatedAt: "2026-08-24T12:34:56.000Z", + mergedAt: null, + closedAt: null, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + }); mockedExecute.mockReturnValueOnce( Effect.succeed( output( // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ - number: 7, - title: "Reuse the summary", - url: "https://github.com/acme/web/pull/7", - author: { login: "octocat", name: "Octo Cat" }, - baseRefName: "main", - headRefName: "feat/summary", - state: "OPEN", - isDraft: false, - mergeable: "MERGEABLE", - reviewDecision: "APPROVED", - additions: 12, - deletions: 3, - changedFiles: 2, - createdAt: "2026-08-20T00:00:00.000Z", - updatedAt: "2026-08-24T12:34:56.000Z", - reviewRequests: [], - labels: [], - statusCheckRollup: [ - { __typename: "CheckRun", status: "COMPLETED", conclusion: "SUCCESS", name: "ci" }, - ], - body: "", + data: { s0: { pullRequest: node(7) }, s1: { pullRequest: node(8) } }, }), ), ), ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const summary = yield* cli.getPullRequestSummary({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); + const reads = yield* Effect.forEach( + [7, 8], + (number) => + cli.getPullRequestSummary({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number, + }), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* TestClock.adjust("10 millis"); + const [seven, eight] = yield* Fiber.join(reads); assert.deepStrictEqual( { - number: summary.number, - state: summary.state, - headBranch: summary.headBranch, - isDraft: summary.isDraft, - author: summary.author?.login, - additions: summary.additions, - deletions: summary.deletions, - changedFiles: summary.changedFiles, - reviewDecision: summary.reviewDecision, - checksState: summary.checksState, - mergeability: summary.mergeability, + number: seven?.number, + state: seven?.state, + headBranch: seven?.headBranch, + author: seven?.author?.login, + changedFiles: seven?.changedFiles, + reviewDecision: seven?.reviewDecision, + checksState: seven?.checksState, + mergeability: seven?.mergeability, }, { number: 7, state: "open", - headBranch: "feat/summary", - isDraft: false, + headBranch: "feat/7", author: "octocat", - additions: 12, - deletions: 3, changedFiles: 2, reviewDecision: "approved", checksState: "passing", mergeability: "mergeable", }, ); + assert.strictEqual(eight?.headBranch, "feat/8"); expect(mockedExecute).toHaveBeenCalledOnce(); - expect(mockedExecute.mock.calls[0]?.[0]?.args).toEqual([ + const document = callAt(0).args.at(-1) ?? ""; + expect(document).toContain( + 's0: repository(owner: "acme", name: "web") { pullRequest(number: 7)', + ); + expect(document).toContain("pullRequest(number: 8)"); + }), + ); + + it.effect("reads a pull request the batch said nothing about on its own", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output('{"data":{"s0":{"pullRequest":null}}}'))) + .mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + number: 7, + title: "Reuse the summary", + url: "https://github.com/acme/web/pull/7", + author: { login: "octocat", name: "Octo Cat" }, + baseRefName: "main", + headRefName: "feat/summary", + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + reviewDecision: "APPROVED", + additions: 12, + deletions: 3, + changedFiles: 2, + createdAt: "2026-08-20T00:00:00.000Z", + updatedAt: "2026-08-24T12:34:56.000Z", + reviewRequests: [], + labels: [], + statusCheckRollup: [ + { + __typename: "CheckRun", + status: "COMPLETED", + conclusion: "SUCCESS", + name: "ci", + }, + ], + body: "", + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const read = yield* cli + .getPullRequestSummary({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("10 millis"); + const summary = yield* Fiber.join(read); + + assert.strictEqual(summary.headBranch, "feat/summary"); + assert.strictEqual(summary.checksState, "passing"); + assert.strictEqual(mockedExecute.mock.calls.length, 2); + expect(callAt(1).args).toEqual([ "pr", "view", "7", @@ -531,7 +591,6 @@ layer("GitHubPullRequestCli.layer", (it) => { "--json", expect.stringContaining("statusCheckRollup"), ]); - expect(mockedGetPullRequest).not.toHaveBeenCalled(); }), ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 02cdfb9676c7..615a2258facf 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -1,10 +1,15 @@ import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Clock from "effect/Clock"; import * as NodeCrypto from "node:crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; +import * as Request from "effect/Request"; +import * as RequestResolver from "effect/RequestResolver"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -48,6 +53,7 @@ import { decodePullRequestCoreJson, PULL_REQUEST_CORE_GRAPHQL_QUERY, type GitHubPullRequestCore, + type GitHubPullRequestSummary, decodePullRequestPreviewJson, PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, decodePullRequestFilesJson, @@ -58,6 +64,7 @@ import { decodePullRequestSearchJson, decodePullRequestStacksJson, decodePullRequestStatsJson, + decodePullRequestSummariesJson, decodeReactionSubjectScopeJson, decodeReviewerCandidatesJson, decodeLabelCandidatesJson, @@ -67,6 +74,7 @@ import { decodeReviewThreadCommentsJson, decodeReviewThreadsJson, buildPullRequestStatsGraphQlQuery, + buildPullRequestSummariesGraphQlQuery, buildPullRequestStackMembershipsGraphQlQuery, decodePullRequestStackMembershipsJson, encodeGraphQlRequestJson, @@ -419,6 +427,23 @@ export interface GitHubPullRequestStat { */ const STAT_ALIASES_PER_REQUEST = 25; const STAT_REQUEST_CONCURRENCY = 4; +/** + * How long a summary read waits for company. The background sync asks for every linked pull + * request at once, and each read reaches the resolver after its own cache check, so a batch + * needs a moment longer than one scheduler tick to gather them. + */ +const SUMMARY_BATCH_WINDOW = "10 millis"; + +class PullRequestSummaryRead extends Request.Class< + { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }, + ProviderChangeRequestSummary, + GitHubPullRequestCliError +> {} export interface GitHubPullRequestSearchBatch { /** Rows across every repository asked for, newest update first, each naming its own. */ @@ -1733,6 +1758,133 @@ export const make = Effect.gen(function* () { { concurrency: 2 }, ).pipe(Effect.map(([, runs]) => runs)); + // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands + // the thread overview its author, diff stat, review decision and checks in the same read. + const viewPullRequestSummary = (input: PullRequestSummaryRead) => + github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + String(input.number), + ...repositoryArgs(input), + "--json", + PULL_REQUEST_DETAIL_JSON_FIELDS, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodePullRequestDetailJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestSummary", + cause: decoded.failure, + }), + ); + } + const detail = decoded.success; + return Effect.succeed({ + number: detail.number, + title: detail.title, + url: detail.url, + headBranch: detail.headBranch, + baseBranch: detail.baseBranch, + state: detail.state, + updatedAt: detail.updatedAt, + closedAt: detail.closedAt ?? null, + mergedAt: detail.mergedAt ?? null, + isDraft: detail.isDraft, + author: detail.author, + additions: detail.additions, + deletions: detail.deletions, + changedFiles: detail.changedFiles, + reviewDecision: detail.reviewDecision, + checksState: detail.checksState, + mergeability: detail.mergeability, + }); + }), + ); + + /** + * Summaries asked for together, on one host under one credential, share aliased GraphQL reads + * of twenty-five: the background sync reads every linked pull request each minute, and one + * `gh pr view` apiece is most of what it spends. Whatever the batch cannot answer — a selector + * GraphQL cannot address, a pull request GitHub returned nothing for — is read on its own. + */ + const summaryResolver = RequestResolver.makeGrouped({ + key: ({ request, context }) => + JSON.stringify([ + request.host.toLowerCase(), + Context.getOrElse(context, GitHubCli.PinnedGitHubCredential, () => null) + ?.credentialFingerprint ?? null, + Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), + ]), + resolver: (entries) => { + const [first] = entries; + const batchable = entries.filter( + (entry) => buildPullRequestSummariesGraphQlQuery([entry.request]) !== null, + ); + const query = buildPullRequestSummariesGraphQlQuery(batchable.map((entry) => entry.request)); + const batched = + query === null + ? Effect.succeed(new Map()) + : graphqlRead({ + cwd: first.request.cwd, + host: first.request.host, + operation: "getPullRequestSummary", + query, + decode: decodePullRequestSummariesJson, + }); + return batched.pipe( + // A GraphQL error anywhere fails the whole document — one repository gone or out of + // reach — so a batch that could not be read leaves every entry to its own read. A paused + // budget is the exception: reading one at a time would only spend what is being saved. + Effect.catchCauseIf( + (cause) => + !Cause.hasInterruptsOnly(cause) && + !Cause.findErrorOption(cause).pipe( + Option.exists((error) => error._tag === "SourceControlRateLimitPausedError"), + ), + (cause) => + Effect.logDebug("batched pull request summary read failed", { cause }).pipe( + Effect.as(new Map()), + ), + ), + Effect.flatMap((summaries) => { + const unanswered = entries.filter((entry) => { + const summary = summaries.get(batchable.indexOf(entry)); + if (summary === undefined) return true; + entry.completeUnsafe(Exit.succeed(summary)); + return false; + }); + return Effect.forEach( + unanswered, + (entry) => + viewPullRequestSummary(entry.request).pipe( + Effect.exit, + Effect.map((exit) => entry.completeUnsafe(exit)), + ), + { concurrency: STAT_REQUEST_CONCURRENCY, discard: true }, + ); + }), + Effect.catchCause((cause) => + Effect.sync(() => { + for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); + }), + ), + ); + }, + }).pipe( + RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), + RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), + ); + const getPullRequestSummary: GitHubPullRequestCli["Service"]["getPullRequestSummary"] = (input) => + Effect.request(new PullRequestSummaryRead(input), summaryResolver); + return GitHubPullRequestCli.of({ withVerifiedCredential, getRoutingIdentity, @@ -1939,56 +2091,7 @@ export const make = Effect.gen(function* () { ).pipe(Effect.map((results) => results.flat())); }, - // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands - // the thread overview its author, diff stat, review decision and checks in the same read. - getPullRequestSummary: (input) => - github - .execute({ - cwd: input.cwd, - args: [ - "pr", - "view", - String(input.number), - ...repositoryArgs(input), - "--json", - PULL_REQUEST_DETAIL_JSON_FIELDS, - ], - }) - .pipe( - Effect.flatMap((result) => { - const decoded = decodePullRequestDetailJson(result.stdout.trim()); - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitHubPullRequestReadError({ - command: "gh", - cwd: input.cwd, - operation: "getPullRequestSummary", - cause: decoded.failure, - }), - ); - } - const detail = decoded.success; - return Effect.succeed({ - number: detail.number, - title: detail.title, - url: detail.url, - headBranch: detail.headBranch, - baseBranch: detail.baseBranch, - state: detail.state, - updatedAt: detail.updatedAt, - closedAt: detail.closedAt ?? null, - mergedAt: detail.mergedAt ?? null, - isDraft: detail.isDraft, - author: detail.author, - additions: detail.additions, - deletions: detail.deletions, - changedFiles: detail.changedFiles, - reviewDecision: detail.reviewDecision, - checksState: detail.checksState, - mergeability: detail.mergeability, - }); - }), - ), + getPullRequestSummary, getPullRequestDetail, getPullRequestPreview: (input) => { diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 162c73f94fd6..6065fd27fa7b 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -3535,6 +3535,67 @@ it.effect("a listing narrowed to some projects is its own cache entry", () => }), ); +it.effect( + "keeps listing freshness tied to read start when filtered reads finish out of order", + () => + Effect.gen(function* () { + const olderStarted = yield* Deferred.make(); + const releaseOlder = yield* Deferred.make(); + let reads = 0; + const updatedAt = "2026-07-02T00:00:00Z"; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: ({ filters }) => + Effect.gen(function* () { + reads += 1; + const older = filters?.checks === "failing"; + if (older) { + yield* Deferred.succeed(olderStarted, undefined); + yield* Deferred.await(releaseOlder); + } + return { + items: [ + { + ...changeRequest(1, updatedAt), + checksState: older ? ("failing" as const) : ("passing" as const), + mergeability: older ? ("mergeable" as const) : ("conflicting" as const), + }, + ], + truncated: false, + continues: false, + }; + }), + }), + ], + }); + const olderInput = { state: "open" as const, filters: { checks: "failing" as const } }; + const newerInput = { state: "open" as const, filters: { checks: "passing" as const } }; + + const olderRead = yield* service.list(olderInput).pipe(Effect.forkChild()); + yield* Deferred.await(olderStarted); + yield* TestClock.adjust("1 second"); + const newer = yield* service.list(newerInput); + yield* Deferred.succeed(releaseOlder, undefined); + const older = yield* Fiber.join(olderRead); + + assert.strictEqual(older.entries[0]?.checksState, "failing"); + assert.strictEqual(older.entries[0]?.mergeability, "mergeable"); + assert.strictEqual(newer.entries[0]?.checksState, "passing"); + assert.strictEqual(newer.entries[0]?.mergeability, "conflicting"); + assert.strictEqual(typeof older.entries[0]?.observedAt, "number"); + assert.strictEqual(typeof newer.entries[0]?.observedAt, "number"); + assert.isBelow(older.entries[0]!.observedAt!, newer.entries[0]!.observedAt!); + + const cachedOlder = yield* service.list(olderInput); + assert.strictEqual(cachedOlder.entries[0]?.observedAt, older.entries[0]?.observedAt); + assert.strictEqual(reads, 2); + }), +); + it.effect("keeps unrelated PRs warm after a mutation, explicit refresh, and project turn", () => Effect.gen(function* () { const calls: string[] = []; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 622ee4dde684..b2f643ae4e07 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -624,6 +624,12 @@ function withRateLimitBackoff( Record, never>; } +// Capture before the provider read so a slow response keeps its original freshness through caches. +const observeRead = Effect.fnUntraced(function* (read: Effect.Effect) { + const observedAt = yield* Clock.currentTimeMillis; + return { value: yield* read, observedAt }; +}); + export const make = Effect.gen(function* () { const mergedPullRequests = yield* PubSub.sliding(64); const pullRequestRefreshes = yield* SubscriptionRef.make(0); @@ -1089,6 +1095,7 @@ export const make = Effect.gen(function* () { readonly project: SupportedProject; readonly item: ProviderChangeRequest; readonly viewer: string; + readonly observedAt: number; }): PullRequestListEntry => { const viewer = input.viewer.toLowerCase(); return { @@ -1111,6 +1118,7 @@ export const make = Effect.gen(function* () { deletions: input.item.deletions, createdAt: input.item.createdAt, updatedAt: input.item.updatedAt, + observedAt: input.observedAt, ...(input.item.checksState === undefined || input.item.checksState === null ? {} : { checksState: input.item.checksState }), @@ -1259,7 +1267,8 @@ export const make = Effect.gen(function* () { }), }) .pipe( - Effect.map((page): RepositoryBatch => { + observeRead, + Effect.map(({ value: page, observedAt }): RepositoryBatch => { // The boundary instant was asked for inclusively, so the rows already sent at it // come back with the slice. Dropping them here rather than asking for strictly // older is what keeps their neighbours at the same instant from being skipped. @@ -1275,7 +1284,7 @@ export const make = Effect.gen(function* () { key, entries: items .filter((item) => matchesRowFilters(item, input.filters, viewer)) - .map((item) => toEntry({ project, item, viewer })), + .map((item) => toEntry({ project, item, viewer, observedAt })), errors: [], truncated: page.truncated, nextCursor: @@ -1336,7 +1345,8 @@ export const make = Effect.gen(function* () { ? {} : { cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered } }), }).pipe( - Effect.flatMap((page) => + observeRead, + Effect.flatMap(({ value: page, observedAt }) => Effect.flatMap(Clock.currentTimeMillis, (now) => { const rows = new Map>(); for (const [key, visibleAt] of searchVisibleAt) { @@ -1397,7 +1407,7 @@ export const make = Effect.gen(function* () { key: project.cursorKey, entries: items .filter((item) => matchesRowFilters(item, input.filters, viewer)) - .map((item) => toEntry({ project, item, viewer })), + .map((item) => toEntry({ project, item, viewer, observedAt })), errors: [], truncated: page.truncated, nextCursor: @@ -1564,7 +1574,8 @@ export const make = Effect.gen(function* () { : project.api.getChangeRequestSummary(providerInput); return read.pipe( Effect.mapError(toPullRequestError("summary")), - Effect.map((changeRequest): PullRequestSummary => ({ + observeRead, + Effect.map(({ value: changeRequest, observedAt }): PullRequestSummary => ({ provider: project.api.kind, projectId: project.project.id, repository: project.repository, @@ -1577,6 +1588,7 @@ export const make = Effect.gen(function* () { closedAt: changeRequest.closedAt ?? null, mergedAt: changeRequest.mergedAt ?? null, updatedAt: changeRequest.updatedAt, + observedAt, ...(changeRequest.isDraft === undefined ? {} : { isDraft: changeRequest.isDraft }), ...(changeRequest.author === undefined ? {} : { author: changeRequest.author }), ...(changeRequest.additions === undefined @@ -1647,12 +1659,12 @@ export const make = Effect.gen(function* () { host: project.host, number: input.number, }) - .pipe(Effect.mapError(toPullRequestError("detail"))), + .pipe(Effect.mapError(toPullRequestError("detail")), observeRead), viewerOf(project), ], { concurrency: 2 }, ).pipe( - Effect.map(([changeRequest, viewer]): PullRequestDetail => ({ + Effect.map(([{ value: changeRequest, observedAt }, viewer]): PullRequestDetail => ({ provider: project.api.kind, capabilities: project.api.capabilities, projectId: project.project.id, @@ -1677,6 +1689,7 @@ export const make = Effect.gen(function* () { baseBranch: changeRequest.baseBranch, createdAt: changeRequest.createdAt, updatedAt: changeRequest.updatedAt, + observedAt, mergedAt: changeRequest.mergedAt, closedAt: changeRequest.closedAt, reviewers: changeRequest.reviewers, @@ -2938,12 +2951,14 @@ export const make = Effect.gen(function* () { closedAt: detail.closedAt, mergedAt: detail.mergedAt, updatedAt: detail.updatedAt, + observedAt: detail.observedAt, }); const shouldReplaceHeldSummary = (key: string, next: PullRequestSummary) => { const current = lastGoodSummary.peek(key); if (current === undefined) return true; if (current.state === "merged" && next.state !== "merged") return false; - return next.updatedAt >= current.updatedAt; + if (next.updatedAt !== current.updatedAt) return next.updatedAt > current.updatedAt; + return (next.observedAt ?? -Infinity) >= (current.observedAt ?? -Infinity); }; const detail: PullRequestService["Service"]["detail"] = (input) => { const key = refCacheKey(input); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index 63073a87dd31..75400fae41d4 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -2,6 +2,8 @@ import * as Result from "effect/Result"; import { describe, expect, it } from "vite-plus/test"; import { + buildPullRequestSummariesGraphQlQuery, + decodePullRequestSummariesJson, buildReviewSubmissionJson, buildPullRequestStackMembershipsGraphQlQuery, decodePullRequestStackMembershipsJson, @@ -97,6 +99,40 @@ describe("pull request list decoding", () => { ]); }); + it("takes the verdict from the latest reviews when GitHub summarizes none, as for a bot's approval", () => { + const batch = expectSuccess( + decodePullRequestListJson( + listJson([ + { + reviewDecision: null, + latestReviews: [{ author: { login: "macroscopeapp" }, state: "APPROVED" }], + }, + { + reviewDecision: "REVIEW_REQUIRED", + latestReviews: [ + { author: { login: "octocat" }, state: "APPROVED" }, + { author: { login: "hubot" }, state: "CHANGES_REQUESTED" }, + ], + }, + { + reviewDecision: "APPROVED", + latestReviews: [{ author: { login: "hubot" }, state: "CHANGES_REQUESTED" }], + }, + { + reviewDecision: null, + latestReviews: [{ author: { login: "octocat" }, state: "COMMENTED" }], + }, + ]), + ), + ); + expect(batch.items.map((entry) => entry.reviewDecision)).toEqual([ + "approved", + "changes-requested", + "approved", + null, + ]); + }); + it("rolls the head commit's checks up to the one word a row has space for", () => { const batch = expectSuccess( decodePullRequestListJson( @@ -121,6 +157,13 @@ describe("pull request list decoding", () => { { statusCheckRollup: [{ context: "ci/legacy", state: "ERROR" }] }, // Neither a pass, a failure nor a wait is no verdict rather than a green tick. { statusCheckRollup: [{ name: "lint", status: "COMPLETED", conclusion: "SKIPPED" }] }, + // Cancelled reads as failing here and in the detail header, so the two never flap. + { + statusCheckRollup: [ + { name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }, + { name: "test", status: "COMPLETED", conclusion: "CANCELLED" }, + ], + }, { statusCheckRollup: [] }, {}, ]), @@ -132,6 +175,7 @@ describe("pull request list decoding", () => { "passing", "failing", null, + "failing", null, null, ]); @@ -1875,3 +1919,54 @@ describe("pull request stack membership batches", () => { ); }); }); + +describe("batched pull request summaries", () => { + it("refuses a repository GraphQL cannot address, rather than writing it into the document", () => { + expect( + buildPullRequestSummariesGraphQlQuery([{ repository: 'acme/web") { x } #', number: 1 }]), + ).toBeNull(); + expect( + buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 0 }]), + ).toBeNull(); + expect(buildPullRequestSummariesGraphQlQuery([])).toBeNull(); + }); + + it("files each answer by its alias and skips what GitHub or the decoder could not give", () => { + const decoded = decodePullRequestSummariesJson( + JSON.stringify({ + data: { + s0: { + pullRequest: { + number: 7, + title: "Merged", + url: "https://github.com/acme/web/pull/7", + author: { __typename: "Bot", login: "renovate", avatarUrl: "https://a/r.png" }, + headRefName: "feat/seven", + baseRefName: "main", + state: "MERGED", + mergedAt: "2026-08-24T00:00:00Z", + closedAt: "2026-08-24T00:00:00Z", + updatedAt: "2026-08-24T00:00:00Z", + commits: { nodes: [{ commit: { statusCheckRollup: { state: "FAILURE" } } }] }, + }, + }, + s1: { pullRequest: null }, + s2: { pullRequest: { number: 9 } }, + rateLimit: { cost: 1 }, + }, + }), + ); + expect(Result.isSuccess(decoded)).toBe(true); + if (!Result.isSuccess(decoded)) return; + expect([...decoded.success.keys()]).toEqual([0]); + expect(decoded.success.get(0)).toMatchObject({ + number: 7, + state: "merged", + mergedAt: "2026-08-24T00:00:00Z", + author: { login: "renovate", isBot: true }, + checksState: "failing", + mergeability: "unknown", + additions: 0, + }); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 2ad7f15a09a8..9e2b1bc0afbf 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -68,6 +68,12 @@ const RawReviewRequestSchema = Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)), }); +/** One reviewer's most recent review: the state is all the verdict needs, the author is for who. */ +const RawLatestReviewSchema = Schema.Struct({ + author: Schema.optional(Schema.NullOr(RawActorSchema)), + state: Schema.optional(Schema.NullOr(Schema.String)), +}); + const RawCheckSchema = Schema.Struct({ __typename: Schema.optional(Schema.String), name: Schema.optional(Schema.NullOr(Schema.String)), @@ -106,6 +112,7 @@ const RawListItemSchema = Schema.Struct({ updatedAt: Schema.String, mergedAt: Schema.optional(Schema.NullOr(Schema.String)), reviewRequests: Schema.optional(Schema.Array(RawReviewRequestSchema)), + latestReviews: Schema.optional(Schema.NullOr(Schema.Array(RawLatestReviewSchema))), labels: Schema.optional(Schema.Array(RawLabelSchema)), /** * Every check of the head commit, which is the only rollup `gh pr list --json` can give: there @@ -143,6 +150,9 @@ const RawSearchItemSchema = Schema.Struct({ isDraft: Schema.optional(Schema.Boolean), mergeable: Schema.optional(Schema.NullOr(Schema.String)), reviewDecision: Schema.optional(Schema.NullOr(Schema.String)), + latestReviews: Schema.optional( + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawLatestReviewSchema)) })), + ), createdAt: Schema.String, updatedAt: Schema.String, mergedAt: Schema.optional(Schema.NullOr(Schema.String)), @@ -514,13 +524,7 @@ const RawReviewThreadsSchema = Schema.Struct({ ), ), latestReviews: Schema.optional( - Schema.NullOr( - Schema.Struct({ - nodes: Schema.Array( - Schema.Struct({ author: Schema.optional(Schema.NullOr(RawActorSchema)) }), - ), - }), - ), + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(RawLatestReviewSchema) })), ), reviewDismissals: Schema.optional( Schema.NullOr( @@ -698,7 +702,7 @@ export function decodeActorAvatarsJson( } export const PULL_REQUEST_LIST_JSON_FIELDS = - "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup"; + "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,latestReviews,labels,statusCheckRollup"; export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest`; @@ -817,6 +821,7 @@ export function pullRequestSearchGraphQlQuery(rows: number, includeStacks = fals isDraft mergeable reviewDecision + latestReviews(first: 20) { nodes { state author { login } } } createdAt updatedAt mergedAt @@ -891,7 +896,7 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin } } latestReviews(first: 50) { - nodes { author { __typename login avatarUrl } } + nodes { state author { __typename login avatarUrl } } } reviewDismissals: timelineItems(itemTypes: [REVIEW_DISMISSED_EVENT], first: ${GRAPHQL_PAGE_SIZE}) { pageInfo { hasNextPage endCursor } @@ -1325,6 +1330,35 @@ function toMergeMethod(value: string | null | undefined): PullRequestMergeMethod } } +/** + * GitHub's own `reviewDecision` counts only reviews that satisfy the branch rules, so an + * approval from an app (a review bot) or from anyone without the required permission leaves it + * empty. The reviewers still said something, and a row should show it: when GitHub reports no + * verdict, the latest review per reviewer decides, changes requested outranking approval. + */ +function toReviewDecisionWithReviews( + value: string | null | undefined, + // `gh pr list` hands the reviews as an array; the GraphQL reads hand a connection. + latestReviews: + | ReadonlyArray> + | { readonly nodes: ReadonlyArray> } + | null + | undefined, +): PullRequestReviewDecision | null { + const summarized = toReviewDecision(value); + if (summarized === "approved" || summarized === "changes-requested") return summarized; + const reviews = + latestReviews === null || latestReviews === undefined + ? [] + : "nodes" in latestReviews + ? latestReviews.nodes + : latestReviews; + const states = new Set(reviews.map((review) => review.state?.trim().toUpperCase() ?? "")); + if (states.has("CHANGES_REQUESTED")) return "changes-requested"; + if (states.has("APPROVED")) return "approved"; + return summarized; +} + function toReviewDecision(value: string | null | undefined): PullRequestReviewDecision | null { switch (value?.trim().toUpperCase()) { case "APPROVED": @@ -1447,8 +1481,9 @@ function toCheckEntries( * GitHub's own indicator reads: a run that has already gone red will not go green by finishing. * * Null rather than "passing" for a head commit with no checks at all, so a repository that runs - * none shows nothing instead of a green tick it never earned. Checks whose verdict is neither a - * pass, a failure nor a wait — skipped, cancelled, neutral — count towards neither. + * none shows nothing instead of a green tick it never earned. A cancelled run is a failure, as + * GitHub's own rollup and the client's detail rollup both read it; skipped and neutral count + * towards neither, so the row and the detail header never disagree about one head commit. * * Counted off the deduped checks rather than the raw rollup, so the word and the list under it * cannot disagree: the run a re-run replaced is not a verdict twice. A row with no name at all is @@ -1463,7 +1498,7 @@ function rollupChecksState( ...(raw ?? []).filter(isNamelessCheck).map((check) => toCheckStatus(check)), ]; if (statuses.length === 0) return null; - if (statuses.includes("failure")) return "failing"; + if (statuses.includes("failure") || statuses.includes("cancelled")) return "failing"; if (statuses.includes("pending") || statuses.includes("action-required")) return "pending"; return statuses.includes("success") ? "passing" : null; } @@ -1557,7 +1592,7 @@ function toListItem(raw: Schema.Schema.Type): GitHubPu state: toState(raw), isDraft: raw.isDraft ?? false, mergeability: toMergeability(raw.mergeable), - reviewDecision: toReviewDecision(raw.reviewDecision), + reviewDecision: toReviewDecisionWithReviews(raw.reviewDecision, raw.latestReviews), additions: raw.additions ?? 0, deletions: raw.deletions ?? 0, createdAt: raw.createdAt, @@ -1680,6 +1715,9 @@ export function decodePullRequestSearchJson( items.push({ ...toListItem({ ...node, + latestReviews: (node.latestReviews?.nodes ?? []).flatMap((review) => + review === null ? [] : [review], + ), reviewRequests: (node.reviewRequests?.nodes ?? []).flatMap((request) => { const login = trimmed(request?.requestedReviewer?.login); return login === null ? [] : [{ login }]; @@ -1809,6 +1847,129 @@ export function decodePullRequestStatsJson( return Result.succeed(stats); } +/** + * The fields a linked thread keeps current, for many pull requests in one aliased read. Same + * shape as the search row where the two overlap: the checks arrive as GitHub's one-word rollup + * rather than the whole check list `gh pr view` hands back, which is what keeps a batch cheap. + */ +const PULL_REQUEST_SUMMARY_SELECTION = + "number title url state isDraft mergeable reviewDecision additions deletions changedFiles " + + "updatedAt mergedAt closedAt headRefName baseRefName " + + "author { __typename login avatarUrl ... on User { name } } " + + "latestReviews(first: 20) { nodes { state author { login } } } " + + "commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }"; + +/** + * Summaries for pull requests anywhere on one host, one aliased lookup each. Checked and written + * into the document the way `buildPullRequestStatsGraphQlQuery` does, so null means "do not send". + */ +export function buildPullRequestSummariesGraphQlQuery( + changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, +): string | null { + if (changeRequests.length === 0) return null; + const selections: string[] = []; + for (const [index, changeRequest] of changeRequests.entries()) { + const [owner, name, ...rest] = changeRequest.repository.trim().split("/"); + if (rest.length > 0 || owner === undefined || name === undefined) return null; + if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; + if (!Number.isSafeInteger(changeRequest.number) || changeRequest.number <= 0) return null; + selections.push( + ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { ${PULL_REQUEST_SUMMARY_SELECTION} } }`, + ); + } + return `query PullRequestSummaries {\n${selections.join("\n")}\n}`; +} + +const RawSummarySchema = Schema.Struct({ + ...RawSearchItemSchema.fields, + changedFiles: Schema.optional(Schema.NullOr(Schema.Int)), + additions: Schema.optional(Schema.NullOr(Schema.Int)), + deletions: Schema.optional(Schema.NullOr(Schema.Int)), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + createdAt: Schema.optional(Schema.String), +}); +const decodeSummaries = decodeJsonResult( + Schema.Struct({ + data: Schema.optional( + Schema.NullOr( + Schema.Record( + Schema.String, + Schema.NullOr(Schema.Struct({ pullRequest: Schema.optional(Schema.Unknown) })), + ), + ), + ), + }), +); +const decodeSummaryEntry = Schema.decodeUnknownExit(RawSummarySchema); + +export interface GitHubPullRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly closedAt: string | null; + readonly mergedAt: string | null; + readonly updatedAt: string; + readonly author: PullRequestActor | null; + readonly additions: number; + readonly deletions: number; + readonly changedFiles: number; + readonly reviewDecision: PullRequestReviewDecision | null; + readonly checksState: PullRequestChecksState | null; + readonly mergeability: PullRequestMergeability; +} + +/** + * Summaries by the position they were asked in. A pull request GitHub answered nothing for, or + * one whose fields no longer decode, is absent rather than failing the rest of the batch. + */ +export function decodePullRequestSummariesJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeSummaries(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const summaries = new Map(); + for (const [alias, value] of Object.entries(decoded.success.data ?? {})) { + const index = /^s(\d+)$/.exec(alias)?.[1]; + if (index === undefined || value?.pullRequest == null) continue; + const entry = decodeSummaryEntry(value.pullRequest); + if (!Exit.isSuccess(entry)) continue; + const pr = entry.value; + summaries.set(Number(index), { + number: pr.number, + title: pr.title, + url: pr.url, + headBranch: pr.headRefName, + baseBranch: pr.baseRefName, + state: toState(pr), + isDraft: pr.isDraft ?? false, + closedAt: trimmed(pr.closedAt), + mergedAt: trimmed(pr.mergedAt), + updatedAt: pr.updatedAt, + author: toActor(pr.author), + additions: pr.additions ?? 0, + deletions: pr.deletions ?? 0, + changedFiles: pr.changedFiles ?? 0, + reviewDecision: toReviewDecisionWithReviews( + pr.reviewDecision, + (pr.latestReviews?.nodes ?? []).flatMap((review) => (review === null ? [] : [review])), + ), + // One enum for the head commit, dressed as a single check like the search row's. + checksState: rollupChecksState( + (pr.commits?.nodes ?? []).flatMap((commitNode) => { + const state = trimmed(commitNode?.commit?.statusCheckRollup?.state); + return state === null ? [] : [{ state }]; + }), + ), + mergeability: toMergeability(pr.mergeable), + }); + } + return Result.succeed(summaries); +} + export interface GitHubPullRequestCore extends GitHubPullRequestDetail { readonly viewerAccess: GitHubViewerAccess & GitHubRepositoryAccess; readonly comparison: GitHubBaseComparison | null; diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 1e7cdc3f00e6..32cf68a61e18 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -20,6 +20,7 @@ import { CommandId, ProviderInstanceId } from "@t3tools/contracts"; import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import { RELAY_ACTIVITY_PUBLISH_TYP, verifyRelayJwt } from "@t3tools/shared/relayJwt"; import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -325,6 +326,9 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { const projectId = "project-1" as ProjectId; const activeThreadId = "thread-active" as ThreadId; const idleThreadId = "thread-idle" as ThreadId; + const oldCompletedId = "thread-old-completed" as ThreadId; + const newCompletedId = "thread-new-completed" as ThreadId; + const freshMessageId = "thread-fresh-message" as ThreadId; const baseThread = { projectId, @@ -351,6 +355,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { expect( AgentAwarenessRelay.resolveAgentAwarenessRelayActiveThreadIds({ environmentId, + startedAt: Date.parse(now), projects: [ { id: projectId, @@ -374,6 +379,44 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { ...baseThread, id: idleThreadId, }, + { + ...baseThread, + id: oldCompletedId, + latestTurn: { + turnId: "turn-old" as TurnId, + state: "completed", + requestedAt: "2026-05-24T00:00:00.000Z", + startedAt: "2026-05-24T00:00:00.000Z", + completedAt: "2026-05-24T00:01:00.000Z", + assistantMessageId: null, + }, + }, + { + ...baseThread, + id: newCompletedId, + latestTurn: { + turnId: "turn-new" as TurnId, + state: "completed", + requestedAt: "2026-05-25T00:00:01.000Z", + startedAt: "2026-05-25T00:00:01.000Z", + completedAt: "2026-05-25T00:00:02.000Z", + assistantMessageId: null, + }, + }, + { + ...baseThread, + id: freshMessageId, + latestUserMessageAt: "2026-05-25T00:00:01.000Z", + session: { + threadId: freshMessageId, + status: "ready", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-05-25T00:00:02.000Z", + }, + }, { ...baseThread, id: "thread-missing-project" as ThreadId, @@ -389,7 +432,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }, ], }), - ).toEqual([activeThreadId]); + ).toEqual([activeThreadId, newCompletedId]); }); it.effect("signs the activity publish JWT and rejects tampering", () => @@ -797,4 +840,138 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }), ), ); + + it.effect("does not alert for historical completions after startup", () => + Effect.scoped( + Effect.gen(function* () { + const secrets = makeMemorySecretStore(); + const now = yield* DateTime.now; + const old = DateTime.formatIso(DateTime.add(now, { days: -7 })); + const threadId = "thread-old" as ThreadId; + const projectId = "project-1" as ProjectId; + const environmentId = "env-1" as EnvironmentId; + const project = { + id: projectId, + title: "T3 Code", + workspaceRoot: "/workspace", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: old, + updatedAt: old, + } satisfies OrchestrationProjectShell; + const completedTurn = { + turnId: "turn-1" as TurnId, + state: "completed", + requestedAt: old, + startedAt: old, + completedAt: old, + assistantMessageId: null, + } as const; + const completedThread = { + id: threadId, + projectId, + title: "Old task", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests: [], + latestTurn: completedTurn, + createdAt: old, + updatedAt: old, + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: old, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + } satisfies OrchestrationThreadShell; + let currentThread: OrchestrationThreadShell | null = completedThread; + let publishes = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + publishes += 1; + return Promise.resolve(Response.json({ ok: true, deliveries: [] })); + }) as unknown as typeof fetch; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + globalThis.fetch = originalFetch; + }), + ); + yield* secrets.setString(RELAY_URL_SECRET, "https://relay.example.test"); + yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential"); + yield* secrets.setString(PUBLISH_AGENT_ACTIVITY_SECRET, "true"); + + const layer = Layer.mergeAll( + Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store), + Layer.succeed(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Effect.succeed(environmentId), + getDescriptor: Effect.die("unused descriptor"), + }), + Layer.succeed(OrchestrationEngineService, {} as OrchestrationEngineShape), + Layer.succeed(ProjectionSnapshotQuery, { + getThreadShellById: () => Effect.sync(() => Option.fromNullishOr(currentThread)), + getProjectShellById: () => Effect.succeed(Option.some(project)), + } as unknown as ProjectionSnapshotQueryShape), + ); + + yield* Effect.gen(function* () { + const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; + yield* relay.publishThread(threadId); + expect(publishes).toBe(0); + + currentThread = { + ...completedThread, + latestTurn: null, + latestUserMessageAt: DateTime.formatIso(DateTime.add(now, { seconds: 1 })), + session: { + threadId, + status: "ready", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: DateTime.formatIso(DateTime.add(now, { seconds: 2 })), + }, + }; + expect( + AgentAwarenessRelay.resolveAgentAwarenessRelayPublishSnapshot({ + environmentId, + threadId, + thread: Option.some(currentThread), + project: Option.some(project), + }).state?.phase, + ).toBe("completed"); + yield* relay.publishThread(threadId); + expect(publishes).toBe(0); + + currentThread = { + ...completedThread, + session: { + threadId, + status: "error", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: "old failure", + updatedAt: old, + }, + }; + yield* relay.publishThread(threadId); + expect(publishes).toBe(0); + }).pipe( + Effect.provide( + AgentAwarenessRelay.layer.pipe( + Layer.provide(layer), + Layer.provideMerge(NodeServices.layer), + ), + ), + ); + }), + ), + ); }); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 052a67959ad1..29d2abecebb7 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -267,8 +267,13 @@ export function resolveAgentAwarenessRelayPublishSnapshot(input: { }; } +function terminalWorkSinceStart(thread: OrchestrationThreadShell, startedAt: number): boolean { + return Date.parse(thread.latestTurn?.completedAt ?? "") > startedAt; +} + export function resolveAgentAwarenessRelayActiveThreadIds(input: { readonly environmentId: EnvironmentId; + readonly startedAt: number; readonly projects: ReadonlyArray>; readonly threads: ReadonlyArray; }): ReadonlyArray { @@ -279,12 +284,16 @@ export function resolveAgentAwarenessRelayActiveThreadIds(input: { if (!project) { return false; } + const state = projectThreadAwareness({ + environmentId: input.environmentId, + project, + thread, + }); return ( - projectThreadAwareness({ - environmentId: input.environmentId, - project, - thread, - }) !== null + state !== null && + (state.phase !== "completed" && state.phase !== "failed" + ? true + : terminalWorkSinceStart(thread, input.startedAt)) ); }) .map((thread) => thread.id); @@ -298,6 +307,7 @@ export const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; const crypto = yield* Crypto.Crypto; const cloudLinkKeyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); + const startedAt = (yield* DateTime.now).epochMilliseconds; const activeSnapshotPublishedRef = yield* Ref.make(false); const publishedStateByThreadRef = yield* Ref.make(new Map()); @@ -417,6 +427,14 @@ export const make = Effect.gen(function* () { }); const publishIdentity = agentAwarenessPublishIdentity(snapshot.state); const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef); + if ( + (snapshot.state?.phase === "completed" || snapshot.state?.phase === "failed") && + !publishedStateByThread.has(threadId) + ) { + // Startup has no publish history. Only work from this server process may + // produce an initial terminal alert; historical threads remain quiet. + if (Option.isNone(thread) || !terminalWorkSinceStart(thread.value, startedAt)) return; + } if (publishedStateByThread.get(threadId) === publishIdentity) { // The projection is back at (or never left) the last published state, so // any pending deferred confirmation is moot. Leaving the deadline in @@ -495,7 +513,11 @@ export const make = Effect.gen(function* () { }); yield* Ref.update(publishedStateByThreadRef, (publishedStates) => { const nextPublishedStates = new Map(publishedStates); - nextPublishedStates.set(threadId, publishIdentity); + if (snapshot.state === null) { + nextPublishedStates.delete(threadId); + } else { + nextPublishedStates.set(threadId, publishIdentity); + } return nextPublishedStates; }); }); @@ -529,6 +551,7 @@ export const make = Effect.gen(function* () { const snapshot = yield* snapshotQuery.getShellSnapshot(); const activeThreadIds = resolveAgentAwarenessRelayActiveThreadIds({ environmentId, + startedAt, projects: snapshot.projects, threads: snapshot.threads, }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index fca92a60e47f..773df3f1d7f0 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -135,6 +135,7 @@ import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationE import { OrchestrationEventStore } from "./persistence/Services/OrchestrationEventStore.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts"; import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; @@ -145,7 +146,10 @@ import { import type { ProviderInstance } from "./provider/ProviderDriver.ts"; import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import { ProviderAdapterRequestError } from "./provider/Errors.ts"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + ProviderVersionCache, +} from "./provider/providerMaintenance.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; @@ -520,6 +524,7 @@ const buildAppUnderTest = (options?: { keybindings?: Partial; environmentTheme?: Partial; providerRegistry?: Partial; + modelManifest?: Partial; usageLimitSources?: Partial; providerService?: Partial; providerAuth?: Partial; @@ -771,13 +776,14 @@ const buildAppUnderTest = (options?: { drainThrough: () => Effect.void, ...options?.layers?.threadDeletionReactor, }); - const threadBootstrapLayer = ThreadBootstrapLive.pipe( - Layer.provide(orchestrationEngineLayer), - Layer.provide(gitWorkflowLayer), - Layer.provide(projectSetupScriptRunnerLayer), - Layer.provide(vcsStatusBroadcasterLayer), - Layer.provide(threadDeletionReactorLayer), - ); + const serverSettingsLayer = Layer.mock(ServerSettings.ServerSettingsService)({ + start: Effect.void, + ready: Effect.void, + getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS), + updateSettings: () => Effect.succeed(DEFAULT_SERVER_SETTINGS), + streamChanges: Stream.empty, + ...options?.layers?.serverSettings, + }); const resourceTelemetryLayer = ResourceTelemetry.layer.pipe( Layer.provide( Layer.mergeAll( @@ -842,6 +848,15 @@ const buildAppUnderTest = (options?: { } as never), ...options?.layers?.providerAdapterRegistry, }); + const threadBootstrapLayer = ThreadBootstrapLive.pipe( + Layer.provide(serverSettingsLayer), + Layer.provide(projectionSnapshotQueryLayer), + Layer.provide(orchestrationEngineLayer), + Layer.provide(gitWorkflowLayer), + Layer.provide(projectSetupScriptRunnerLayer), + Layer.provide(vcsStatusBroadcasterLayer), + Layer.provide(threadDeletionReactorLayer), + ); const mcpToolkitDependenciesLayer = Layer.mergeAll( orchestrationEngineLayer, projectionSnapshotQueryLayer, @@ -887,6 +902,10 @@ const buildAppUnderTest = (options?: { ), Layer.provide( Layer.mergeAll( + Layer.mock(ModelManifest.ModelManifest)({ + forceRefresh: Effect.succeed(ModelManifest.BUNDLED_MODEL_MANIFEST), + ...options?.layers?.modelManifest, + }), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), refresh: () => Effect.succeed([]), @@ -929,16 +948,7 @@ const buildAppUnderTest = (options?: { }), ), ), - Layer.provide( - Layer.mock(ServerSettings.ServerSettingsService)({ - start: Effect.void, - ready: Effect.void, - getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS), - updateSettings: () => Effect.succeed(DEFAULT_SERVER_SETTINGS), - streamChanges: Stream.empty, - ...options?.layers?.serverSettings, - }), - ), + Layer.provide(serverSettingsLayer), Layer.provide( Layer.mergeAll( Layer.mock(ExternalLauncher.ExternalLauncher)({ @@ -6416,6 +6426,99 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + for (const mode of ["all", "targeted", "background"] as const) { + it.effect(`provider refresh invalidates T3 caches before probing (${mode})`, () => { + const driver = ProviderDriverKind.make("codex"); + const instanceIds = [ProviderInstanceId.make("codex"), ProviderInstanceId.make("codex_work")]; + const packageNames = ["@example/personal", "@example/work"]; + const versionCache = new Map( + packageNames.map((name) => [ + name, + { + expiresAt: Number.MAX_SAFE_INTEGER, + version: "1.0.0", + }, + ]), + ); + const invalidated: string[] = []; + const freshMaintenance: string[] = []; + let manifestRefreshed = false; + let probed = false; + const instances = instanceIds.map( + (instanceId, index) => + ({ + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: instanceId }, + displayName: undefined, + enabled: true, + invalidateCaches: Effect.sync(() => { + invalidated.push(instanceId); + }), + snapshot: { + resolveMaintenance: (options) => + Effect.sync(() => { + assert.isTrue(options?.fresh); + freshMaintenance.push(instanceId); + return makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: packageNames[index]!, + }); + }), + getSnapshot: Effect.never, + refresh: Effect.never, + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }) satisfies ProviderInstance, + ); + const expected = + mode === "background" ? [] : mode === "targeted" ? [instanceIds[1]!] : instanceIds; + const probe = Effect.sync(() => { + probed = true; + assert.equal(manifestRefreshed, mode !== "background"); + assert.deepEqual(invalidated.toSorted(), expected.toSorted()); + assert.deepEqual(freshMaintenance.toSorted(), expected.toSorted()); + for (let index = 0; index < instanceIds.length; index++) { + assert.equal( + versionCache.has(packageNames[index]!), + !expected.includes(instanceIds[index]!), + ); + } + return []; + }); + return Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + modelManifest: { + forceRefresh: Effect.sync(() => { + manifestRefreshed = true; + return ModelManifest.BUNDLED_MODEL_MANIFEST; + }), + }, + providerInstanceRegistry: { listInstances: Effect.succeed(instances) }, + providerRegistry: { refresh: () => probe, refreshInstance: () => probe }, + }, + }); + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.serverRefreshProviders]({ + ...(mode === "targeted" ? { instanceId: instanceIds[1]! } : {}), + ...(mode !== "background" ? { refreshModels: true } : {}), + }), + ), + ); + assert.isTrue(probed); + }).pipe( + Effect.provideService(ProviderVersionCache, versionCache), + Effect.provide(NodeHttpServer.layerTest), + ); + }); + } + it.effect("serves config on reconnect without starting provider probes", () => Effect.gen(function* () { const refresh = vi.fn(() => Effect.never); diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index 88710744179c..d7b6397d2b09 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -8,8 +8,9 @@ import * as Tracer from "effect/Tracer"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; -import * as ServerConfig from "./config.ts"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; + +import * as ServerConfig from "./config.ts"; import { ServerLoggerLive } from "./serverLogger.ts"; interface ExportedRequest { diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 2aaabeed3d7a..f450eef0dc24 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -30,6 +30,7 @@ import { type VcsRemoveWorktreeInput, type VcsStatusInput, type VcsStatusResult, + type WorktreeSubmodules, } from "@t3tools/contracts"; import { makeGitVcsDriverCore, @@ -128,6 +129,10 @@ export interface CreateWorktreeProgress { total: number; }) => Effect.Effect; readonly onSubmodulesStarted?: () => Effect.Effect; + /** Fires when `.gitmodules` exists but the resolved submodule mode is `"none"`. */ + readonly onSubmodulesDisabled?: (input: { + source: "settings" | "t3.json"; + }) => Effect.Effect; readonly onSubmoduleLine?: (line: string) => Effect.Effect; readonly onSubmodulesFinished?: (input: { ok: boolean; @@ -137,6 +142,12 @@ export interface CreateWorktreeProgress { export interface CreateWorktreeOptions { readonly progress?: CreateWorktreeProgress; + /** + * The project-over-environment `worktreeSubmodules` setting. Null (or + * omitted, for callers without settings access) defers to the checkout's + * own t3.json. + */ + readonly submodules?: WorktreeSubmodules | null; } export interface GitCommitProgress { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index f798abba74b6..98dec86522de 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -26,6 +26,7 @@ import { GitCommandError, ReviewDiffPreviewInput, type ReviewDiffFileContentsInput, + type WorktreeSubmodules, } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; import { gitCommandDuration } from "../observability/Metrics.ts"; @@ -2353,6 +2354,100 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("resolves the submodule mode from the option, then t3.json", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + + const previousAllowedProtocol = process.env.GIT_ALLOW_PROTOCOL; + process.env.GIT_ALLOW_PROTOCOL = "file"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previousAllowedProtocol === undefined) { + delete process.env.GIT_ALLOW_PROTOCOL; + } else { + process.env.GIT_ALLOW_PROTOCOL = previousAllowedProtocol; + } + }), + ); + + // inner -> nested, so a recursive init populates nested/NESTED.md and + // a top-level init leaves it empty. + const nestedRepo = yield* makeTmpDir("git-nested-"); + yield* initRepoWithCommit(nestedRepo); + yield* writeTextFile(nestedRepo, "NESTED.md", "# nested\n"); + yield* git(nestedRepo, ["add", "."]); + yield* git(nestedRepo, ["commit", "-m", "nested"]); + const innerRepo = yield* makeTmpDir("git-inner-"); + yield* initRepoWithCommit(innerRepo); + yield* writeTextFile(innerRepo, "INNER.md", "# inner\n"); + yield* git(innerRepo, ["submodule", "add", nestedRepo, "nested"]); + yield* git(innerRepo, ["add", "."]); + yield* git(innerRepo, ["commit", "-m", "inner"]); + + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["submodule", "add", innerRepo, "inner"]); + yield* git(cwd, ["commit", "-m", "add submodule"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + const worktreesDir = yield* makeTmpDir("git-worktrees-"); + + const createWithMode = Effect.fn(function* ( + fileMode: WorktreeSubmodules, + branch: string, + submodules: WorktreeSubmodules | null = null, + ) { + yield* writeTextFile(cwd, "t3.json", `{ "worktreeSubmodules": "${fileMode}" }`); + yield* git(cwd, ["add", "t3.json"]); + // Consecutive cases may reuse a file mode to test the option alone. + yield* git(cwd, ["commit", "--allow-empty", "-m", `submodules: ${fileMode}`]); + const worktreePath = pathService.join(worktreesDir, branch); + const disabled = yield* Ref.make<"settings" | "t3.json" | false>(false); + yield* driver.createWorktree( + { cwd, path: worktreePath, refName: initialBranch, newRefName: branch }, + { + submodules, + progress: { onSubmodulesDisabled: ({ source }) => Ref.set(disabled, source) }, + }, + ); + return { + disabled: yield* Ref.get(disabled), + inner: yield* fileSystem.exists(pathService.join(worktreePath, "inner", "INNER.md")), + nested: yield* fileSystem.exists( + pathService.join(worktreePath, "inner", "nested", "NESTED.md"), + ), + }; + }); + + assert.deepEqual(yield* createWithMode("recursive", "recursive"), { + disabled: false, + inner: true, + nested: true, + }); + assert.deepEqual(yield* createWithMode("top-level", "top-level"), { + disabled: false, + inner: true, + nested: false, + }); + // A resolved setting outranks the file in both directions. + assert.deepEqual(yield* createWithMode("recursive", "setting-none", "none"), { + disabled: "settings", + inner: false, + nested: false, + }); + assert.deepEqual(yield* createWithMode("none", "setting-wins", "top-level"), { + disabled: false, + inner: true, + nested: false, + }); + assert.deepEqual(yield* createWithMode("none", "none"), { + disabled: "t3.json", + inner: false, + nested: false, + }); + }), + ); + it.effect("reports checkout progress during parallel worktree creation", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 56cff0cb9a58..5fbae919c258 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -20,6 +20,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, + T3_PROJECT_FILE_NAME, type ReviewDiffFileContentsInput, type ReviewDiffPreviewInput, type ReviewDiffFileStat, @@ -30,6 +31,8 @@ import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3 import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; +import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import { @@ -2282,13 +2285,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const readRangeContext: GitVcsDriver.GitVcsDriver["Service"]["readRangeContext"] = Effect.fn( "readRangeContext", )(function* (cwd, baseRef) { - const range = `${baseRef}..HEAD`; + const commitRange = `${baseRef}..HEAD`; + // PR diffs start at the common ancestor when the base branch has advanced. + const diffRange = `${baseRef}...HEAD`; const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( [ runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.log", cwd, - ["log", "--oneline", range], + ["log", "--oneline", commitRange], { maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2297,7 +2302,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.diffStat", cwd, - ["diff", "--stat", range], + ["diff", "--stat", diffRange], { maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2306,7 +2311,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.diffPatch", cwd, - ["diff", "--no-ext-diff", "--patch", "--minimal", range], + ["diff", "--no-ext-diff", "--patch", "--minimal", diffRange], { maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -3092,11 +3097,37 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* // skills, tooling or source in one gets a worktree that is quietly missing // them. Best-effort: the objects are usually already in the parent's // `.git/modules`, but a first-ever clone needs the network, and failing to - // populate a submodule must not roll back the caller's thread. + // populate a submodule must not roll back the caller's thread. Repos with + // hundreds of nested submodules opt out or stop at the top level; the + // caller resolves that from settings, or the checkout's t3.json decides. const hasSubmodules = yield* fileSystem .exists(path.join(worktreePath, ".gitmodules")) .pipe(Effect.orElseSucceed(() => false)); - if (hasSubmodules) { + const submoduleMode = !hasSubmodules + ? { value: "none" as const, source: "environment" as const } + : resolveProjectFileBackedSetting( + "worktreeSubmodules", + options?.submodules ?? null, + options?.submodules != null + ? null + : yield* fileSystem.readFileString(path.join(worktreePath, T3_PROJECT_FILE_NAME)).pipe( + Effect.flatMap((contents) => { + const file = parseT3ProjectFile(contents); + return file === null + ? Effect.logWarning("t3.json is invalid; initializing submodules recursively", { + worktreePath, + }).pipe(Effect.as(null)) + : Effect.succeed(file); + }), + Effect.orElseSucceed(() => null), + ), + ); + if (hasSubmodules && submoduleMode.value === "none" && progress?.onSubmodulesDisabled) { + yield* progress.onSubmodulesDisabled({ + source: submoduleMode.source === "t3.json" ? "t3.json" : "settings", + }); + } + if (submoduleMode.value !== "none") { if (progress?.onSubmodulesStarted) { yield* progress.onSubmodulesStarted(); } @@ -3104,7 +3135,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* runGit( "GitVcsDriver.createWorktree.updateSubmodules", worktreePath, - ["submodule", "update", "--init", "--recursive"], + submoduleMode.value === "recursive" + ? ["submodule", "update", "--init", "--recursive"] + : ["submodule", "update", "--init"], onSubmoduleLine ? { env: { LC_ALL: "C" }, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index b41b97d9deec..738736820fac 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -102,6 +102,8 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ModelManifest from "./provider/ModelManifest.ts"; +import * as ProviderMaintenance from "./provider/providerMaintenance.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; @@ -139,7 +141,7 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; -import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; +import { requiredScopeForRpcMethod, requiredScopeForDeviceList } from "./auth/RpcAuthorization.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; @@ -528,6 +530,8 @@ const makeWsRpcLayer = ( yield* Effect.context>>(); const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; + const modelManifest = yield* ModelManifest.ModelManifest; + const providerVersionCache = yield* ProviderMaintenance.ProviderVersionCache; const providerService = yield* ProviderService.ProviderService; const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; @@ -1530,6 +1534,28 @@ const makeWsRpcLayer = ( observeRpcEffect( WS_METHODS.serverRefreshProviders, Effect.gen(function* () { + // Only explicit catalog refreshes bypass T3's caches. Workspace + // discovery and background status checks retain their timers. + if (input.refreshModels) { + yield* modelManifest.forceRefresh; + const instances = yield* providerInstances.listInstances; + yield* Effect.forEach( + instances.filter( + (instance) => + input.instanceId === undefined || input.instanceId === instance.instanceId, + ), + (instance) => + Effect.gen(function* () { + yield* instance.invalidateCaches ?? Effect.void; + const maintenance = yield* instance.snapshot.resolveMaintenance({ + fresh: true, + }); + if (maintenance.packageName) + providerVersionCache.delete(maintenance.packageName); + }), + { concurrency: "unbounded", discard: true }, + ); + } // An untargeted refresh is "re-read everything's status", which // includes quota from configured usage-limit sources. Awaited, // not forked: the RPC scope closes on return and would @@ -1640,6 +1666,15 @@ const makeWsRpcLayer = ( providerAuth.start(input, currentSessionId), { "rpc.aggregate": "provider" }, ), + [WS_METHODS.providerAuthRespond]: (input) => + observeRpcEffect( + WS_METHODS.providerAuthRespond, + providerAuth.respond(input, currentSessionId), + { + "rpc.aggregate": "provider", + instanceId: input.instanceId, + }, + ), [WS_METHODS.providerAuthComplete]: (input) => observeRpcEffect( WS_METHODS.providerAuthComplete, @@ -2664,10 +2699,23 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.deviceTestHost, deviceService.testHost(input), { "rpc.aggregate": "device", }), - [WS_METHODS.deviceList]: (_input) => - observeRpcEffect(WS_METHODS.deviceList, deviceService.list, { - "rpc.aggregate": "device", - }), + [WS_METHODS.deviceList]: (input) => + observeRpcEffect( + WS_METHODS.deviceList, + input.inspectOnly && !input.updateTool + ? deviceService.inspect + : authorizeEffect( + requiredScopeForDeviceList(input), + input.updateTool + ? deviceService.updateTool(input.updateTool) + : input.retryHostId + ? deviceService.retryHost(input.retryHostId) + : deviceService.list, + ), + { + "rpc.aggregate": "device", + }, + ), [WS_METHODS.deviceOpen]: (input) => observeRpcEffect(WS_METHODS.deviceOpen, deviceService.open(input), { "rpc.aggregate": "device", diff --git a/apps/web/package.json b/apps/web/package.json index e6980cf778ea..ec7334e1fd86 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,6 +32,7 @@ "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", "@tiptap/core": "^3.31.3", + "@tiptap/extension-code": "^3.31.3", "@tiptap/extension-task-item": "^3.31.3", "@tiptap/extension-task-list": "^3.31.3", "@tiptap/pm": "^3.31.3", diff --git a/apps/web/src/browser/BrowserDeviceToolbar.tsx b/apps/web/src/browser/BrowserDeviceToolbar.tsx index e8ad80b597ba..a8c9027f8a16 100644 --- a/apps/web/src/browser/BrowserDeviceToolbar.tsx +++ b/apps/web/src/browser/BrowserDeviceToolbar.tsx @@ -189,15 +189,12 @@ export function BrowserDeviceToolbar({ = 440 ? "w-36" : "w-24", - )} + className={cn("shrink-0 justify-between", width >= 440 ? "w-36" : "w-24")} aria-label="Browser device preset" > - + Responsive Standard @@ -227,7 +224,8 @@ export function BrowserDeviceToolbar({ nativeInput type="number" inputMode="numeric" - size="sm" + size="compact" + font="mono" min={PREVIEW_VIEWPORT_MIN_DIMENSION} max={PREVIEW_VIEWPORT_MAX_DIMENSION} value={presentedSize.width} @@ -244,17 +242,15 @@ export function BrowserDeviceToolbar({ onChange={(event) => updateCustomDimension("width", event.target.value)} aria-label="Viewport width" aria-invalid={!customValid} - className={cn( - "h-6 rounded-md text-center tabular-nums [&_[data-slot=input]]:h-full [&_[data-slot=input]]:px-1 [&_[data-slot=input]]:text-xs [&_[data-slot=input]]:leading-none [&_[data-slot=input]::-webkit-inner-spin-button]:appearance-none [&_[data-slot=input]]:[appearance:textfield]", - width >= 360 ? "w-14" : "w-11", - )} + className={width >= 360 ? "w-14" : "w-13"} /> × updateCustomDimension("height", event.target.value)} aria-label="Viewport height" aria-invalid={!customValid} - className={cn( - "h-6 rounded-md text-center tabular-nums [&_[data-slot=input]]:h-full [&_[data-slot=input]]:px-1 [&_[data-slot=input]]:text-xs [&_[data-slot=input]]:leading-none [&_[data-slot=input]::-webkit-inner-spin-button]:appearance-none [&_[data-slot=input]]:[appearance:textfield]", - width >= 360 ? "w-14" : "w-11", - )} + className={width >= 360 ? "w-14" : "w-13"} /> @@ -282,14 +275,13 @@ export function BrowserDeviceToolbar({ event.preventDefault()} onClick={toggleAspectRatio} @@ -316,19 +308,21 @@ export function BrowserDeviceToolbar({ > - + {/* Sticky backing so scrolled controls do not show through the close action. */} + + +
); } diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 42d5bcfb35b8..6c303afb27e9 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -156,9 +156,18 @@ export function HostedBrowserWebview(props: { } }, recovery.delayMs); }; + // A click inside the guest only reaches this document as a webview focus + // event, so open menus and popovers never see the outside press that + // would dismiss them. Replay it as a pointerdown on the webview itself. + const dismissHostPopups = () => { + webview.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" }), + ); + }; webview.addEventListener("did-attach", register); webview.addEventListener("dom-ready", register); webview.addEventListener("render-process-gone", recoverGuest); + webview.addEventListener("focus", dismissHostPopups); register(); return () => { disposed = true; @@ -166,6 +175,7 @@ export function HostedBrowserWebview(props: { webview.removeEventListener("did-attach", register); webview.removeEventListener("dom-ready", register); webview.removeEventListener("render-process-gone", recoverGuest); + webview.removeEventListener("focus", dismissHostPopups); }; }, [clientSettingsHydrated, config, initialSrc, runtimeTabId, webviewGeneration]); diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index 5ea10c2b05d5..0bbe4bd7bf18 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -8,6 +8,8 @@ import { previewBridge } from "~/components/preview/previewBridge"; import { ensureClientSettingsHydrated, getClientSettings } from "~/hooks/useSettings"; import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { createRecordingCompositor } from "./recordingCompositor"; + import { acquireBrowserSurfaceActivity } from "./browserSurfaceStore"; export class BrowserRecordingUnavailableError extends Schema.TaggedError()( @@ -123,6 +125,7 @@ interface ActiveRecording { releaseSurfaceActivity: (() => void) | null; stream: MediaStream | null; recorder: MediaRecorder | null; + compositor: Awaited>; savedBlob?: Blob; uploadPromise?: Promise; lifecycle: BrowserRecordingLifecycle; @@ -381,6 +384,8 @@ const captureTabMediaStreamWithTimeout = async ( }; const clearActiveRecording = (recording: ActiveRecording): void => { + recording.compositor?.dispose(); + recording.compositor = null; recording.releaseSurfaceActivity?.(); recording.releaseSurfaceActivity = null; if (activeRecordings.get(recording.tabId) !== recording) return; @@ -525,6 +530,7 @@ export async function startBrowserRecording( releaseSurfaceActivity, stream: null, recorder: null, + compositor: null, lifecycle: startingLifecycle, }; activeRecordings.set(tabId, recording); @@ -534,7 +540,8 @@ export async function startBrowserRecording( clearActiveRecording(recording); throw cause; }); - const frameRate = getClientSettings().browserRecordingFrameRate; + const settings = getClientSettings(); + const frameRate = settings.browserRecordingFrameRate; await waitForBrowserRecordingPaint(); const throwIfStartupCancelled = async (): Promise => { // Once a grant starts, a stop lets startup finish so the caller receives an artifact. @@ -614,7 +621,19 @@ export async function startBrowserRecording( let recorder: MediaRecorder; try { - recorder = createMediaRecorder(stream); + recording.compositor = await createRecordingCompositor( + stream, + { + showKeyPresses: settings.browserRecordingShowKeyPresses, + showMousePresses: settings.browserRecordingShowMousePresses, + frameRate, + }, + (listener) => + bridge.recording.onInput((event) => { + if (event.tabId === tabId) listener(event.input); + }), + ); + recorder = createMediaRecorder(recording.compositor?.stream ?? stream); recording.recorder = recorder; recorder.addEventListener("dataavailable", (event) => { if (event.data.size > 0) chunks.push(event.data); @@ -694,6 +713,8 @@ const finalizeBrowserRecording = async ( cause, }); } + recording.compositor?.dispose(); + recording.compositor = null; // Encoding has flushed; release native capture before materializing and saving the file. stopMediaStream(recording.stream); recording.stream = null; diff --git a/apps/web/src/browser/recordingCompositor.test.ts b/apps/web/src/browser/recordingCompositor.test.ts new file mode 100644 index 000000000000..d10e69d24663 --- /dev/null +++ b/apps/web/src/browser/recordingCompositor.test.ts @@ -0,0 +1,179 @@ +import type { DesktopPreviewRecordingInput } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { createRecordingCompositor, RecordingDecorations } from "./recordingCompositor"; + +const primaryColor = "oklch(0.65 0.2 310)"; +vi.mock("./annotationTheme", () => ({ + readPreviewAnnotationTheme: () => ({ primary: "oklch(0.65 0.2 310)" }), +})); + +const options = { showKeyPresses: true, showMousePresses: true, frameRate: 30 }; +const pointer = ( + phase: "move" | "down" | "up" | "click", + x = 100, +): DesktopPreviewRecordingInput => ({ + type: "pointer", + phase, + x, + y: 80, + width: 800, + height: 600, +}); +const context = () => ({ + save: vi.fn(), + restore: vi.fn(), + beginPath: vi.fn(), + fill: vi.fn(), + stroke: vi.fn(), + ellipse: vi.fn(), + roundRect: vi.fn(), + fillText: vi.fn(), + drawImage: vi.fn(), + measureText: () => ({ width: 40 }), + globalAlpha: 1, + strokeStyle: "", + fillStyle: "", +}); + +describe("recording decorations", () => { + it("keeps rings aligned through dragging and stops following the cursor after release", () => { + const decorations = new RecordingDecorations(options, primaryColor); + const ctx = context(); + decorations.apply(pointer("down"), 0); + decorations.apply(pointer("move", 120), 10); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 10); + expect(ctx.ellipse.mock.calls[0]?.slice(0, 4)).toEqual([240, 160, 40, 40]); + expect(ctx.strokeStyle).toBe(primaryColor); + expect(ctx.fillStyle).toBe(primaryColor); + expect(decorations.nextRedraw(10)).toBeNull(); + decorations.apply(pointer("up", 130), 20); + decorations.apply(pointer("move", 300), 30); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 320); + expect(ctx.ellipse.mock.calls[1]?.slice(0, 4)).toEqual([260, 160, 50, 50]); + ctx.ellipse.mockClear(); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 620); + expect(ctx.ellipse).not.toHaveBeenCalled(); + expect(decorations.nextRedraw(620)).toBeNull(); + }); + + it("pulses agent clicks and clears decorations on blur or navigation", () => { + const decorations = new RecordingDecorations(options, primaryColor); + const ctx = context(); + decorations.apply(pointer("click"), 0); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 800, 600, 300); + expect(ctx.ellipse).toHaveBeenCalledOnce(); + decorations.apply({ type: "clear" }, 301); + ctx.ellipse.mockClear(); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 800, 600, 302); + expect(ctx.ellipse).not.toHaveBeenCalled(); + expect(decorations.nextRedraw(302)).toBeNull(); + }); + + it("holds shortcut badges until release, then expires them even on a static page", () => { + const decorations = new RecordingDecorations(options, primaryColor); + const ctx = context(); + const key = { type: "key" as const, label: "⌘C", held: true, width: 800 }; + decorations.apply(key, 0); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 5000); + expect(ctx.fillText.mock.calls[0]?.slice(0, 3)).toEqual(["⌘C", 800, 1098]); + decorations.apply({ ...key, held: false }, 5000); + expect(decorations.nextRedraw(5500)).toBe(400); + ctx.fillText.mockClear(); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 5900); + expect(ctx.fillText).not.toHaveBeenCalled(); + }); + + it("removes the previous key badge on password focus", () => { + const decorations = new RecordingDecorations(options, primaryColor); + const ctx = context(); + decorations.apply({ type: "key", label: "A", held: true, width: 800 }, 0); + decorations.apply({ type: "key", label: null, held: true, width: 800 }, 1); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 800, 600, 2); + expect(ctx.fillText).not.toHaveBeenCalled(); + }); + + it("honors independent opt-in flags", () => { + const decorations = new RecordingDecorations( + { ...options, showMousePresses: false }, + primaryColor, + ); + const ctx = context(); + decorations.apply(pointer("down"), 0); + decorations.apply({ type: "key", label: "⌘C", held: true, width: 800 }, 0); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 800, 600, 1); + expect(ctx.ellipse).not.toHaveBeenCalled(); + expect(ctx.fillText).toHaveBeenCalledOnce(); + }); +}); + +describe("detached recording compositor", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("keeps native capture when both decorations are off", async () => { + vi.stubGlobal("document", { + createElement: () => { + throw new Error("must not allocate"); + }, + }); + expect( + await createRecordingCompositor( + {} as MediaStream, + { + ...options, + showKeyPresses: false, + showMousePresses: false, + }, + () => { + throw new Error("must not subscribe"); + }, + ), + ).toBeNull(); + }); + + it.each([false, true])( + "releases the detached output on disposal or playback failure (%s)", + async (failPlayback) => { + const ctx = context(); + const stop = vi.fn(); + const unsubscribe = vi.fn(); + const cancelFrame = vi.fn(); + const source = { + getVideoTracks: () => [{ getSettings: () => ({ width: 800, height: 600 }) }], + } as unknown as MediaStream; + const stream = { getTracks: () => [{ stop }] } as unknown as MediaStream; + const canvas = { width: 0, height: 0, getContext: () => ctx, captureStream: () => stream }; + const video = { + muted: false, + playsInline: false, + srcObject: null as MediaStream | null, + readyState: 2, + videoWidth: 800, + videoHeight: 600, + pause: vi.fn(), + play: async () => { + if (failPlayback) throw new Error("play failed"); + }, + requestVideoFrameCallback: () => 1, + cancelVideoFrameCallback: cancelFrame, + }; + vi.stubGlobal("document", { + createElement: (tag: string) => (tag === "canvas" ? canvas : video), + }); + vi.stubGlobal("window", { clearTimeout: vi.fn(), setTimeout: vi.fn() }); + const compositor = createRecordingCompositor(source, options, () => unsubscribe); + if (failPlayback) await expect(compositor).rejects.toThrow("play failed"); + else { + const result = await compositor; + expect(result?.stream).toBe(stream); + expect(ctx.drawImage).toHaveBeenCalledOnce(); + result?.dispose(); + result?.dispose(); + } + expect(stop).toHaveBeenCalledOnce(); + expect(unsubscribe).toHaveBeenCalledOnce(); + expect(cancelFrame).toHaveBeenCalledWith(1); + expect(video.srcObject).toBeNull(); + }, + ); +}); diff --git a/apps/web/src/browser/recordingCompositor.ts b/apps/web/src/browser/recordingCompositor.ts new file mode 100644 index 000000000000..352154bd9aef --- /dev/null +++ b/apps/web/src/browser/recordingCompositor.ts @@ -0,0 +1,190 @@ +import type { DesktopPreviewRecordingInput } from "@t3tools/contracts"; + +import { readPreviewAnnotationTheme } from "./annotationTheme"; + +interface RecordingDecorationOptions { + readonly showKeyPresses: boolean; + readonly showMousePresses: boolean; + readonly frameRate: number; +} + +/** Decorates a detached canvas; no recording UI is inserted into the preview page. */ +export async function createRecordingCompositor( + source: MediaStream, + options: RecordingDecorationOptions, + subscribe: (listener: (input: DesktopPreviewRecordingInput) => void) => () => void, +) { + if (!options.showKeyPresses && !options.showMousePresses) return null; + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d", { alpha: false }); + if (!context) throw new Error("Recording canvas is unavailable."); + const video = document.createElement("video"); + video.muted = true; + video.playsInline = true; + video.srcObject = source; + const settings = source.getVideoTracks()[0]?.getSettings(); + canvas.width = settings?.width ?? 1920; + canvas.height = settings?.height ?? 1080; + const decorations = new RecordingDecorations(options, readPreviewAnnotationTheme().primary); + let disposed = false; + let frameId: number | undefined; + let timer: number | undefined; + const draw = () => { + if (disposed || video.readyState < 2) return; + const width = video.videoWidth || canvas.width; + const height = video.videoHeight || canvas.height; + if (canvas.width !== width) canvas.width = width; + if (canvas.height !== height) canvas.height = height; + context.drawImage(video, 0, 0, width, height); + const now = performance.now(); + decorations.draw(context, width, height, now); + window.clearTimeout(timer); + const next = decorations.nextRedraw(now); + if (next !== null) timer = window.setTimeout(draw, next); + }; + const frame = () => { + if (disposed) return; + draw(); + frameId = video.requestVideoFrameCallback(frame); + }; + const output = canvas.captureStream(options.frameRate); + let unsubscribe: (() => void) | undefined; + const dispose = () => { + if (disposed) return; + disposed = true; + unsubscribe?.(); + window.clearTimeout(timer); + if (frameId !== undefined) video.cancelVideoFrameCallback(frameId); + video.pause(); + video.srcObject = null; + for (const track of output.getTracks()) track.stop(); + }; + try { + unsubscribe = subscribe((input) => { + decorations.apply(input, performance.now()); + draw(); + }); + frameId = video.requestVideoFrameCallback(frame); + await video.play(); + draw(); + return { stream: output, dispose }; + } catch (error) { + dispose(); + throw error; + } +} + +/** Keeps input timing and coordinates independent of native video frame delivery. */ +export class RecordingDecorations { + private ring: { + x: number; + y: number; + width: number; + height: number; + held: boolean; + releasedAt: number | null; + } | null = null; + private key: { label: string; width: number; expiresAt: number | null } | null = null; + + constructor( + private readonly options: RecordingDecorationOptions, + private readonly primaryColor: string, + ) {} + + apply(input: DesktopPreviewRecordingInput, now: number) { + if (input.type === "clear") { + this.ring = null; + this.key = null; + } else if (input.type === "key" && this.options.showKeyPresses) { + this.key = input.label + ? { label: input.label, width: input.width, expiresAt: input.held ? null : now + 900 } + : null; + } else if (input.type === "pointer" && this.options.showMousePresses) { + if (input.phase === "down" || input.phase === "click") { + this.ring = { + x: input.x, + y: input.y, + width: input.width, + height: input.height, + held: input.phase === "down", + releasedAt: input.phase === "click" ? now : null, + }; + } else if (this.ring?.held) { + this.ring = { + ...this.ring, + x: input.x, + y: input.y, + width: input.width, + height: input.height, + held: input.phase !== "up", + releasedAt: input.phase === "up" ? now : null, + }; + } + } + } + + nextRedraw(now: number): number | null { + if ( + this.ring?.releasedAt !== null && + this.ring?.releasedAt !== undefined && + now < this.ring.releasedAt + 600 + ) { + return 1000 / this.options.frameRate; + } + return this.key?.expiresAt !== null && + this.key?.expiresAt !== undefined && + now < this.key.expiresAt + ? this.key.expiresAt - now + : null; + } + + draw(context: CanvasRenderingContext2D, width: number, height: number, now: number) { + // Guest coordinates are CSS pixels; native frames include zoom and display scale. + const scale = width / (this.key?.width ?? this.ring?.width ?? 1280); + const ring = this.ring; + if (ring && (ring.held || (ring.releasedAt !== null && now < ring.releasedAt + 600))) { + const progress = ring.releasedAt === null ? 0 : Math.min(1, (now - ring.releasedAt) / 600); + context.save(); + const opacity = 0.9 * (1 - progress); + context.strokeStyle = this.primaryColor; + context.fillStyle = this.primaryColor; + context.lineWidth = 2 * scale; + context.beginPath(); + context.ellipse( + (ring.x * width) / ring.width, + (ring.y * height) / ring.height, + ((20 * width) / ring.width) * (1 + progress * 0.5), + ((20 * height) / ring.height) * (1 + progress * 0.5), + 0, + 0, + Math.PI * 2, + ); + context.globalAlpha = opacity * 0.15; + context.fill(); + context.globalAlpha = opacity; + context.stroke(); + context.restore(); + } + const key = this.key; + if (key && (key.expiresAt === null || now < key.expiresAt)) { + context.save(); + context.font = `500 ${26 * scale}px system-ui, sans-serif`; + const badgeWidth = Math.min( + width - 32 * scale, + context.measureText(key.label).width + 36 * scale, + ); + const badgeHeight = 54 * scale; + const left = (width - badgeWidth) / 2; + const top = height - 24 * scale - badgeHeight; + context.fillStyle = "rgba(32,32,34,.86)"; + context.beginPath(); + context.roundRect(left, top, badgeWidth, badgeHeight, 14 * scale); + context.fill(); + context.fillStyle = "white"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(key.label, width / 2, top + badgeHeight / 2, badgeWidth - 24 * scale); + context.restore(); + } + } +} diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 52b45ab8405c..eccf3ef21355 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -7,7 +7,7 @@ import { type CSSProperties, type ReactNode, } from "react"; -import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { isElectron } from "../env"; import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage"; @@ -16,6 +16,13 @@ import { resolveShortcutCommand, shortcutLabelForCommand, } from "../keybindings"; +import { isEditableFocused } from "../lib/editableFocus"; +import { isPreviewFocused } from "../lib/previewFocus"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { isModelPickerOpen } from "../modelPickerVisibility"; +import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { resolveThreadRouteRef } from "../threadRoutes"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; @@ -125,11 +132,12 @@ function SidebarControl() { resolveThreadRouteRef(params), + }); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + if ( + event.target instanceof HTMLElement && + event.target.closest("[data-keybinding-capture]") + ) { + return; + } + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: isTerminalFocused(), + terminalOpen: routeThreadRef + ? selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + routeThreadRef, + ).terminalOpen + : false, + previewFocus: isPreviewFocused(), + previewOpen: routeThreadRef + ? selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) === + "preview" + : false, + editableFocus: isEditableFocused(event.target), + modelPickerOpen: isModelPickerOpen(), + }, + }); + if (command !== "navigation.back" && command !== "navigation.forward") return; + + event.preventDefault(); + event.stopPropagation(); + if (command === "navigation.back") window.history.back(); + else window.history.forward(); + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [keybindings, routeThreadRef]); + + return null; +} + // Settings swaps the thread sidebar out of the tree. Keep the lightweight // project projection subscribed so returning to a draft never renders the // zero-project state while the environment snapshot reconnects. @@ -244,7 +302,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { side="left" collapsible="offcanvas" data-app-sidebar="" - className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground" resizable={{ maxWidth: sidebarMaximumWidth, minWidth: THREAD_SIDEBAR_MIN_WIDTH, @@ -269,6 +326,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { {children} + ); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 0577f5e8dd1f..5b046ed6a686 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,4 +1,10 @@ -import type { EnvironmentId, EnvironmentMachineKind, VcsRef, ProjectId } from "@t3tools/contracts"; +import type { + EnvironmentId, + EnvironmentMachineKind, + VcsRef, + ProjectId, + WorktreeSubmodules, +} from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { toSortableTimestamp } from "../lib/threadSort"; export { @@ -86,6 +92,12 @@ export function resolveEnvModeLabel(mode: EnvMode): string { return mode === "worktree" ? "New worktree" : "Current checkout"; } +export const WORKTREE_SUBMODULES_LABELS: Record = { + recursive: "Recursive", + "top-level": "Top level only", + none: "Skip", +}; + export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): string { return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local"); } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 3330f40a090f..912ee901eec8 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -5,7 +5,6 @@ import { FolderGit2Icon, FolderGitIcon, FolderIcon, - HistoryIcon, ScaleIcon, } from "lucide-react"; import { @@ -41,7 +40,8 @@ import { } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; -import { Button } from "./ui/button"; +import { PreviousWorktreeItemContent } from "./PreviousWorktreeItemContent"; +import { ComposerControl } from "./chat/ComposerControl"; import { Menu, MenuGroup, @@ -54,6 +54,7 @@ import { } from "./ui/menu"; import { Separator } from "./ui/separator"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { MiddleTruncate } from "./ui/middle-truncate"; import { ComposerSurface } from "./chat/ComposerSurface"; import { useComposerMenuProps } from "./chat/composerEventScope"; import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; @@ -104,6 +105,7 @@ interface MobileRunContextSelectorProps { activeWorktreePath: string | null; onEnvModeChange: (mode: EnvMode) => void; previousWorktreeLabel: string | null; + previousWorktreeBranch: string | null; onUsePreviousWorktree: () => void; } @@ -122,6 +124,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ activeWorktreePath, onEnvModeChange, previousWorktreeLabel, + previousWorktreeBranch, onUsePreviousWorktree, }: MobileRunContextSelectorProps) { const composerFloatingLayerProps = useComposerMenuProps(); @@ -205,8 +208,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ return ( } - className="min-w-0 max-w-[48%] flex-initial justify-start font-normal text-muted-foreground/70 text-xs! hover:text-foreground/80" + render={} + className="min-w-0 max-w-[48%] flex-initial justify-start" data-composer-context-control data-composer-shortcut={[ showEnvironmentPicker && !envLocked ? "composer.host" : "", @@ -216,7 +219,12 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ {triggerContent} - + {showEnvironmentPicker && availableEnvironments && onEnvironmentChange ? ( <> @@ -233,6 +241,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ { if (autoEnvironmentLabel) onAutoEnvironment?.(); }} @@ -250,6 +259,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ key={env.environmentId} disabled={envLocked} value={env.environmentId} + closeOnClick > @@ -274,30 +284,25 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ onEnvModeChange(value as EnvMode); }} > - + {activeWorktreePath ? ( ) : ( )} - - {resolveCurrentWorkspaceLabel(activeWorktreePath)} - + - + {resolveEnvModeLabel("worktree")} {previousWorktreeLabel ? ( - - - - {previousWorktreeLabel} - + + ) : null} @@ -629,6 +634,7 @@ export const BranchToolbar = memo(function BranchToolbar({ activeWorktreePath={activeWorktreePath} onEnvModeChange={onEnvModeChange} previousWorktreeLabel={previousWorktreeLabel} + previousWorktreeBranch={previousWorktreeSeed?.branch ?? null} onUsePreviousWorktree={onUsePreviousWorktree} />
@@ -668,6 +674,7 @@ export const BranchToolbar = memo(function BranchToolbar({ activeWorktreePath={activeWorktreePath} onEnvModeChange={onEnvModeChange} previousWorktreeLabel={previousWorktreeLabel} + previousWorktreeBranch={previousWorktreeSeed?.branch ?? null} onUsePreviousWorktree={onUsePreviousWorktree} /> ) : null} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index d958f2f76625..a548d414bcc8 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -59,6 +59,7 @@ import { useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; import { Button } from "./ui/button"; +import { ComposerControl } from "./chat/ComposerControl"; import { Switch } from "./ui/switch"; import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; import { @@ -73,6 +74,7 @@ import { } from "./ui/combobox"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { MiddleTruncate } from "./ui/middle-truncate"; export interface BranchToolbarBranchSelectorHandle { open: () => void; @@ -545,6 +547,7 @@ export function BranchToolbarBranchSelector({ setIsBranchMenuOpen(open); if (!open) { setBranchQuery(""); + highlightedBranchValueRef.current = null; } }, []); @@ -594,6 +597,9 @@ export function BranchToolbarBranchSelector({ }, [fetchNextBranchPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]); const branchListRef = useRef(null); + // Tracks the highlighted picker value so Enter can activate it even when the + // virtualized row is not mounted (Base UI Enter clicks the mounted element). + const highlightedBranchValueRef = useRef(null); const updateBranchListScrollFades = useCallback(() => { const scrollElement = branchListRef.current?.getScrollableNode?.(); if (!(scrollElement instanceof HTMLElement)) { @@ -676,6 +682,20 @@ export function BranchToolbarBranchSelector({ const prUrl = currentLinkedPr?.url ?? displayedPr?.url; const openPrLink = useOpenPrLink(threadRef); + function selectPickerItem(itemValue: string) { + highlightedBranchValueRef.current = null; + if (itemValue === checkoutPullRequestItemValue && prReference && onCheckoutPullRequestRequest) { + handleOpenChange(false); + onComposerFocusRequest?.(); + onCheckoutPullRequestRequest(prReference); + } else if (itemValue === createBranchItemValue) { + createRef(trimmedBranchQuery); + } else { + const refName = branchByName.get(itemValue); + if (refName) selectBranch(refName); + } + } + function renderPickerItem(itemValue: string, index: number) { if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { return ( @@ -684,16 +704,7 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} - className="pe-2" - onClick={() => { - if (!prReference || !onCheckoutPullRequestRequest) { - return; - } - setIsBranchMenuOpen(false); - setBranchQuery(""); - onComposerFocusRequest?.(); - onCheckoutPullRequestRequest(prReference); - }} + onClick={() => selectPickerItem(itemValue)} >
@@ -714,8 +725,7 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} - className="pe-1.5" - onClick={() => createRef(trimmedBranchQuery)} + onClick={() => selectPickerItem(itemValue)} > Create new ref "{newRefName}" @@ -742,12 +752,11 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} - className="pe-1.5" - onClick={() => selectBranch(refName)} + onClick={() => selectPickerItem(itemValue)} onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} >
- {itemValue} + {badge && {badge}}
@@ -760,7 +769,8 @@ export function BranchToolbarBranchSelector({ filteredItems={filteredBranchPickerItems} autoHighlight virtualized - onItemHighlighted={(_value, eventDetails) => { + onItemHighlighted={(value, eventDetails) => { + highlightedBranchValueRef.current = typeof value === "string" ? value : null; if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { return; } @@ -778,7 +788,7 @@ export function BranchToolbarBranchSelector({ data-composer-context-control > } badge={prBadge} number={prNumber} url={prUrl} @@ -796,10 +806,10 @@ export function BranchToolbarBranchSelector({ onContextMenu={(event) => handleBranchContextMenu(event, resolvedActiveBranch)} > } + render={} // No press-scale: the popup aligns live to this trigger, so a // momentary 0.97 shrink would drag the open popup ~3px sideways. - className="min-w-0 max-w-full font-normal text-muted-foreground/70 text-xs! hover:text-foreground/80 active:scale-100" + className="min-w-0 max-w-full active:scale-100" disabled={isInitialBranchesLoadPending || isBranchActionPending} > @@ -809,9 +819,9 @@ export function BranchToolbarBranchSelector({ > - {triggerLabel} + @@ -828,11 +838,29 @@ export function BranchToolbarBranchSelector({ placeholder="Search refs..." value={branchQuery} onChange={(event) => setBranchQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Enter" || event.nativeEvent.isComposing || event.keyCode === 229) { + return; + } + const highlightedValue = highlightedBranchValueRef.current; + if ( + highlightedValue === null || + !filteredBranchPickerItems.includes(highlightedValue) + ) { + return; + } + ( + event as typeof event & { preventBaseUIHandler?: () => void } + ).preventBaseUIHandler?.(); + event.preventDefault(); + event.stopPropagation(); + selectPickerItem(highlightedValue); + }} />
No refs found.
- + ref={branchListRef} data={filteredBranchPickerItems} @@ -876,7 +904,7 @@ export function BranchToolbarBranchSelector({ className="flex cursor-pointer items-center justify-between gap-3 border-t border-border/60 px-3 py-2 text-xs" > - } /> - + Creates the worktree from the latest matching branch on origin instead of your local branch. diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 0ec794b2970c..cb1faef9a50d 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -1,4 +1,4 @@ -import { FolderGit2Icon, FolderGitIcon, FolderIcon, HistoryIcon } from "lucide-react"; +import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react"; import { memo, useMemo } from "react"; import { @@ -8,6 +8,7 @@ import { type EnvMode, } from "./BranchToolbar.logic"; import { useComposerMenuProps } from "./chat/composerEventScope"; +import { PreviousWorktreeItemContent } from "./PreviousWorktreeItemContent"; import { Select, SelectGroup, @@ -28,6 +29,7 @@ interface BranchToolbarEnvModeSelectorProps { activeWorktreePath: string | null; onEnvModeChange: (mode: EnvMode) => void; previousWorktreeLabel?: string | null; + previousWorktreeBranch?: string | null; onUsePreviousWorktree?: () => void; } @@ -38,6 +40,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe activeWorktreePath, onEnvModeChange, previousWorktreeLabel, + previousWorktreeBranch = null, onUsePreviousWorktree, }: BranchToolbarEnvModeSelectorProps) { const composerFloatingLayerProps = useComposerMenuProps(); @@ -110,7 +113,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe - + Workspace @@ -163,10 +170,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe {showPreviousWorktree && previousWorktreeLabel ? ( - - - {previousWorktreeLabel} - + ) : null} diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index a29bb92e83d0..8ab7577e8002 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -102,7 +102,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir ({ vi.mock("~/lib/openPullRequestLink", () => ({ findProjectOnChangeRequestHost: () => undefined, parseChangeRequestUrl: () => null, + resolvePullRequestPreviewTarget: () => null, useOpenChangeRequestLink: () => vi.fn(), })); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index abebe4841cbd..f48c573aa8bc 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -53,7 +53,6 @@ import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-li import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { AsyncResult } from "effect/unstable/reactivity"; import React, { Children, @@ -106,7 +105,7 @@ import { markdownImageGallery, markdownImageItems } from "./chat/markdownImageGa import { MediaVideoPlayer } from "./media/MediaVideoPlayer"; import { MediaActions, type MediaActionSource } from "./media/MediaActions"; import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; -import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; +import { FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { revealInFileExplorerLabelForKind, @@ -119,6 +118,7 @@ import { import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Button } from "./ui/button"; +import { ContextChip } from "./ContextChip"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible"; import { ScrollArea } from "./ui/scroll-area"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; @@ -178,9 +178,9 @@ import { WORKSPACE_BASENAME_LOOKUP_LIMIT, } from "../workspaceBasenameLookup"; import { - findProjectForChangeRequest, parseChangeRequestUrl, pullRequestCandidateUrlFromReferenceAutolink, + resolvePullRequestPreviewTarget, useOpenChangeRequestLink, } from "~/lib/openPullRequestLink"; import { useOpenLink } from "../browser/useOpenLink"; @@ -773,7 +773,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { className="chat-markdown-table-container" data-expanded={expanded ? "true" : "false"} > - + {children}
@@ -784,9 +784,8 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { render={
); } @@ -989,9 +991,8 @@ function MarkdownCodeBlock({ render={ + + ) } /> - + {/* The full path: the chip already shows the shortened form, and a link to the workspace root collapses to a bare label that repeats it. */}
@@ -2242,8 +2229,7 @@ function areMarkdownFileLinkPropsEqual( previous.onOpenInBrowser === next.onOpenInBrowser && previous.onOpenMedia === next.onOpenMedia && previous.onReveal === next.onReveal && - previous.revealLabel === next.revealLabel && - previous.className === next.className + previous.revealLabel === next.revealLabel ); } @@ -2571,12 +2557,7 @@ function useChatMarkdownState({ [cwd, findWorkspaceBasenameMatch, revealFileInFileManager], ); const fileLinkChip = useCallback( - ( - fileLinkMeta: MarkdownFileLinkMeta, - copyMarkdown: string, - className?: string, - mediaSource?: string, - ) => { + (fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, mediaSource?: string) => { const parentSuffix = fileLinkParentSuffixByPath.get( fileLinkMeta.filePath.replaceAll("\\", "/"), ); @@ -2633,7 +2614,6 @@ function useChatMarkdownState({ ? () => openMarkdownFileInPreview(fileLinkMeta.filePath) : undefined } - className={className} /> ); }, @@ -2886,32 +2866,14 @@ const CHAT_MARKDOWN_COMPONENTS = { const confirmBeforeOpen = pullRequestAutolink === "reference"; const pullRequestCandidateUrl = confirmBeforeOpen && href ? pullRequestCandidateUrlFromReferenceAutolink(href) : href; - const pullRequestCandidate = pullRequestCandidateUrl - ? parseChangeRequestUrl(pullRequestCandidateUrl) + const pullRequestPreviewTarget = pullRequestCandidateUrl + ? resolvePullRequestPreviewTarget({ + environmentId, + projects, + pullRequestsEnabled: serverConfig?.environment.capabilities.pullRequests === true, + url: pullRequestCandidateUrl, + }) : null; - const pullRequestProject = - environmentId !== null && - serverConfig?.environment.capabilities.pullRequests === true && - pullRequestCandidate !== null - ? findProjectForChangeRequest( - projects.filter((project) => project.environmentId === environmentId), - pullRequestCandidate, - ) - : undefined; - const pullRequestPreviewTarget = - environmentId === null || pullRequestProject === undefined || pullRequestCandidate === null - ? null - : { - environmentId, - input: { - projectId: pullRequestProject.id, - host: pullRequestCandidate.authority ?? pullRequestCandidate.host, - repository: - sourceControlRepositorySelector(pullRequestProject.repositoryIdentity) ?? - pullRequestCandidate.repository, - number: pullRequestCandidate.number, - }, - }; const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); @@ -3078,12 +3040,7 @@ const CHAT_MARKDOWN_COMPONENTS = { return ( - - {href} - + {href} ); } @@ -3091,7 +3048,6 @@ const CHAT_MARKDOWN_COMPONENTS = { return fileLinkChip( fileLinkMeta, `[${fileLinkMeta.basename}](${normalizedHref})`, - props.className, normalizedHref, ); }, @@ -3108,7 +3064,6 @@ const CHAT_MARKDOWN_COMPONENTS = { return fileLinkChip( fileLinkMeta, `\`${codeText}\``, - undefined, inlineCodeFilePathCandidate(codeText) ?? codeText.trim(), ); } @@ -3332,7 +3287,7 @@ function ChatMarkdown({
({ vi.mock("~/lib/openPullRequestLink", () => ({ findProjectOnChangeRequestHost: () => undefined, parseChangeRequestUrl: () => null, + resolvePullRequestPreviewTarget: () => null, useOpenChangeRequestLink: () => vi.fn(), })); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 9a2870019bda..ce121210351a 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -12,7 +12,7 @@ import { TurnId, type WorktreeSetupSnapshot, } from "@t3tools/contracts"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { Atom, AsyncResult } from "effect/unstable/reactivity"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadDetails } from "../state/threads"; @@ -87,7 +87,6 @@ import { shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, shouldWriteThreadErrorToCurrentServerThread, - toolGroupConsumesUpwardNavigation, waitForRevertedMessage, prepareRevertedMessageAttachments, } from "./ChatView.logic"; @@ -447,134 +446,6 @@ describe("proactive panels", () => { }); }); -describe("toolGroupConsumesUpwardNavigation", () => { - class ScrollElement extends EventTarget { - scrollTop = 0; - scrollHeight = 100; - clientHeight = 100; - overflowY = "visible"; - - constructor( - readonly parentElement: ScrollElement | null = null, - readonly isToolGroup = false, - ) { - super(); - } - - closest(selector: string): ScrollElement | null { - if (selector !== "[data-tool-group-scroll]") return null; - return this.isToolGroup ? this : (this.parentElement?.closest(selector) ?? null); - } - } - - beforeEach(() => { - vi.stubGlobal("Element", ScrollElement); - vi.stubGlobal("getComputedStyle", (element: ScrollElement) => ({ - overflowY: element.overflowY, - })); - }); - afterEach(() => vi.unstubAllGlobals()); - - it("releases upward navigation when an overflowing group is at the top", () => { - const group = Object.assign(new ScrollElement(null, true), { - overflowY: "auto", - scrollHeight: 300, - }); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(group))).toBe(false); - }); - - it.each([ - { overflowY: "auto", scrollTop: 1 }, - { overflowY: "auto", scrollTop: 0.25 }, - { overflowY: "scroll", scrollTop: 80 }, - ])("consumes upward navigation within a scrolled group: %j", (scroll) => { - const group = Object.assign(new ScrollElement(null, true), { - scrollHeight: 300, - ...scroll, - }); - - expect(toolGroupConsumesUpwardNavigation(group)).toBe(true); - }); - - it.each([100, 300])( - "consumes scrolling in a nested result with a group content height of %i", - (scrollHeight) => { - const group = Object.assign(new ScrollElement(null, true), { - overflowY: "auto", - scrollHeight, - }); - const result = Object.assign(new ScrollElement(group), { - overflowY: "auto", - scrollHeight: 300, - scrollTop: 0.25, - }); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(true); - }, - ); - - it("releases upward navigation when the group and nested result are both at the top", () => { - const group = Object.assign(new ScrollElement(null, true), { - overflowY: "auto", - scrollHeight: 300, - }); - const result = Object.assign(new ScrollElement(group), { - overflowY: "scroll", - scrollHeight: 300, - }); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(false); - }); - - it("ignores targets outside a tool group and non-element targets", () => { - const outside = Object.assign(new ScrollElement(), { - overflowY: "auto", - scrollHeight: 300, - scrollTop: 40, - }); - - expect(toolGroupConsumesUpwardNavigation(outside)).toBe(false); - expect(toolGroupConsumesUpwardNavigation(new EventTarget())).toBe(false); - expect(toolGroupConsumesUpwardNavigation(null)).toBe(false); - }); - - it("does not consume scrolling from an ancestor beyond the tool group", () => { - const timeline = Object.assign(new ScrollElement(), { - overflowY: "auto", - scrollHeight: 300, - scrollTop: 40, - }); - const group = new ScrollElement(timeline, true); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(group))).toBe(false); - }); - - it.each(["hidden", "clip", "visible"])( - "ignores a non-scrollable child with overflow-y %s", - (overflowY) => { - const group = new ScrollElement(null, true); - const result = Object.assign(new ScrollElement(group), { - overflowY, - scrollHeight: 300, - scrollTop: 40, - }); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(false); - }, - ); - - it("does not consume programmatic scrolling on an overflow-hidden group", () => { - const group = Object.assign(new ScrollElement(null, true), { - overflowY: "hidden", - scrollHeight: 300, - scrollTop: 40, - }); - - expect(toolGroupConsumesUpwardNavigation(group)).toBe(false); - }); -}); - const environmentId = EnvironmentId.make("environment-local"); const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index f2189ed3c388..8a3525e15d9c 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -239,22 +239,6 @@ export function shouldReleaseTimelineAnchorForToolActivity(input: { }); } -export function toolGroupConsumesUpwardNavigation(target: EventTarget | null): boolean { - const elementTarget = target instanceof Element ? target : null; - const group = elementTarget?.closest("[data-tool-group-scroll]"); - if (!group) return false; - - // A nested result or the group itself can consume an upward scroll. - for (let element = elementTarget; element; element = element.parentElement) { - if (element.scrollTop > 0) { - const overflowY = getComputedStyle(element).overflowY; - if (overflowY === "auto" || overflowY === "scroll") return true; - } - if (element === group) break; - } - return false; -} - export { findRecordedWorktreeSetup, resolveVisibleWorktreeSetup, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d8a0439e6bfe..7c037c501da4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -148,6 +148,7 @@ import { } from "./chat/timelineScrollAnchoring"; import { buildPendingUserInputAnswers, + carryDisplacedCustomAnswerIntoPrompt, derivePendingUserInputProgress, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, @@ -232,6 +233,7 @@ import { } from "@t3tools/client-runtime/state/background-work"; import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; +import { isEditableFocused } from "../lib/editableFocus"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, @@ -367,6 +369,7 @@ import { import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController"; +import { isTimelineScrollTarget } from "./chat/timelineScrollTarget"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -408,7 +411,6 @@ import { import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, - DRAFT_HERO_TRANSITION_DURATION_MS, DRAFT_HERO_TRANSITION_EASING, MOBILE_COMPOSER_VIEW_TRANSITION_NAME, MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME, @@ -478,7 +480,6 @@ import { shouldWriteThreadErrorToCurrentServerThread, startNewThreadForProject, codexArtifactTemplatePromptToAppend, - toolGroupConsumesUpwardNavigation, waitForStartedServerThread, shouldRefocusComposerOnWindowFocus, } from "./ChatView.logic"; @@ -544,7 +545,11 @@ const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; const EMPTY_LIVE_BACKGROUND_TASKS: ReadonlyArray = []; -function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { +function useDraftHeroLayoutTransition( + isDraftHeroState: boolean, + animationsActive: boolean, + animationDurationMs: number, +) { const transitionGroupRef = useRef(null); const composerAnchorRef = useRef(null); const previousStateRef = useRef(isDraftHeroState); @@ -564,9 +569,6 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const transitionGroup = transitionGroupRef.current; const nextComposerRect = composerAnchorRef.current?.getBoundingClientRect() ?? null; const stateChanged = previousStateRef.current !== isDraftHeroState; - const prefersReducedMotion = - typeof window !== "undefined" && - window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; const mobileComposerTransitionActive = typeof document !== "undefined" && document.documentElement.dataset.mobileComposerRouteTransition === "true"; @@ -577,7 +579,7 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const previousComposerRect = previousComposerRectRef.current; if ( stateChanged && - !prefersReducedMotion && + animationsActive && !mobileComposerTransitionActive && transitionGroup && previousComposerRect && @@ -593,7 +595,7 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { { transform: "translate3d(0, 0, 0)" }, ], { - duration: DRAFT_HERO_TRANSITION_DURATION_MS, + duration: animationDurationMs, easing: DRAFT_HERO_TRANSITION_EASING, }, ); @@ -612,7 +614,7 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { previousStateRef.current = isDraftHeroState; previousComposerRectRef.current = nextComposerRect; - }, [isDraftHeroState]); + }, [animationDurationMs, animationsActive, isDraftHeroState]); return [attachTransitionGroupRef, attachComposerAnchorRef, captureComposerRect] as const; } @@ -3639,7 +3641,11 @@ export default function ChatView(props: ChatViewProps) { attachDraftHeroTransitionGroupRef, attachDraftHeroComposerAnchorRef, captureDraftHeroComposerRect, - ] = useDraftHeroLayoutTransition(isDraftHeroState); + ] = useDraftHeroLayoutTransition( + isDraftHeroState, + panelAnimationsActive, + panelAnimationDurationMs, + ); const gitCwd = activeProject ? projectScriptCwd({ @@ -5467,6 +5473,8 @@ export default function ChatView(props: ChatViewProps) { // Only an upward wheel is a navigation intent; wheeling down while // following either does nothing (at the end) or moves toward it. const handleWheel = (event: WheelEvent) => { + if (event.ctrlKey || !isTimelineScrollTarget(event.target, scrollNode, event.deltaY)) + return; if (event.deltaY > 0) { timelineScrollIntentRef.current = "toward-end"; if (isAtEndRef.current) { @@ -5475,11 +5483,7 @@ export default function ChatView(props: ChatViewProps) { } else if (event.deltaY < 0) { timelineScrollIntentRef.current = "away-from-end"; } - if ( - event.deltaY < 0 && - contentScrollsUp() && - !toolGroupConsumesUpwardNavigation(event.target) - ) { + if (event.deltaY < 0 && contentScrollsUp()) { handleManualNavigation(); } }; @@ -5529,12 +5533,20 @@ export default function ChatView(props: ChatViewProps) { ) { return; } + if (!["PageUp", "Home", "ArrowUp", "PageDown", "End", "ArrowDown"].includes(event.key)) + return; + const scrollDirection = ["PageUp", "Home", "ArrowUp"].includes(event.key) ? -1 : 1; + if ( + scrollNode.contains(event.target) && + !isTimelineScrollTarget(event.target, scrollNode, scrollDirection) + ) + return; switch (event.key) { case "PageUp": case "Home": case "ArrowUp": timelineScrollIntentRef.current = "away-from-end"; - if (contentScrollsUp() && !toolGroupConsumesUpwardNavigation(event.target)) { + if (contentScrollsUp()) { handleManualNavigation(); composerRef.current?.collapseForTimelineScrollKey(event.key); } @@ -6562,7 +6574,7 @@ export default function ChatView(props: ChatViewProps) { } /> - + This thread last ran on {localCheckoutBranchMismatch.threadBranch}. Sending will continue on {localCheckoutBranchMismatch.currentBranch}. @@ -6681,11 +6693,12 @@ export default function ChatView(props: ChatViewProps) { }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); const getShortcutContext = useCallback( - () => ({ + (eventTarget: EventTarget | null = document.activeElement) => ({ terminalFocus: getTerminalFocusOwner() !== null, terminalOpen: Boolean(terminalUiState.terminalOpen), previewFocus: isPreviewFocused(), previewOpen: previewPanelOpen, + editableFocus: isEditableFocused(eventTarget), modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, isWeb: !isElectron, isDesktop: isElectron, @@ -6713,7 +6726,7 @@ export default function ChatView(props: ChatViewProps) { if (event.defaultPrevented && terminalFocusOwner === null) { return; } - const shortcutContext = getShortcutContext(); + const shortcutContext = getShortcutContext(event.target); if ( !shortcutContext.terminalFocus && @@ -7909,13 +7922,19 @@ export default function ChatView(props: ChatViewProps) { const dockStarted = new Promise((resolve) => { resolveDockStarted = resolve; }); - const dockTransition = runMobileComposerTransition(() => { - flushSync(() => { - captureDraftHeroComposerRect(); - setDockedDraftHeroThreadKey(activeThreadKey); - }); - resolveDockStarted?.(); - }); + const dockTransition = runMobileComposerTransition( + () => { + flushSync(() => { + captureDraftHeroComposerRect(); + setDockedDraftHeroThreadKey(activeThreadKey); + }); + resolveDockStarted?.(); + }, + { + active: panelAnimationsActive, + durationMs: panelAnimationDurationMs, + }, + ); void dockTransition.catch(() => resolveDockStarted?.()); await dockStarted; } @@ -8845,6 +8864,16 @@ export default function ChatView(props: ChatViewProps) { if (!activePendingUserInput) { return; } + // The option replaces the custom answer. Anything typed there is the + // user's text, so it goes back to the thread draft instead of vanishing. + const displacedAnswer = + pendingUserInputAnswersByRequestId[activePendingRequestKey]?.[questionId]?.customAnswer; + const currentPrompt = + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt ?? ""; + const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer); + if (nextPrompt !== currentPrompt) { + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + } setPendingUserInputAnswersByRequestId((existing) => { const question = (activePendingProgress?.activeQuestion?.id === questionId @@ -8874,7 +8903,10 @@ export default function ChatView(props: ChatViewProps) { activePendingProgress?.activeQuestion, activePendingUserInput, activePendingRequestKey, + composerDraftTarget, composerRef, + pendingUserInputAnswersByRequestId, + setComposerDraftPrompt, ], ); @@ -9995,7 +10027,7 @@ export default function ChatView(props: ChatViewProps) { composerRef.current?.restoreAfterTimelineReachedEnd(); scrollToEnd(true); }} - className="pointer-events-auto gap-1.5 rounded-full px-3 text-muted-foreground hover:text-foreground" + className="pointer-events-auto" size="xs" variant="glass" > @@ -10382,7 +10414,6 @@ export default function ChatView(props: ChatViewProps) { { ]); }); + it("orders title matches by recent activity before older prefix matches", () => { + const threads = [ + makeThread({ + id: ThreadId.make("old-prefix"), + title: "Convex InvalidCursor in Convex threads query", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("recent-title"), + title: "Disable Convex schema validation", + createdAt: "2025-12-01T00:00:00.000Z", + updatedAt: "2026-03-24T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("recent-content"), + title: "Fix schema validation", + createdAt: "2026-03-25T00:00:00.000Z", + updatedAt: "2026-03-25T00:00:00.000Z", + }), + ]; + const items = buildThreadActionItems({ + threads, + projectTitleById: new Map([[PROJECT_ID, "T3 Code"]]), + sortOrder: "created_at", + icon: null, + getContentMatch: (thread) => + thread.id === ThreadId.make("recent-content") + ? { source: "user", snippet: "Please check Convex", query: "convex" } + : undefined, + runThread: async () => undefined, + }); + + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query: "convex", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: items, + }); + + expect(groups[0]?.items.map((item) => item.value)).toEqual([ + "thread:recent-title", + "thread:old-prefix", + "thread:recent-content", + ]); + }); + it("preserves thread project-name matches when there is no stronger title match", () => { const group: CommandPaletteGroup = { value: "threads-search", diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 2dce3930b410..6cd3a89d7fcf 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -11,7 +11,7 @@ import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; import { type ReactNode } from "react"; -import { sortThreads } from "../lib/threadSort"; +import { getThreadSortTimestamp, sortThreads } from "../lib/threadSort"; import { normalizeSearchText } from "../lib/utils"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { type Project, type SidebarThreadSummary, type Thread } from "../types"; @@ -141,6 +141,7 @@ export interface CommandPaletteItem { readonly description?: ReactNode; readonly threadContentMatch?: CommandPaletteThreadContentMatch; readonly timestamp?: string; + readonly searchRecency?: number; readonly icon: ReactNode; readonly disabled?: boolean; /** Optional content rendered inline before the title text. */ @@ -321,6 +322,7 @@ export function buildThreadActionItems Number(left.item.secondary ?? false) - Number(right.item.secondary ?? false) || right.rank - left.rank || + (right.item.searchRecency ?? 0) - (left.item.searchRecency ?? 0) || left.index - right.index, ) .map((entry) => entry.item); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 5c23f13fc292..22ac9b72f1ca 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -653,7 +653,7 @@ function CommandPaletteDialog(props: { ? "Search project contents" : "Command palette" } - className={cn("overflow-hidden p-0", props.mode === "content" && "h-105")} + className={cn("overflow-hidden", props.mode === "content" && "h-105")} data-command-palette="true" data-palette-mode={props.mode} data-testid="command-palette" @@ -1535,9 +1535,8 @@ function OpenCommandPaletteDialog(props: { { openSourceControlSettings(); }} @@ -2932,7 +2931,7 @@ function OpenCommandPaletteDialog(props: { variant="outline" size="xs" tabIndex={-1} - className="absolute inset-e-2.5 top-1/2 gap-1.5 pe-1 ps-2 -translate-y-1/2" + className="absolute inset-e-2.5 top-1/2 -translate-y-1/2" aria-label={`${remoteProjectButtonLabel ?? "Continue"} (Enter)`} disabled={!canSubmitRemoteProjectFlow} onMouseDown={(event) => { @@ -2945,7 +2944,7 @@ function OpenCommandPaletteDialog(props: { } > {isRemoteProjectPending ? "Working" : remoteProjectButtonLabel} - + Enter @@ -2959,10 +2958,7 @@ function OpenCommandPaletteDialog(props: { variant="outline" size="xs" tabIndex={-1} - className={cn( - "absolute inset-e-2.5 top-1/2 pe-1 ps-2 -translate-y-1/2", - hasHighlightedBrowseItem ? "gap-1" : "gap-1.5", - )} + className="absolute inset-e-2.5 top-1/2 -translate-y-1/2" aria-label={`${submitActionLabel} (${addShortcutLabel})`} disabled={ !canCreateProjectInEnvironment(browseEnvironment?.connection.phase) || @@ -2988,7 +2984,7 @@ function OpenCommandPaletteDialog(props: { {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel} - + {hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"} @@ -3037,9 +3033,6 @@ function OpenCommandPaletteDialog(props: { }) : undefined, placeholder: inputPlaceholder, - wrapperClassName: isSubmenu - ? "[&_[data-slot=autocomplete-start-addon]]:pointer-events-auto" - : undefined, ...(isSubmenu ? { startAddon: ( diff --git a/apps/web/src/components/CommandPaletteContent.tsx b/apps/web/src/components/CommandPaletteContent.tsx index 8c1a5b0e3c83..e9e6149b6cd7 100644 --- a/apps/web/src/components/CommandPaletteContent.tsx +++ b/apps/web/src/components/CommandPaletteContent.tsx @@ -50,9 +50,9 @@ export function CommandPaletteContent({ {inputAccessory}
{children} - +
- + @@ -62,18 +62,18 @@ export function CommandPaletteContent({ Navigate {footerActionLabel !== undefined ? ( - + Enter {footerActionLabel} ) : null} {showBackHint ? ( - + Backspace Back ) : null} - + Esc {escapeLabel} diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index c66ab9a1cf2b..b94707f5eca6 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -15,7 +15,6 @@ import { CommandShortcut, } from "./ui/command"; import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; -import { cn } from "~/lib/utils"; interface CommandPaletteResultsProps { emptyStateMessage?: string; @@ -42,7 +41,7 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { {props.groups.map((group) => ( - {group.label} + {group.label} {(item) => item.disabled ? ( @@ -109,10 +108,7 @@ function CommandPaletteResultRow(props: { return ( { event.preventDefault(); }} diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 5126246dd05c..50305ffdbef0 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -46,6 +46,7 @@ import { buildDocJson, buildTiptapContent, collapsedToFlat, + ComposerCodeExtension, ComposerTaskItemExtension, flatToCollapsed, flatToMarkdown, @@ -57,18 +58,13 @@ import { import { collectInlineContextIds } from "~/lib/composerContextReferences"; import { cn, isMacPlatform } from "~/lib/utils"; import { basenameOfPath } from "~/pierre-icons"; -import { - COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME, - COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, - COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, - COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, - SKILL_CHIP_ICON_SVG, -} from "./composerInlineChip"; -import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; +import { FileTagChipContent } from "./chat/FileTagChip"; +import { SkillChipIcon } from "./chat/SkillInlineText"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; import { getTimelinePageScrollKey } from "./chat/pageScrollController"; import { ContextChipPopover } from "./contextChipParts"; import { Button } from "./ui/button"; +import { ContextChip } from "./ContextChip"; import { ComposerContextActionsContext, ComposerContextReferenceChip, @@ -136,7 +132,7 @@ export interface ComposerPromptEditorProps { ) => void; onVisibleSelectionChange?: () => void; onCommandKeyDown?: ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", + key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab" | "Escape", event: KeyboardEvent, isTaskItem?: boolean, ) => boolean; @@ -189,6 +185,13 @@ function resolvedThemeFromDocument(): "light" | "dark" { // ── Inline atom nodes (chips) ───────────────────────────────────────────── +/** + * Wraps an inline chip node view: keeps the caret and text selection out of the chip and + * paints the editor's node selection over it. + */ +const CHIP_NODE_SELECTION_CLASS_NAME = + "relative inline-flex select-none items-center align-middle leading-none data-[composer-chip-selected]:after:pointer-events-none data-[composer-chip-selected]:after:absolute data-[composer-chip-selected]:after:inset-0 data-[composer-chip-selected]:after:rounded-[6px] data-[composer-chip-selected]:after:bg-[Highlight] data-[composer-chip-selected]:after:opacity-30 data-[composer-chip-selected]:after:content-['']"; + const ComposerMentionExtension = Node.create({ name: "composer-mention", group: "inline", @@ -216,11 +219,11 @@ function ComposerMentionNodeView({ node }: NodeViewProps) { const actions = use(ComposerContextActionsContext); const path = (node.attrs.path as string) ?? ""; const chip = ( - + ); return ( - + - - {path} - + {path} ); @@ -279,20 +277,12 @@ function ComposerSkillNodeView({ node }: NodeViewProps) { const skillDescription = (node.attrs.skillDescription as string | null) ?? null; const skill = skills.find((candidate) => candidate.name === skillName); return ( - + } + label={skillLabel} accessibleLabel={`Skill ${skillLabel}`} - triggerClassName={COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME} - chip={ - <> -