diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index b4fe25fab83fd..e27a8c90fdff8 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -13,6 +13,10 @@ inputs: description: Arguments for `vp install`, such as filters. Empty installs the whole workspace. required: false default: "" + save-pnpm-store: + description: Set to "false" when a concurrent job with the same install args saves the pnpm store + required: false + default: "true" runs: using: composite steps: @@ -125,9 +129,11 @@ runs: # A filtered install that started from the current full store adds nothing # worth a second copy of it. A stacked PR, based on a branch other than the # default, saves a ~1 GB store only its own reruns can read, so it only restores. + # Concurrent jobs with the same install args reserve one key, so the losers + # compress the store for nothing; all but one pass save-pnpm-store: false. - name: Save pnpm store if: >- - inputs.install == 'true' && + inputs.install == 'true' && inputs.save-pnpm-store == 'true' && (github.event_name != 'pull_request' || github.base_ref == github.event.repository.default_branch) && steps.pnpm_cache.outputs.cache-hit != 'true' && (steps.pnpm_store.outputs.namespace == 'full' || diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index b7dce1a102e5e..8056d08f4385d 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -77,6 +77,12 @@ on: required: false default: "" type: string + # Whether this job saves the workspace pnpm store. Release's two Macs + # install the same store on the same runner type, so only one saves it. + save_pnpm_store: + required: false + default: true + type: boolean version: required: true type: string @@ -160,6 +166,7 @@ jobs: uses: ./.github/actions/setup-ci-deps with: install-args: --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... + save-pnpm-store: ${{ inputs.save_pnpm_store }} env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata @@ -408,6 +415,7 @@ jobs: run: command -v patchelf || { sudo apt-get update && sudo apt-get install -y patchelf; } - name: Build CLI archive + id: build_cli if: inputs.cli_archive shell: bash env: @@ -425,25 +433,38 @@ jobs: AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} run: | set -euo pipefail - if [[ "${{ inputs.platform }}" == "mac" && -n "${APPLE_API_KEY:-}" ]]; then - key_path="$RUNNER_TEMP/AuthKey_cli_${APPLE_API_KEY_ID}.p8" - printf '%s' "$APPLE_API_KEY" > "$key_path" - export APPLE_API_KEY="$key_path" + extra_args=() + if [[ "${{ inputs.platform }}" == "mac" ]]; then + if [[ -n "${APPLE_API_KEY:-}" ]]; then + key_dir="$(mktemp -d "$RUNNER_TEMP/t3-cli-notary.XXXXXXXX")" + trap 'rm -rf "$key_dir"' EXIT + key_path="$key_dir/AuthKey.p8" + (umask 077 && printf '%s' "$APPLE_API_KEY" > "$key_path") + export APPLE_API_KEY="$key_path" + fi + # Older release refs lack deferred notarization and still wait here. + cli_help="$(node scripts/build-cli-archive.ts --help)" + if [[ "$cli_help" == *--notary-submission-file* ]]; then + extra_args=(--notary-submission-file "$RUNNER_TEMP/t3-cli-notary-submission") + echo "deferred_notarization=true" >> "$GITHUB_OUTPUT" + fi fi node scripts/build-cli-archive.ts \ --platform "${{ inputs.platform }}" \ --arch "${{ inputs.arch }}" \ --version "${{ inputs.version }}" \ --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ - --output-dir release-cli + --output-dir release-cli \ + ${extra_args[@]+"${extra_args[@]}"} - name: Smoke-test CLI archive if: inputs.cli_archive && inputs.verify_runner == '' shell: bash run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ inputs.version }}" + # macOS uploads its archive after "Await CLI notarization" instead. - name: Upload CLI archive - if: inputs.cli_archive + if: inputs.cli_archive && inputs.platform != 'mac' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: cli-${{ inputs.platform }}-${{ inputs.arch }} @@ -620,6 +641,39 @@ jobs: vp run dist:desktop:artifact "${args[@]}" + # The CLI was submitted without waiting so Apple reviews it while the + # desktop builds. Notarization does not change the archive bytes, but + # nothing uploads them, so nothing publishes them, until Apple accepts. + # With signing configured, a missing submission id fails this step. + - name: Await CLI notarization + if: inputs.cli_archive && inputs.platform == 'mac' && steps.build_cli.outputs.deferred_notarization == 'true' + shell: bash + env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: | + set -euo pipefail + if [[ -n "${APPLE_API_KEY:-}" ]]; then + key_dir="$(mktemp -d "$RUNNER_TEMP/t3-cli-notary.XXXXXXXX")" + trap 'rm -rf "$key_dir"' EXIT + key_path="$key_dir/AuthKey.p8" + (umask 077 && printf '%s' "$APPLE_API_KEY" > "$key_path") + export APPLE_API_KEY="$key_path" + fi + node scripts/build-cli-archive.ts await-notarization \ + --submission-file "$RUNNER_TEMP/t3-cli-notary-submission" + + - name: Upload notarized CLI archive + if: inputs.cli_archive && inputs.platform == 'mac' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: cli-${{ inputs.platform }}-${{ inputs.arch }} + path: release-cli/* + if-no-files-found: error + # The archive is already gzip or zip; deflating it again saves under 2%. + compression-level: 0 + # Warm installs only touch the store index, so new content means a fetch. - name: Check staged install cache for new content id: stage_pnpm_changed diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c3c594972327..9df801c108b7f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -517,6 +517,8 @@ jobs: if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: 30 + outputs: + npm_launcher_artifact_id: ${{ steps.npm_launcher.outputs.artifact-id }} env: T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} @@ -604,6 +606,22 @@ jobs: if-no-files-found: error retention-days: 1 + # publish_cli publishes this exact tarball, so the trusted publisher + # needs no workspace checkout or install. It is version-matched here. + - name: Build the npm launcher + run: node scripts/build-npm-launcher.ts --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages + + # GitHub allows a failed-jobs rerun for 30 days. publish_cli downloads + # this upload by its ID, which a publish_cli-only rerun keeps. + - name: Upload the npm launcher + id: npm_launcher + uses: actions/upload-artifact@v7 + with: + name: npm-launcher + path: npm-packages/t3.tgz + if-no-files-found: error + retention-days: 30 + # One job per platform and architecture (see release-desktop.yml), each # gated only on the JS bundle. Windows targets build on Linux and run their # native checks on a Windows runner of their own architecture. @@ -635,6 +653,8 @@ jobs: resource_key: darwin-arm64 cli_archive: true launch_check: true + # desktop_mac_x64 saves the shared pnpm store; this job finishes last. + save_pnpm_store: false desktop_mac_x64: name: Desktop macOS x64 @@ -806,6 +826,24 @@ jobs: with: install-args: --filter=@t3tools/scripts... + # Overlaps the artifact downloads. The wait below ends it before any + # workspace script runs, so the Play key lives only in this process. + # `exec` lets the runner's stop signals reach node directly. + - id: android_apk + name: Download nightly Android APK + if: needs.preflight.outputs.release_channel == 'nightly' + background: true + continue-on-error: true + timeout-minutes: 5 + env: + GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} + PACKAGE_NAME: com.t3tools.t3code + APK_OUTPUT_DIR: ${{ runner.temp }}/android-apk + run: | + if [ -n "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then + exec node .github/scripts/mirror-play-apk.cjs + fi + - name: Download all desktop artifacts uses: actions/download-artifact@v8 with: @@ -820,18 +858,6 @@ jobs: merge-multiple: true path: release-assets - - name: Download internal Android APK for Nightly - if: needs.preflight.outputs.release_channel == 'nightly' - continue-on-error: true - env: - GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} - PACKAGE_NAME: com.t3tools.t3code - APK_OUTPUT_DIR: release-assets - run: | - if [ -n "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then - node .github/scripts/mirror-play-apk.cjs - fi - # Installers verify archives against this file, so it is written from the # signed bytes that get uploaded, never from an earlier stage. - name: Write CLI archive checksums @@ -848,6 +874,10 @@ jobs: sha256sum "${archives[@]}" > SHA256SUMS cat SHA256SUMS + # Always runs, even after a failed step, and keeps that failure. + - name: Wait for nightly Android APK + wait: android_apk + # The desktop build omits the publish config for preview versions, so # electron-builder emits no updater manifests for them. Refuse to publish # if one shows up anyway: a `latest*.yml` or `nightly*.yml` on a preview @@ -913,10 +943,13 @@ jobs: echo 'release-assets/*.yml' fi echo 'release-assets/*.tar.gz' - # Only list APKs when Play has a processed internal build available. - for apk in release-assets/*.apk; do - [[ -f "$apk" ]] && echo "$apk" - done + # A failed or timed-out fetch can leave partial bytes, so list the APK + # only when this attempt's fetch succeeded and Play had one ready. + if [[ "${{ steps.android_apk.outcome }}" == "success" ]]; then + for apk in "$RUNNER_TEMP"/android-apk/*.apk; do + [[ -f "$apk" ]] && echo "$apk" + done + fi echo 'release-assets/SHA256SUMS' echo 'EOF' } >> "$GITHUB_OUTPUT" @@ -1010,7 +1043,7 @@ jobs: # nothing resolves unless asked for by name. publish_cli: name: Publish CLI to npm - needs: [preflight, release] + needs: [preflight, build_bundle, release] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }} runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 15 @@ -1018,26 +1051,57 @@ jobs: contents: read id-token: write steps: + # Only what setup-ci-deps reads to install the project's Node and npm. - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | - /* - !/.repos/ + /.github/actions/setup-ci-deps/ + /package.json + /pnpm-workspace.yaml sparse-checkout-cone-mode: false - - name: Setup dependencies + - name: Setup Node.js uses: ./.github/actions/setup-ci-deps with: - install-args: --filter=t3... --filter=@t3tools/scripts... + install: "false" - - name: Build the npm launcher - run: node scripts/build-npm-launcher.ts --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages + # Bind to build_bundle's exact upload: a name lookup takes the newest + # same-name artifact, and an empty ID downloads every artifact in the + # run. A later same-name upload hides this ID, so the download fails. + - name: Check the npm launcher artifact ID + env: + ARTIFACT_ID: ${{ needs.build_bundle.outputs.npm_launcher_artifact_id }} + run: | + if [[ ! "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::build_bundle gave no valid npm-launcher artifact ID: '$ARTIFACT_ID'." >&2 + exit 1 + fi + + - name: Download the npm launcher + uses: actions/download-artifact@v8 + with: + artifact-ids: ${{ needs.build_bundle.outputs.npm_launcher_artifact_id }} + path: npm-packages + digest-mismatch: error + + - name: Check the npm launcher version + env: + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + run: | + built="$(tar -xzOf npm-packages/t3.tgz package/package.json | jq -r .version)" + if [[ "$built" != "$RELEASE_VERSION" ]]; then + echo "::error::npm-launcher holds t3@$built, expected t3@$RELEASE_VERSION." >&2 + exit 1 + fi - name: Publish the npm launcher + env: + DIST_TAG: ${{ needs.preflight.outputs.cli_dist_tag }} run: | - if ! node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --verbose; then + # The ./ keeps npm from reading the path as a GitHub owner/repo spec. + if ! npm publish --access public --tag "$DIST_TAG" --provenance ./npm-packages/t3.tgz; then echo "::error::npm publish failed. Make sure package t3 has a trusted publisher registered for .github/workflows/release.yml (see docs/operations/release.md)." >&2 exit 1 fi diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 32e7b3b3118d9..74d134c0b1001 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -42,6 +42,7 @@ const layerElectronApp = (calls: ElectronAppCalls) => metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("T3 Code"), systemLocale: Effect.succeed("en-US"), + preferredSystemLanguages: Effect.succeed(["en-US"]), whenReady: Effect.void, requestSingleInstanceLock: Effect.succeed(true), quit: Effect.void, diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index fa1bcd8bbf05a..30df96c58ad79 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -39,6 +39,7 @@ function layerElectronApp( metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("T3 Code"), systemLocale: Effect.succeed("en-US"), + preferredSystemLanguages: Effect.succeed(["en-US"]), whenReady: Effect.void, requestSingleInstanceLock: Effect.succeed(true), quit, diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index d677baa56dce3..188f53c4be8d7 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -46,6 +46,7 @@ export class ElectronApp extends Context.Service< readonly name: Effect.Effect; /** Packaged builds ship only en-US; app.getLocale() and Intl defaults do not reflect the OS locale. */ readonly systemLocale: Effect.Effect; + readonly preferredSystemLanguages: Effect.Effect>; readonly whenReady: Effect.Effect; readonly requestSingleInstanceLock: Effect.Effect; readonly quit: Effect.Effect; @@ -155,6 +156,7 @@ export const make = ElectronApp.of({ } return toSystemLocaleTag(identifier, hourCycle); }), + preferredSystemLanguages: Effect.sync(() => Electron.app.getPreferredSystemLanguages()), whenReady: Effect.gen(function* () { const isPackaged = Electron.app.isPackaged; yield* Effect.tryPromise({ diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index a46dbafab0f41..db26c525c0015 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -6,6 +6,7 @@ import { installNotificationBadge } from "./methods/notificationBadge.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; import { getConnectionCatalog, setConnectionCatalog } from "./methods/connectionCatalog.ts"; import { + getHostEnvironmentId, getLocalEnvironmentEnabled, setLocalEnvironmentEnabled, } from "./methods/localEnvironment.ts"; @@ -38,6 +39,7 @@ import { getLocalEnvironmentBearerToken, installRelayTelemetryEnabled, getSystemLocale, + getPreferredSystemLanguages, getWindowFullscreenState, openExternal, readMedia, @@ -94,10 +96,12 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getAppBranding); yield* installRelayTelemetryEnabled(); yield* ipc.handleSync(getSystemLocale); + yield* ipc.handleSync(getPreferredSystemLanguages); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handleSync(getLocalEnvironmentEnabled); yield* ipc.handle(setLocalEnvironmentEnabled); + yield* ipc.handle(getHostEnvironmentId); yield* ipc.handle(getLocalEnvironmentBearerToken); yield* ipc.handle(getClientSettings); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 1d7edaf683250..6a149e24ae695 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -34,9 +34,11 @@ export const CLI_COMMAND_UNINSTALL_CHANNEL = "desktop:cli-command-uninstall"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; export const GET_RELAY_TELEMETRY_ENABLED_CHANNEL = "desktop:get-relay-telemetry-enabled"; export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; +export const GET_PREFERRED_SYSTEM_LANGUAGES_CHANNEL = "desktop:get-preferred-system-languages"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; export const GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:get-local-environment-enabled"; export const SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:set-local-environment-enabled"; +export const GET_HOST_ENVIRONMENT_ID_CHANNEL = "desktop:get-host-environment-id"; export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = "desktop:get-local-environment-bearer-token"; export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; diff --git a/apps/desktop/src/ipc/methods/localEnvironment.test.ts b/apps/desktop/src/ipc/methods/localEnvironment.test.ts index 94bbf085a9921..36e89eb880c15 100644 --- a/apps/desktop/src/ipc/methods/localEnvironment.test.ts +++ b/apps/desktop/src/ipc/methods/localEnvironment.test.ts @@ -1,4 +1,7 @@ import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -10,7 +13,11 @@ import * as ElectronApp from "../../electron/ElectronApp.ts"; import * as ElectronTheme from "../../electron/ElectronTheme.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as DesktopWindow from "../../window/DesktopWindow.ts"; -import { getLocalEnvironmentEnabled, setLocalEnvironmentEnabled } from "./localEnvironment.ts"; +import { + getHostEnvironmentId, + getLocalEnvironmentEnabled, + setLocalEnvironmentEnabled, +} from "./localEnvironment.ts"; const layerUnusedLifecycleRuntime = Layer.mergeAll( DesktopShutdown.layer, @@ -27,6 +34,26 @@ const layerUnusedLifecycleRuntime = Layer.mergeAll( ); describe("local environment IPC", () => { + it.effect("reads the shared home identity without creating one", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = yield* fs.makeTempDirectoryScoped(); + const environment = DesktopEnvironment.DesktopEnvironment.of({ + path, + stateDir, + } as DesktopEnvironment.DesktopEnvironment["Service"]); + const read = getHostEnvironmentId + .handler(undefined) + .pipe(Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment)); + assert.strictEqual(yield* read, null); + assert.isFalse(yield* fs.exists(path.join(stateDir, "environment-id"))); + yield* fs.writeFileString(path.join(stateDir, "environment-id"), " service-id\n"); + assert.strictEqual(yield* read, "service-id"); + yield* fs.writeFileString(path.join(stateDir, "environment-id"), "\n"); + assert.strictEqual(yield* read, null); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); it.effect("relaunches only when the setting changes and keeps other settings", () => { const relaunchReasons: Array = []; const layer = Layer.mergeAll( diff --git a/apps/desktop/src/ipc/methods/localEnvironment.ts b/apps/desktop/src/ipc/methods/localEnvironment.ts index 74cccd04a0c58..3c5c2c63d77da 100644 --- a/apps/desktop/src/ipc/methods/localEnvironment.ts +++ b/apps/desktop/src/ipc/methods/localEnvironment.ts @@ -1,6 +1,8 @@ import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as IpcChannels from "../channels.ts"; @@ -28,3 +30,18 @@ export const setLocalEnvironmentEnabled = makeIpcMethod({ } }), }); + +// A background service sharing this app's T3 home records its environment ID in the same state dir. +export const getHostEnvironmentId = makeIpcMethod({ + channel: IpcChannels.GET_HOST_ENVIRONMENT_ID_CHANNEL, + payload: Schema.Void, + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.localEnvironment.getHostEnvironmentId")(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const contents = yield* fileSystem + .readFileString(environment.path.join(environment.stateDir, "environment-id")) + .pipe(Effect.orElseSucceed(() => "")); + return contents.trim() || null; + }), +}); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 2833abca43859..9580b29b94bba 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -106,6 +106,15 @@ export const getSystemLocale = DesktopIpc.makeSyncIpcMethod({ }), }); +export const getPreferredSystemLanguages = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_PREFERRED_SYSTEM_LANGUAGES_CHANNEL, + result: Schema.Array(Schema.String), + handler: Effect.fn("desktop.ipc.window.getPreferredSystemLanguages")(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + return yield* electronApp.preferredSystemLanguages; + }), +}); + export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL, result: Schema.Boolean, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 800f1e744c9d1..7c44ec5133785 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -107,6 +107,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); return typeof result === "string" ? result : null; }, + getPreferredSystemLanguages: () => { + const result: unknown = ipcRenderer.sendSync( + IpcChannels.GET_PREFERRED_SYSTEM_LANGUAGES_CHANNEL, + ); + return Array.isArray(result) ? result.filter((tag) => typeof tag === "string") : []; + }, getLocalEnvironmentBootstraps: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL); if (!Array.isArray(result)) { @@ -120,6 +126,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL) !== false, setLocalEnvironmentEnabled: (enabled) => ipcRenderer.invoke(IpcChannels.SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, enabled), + getHostEnvironmentId: () => ipcRenderer.invoke(IpcChannels.GET_HOST_ENVIRONMENT_ID_CHANNEL), getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), setClientSettings: (settings) => ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings), diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 7a339102f19a6..e68e52961fa60 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -16,6 +16,7 @@ const { fromPartition, sessions } = vi.hoisted(() => ({ readonly setPermissionRequestHandler: ReturnType; readonly setPermissionCheckHandler: ReturnType; readonly setUserAgent: ReturnType; + readonly webRequest: { readonly onBeforeSendHeaders: ReturnType }; } >(), })); @@ -62,6 +63,7 @@ describe("BrowserSession", () => { setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), setUserAgent: vi.fn(), + webRequest: { onBeforeSendHeaders: vi.fn() }, }; sessions.set(partition, browserSession); return browserSession; @@ -115,10 +117,12 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); - it.effect("keeps the guest's effective User-Agent equal to Electron's native one", () => + it.effect("keeps the native User-Agent except for the Electron token sent to Teams", () => Effect.gen(function* () { const nativeUserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) T3Code(Alpha)/0.0.33 Chrome/146.0.7680.216 Electron/41.5.0 Safari/537.36"; + const teamsUserAgent = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) T3Code(Alpha)/0.0.33 Chrome/146.0.7680.216 Safari/537.36"; fromPartition.mockReset(); fromPartition.mockImplementation((partition: string) => { let userAgent = nativeUserAgent; @@ -131,6 +135,7 @@ describe("BrowserSession", () => { setUserAgent: vi.fn((next: string) => { userAgent = next; }), + webRequest: { onBeforeSendHeaders: vi.fn() }, }; sessions.set(partition, browserSession); return browserSession; @@ -143,6 +148,29 @@ describe("BrowserSession", () => { const browserSession = sessions.get(partition); assert.isDefined(browserSession); assert.strictEqual(browserSession.getUserAgent(), nativeUserAgent); + + // Teams redirects Electron to an error page before sign-in; every other host sees the native one. + const calls = browserSession.webRequest.onBeforeSendHeaders.mock.calls; + assert.strictEqual(calls.length, 1); + const [filter, listener] = calls[0] ?? []; + assert.deepStrictEqual(filter, { + urls: ["https://teams.cloud.microsoft/*", "https://teams.microsoft.com/*"], + }); + const send = (requestHeaders: Record) => { + let response: { requestHeaders?: Record } | undefined; + listener({ requestHeaders }, (next: typeof response) => { + response = next; + }); + return response; + }; + for (const key of ["User-Agent", "user-agent"]) { + const requestHeaders = { [key]: nativeUserAgent, Accept: "*/*" }; + assert.deepStrictEqual(send(requestHeaders), { + requestHeaders: { [key]: teamsUserAgent, Accept: "*/*" }, + }); + assert.strictEqual(requestHeaders[key], nativeUserAgent); + } + assert.deepStrictEqual(send({ Accept: "*/*" }), {}); }).pipe(Effect.provide(layer)), ); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index a780cacf11ea9..b2523ba62b973 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -55,6 +55,9 @@ const externalProtocolPromptUrl = (rawUrl: string | undefined): string | null => } }; +// Teams sends Electron User-Agents to an error page before sign-in. Electron allows one listener per session. +const TEAMS_URL_PATTERNS = ["https://teams.cloud.microsoft/*", "https://teams.microsoft.com/*"]; + const MAX_PROMPT_URL_LENGTH = 300; const truncateForPrompt = (text: string): string => @@ -342,6 +345,22 @@ export const make = Effect.gen(function* BrowserSessionMake() { ? clipboardReadDecisions.get(permissionOrigin(requestingOrigin) ?? "") === true : ALLOWED_PREVIEW_PERMISSIONS.has(permission), ); + browserSession.webRequest.onBeforeSendHeaders( + { urls: TEAMS_URL_PATTERNS }, + ({ requestHeaders }, callback) => { + const userAgent = Object.entries(requestHeaders).find( + ([name]) => name.toLowerCase() === "user-agent", + ); + if (userAgent === undefined) { + callback({}); + return; + } + const [name, value] = userAgent; + callback({ + requestHeaders: { ...requestHeaders, [name]: value.replace(/ Electron\/\S+/, "") }, + }); + }, + ); const next = new Map(sessions); next.set(partition, browserSession); return [browserSession, next] as const; diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index 1180c0fa70915..9201bc01110e8 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -37,6 +37,7 @@ function layerElectronApp( metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("T3 Code"), systemLocale: Effect.succeed("en-US"), + preferredSystemLanguages: Effect.succeed(["en-US"]), whenReady: Effect.void, requestSingleInstanceLock: Effect.succeed(true), quit: Effect.void, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 9d8efd056665b..c1bb89b759e6b 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -31,6 +31,7 @@ const layerElectronApp = Layer.succeed(ElectronApp.ElectronApp, { metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("T3 Code"), systemLocale: Effect.succeed("en-US"), + preferredSystemLanguages: Effect.succeed(["en-US"]), whenReady: Effect.void, requestSingleInstanceLock: Effect.succeed(true), quit: Effect.void, diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index a2cf8ca92ff4c..519eb48b2ca81 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -1191,6 +1191,10 @@ const mobileEndorsementRows = [ .cta-title { font-size: clamp(40px, 6vw, 72px); } + .open-head h2, + .cta-title { + text-wrap: balance; + } .cta-sub { color: var(--fg-muted); font-size: 18px; diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 784668082bb6f..97eb9b0cadcab 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -458,6 +458,9 @@ const config: ExpoConfig = { ], extra: { appVariant: APP_VARIANT, + secretsFeatureEnabled: ["1", "true"].includes( + process.env.T3CODE_FEATURE_SECRETS?.trim().toLowerCase() ?? "", + ), iosPersonalTeamBuild: isIosPersonalTeamBuild, relay: { url: repoEnv.T3CODE_RELAY_URL ?? null, diff --git a/apps/mobile/deps/README.md b/apps/mobile/deps/README.md index 939ac2d9147ed..67390ab1091c5 100644 --- a/apps/mobile/deps/README.md +++ b/apps/mobile/deps/README.md @@ -1,3 +1,5 @@ The Enriched Markdown tarball comes from Julius's [`fork/t3-mobile` branch](https://github.com/pingdotgg/enriched-markdown/tree/fork/t3-mobile). Its `t3-fork.json` records the exact source commit and upstream base. React block renderers and signed image sources (`renderers`, `resolveImageSource`) are upstream. The fork carries native streaming memory bounds (an 8 MiB measurement-key budget and coalesced pending renders), plus changes with an open upstream PR: the `onLinksChange` link list that link pills and iOS link menus read ([#1088](https://github.com/software-mansion/enriched-markdown/pull/1088)), Android inline image alignment ([#1089](https://github.com/software-mansion/enriched-markdown/pull/1089)), clipboard link metadata ([#962](https://github.com/software-mansion/enriched-markdown/pull/962)), development-host autolinks ([#961](https://github.com/software-mansion/enriched-markdown/pull/961)), and `md4cFlags.preserveWindowsPaths`, which keeps backslashes in Windows drive paths in prose ([#1105](https://github.com/software-mansion/enriched-markdown/pull/1105), after [md4c#5](https://github.com/software-mansion-labs/md4c/pull/5)). To replace it, commit the fork changes, run `yarn install --immutable` and `yarn workspace react-native-enriched-markdown prepare` in the fork checkout, then `node scripts/pack-t3.mjs /path/to/t3code/apps/mobile/deps`. Keep the package version aligned with Julius's `fork/t3-mobile` branch. Rename the archive to include the first eight characters of the source commit, such as `react-native-enriched-markdown-1.2.0-t3.16-8d2f84a8.tgz`, so each build has a distinct dependency path without version bumps. Update the tarball path in `apps/mobile/package.json` and `pnpm-workspace.yaml`'s `allowBuilds`, and run `vp i` to update the lockfile. Native clients need rebuilding when this dependency changes. + +`patches/react-native-enriched-markdown@1.2.0-t3.16.patch` writes a newline inside a copied table cell as `
`, so Copy as Markdown keeps the row whole. It is not in the fork yet; fold it into `fork/t3-mobile` or drop it when you replace the tarball, since pnpm applies it to any archive with the same version. diff --git a/apps/mobile/modules/t3-composer-editor/android/build.gradle b/apps/mobile/modules/t3-composer-editor/android/build.gradle index 9425c8f7de36b..fbb0d2d3767c0 100644 --- a/apps/mobile/modules/t3-composer-editor/android/build.gradle +++ b/apps/mobile/modules/t3-composer-editor/android/build.gradle @@ -20,6 +20,7 @@ android { dependencies { implementation project(':expo-modules-core') + implementation 'com.facebook.react:react-android' testImplementation 'junit:junit:4.13.2' testImplementation 'org.robolectric:robolectric:4.16.1' } diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index 0fab5b17da8d6..4d2a5e3a744f6 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -30,6 +30,7 @@ import android.view.inputmethod.InputConnection import android.view.inputmethod.InputConnectionWrapper import android.view.inputmethod.InputMethodManager import android.widget.EditText +import com.facebook.react.common.assets.ReactFontManager import expo.modules.kotlin.AppContext import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.views.ExpoView @@ -276,8 +277,9 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( editor.typeface = if (fontFamily.contains("Mono", ignoreCase = true)) { Typeface.MONOSPACE } else { - Typeface.DEFAULT + ReactFontManager.getInstance().getTypeface(fontFamily, Typeface.NORMAL, context.assets) } + applyLineHeight() editor.applyPlaceholder() } diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt index b181c663df54b..05aa101655fbe 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt @@ -9,6 +9,7 @@ import android.graphics.RectF import android.graphics.Typeface import android.text.TextPaint import android.text.TextUtils +import com.facebook.react.common.assets.ReactFontManager import kotlin.math.ceil import kotlin.math.min @@ -29,14 +30,14 @@ class T3ContextChip( private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { textSize = fontSize - typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + typeface = chipTypeface() color = this@T3ContextChip.color } private val em = fontSize private val detailPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { textSize = fontSize * 0.84f - typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + typeface = chipTypeface() color = this@T3ContextChip.color } private val detailText = if (content.detail.isEmpty()) "" else " ${content.detail}" @@ -86,6 +87,18 @@ class T3ContextChip( } companion object { + private const val CHIP_FONT_FAMILY = "DMSans-Medium" + + // Matches the iOS chips; builds without the bundled font keep the platform medium. + private fun chipTypeface(): Typeface { + val fonts = ReactFontManager.getInstance() + return if (CHIP_FONT_FAMILY in fonts.customFontFamilies) { + fonts.getTypeface(CHIP_FONT_FAMILY, Typeface.NORMAL, null) + } else { + Typeface.create("sans-serif-medium", Typeface.NORMAL) + } + } + fun color( value: String, fallback: Int diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt index 1291826f1c1bf..63ab13dde3d12 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt @@ -69,7 +69,8 @@ class T3ReviewDiffModule : Module() { AsyncFunction("scrollToTop") { view: T3ReviewDiffView, animated: Boolean -> view.scrollToTop(animated) } - AsyncFunction("setRowsJson") { view: T3ReviewDiffView, rowsJson: String -> + AsyncFunction("setRowsJson") { view: T3ReviewDiffView, rowsJson: String, resetKey: String -> + view.setContentResetKey(resetKey) view.setRowsJson(rowsJson) } AsyncFunction("setTokensJson") { view: T3ReviewDiffView, tokensJson: String -> diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift index caab10799c09f..2fcb8a4801809 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift @@ -102,7 +102,8 @@ public class T3ReviewDiffModule: Module { // Large, frequently changing JSON values cannot be regular Fabric props. Expo's // prop adapter compares strings on the main thread before invoking a setter, which // makes a syntax-token patch capable of blocking a frame by itself. - AsyncFunction("setRowsJson") { (view: T3ReviewDiffView, rowsJson: String) in + AsyncFunction("setRowsJson") { (view: T3ReviewDiffView, rowsJson: String, contentResetKey: String) in + view.setContentResetKey(contentResetKey) view.setRowsJson(rowsJson) } diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index 14f91964dcaf7..e5c8f6c0fd8f4 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -286,8 +286,10 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex onInput(mapOf("data" to "\r")) }, ) + val navigationKeys = TerminalNavigationKeyListener { onInput(mapOf("data" to it)) } inputView.setOnKeyListener { _, keyCode, event -> if (readOnly) return@setOnKeyListener true + if (navigationKeys.onKey(inputView, keyCode, event)) return@setOnKeyListener true if (event.action != KeyEvent.ACTION_DOWN) return@setOnKeyListener false when { keyCode == KeyEvent.KEYCODE_DEL -> { diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalNavigationKeyListener.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalNavigationKeyListener.kt new file mode 100644 index 0000000000000..3ba3ec935aa59 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalNavigationKeyListener.kt @@ -0,0 +1,28 @@ +package expo.modules.t3terminal + +import android.view.KeyEvent +import android.view.View + +internal class TerminalNavigationKeyListener( + private val send: (String) -> Unit +) : View.OnKeyListener { + override fun onKey(view: View?, keyCode: Int, event: KeyEvent): Boolean { + val sequence = sequenceFor(keyCode, event) ?: return false + if (event.action == KeyEvent.ACTION_DOWN) send(sequence) + // Unconsumed arrow and Tab key events move focus off the terminal input. + return true + } + + // Matches iOS: these keys only without modifiers, plus Shift+Tab. + private fun sequenceFor(keyCode: Int, event: KeyEvent): String? = when { + keyCode == KeyEvent.KEYCODE_TAB && event.hasModifiers(KeyEvent.META_SHIFT_ON) -> "\u001b[Z" + !event.hasNoModifiers() -> null + keyCode == KeyEvent.KEYCODE_DPAD_UP -> "\u001b[A" + keyCode == KeyEvent.KEYCODE_DPAD_DOWN -> "\u001b[B" + keyCode == KeyEvent.KEYCODE_DPAD_RIGHT -> "\u001b[C" + keyCode == KeyEvent.KEYCODE_DPAD_LEFT -> "\u001b[D" + keyCode == KeyEvent.KEYCODE_TAB -> "\t" + keyCode == KeyEvent.KEYCODE_ESCAPE -> "\u001b" + else -> null + } +} diff --git a/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalNavigationKeyListenerTest.kt b/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalNavigationKeyListenerTest.kt new file mode 100644 index 0000000000000..532160dae18b9 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalNavigationKeyListenerTest.kt @@ -0,0 +1,67 @@ +package expo.modules.t3terminal + +import android.app.Activity +import android.view.KeyEvent +import android.view.View +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36], manifest = Config.NONE) +class TerminalNavigationKeyListenerTest { + @Test + fun hardwareNavigationKeysReachThePtyAndKeepFocus() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val sent = StringBuilder() + val input = EditText(activity).apply { + setSingleLine(true) + isFocusableInTouchMode = true + setOnKeyListener(TerminalNavigationKeyListener { sent.append(it) }) + } + val nextControl = Button(activity).apply { isFocusableInTouchMode = true } + val root = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + addView(input) + addView(nextControl) + } + activity.setContentView(root) + // Focus search below the input only finds controls that have bounds. + root.measure( + View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(1920, View.MeasureSpec.EXACTLY), + ) + root.layout(0, 0, 1080, 1920) + assertTrue(input.requestFocus()) + + val shift = KeyEvent.META_SHIFT_ON or KeyEvent.META_SHIFT_LEFT_ON + val keys = listOf( + Triple(KeyEvent.KEYCODE_DPAD_UP, 0, "\u001b[A"), + Triple(KeyEvent.KEYCODE_DPAD_DOWN, 0, "\u001b[B"), + Triple(KeyEvent.KEYCODE_DPAD_RIGHT, 0, "\u001b[C"), + Triple(KeyEvent.KEYCODE_DPAD_LEFT, 0, "\u001b[D"), + Triple(KeyEvent.KEYCODE_TAB, 0, "\t"), + Triple(KeyEvent.KEYCODE_TAB, shift, "\u001b[Z"), + Triple(KeyEvent.KEYCODE_ESCAPE, 0, "\u001b"), + ) + for ((keyCode, meta, expected) in keys) { + sent.clear() + input.dispatchKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_DOWN, keyCode, 0, meta)) + input.dispatchKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_UP, keyCode, 0, meta)) + assertEquals("bytes for key $keyCode meta $meta", expected, sent.toString()) + assertTrue("focus left the input after key $keyCode meta $meta", input.isFocused) + } + + sent.clear() + val ctrl = KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON + input.dispatchKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_UP, 0, ctrl)) + assertEquals("modified arrows are left to the app, as on iOS", "", sent.toString()) + } +} diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index cd4574b6f84e5..c5d9acacba238 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -11,7 +11,7 @@ import { resolveOfficialAcpRegistryIconUrl } from "@t3tools/contracts"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { AppText as Text } from "./ui/AppText"; import { cn } from "../lib/cn"; -import { getProviderClient } from "@t3tools/client-runtime/provider-clients"; +import { getProviderClient, openRouterIcon } from "@t3tools/client-runtime/provider-clients"; type ProviderIconProps = { readonly provider: string | null | undefined; @@ -99,7 +99,8 @@ export function ProviderIcon(props: ProviderIconProps) { ); } - const packageIcon = getProviderClient(props.provider)?.icon; + const packageIcon = + props.provider === "openrouter" ? openRouterIcon : getProviderClient(props.provider)?.icon; if (packageIcon) { const themeFill = (fill: { readonly light: string; readonly dark: string }) => isDarkMode ? fill.dark : fill.light; diff --git a/apps/mobile/src/components/ui/AppBar.android.tsx b/apps/mobile/src/components/ui/AppBar.android.tsx index f312999c58d14..9708048704235 100644 --- a/apps/mobile/src/components/ui/AppBar.android.tsx +++ b/apps/mobile/src/components/ui/AppBar.android.tsx @@ -155,13 +155,15 @@ function AppBarView(props: AppBarProps & { readonly searchState?: SearchState }) )); const confirmButton = props.confirm ? ( - + {props.onRetryCatalog ? ( + + ) : null} {props.downloading ? ( ) : null} - + {model ? ( + + ) : null} ) : ( <> diff --git a/apps/web/src/components/chat/useSideChat.ts b/apps/web/src/components/chat/useSideChat.ts index 1587526528987..1083ca819c6dc 100644 --- a/apps/web/src/components/chat/useSideChat.ts +++ b/apps/web/src/components/chat/useSideChat.ts @@ -1,4 +1,4 @@ -import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { ModelSelection, RuntimeMode, ScopedThreadRef } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations"; import { @@ -9,6 +9,7 @@ import { import { useRef } from "react"; import { newMessageId, newThreadId } from "~/lib/utils"; +import { useComposerDraftStore } from "../../composerDraftStore"; import { useStableCallback } from "../../hooks/useStableCallback"; import { useRightPanelStore } from "../../rightPanelStore"; import { readThreadShell, waitForThreadShell } from "../../state/entities"; @@ -18,16 +19,24 @@ import { useOrchestrationCommand } from "../../state/use-orchestration-command"; /** * Starts side chats from `threadRef`, or offers none when it is null. `open` forks the - * thread's latest stable point, sends `prompt` there, and shows the side chat's tab. It - * resolves false when the side chat did not start, or another one is still starting. + * thread's latest stable point, sends `prompt` there with `readSettings()`, the parent + * composer's settings, and shows the tab. It resolves false when the side chat did not + * start, or another one is still starting. */ export function useSideChat(input: { readonly threadRef: ScopedThreadRef | null; + readonly readSettings: () => { + readonly runtimeMode: RuntimeMode; + readonly modelSelection: ModelSelection | undefined; + }; readonly onError: (message: string) => void; }) { const pendingRef = useRef(false); const forkSideChat = useAtomCommand(threadEnvironment.forkSideChat, { reportFailure: false }); const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); + const setThreadRuntimeMode = useOrchestrationCommand(threadEnvironment.setRuntimeMode, { + reportFailure: false, + }); const startThreadTurn = useOrchestrationCommand(threadEnvironment.startTurn, { reportFailure: false, }); @@ -40,6 +49,7 @@ export function useSideChat(input: { input.onError(error instanceof Error ? error.message : fallback); }; const start = async (threadRef: ScopedThreadRef, prompt: string | undefined) => { + const { runtimeMode, modelSelection } = input.readSettings(); const targetThreadId = newThreadId(); const fork = await forkSideChat({ environmentId: threadRef.environmentId, @@ -67,6 +77,17 @@ export function useSideChat(input: { discard(); return false; } + if (runtimeMode !== sideChat.runtimeMode) { + const mode = await setThreadRuntimeMode({ + environmentId: threadRef.environmentId, + input: { threadId: targetThreadId, runtimeMode, createdAt: new Date().toISOString() }, + }); + if (mode._tag === "Failure") { + reportFailure(mode, "Could not set the side chat's permission mode."); + discard(); + return false; + } + } const text = prompt?.trim(); // Send before showing the tab: closing a tab whose side chat has no run deletes it. if (text) { @@ -75,8 +96,9 @@ export function useSideChat(input: { input: { threadId: targetThreadId, message: { messageId: newMessageId(), role: "user", text, attachments: [] }, + ...(modelSelection === undefined ? {} : { modelSelection }), titleSeed: deriveThreadTitleSeed({ text, attachments: [] }), - runtimeMode: sideChat.runtimeMode, + runtimeMode, interactionMode: "default", createdAt: new Date().toISOString(), }, @@ -86,6 +108,8 @@ export function useSideChat(input: { discard(); return false; } + } else if (modelSelection !== undefined) { + useComposerDraftStore.getState().setModelSelection(targetRef, modelSelection); } useRightPanelStore.getState().openSideChat(threadRef, targetThreadId); return true; diff --git a/apps/web/src/components/preview/PreviewAnnotationSpeech.tsx b/apps/web/src/components/preview/PreviewAnnotationSpeech.tsx index 5bb06553b252d..8d52066cd5f7a 100644 --- a/apps/web/src/components/preview/PreviewAnnotationSpeech.tsx +++ b/apps/web/src/components/preview/PreviewAnnotationSpeech.tsx @@ -61,6 +61,7 @@ export function PreviewAnnotationSpeech({ projectName: project?.title, ownerKey: JSON.stringify(["preview-annotation", threadRef, tabId, config.sessionId]), draftText: text, + offerSetup: false, readDraft: () => ({ text: draft.current.text, selection: { start: draft.current.cursor, end: draft.current.selectionEnd }, @@ -183,9 +184,11 @@ export function PreviewAnnotationSpeech({ step={speech.setup.step} status={speech.status} model={speech.setup.model} + language={speech.setup.language} downloading={speech.setup.downloading} error={speech.setup.error} onOpenChange={speech.setup.setOpen} + onRetryCatalog={speech.setup.retryCatalog} onDownload={() => void speech.setup.download()} onCancelDownload={() => void speech.setup.cancelDownload()} onStartRecording={() => void speech.setup.startRecording()} diff --git a/apps/web/src/components/settings/AcpSessionManagementSection.browser.test.tsx b/apps/web/src/components/settings/AcpSessionManagementSection.browser.test.tsx index a61814ae3efa6..824d4a28f99a1 100644 --- a/apps/web/src/components/settings/AcpSessionManagementSection.browser.test.tsx +++ b/apps/web/src/components/settings/AcpSessionManagementSection.browser.test.tsx @@ -7,6 +7,7 @@ import { type ServerProvider, type AcpRegistryConfigurableProvider, } from "@t3tools/contracts"; +import type { ComponentProps } from "react"; import { expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; @@ -79,16 +80,28 @@ const provider = { skills: [], } satisfies ServerProvider; -async function renderSection(readOnly = false) { - const screen = await render( +const project = { id: projectId, title: "Antigravity", workspaceRoot: session.cwd }; +const replacement = { + id: ProjectId.make("replacement"), + title: "Replacement", + workspaceRoot: "/replacement", +}; +type Projects = ComponentProps["projects"]; + +async function renderSection(options: { canList?: boolean; projects?: Projects } = {}) { + const section = (projects = options.projects ?? [project]) => ( , + provider={{ + ...provider, + nativeSessions: { ...provider.nativeSessions, canList: options.canList ?? true }, + }} + projects={projects} + readOnly={false} + /> ); + const screen = await render(section()); const button = (label: string) => { const found = [...screen.container.querySelectorAll("button")].find( (element) => element.textContent === label, @@ -99,6 +112,7 @@ async function renderSection(readOnly = false) { return Object.assign(screen, { button, click: (label: string) => act(async () => button(label).click()), + setProjects: (projects: Projects) => screen.rerender(section(projects)), }); } @@ -224,3 +238,210 @@ it.each(["Save", "Disable"])("keeps other provider edits through a %s refetch", expect(input("other base URL").value).toBe("https://api.example.test/v1"); expect(input("other write-only headers JSON").value).toBe(""); }); + +it.each([true, false])( + "enables project actions after projects arrive (sessions: %s)", + async (canList) => { + setup(); + await using screen = await renderSection({ canList, projects: [] }); + await screen.setProjects([project]); + const action = screen.getByRole("button", { + name: canList ? "List sessions" : "List providers", + }); + await expect.element(action).toBeEnabled(); + if (!canList) { + await expect.element(screen.getByRole("combobox")).not.toBeInTheDocument(); + } + await action.click(); + if (canList) { + await expect.element(screen.getByText(session.title)).toBeVisible(); + expect(commands.list).toHaveBeenCalledWith({ + environmentId, + input: { instanceId, projectId }, + }); + } else { + await expect + .element(screen.getByRole("textbox", { name: "google base URL" })) + .toHaveValue("https://api.example.test/v1"); + expect(commands.listProviders).toHaveBeenCalledWith({ + environmentId, + input: { instanceId, projectId }, + }); + } + }, +); + +it.each([true, false])( + "preserves a valid selection and clears a removed project's data (sessions: %s)", + async (canList) => { + setup(); + const second = { id: ProjectId.make("second"), title: "Second", workspaceRoot: "/second" }; + commands.list.mockResolvedValue({ + _tag: "Success", + value: { sessions: [session], nextCursor: "page-2" }, + }); + await using screen = await renderSection({ canList, projects: [project, second] }); + await screen.getByRole("combobox").click(); + if (canList) await userEvent.keyboard("{ArrowDown}{Enter}"); + else await screen.getByRole("option", { name: "Second", exact: true }).click(); + await expect + .element( + screen.getByRole("combobox", { + name: canList ? "Project for ACP sessions" : "Project for ACP providers", + }), + ) + .toHaveFocus(); + if (canList) await screen.getByRole("button", { name: "List sessions" }).click(); + await screen.getByRole("button", { name: "List providers" }).click(); + await userEvent.fill( + screen.getByRole("textbox", { name: "google base URL" }), + "https://draft.test", + ); + await userEvent.fill( + screen.getByLabelText("google write-only headers JSON"), + '{"Secret":"draft"}', + ); + + await screen.setProjects([project, replacement, second]); + await expect + .element( + screen.getByRole("combobox", { + name: canList ? "Project for ACP sessions" : "Project for ACP providers", + }), + ) + .toHaveTextContent("Second"); + await expect + .element(screen.getByRole("textbox", { name: "google base URL" })) + .toHaveValue("https://draft.test"); + await screen.getByRole("button", { name: "Save", exact: true }).click(); + expect(commands.setProvider).toHaveBeenLastCalledWith({ + environmentId, + input: { + instanceId, + projectId: second.id, + providerId: "google", + apiType: "openai", + baseUrl: "https://draft.test", + headers: { Secret: "draft" }, + }, + }); + + await screen.setProjects([project]); + await expect.element(screen.getByText(session.title)).not.toBeInTheDocument(); + await expect.element(screen.getByRole("button", { name: "Load more" })).not.toBeInTheDocument(); + await expect + .element(screen.getByRole("textbox", { name: "google base URL" })) + .not.toBeInTheDocument(); + await screen.getByRole("button", { name: "List providers" }).click(); + expect(commands.listProviders).toHaveBeenLastCalledWith({ + environmentId, + input: { instanceId, projectId }, + }); + await expect.element(screen.getByLabelText("google write-only headers JSON")).toHaveValue(""); + await expect + .element(screen.getByRole("textbox", { name: "google base URL" })) + .toHaveValue("https://api.example.test/v1"); + + await screen.setProjects([second, project]); + await expect + .element( + screen.getByRole("combobox", { + name: canList ? "Project for ACP sessions" : "Project for ACP providers", + }), + ) + .toHaveTextContent("Antigravity"); + await expect + .element(screen.getByRole("textbox", { name: "google base URL" })) + .toHaveValue("https://api.example.test/v1"); + + await screen.setProjects([]); + await expect.element(screen.getByRole("button", { name: "List providers" })).toBeDisabled(); + await expect + .element(screen.getByRole("textbox", { name: "google base URL" })) + .not.toBeInTheDocument(); + }, +); + +it("ignores a late listing after project replacement", async () => { + setup(); + let finish!: (value: unknown) => void; + commands.list.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ); + await using screen = await renderSection(); + await screen.click("List sessions"); + await screen.setProjects([replacement]); + await act(async () => + finish({ _tag: "Success", value: { sessions: [session], nextCursor: "old-page" } }), + ); + await expect.element(screen.getByText(session.title)).not.toBeInTheDocument(); + await expect.element(screen.getByRole("button", { name: "Load more" })).not.toBeInTheDocument(); + await expect.element(screen.getByRole("button", { name: "List sessions" })).toBeEnabled(); +}); + +it.each(["Save", "Disable"])("does not refetch the old project after a late %s", async (action) => { + setup(); + const command = action === "Save" ? commands.setProvider : commands.disableProvider; + let finish!: () => void; + command.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }).then(() => ({ + _tag: "Success", + value: action === "Save" ? { configured: true } : { disabled: true }, + })), + ); + await using screen = await renderSection(); + await screen.click("List providers"); + await screen.click(action); + expect(command).toHaveBeenCalledOnce(); + await screen.setProjects([replacement]); + await act(async () => finish()); + expect(commands.listProviders).toHaveBeenCalledOnce(); + await expect.element(screen.getByRole("button", { name: "List providers" })).toBeEnabled(); +}); + +it.each(["Delete", "Disable"])( + "does not act on a replaced project after confirming %s", + async (action) => { + setup(); + let finish!: (confirmed: boolean) => void; + dialogs.confirm.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ); + await using screen = await renderSection(); + await screen.click(action === "Delete" ? "List sessions" : "List providers"); + await screen.click(action); + await screen.setProjects([replacement]); + await act(async () => finish(true)); + expect(action === "Delete" ? commands.delete : commands.disableProvider).not.toHaveBeenCalled(); + }, +); + +it("keeps logout pending across project replacement and clears replacement sessions", async () => { + setup(); + let finish!: () => void; + commands.logout.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }).then(() => ({ + _tag: "Success", + value: { loggedOut: true }, + })), + ); + await using screen = await renderSection(); + await screen.click("Log out"); + await screen.setProjects([replacement]); + await screen.click("List sessions"); + await expect.element(screen.getByText(session.title)).toBeVisible(); + await expect.element(screen.getByRole("button", { name: "Logging out" })).toBeDisabled(); + await screen.click("Logging out"); + expect(commands.logout).toHaveBeenCalledOnce(); + await act(async () => finish()); + await expect.element(screen.getByText(session.title)).not.toBeInTheDocument(); + await expect.element(screen.getByRole("button", { name: "Log out", exact: true })).toBeEnabled(); +}); diff --git a/apps/web/src/components/settings/AcpSessionManagementSection.tsx b/apps/web/src/components/settings/AcpSessionManagementSection.tsx index 675294497b118..d187633aa3172 100644 --- a/apps/web/src/components/settings/AcpSessionManagementSection.tsx +++ b/apps/web/src/components/settings/AcpSessionManagementSection.tsx @@ -17,7 +17,7 @@ import { squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; -import { useState } from "react"; +import { useLayoutEffect, useRef, useState } from "react"; import { ensureLocalApi } from "../../localApi"; import { serverEnvironment } from "../../state/server"; @@ -70,16 +70,49 @@ function parseProviderHeaders( return { ok: true, headers: parsed as Record }; } -export function AcpSessionManagementSection(props: { +interface AcpSessionManagementProps { readonly environmentId: EnvironmentId; readonly instanceId: ProviderInstanceId; readonly provider: ServerProvider; readonly projects: ReadonlyArray; readonly readOnly: boolean; -}) { +} + +export function AcpSessionManagementSection(props: AcpSessionManagementProps) { + const [selectedProjectId, setSelectedProjectId] = useState(null); + const projectId = + props.projects.find((project) => project.id === selectedProjectId)?.id ?? + props.projects[0]?.id ?? + null; + if (selectedProjectId !== projectId) setSelectedProjectId(projectId); + + return ( + + ); +} + +function InstanceSessionManagement( + props: AcpSessionManagementProps & { + readonly projectId: ProjectId | null; + readonly onProjectChange: (projectId: ProjectId) => void; + }, +) { const canOperate = useEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope); const canRead = useEnvironmentScope(props.environmentId, AuthOrchestrationReadScope); - const [projectId, setProjectId] = useState(props.projects[0]?.id ?? null); + const { projectId } = props; + const projectGeneration = useRef(0); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Late continuations must not act on a replaced project scope. + useLayoutEffect(() => { + return () => { + projectGeneration.current += 1; + }; + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Retiring a project invalidates its pending operations even when the instance stays mounted. + }, [projectId]); const [sessions, setSessions] = useState>([]); const [nextCursor, setNextCursor] = useState(null); const [loading, setLoading] = useState(false); @@ -92,6 +125,19 @@ export function AcpSessionManagementSection(props: { const [providerDrafts, setProviderDrafts] = useState< Readonly> >({}); + const [previousProjectId, setPreviousProjectId] = useState(projectId); + if (previousProjectId !== projectId) { + setPreviousProjectId(projectId); + setSessions([]); + setNextCursor(null); + setLoading(false); + setImportingSessionId(null); + setDeletingSessionId(null); + setProviders([]); + setLoadingProviders(false); + setSavingProviderId(null); + setProviderDrafts({}); + } const listSessions = useAtomCommand(serverEnvironment.listAcpRegistrySessions, { reportFailure: false, }); @@ -129,6 +175,7 @@ export function AcpSessionManagementSection(props: { if (!canList && !canLogout && !canConfigureProviders) return null; const loadSessions = async (cursor?: string) => { + const generation = projectGeneration.current; if (projectId === null || loading) return; setLoading(true); const result = await listSessions({ @@ -139,6 +186,7 @@ export function AcpSessionManagementSection(props: { ...(cursor === undefined ? {} : { cursor }), }, }); + if (generation !== projectGeneration.current) return; setLoading(false); if (result._tag === "Success") { setSessions((current) => @@ -151,6 +199,7 @@ export function AcpSessionManagementSection(props: { }; const importNativeSession = async (session: AcpRegistrySession) => { + const generation = projectGeneration.current; if ( !readEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope) || projectId === null || @@ -168,6 +217,7 @@ export function AcpSessionManagementSection(props: { updatedAt: session.updatedAt, }, }); + if (generation !== projectGeneration.current) return; setImportingSessionId(null); if (result._tag === "Success") { setSessions((current) => @@ -187,19 +237,25 @@ export function AcpSessionManagementSection(props: { }; const deleteNativeSession = async (session: AcpRegistrySession) => { + const generation = projectGeneration.current; if (projectId === null || deletingSessionId !== null || session.importedThreadId !== null) return; const confirmed = await ensureLocalApi().dialogs.confirm( `Permanently delete native ACP session "${session.title ?? session.sessionId}"?`, { variant: "destructive" }, ); - if (!confirmed || !readEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope)) + if ( + generation !== projectGeneration.current || + !confirmed || + !readEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope) + ) return; setDeletingSessionId(session.sessionId); const result = await deleteSession({ environmentId: props.environmentId, input: { instanceId: props.instanceId, projectId, sessionId: session.sessionId }, }); + if (generation !== projectGeneration.current) return; setDeletingSessionId(null); if (result._tag === "Success") { setSessions((current) => @@ -212,12 +268,14 @@ export function AcpSessionManagementSection(props: { }; const loadProviders = async (resetProviderId?: string) => { + const generation = projectGeneration.current; if (projectId === null || loadingProviders) return; setLoadingProviders(true); const result = await listProviders({ environmentId: props.environmentId, input: { instanceId: props.instanceId, projectId }, }); + if (generation !== projectGeneration.current) return; setLoadingProviders(false); if (result._tag === "Success") { setProviders(result.value.providers); @@ -241,6 +299,7 @@ export function AcpSessionManagementSection(props: { }; const saveProvider = async (provider: AcpRegistryConfigurableProvider) => { + const generation = projectGeneration.current; if ( !readEnvironmentScope(props.environmentId, AuthProvidersManageScope) || projectId === null || @@ -272,6 +331,7 @@ export function AcpSessionManagementSection(props: { ...(headers === undefined ? {} : { headers }), }, }); + if (generation !== projectGeneration.current) return; setSavingProviderId(null); if (result._tag === "Success") { toastManager.add({ type: "success", title: "ACP provider configured" }); @@ -282,18 +342,25 @@ export function AcpSessionManagementSection(props: { }; const disableConfiguredProvider = async (provider: AcpRegistryConfigurableProvider) => { + const generation = projectGeneration.current; if (projectId === null || savingProviderId !== null || loadingProviders || provider.required) return; const confirmed = await ensureLocalApi().dialogs.confirm( `Disable ACP provider "${provider.providerId}"?`, { variant: "destructive" }, ); - if (!confirmed || !readEnvironmentScope(props.environmentId, AuthProvidersManageScope)) return; + if ( + generation !== projectGeneration.current || + !confirmed || + !readEnvironmentScope(props.environmentId, AuthProvidersManageScope) + ) + return; setSavingProviderId(provider.providerId); const result = await disableProvider({ environmentId: props.environmentId, input: { instanceId: props.instanceId, projectId, providerId: provider.providerId }, }); + if (generation !== projectGeneration.current) return; setSavingProviderId(null); if (result._tag === "Success") { toastManager.add({ type: "success", title: "ACP provider disabled" }); @@ -350,11 +417,7 @@ export function AcpSessionManagementSection(props: { disabled={!canRead || projectOperationPending} onValueChange={(value) => { if (value === null) return; - setProjectId(value as ProjectId); - setSessions([]); - setNextCursor(null); - setProviders([]); - setProviderDrafts({}); + props.onProjectChange(value as ProjectId); }} > @@ -485,9 +548,7 @@ export function AcpSessionManagementSection(props: { disabled={!canRead || projectOperationPending} onValueChange={(value) => { if (value === null) return; - setProjectId(value as ProjectId); - setProviders([]); - setProviderDrafts({}); + props.onProjectChange(value as ProjectId); }} > diff --git a/apps/web/src/components/settings/ArchivedThreadsPanel.tsx b/apps/web/src/components/settings/ArchivedThreadsPanel.tsx index 8aacb39c50320..a694ba5750596 100644 --- a/apps/web/src/components/settings/ArchivedThreadsPanel.tsx +++ b/apps/web/src/components/settings/ArchivedThreadsPanel.tsx @@ -1,10 +1,10 @@ import { settingsScopeEnvironmentLabels } from "./settingsScopeAxis"; import { Spinner } from "~/components/ui/spinner"; -import { ArchiveIcon, ArchiveX } from "lucide-react"; -import { type ScopedThreadRef } from "@t3tools/contracts"; -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { ArchiveIcon, ArchiveX, Trash2Icon } from "lucide-react"; +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { presentThreadShell } from "@t3tools/client-runtime/state/shell"; import { + type AtomCommandResult, isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure, @@ -20,9 +20,20 @@ import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsL import { searchableSetting } from "./settingsSearch"; import { ProjectFavicon } from "../ProjectFavicon"; +import { useRef, useState } from "react"; +import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; + +const ARCHIVE_ACTIONS = [ + { action: "unarchive", label: "Unarchive", Icon: ArchiveX }, + { action: "delete", label: "Delete", Icon: Trash2Icon }, +] as const; +type ArchiveAction = (typeof ARCHIVE_ACTIONS)[number]["action"]; + export function ArchivedThreadsPanel() { const { scope, groups: projectGroups, environments } = useSettingsScope(); const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); + const pendingActionsRef = useRef(new Set()); + const [pendingKeys, setPendingKeys] = useState>(() => new Set()); const { snapshots: archivedSnapshots, error: archiveError, @@ -122,52 +133,57 @@ export function ArchivedThreadsPanel() { return { groups: sortedGroups, hasMultipleEnvironments }; })(); - const handleArchivedThreadContextMenu = async ( - threadRef: ScopedThreadRef, - position: { x: number; y: number }, - ) => { - const api = readLocalApi(); - if (!api) return; - const clicked = await api.contextMenu.show( - [ - { id: "unarchive", label: "Unarchive" }, - { id: "delete", label: "Delete", destructive: true }, - ], - position, + type ArchivedThread = (typeof archivedGroups)[number]["threads"][number]; + + const reportFailure = (result: AtomCommandResult | null, title: string) => { + if (result?._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : "An error occurred.", + }), ); + }; - if (clicked === "unarchive") { - const result = await unarchiveThread(threadRef); - if (result._tag === "Success") { - refreshArchivedThreads(); - } else if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to unarchive thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; - } + const handleThreadAction = async (action: ArchiveAction, thread: ArchivedThread) => { + const target = scopeThreadRef(thread.environmentId, thread.id); + const key = scopedThreadKey(target); + // Context menus can outlive their render, so checks and cleanup use the live set. + const pending = pendingActionsRef.current; + if (pending.has(key)) return; + pending.add(key); + setPendingKeys(new Set(pending)); + const result = await settlePromise(async () => { + const result = await (action === "unarchive" + ? unarchiveThread(target) + : confirmAndDeleteThread(target, thread.title)); + if (result._tag === "Success") refreshArchivedThreads(); + return result; + }).finally(() => { + pending.delete(key); + setPendingKeys(new Set(pending)); + }); + reportFailure(result._tag === "Failure" ? result : result.value, `Failed to ${action} thread`); + }; - if (clicked === "delete") { - const result = await confirmAndDeleteThread(threadRef); - if (result._tag === "Success") { - refreshArchivedThreads(); - } else if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to delete thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - } + const handleContextMenu = async (thread: ArchivedThread, position: { x: number; y: number }) => { + const pending = pendingActionsRef.current; + if (pending.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)))) return; + const result = await settlePromise(async () => { + const clicked = await readLocalApi()?.contextMenu.show( + ARCHIVE_ACTIONS.map(({ action, label }) => ({ + id: action, + label, + destructive: action === "delete", + })), + position, + ); + if (clicked === "unarchive" || clicked === "delete") + await handleThreadAction(clicked, thread); + }); + reportFailure(result, "Archived thread action failed"); }; return ( @@ -207,78 +223,50 @@ export function ArchivedThreadsPanel() { title={title} icon={} > - {projectThreads.map((thread) => ( - { - event.preventDefault(); - void (async () => { - const result = await settlePromise(() => - handleArchivedThreadContextMenu( - scopeThreadRef(thread.environmentId, thread.id), - { - x: event.clientX, - y: event.clientY, - }, - ), - ); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Archived thread action failed", - description: - error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - }} - title={thread.title} - description={ - - {hasMultipleEnvironments ? `${thread.environmentLabel} ยท ` : null} - Archived - {" \u00b7 Created "} - - - } - control={ - - } - /> - ))} + {projectThreads.map((thread) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + return ( + { + event.preventDefault(); + void handleContextMenu(thread, { x: event.clientX, y: event.clientY }); + }} + title={thread.title} + description={ + + {hasMultipleEnvironments ? `${thread.environmentLabel} ยท ` : null} + Archived + {" \u00b7 Created "} + + + } + control={ +
+ {ARCHIVE_ACTIONS.map(({ action, label, Icon }) => ( + + void handleThreadAction(action, thread)} + /> + } + > + + + {label} + + ))} +
+ } + /> + ); + })} )) )} diff --git a/apps/web/src/components/settings/ChatGptAccountPicker.tsx b/apps/web/src/components/settings/ChatGptAccountPicker.tsx index f3eaa907765d1..f3f113a916039 100644 --- a/apps/web/src/components/settings/ChatGptAccountPicker.tsx +++ b/apps/web/src/components/settings/ChatGptAccountPicker.tsx @@ -9,6 +9,7 @@ import { DialogDescription, DialogFooter, } from "../ui/dialog"; +import { Button } from "../ui/button"; import { ChatGptConnectionButton } from "./ChatGptConnectionButton"; export function ChatGptAccountPicker({ @@ -16,11 +17,19 @@ export function ChatGptAccountPicker({ methods, onClose, onSelect, + onRemove, + disabled, + canRemove, + error, }: { open: boolean; methods: readonly ProviderAuthMethod[]; onClose: () => void; onSelect: (methodId: string) => void; + onRemove: (methodId: string) => void; + disabled: boolean; + canRemove: boolean; + error: string | null; }) { const profiles = methods.filter((method) => method.id.startsWith("chatgpt-profile:")); const [selection, setSelection] = useState(null); @@ -47,6 +56,7 @@ export function ChatGptAccountPicker({
setSelection(value)} > @@ -65,8 +75,22 @@ export function ChatGptAccountPicker({
+ {error ? ( +

+ {error} +

+ ) : null} - onSelect(selectedMethodId)} /> + {profiles.find((profile) => profile.id === selectedMethodId)?.canRemove ? ( + + ) : null} + onSelect(selectedMethodId)} /> diff --git a/apps/web/src/components/settings/CloudVoiceConfiguration.browser.test.tsx b/apps/web/src/components/settings/CloudVoiceConfiguration.browser.test.tsx new file mode 100644 index 0000000000000..0295603ed2038 --- /dev/null +++ b/apps/web/src/components/settings/CloudVoiceConfiguration.browser.test.tsx @@ -0,0 +1,227 @@ +import { PrimaryConnectionTarget } from "@t3tools/client-runtime/connection"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; +import { render } from "../../../test/render"; +import "~/index.css"; +import { chooseAudioDestination } from "../../speech/voiceConsent"; +import { useEnvironmentScope, usePreparedConnection } from "../../state/session"; +import { VoiceSettingsPanel } from "./VoiceSettingsPanel"; +import { SettingsScopeProvider } from "./SettingsScopeContext"; + +vi.mock(import("../../state/session"), { spy: true }); +vi.mock("../../env", async (original) => ({ + ...(await original()), + isSecretsFeatureEnabled: true, +})); +vi.mock("../../state/environments", async (importOriginal) => ({ + ...(await importOriginal()), + useEnvironments: () => ({ + environments: [ + { + environmentId: "voice-host", + label: "Laptop", + connection: { phase: "available" }, + serverConfig: { + secretReferences: true, + settings: { + secretProviders: { + work: { kind: "one_password", label: "Work vault", config: { account: "work" } }, + }, + }, + environment: { + platform: { os: "linux", arch: "x64" }, + capabilities: { voiceTranscription: true, cloudVoiceTranscription: true }, + }, + }, + }, + ], + }), +})); + +it("enables the chosen provider only after a successful setup and keeps its grant when editing the key", async () => { + const id = EnvironmentId.make("voice-host"); + const target = new PrimaryConnectionTarget({ + environmentId: id, + label: "Laptop", + httpBaseUrl: window.location.origin, + wsBaseUrl: "ws://localhost", + }); + const prepared = Option.some({ + ...target, + target, + socketUrl: target.wsBaseUrl, + httpAuthorization: null, + }); + using cleanup = new DisposableStack(); + cleanup.defer(() => chooseAudioDestination(null)); + chooseAudioDestination({ kind: "environment", environmentId: id }); + const writes: unknown[] = []; + let rejectNextWrite = true; + const fetch = globalThis.fetch; + using _network = vi.spyOn(globalThis, "fetch").mockImplementation((request, init) => { + const url = new URL(request instanceof Request ? request.url : request, window.location.href); + if (url.pathname === "/api/voice/cloud/keys") + return Promise.resolve(Response.json({ openai: writes.length > 0, xai: true })); + if (url.pathname === "/api/voice/cloud/openai/key") { + if (rejectNextWrite) { + rejectNextWrite = false; + return Promise.resolve(new Response(null, { status: 403 })); + } + return new Request(request, init).json().then((body) => { + writes.push(body); + return new Response(null, { status: 204 }); + }); + } + return fetch(request, init); + }); + await vi.mocked(useEnvironmentScope).withImplementation( + () => true, + async () => { + await vi.mocked(usePreparedConnection).withImplementation( + () => prepared, + async () => { + await using screen = await render( + {}}> + + , + ); + const destination = screen.getByRole("combobox", { name: "Transcribe with" }); + await expect.element(destination).toHaveTextContent("Laptop"); + await destination.click(); + await page.getByRole("option", { name: "OpenAI via Laptop" }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect.element(destination).toHaveTextContent("Laptop"); + expect(writes).toEqual([]); + await destination.click(); + await page.getByRole("option", { name: "OpenAI via Laptop" }).click(); + await dialog.getByRole("combobox", { name: "OpenAI API key source" }).click(); + await page.getByRole("option", { name: "Work vault" }).click(); + await dialog.getByLabelText("OpenAI API key reference").fill("op://Work/Voice/key"); + await userEvent.keyboard("{Enter}"); + await expect.element(dialog.getByRole("alert")).toBeVisible(); + expect(writes).toEqual([]); + await expect + .element(dialog.getByRole("button", { name: "Save and enable" })) + .toBeEnabled(); + await dialog.getByLabelText("OpenAI API key reference").click(); + await userEvent.keyboard("{Enter}"); + await expect.element(dialog).not.toBeInTheDocument(); + await expect.element(destination).toHaveTextContent("OpenAI via Laptop"); + expect(writes).toEqual([{ key: { provider: "work", reference: "op://Work/Voice/key" } }]); + const grant = window.localStorage.getItem("t3code:voice-audio-grant:v1"); + await screen.getByRole("button", { name: "Manage key" }).click(); + await dialog.getByLabelText("OpenAI API key", { exact: true }).fill("replacement"); + await userEvent.keyboard("{Enter}"); + await expect.element(dialog).not.toBeInTheDocument(); + expect(writes.at(-1)).toEqual({ key: "replacement" }); + expect(window.localStorage.getItem("t3code:voice-audio-grant:v1")).toBe(grant); + await destination.click(); + await page.getByRole("option", { name: "Grok Voice Transcribe via Laptop" }).click(); + await dialog.getByRole("button", { name: "Use Grok Voice Transcribe" }).click(); + await expect.element(destination).toHaveTextContent("Grok Voice Transcribe via Laptop"); + await destination.click(); + await page.getByRole("option", { name: "Off", exact: true }).click(); + await expect.element(destination).toHaveTextContent("Off"); + }, + ); + }, + ); +}); + +it("OpenRouter needs an exact model, and changing it writes no credential", async () => { + const id = EnvironmentId.make("voice-host"); + const target = new PrimaryConnectionTarget({ + environmentId: id, + label: "Laptop", + httpBaseUrl: window.location.origin, + wsBaseUrl: "ws://localhost", + }); + const prepared = Option.some({ + ...target, + target, + socketUrl: target.wsBaseUrl, + httpAuthorization: null, + }); + using cleanup = new DisposableStack(); + cleanup.defer(() => chooseAudioDestination(null)); + chooseAudioDestination(null); + const writes: unknown[] = []; + const fetch = globalThis.fetch; + using _network = vi.spyOn(globalThis, "fetch").mockImplementation((request, init) => { + const url = new URL(request instanceof Request ? request.url : request, window.location.href); + if (url.pathname === "/api/voice/cloud/keys") + return Promise.resolve(Response.json({ openai: false, xai: false, openrouter: true })); + if (url.pathname === "/api/voice/cloud/openrouter/models") + return Promise.resolve( + Response.json({ + models: [ + { id: "acme/transcribe-1", name: "Acme: Transcribe 1" }, + { id: "other/listen-2", name: "Other: Listen 2" }, + ], + }), + ); + if (url.pathname === "/api/voice/cloud/openrouter/key") + return new Request(request, init).json().then((body) => { + writes.push(body); + return new Response(null, { status: 204 }); + }); + return fetch(request, init); + }); + const grantKey = "t3code:voice-audio-grant:v1"; + const grantModel = () => { + const raw = window.localStorage.getItem(grantKey); + const parsed = raw ? (JSON.parse(raw) as { destination?: { model?: string } }) : null; + return parsed?.destination?.model ?? null; + }; + await vi.mocked(useEnvironmentScope).withImplementation( + () => true, + async () => { + await vi.mocked(usePreparedConnection).withImplementation( + () => prepared, + async () => { + await using screen = await render( + {}}> + + , + ); + const destination = screen.getByRole("combobox", { name: "Transcribe with" }); + await destination.click(); + await page.getByRole("option", { name: "OpenRouter via Laptop" }).click(); + const dialog = page.getByRole("dialog"); + const save = dialog.getByRole("button", { name: "Save and enable" }); + await expect.element(save).toBeDisabled(); + const picker = dialog.getByRole("combobox", { name: "OpenRouter model" }); + await expect.element(picker).toBeVisible(); + await picker.click(); + await userEvent.keyboard("listen"); + await expect.element(page.getByRole("option", { name: /Other: Listen 2/ })).toBeVisible(); + await page.getByRole("option", { name: /Other: Listen 2/ }).click(); + await expect.element(save).toBeEnabled(); + const key = dialog.getByLabelText("OpenRouter API key", { exact: true }); + await key.fill("op://not-a-literal-key"); + await expect.element(save).toBeDisabled(); + await key.fill(""); + await save.click(); + await expect.element(dialog).not.toBeInTheDocument(); + await expect.element(destination).toHaveTextContent("OpenRouter via Laptop"); + expect(grantModel()).toBe("other/listen-2"); + expect(writes).toEqual([]); + await screen.getByRole("button", { name: "Manage OpenRouter" }).click(); + const current = dialog.getByRole("combobox", { name: "OpenRouter model" }); + await expect.element(current).toHaveValue("Other: Listen 2"); + await userEvent.clear(current); + await userEvent.type(current, "acme"); + await page.getByRole("option", { name: /Acme: Transcribe 1/ }).click(); + await dialog.getByRole("button", { name: "Save" }).click(); + await expect.element(dialog).not.toBeInTheDocument(); + await expect.element(destination).toHaveTextContent("OpenRouter via Laptop"); + expect(grantModel()).toBe("acme/transcribe-1"); + expect(writes).toEqual([]); + }, + ); + }, + ); +}); diff --git a/apps/web/src/components/settings/CloudVoiceConfiguration.tsx b/apps/web/src/components/settings/CloudVoiceConfiguration.tsx new file mode 100644 index 0000000000000..95dcee4cffb45 --- /dev/null +++ b/apps/web/src/components/settings/CloudVoiceConfiguration.tsx @@ -0,0 +1,472 @@ +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; +import { cloudSpeechDisclosure } from "@t3tools/shared/speech"; +import type { PreparedConnection } from "@t3tools/client-runtime/connection"; +import { + configuredSecretSources, + secretRequestReference, +} from "@t3tools/client-runtime/secret-request"; +import { + getCloudSpeechKeys, + getCloudSpeechModels, + setCloudSpeechKey, +} from "@t3tools/client-runtime/voice-input"; +import { + AuthOrchestrationOperateScope, + AuthSettingsWriteScope, + CloudSpeechApiKey, + CloudSpeechModelId, + CLOUD_SPEECH_LABELS, + CLOUD_SPEECH_PROVIDER_NAMES, + isSecretReference, + type CloudSpeechModel, + type CloudSpeechProvider, + type SecretProviderId, + type SettingsSecretValue, + type VoiceTranscriptionDestination, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import type { HttpClient } from "effect/http"; +import { Atom, AsyncResult } from "effect/reactivity"; +import { useId, useState } from "react"; +import { isSecretsFeatureEnabled } from "../../env"; +import { + inEnvironmentSpeechRuntime, + runEnvironmentSpeech, +} from "../../speech/runEnvironmentSpeech"; +import { + chooseAudioDestination, + pendingCloudAudioChoice, + useAudioChoice, +} from "../../speech/voiceConsent"; +import type { EnvironmentPresentation } from "../../state/environments"; +import { useEnvironmentScope, usePreparedConnection } from "../../state/session"; +import { Button } from "../ui/button"; +import { + Combobox, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxPopup, + useComboboxFilter, +} from "../ui/combobox"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { SecretInput } from "./SecretInput"; +import { SecretSourceSelect } from "./SecretSourceInput"; + +// Per dialog instance, not an Atom.family: key presence must be read fresh each time it opens. +function useCloudRead( + prepared: PreparedConnection | null, + read: (prepared: PreparedConnection) => Effect.Effect, +) { + const make = (connection: PreparedConnection | null) => ({ + prepared: connection, + atom: Atom.make( + connection ? inEnvironmentSpeechRuntime(read(connection)) : Effect.succeed(null), + ), + }); + const [current, setCurrent] = useState(() => make(prepared)); + if (current.prepared !== prepared) setCurrent(make(prepared)); + return [useAtomValue(current.atom), useAtomRefresh(current.atom)] as const; +} +const isApiKey = Schema.is(CloudSpeechApiKey); +const isModelId = Schema.is(CloudSpeechModelId); +type CloudDestination = Extract; + +function ModelPicker({ + models, + value, + onChange, + disabled, + current, +}: { + models: ReadonlyArray; + value: string; + onChange: (next: string) => void; + disabled: boolean; + /** The grant's model, flagged when the catalog no longer lists it. */ + current: string | undefined; +}) { + const { contains } = useComboboxFilter(); + const selected = models.find((model) => model.id === value) ?? null; + const delisted = current !== undefined && !models.some((model) => model.id === current); + return ( +
+ model.name} + itemToStringValue={(model: CloudSpeechModel) => model.id} + filter={(model: CloudSpeechModel, query) => + contains(model.name, query) || contains(model.id, query) + } + value={selected} + onValueChange={(model) => onChange(model?.id ?? "")} + autoHighlight + disabled={disabled} + > + + + No transcription model matches. + + {(model: CloudSpeechModel) => ( + + + {model.name} + {model.id} + + + )} + + + + {delisted ? ( +

+ OpenRouter no longer lists {current}. Dictation stays off until you choose another model. +

+ ) : selected ? ( +

{selected.id}

+ ) : null} +
+ ); +} + +export function CloudVoiceConfiguration({ + environment, + provider, + active, + close, +}: { + environment: Pick; + provider: CloudSpeechProvider; + active: boolean; + close: () => void; +}) { + const id = environment.environmentId; + const name = CLOUD_SPEECH_LABELS[provider]; + const company = CLOUD_SPEECH_PROVIDER_NAMES[provider]; + const routed = provider === "openrouter"; + const formId = useId(); + const prepared = Option.getOrNull(usePreparedConnection(id)); + const canUse = useEnvironmentScope(id, AuthOrchestrationOperateScope); + const canManage = useEnvironmentScope(id, AuthSettingsWriteScope); + const [presence, refresh] = useCloudRead(canUse ? prepared : null, getCloudSpeechKeys); + const [models, refreshModels] = useCloudRead( + routed && canUse ? prepared : null, + getCloudSpeechModels, + ); + const choice = useAudioChoice(); + const grant = choice === "off" ? null : choice; + const currentModel = + active && grant?.destination.kind === "cloud" && grant.destination.provider === "openrouter" + ? grant.destination.model + : undefined; + const [remove, setRemove] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [model, setModel] = useState(currentModel ?? ""); + const saved = AsyncResult.isSuccess(presence) ? presence.value?.[provider] : undefined; + const existing = environment.serverConfig?.settings?.cloudSpeechCredentials?.[provider]; + const sources = + isSecretsFeatureEnabled && environment.serverConfig?.secretReferences + ? configuredSecretSources(environment.serverConfig.settings?.secretProviders ?? {}) + : []; + const reference = + isSecretReference(existing) && sources.some((s) => s.id === existing.provider) + ? existing + : null; + const [sourceId, setSourceId] = useState(reference?.provider ?? null); + const [draft, setDraft] = useState(reference?.reference ?? ""); + const source = sources.find((s) => s.id === sourceId); + const credential = + sourceId !== null + ? source && secretRequestReference(source, draft) + : isApiKey(draft.trim()) + ? draft.trim() + : undefined; + const keyChanged = + credential !== undefined && + !( + isSecretReference(credential) && + reference?.provider === credential.provider && + reference.reference === credential.reference + ); + const connected = canUse && prepared !== null; + // Fixed providers edit the key only when nothing is saved or the device already uses them. + const editingKey = canManage && saved !== undefined && (routed || active || !saved); + const listedModels = AsyncResult.isSuccess(models) ? (models.value?.models ?? null) : null; + const modelChosen = isModelId(model) && (listedModels?.some((m) => m.id === model) ?? false); + const keyReady = draft.trim() ? credential !== undefined : Boolean(saved); + const modelChanged = model !== (currentModel ?? ""); + const destination = (): CloudDestination => + routed && isModelId(model) + ? { kind: "cloud", provider: "openrouter", environmentId: id, model } + : { kind: "cloud", provider: provider as "openai" | "xai", environmentId: id }; + const enable = () => chooseAudioDestination(destination()); + const canSubmit = routed + ? modelChosen && keyReady && (!active || modelChanged || keyChanged) + : credential !== undefined; + + const saveKey = async (value: SettingsSecretValue | null, thenEnable: boolean) => { + if (!connected || !canManage || busy) return; + setError(""); + setBusy(true); + try { + const commit = thenEnable ? pendingCloudAudioChoice(destination()) : null; + await runEnvironmentSpeech(setCloudSpeechKey(prepared, provider, value)); + refresh(); + commit?.(); + close(); + } catch { + setError("Couldnโ€™t save this change. Check your connection and try again."); + } + setBusy(false); + }; + const submit = () => { + if (!canSubmit || busy) return; + const grantChanges = !active || modelChanged; + if (credential !== undefined && (!routed || keyChanged)) + return void saveKey(credential, grantChanges); + // Model-only changes never write to the environment. + if (grantChanges) enable(); + close(); + }; + + const keyField = ( + <> + +
+ {sources.length > 0 && ( + { + setSourceId(next); + setDraft(""); + setError(""); + }} + /> + )} +
+ {sourceId !== null ? ( + setDraft(event.target.value)} + /> + ) : ( + setDraft(event.target.value)} + /> + )} +
+
+

+ {draft.trim() && !credential + ? (source?.invalidReferenceMessage ?? "Enter an API key, not a URL or secret reference.") + : active && !routed + ? "Updating the key keeps dictation enabled." + : routed && saved + ? "Leave blank to keep the saved key." + : `Anyone with dictation access to ${environment.label} can bill this key.`} +

+ + ); + + return ( + { + if (!open && !busy) close(); + }} + > + + + + {remove + ? `Remove the ${company} key?` + : active + ? routed + ? "Manage OpenRouter dictation" + : `Manage ${company} key` + : `Use ${name} for dictation?`} + + + {remove + ? `Everyone using ${name} for dictation on ${environment.label} will lose access.` + : active && !routed + ? `Anyone with dictation access to ${environment.label} can use this API key.` + : cloudSpeechDisclosure(provider, environment.label)} + + + {!remove && ( + + {!connected ? ( +

Connect to {environment.label} with dictation access to continue.

+ ) : AsyncResult.isFailure(presence) && saved === undefined ? ( + + ) : routed && + AsyncResult.isSuccess(presence) && + presence.value && + saved === undefined ? ( +

Update {environment.label} to use OpenRouter dictation.

+ ) : saved === undefined ? ( +

Checking for an API keyโ€ฆ

+ ) : routed ? ( +
{ + event.preventDefault(); + submit(); + }} + > +
+ + {listedModels ? ( + + ) : AsyncResult.isFailure(models) ? ( + + ) : ( +

+ Loading transcription modelsโ€ฆ +

+ )} +
+ {editingKey ? ( +
{keyField}
+ ) : !saved ? ( +

Ask an administrator on {environment.label} to add an API key.

+ ) : ( +

+ Uses the API key saved on {environment.label}. This choice applies only to this + device. +

+ )} +
+ ) : editingKey ? ( +
{ + event.preventDefault(); + submit(); + }} + > + {keyField} +
+ ) : !saved ? ( +

Ask an administrator on {environment.label} to add an API key.

+ ) : ( +

+ This choice applies only to this device. Choose Off to stop sharing. +

+ )} +
+ )} + {error && ( +

+ {error} +

+ )} + + {connected && canManage && saved && (active || routed) && !remove && ( +
+ +
+ )} + + {connected && + (remove ? ( + + ) : routed ? ( + saved !== undefined && (saved || canManage) ? ( + + ) : null + ) : editingKey ? ( + + ) : saved && !active ? ( + + ) : null)} +
+
+
+ ); +} diff --git a/apps/web/src/components/settings/CodexSetupSection.tsx b/apps/web/src/components/settings/CodexSetupSection.tsx index 0dfd68dcdc378..f4dd3da002865 100644 --- a/apps/web/src/components/settings/CodexSetupSection.tsx +++ b/apps/web/src/components/settings/CodexSetupSection.tsx @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import { AuthProvidersManageScope, ProviderDriverKind } from "@t3tools/contracts"; import { readPreparedConnection, useEnvironmentScope } from "../../state/session"; import { @@ -293,6 +294,10 @@ function ManagedCodexSetup({ const completeAuth = useAtomCommand(serverEnvironment.completeProviderAuth, options); const cancelAuth = useAtomCommand(serverEnvironment.cancelProviderAuth, options); const logoutAuth = useAtomCommand(serverEnvironment.logoutProviderAuth, options); + const removeAuthMethod = useAtomCommand(serverEnvironment.removeProviderAuthMethod, options); + const canRemoveAuthMethod = useAtomValue( + serverEnvironment.removeProviderAuthMethod.permissionAtom(environmentId), + ); const startInstall = useAtomCommand(serverEnvironment.startProviderInstall, options); const cancelInstall = useAtomCommand(serverEnvironment.cancelProviderInstall, options); const reconnectProfile = useAtomCommand(serverEnvironment.chatGptReconnectProfile, options); @@ -771,6 +776,12 @@ function ManagedCodexSetup({ open={accountPickerOpen} methods={auth?.methods ?? []} onClose={() => setAccountPickerOpen(false)} + disabled={unavailable || busy} + canRemove={canRemoveAuthMethod} + error={error} + onRemove={(methodId) => { + void run(() => removeAuthMethod({ environmentId, input: { instanceId, methodId } })); + }} onSelect={(methodId) => { setAccountPickerOpen(false); void setup( diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 605f0615085b0..5db8971521128 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -352,25 +352,9 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { hasServerConfig: environment.serverConfig !== null, }), )?.environmentId; - const searchableCursorEnvironmentId = options.find( - (environment) => - environment.serverConfig?.environment.platform.os === "darwin" && - isProviderSettingsEnvironmentAvailable({ - connectionPhase: environment.connection.phase, - hasServerConfig: true, - }), - )?.environmentId; let searchRedirectEnvironmentId: EnvironmentId | undefined; { if ( - !target.scoped && - searchTargetId === searchableSetting("cursor-keychain-usage").id && - (!selectedEnvironmentCanRenderSettings || - selectedEnvironment?.serverConfig?.environment.platform.os !== "darwin") && - searchableCursorEnvironmentId !== undefined - ) { - searchRedirectEnvironmentId = searchableCursorEnvironmentId; - } else if ( !target.scoped && (searchTargetId === searchableSetting("provider-health-check-interval").id || searchTargetId === searchableSetting("usage-providers").id) && @@ -1129,7 +1113,6 @@ export function EnvironmentProviderSettings({ environmentId={environmentId} environmentLabel={environmentLabel} sources={settings.usageLimitSources} - cursorKeychainUsageEnabled={settings.cursorKeychainUsageEnabled} readOnly={readOnly} /> diff --git a/apps/web/src/components/settings/SecretsSettings.logic.test.ts b/apps/web/src/components/settings/SecretsSettings.logic.test.ts index 1a0ff29e11cfb..1480844f58baf 100644 --- a/apps/web/src/components/settings/SecretsSettings.logic.test.ts +++ b/apps/web/src/components/settings/SecretsSettings.logic.test.ts @@ -31,6 +31,7 @@ const settings = Schema.decodeSync(ServerSettings)({ easDeviceHosts: [ { kind: "eas", id: "eas", label: "EAS Simulator", accessToken: personal("op://expo") }, ], + cloudSpeechCredentials: { openai: personal("op://voice"), xai: "" }, }); it.each([ @@ -42,6 +43,7 @@ it.each([ "GitHub ยท github.com", "https://hub.test ยท Management Key", "EAS Simulator ยท Access Token", + "Voice ยท OpenAI", ], ], ["work", ["Antigravity Work ยท OTHER_TOKEN", "GitHub ยท ghe.acme.test"]], diff --git a/apps/web/src/components/settings/SecretsSettings.logic.ts b/apps/web/src/components/settings/SecretsSettings.logic.ts index 3ae528aed162a..a85bc9e330143 100644 --- a/apps/web/src/components/settings/SecretsSettings.logic.ts +++ b/apps/web/src/components/settings/SecretsSettings.logic.ts @@ -1,4 +1,6 @@ import { + CLOUD_SPEECH_LABELS, + CloudSpeechProvider, EasDeviceHostConfig, isSecretReference, secretReferencesInSettings, @@ -12,7 +14,11 @@ import { deriveProviderSettingsFields, type SettingsFormDefinition } from "./Pro type SecretUseSettings = Pick< ServerSettings, - "providerInstances" | "sourceControlHosts" | "usageLimitSources" | "easDeviceHosts" + | "providerInstances" + | "sourceControlHosts" + | "usageLimitSources" + | "easDeviceHosts" + | "cloudSpeechCredentials" >; interface SecretProviderUse { @@ -86,5 +92,11 @@ export function secretProviderUses( ...settings.easDeviceHosts.flatMap((host) => uses({ settingsSchema: EasDeviceHostConfig }, `eas:${host.id}`, host.label, host), ), + ...CloudSpeechProvider.literals.flatMap((provider) => { + const credential = settings.cloudSpeechCredentials[provider]; + return isSecretReference(credential) && credential.provider === providerId + ? [{ id: `voice:${provider}`, label: `Voice ยท ${CLOUD_SPEECH_LABELS[provider]}` }] + : []; + }), ]; } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 2eb5dcc83a4f6..4a245998d24e2 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -713,7 +713,7 @@ export function useSettingsRestore(onRestored?: () => void) { }; } -const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ?? 3; +const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ?? 7; function AutoSettleDaysInput({ value, diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index e273d0c672e76..e175fbc439f22 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -57,7 +57,6 @@ import { type SettingsSearchItem, } from "./settingsSearch"; import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems"; -import { validateSettingsScopeSearch } from "./settingsScope"; import { useSettingsSectionNavigation } from "./useSettingsSectionNavigation"; import { workspaceAreaProps } from "../../lib/workspaceFocus"; @@ -132,8 +131,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { const navArea = workspaceAreaProps("settings-nav", 0, "Settings sections"); const navigate = useNavigate(); const currentHash = useLocation({ select: (location) => location.hash }); - const currentSearch = useLocation({ select: (location) => location.search }); - const scopeSearch = validateSettingsScopeSearch(currentSearch); const activePath = SETTINGS_DETAIL_PAGE_PARENTS[pathname] ?? pathname; const navPaths = SETTINGS_NAV_GROUPS.flatMap((group) => group.paths); const sectionNavigation = useSettingsSectionNavigation( @@ -146,7 +143,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { // Below md the nav is an icon rail; searching expands it over the page. const [compactSearchOpen, setCompactSearchOpen] = useState(false); const [activeResultIndex, setActiveResultIndex] = useState(0); - const searchableItems = useAvailableSettingsSearchItems(scopeSearch); + const searchableItems = useAvailableSettingsSearchItems(); const results = searchSettings(query, searchableItems); const isSearching = query.trim().length > 0; const hasResults = results.length > 0; diff --git a/apps/web/src/components/settings/UsageProviderSettings.tsx b/apps/web/src/components/settings/UsageProviderSettings.tsx index f238850ae04d0..93f2fd6ddec51 100644 --- a/apps/web/src/components/settings/UsageProviderSettings.tsx +++ b/apps/web/src/components/settings/UsageProviderSettings.tsx @@ -1,11 +1,8 @@ import type { EnvironmentId, UnifiedSettings } from "@t3tools/contracts"; -import { useAtomValue } from "@effect/atom-react"; import { PlusIcon } from "lucide-react"; import { useState } from "react"; import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; -import { serverEnvironment } from "../../state/server"; -import { useAtomCommand } from "../../state/use-atom-command"; import { AlertDialog, AlertDialogClose, @@ -16,7 +13,6 @@ import { AlertDialogTitle, } from "../ui/alert-dialog"; import { Button } from "../ui/button"; -import { Switch } from "../ui/switch"; import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog"; import { searchableSetting } from "./settingsSearch"; import { SettingsRow, SettingsSection } from "./settingsLayout"; @@ -25,40 +21,17 @@ export function UsageProviderSettings({ environmentId, environmentLabel, sources, - cursorKeychainUsageEnabled, readOnly, }: { readonly environmentId: EnvironmentId; readonly environmentLabel: string; readonly sources: UnifiedSettings["usageLimitSources"]; - readonly cursorKeychainUsageEnabled: boolean; readonly readOnly: boolean; }) { const updateSettings = useUpdateEnvironmentSettings(environmentId); - const updateCursorSettings = useAtomCommand(serverEnvironment.updateSettings, { - label: "update Cursor account usage", - }); - const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { - reportFailure: false, - }); - const platform = useAtomValue(serverEnvironment.configValueAtom(environmentId))?.environment - .platform; const [adding, setAdding] = useState(false); - const [updatingCursor, setUpdatingCursor] = useState(false); const entries = Object.entries(sources); - const setCursorUsageEnabled = async (enabled: boolean) => { - setUpdatingCursor(true); - const result = await updateCursorSettings({ - environmentId, - input: { patch: { cursorKeychainUsageEnabled: enabled } }, - }); - if (result._tag === "Success") { - await refreshProviders({ environmentId, input: {} }); - } - setUpdatingCursor(false); - }; - return ( <> - {platform?.os === "darwin" ? ( - void setCursorUsageEnabled(enabled)} - /> - } - /> - ) : null} {entries.length === 0 ? ( ) : ( diff --git a/apps/web/src/components/settings/VoiceSettingsPanel.browser.test.tsx b/apps/web/src/components/settings/VoiceSettingsPanel.browser.test.tsx index ab1c0187c2806..969420ff2e0fc 100644 --- a/apps/web/src/components/settings/VoiceSettingsPanel.browser.test.tsx +++ b/apps/web/src/components/settings/VoiceSettingsPanel.browser.test.tsx @@ -1,4 +1,5 @@ -import { PrimaryConnectionTarget } from "@t3tools/client-runtime/connection"; +import { PrimaryConnectionTarget, RelayConnectionTarget } from "@t3tools/client-runtime/connection"; +import type * as Effect from "effect/Effect"; import { EnvironmentId, type EnvironmentSpeechModel, @@ -13,14 +14,64 @@ import { useVoiceTranscriptionDestination, } from "../../speech/useEnvironmentSpeechInput"; import { chooseAudioDestination, chooseTranscriptCleanup } from "../../speech/voiceConsent"; +import { useEnvironmentScope } from "../../state/session"; import { toastManager } from "../ui/toast"; import { SettingsScopeProvider } from "./SettingsScopeContext"; import { SpeechLanguageSelect } from "./SpeechLanguageSelect"; import { VoiceSettingsPanel } from "./VoiceSettingsPanel"; vi.mock(import("../../speech/useEnvironmentSpeechInput"), { spy: true }); +vi.mock(import("../../state/session"), { spy: true }); -it("preserves the model test transcript when its model is filtered out", async () => { +// The real connection runtime authorizes relay requests with a signed-in cloud session. +vi.mock("../../connection/runtime", async () => { + const [{ ManagedRelay }, rpc, { Atom }, Context, Effect, Layer] = await Promise.all([ + import("@t3tools/client-runtime/relay"), + import("@t3tools/client-runtime/rpc"), + import("effect/reactivity"), + import("effect/Context"), + import("effect/Effect"), + import("effect/Layer"), + ]); + type AuthorizedHttp = + import("@t3tools/client-runtime/authorization").AuthorizedRemoteHttpEnvironment; + // Services resolve by key, and client-runtime does not export this one. + class RemoteEnvironmentAuthorization extends Context.Service< + RemoteEnvironmentAuthorization, + { + readonly authorizeDpopHttp: (input: { + readonly expectedEnvironmentId: AuthorizedHttp["environmentId"]; + }) => Effect.Effect; + } + // @effect-diagnostics-next-line deterministicKeys:off + >()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {} + return { + connectionAtomRuntime: Atom.runtime( + Layer.mergeAll( + rpc.layerRemoteHttpClient((input, init) => globalThis.fetch(input, init)), + Layer.succeed(ManagedRelay.ManagedRelayDpopSigner, { + thumbprint: Effect.succeed("thumbprint"), + createProof: () => Effect.succeed("proof"), + }), + Layer.succeed(RemoteEnvironmentAuthorization, { + authorizeDpopHttp: ({ expectedEnvironmentId }) => + Effect.succeed({ + environmentId: expectedEnvironmentId, + label: "MacBook Pro", + httpBaseUrl: window.location.origin, + httpAuthorization: { + _tag: "Dpop", + accessToken: "relay-access-token", + expiresAtEpochMs: 4_102_444_800_000, + }, + }), + }), + ), + ), + }; +}); + +it("tests the selected model after download and preserves its transcript across filters", async () => { const target = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("voice-filter"), label: "Test environment", @@ -55,15 +106,23 @@ it("preserves the model test transcript when its model is filtered out", async ( modelUnloadTimeout: "never", gpuDevices: [], } satisfies EnvironmentSpeechStatus; + let installed = false; const fetch = globalThis.fetch; using _network = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => { const url = new URL(input instanceof Request ? input.url : input, window.location.href); - if (url.pathname === "/api/voice/status") return Promise.resolve(Response.json(status)); + if (url.pathname === "/api/voice/status") + return Promise.resolve( + Response.json(installed ? status : { ...status, state: "missing-model" }), + ); + if (url.pathname === "/api/voice/models/download") { + installed = true; + return Promise.resolve(Response.json({ models: [model] })); + } if (url.pathname === "/api/voice/models") return Promise.resolve( Response.json({ models: [ - model, + installed ? model : { ...model, state: "downloadable", active: false }, { ...model, id: "both", @@ -88,6 +147,7 @@ it("preserves the model test transcript when its model is filtered out", async ( return fetch(input, init); }); const transcript = "Keep this transcript."; + using _scope = vi.mocked(useEnvironmentScope).mockReturnValue(true); await vi.mocked(useVoiceTranscriptionDestination).withImplementation( () => ({ environmentId: target.environmentId, label: target.label, grantId: "test", prepared }), async () => { @@ -101,7 +161,10 @@ it("preserves the model test transcript when its model is filtered out", async ( open: false, step: 0, model: null, + language: null, downloading: false, + catalogFailed: false, + retryCatalog: null, error: null, setOpen: () => {}, download: async () => {}, @@ -129,7 +192,13 @@ it("preserves the model test transcript when its model is filtered out", async ( , ); const testModel = screen.getByRole("button", { name: "Test model", exact: true }); + const download = screen.getByRole("button", { name: "Download", exact: true }); + await expect.element(download).toBeVisible(); + expect(testModel.query()).toBeNull(); + expect(screen.getByRole("button", { name: "Set up voice input" }).query()).toBeNull(); + await userEvent.click(download); await expect.element(testModel).toBeVisible(); + expect(screen.getByRole("button", { name: "Set up voice input" }).query()).toBeNull(); await userEvent.click(testModel); const result = screen.getByRole("status", { name: "Test transcript" }); await expect.element(result).toHaveTextContent(transcript); @@ -157,6 +226,81 @@ it("preserves the model test transcript when its model is filtered out", async ( ); }); +it("loads the models of a relay-connected destination", async () => { + const target = new RelayConnectionTarget({ + environmentId: EnvironmentId.make("voice-relay"), + label: "MacBook Pro", + }); + const prepared = { + environmentId: target.environmentId, + label: target.label, + httpBaseUrl: window.location.origin, + socketUrl: "ws://localhost", + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: "stale-access-token", + expiresAtEpochMs: 4_102_444_800_000, + }, + target, + }; + const model: EnvironmentSpeechModel = { + id: "tiny", + name: "Tiny", + description: "", + size: 100, + languages: ["en"], + accuracy: 1, + speed: 1, + recommended: false, + supportsStreaming: false, + supportsTranslation: false, + supportsLanguageDetection: false, + active: false, + state: "downloadable", + }; + const status = { + supported: true, + state: "missing-model", + modelId: model.id, + model: model.name, + size: model.size, + supportsStreaming: false, + supportsTranslation: false, + acceleration: "cpu", + modelUnloadTimeout: "never", + gpuDevices: [], + } satisfies EnvironmentSpeechStatus; + const authorizations: Array = []; + const fetch = globalThis.fetch; + using _network = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => { + const url = new URL(input instanceof Request ? input.url : input, window.location.href); + if (url.pathname === "/api/voice/status") { + authorizations.push(new Headers(init?.headers).get("authorization")); + return Promise.resolve(Response.json(status)); + } + if (url.pathname === "/api/voice/models") + return Promise.resolve(Response.json({ models: [model] })); + return fetch(input, init); + }); + using notify = vi.spyOn(toastManager, "add").mockReturnValue("test"); + // Restored even when an assertion fails, unlike `withImplementation`. + using _destination = vi.mocked(useVoiceTranscriptionDestination).mockImplementation(() => ({ + environmentId: target.environmentId, + label: target.label, + grantId: "test", + prepared, + })); + await using screen = await render( + {}}> + + , + ); + await vi.waitFor(() => expect(authorizations.length + notify.mock.calls.length).not.toBe(0)); + expect(notify).not.toHaveBeenCalled(); + expect(authorizations).toEqual(["DPoP relay-access-token"]); + await expect.element(screen.getByText("Tiny", { exact: true })).toBeVisible(); +}); + it("recovers from a failed download progress poll without notifying, but reports a failed initial load", async () => { const target = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("voice-progress"), @@ -252,7 +396,7 @@ it.each([ ); await vi.waitFor(() => expect(document.activeElement?.id).toBe(prerequisite)); await expect - .element(screen.getByRole("combobox", { name: "Send microphone audio to" })) + .element(screen.getByRole("combobox", { name: "Transcribe with" })) .toHaveTextContent("Off"); await expect .element(screen.getByRole("switch", { name: "Clean up transcripts with a provider" })) @@ -271,7 +415,7 @@ it("keeps a missing destination selected without claiming it cannot transcribe", , ); await expect - .element(screen.getByRole("combobox", { name: "Send microphone audio to" })) + .element(screen.getByRole("combobox", { name: "Transcribe with" })) .toHaveTextContent("Chosen environment (unavailable)"); await expect.element(screen.getByText(/canโ€™t transcribe audio/)).not.toBeInTheDocument(); }); diff --git a/apps/web/src/components/settings/VoiceSettingsPanel.tsx b/apps/web/src/components/settings/VoiceSettingsPanel.tsx index b0fd5d2fff5d5..4a3df08f40f14 100644 --- a/apps/web/src/components/settings/VoiceSettingsPanel.tsx +++ b/apps/web/src/components/settings/VoiceSettingsPanel.tsx @@ -9,13 +9,17 @@ import { updateEnvironmentSpeechModelUnloadTimeout, } from "@t3tools/client-runtime/voice-input"; import type { PreparedConnection } from "@t3tools/client-runtime/connection"; +import { openRouterIcon } from "@t3tools/client-runtime/provider-clients"; import { AuthSettingsWriteScope, + CLOUD_SPEECH_LABELS, + CloudSpeechProvider, DEFAULT_SPEECH_POST_PROCESSING_PROMPT, type EnvironmentId, type EnvironmentSpeechModel, type EnvironmentSpeechStatus, type ProjectId, + ProviderDriverKind, type SpeechAcceleration, type SpeechCustomWords, type SpeechModelUnloadTimeout, @@ -23,11 +27,15 @@ import { } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { resolveSpeechPostProcessingModelSelection } from "@t3tools/shared/serverSettings"; +import { cloudSpeechLanguages } from "@t3tools/shared/speech"; import { AudioLinesIcon, CheckIcon, DownloadIcon, LanguagesIcon, + KeyRoundIcon, + MonitorIcon, + MicOffIcon, MicIcon, RefreshCwIcon, StarIcon, @@ -39,13 +47,14 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { useStableCallback } from "~/hooks/useStableCallback"; import { listBrowserMicrophones, type BrowserMicrophone } from "../../speech/browserMicrophones"; +import { preferredSpeechLanguages } from "../../speech/preferredSpeechLanguages"; import { chooseAudioDestination, chooseTranscriptCleanup, useCleanupGrant, } from "../../speech/voiceConsent"; import { updateClientSettings, useClientSettings } from "../../hooks/useSettings"; -import { runtime } from "../../lib/runtime"; +import { runEnvironmentSpeech } from "../../speech/runEnvironmentSpeech"; import { getCustomModelOptionsByInstance, resolveAppModelSelectionState, @@ -60,6 +69,8 @@ import { } from "../../speech/useEnvironmentSpeechInput"; import { ComposerSpeechRecordingPill } from "../chat/ComposerSpeechButton"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; +import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; +import { ProviderPackageIcon } from "../chat/ProviderPackageIcon"; import { TraitsPicker } from "../chat/TraitsPicker"; import { VoiceInputSetup } from "../chat/VoiceInputSetup"; import { Badge } from "../ui/badge"; @@ -79,11 +90,20 @@ import { MenuSeparator, MenuTrigger, } from "../ui/menu"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { + Select, + SelectGroup, + SelectGroupLabel, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "../ui/select"; import { Switch } from "../ui/switch"; import { Textarea } from "../ui/textarea"; import { toastManager } from "../ui/toast"; import { MicrophoneTest } from "./MicrophoneTest"; +import { CloudVoiceConfiguration } from "./CloudVoiceConfiguration"; import { LanguageRow, ModelBenchmarks, orderSpeechLanguages } from "./SpeechModelComparison"; import { languageLabel, SpeechLanguageSelect } from "./SpeechLanguageSelect"; import { filterSpeechModels, type SpeechModelSort } from "./speechModelBrowser"; @@ -102,6 +122,17 @@ import { useScopedModelDisabledReason } from "./useScopedModelAvailability"; import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; const OFF = "off"; +function CloudSpeechLogo({ provider }: { provider: CloudSpeechProvider }) { + if (provider === "openrouter") + return ; + return ( + + ); +} const SYSTEM_DEFAULT = "system-default"; const formatSize = (bytes: number) => `${Math.round(bytes / 1024 / 1024)} MB`; const SHORTCUT_MODE_LABELS: Record = { @@ -542,8 +573,8 @@ function useVoiceModels(prepared: PreparedConnection | null) { const revision = ++refreshRevision.current; try { const [nextStatus, nextModels] = await Promise.all([ - runtime.runPromise(getEnvironmentSpeechStatus(connection)), - runtime.runPromise(getEnvironmentSpeechModels(connection, mode === "foreground")), + runEnvironmentSpeech(getEnvironmentSpeechStatus(connection)), + runEnvironmentSpeech(getEnvironmentSpeechModels(connection, mode === "foreground")), ]); if (current !== epoch.current || revision !== refreshRevision.current) return; setStatus(nextStatus); @@ -649,7 +680,7 @@ function AdvancedSpeechSettings({ onValueChange={(value) => { if (value) void run("Could not change transcription hardware", () => - runtime.runPromise( + runEnvironmentSpeech( updateEnvironmentSpeechAcceleration(prepared, value as SpeechAcceleration), ), ); @@ -688,7 +719,7 @@ function AdvancedSpeechSettings({ onValueChange={(value) => { if (value) void run("Could not change when the model unloads", () => - runtime.runPromise( + runEnvironmentSpeech( updateEnvironmentSpeechModelUnloadTimeout( prepared, value as SpeechModelUnloadTimeout, @@ -730,7 +761,7 @@ function SpeechModelBrowser(props: { const [downloadedOnly, setDownloadedOnly] = useState(false); const [languageSearch, setLanguageSearch] = useState(""); const [selection, setSelection] = useState(null); - const preferred = navigator.languages.map((language) => language.toLowerCase().split("-")[0]!); + const [preferred] = useState(preferredSpeechLanguages); const supportedLanguages = [...new Set(models.flatMap((model) => model.languages))]; const defaultLanguage = preferred.find((code) => supportedLanguages.includes(code)); const selectedLanguages = selection ?? (defaultLanguage ? [defaultLanguage] : []); @@ -896,22 +927,22 @@ function SpeechModelBrowser(props: { void run( "Could not download the model", () => - runtime.runPromise(downloadEnvironmentSpeechModel(prepared, model.id)), + runEnvironmentSpeech(downloadEnvironmentSpeechModel(prepared, model.id)), model.id, ), onSelect: () => void run("Could not select the model", () => - runtime.runPromise(selectEnvironmentSpeechModel(prepared, model.id)), + runEnvironmentSpeech(selectEnvironmentSpeechModel(prepared, model.id)), ), onCancel: () => void run("Could not cancel the download", () => - runtime.runPromise( + runEnvironmentSpeech( cancelEnvironmentSpeechModelDownload(prepared, model.id), ), ), onDelete: () => void run("Could not delete the model", () => - runtime.runPromise(removeEnvironmentSpeechModel(prepared, model.id)), + runEnvironmentSpeech(removeEnvironmentSpeechModel(prepared, model.id)), ), }; return model.active ? ( @@ -1047,9 +1078,11 @@ function TranscriptionTest(props: { step={speech.setup.step} status={speech.status} model={speech.setup.model} + language={speech.setup.language} downloading={speech.setup.downloading} error={speech.setup.error} onOpenChange={speech.setup.setOpen} + onRetryCatalog={speech.setup.retryCatalog} onDownload={() => void speech.setup.download()} onCancelDownload={() => void speech.setup.cancelDownload()} onStartRecording={() => void speech.setup.startRecording()} @@ -1063,9 +1096,14 @@ export function VoiceSettingsPanel() { const settings = useClientSettings(); const settingsScope = useOptionalSettingsScope(); const destination = useVoiceTranscriptionDestination(); + const canManageKey = useEnvironmentScope(destination.environmentId, AuthSettingsWriteScope); + const [pendingCloud, setPendingCloud] = useState<{ + provider: CloudSpeechProvider; + environmentId: EnvironmentId; + } | null>(null); const { environments } = useEnvironments(); const [microphones, setMicrophones] = useState([]); - const prepared = destination.prepared; + const prepared = destination.cloudProvider ? null : destination.prepared; const catalog = useVoiceModels(prepared); const refreshMicrophones = useStableCallback(async () => { @@ -1098,11 +1136,39 @@ export function VoiceSettingsPanel() { ? (microphones.find((device) => device.deviceId === selectedMicrophone)?.label ?? "Selected microphone (unavailable)") : "System default"; - const destinationValue = destination.environmentId ?? OFF; + const environmentChoices = environments.map((environment) => ({ + environment, + value: JSON.stringify(["environment", environment.environmentId]), + })); + const cloudGroups = environments + .filter( + (environment) => environment.serverConfig?.environment.capabilities.cloudVoiceTranscription, + ) + .map((environment) => ({ + environment, + choices: CloudSpeechProvider.literals.map((provider) => ({ + environment, + provider, + value: JSON.stringify(["cloud", environment.environmentId, provider]), + })), + })); + const cloudChoices = cloudGroups.flatMap((group) => group.choices); + const destinationValue = destination.cloudProvider + ? JSON.stringify(["cloud", destination.environmentId, destination.cloudProvider]) + : destination.environmentId + ? JSON.stringify(["environment", destination.environmentId]) + : OFF; + const cloudEnvironment = environments.find( + (env) => env.environmentId === pendingCloud?.environmentId, + ); + // OpenRouter users without settings access still manage their own model choice. + const manageLabel = + destination.cloudProvider === "openrouter" ? "Manage OpenRouter" : "Manage key"; const chosenEnvironment = environments.find( (environment) => environment.environmentId === destination.environmentId, ); const chosenUnsupported = + !destination.cloudProvider && chosenEnvironment?.serverConfig != null && chosenEnvironment.serverConfig.environment.capabilities.voiceTranscription !== true; const model = prepared && modelStatus?.supported ? modelStatus : null; @@ -1110,6 +1176,8 @@ export function VoiceSettingsPanel() { return ( - - Microphone audio is sent to this environment to transcribe, then discarded. Choose Off - to turn voice input off on this device. This choice is saved on this device only. - - } - control={ - - } + className="@container/destination px-3 py-3 sm:px-4" > +
+

+ Transcribe with + Use +

+
+ + {destination.cloudProvider && + chosenEnvironment && + (canManageKey || destination.cloudProvider === "openrouter") && ( + + { + if (destination.cloudProvider) + setPendingCloud({ + provider: destination.cloudProvider, + environmentId: chosenEnvironment.environmentId, + }); + }} + > + + + } + /> + {manageLabel} + + )} +
+
{destination.environmentId && chosenUnsupported ? (

- {chosenEnvironment.label} canโ€™t transcribe audio. Pick an environment that can to use - voice input. + {chosenEnvironment.label} canโ€™t transcribe audio locally. Choose a different + destination.

) : null} -
+ - } - /> + {pendingCloud && cloudEnvironment && ( + setPendingCloud(null)} + /> + )} + {(model || destination.cloudProvider) && ( + + } + /> + )} {model?.supportsTranslation ? ( void updateClientSettings({ speechSilenceFiltering: checked }) @@ -1341,7 +1527,7 @@ export function VoiceSettingsPanel() { { hasCloudPublicConfig: false, hasEnvironment: false, hasProviderSettingsEnvironment: false, - hasMacProviderSettingsEnvironment: false, canManageLocalBackend: false, isWslSettingsRowVisible: false, hasThreadAutoSettlement: false, @@ -134,7 +133,6 @@ describe("searchSettings", () => { "network-access", "publish-agent-activity", "provider-health-check-interval", - "cursor-keychain-usage", "source-control-writer-model", "review-guide-model", "review-guide-instructions", @@ -156,7 +154,6 @@ describe("searchSettings", () => { hasCloudPublicConfig: false, hasEnvironment: true, hasProviderSettingsEnvironment: true, - hasMacProviderSettingsEnvironment: false, canManageLocalBackend: false, localEnvironmentDisabled: true, isWslSettingsRowVisible: false, @@ -271,7 +268,6 @@ describe("auto-settlement search availability", () => { hasCloudPublicConfig: false, hasEnvironment: true, hasProviderSettingsEnvironment: true, - hasMacProviderSettingsEnvironment: false, canManageLocalBackend: false, isWslSettingsRowVisible: false, hasThreadAutoSettlement: availability.eligibleEnvironmentIds.length > 0, diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 0cdabdfed98da..4267439274016 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -63,7 +63,6 @@ export interface SettingsSearchItem { readonly cloudOnly?: boolean; readonly environmentOnly?: boolean; readonly providerSettingsOnly?: boolean; - readonly macProviderSettingsOnly?: boolean; readonly localBackendManagementOnly?: boolean; readonly localEnvironmentOnly?: boolean; readonly wslAvailableOnly?: boolean; @@ -78,7 +77,6 @@ export interface SettingsSearchAvailability { readonly hasCloudPublicConfig: boolean; readonly hasEnvironment: boolean; readonly hasProviderSettingsEnvironment: boolean; - readonly hasMacProviderSettingsEnvironment: boolean; readonly canManageLocalBackend: boolean; readonly isWslSettingsRowVisible: boolean; readonly hasThreadAutoSettlement: boolean; @@ -500,9 +498,11 @@ export const SETTINGS_SEARCH_ITEMS = [ }, { id: "voice-transcription-destination", - title: "Send microphone audio to", + title: "Transcribe with", to: "/settings/voice", - searchTerms: ["speech voice dictation stt transcription environment machine consent privacy"], + searchTerms: [ + "send microphone audio dictation destination speech voice stt transcription environment machine consent privacy cloud OpenAI Grok OpenRouter", + ], }, { id: "voice-microphone", @@ -948,14 +948,6 @@ export const SETTINGS_SEARCH_ITEMS = [ ], providerSettingsOnly: true, }, - { - id: "cursor-keychain-usage", - title: "Cursor account usage", - to: "/settings/providers", - searchTerms: ["cursor macOS keychain usage tokens cost limits permission"], - providerSettingsOnly: true, - macProviderSettingsOnly: true, - }, { id: "provider-health-check-interval", title: "Health check interval", @@ -1461,7 +1453,6 @@ export function filterAvailableSettingsSearchItems( (!item.cloudOnly || availability.hasCloudPublicConfig) && (!item.environmentOnly || availability.hasEnvironment) && (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) && - (!item.macProviderSettingsOnly || availability.hasMacProviderSettingsEnvironment) && (!item.localBackendManagementOnly || availability.canManageLocalBackend) && (!item.localEnvironmentOnly || !availability.localEnvironmentDisabled) && (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) && diff --git a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts index 254673b0026c2..834e7e03a7ad7 100644 --- a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts +++ b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts @@ -11,13 +11,12 @@ import { useEnvironmentQuery } from "~/state/query"; import { useEnvironmentScope } from "~/state/session"; import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic"; -import type { SettingsScopeSearch } from "./settingsScope"; import { filterAvailableSettingsSearchItems, getThreadAutoSettlementSearchAvailability, } from "./settingsSearch"; -export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch = {}) { +export function useAvailableSettingsSearchItems() { const { environments } = useEnvironments(); // Explicit booleans keep flags out of the filter cache so environment updates do not rebuild the list. const hasEnvironment = @@ -29,16 +28,6 @@ export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch hasServerConfig: environment.serverConfig !== null, }), ) === true; - const hasMacProviderSettingsEnvironment = - environments.some( - (environment) => - (scopeSearch.machine === undefined || environment.environmentId === scopeSearch.machine) && - environment.serverConfig?.environment.platform.os === "darwin" && - isProviderSettingsEnvironmentAvailable({ - connectionPhase: environment.connection.phase, - hasServerConfig: true, - }), - ) === true; const hasThreadAutoSettlement = getThreadAutoSettlementSearchAvailability(environments).eligibleEnvironmentIds.length > 0; const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -63,7 +52,6 @@ export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch hasCloudPublicConfig: cloudPublicConfig, hasEnvironment, hasProviderSettingsEnvironment, - hasMacProviderSettingsEnvironment, canManageLocalBackend, isWslSettingsRowVisible: wslSettingsRowVisible, hasThreadAutoSettlement, diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index afdfdc9b1ca3a..4caa4e6c350bb 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -13,7 +13,10 @@ import { QUEUED_TURN_START_GRACE_MS, } from "@t3tools/client-runtime/state/thread-settled"; import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; -import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; +import { + resolveSettledThreadTimestamp, + resolveThreadActivityTimestamp, +} from "@t3tools/client-runtime/state/thread-sort"; import { type EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; import { formatWorkingDurationLabel, @@ -146,10 +149,6 @@ function RelativeTimeLabel(props: { timestamp: string | null }) { ); } -function threadTimeTimestamp(thread: SidebarThreadSummary): string { - return thread.latestUserMessageAt ?? thread.updatedAt; -} - function JumpHintBadge(props: { label: string }) { return ( @@ -1322,7 +1321,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP timestamp={ variantAction === "unsettle" ? resolveSettledThreadTimestamp(thread) - : threadTimeTimestamp(thread) + : resolveThreadActivityTimestamp(thread) } /> @@ -1517,7 +1516,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) ) : ( - + )} {props.settlementSupported || showSnoozeButton || hasUnsentDraft ? ( @@ -1756,7 +1755,7 @@ export const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props /> - + {props.searchMatch ? ( diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index e03aee4011593..ecbf7a6667062 100644 --- a/apps/web/src/components/ui/popover.tsx +++ b/apps/web/src/components/ui/popover.tsx @@ -94,6 +94,8 @@ function PopoverPopup({ className={cn( "relative flex h-(--popup-height,auto) w-(--popup-width,auto) origin-(--transform-origin) rounded-lg text-popover-foreground outline-none transition-[width,height,scale,opacity] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] has-data-[slot=calendar]:rounded-xl has-data-[slot=calendar]:before:rounded-[calc(var(--radius-xl)-1px)] data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0 dark:before:shadow-[0_-1px_--theme(--color-white/6%)]", variant !== "panel" && "dropdown-glass", + // Electron drag hit-testing ignores z-index; popups over a drag region would swallow hover. + "[-webkit-app-region:no-drag]", tooltipStyle && "w-fit text-balance rounded-md text-xs shadow-md/5 before:rounded-[calc(var(--radius-md)-1px)]", !tooltipStyle && diff --git a/apps/web/src/components/ui/sidebar.browser.test.tsx b/apps/web/src/components/ui/sidebar.browser.test.tsx index 288367fa4a3ed..3e1d24db40ed8 100644 --- a/apps/web/src/components/ui/sidebar.browser.test.tsx +++ b/apps/web/src/components/ui/sidebar.browser.test.tsx @@ -72,3 +72,61 @@ describe("Sidebar", () => { expect(onResize).toHaveBeenCalledTimes(1); }); }); + +it("restores collapsed and expanded desktop state after remounting", async () => { + await page.viewport(1280, 800); + const storageKey = "sidebar_persistence_test"; + localStorage.removeItem(storageKey); + const layout = () => ( + + + + ); + { + await using screen = await render(layout(), { router: false /* no routes involved */ }); + const trigger = screen.getByRole("button", { name: "Toggle Sidebar" }); + await expect.element(trigger).toHaveAttribute("aria-pressed", "true"); + await userEvent.click(trigger); + await expect.element(trigger).toHaveAttribute("aria-pressed", "false"); + } + { + await using screen = await render(layout(), { router: false /* no routes involved */ }); + const trigger = screen.getByRole("button", { name: "Toggle Sidebar" }); + await expect.element(trigger).toHaveAttribute("aria-pressed", "false"); + await userEvent.click(trigger); + } + await using screen = await render(layout(), { router: false /* no routes involved */ }); + await expect + .element(screen.getByRole("button", { name: "Toggle Sidebar" })) + .toHaveAttribute("aria-pressed", "true"); + localStorage.removeItem(storageKey); +}); + +it("keeps the mobile sheet closed and leaves the saved desktop state alone", async () => { + await page.viewport(390, 844); + const storageKey = "sidebar_mobile_persistence_test"; + localStorage.setItem(storageKey, "false"); + { + await using screen = await render( + + + , + { router: false /* no routes involved */ }, + ); + const trigger = screen.getByRole("button", { name: "Toggle Sidebar" }); + await expect.element(trigger).toHaveAttribute("aria-pressed", "false"); + await userEvent.click(trigger); + await expect.element(trigger).toHaveAttribute("aria-pressed", "true"); + expect(localStorage.getItem(storageKey)).toBe("false"); + } + await using screen = await render( + + + , + { router: false /* no routes involved */ }, + ); + await expect + .element(screen.getByRole("button", { name: "Toggle Sidebar" })) + .toHaveAttribute("aria-pressed", "false"); + localStorage.removeItem(storageKey); +}); diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 2982099a8dd6f..22964a8223270 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -18,13 +18,15 @@ import { import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { useResizeDrag } from "~/hooks/useResizeDrag"; import { useMediaQuery } from "~/hooks/useMediaQuery"; -import { setLocalStorageItem } from "~/hooks/useLocalStorage"; +import { + getLocalStorageItem, + setLocalStorageItem, + writeLocalStorageValue, +} from "~/hooks/useLocalStorage"; import { resolveSidebarState, type ResponsiveSidebarState } from "./sidebarState"; import * as Schema from "effect/Schema"; import { announceSidebarVisibility } from "~/lib/accessibilityAnnouncements"; -const SIDEBAR_COOKIE_NAME = "sidebar_state"; -const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; const SIDEBAR_WIDTH = "16rem"; const SIDEBAR_WIDTH_MOBILE = "calc(100vw - var(--spacing(3)))"; const SIDEBAR_WIDTH_ICON = "3rem"; @@ -113,13 +115,14 @@ function isMobileViewport(): boolean { return typeof window !== "undefined" && window.matchMedia(SIDEBAR_MOBILE_QUERY).matches; } -async function persistSidebarOpen(open: boolean) { - await cookieStore.set({ - expires: Date.now() + SIDEBAR_COOKIE_MAX_AGE * 1000, - name: SIDEBAR_COOKIE_NAME, - path: "/", - value: String(open), - }); +function readStoredSidebarOpen(storageKey: string | undefined): boolean | null { + if (!storageKey) return null; + try { + return getLocalStorageItem(storageKey, Schema.Boolean); + } catch (error) { + console.error("Could not restore persisted sidebar state.", error); + return null; + } } // An offcanvas sidebar turns inert once collapsed, which drops focus to the body. @@ -141,8 +144,11 @@ interface SidebarOpenState { } /** Per-provider open state, kept outside React so shortcuts can toggle it at event time. */ -function createSidebarOpenStore(defaultOpen: boolean) { - let state: SidebarOpenState = { open: defaultOpen, openMobile: false }; +function createSidebarOpenStore(defaultOpen: boolean, storageKey: string | undefined) { + let state: SidebarOpenState = { + open: readStoredSidebarOpen(storageKey) ?? defaultOpen, + openMobile: false, + }; const listeners = new Set<() => void>(); const collapseListeners = new Set<() => void>(); const publish = (next: SidebarOpenState) => { @@ -158,7 +164,7 @@ function createSidebarOpenStore(defaultOpen: boolean) { if (!isMobileViewport()) announceSidebarVisibility(open); publish({ ...state, open }); } - void persistSidebarOpen(open); + if (storageKey) writeLocalStorageValue(storageKey, open, defaultOpen, Schema.Boolean); }; const setOpenMobile = (openMobile: boolean) => { if (openMobile !== state.openMobile) publish({ ...state, openMobile }); @@ -185,15 +191,17 @@ function createSidebarOpenStore(defaultOpen: boolean) { function SidebarProvider({ defaultOpen = true, + storageKey, className, style, children, ...props }: React.ComponentProps<"div"> & { defaultOpen?: boolean; + storageKey?: string; }) { const isMobile = useMediaQuery(SIDEBAR_MOBILE_QUERY); - const [store] = React.useState(() => createSidebarOpenStore(defaultOpen)); + const [store] = React.useState(() => createSidebarOpenStore(defaultOpen, storageKey)); const { open, openMobile } = React.useSyncExternalStore( store.subscribe, store.getSnapshot, diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 1153b9cc74bc0..43ee023af2400 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -19,7 +19,7 @@ import { remainingPercent, } from "@t3tools/shared/usageLimits"; import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; -import { Fragment, type ReactNode, useLayoutEffect, useState } from "react"; +import { Fragment, useLayoutEffect, useState } from "react"; import { usePrimarySettings } from "../../hooks/useSettings"; import { observeResize } from "../../lib/observeResize"; @@ -365,12 +365,10 @@ export function UsageLimitsSection({ selectedEnvironmentIds, hiddenProviders, now, - cursorPrompt, }: { readonly selectedEnvironmentIds: ReadonlySet | null; readonly hiddenProviders: ReadonlySet; readonly now: number; - readonly cursorPrompt?: ReactNode; }) { const presentations = useAtomValue(environmentPresentations.presentationsWithProvidersAtom); const hiddenDrivers = new Set( @@ -402,5 +400,5 @@ export function UsageLimitsSection({ ]; }), ); - return ; + return ; } diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 30a372b2923eb..af9a07f69c652 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -617,33 +617,25 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu export function UsageLimitsPooled({ presentations, now, - cursorPrompt, }: { readonly presentations: Parameters[0]; readonly now: number; - readonly cursorPrompt?: ReactNode; }) { const [segmentPopover] = useState(() => PopoverCreateHandle()); const segmentPopoverActions = useRef(null); const pools = collectLimitPools(collectLimitAccounts(presentations), now); const notices = collectLimitNotices(presentations); const externalLinks = collectExternalUsageLinks(presentations); - const cursorPromptAt = - Math.max( - pools.findIndex((pool) => pool.driver === "codex"), - pools.findIndex((pool) => pool.driver === "claudeAgent"), - ) + 1; return (
- {pools.length === 0 && notices.length === 0 && !cursorPrompt && externalLinks.length === 0 ? ( + {pools.length === 0 && notices.length === 0 && externalLinks.length === 0 ? (

No provider on the selected environments reports subscription limits.

) : null} - {pools.map((pool, index) => ( + {pools.map((pool) => ( - {index === cursorPromptAt ? cursorPrompt : null} ))} @@ -661,7 +653,6 @@ export function UsageLimitsPooled({ ) : null } - {cursorPromptAt === pools.length ? cursorPrompt : null} {externalLinks.map((link) => (
+ const refreshLimits = (automatic = false) => // Settled so the timestamp still advances when a refresh throws. settlePromise(() => Promise.all( @@ -266,7 +258,6 @@ export function UsagePage() { environmentId, () => refreshProviders({ environmentId, input: {} }), automatic, - afterPending, ); } }), @@ -530,17 +521,6 @@ export function UsagePage() { selectedEnvironmentIds={selectedEnvironmentIds} hiddenProviders={hiddenProviders} now={limitsNow} - cursorPrompt={ - cursorAccessEnvironments.length > 0 ? ( - { - void refresh(); - void refreshLimits(false, true); - }} - /> - ) : null - } /> ) : shown === null ? ( @@ -565,12 +545,7 @@ export function UsagePage() { selectedEnvironments={selectedEnvironments} hiddenProviders={hiddenProviders} refreshingUsage={refreshingUsage} - cursorAccessEnvironments={cursorAccessEnvironments} showChatGptSharing={showChatGptSharing} - onCursorEnabled={() => { - void refresh(); - void refreshLimits(false, true); - }} onSelectModel={selectUsageModel} /> )} @@ -618,9 +593,7 @@ function UsageOverview({ selectedEnvironments, hiddenProviders, refreshingUsage, - cursorAccessEnvironments, showChatGptSharing, - onCursorEnabled, onSelectModel, }: { readonly merged: MergedUsage; @@ -633,9 +606,7 @@ function UsageOverview({ readonly selectedEnvironments: readonly EnvironmentUsageStatus[]; readonly hiddenProviders: ReadonlySet; readonly refreshingUsage: boolean; - readonly cursorAccessEnvironments: readonly EnvironmentUsageStatus[]; readonly showChatGptSharing: boolean; - readonly onCursorEnabled: () => void; readonly onSelectModel: (key: string) => void; }) { const [breakdown, setBreakdown] = useState<"model" | "time">("model"); @@ -676,20 +647,6 @@ function UsageOverview({ 0, ); const rankedProviders = rankProvidersByMetric(activeProviders, merged.providers, metric); - const cursorInsertAt = - Math.max(rankedProviders.indexOf("codex"), rankedProviders.indexOf("claude")) + 1; - const summaryRows: ReadonlyArray< - | { readonly kind: "usage"; readonly provider: UsageProviderKind } - | { readonly kind: "enable"; readonly environment: EnvironmentUsageStatus } - > = [ - ...rankedProviders - .slice(0, cursorInsertAt) - .map((provider) => ({ kind: "usage" as const, provider })), - ...cursorAccessEnvironments.map((environment) => ({ kind: "enable" as const, environment })), - ...rankedProviders - .slice(cursorInsertAt) - .map((provider) => ({ kind: "usage" as const, provider })), - ]; return (
@@ -741,19 +698,7 @@ function UsageOverview({
) : null} - {summaryRows.map((row) => { - if (row.kind === "enable") { - return ( - 1} - onEnabled={onCursorEnabled} - /> - ); - } - const provider = row.provider; + {rankedProviders.map((provider) => { const totals = merged.providers.find((entry) => entry.provider === provider); const share = metric === "cost" ? (totals?.costShare ?? 0) : (totals?.tokenShare ?? 0); const providerSessions = totals?.sessions ?? 0; @@ -1079,134 +1024,6 @@ function UsageOverview({ ); } -const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain."; - -function CursorEnableButton({ - environmentId, - label, - onEnabled, - tooltip, - buttonText = "Enable", -}: { - readonly environmentId: EnvironmentId; - readonly label: string; - readonly onEnabled: () => void; - readonly tooltip: boolean; - readonly buttonText?: string; -}) { - const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { - label: "enable Cursor account usage", - }); - const [pending, setPending] = useState(false); - const enable = async () => { - setPending(true); - const result = await updateSettings({ - environmentId, - input: { patch: { cursorKeychainUsageEnabled: true } }, - }); - if (result._tag === "Success") onEnabled(); - setPending(false); - }; - const button = tooltip ? ( - void enable()} - > - {buttonText} - - ) : ( - - ); - if (!tooltip) return button; - return ( - - - {CURSOR_KEYCHAIN_COPY} - - ); -} - -function CursorEnableRow({ - environmentId, - label, - showEnvironment, - onEnabled, -}: { - readonly environmentId: EnvironmentId; - readonly label: string; - readonly showEnvironment: boolean; - readonly onEnabled: () => void; -}) { - return ( -
- - - - Cursor{showEnvironment ? ` ยท ${label}` : ""} - - -
- ); -} - -function CursorEnableLimits({ - environments, - onEnabled, -}: { - readonly environments: readonly EnvironmentUsageStatus[]; - readonly onEnabled: () => void; -}) { - return ( -
-

- - Cursor -

-
-

{CURSOR_KEYCHAIN_COPY}

-
- {environments.map((environment) => ( - 1 ? `Enable on ${environment.label}` : "Enable"} - onEnabled={onEnabled} - tooltip={false} - /> - ))} -
-
-
- ); -} - function ProviderMark({ provider, className, diff --git a/apps/web/src/hooks/useDefaultTheme.ts b/apps/web/src/hooks/useDefaultTheme.ts index 5731da6eb53d5..9077142289c03 100644 --- a/apps/web/src/hooks/useDefaultTheme.ts +++ b/apps/web/src/hooks/useDefaultTheme.ts @@ -1,6 +1,6 @@ import { appAtomRegistry } from "../rpc/atomRegistry"; -import { primaryEnvironmentIdAtom } from "../state/primaryEnvironment"; -import { primaryServerSettingsAtom } from "../state/server"; +import { themeEnvironmentIdAtom } from "../state/primaryEnvironment"; +import { themeServerSettingsAtom } from "../state/server"; import { getThemeDefinition, singleAppearanceOf, subscribeToCustomThemes } from "../themePalette"; import { setAppearanceMode, setTheme } from "./useTheme"; @@ -45,9 +45,9 @@ function writeAppliedGeneration(storageKey: string, generation: string): void { function adoptDefaultTheme(): void { if (typeof window === "undefined") return; - const environmentId = appAtomRegistry.get(primaryEnvironmentIdAtom); + const environmentId = appAtomRegistry.get(themeEnvironmentIdAtom); if (environmentId === null) return; - const { defaultTheme, defaultThemeSetAt } = appAtomRegistry.get(primaryServerSettingsAtom); + const { defaultTheme, defaultThemeSetAt } = appAtomRegistry.get(themeServerSettingsAtom); const definition = getThemeDefinition(defaultTheme); const storageKey = `${APPLIED_DEFAULT_THEME_STORAGE_PREFIX}${environmentId}`; const generation = defaultThemeToApply({ @@ -68,15 +68,15 @@ function adoptDefaultTheme(): void { } /** - * Applies the primary environment's default theme once per generation. Start it after the + * Applies the theme environment's default theme once per generation. Start it after the * environment theme sync so a first-run client adopting the environment's own theme finds it * already in the library; it re-checks when published or custom palettes change, so a theme * that resolves late still applies. */ export function startDefaultThemeAdoption(): () => void { const unsubscribes = [ - appAtomRegistry.subscribe(primaryEnvironmentIdAtom, adoptDefaultTheme), - appAtomRegistry.subscribe(primaryServerSettingsAtom, adoptDefaultTheme), + appAtomRegistry.subscribe(themeEnvironmentIdAtom, adoptDefaultTheme), + appAtomRegistry.subscribe(themeServerSettingsAtom, adoptDefaultTheme), subscribeToCustomThemes(adoptDefaultTheme), ]; adoptDefaultTheme(); diff --git a/apps/web/src/hooks/useEnvironmentTheme.test.ts b/apps/web/src/hooks/useEnvironmentTheme.test.ts index 80b8fcbc216a0..855d1a3d5463a 100644 --- a/apps/web/src/hooks/useEnvironmentTheme.test.ts +++ b/apps/web/src/hooks/useEnvironmentTheme.test.ts @@ -9,7 +9,7 @@ const publishedThemes = vi.hoisted(() => ({ vi.mock(import("../state/server"), async () => { const { Atom } = await import("effect/reactivity"); publishedThemes.atom = Atom.make>([]); - return { primaryServerEnvironmentThemesAtom: publishedThemes.atom } as never; + return { themeServerEnvironmentThemesAtom: publishedThemes.atom } as never; }); const refreshTheme = vi.hoisted(() => vi.fn()); vi.mock(import("./useTheme"), () => ({ refreshTheme }) as never); diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index de5056c4af696..4a75943c3aeba 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -307,6 +307,18 @@ describe("thread action permissions", () => { }, ); + it("names an archived thread in its delete confirmation and preserves it on cancel", async () => { + state.threads = []; + state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); + state.confirm.mockResolvedValue(false); + await useThreadActions().confirmAndDeleteThread(target, "Archived release notes"); + expect(state.confirm).toHaveBeenCalledWith( + expect.stringContaining('Delete thread "Archived release notes"?'), + { variant: "destructive" }, + ); + expect(state.requests).toEqual([]); + }); + it("deletes an archived thread only with its own environment's grant", async () => { state.threads = []; seedThreadPanelState(target); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 904b94ebfc5c5..1b8c3001eac48 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -921,14 +921,14 @@ export function useThreadActions() { (result) => result._tag === "Success", ); - const confirmAndDeleteThread = async (target: ScopedThreadRef) => { + const confirmAndDeleteThread = async (target: ScopedThreadRef, knownTitle?: string) => { const permissionFailure = threadOperationFailure(target); if (permissionFailure) return permissionFailure; const localApi = readLocalApi(); const resolved = resolveThreadTarget(target); if (confirmThreadDelete && localApi) { - const title = resolved?.thread.title ?? "this thread"; + const title = resolved?.thread.title ?? knownTitle ?? "this thread"; const confirmationResult = await settlePromise(() => localApi.dialogs.confirm( [ diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 71a60ee6df880..be7587627b012 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1847,21 +1847,21 @@ code { -webkit-app-region: no-drag; } -/* Scrollbar styling */ -::-webkit-scrollbar { +/* Keep parent scrollbar styles off iframe owners to avoid WebKit resize loops: https://webkit.org/b/324485 */ +:where(:not(iframe))::-webkit-scrollbar { width: var(--app-scrollbar-width); } -::-webkit-scrollbar-track { +:where(:not(iframe))::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { +:where(:not(iframe))::-webkit-scrollbar-thumb { background: var(--app-scrollbar-thumb); border-radius: 3px; } -::-webkit-scrollbar-thumb:hover { +:where(:not(iframe))::-webkit-scrollbar-thumb:hover { background: var(--app-scrollbar-thumb-hover); } diff --git a/apps/web/src/speech/browserVoiceInput.ts b/apps/web/src/speech/browserVoiceInput.ts index 1d04f96bba0e2..345ebf78f2bb4 100644 --- a/apps/web/src/speech/browserVoiceInput.ts +++ b/apps/web/src/speech/browserVoiceInput.ts @@ -1,5 +1,7 @@ -import { effectiveSpeechLanguage } from "@t3tools/shared/speech"; +import { cloudSpeechLanguages, effectiveSpeechLanguage } from "@t3tools/shared/speech"; import { + requireCloudSpeechKey, + transcribeCloudPcm, getEnvironmentSpeechStatus, getEnvironmentSpeechStreamUrl, openSpeechStream, @@ -10,12 +12,19 @@ import { } from "@t3tools/client-runtime/voice-input"; import type { PreparedConnection } from "@t3tools/client-runtime/connection"; import type { SpeechTranscriptionOptions, SpeechStreamText } from "@t3tools/contracts"; +import type { CloudSpeechModelId, CloudSpeechProvider } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; -import { runtime } from "../lib/runtime"; import { createBrowserPcmCapture } from "./browserPcmCapture"; +import { runEnvironmentSpeech } from "./runEnvironmentSpeech"; + +const isVoiceTranscriptionError = Schema.is(VoiceTranscriptionError); export function createBrowserVoiceInputPlatform(input: { readonly prepared: PreparedConnection; + readonly cloudProvider?: CloudSpeechProvider | undefined; + /** The exact OpenRouter model the grant names; required with that provider. */ + readonly cloudModel?: CloudSpeechModelId | undefined; readonly hasAudioConsent: () => boolean; readonly getTranscriptionOptions: () => SpeechTranscriptionOptions; readonly getMicrophoneId: () => string; @@ -26,25 +35,6 @@ export function createBrowserVoiceInputPlatform(input: { }) { return createBrowserPcmCapture(input, async (options) => { const recordingOptions = input.getTranscriptionOptions(); - const status = await runtime.runPromise(getEnvironmentSpeechStatus(input.prepared), options); - throwIfVoiceTranscriptionAborted(options.signal); - if (!status.supported) - throw new VoiceTranscriptionError({ code: "unavailable", message: status.reason }); - const language = status.languages - ? effectiveSpeechLanguage( - { - languages: status.languages, - supportsLanguageDetection: status.supportsLanguageDetection ?? false, - }, - recordingOptions.speechLanguage, - ) - : "auto"; - const locale = - !status.supportsStreaming && - status.supportsTranslation && - recordingOptions.speechTranslateToEnglish - ? "en" - : language; const transcribeRecording = async ( uri: string, { signal: transcriptionSignal }: VoiceTranscriptionOptions, @@ -52,27 +42,75 @@ export function createBrowserVoiceInputPlatform(input: { try { const response = await fetch(uri, { signal: transcriptionSignal }); const pcm = new Uint8Array(await response.arrayBuffer()); - const result = await runtime.runPromise( - transcribeEnvironmentPcm(input.prepared, pcm, recordingOptions, input.hasAudioConsent), - { - signal: transcriptionSignal, - }, + const result = await runEnvironmentSpeech( + input.cloudProvider + ? transcribeCloudPcm( + input.prepared, + input.cloudProvider, + pcm, + recordingOptions, + input.hasAudioConsent, + input.cloudModel, + ) + : transcribeEnvironmentPcm( + input.prepared, + pcm, + recordingOptions, + input.hasAudioConsent, + ), + { signal: transcriptionSignal }, ); throwIfVoiceTranscriptionAborted(transcriptionSignal); return result.text; } catch (cause) { throwIfVoiceTranscriptionAborted(transcriptionSignal); + if (isVoiceTranscriptionError(cause)) throw cause; throw new VoiceTranscriptionError({ code: "transcription-failed", - message: "Voice transcription on this environment failed.", + message: "Voice transcription failed. Check the destination in Settings โ†’ Voice.", cause, }); } }; + if (input.cloudProvider) { + if (input.cloudProvider === "openrouter" && !input.cloudModel) + throw new VoiceTranscriptionError({ + code: "unavailable", + message: "Choose an OpenRouter model in Settings โ†’ Voice.", + }); + await runEnvironmentSpeech( + requireCloudSpeechKey(input.prepared, input.cloudProvider), + options, + ); + throwIfVoiceTranscriptionAborted(options.signal); + return { + locale: effectiveSpeechLanguage(cloudSpeechLanguages, recordingOptions.speechLanguage), + transcribe: transcribeRecording, + }; + } + const status = await runEnvironmentSpeech(getEnvironmentSpeechStatus(input.prepared), options); + throwIfVoiceTranscriptionAborted(options.signal); + if (!status.supported) + throw new VoiceTranscriptionError({ code: "unavailable", message: status.reason }); + const language = status.languages + ? effectiveSpeechLanguage( + { + languages: status.languages, + supportsLanguageDetection: status.supportsLanguageDetection ?? false, + }, + recordingOptions.speechLanguage, + ) + : "auto"; + const locale = + !status.supportsStreaming && + status.supportsTranslation && + recordingOptions.speechTranslateToEnglish + ? "en" + : language; if (!status.supportsStreaming && !status.supportsBufferedStreaming) { return { locale, transcribe: transcribeRecording }; } - const url = await runtime.runPromise(getEnvironmentSpeechStreamUrl(input.prepared), options); + const url = await runEnvironmentSpeech(getEnvironmentSpeechStreamUrl(input.prepared), options); return { locale, transcribe: transcribeRecording, diff --git a/apps/web/src/speech/preferredSpeechLanguages.ts b/apps/web/src/speech/preferredSpeechLanguages.ts new file mode 100644 index 0000000000000..548e7955ff08a --- /dev/null +++ b/apps/web/src/speech/preferredSpeechLanguages.ts @@ -0,0 +1,10 @@ +// Packaged desktop pins `navigator.languages` to en-US, so the OS's own list leads. +export function preferredSpeechLanguages() { + const bridge = window.desktopBridge; + let system = bridge?.getPreferredSystemLanguages?.() ?? []; + if (!system.length) { + const locale = bridge?.getSystemLocale?.(); + if (locale) system = [locale]; + } + return [...system, ...navigator.languages].map((tag) => tag.toLowerCase().split(/[-_]/)[0]!); +} diff --git a/apps/web/src/speech/runEnvironmentSpeech.ts b/apps/web/src/speech/runEnvironmentSpeech.ts new file mode 100644 index 0000000000000..f9635774d030e --- /dev/null +++ b/apps/web/src/speech/runEnvironmentSpeech.ts @@ -0,0 +1,22 @@ +import * as Effect from "effect/Effect"; +import type { HttpClient } from "effect/http"; +import { AtomRegistry } from "effect/reactivity"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "../rpc/atomRegistry"; + +// Relay (DPoP) environments authorize through services only the connection runtime provides. +export function inEnvironmentSpeechRuntime( + effect: Effect.Effect, +) { + return AtomRegistry.getResult(appAtomRegistry, connectionAtomRuntime).pipe( + Effect.flatMap((context) => Effect.provide(effect, context)), + ); +} + +export function runEnvironmentSpeech( + effect: Effect.Effect, + options?: Effect.RunOptions, +): Promise { + return Effect.runPromise(inEnvironmentSpeechRuntime(effect), options); +} diff --git a/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx b/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx index a41f359f68ef4..508ef9aa7b605 100644 --- a/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx +++ b/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx @@ -1,21 +1,32 @@ import { useLayoutEffect } from "react"; import { expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import type { PreparedConnection } from "@t3tools/client-runtime/connection"; +import { getEnvironmentSpeechModels } from "@t3tools/client-runtime/voice-input"; import { DEFAULT_CLIENT_SETTINGS, DEFAULT_SERVER_SETTINGS, - type EnvironmentId, + EnvironmentId, + type EnvironmentSpeechModel, type ProjectId, type SpeechCustomWords, type SpeechPostProcessingOptions, type SpeechTranscriptionOptions, } from "@t3tools/contracts"; -import { act, render } from "../../test/render"; +import { act, render, stubGlobal } from "../../test/render"; +import { VoiceInputSetup } from "../components/chat/VoiceInputSetup"; +import type { createBrowserVoiceInputPlatform } from "./browserVoiceInput"; import { useEnvironmentSpeechInput } from "./useEnvironmentSpeechInput"; -import { chooseAudioDestination, chooseTranscriptCleanup, bindAudioGrant } from "./voiceConsent"; +import { + chooseAudioDestination, + chooseTranscriptCleanup, + bindAudioGrant, + pendingCloudAudioChoice, +} from "./voiceConsent"; import { writeBrowserClientSettings } from "../clientPersistenceStorage"; const mocks = vi.hoisted(() => ({ @@ -23,13 +34,19 @@ const mocks = vi.hoisted(() => ({ settings: {} as Record, connected: new Set(), voiceCapable: new Set(), + cloudVoiceCapable: new Set(), canDictate: new Set(), prepared: new Map(), primaryEnvironmentId: "primary-env", statusRequests: [] as string[], statusUnavailable: false, modelState: "ready" as "ready" | "missing-model", - download: vi.fn<() => Promise>(), + statusModelId: "test-model", + models: [] as EnvironmentSpeechModel[], + catalogOffline: false, + download: vi.fn<(modelId: string) => Promise>(), + selected: [] as string[], + selectionFails: false, platforms: [] as string[], microphoneRequests: [] as string[], transcribeCalls: [] as string[], @@ -63,13 +80,17 @@ vi.mock("../state/environments", () => ({ environmentId, serverConfig: { environment: { - capabilities: { voiceTranscription: mocks.voiceCapable.has(environmentId) }, + capabilities: { + voiceTranscription: mocks.voiceCapable.has(environmentId), + cloudVoiceTranscription: mocks.cloudVoiceCapable.has(environmentId), + }, }, }, } : null, })); vi.mock("../hooks/useSettings", () => ({ + updateClientSettings: vi.fn(), useClientSettingsHydrated: () => true, useClientSettings: (selector?: (settings: Record) => unknown) => { const settings = { ...DEFAULT_CLIENT_SETTINGS, ...mocks.settings }; @@ -88,7 +109,7 @@ vi.mock("../hooks/useSettings", () => ({ ), }), })); -vi.mock("../lib/runtime", () => ({ runtime: { runPromise: Effect.runPromise } })); +vi.mock("./runEnvironmentSpeech", () => ({ runEnvironmentSpeech: Effect.runPromise })); vi.mock("../components/ui/toast", () => ({ toastManager: { add: vi.fn() } })); vi.mock("@t3tools/client-runtime/voice-input", async (importOriginal) => ({ ...(await importOriginal()), @@ -99,7 +120,7 @@ vi.mock("@t3tools/client-runtime/voice-input", async (importOriginal) => ({ return { supported: true, state: mocks.modelState, - modelId: "test-model", + modelId: mocks.statusModelId, model: "Test", size: 1, supportsStreaming: false, @@ -109,8 +130,24 @@ vi.mock("@t3tools/client-runtime/voice-input", async (importOriginal) => ({ gpuDevices: [], }; }), - downloadEnvironmentSpeechModel: () => Effect.promise(mocks.download), - getEnvironmentSpeechModels: () => Effect.succeed({ models: [] }), + downloadEnvironmentSpeechModel: (_prepared: PreparedConnection, modelId: string) => + Effect.promise(async () => { + await mocks.download(modelId); + return { models: mocks.models }; + }), + selectEnvironmentSpeechModel: (_prepared: PreparedConnection, modelId: string) => + Effect.sync(() => { + mocks.selected.push(modelId); + if (mocks.selectionFails) throw new Error("selection failed"); + mocks.statusModelId = modelId; + return { models: mocks.models }; + }), + getEnvironmentSpeechModels: vi.fn(() => + Effect.sync(() => { + if (mocks.catalogOffline) throw new Error("Catalog offline"); + return { models: mocks.models }; + }), + ), postProcessEnvironmentTranscript: ( prepared: PreparedConnection, transcript: string, @@ -124,12 +161,10 @@ vi.mock("@t3tools/client-runtime/voice-input", async (importOriginal) => ({ return { text: `Cleaned ${transcript}` }; }), })); -// Capture and the wire live behind the platform; its `prepared` is where audio would go. vi.mock("./browserVoiceInput", () => ({ - createBrowserVoiceInputPlatform: (input: { - prepared: PreparedConnection; - getTranscriptionOptions?: () => SpeechTranscriptionOptions; - }) => { + createBrowserVoiceInputPlatform: ( + input: Parameters[0], + ) => { const environmentId = input.prepared.environmentId; mocks.platforms.push(environmentId); return { @@ -143,12 +178,15 @@ vi.mock("./browserVoiceInput", () => ({ }, transcriber: { prepare: async () => { - const options = input.getTranscriptionOptions?.(); - if (options) mocks.recordingOptions.push(options); + mocks.recordingOptions.push(input.getTranscriptionOptions()); return { locale: "en", transcribe: async () => { - mocks.transcribeCalls.push(environmentId); + mocks.transcribeCalls.push( + input.cloudProvider + ? `${[input.cloudProvider, (input as { cloudModel?: string }).cloudModel].filter(Boolean).join(":")} via ${environmentId}` + : environmentId, + ); const next = mocks.transcripts.shift() ?? "dictated"; if (next instanceof Error) throw next; return next; @@ -167,16 +205,23 @@ const THREAD_ENV = "thread-env"; function setup(options: { readonly destination?: string | null } = {}) { mocks.settings = {}; setDestination(options.destination ?? null); + if (!options.destination) localStorage.removeItem("t3code:voice-audio-grant:v1"); chooseTranscriptCleanup(false); mocks.primaryEnvironmentId = "primary-env"; mocks.connected = new Set(["primary-env", THREAD_ENV, "env-a", "env-b"]); mocks.voiceCapable = new Set(["primary-env", THREAD_ENV, "env-a", "env-b"]); + mocks.cloudVoiceCapable.clear(); mocks.canDictate = new Set(mocks.voiceCapable); mocks.prepared.clear(); mocks.statusRequests = []; mocks.statusUnavailable = false; mocks.modelState = "ready"; + mocks.statusModelId = "test-model"; + mocks.models = []; + mocks.catalogOffline = false; mocks.download.mockReset(); + mocks.selected = []; + mocks.selectionFails = false; mocks.platforms = []; mocks.microphoneRequests = []; mocks.transcribeCalls = []; @@ -200,6 +245,7 @@ function Probe(props: { readonly projectId?: string; readonly projectName?: string; readonly cleanup?: boolean; + readonly showWizard?: boolean; }) { const value = useEnvironmentSpeechInput({ environmentId: THREAD_ENV as EnvironmentId, @@ -217,7 +263,18 @@ function Probe(props: { useLayoutEffect(() => { mocks.voice = value; }); - return null; + return props.showWizard ? ( + + ) : null; } function expectNoAudioSent() { @@ -231,15 +288,22 @@ it.each([ ["nothing is configured", {}], // A transcription host picked by an earlier PoC build is not consent. ["a legacy transcription host is configured", { voiceTranscriptionEnvironmentId: "env-a" }], -])("stays local-only when %s, even with voice-capable environments", async (_, legacy) => { +])("opens destination setup without sending audio when %s", async (_, legacy) => { setup(); mocks.settings = { ...legacy, speechSilenceFiltering: true }; - await using _screen = await render(); + await using screen = await render(); + // The composer mic and the dictation shortcut both gate on this. + expect(mocks.voice.available).toBe(true); await act(() => mocks.voice.start()); + expect(screen.router?.state.location).toMatchObject({ + pathname: "/settings/voice", + hash: "voice-transcription-destination", + }); expectNoAudioSent(); expect(mocks.committed).not.toHaveBeenCalled(); - expect(mocks.voice.available).toBe(false); + expect(mocks.voice.state.phase).toBe("idle"); expect(mocks.voice.transcriptionEnvironmentId).toBeNull(); + expect(localStorage.getItem("t3code:voice-audio-grant:v1")).toBeNull(); }); it.each([true, false])( @@ -290,6 +354,7 @@ it.each([ expect(mocks.voice.state.phase).toBe("recording"); setDestination(next); await screen.rerender(); + if (next === null) expect(mocks.voice.available).toBe(false); expect(mocks.voice.state.phase).toBe("idle"); await act(() => mocks.voice.stop()); expect(mocks.transcribeCalls).toEqual([]); @@ -312,6 +377,8 @@ it("retries a failed transcription on the same chosen environment", async () => it("reopens setup after reconnecting during a download and ignores its stale completion", async () => { setup({ destination: "env-a" }); mocks.modelState = "missing-model"; + mocks.settings = { speechLanguage: "en" }; + mocks.models = [catalogModel(mocks.statusModelId, ["en"], true)]; let finishDownload!: () => void; mocks.download.mockReturnValue( new Promise((resolve) => { @@ -339,6 +406,171 @@ it("reopens setup after reconnecting during a download and ignores its stale com expect(mocks.voice.setup.error).toBeNull(); }); +function catalogModel( + id: string, + languages: readonly string[], + recommended: boolean, +): EnvironmentSpeechModel { + return { + id, + name: id, + description: "", + size: 1, + languages, + accuracy: 0, + speed: 0, + recommended, + supportsStreaming: false, + supportsLanguageDetection: languages.length > 1, + supportsTranslation: false, + active: false, + state: "downloadable", + }; +} + +it.each([ + ["the browser prefers German", ["de-DE"], undefined, "auto"], + ["the desktop exposes no locale methods", ["de-DE"], {}, "auto"], + [ + "an older preload exposes only the OS locale", + ["en-US"], + { getSystemLocale: () => "de-DE" }, + "auto", + ], + [ + "the desktop OS prefers German", + ["en-US"], + { getPreferredSystemLanguages: () => ["de-DE"] }, + "auto", + ], + ["German is the chosen Voice language", ["en-US"], undefined, "de"], +])( + "offers and downloads a German-capable model when %s", + async (_, languages, bridge, speechLanguage) => { + setup({ destination: "env-a" }); + mocks.settings = { speechLanguage }; + using _languages = vi.spyOn(navigator, "languages", "get").mockReturnValue(languages); + using _bridge = stubGlobal("desktopBridge", bridge); + const englishDefault = "handy-computer/parakeet-unified-en-0.6b-gguf"; + const german = "handy-computer/nemotron-3.5-asr-streaming-0.6b-gguf"; + mocks.modelState = "missing-model"; + mocks.statusModelId = englishDefault; + mocks.models = [ + { ...catalogModel(englishDefault, ["en"], true), active: true }, + catalogModel(german, ["en", "de", "fr"], true), + catalogModel("handy-computer/canary-180m-flash-gguf", ["en", "de", "es", "fr"], true), + ]; + mocks.download.mockResolvedValue(undefined); + await using _screen = await render(); + await act(() => mocks.voice.start()); + expect(mocks.voice.setup.open).toBe(true); + await act(() => mocks.voice.setup.download()); + expect(mocks.download).toHaveBeenCalledExactlyOnceWith(german); + expect(mocks.voice.setup.model?.id).toBe(german); + }, +); + +it.each(["cancel", "failed restart"] as const)( + "closes pending catalog setup on %s", + async (action) => { + setup({ destination: "env-a" }); + mocks.modelState = "missing-model"; + mocks.settings = { speechLanguage: "en" }; + mocks.models = [catalogModel("current", ["en"], true)]; + const response = Deferred.makeUnsafe<{ models: EnvironmentSpeechModel[] }>(); + vi.mocked(getEnvironmentSpeechModels).mockReturnValueOnce(Deferred.await(response)); + await using _screen = await render(); + let firstStart!: Promise; + await act(() => { + firstStart = mocks.voice.start(); + }); + await expect.element(page.getByText("Loading transcription modelsโ€ฆ")).toBeVisible(); + if (action === "cancel") { + await act(() => mocks.voice.cancel()); + } else { + mocks.statusUnavailable = true; + await act(() => mocks.voice.start()); + mocks.statusUnavailable = false; + } + expect(mocks.voice.setup.open).toBe(false); + + await act(() => mocks.voice.start()); + Deferred.doneUnsafe( + response, + Effect.succeed({ models: [catalogModel("stale", ["en"], true)] }), + ); + await act(() => firstStart); + await expect.element(page.getByRole("heading", { name: "current" })).toBeVisible(); + expect(mocks.microphoneRequests).toEqual([]); + }, +); + +it.each([true, false])( + "never downloads the English default when no German recommendation is available (catalog offline: %s)", + async (offline) => { + setup({ destination: "env-a" }); + mocks.settings = { speechLanguage: "de" }; + mocks.modelState = "missing-model"; + mocks.statusModelId = "english"; + mocks.models = [catalogModel("english", ["en"], true)]; + mocks.catalogOffline = offline; + await using _screen = await render(); + await act(() => mocks.voice.start()); + const wizard = page.getByRole("dialog", { name: "Set up voice input" }); + const retry = wizard.getByRole("button", { name: "Retry loading models" }); + const download = wizard.getByRole("button", { name: "Download model" }); + await expect.element(wizard.getByRole("alert")).toBeVisible(); + await expect.element(download).not.toBeInTheDocument(); + if (offline) { + mocks.catalogOffline = false; + mocks.models.push(catalogModel("german", ["de"], true)); + // Another client can finish setup while the catalog is offline. + mocks.modelState = "ready"; + await userEvent.click(retry); + await expect.element(wizard.getByRole("heading", { name: "german" })).toBeVisible(); + await expect.element(download).toBeEnabled(); + } + await expect.element(retry).not.toBeInTheDocument(); + expect(mocks.download).not.toHaveBeenCalled(); + expect(mocks.microphoneRequests).toEqual([]); + }, +); + +it.each([ + ["selects the offered model once its download installed it", "installed", ["german"], null], + [ + "does not advance a cancelled download when another model is ready", + "downloadable", + [], + "The model is not ready. Try downloading it again.", + ], + [ + "reports a failed selection apart from the download", + "installed", + ["german"], + "The model downloaded, but this environment could not select it. Try again.", + ], +] as const)("%s", async (_, landedState, selected, error) => { + setup({ destination: "env-a" }); + using _languages = vi.spyOn(navigator, "languages", "get").mockReturnValue(["de-DE"]); + mocks.selectionFails = error !== null && landedState === "installed"; + mocks.modelState = "missing-model"; + mocks.statusModelId = "english"; + mocks.models = [catalogModel("english", ["en"], true), catalogModel("german", ["de"], true)]; + mocks.download.mockImplementation(async () => { + mocks.models = [mocks.models[0]!, { ...mocks.models[1]!, state: landedState }]; + mocks.modelState = "ready"; + }); + await using _screen = await render(); + await act(() => mocks.voice.start()); + expect(mocks.voice.setup.language).toBe("de"); + await act(() => mocks.voice.setup.download()); + expect(mocks.selected).toEqual(selected); + expect(mocks.voice.setup.error).toBe(error); + expect(mocks.voice.setup.downloading).toBe(false); + expect(mocks.voice.setup.step).toBe(landedState === "installed" && !error ? 1 : 0); +}); + it("does not resend a failed recording after consent is revoked", async () => { setup({ destination: "env-a" }); mocks.transcripts = [new Error("inference failed")]; @@ -367,10 +599,18 @@ it("revokes across windows even before the storage event and survives stale pref const grant = JSON.parse(storage.getItem(key)!) as { id: string }; const canSend = bindAudioGrant(grant.id); expect(canSend()).toBe(true); + const saved = pendingCloudAudioChoice({ + kind: "cloud", + provider: "openai", + environmentId: EnvironmentId.make("env-a"), + }); + expect(canSend()).toBe(true); storage.removeItem(key); + saved(); + expect(storage.getItem(key)).toBeNull(); expect(canSend()).toBe(false); writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, wordWrap: false }); - await vi.waitFor(() => expect(mocks.voice.available).toBe(false)); + await vi.waitFor(() => expect(mocks.voice.transcriptionEnvironmentId).toBeNull()); expect(mocks.voice.state.phase).toBe("idle"); await act(() => mocks.voice.stop()); expect(mocks.transcribeCalls).toEqual([]); @@ -381,15 +621,94 @@ it("revokes across windows even before the storage event and survives stale pref } }); -it("cleans the transcript only after cleanup is separately enabled", async () => { +it.each(["not-json", JSON.stringify("off")])( + "does not enable a pending provider over %s before the save completes", + async (previous) => { + setup(); + mocks.cloudVoiceCapable.add("env-b"); + mocks.modelState = "missing-model"; + window.localStorage.setItem("t3code:voice-audio-grant:v1", previous); + const save = pendingCloudAudioChoice({ + kind: "cloud", + environmentId: EnvironmentId.make("env-b"), + provider: "xai", + }); + await using _screen = await render(); + expect(mocks.voice.transcriptionEnvironmentId).toBeNull(); + if (previous === JSON.stringify("off")) expect(mocks.voice.available).toBe(false); + await act(() => mocks.voice.start()); + expectNoAudioSent(); + await act(save); + await act(() => mocks.voice.start()); + await act(() => mocks.voice.stop()); + expect(mocks.transcribeCalls).toEqual(["xai via env-b"]); + expect(mocks.statusRequests).toEqual([]); + }, +); + +it("sends audio to OpenRouter only under a device grant naming the exact model", async () => { + setup(); + mocks.cloudVoiceCapable.add("env-b"); + const key = "t3code:voice-audio-grant:v1"; + const destination = { kind: "cloud", environmentId: "env-b", provider: "openrouter" }; + window.localStorage.setItem(key, JSON.stringify({ id: "no-model", destination })); + { + await using _screen = await render(); + await act(() => mocks.voice.start()); + expectNoAudioSent(); + } + const model = "acme/transcribe-1"; + window.localStorage.setItem( + key, + JSON.stringify({ id: "model", destination: { ...destination, model } }), + ); + await using _screen = await render(); + await act(() => mocks.voice.start()); + await act(() => mocks.voice.stop()); + expect(mocks.transcribeCalls).toEqual([`openrouter:${model} via env-b`]); + expect(mocks.statusRequests).toEqual([]); +}); + +it("uses neither destination when the cloud save marker cannot be read", async () => { setup({ destination: "env-a" }); + const key = "t3code:voice-audio-grant:v1"; + const previous = JSON.parse(window.localStorage.getItem(key)!) as { id: string }; + const canSendToPrevious = bindAudioGrant(previous.id); + const save = pendingCloudAudioChoice({ + kind: "cloud", + environmentId: EnvironmentId.make("env-b"), + provider: "openai", + }); + save(); + const getItem = Storage.prototype.getItem; + using _blocked = vi.spyOn(Storage.prototype, "getItem").mockImplementation(function ( + this: Storage, + k, + ) { + if (k.startsWith(`${key}:saved:`)) + throw new DOMException("Storage is blocked", "SecurityError"); + return getItem.call(this, k); + }); + await using _screen = await render(); + expect(mocks.voice.transcriptionEnvironmentId).toBeNull(); + expect(canSendToPrevious()).toBe(false); + expect(mocks.voice.available).toBe(false); + await act(() => mocks.voice.start()); + expectNoAudioSent(); +}); + +it("cleans text on a cloud-only draft environment only while cleanup is separately enabled", async () => { + setup({ destination: "env-a" }); + mocks.voiceCapable.delete(THREAD_ENV); + mocks.cloudVoiceCapable.add(THREAD_ENV); chooseTranscriptCleanup(true); const nearby = "x".repeat(1_024); mocks.draft = "unrelated private paragraph " + nearby; await using screen = await render(); await act(() => mocks.voice.start()); await act(() => mocks.voice.stop()); - expect(mocks.cleanupCalls).toHaveLength(1); + expect(mocks.transcribeCalls).toEqual(["env-a"]); + expect(mocks.cleanupCalls).toEqual([THREAD_ENV]); expect(mocks.cleanupDraft).toHaveBeenCalledExactlyOnceWith({ text: nearby, selection: { start: nearby.length, end: nearby.length }, @@ -414,6 +733,7 @@ it.each([ "keeps a recording without authorized cleanup off the provider: $cleanup / $authorized", async ({ cleanup, authorized }) => { setup({ destination: "env-a" }); + mocks.cloudVoiceCapable.add(THREAD_ENV); chooseTranscriptCleanup(true); mocks.settings.speechCorrectionWord = "sorry"; if (!authorized) mocks.canDictate.delete(THREAD_ENV); diff --git a/apps/web/src/speech/useEnvironmentSpeechInput.ts b/apps/web/src/speech/useEnvironmentSpeechInput.ts index 644edb303aef2..c569c14500be6 100644 --- a/apps/web/src/speech/useEnvironmentSpeechInput.ts +++ b/apps/web/src/speech/useEnvironmentSpeechInput.ts @@ -4,6 +4,7 @@ import { getEnvironmentSpeechModels, getEnvironmentSpeechStatus, postProcessEnvironmentTranscript, + selectEnvironmentSpeechModel, VoiceInputController, voiceInputBlocksSubmission, type VoiceDraftSnapshot, @@ -11,7 +12,7 @@ import { type VoiceInputState, } from "@t3tools/client-runtime/voice-input"; import type { PreparedConnection } from "@t3tools/client-runtime/connection"; -import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; +import { AuthOrchestrationOperateScope, CLOUD_SPEECH_LABELS } from "@t3tools/contracts"; import type { EnvironmentId, EnvironmentSpeechModel, @@ -22,7 +23,8 @@ import type { SpeechTranscriptionOptions, } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { resolveSpeechVocabulary } from "@t3tools/shared/speech"; +import { chooseSetupSpeechModel, resolveSpeechVocabulary } from "@t3tools/shared/speech"; +import { useNavigate } from "@tanstack/react-router"; import * as Option from "effect/Option"; import { useEffect, useRef, useState } from "react"; @@ -33,11 +35,12 @@ import { useClientSettingsHydrated, useEnvironmentSettings, } from "../hooks/useSettings"; -import { runtime } from "../lib/runtime"; import { useEnvironment } from "../state/environments"; import { useEnvironmentScope, usePreparedConnection } from "../state/session"; import { createBrowserVoiceInputPlatform } from "./browserVoiceInput"; -import { bindAudioGrant, readCleanupGrant, useAudioGrant } from "./voiceConsent"; +import { preferredSpeechLanguages } from "./preferredSpeechLanguages"; +import { runEnvironmentSpeech } from "./runEnvironmentSpeech"; +import { bindAudioGrant, readCleanupGrant, useAudioChoice } from "./voiceConsent"; const INITIAL_STATE: VoiceInputState = { phase: "idle", error: null, errorAction: null }; const WAITING_STATE: VoiceInputState = { phase: "preparing", error: null, errorAction: null }; @@ -45,7 +48,9 @@ const INITIAL_SETUP = { open: false, step: 0, model: null as EnvironmentSpeechModel | null, + language: null as string | null, downloading: false, + catalogFailed: false, error: null as string | null, }; @@ -54,7 +59,7 @@ const statusProbes = new WeakMap { if (statusProbes.get(prepared) === probe) statusProbes.delete(prepared); @@ -88,6 +93,11 @@ type HookInput = { readonly ownerKey: string; readonly draftText: string; readonly disabled?: boolean | undefined; + /** + * Until a destination is chosen, voice input is offered and its first gesture opens Voice + * settings. False hides it instead, for editors whose draft would not survive the navigation. + */ + readonly offerSetup?: boolean | undefined; /** False keeps the transcript off the cleanup provider even when cleanup is on; the model test uses it. */ readonly cleanup?: boolean | undefined; readonly readDraft: () => DraftInput; @@ -104,30 +114,47 @@ type HookInput = { */ export function useVoiceTranscriptionDestination() { const hydrated = useClientSettingsHydrated(); - const grant = useAudioGrant(); + const choice = useAudioChoice(); + const grant = choice === "off" ? null : choice; const environmentId = hydrated && grant ? grant.destination.environmentId : null; const environment = useEnvironment(environmentId); const connection = Option.getOrNull(usePreparedConnection(environmentId)); const canDictate = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); - const supported = environment?.serverConfig?.environment.capabilities.voiceTranscription === true; + const cloud = grant?.destination.kind === "cloud" ? grant.destination : undefined; + const provider = cloud?.provider; + const model = cloud?.provider === "openrouter" ? cloud.model : undefined; + const supported = + environment?.serverConfig?.environment.capabilities[ + provider ? "cloudVoiceTranscription" : "voiceTranscription" + ] === true; return { + ...(hydrated && choice === null ? { offerFirstRunSetup: true } : {}), environmentId, + ...(provider ? { cloudProvider: provider } : {}), + ...(model ? { cloudModel: model } : {}), grantId: grant?.id ?? null, - label: environment?.label ?? null, + label: + provider && environment + ? `${CLOUD_SPEECH_LABELS[provider]} via ${environment.label}` + : (environment?.label ?? null), prepared: supported && canDictate ? connection : null, }; } export function useEnvironmentSpeechInput(input: HookInput) { + const navigate = useNavigate(); const destination = useVoiceTranscriptionDestination(); const prepared = destination.prepared; const grantId = destination.grantId; - const originatingEnvironment = useEnvironment(input.environmentId); + const cloudProvider = destination.cloudProvider; + const cloudModel = destination.cloudModel; + const draftCapabilities = useEnvironment(input.environmentId)?.serverConfig?.environment + .capabilities; const originatingConnection = Option.getOrNull(usePreparedConnection(input.environmentId)); const canCleanUp = useEnvironmentScope(input.environmentId, AuthOrchestrationOperateScope); const cleanupPrepared = canCleanUp && - originatingEnvironment?.serverConfig?.environment.capabilities.voiceTranscription === true + (draftCapabilities?.voiceTranscription || draftCapabilities?.cloudVoiceTranscription) ? originatingConnection : null; const clientSettings = useClientSettings(); @@ -231,6 +258,8 @@ export function useEnvironmentSpeechInput(input: HookInput) { }; const platform = createBrowserVoiceInputPlatform({ prepared, + cloudProvider, + cloudModel, hasAudioConsent, getTranscriptionOptions: (): SpeechTranscriptionOptions => { const { @@ -296,18 +325,16 @@ export function useEnvironmentSpeechInput(input: HookInput) { return Promise.reject( new Error("The draft's environment is unavailable for transcript cleanup."), ); - return runtime - .runPromise( - postProcessEnvironmentTranscript( - target, - transcript, - cleanupContext(options.draft), - preferences.cleanup, - () => hasAudioConsent() && preferences.cleanupGrant === readCleanupGrant(), - ), - options, - ) - .then((result) => result.text); + return runEnvironmentSpeech( + postProcessEnvironmentTranscript( + target, + transcript, + cleanupContext(options.draft), + preferences.cleanup, + () => hasAudioConsent() && preferences.cleanupGrant === readCleanupGrant(), + ), + options, + ).then((result) => result.text); }; controller = new VoiceInputController({ retryFailedRecording: true, @@ -346,11 +373,11 @@ export function useEnvironmentSpeechInput(input: HookInput) { controller.dispose(); if (controllerRef.current === controller) controllerRef.current = null; }; - }, [prepared, grantId]); + }, [prepared, grantId, cloudProvider, cloudModel]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- probes the chosen environment's speech status once its connection is available useEffect(() => { - if (!prepared) return; + if (!prepared || cloudProvider) return; let disposed = false; void probeSpeechStatus(prepared) .then((value) => { @@ -362,24 +389,23 @@ export function useEnvironmentSpeechInput(input: HookInput) { return () => { disposed = true; }; - }, [prepared]); - const currentStatus = status?.prepared === prepared ? status.value : null; + }, [prepared, cloudProvider]); + const currentStatus = !cloudProvider && status?.prepared === prepared ? status.value : null; + const setupModelId = setup.model?.id; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- polls download progress while the setup dialog owns an active model download useEffect(() => { - if (!setup.open || !setup.downloading || !prepared || !currentStatus?.supported) return; - const modelId = currentStatus.modelId; + if (!setup.open || !setup.downloading || !prepared || !setupModelId) return; let disposed = false; let refreshing = false; const timer = window.setInterval(() => { if (refreshing) return; refreshing = true; - void runtime - .runPromise(getEnvironmentSpeechModels(prepared)) + void runEnvironmentSpeech(getEnvironmentSpeechModels(prepared)) .then((result) => { if (disposed) return; - const model = result.models.find((candidate) => candidate.id === modelId) ?? null; - setSetup((current) => ({ ...current, model })); + const model = result.models.find((candidate) => candidate.id === setupModelId) ?? null; + if (model) setSetup((current) => ({ ...current, model })); }) .catch(() => undefined) .finally(() => { @@ -390,7 +416,7 @@ export function useEnvironmentSpeechInput(input: HookInput) { disposed = true; window.clearInterval(timer); }; - }, [currentStatus, prepared, setup.downloading, setup.open]); + }, [prepared, setup.downloading, setup.open, setupModelId]); const previousOwnerRef = useRef(input.ownerKey); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- abandons the recording and any queued submission when the draft's owner changes @@ -423,16 +449,54 @@ export function useEnvironmentSpeechInput(input: HookInput) { if (controller.currentState.phase === "recording") void controller.stop(); }); + const loadSetupCatalog = useStableCallback( + async (connection: PreparedConnection, selectedModelId: string, request: number) => { + setSetup({ ...INITIAL_SETUP, open: true }); + const catalog = await runEnvironmentSpeech(getEnvironmentSpeechModels(connection)).catch( + () => null, + ); + if (request !== startRequestRef.current) return; + const choice = chooseSetupSpeechModel(catalog?.models ?? [], { + selectedModelId, + intent: latestRef.current.clientSettings.speechLanguage, + preferred: preferredSpeechLanguages(), + }); + setSetup((current) => ({ + ...current, + model: choice?.model ?? null, + language: choice?.language ?? null, + catalogFailed: catalog === null, + error: choice + ? null + : catalog + ? "No model matches your language. Choose a model or change the language in Voice settings." + : "Could not load transcription models. Check the connection and try again.", + })); + }, + ); + const start = useStableCallback(async () => { + if (latestRef.current.input.disabled || (setup.open && setup.downloading)) return; + if (!grantId) { + if (destination.offerFirstRunSetup && latestRef.current.input.offerSetup !== false) + await navigate({ to: "/settings/voice", hash: "voice-transcription-destination" }); + return; + } const request = ++startRequestRef.current; const controller = controllerRef.current; const owner = latestRef.current.input.ownerKey; - if (latestRef.current.input.disabled || !controller || !prepared) return; + if (!controller || !prepared) return; + if (cloudProvider) { + setLevel(0); + await controller.start(); + return; + } const stillCurrent = () => request === startRequestRef.current && controllerRef.current === controller && latestRef.current.input.ownerKey === owner && !latestRef.current.input.disabled; + setSetup(INITIAL_SETUP); setQueuedStart(prepared); let latestStatus: EnvironmentSpeechStatus; try { @@ -448,8 +512,11 @@ export function useEnvironmentSpeechInput(input: HookInput) { return; } if (!stillCurrent()) return; - setQueuedStart(null); setStatus({ prepared, value: latestStatus }); + setQueuedStart(null); + if (latestStatus.supported && latestStatus.state === "missing-model") { + return loadSetupCatalog(prepared, latestStatus.modelId, request); + } if (!latestStatus.supported) { toastManager.add({ type: "error", @@ -458,32 +525,46 @@ export function useEnvironmentSpeechInput(input: HookInput) { }); return; } - if (latestStatus.state === "missing-model") { - setSetup({ ...INITIAL_SETUP, open: true }); - return; - } setLevel(0); await controller.start(); }); const downloadSetupModel = useStableCallback(async () => { - if (!prepared || !currentStatus?.supported || setup.downloading) return; + if (!prepared || !currentStatus?.supported || !setupModelId || setup.downloading) return; + const modelId = setupModelId; const request = ++startRequestRef.current; setSetup((current) => ({ ...current, downloading: true, error: null })); try { - await runtime.runPromise(downloadEnvironmentSpeechModel(prepared, currentStatus.modelId)); + const { models } = await runEnvironmentSpeech( + downloadEnvironmentSpeechModel(prepared, modelId), + ); + if (request !== startRequestRef.current) return; + // A download cancelled from elsewhere still resolves, so select the model only once it landed. + const installed = models.some(({ id, state }) => id === modelId && state === "installed"); + if (installed && modelId !== currentStatus.modelId) { + try { + await runEnvironmentSpeech(selectEnvironmentSpeechModel(prepared, modelId)); + } catch { + if (request !== startRequestRef.current) return; + setSetup((current) => ({ + ...current, + downloading: false, + error: "The model downloaded, but this environment could not select it. Try again.", + })); + return; + } + } if (request !== startRequestRef.current) return; const next = await probeSpeechStatus(prepared, true); if (request !== startRequestRef.current) return; setStatus({ prepared, value: next }); + const ready = + installed && next.supported && next.modelId === modelId && next.state !== "missing-model"; setSetup((current) => ({ ...current, downloading: false, - step: next.supported && next.state !== "missing-model" ? 1 : current.step, - error: - next.supported && next.state !== "missing-model" - ? null - : "The model is not ready. Try downloading it again.", + step: ready ? 1 : current.step, + error: ready ? null : "The model is not ready. Try downloading it again.", })); } catch { if (request !== startRequestRef.current) return; @@ -496,13 +577,11 @@ export function useEnvironmentSpeechInput(input: HookInput) { }); const cancelSetupDownload = useStableCallback(async () => { - if (!prepared || !currentStatus?.supported) return; + if (!prepared || !setupModelId) return; startRequestRef.current += 1; setSetup((current) => ({ ...current, open: false, downloading: false })); try { - await runtime.runPromise( - cancelEnvironmentSpeechModelDownload(prepared, currentStatus.modelId), - ); + await runEnvironmentSpeech(cancelEnvironmentSpeechModelDownload(prepared, setupModelId)); } catch (error) { setSetup((current) => ({ ...current, @@ -511,11 +590,15 @@ export function useEnvironmentSpeechInput(input: HookInput) { } }); + const destinationReady = + grantId === null + ? Boolean(destination.offerFirstRunSetup) && input.offerSetup !== false + : prepared !== null && + (cloudProvider !== undefined || + (status?.prepared === prepared && status.value?.supported !== false)); return { available: - prepared !== null && - status?.prepared === prepared && - status.value?.supported !== false && + destinationReady && typeof navigator !== "undefined" && Boolean(navigator.mediaDevices?.getUserMedia) && typeof AudioWorkletNode !== "undefined", @@ -525,6 +608,10 @@ export function useEnvironmentSpeechInput(input: HookInput) { setup: { ...setup, setOpen: (open: boolean) => setSetup((current) => ({ ...current, open })), + retryCatalog: + setup.open && setup.catalogFailed && prepared && currentStatus?.supported + ? () => loadSetupCatalog(prepared, currentStatus.modelId, ++startRequestRef.current) + : null, download: downloadSetupModel, cancelDownload: cancelSetupDownload, startRecording: async () => { @@ -545,6 +632,7 @@ export function useEnvironmentSpeechInput(input: HookInput) { pendingSubmissionRef.current = null; startRequestRef.current += 1; setQueuedStart(null); + setSetup(INITIAL_SETUP); controllerRef.current?.cancel(); }), skipPostProcessing: useStableCallback(() => controllerRef.current?.skipPostProcessing()), diff --git a/apps/web/src/speech/useTextFieldSpeech.tsx b/apps/web/src/speech/useTextFieldSpeech.tsx index 5486482d623b6..a6c995355015d 100644 --- a/apps/web/src/speech/useTextFieldSpeech.tsx +++ b/apps/web/src/speech/useTextFieldSpeech.tsx @@ -33,6 +33,7 @@ export function useTextFieldSpeech(input: { ownerKey: input.ownerKey, draftText: input.text, disabled: input.disabled, + offerSetup: false, readDraft: () => { const field = latest.current.textareaRef.current; const cursor = field?.selectionStart ?? latest.current.text.length; @@ -109,9 +110,11 @@ export function TextFieldSpeechControls({ step={speech.setup.step} status={speech.status} model={speech.setup.model} + language={speech.setup.language} downloading={speech.setup.downloading} error={speech.setup.error} onOpenChange={speech.setup.setOpen} + onRetryCatalog={speech.setup.retryCatalog} onDownload={() => void speech.setup.download()} onCancelDownload={() => void speech.setup.cancelDownload()} onStartRecording={() => void speech.setup.startRecording()} diff --git a/apps/web/src/speech/voiceConsent.ts b/apps/web/src/speech/voiceConsent.ts index 81da7cd6adb3d..7c897fbc37ea5 100644 --- a/apps/web/src/speech/voiceConsent.ts +++ b/apps/web/src/speech/voiceConsent.ts @@ -1,8 +1,11 @@ import { VoiceTranscriptionDestination } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import { useSyncExternalStore } from "react"; import { randomUUID } from "../lib/utils"; import { + getLocalStorageItem, readLocalStorageValue, + subscribeLocalStorageKey, useLocalStorage, writeLocalStorageValue, } from "../hooks/useLocalStorage"; @@ -10,33 +13,94 @@ import { // Separate keys: a stale window saving ordinary preferences must not restore either grant. const AUDIO_KEY = "t3code:voice-audio-grant:v1"; const CLEANUP_KEY = "t3code:voice-cleanup-grant:v1"; -const AudioGrant = Schema.NullOr( - Schema.Struct({ id: Schema.String, destination: VoiceTranscriptionDestination }), -); +const Grant = Schema.Struct({ id: Schema.String, destination: VoiceTranscriptionDestination }); +const AudioGrant = Schema.NullOr(Grant); +const sameGrant = Schema.toEquivalence(AudioGrant); +const SavedChoice = Schema.Union([AudioGrant, Schema.Literal("off")]); +const AudioChoice = Schema.Union([ + SavedChoice, + Schema.Struct({ pending: Grant, previous: SavedChoice }), +]); +type AudioChoice = typeof AudioChoice.Type; +const savedKey = (id: string) => `${AUDIO_KEY}:saved:${id}`; +const pendingGrant = (choice: AudioChoice) => + choice && choice !== "off" && "pending" in choice ? choice.pending : null; const CleanupGrant = Schema.NullOr(Schema.String); -const decodeAudioGrant = Schema.decodeSync(Schema.fromJsonString(AudioGrant)); +const decodeChoice = Schema.decodeSync(Schema.fromJsonString(AudioChoice)); +const readChoice = () => readLocalStorageValue(AUDIO_KEY, null, AudioChoice); -export function useAudioGrant() { - return useLocalStorage(AUDIO_KEY, null, AudioGrant)[0]; +function readSaveMarker(id: string | undefined) { + try { + return id ? window.localStorage.getItem(savedKey(id)) : null; + } catch { + return undefined; + } +} + +function activeChoice(choice: AudioChoice, marker: string | null | undefined) { + if (!choice || choice === "off" || !("pending" in choice)) return choice; + // Only a confirmed missing marker means the previous grant still applies. + if (marker === null) return choice.previous; + return marker === "true" ? choice.pending : ("off" as const); +} + +export function useAudioChoice() { + const [choice] = useLocalStorage(AUDIO_KEY, null, AudioChoice); + const id = pendingGrant(choice)?.id; + const marker = useSyncExternalStore( + (notify) => (id ? subscribeLocalStorageKey(savedKey(id), notify) : () => {}), + () => readSaveMarker(id), + () => undefined, + ); + return activeChoice(choice, marker); +} + +function replaceChoice(choice: AudioChoice, previous = readChoice()) { + const old = pendingGrant(previous); + writeLocalStorageValue(AUDIO_KEY, choice, null, AudioChoice); + if (old && pendingGrant(getLocalStorageItem(AUDIO_KEY, AudioChoice))?.id !== old.id) + window.localStorage.removeItem(savedKey(old.id)); } export function chooseAudioDestination(destination: VoiceTranscriptionDestination | null) { - writeLocalStorageValue( - AUDIO_KEY, - destination && { id: randomUUID(), destination }, - null, - AudioGrant, + replaceChoice(destination ? { id: randomUUID(), destination } : "off"); +} + +export function pendingCloudAudioChoice( + destination: Extract, +) { + const choice = readChoice(); + const pending = { id: randomUUID(), destination }; + replaceChoice( + { pending, previous: activeChoice(choice, readSaveMarker(pendingGrant(choice)?.id)) }, + choice, ); + // A late response can acknowledge only its own choice, never write the destination another tab picked. + return () => { + if (pendingGrant(readChoice())?.id === pending.id) + writeLocalStorageValue(savedKey(pending.id), true, false, Schema.Boolean); + }; } // Bind this recording to this act of consent, even if the user later reselects the same destination. export function bindAudioGrant(id: string) { try { - const raw = window.localStorage.getItem(AUDIO_KEY); - if (!raw || decodeAudioGrant(raw)?.id !== id) return () => false; + let cachedRaw: string | null = null; + let choice: AudioChoice = null; + const current = () => { + const raw = window.localStorage.getItem(AUDIO_KEY); + if (raw !== cachedRaw) { + choice = raw ? decodeChoice(raw) : null; + cachedRaw = raw; + } + const active = activeChoice(choice, readSaveMarker(pendingGrant(choice)?.id)); + return active === "off" ? null : active; + }; + const bound = current(); + if (bound?.id !== id) return () => false; return () => { try { - return window.localStorage.getItem(AUDIO_KEY) === raw; + return sameGrant(current(), bound); } catch { return false; } diff --git a/apps/web/src/state/primaryEnvironment.test.ts b/apps/web/src/state/primaryEnvironment.test.ts new file mode 100644 index 0000000000000..2a90209ae463f --- /dev/null +++ b/apps/web/src/state/primaryEnvironment.test.ts @@ -0,0 +1,41 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { Atom, AtomRegistry } from "effect/reactivity"; +import { describe, expect, it } from "vite-plus/test"; + +import { createThemeEnvironmentIdAtom } from "./primaryEnvironment"; + +const PRIMARY = EnvironmentId.make("primary"); +const SERVICE = EnvironmentId.make("service"); +const REMOTE = EnvironmentId.make("remote"); + +function themeEnvironmentId(input: { + primary: EnvironmentId | null; + host: string | null; + catalog: ReadonlyArray; +}) { + const atom = createThemeEnvironmentIdAtom({ + primaryEnvironmentIdAtom: Atom.make(input.primary), + hostEnvironmentIdAtom: Atom.make(input.host), + catalogValueAtom: Atom.make({ entries: new Map(input.catalog.map((id) => [id, null])) }), + }); + return AtomRegistry.make().get(atom); +} + +describe("themeEnvironmentIdAtom", () => { + it("prefers the primary environment over this machine's background service", () => { + expect( + themeEnvironmentId({ primary: PRIMARY, host: "service", catalog: [PRIMARY, SERVICE] }), + ).toBe(PRIMARY); + }); + + it("uses the connected background service sharing the app's T3 home when there is no primary", () => { + expect(themeEnvironmentId({ primary: null, host: "service", catalog: [REMOTE, SERVICE] })).toBe( + SERVICE, + ); + }); + + it("uses no environment when that service is not among the connections", () => { + expect(themeEnvironmentId({ primary: null, host: "service", catalog: [REMOTE] })).toBeNull(); + expect(themeEnvironmentId({ primary: null, host: null, catalog: [REMOTE] })).toBeNull(); + }); +}); diff --git a/apps/web/src/state/primaryEnvironment.ts b/apps/web/src/state/primaryEnvironment.ts index 75739b8ddeffb..92b0b4bb8b3d5 100644 --- a/apps/web/src/state/primaryEnvironment.ts +++ b/apps/web/src/state/primaryEnvironment.ts @@ -1,6 +1,8 @@ +import type { EnvironmentId } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import { environmentCatalog } from "../connection/catalog"; +import { appAtomRegistry } from "../rpc/atomRegistry"; export const primaryEnvironmentIdAtom = Atom.make((get) => { for (const [environmentId, entry] of get(environmentCatalog.catalogValueAtom).entries) { @@ -10,3 +12,38 @@ export const primaryEnvironmentIdAtom = Atom.make((get) => { } return null; }).pipe(Atom.withLabel("web-primary-environment-id")); + +const hostEnvironmentIdAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("web-host-environment-id"), +); + +// Without a primary, only the service sharing the desktop app's T3 home supplies themes. +export function createThemeEnvironmentIdAtom(input: { + readonly primaryEnvironmentIdAtom: Atom.Atom; + readonly hostEnvironmentIdAtom: Atom.Atom; + readonly catalogValueAtom: Atom.Atom<{ readonly entries: ReadonlyMap }>; +}) { + return Atom.make((get) => { + const primaryEnvironmentId = get(input.primaryEnvironmentIdAtom); + if (primaryEnvironmentId !== null) return primaryEnvironmentId; + const hostEnvironmentId = get(input.hostEnvironmentIdAtom); + for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + if (environmentId === hostEnvironmentId) return environmentId; + } + return null; + }).pipe(Atom.withLabel("web-theme-environment-id")); +} + +export const themeEnvironmentIdAtom = createThemeEnvironmentIdAtom({ + primaryEnvironmentIdAtom, + hostEnvironmentIdAtom, + catalogValueAtom: environmentCatalog.catalogValueAtom, +}); + +export function startHostEnvironmentLookup(): void { + void window.desktopBridge + ?.getHostEnvironmentId?.() + .then((environmentId) => appAtomRegistry.set(hostEnvironmentIdAtom, environmentId)) + .catch(() => {}); +} diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index bced4e959fa8e..8be2ccd51f03e 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -26,7 +26,7 @@ import { isConnectedWithConfig } from "../components/settings/scopedSettings"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { environmentAtomList } from "./environmentAtomList"; -import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; +import { primaryEnvironmentIdAtom, themeEnvironmentIdAtom } from "./primaryEnvironment"; import { environmentSession } from "./session"; import { reuseProvidersIgnoringCheckedAt } from "./serverProviders.logic"; @@ -173,10 +173,19 @@ export const primaryServerAvailableEditorsAtom = Atom.make( const EMPTY_ENVIRONMENT_THEMES: ReadonlyArray = []; -export const primaryServerEnvironmentThemesAtom = Atom.make( +const themeServerConfigAtom = Atom.make((get): ServerConfig | null => { + const environmentId = get(themeEnvironmentIdAtom); + return environmentId === null ? null : get(serverEnvironment.configValueAtom(environmentId)); +}).pipe(Atom.withLabel("web-theme-server-config")); + +export const themeServerEnvironmentThemesAtom = Atom.make( (get): ReadonlyArray => - get(primaryServerConfigAtom)?.environmentThemes ?? EMPTY_ENVIRONMENT_THEMES, -).pipe(Atom.withLabel("web-primary-server-environment-themes")); + get(themeServerConfigAtom)?.environmentThemes ?? EMPTY_ENVIRONMENT_THEMES, +).pipe(Atom.withLabel("web-theme-server-environment-themes")); + +export const themeServerSettingsAtom = Atom.make( + (get): ServerSettings => get(themeServerConfigAtom)?.settings ?? DEFAULT_SERVER_SETTINGS, +).pipe(Atom.withLabel("web-theme-server-settings")); export const providersIgnoringCheckedAtAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get): ReadonlyArray => diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index f47a65cd08268..bf5de2e330a3f 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -8,11 +8,7 @@ import { type UsageProviderKind, type UsageSummaryInput, } from "@t3tools/contracts"; -import { - needsCursorKeychainAccess, - refreshUsage, - usageSummaryInput, -} from "@t3tools/client-runtime/state/usage"; +import { refreshUsage, usageSummaryInput } from "@t3tools/client-runtime/state/usage"; import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access"; import { AsyncResult, Atom } from "effect/reactivity"; import { useState } from "react"; @@ -31,7 +27,6 @@ export interface EnvironmentUsageStatus { readonly isConnected: boolean; readonly error: string | null; readonly summary: UsageSummary | null; - readonly needsCursorKeychainAccess: boolean; } const usageByWindowAtom = Atom.family((windowKey: string) => @@ -55,7 +50,6 @@ const usageByWindowAtom = Atom.family((windowKey: string) => isConnected, ...access, summary: null, - needsCursorKeychainAccess: false, }); continue; } @@ -77,10 +71,6 @@ const usageByWindowAtom = Atom.family((windowKey: string) => isConnected, error: result._tag === "Failure" ? "This environment could not report usage." : null, summary, - needsCursorKeychainAccess: needsCursorKeychainAccess( - summary, - get(serverEnvironment.providersValueAtom(environmentId)), - ), }); } return statuses; diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 3c561e5417eac..309010a383602 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -30,7 +30,7 @@ import { } from "./lib/contextChipAccents"; import { appAtomRegistry, subscribeAtomChanges } from "./rpc/atomRegistry"; -import { primaryServerEnvironmentThemesAtom } from "./state/server"; +import { themeServerEnvironmentThemesAtom } from "./state/server"; export { EMBER_THEME, GROVE_THEME, IRIS_THEME, OCEAN_THEME, T3_CHAT_THEME, THEME_COLOR_ROLES }; export type { ThemeAppearance, ThemeColorRole, ThemeColors, ThemeDefinition, ThemeVariants }; @@ -315,7 +315,7 @@ export function publishedThemeDefinitions( // Every reconnect snapshot delivers a fresh but usually identical array; keeping the // previous one skips regenerating palettes and repainting the document for it. const publishedThemesAtom = Atom.make((get): ReadonlyArray => { - const next = get(primaryServerEnvironmentThemesAtom); + const next = get(themeServerEnvironmentThemesAtom); const previous = Option.getOrNull(get.self>()); return previous !== null && Equal.equals(previous, next) ? previous : next; }).pipe(Atom.withLabel("web-published-environment-themes")); diff --git a/assets/README.md b/assets/README.md index 9ed3fc3cd050b..f03136d3231bc 100644 --- a/assets/README.md +++ b/assets/README.md @@ -8,7 +8,7 @@ The three Icon Composer projects are the source of truth for full application ic Each project uses `text.svg` for the T3 mark and `background.svg` when the background is a vector layer. Additional layers use semantic names that describe their role and placement. -Run `vp run icons:export` from the repository root to regenerate the tracked iOS, Linux, Windows, and web assets. The development web exports are also copied to `apps/web/public` for the browser favicon and splash screen. Run `vp run icons:check` to verify that the generated assets and public copies match their sources without changing files. +Run `vp run icons:export` from the repository root to regenerate the tracked iOS, Linux, Windows, and web assets. The development web exports are also copied to `apps/web/public` for the browser favicon, web app manifest icons, and splash screen. Run `vp run icons:check` to verify that the generated assets and public copies match their sources without changing files. Exporting requires Icon Composer 2 or newer on macOS. The script selects the newest compatible exporter from Xcode or a standalone Icon Composer installation and pins design generation 26. Set `ICON_COMPOSER_TOOL` to the full path of `Icon Composer.app/Contents/Executables/ictool` to override automatic discovery. diff --git a/assets/dev/blueprint-web-icon-192x192.png b/assets/dev/blueprint-web-icon-192x192.png new file mode 100644 index 0000000000000..3e2637386b1d1 Binary files /dev/null and b/assets/dev/blueprint-web-icon-192x192.png differ diff --git a/assets/dev/blueprint-web-icon-512x512.png b/assets/dev/blueprint-web-icon-512x512.png new file mode 100644 index 0000000000000..1fac754a327fd Binary files /dev/null and b/assets/dev/blueprint-web-icon-512x512.png differ diff --git a/assets/nightly/nightly-web-icon-192x192.png b/assets/nightly/nightly-web-icon-192x192.png new file mode 100644 index 0000000000000..95e9256dc8abb Binary files /dev/null and b/assets/nightly/nightly-web-icon-192x192.png differ diff --git a/assets/nightly/nightly-web-icon-512x512.png b/assets/nightly/nightly-web-icon-512x512.png new file mode 100644 index 0000000000000..6ce057fba4a67 Binary files /dev/null and b/assets/nightly/nightly-web-icon-512x512.png differ diff --git a/assets/prod/t3-black-web-icon-192x192.png b/assets/prod/t3-black-web-icon-192x192.png new file mode 100644 index 0000000000000..aa7944053b5d8 Binary files /dev/null and b/assets/prod/t3-black-web-icon-192x192.png differ diff --git a/assets/prod/t3-black-web-icon-512x512.png b/assets/prod/t3-black-web-icon-512x512.png new file mode 100644 index 0000000000000..597cfcc592370 Binary files /dev/null and b/assets/prod/t3-black-web-icon-512x512.png differ diff --git a/docs/internals/voice-input.md b/docs/internals/voice-input.md index fb84458a03a67..2bf73af0ab36d 100644 --- a/docs/internals/voice-input.md +++ b/docs/internals/voice-input.md @@ -2,8 +2,9 @@ Transcription edits a composer draft. It does not submit an agent turn. Audio is temporary client input, and only normal message submission sends the resulting -text. Mobile defaults to on-device transcription; any client that sends audio off -the device needs a grant for one environment. +text to the agent. Mobile defaults to on-device transcription. Sending audio off +the device requires an explicit grant naming one environment and, for cloud +transcription, one provider. The [shared controller](../../packages/client-runtime/src/voice-input/controller.ts) owns the operation while the client supplies capture and transcription. Preparation @@ -15,28 +16,42 @@ capture and `finish` replaces the file-based `transcribe`. ## Where audio may go -Only an explicit audio grant authorizes off-device transcription. On mobile the -grant is stored in device-bound secure storage, outside preference backups. The -recorder is the same for both destinations; only a finished recording can be -uploaded, and its grant is checked again after native decoding and before dispatch. +On mobile, on-device transcription needs no audio grant. Environment and cloud +transcription share a recorder and upload only finished recordings. The client +keeps the grant in device-bound secure storage, outside preference backups, and +rechecks it after native decoding and before upload. + The [web grant](../../apps/web/src/speech/voiceConsent.ts) lives outside the preferences snapshot so another -window saving unrelated settings cannot resurrect a revoked grant. Each new -choice gets a distinct grant, even if the destination was previously selected. The -[web hook](../../apps/web/src/speech/useEnvironmentSpeechInput.ts) binds a -recording controller to the prepared connection of that destination and to -nothing else. When the destination changes, disconnects, or stops advertising the -`voiceTranscription` capability, the controller is disposed: a recording in -progress is discarded, a failed recording kept for retry is deleted, and nothing -falls back to another environment. Every sender in the web app, including the -settings page's transcription test, goes through that hook. The microphone loopback +window saving unrelated settings cannot resurrect a revoked grant. Each new choice +gets a distinct grant, even if the destination was previously selected. The +[web hook](../../apps/web/src/speech/useEnvironmentSpeechInput.ts) binds the +controller to that grant and the selected environment's connection. It requires +`voiceTranscription` to use the environment's model or `cloudVoiceTranscription` +to forward audio to the selected provider. Changing the grant or losing the +connection, permission, or required capability discards both the active recording +and any recording retained for retry. It never chooses a fallback destination. +The settings page's transcription test uses the same hook. The microphone loopback test captures separately for local playback and sends nothing. The transport refuses redirects so credentials and audio cannot follow one to another host. Transcript cleanup requires its own grant, off by default. It sends text, never audio, to the text generation provider of the draft's -own environment, because that is where the provider credentials live. A later -backend adds its own destination kind, disclosure, and transcriber; capture and -the controller stay as they are. +own environment, because that is where the provider credentials live. + +A cloud destination binds both the forwarding environment and the named provider. +It uses a separate, batch-only endpoint: changing a server key cannot upgrade +an environment-local grant, and a cloud recording never uses the streaming +socket or falls back to another provider or the environment's native model. + +OpenRouter routes to many upstreams, so its grant also names the exact model and +the model travels with every upload as a query parameter. The server accepts only +ids present in OpenRouter's public ZDR-eligible transcription catalog, +which it caches for ten minutes after a successful read; +router aliases such as `openrouter/auto` or `:nitro` fail schema validation before +that. Requests set `provider.zdr` and carry no `models` fallback list. OpenRouter +applies the account's safety-retention policy when choosing an endpoint. The catalog +holds no credential or consent state, which is why it may be cached while key +presence is read fresh on every request. ## Server side diff --git a/docs/operations/release.md b/docs/operations/release.md index 4dd489bfde064..be74b2ad2982e 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -342,9 +342,9 @@ uploads resume on the next check. Normal Nightly releases include the newest processed internal-testing APK available when the release publishes. Internal builds do not create separate Android releases. -If Play credentials or a processed APK are unavailable, the Nightly can still publish -without an APK. Android submissions continue to go to internal testing; promote a -build to Alpha to publish it as a beta. +If Play credentials or a processed APK are unavailable, or the download takes more +than five minutes, the Nightly can still publish without an APK. Android submissions +continue to go to internal testing; promote a build to Alpha to publish it as a beta. The APK is the universal APK Play generates and signs from the uploaded bundle, so no signing key lives in GitHub. The job skips itself until @@ -533,9 +533,11 @@ blockmaps, with a 60 MB maximum for a representative sidecar-to-sidecar update. ## 0) npm OIDC trusted publishing setup (CLI) -After the GitHub Release is published, the workflow runs `node scripts/build-npm-launcher.ts`, then -`node apps/server/scripts/cli.ts publish --packages-dir npm-packages`, which runs `npm publish` on -`t3.tgz`. That is the only package published per release. +`build_bundle` builds the version-matched `t3.tgz` with `node scripts/build-npm-launcher.ts` and +uploads it as the `npm-launcher` artifact. After the GitHub Release is published, `publish_cli` +checks out only what it needs to set up the project's Node.js, downloads that exact upload by the +artifact ID `build_bundle` outputs (never by name), checks the tarball version, and runs +`npm publish --provenance` on it. That is the only package published per release. Checklist: @@ -663,7 +665,7 @@ Checklist: - preflight passes - release quality checks pass - `build_bundle` and all platform builds pass - - `publish_cli` publishes the exact release version before the release job + - `publish_cli` publishes the exact release version after the release job - release job uploads expected files 5. Smoke test downloaded artifacts. diff --git a/docs/user/appearance.md b/docs/user/appearance.md index 4ee09382c641c..5937815ce2578 100644 --- a/docs/user/appearance.md +++ b/docs/user/appearance.md @@ -64,7 +64,9 @@ change. Export your theme as JSON to share it. ## Environment themes Environment themes and defaults come from the server serving your web app or the desktop app's -main local environment. app.t3.codes and additional connections do not use them. +main local environment. With the desktop app's local environment off, they come from the +[background service](./background-service.md) on the same machine while the app is connected to +it. app.t3.codes and other connections do not use them. Select a published theme in **Settings โ†’ Appearance** to follow its palette as the server updates it. **Duplicate** makes an independent copy you can edit. A saved custom theme with the same ID diff --git a/docs/user/composer.md b/docs/user/composer.md index 13f451e2caa73..22b499ebe7b0c 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -203,6 +203,8 @@ choose the environment, and confirm. Finished recordings go only to that environ even when a draft belongs to another one. This also works on phones without local speech recognition. If the chosen environment disconnects, dictation stops; it never switches hosts. Choose **This device** to revoke access and discard pending audio. +For [cloud dictation](./voice-input.md#choose-where-audio-goes), open **Cloud providers** +under the environment in **Settings โ†’ Voice** and confirm which provider may receive recordings. On Android, keep T3 Code open while recording and transcribing. On-device dictation may need a network connection to download a speech model the diff --git a/docs/user/outside-agents.md b/docs/user/outside-agents.md index f040f2218058d..ebc477d712fca 100644 --- a/docs/user/outside-agents.md +++ b/docs/user/outside-agents.md @@ -45,9 +45,10 @@ Only approve a sign-in you just started. To approve, enter a pairing code from browser already signed in to the environment with permission to manage access can approve without a code. -Then choose what the agent may do: +Then choose what the agent may do. **Auto** is selected by default, or the +closest lower mode allowed by your organizationโ€™s policy: -- **Read only** is the default. The agent can read projects and threads and see +- **Read only** lets the agent read projects and threads and see the available providers and models. It cannot change anything. - **Supervised** through **Full access** also let it start, message and stop threads in every project. A thread it starts or steers cannot run with more diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index bdc87902a63b2..3384831aa87b9 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -12,6 +12,9 @@ Manage shared usage and credits in ChatGPT through **Manage usage** in T3 Code. If a request uses a feature that ChatGPT sharing does not support, use another provider for that request. +Fast and Ultrafast speeds are unavailable with this connection. To use the speed +options your account supports, [use an existing Codex login](#use-an-existing-codex-login). + When reconnecting, choose the same account in T3 Code and on OpenAI's sign-in page. Disconnecting stops running threads but keeps their history and lets you reconnect later. @@ -25,6 +28,11 @@ T3 Code can use your installed Codex and its existing login. Run `codex login` on the environment's machine to sign in. [Provider setup](./install.md#providers) covers installation and custom configuration. +Running `codex login` does not change a managed ChatGPT connection. In +**Settings > Providers**, add a Codex instance, choose **Configure manually**, +and complete the wizard. Then select that instance from the thread's model +picker. See [Use multiple accounts](#use-multiple-accounts). + ## Use multiple accounts Add another ChatGPT account in **Settings โ†’ Providers**, then select the account diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index e71dde0d7db86..59e525ae333db 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -201,7 +201,7 @@ settle every thread in that section between it and the one you release on. The **Un-settle** and **Wake** buttons work the same way in their sections. Press `Escape` while dragging to cancel. -By default, environments settle inactive threads after three days and settle +By default, environments settle inactive threads after seven days and settle threads whose pull request merged. A closed pull request can also settle an idle thread. An empty thread's inactivity counts from when it was created. Work in progress, pending questions or approvals, and background work the agent @@ -320,9 +320,10 @@ You can also choose **Side chat** from the **+** menu in the right panel. The si chat opens as a tab there, with its own composer. Side chats do not show in the sidebar. Find one again under **Side chats** in thread details. -A side chat works in the same folder as the thread and inherits its permissions -when created, including for its first reply. Change permissions for an individual -side chat in its composer. Later changes to the parent do not change existing side chats. +A side chat works in the same folder as the thread and starts with the permissions, +model, and reasoning level shown in the thread's composer, including for its first +reply. Change them for an individual side chat in its composer. Later changes to the +parent do not change existing side chats. To use what you learned, choose **Bring back**. The thread gets the side chat with your next message there. Bring back is available while the thread is idle. diff --git a/docs/user/updating.md b/docs/user/updating.md index 6f8b35945cf6b..7e62c7bf580fc 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -170,8 +170,11 @@ Machines without npm check the public npm registry directly. **Settings โ†’ Providers** shows provider updates for the selected environment. **Update all** updates every outdated provider on every connected environment -at once. Hover it to see which providers it will update. Providers that only -offer a manual update command are not included. +at once. Hover it to see which providers it will update. A provider is left out +when any of its enabled, outdated instances offers only a manual update command, +or when its instances use different update commands, such as two instances that +run different CLI installs. Update those by hand, or point every instance of the +provider at the same CLI. After a successful provider CLI update, T3 Code refreshes model discovery for every enabled instance of that provider using its own configuration. Connected diff --git a/docs/user/usage.md b/docs/user/usage.md index d25bdba81f00a..d7e0dad901bef 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -25,13 +25,9 @@ databases, including T3-managed profiles. Set `OPENCODE_DATA_DIR` or `ANTIGRAVIT server to read a different data directory; comma-separated paths read multiple directories. Cursor reads account usage from Cursor's API for the account you signed in with in T3 or set as -`CURSOR_API_KEY`, or otherwise the CLI login saved on the server. -This includes headless T3 sessions and desktop usage across machines; the same account counts -once across connected environments. Without any of these, T3 shows a -notice instead of incomplete local totals. T3 does not estimate missing tokens from conversation text. -On macOS without a T3 sign-in, choose **Enable Cursor usage** on Usage to allow T3 to read your -existing CLI login from Keychain. You can turn it off in **Settings โ†’ Providers โ†’ Usage providers**. macOS may ask -you to allow access on the server Mac. +`CURSOR_API_KEY`. This includes headless T3 sessions and desktop usage across machines; the same +account counts once across connected environments. T3 does not estimate missing tokens from +conversation text. Usage includes each configured account's history, including disabled accounts. Custom homes follow the account's home setting or its `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, or `GROK_HOME` environment @@ -161,10 +157,9 @@ in OpenCode with an API key. A Console browser sign-in alone does not report the your Go API key as `OPENCODE_API_KEY` in the OpenCode instance's **Environment variables**, then refresh provider status. Cursor reports its monthly allowance, including separate Auto and API usage, for `CURSOR_AUTH_TOKEN` when it is set, -otherwise for the account you signed in with in T3 or set as `CURSOR_API_KEY`, otherwise for the CLI -login. On macOS, this includes the default Keychain login after you enable Cursor usage. Sign-in, API key, -and Keychain login report limits only with Cursor's default API endpoint. If you configure a custom -Cursor endpoint, use an explicit token or file-based CLI login for limits. +otherwise for the account you signed in with in T3 or set as `CURSOR_API_KEY`. +Sign-in and API keys report limits only with Cursor's default API endpoint. If you configure a custom +Cursor endpoint, use an explicit `CURSOR_AUTH_TOKEN` for limits. Grok reports the remaining subscription allowance and reset time for its current billing period after signing in with `grok login`. Explicit `XAI_API_KEY` connections and custom authentication diff --git a/docs/user/voice-input.md b/docs/user/voice-input.md index e204b28660626..c116c9e67a395 100644 --- a/docs/user/voice-input.md +++ b/docs/user/voice-input.md @@ -12,9 +12,10 @@ to a named environment on that phone, open **Settings โ†’ Voice โ†’ Transcribe o ## Choose where audio goes -On web and desktop, transcription runs on one of your T3 Code environments. +On web and desktop, you choose where transcription runs. Nothing is sent until you choose one: open **Settings โ†’ Voice** and set -**Send microphone audio to** the environment that should transcribe for you. +**Transcribe with** to the environment that should transcribe for you. +On a new device, the microphone or dictation shortcut opens that setting from the composer. Microphone audio recorded on this device is sent to that environment over your authenticated connection, transcribed there, and discarded. The choice is saved on this device only, so every computer and browser you use asks for it once. @@ -23,11 +24,37 @@ Set it back to **Off** at any time; a recording in progress is discarded. If the chosen environment goes offline, voice input waits for it. T3 Code never substitutes another environment. +For cloud dictation, choose **OpenAI** or **Grok Voice Transcribe** via the environment +you want to use. If it needs a key, enter one and choose **Save and enable**. +You can also choose a [secret provider](secrets.md) instead of **Value** and enter a +reference. Unlock the vault on that environment before dictating. Anyone with +dictation access to the environment can use the key, but only this device opts in. +If a key is already saved, just confirm the provider. After +you stop, the environment forwards your recording and any vocabulary hints to +the selected provider, which bills its API account. No local model download or chat +subscription is needed. Switch back to **This device** on mobile or **Off** on +web and desktop to stop sharing. Removing the key stops that provider for everyone +on the environment. + +**OpenRouter** works the same way but has no default model: pick one from its live +speech-to-text catalog when you enable it, or later under **Manage OpenRouter**. +The model choice is yours alone and needs no settings access, so anyone with +dictation access can pick a different model without touching the saved key. +OpenRouter chooses who runs the model. T3 requests zero data retention; your +OpenRouter account can also allow safety retention. If no endpoint qualifies, +dictation fails instead of switching models. +Vocabulary hints are not sent to OpenRouter; dictionary spellings are applied +after transcription. If OpenRouter stops listing your model, dictation stops +until you choose another. + ## Set up a model -The first time you dictate, T3 Code offers to download the recommended English -model to the chosen environment. In **Settings โ†’ Voice โ†’ Transcription**, select -the languages you dictate in to compare models that support all of them. +In **Settings โ†’ Voice โ†’ Transcription**, download a model and choose **Use model** if it +isnโ€™t already selected. **Test model** lets you try it without sending a message. +If you use the composer microphone before a model is installed, it offers to download one. +The recommendation uses your Voice language, or your systemโ€™s preferred languages with **Auto-detect**. + +Select the languages you dictate in to compare models that support all of them. Accuracy shows how often the model got words or characters wrong in each language; speed is the time to transcribe 30 seconds on a reference GPU. Choose **All languages** to browse the full catalog. Filtering does not switch your model. diff --git a/packages/client-runtime/src/modelTraits.test.ts b/packages/client-runtime/src/modelTraits.test.ts new file mode 100644 index 0000000000000..67b22e3099464 --- /dev/null +++ b/packages/client-runtime/src/modelTraits.test.ts @@ -0,0 +1,36 @@ +import { ProviderDriverKind, type ProviderOptionDescriptor } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildTraitsTriggerDisplay, getDescriptorStringValue } from "./modelTraits.ts"; + +describe("buildTraitsTriggerDisplay", () => { + it.each([ + { currentValue: undefined, defaultValue: undefined, value: null, label: "Thinking" }, + { currentValue: undefined, defaultValue: "high", value: "high", label: "High" }, + { currentValue: "low", defaultValue: undefined, value: "low", label: "Low" }, + { currentValue: "max", defaultValue: "high", value: "max", label: "Max" }, + { id: "effort", currentValue: undefined, defaultValue: undefined, value: null, label: "" }, + ])("labels Pi thinking without inventing a selection: $label ($value)", (input) => { + const descriptor: Extract = { + id: input.id ?? "thinking", + label: "Thinking", + type: "select", + options: ["low", "high", "max"].map((id) => ({ + id, + label: id[0]!.toUpperCase() + id.slice(1), + ...(id === input.defaultValue ? { isDefault: true } : {}), + })), + ...(input.currentValue ? { currentValue: input.currentValue } : {}), + }; + + expect( + buildTraitsTriggerDisplay({ + provider: ProviderDriverKind.make("pi"), + descriptors: [descriptor], + primarySelectDescriptorId: descriptor.id, + ultrathinkPromptControlled: false, + }).label, + ).toBe(input.label); + expect(getDescriptorStringValue(descriptor)).toBe(input.value); + }); +}); diff --git a/packages/client-runtime/src/modelTraits.ts b/packages/client-runtime/src/modelTraits.ts index 9a3dc06a15173..3782b79e567f8 100644 --- a/packages/client-runtime/src/modelTraits.ts +++ b/packages/client-runtime/src/modelTraits.ts @@ -169,11 +169,11 @@ export function buildTraitsTriggerDisplay(input: { ? "Ultrathink" : descriptor.type === "boolean" ? `${descriptor.label} ${descriptor.currentValue === true ? "On" : "Off"}` - : getProviderOptionCurrentLabel( + : (getProviderOptionCurrentLabel( descriptor, input.modelSelection, input.reportedModelSelection, - ); + ) ?? (descriptor.id === "thinking" ? descriptor.label : null)); if (typeof label === "string" && label.length > 0) { if (reasoningLabelIndex === -1 && isReasoningSelect(descriptor)) { reasoningLabelIndex = labels.length; diff --git a/packages/client-runtime/src/providerClients.ts b/packages/client-runtime/src/providerClients.ts index e23200653b963..3759cecbc7c7d 100644 --- a/packages/client-runtime/src/providerClients.ts +++ b/packages/client-runtime/src/providerClients.ts @@ -6,7 +6,7 @@ import { ProviderDriverKind, } from "@t3tools/contracts"; import { acpRegistryClient } from "@t3tools/provider-acp-registry/client"; -import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; +import { makeProviderClientRegistry, type ProviderIcon } from "@t3tools/provider-core/client"; import { modelShortName } from "@t3tools/provider-core/modelShortName"; import { cursorClient } from "@t3tools/provider-cursor/client"; import { fxClient } from "@t3tools/provider-fx/client"; @@ -16,6 +16,17 @@ import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; +// OpenRouter's Ink and Cloud glyphs from https://openrouter.ai/brand. +export const openRouterIcon: ProviderIcon = { + viewBox: "0 0 1024 730", + fill: { light: "#03080A", dark: "#FCFCFE" }, + paths: [ + { + d: "M795.893 0C915.776 0 1012.95 97.9963 1012.95 218.88C1012.95 339.764 915.776 437.76 795.893 437.76L1011.2 654.869C1038.55 682.447 1019.18 729.6 980.504 729.6H361.77C161.97 729.6 0 566.273 0 364.8C0 163.327 161.97 0 361.77 0L795.893 0ZM361.77 145.92C241.89 145.92 144.708 243.916 144.708 364.8C144.708 485.684 241.89 583.68 361.77 583.68C481.649 583.68 578.831 485.684 578.831 364.8C578.831 243.916 481.649 145.92 361.77 145.92Z", + }, + ], +}; + export const providerClients = makeProviderClientRegistry([ { driverKind: ProviderDriverKind.make("codex"), diff --git a/packages/client-runtime/src/secretRequest.ts b/packages/client-runtime/src/secretRequest.ts index d9612411db09d..26a32e735bcfb 100644 --- a/packages/client-runtime/src/secretRequest.ts +++ b/packages/client-runtime/src/secretRequest.ts @@ -95,6 +95,12 @@ export function secretRequestSources( providers: ServerSettings["secretProviders"], ): ReadonlyArray { if (config?.secretRequestReferences !== true) return NO_SOURCES; + return configuredSecretSources(providers); +} + +export function configuredSecretSources( + providers: ServerSettings["secretProviders"], +): ReadonlyArray { return (Object.keys(providers) as Array).flatMap((id) => { const provider = providers[id]; const definition = diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index 226931f38c0b9..79276f3851504 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -12,6 +12,7 @@ import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthPreviewOperateScope, + AuthProvidersManageScope, AuthSettingsWriteScope, AuthSourceControlWriteScope, ThreadId, @@ -130,6 +131,32 @@ describe("command permissions", () => { ), ); + it.effect("requires the destination provider grant to remove an incomplete connection", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + const remove = createCommandPermissions(runtime, WS_METHODS.providerAuthRemoveMethod); + registry.set(sessions(env), AsyncResult.success(grant(true))); + expect(registry.get(remove.permissionAtom(env))).toBe(false); + expect((yield* remove.authorize(registry, env).pipe(Effect.flip))._tag).toBe( + "EnvironmentAuthorizationError", + ); + registry.set( + sessions(env), + AsyncResult.success({ + ...grant(false), + scopes: [AuthProvidersManageScope], + permissions: [AuthProvidersManageScope], + }), + ); + expect(registry.get(remove.permissionAtom(env))).toBe(true); + yield* remove.authorize(registry, env); + registry.set(sessions(other), AsyncResult.success(grant(false))); + expect(registry.get(remove.permissionAtom(other))).toBe(false); + }), + ), + ); + it.effect("requires the destination settings grant to run storage cleanup", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/pendingUserInput.test.ts b/packages/client-runtime/src/state/pendingUserInput.test.ts index 7f5a2b0b84c3d..5baaaac41c83a 100644 --- a/packages/client-runtime/src/state/pendingUserInput.test.ts +++ b/packages/client-runtime/src/state/pendingUserInput.test.ts @@ -178,3 +178,30 @@ describe("buildPendingUserInputAnswers", () => { }); }); }); + +it.each([ + ["single-select", singleSelectQuestion, ["Orchestration-first"], '"Orchestration-first"'], + [ + "multi-select", + { + ...singleSelectQuestion, + multiSelect: true, + options: [{ label: "Server" }, { label: "Web" }], + }, + ["Server", "Web"], + '["Server","Web"]', + ], +])("sends a %s answer to a question with the id __proto__", (_kind, question, selected, json) => { + const answers = buildPendingUserInputAnswers( + [ + { ...question, id: "__proto__" }, + { ...singleSelectQuestion, id: "name" }, + ], + { + ["__proto__"]: { selectedOptionValues: selected }, + name: { selectedOptionValues: ["Orchestration-first"] }, + }, + ); + + expect(JSON.stringify(answers)).toBe(`{"__proto__":${json},"name":"Orchestration-first"}`); +}); diff --git a/packages/client-runtime/src/state/pendingUserInput.ts b/packages/client-runtime/src/state/pendingUserInput.ts index 1c71cdb29b4df..520fb626b5936 100644 --- a/packages/client-runtime/src/state/pendingUserInput.ts +++ b/packages/client-runtime/src/state/pendingUserInput.ts @@ -162,15 +162,15 @@ export function buildPendingUserInputAnswers( questions: ReadonlyArray, draftAnswers: Record, ): Record | null { - const answers: Record = {}; + const entries: Array<[string, PendingUserInputAnswer]> = []; for (const question of questions) { const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); if (answer === null) { return null; } - answers[question.id] = answer; + entries.push([question.id, answer]); } - return answers; + return Object.fromEntries(entries); } diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index de618e9f94b60..d3229468ab41b 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1132,7 +1132,6 @@ export function createServerEnvironmentAtoms( Object.keys(aliases) .sort() .map((model) => [model, aliases[model]]), - settings?.cursorKeychainUsageEnabled ?? false, ]); }).pipe(Atom.withLabel(`environment-data:server:usage-scan-settings:${environmentId}`)), ); @@ -1269,6 +1268,10 @@ export function createServerEnvironmentAtoms( label: "environment-data:provider:auth-cancel", tag: WS_METHODS.providerAuthCancel, }), + removeProviderAuthMethod: createEnvironmentRpcCommand(runtime, { + label: "environment-data:provider:auth-remove-method", + tag: WS_METHODS.providerAuthRemoveMethod, + }), logoutProviderAuth: createEnvironmentRpcCommand(runtime, { label: "environment-data:provider:auth-logout", tag: WS_METHODS.providerAuthLogout, diff --git a/packages/client-runtime/src/state/threadRequests.test.ts b/packages/client-runtime/src/state/threadRequests.test.ts index c2e1024e232ae..c8c43f2334087 100644 --- a/packages/client-runtime/src/state/threadRequests.test.ts +++ b/packages/client-runtime/src/state/threadRequests.test.ts @@ -235,6 +235,20 @@ describe("initial user input drafts", () => { customAnswer: " Proposed commit\n\n", }); }); + + it("seeds a question with the id __proto__ as its own draft", () => { + const seeded = seedUserInputDraftAnswers( + [ + { id: "__proto__", initialAnswer: "Proposed" }, + { id: "name", initialAnswer: "Control" }, + ], + {}, + ); + + expect(JSON.stringify(seeded)).toBe( + '{"__proto__":{"customAnswer":"Proposed"},"name":{"customAnswer":"Control"}}', + ); + }); }); const projection: OrchestrationV2ThreadProjection = { ...v2Projection, diff --git a/packages/client-runtime/src/state/threadRequests.ts b/packages/client-runtime/src/state/threadRequests.ts index 6cbad534cba1d..7c3c44c4caa59 100644 --- a/packages/client-runtime/src/state/threadRequests.ts +++ b/packages/client-runtime/src/state/threadRequests.ts @@ -99,18 +99,15 @@ export function seedUserInputDraftAnswers, drafts: Record, ): Record { - let seeded: Record = drafts; - for (const question of questions) { - if ( - question.initialAnswer === undefined || - question.allowCustomAnswer === false || - seeded[question.id] !== undefined - ) - continue; - if (seeded === drafts) seeded = { ...drafts }; - seeded[question.id] = { customAnswer: question.initialAnswer }; - } - return seeded; + // Entries, not assignment, so an id such as `__proto__` becomes an own draft. + const seeds = questions.flatMap((question) => + question.initialAnswer === undefined || + question.allowCustomAnswer === false || + Object.hasOwn(drafts, question.id) + ? [] + : [[question.id, { customAnswer: question.initialAnswer }] as const], + ); + return seeds.length === 0 ? drafts : { ...drafts, ...Object.fromEntries(seeds) }; } /** Keys a user-input draft by thread so a thread's resolved requests can be pruned together. */ diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index 5d258ed95b9ff..89b412a2c5dab 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -27,7 +27,12 @@ export function resolveSettledThreadTimestamp(thread: SettledThreadTimestampInpu if (thread.settledAt != null && toSortableTimestamp(thread.settledAt) !== null) { return thread.settledAt; } + return resolveThreadActivityTimestamp(thread); +} +export function resolveThreadActivityTimestamp( + thread: Pick, +): string | null { let latest: string | null = null; let latestMs = Number.NEGATIVE_INFINITY; for (const candidate of [ diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 0650b1f2093c7..380e2de731b5d 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -1,10 +1,7 @@ import { EnvironmentId, - ProviderDriverKind, - ProviderInstanceId, UsageDay, USAGE_CONTRACT_VERSION, - type ServerProvider, type UsageModelFilter, type UsageSummary, type UsageSummaryInput, @@ -14,12 +11,7 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { EnvironmentPresentation } from "../connection/presentation.ts"; -import { - cursorKeychainAccessEnvironments, - needsCursorKeychainAccess, - refreshUsage, - refreshUsageLimits, -} from "./usage.ts"; +import { refreshUsage, refreshUsageLimits } from "./usage.ts"; const input = { sinceDay: UsageDay.make("2026-09-05"), @@ -199,91 +191,17 @@ describe("manual usage refresh", () => { }); describe("limits refresh cooldown", () => { - it("runs a fresh check after an in-flight check when settings change", async () => { - const id = EnvironmentId.make("limits-after-enable"); + it("shares an in-flight check and skips duplicate automatic refreshes", async () => { + const id = EnvironmentId.make("limits-in-flight"); const oldCheck = Promise.withResolvers(); const first = refreshUsageLimits(id, () => oldCheck.promise, true); const newCheck = vi.fn(async () => "new limits"); - const afterEnable = refreshUsageLimits(id, newCheck, false, true); + const manual = refreshUsageLimits(id, newCheck); + expect(await refreshUsageLimits(id, newCheck, true)).toBeUndefined(); expect(newCheck).not.toHaveBeenCalled(); oldCheck.resolve("old limits"); expect(await first).toBe("old limits"); - expect(await afterEnable).toBe("new limits"); - expect(newCheck).toHaveBeenCalledTimes(1); - }); -}); - -describe("needsCursorKeychainAccess", () => { - const cursorPrompt: UsageSummary = { - ...summary, - sources: [ - { - fingerprint: { - hostId: "host", - provider: "cursor", - resolvedHomePath: "/Users/me/.cursor/auth.json", - volumeId: "volume", - }, - status: "ok", - scannedFiles: 0, - skippedFiles: 0, - malformedRecords: 0, - distinctSessions: 0, - message: "Cursor account usage is off on this environment.", - action: "enableCursorKeychain", - }, - ], - }; - const cursor = (status: ServerProvider["status"]): ServerProvider => ({ - instanceId: ProviderInstanceId.make("cursor"), - driver: ProviderDriverKind.make("cursor"), - enabled: status !== "disabled", - installed: status === "ready", - version: null, - status, - auth: { status: "unknown" }, - checkedAt: "2026-09-05T12:00:00.000Z", - models: [], - slashCommands: [], - skills: [], - }); - - it("offers access only when Cursor is ready on that environment", () => { - expect(needsCursorKeychainAccess(cursorPrompt, [cursor("ready")])).toBe(true); - expect(needsCursorKeychainAccess(cursorPrompt, [cursor("error")])).toBe(false); - expect(needsCursorKeychainAccess(cursorPrompt, [cursor("disabled")])).toBe(false); - expect(needsCursorKeychainAccess(cursorPrompt, [])).toBe(false); - expect(needsCursorKeychainAccess(cursorPrompt, null)).toBe(false); - expect(needsCursorKeychainAccess(summary, [cursor("ready")])).toBe(false); - }); - - it("stops offering access once any environment reads the Cursor account", () => { - const off = { summary: cursorPrompt, needsCursorKeychainAccess: true }; - const account: UsageSummary = { - ...summary, - sources: [ - { - fingerprint: { - hostId: "cursor.com", - provider: "cursor", - resolvedHomePath: "cursor-account:abc", - volumeId: "abc", - }, - status: "ok", - scannedFiles: 1, - skippedFiles: 0, - malformedRecords: 0, - distinctSessions: 1, - message: null, - }, - ], - }; - expect(cursorKeychainAccessEnvironments([off, off])).toEqual([off, off]); - expect( - cursorKeychainAccessEnvironments([ - off, - { summary: account, needsCursorKeychainAccess: false }, - ]), - ).toEqual([]); + expect(await manual).toBe("old limits"); + expect(newCheck).not.toHaveBeenCalled(); }); }); diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 2dea725be1f14..f302ffbdfd85c 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -1,10 +1,4 @@ -import type { - EnvironmentId, - ServerProvider, - UsageModelFilter, - UsageSummary, - UsageSummaryInput, -} from "@t3tools/contracts"; +import type { EnvironmentId, UsageModelFilter, UsageSummaryInput } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import type { AtomRegistry } from "effect/reactivity"; @@ -15,35 +9,6 @@ import type { createServerEnvironmentAtoms } from "./server.ts"; const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError); -export function needsCursorKeychainAccess( - summary: UsageSummary | null, - providers: readonly ServerProvider[] | null, -): boolean { - return ( - summary?.sources.some((source) => source.action === "enableCursorKeychain") === true && - providers?.some((provider) => provider.driver === "cursor" && provider.status === "ready") === - true - ); -} - -/** - * Any environment reading a Cursor account already reports that account's - * history from every machine, so the prompt only adds duplicates there. - */ -export function cursorKeychainAccessEnvironments< - E extends { readonly summary: UsageSummary | null; readonly needsCursorKeychainAccess: boolean }, ->(environments: readonly E[]): readonly E[] { - const hasCursorAccount = environments.some((environment) => - environment.summary?.sources.some( - (source) => - source.fingerprint.provider === "cursor" && source.fingerprint.hostId === "cursor.com", - ), - ); - return hasCursorAccount - ? [] - : environments.filter((environment) => environment.needsCursorKeychainAccess); -} - /** The Usage tab asks each environment with that environment's configured model filter. */ export function usageSummaryInput( input: UsageSummaryInput, @@ -59,16 +24,9 @@ export async function refreshUsageLimits( environmentId: EnvironmentId, refresh: () => Promise, automatic = false, - afterPending = false, ): Promise { const pending = limitsRefreshes.get(environmentId); if (pending !== undefined) { - if (afterPending) { - try { - await pending; - } catch {} - return refreshUsageLimits(environmentId, refresh, false, true); - } return automatic ? undefined : ((await pending) as A); } const refreshAfter = limitsRefreshAfter.get(environmentId) ?? 0; diff --git a/packages/client-runtime/src/voice-input/environment.ts b/packages/client-runtime/src/voice-input/environment.ts index 21166d8b9a084..52fa5a0d1fe5b 100644 --- a/packages/client-runtime/src/voice-input/environment.ts +++ b/packages/client-runtime/src/voice-input/environment.ts @@ -1,8 +1,12 @@ import { encodeSpeechPcmRequest } from "@t3tools/shared/speech"; import * as Effect from "effect/Effect"; import * as Redacted from "effect/Redacted"; -import { SPEECH_STREAM_PATH } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { CloudSpeechError, SPEECH_STREAM_PATH } from "@t3tools/contracts"; import type { + CloudSpeechModelId, + CloudSpeechProvider, + SettingsSecretValue, SpeechAcceleration, SpeechModelUnloadTimeout, SpeechTranscriptionOptions, @@ -17,8 +21,10 @@ import { makeEnvironmentHttpApiUrlBuilder, } from "../rpc/http.ts"; import { executeAuthenticatedEnvironmentHttpRequest } from "../state/environmentHttpAuth.ts"; +import { VoiceTranscriptionError } from "./transcription.ts"; const INFERENCE_OR_DOWNLOAD_TIMEOUT_MS = 30 * 60_000; +const isCloudSpeechError = Schema.is(CloudSpeechError); type EnvironmentApiClient = Effect.Success< ReturnType> >; @@ -65,6 +71,94 @@ export const getEnvironmentSpeechStatus = (prepared: PreparedConnection) => run: ({ client, headers }) => client.status({ headers }), }); +export const getCloudSpeechKeys = (prepared: PreparedConnection) => + request({ + group: "voice", + prepared, + method: "GET", + path: (urls) => urls.cloudKeys(), + run: ({ client, headers }) => client.cloudKeys({ headers }), + }); + +export const getCloudSpeechModels = (prepared: PreparedConnection) => + request({ + group: "voice", + prepared, + method: "GET", + path: (urls) => urls.cloudModels(), + run: ({ client, headers }) => client.cloudModels({ headers }), + }); + +export const requireCloudSpeechKey = ( + prepared: PreparedConnection, + provider: CloudSpeechProvider, +) => + getCloudSpeechKeys(prepared).pipe( + Effect.flatMap((keys) => + keys[provider] + ? Effect.void + : Effect.fail( + new VoiceTranscriptionError({ + code: "unavailable", + message: new CloudSpeechError({ provider, reason: "missing-key" }).message, + }), + ), + ), + ); + +export const setCloudSpeechKey = ( + prepared: PreparedConnection, + provider: CloudSpeechProvider, + key: SettingsSecretValue | null, +) => + request({ + group: "voice", + prepared, + method: "POST", + path: (urls) => urls.setCloudKey({ params: { provider } }), + run: ({ client, headers }) => + client.setCloudKey({ + headers, + params: { provider }, + payload: { key: typeof key === "string" ? Redacted.make(key) : key }, + }), + }); + +export const transcribeCloudPcm = ( + prepared: PreparedConnection, + provider: CloudSpeechProvider, + pcm: Uint8Array, + options: SpeechTranscriptionOptions, + canSend: () => boolean, + model?: CloudSpeechModelId, +) => { + const query = model === undefined ? {} : { model }; + return request({ + group: "voice", + prepared, + method: "POST", + canSend, + timeoutMs: 130_000, + path: (urls) => urls.transcribeCloud({ params: { provider }, query }), + run: ({ client, headers }) => + client.transcribeCloud({ + headers, + params: { provider }, + query, + payload: encodeSpeechPcmRequest(pcm, options), + }), + }).pipe( + Effect.mapError((error) => + error._tag === "RemoteEnvironmentAuthFetchError" && isCloudSpeechError(error.cause) + ? new VoiceTranscriptionError({ + code: "transcription-failed", + message: error.cause.message, + }) + : error, + ), + ); +}; + export const getEnvironmentSpeechStreamUrl = (prepared: PreparedConnection) => { let endpoint = prepared.httpBaseUrl; return request({ diff --git a/packages/client-runtime/src/voice-input/index.ts b/packages/client-runtime/src/voice-input/index.ts index 06a5399d6813c..5843267800f67 100644 --- a/packages/client-runtime/src/voice-input/index.ts +++ b/packages/client-runtime/src/voice-input/index.ts @@ -14,6 +14,11 @@ export { cancelEnvironmentSpeechModelDownload, downloadEnvironmentSpeechModel, getEnvironmentSpeechModels, + getCloudSpeechKeys, + getCloudSpeechModels, + requireCloudSpeechKey, + setCloudSpeechKey, + transcribeCloudPcm, getEnvironmentSpeechStatus, getEnvironmentSpeechStreamUrl, postProcessEnvironmentTranscript, diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index b5d1b9b7d788d..55a6176aabacb 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -5,6 +5,7 @@ import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthPreviewOperateScope, + AuthProvidersManageScope, AuthSettingsWriteScope, AuthSourceControlWriteScope, AuthTerminalOperateScope, @@ -15,6 +16,7 @@ import { WS_METHODS } from "./rpc.ts"; /** Incremental client enforcement; the server still authorizes every request. */ export const CLIENT_GUARDED_RPC_SCOPES = { + [WS_METHODS.providerAuthRemoveMethod]: AuthProvidersManageScope, [WS_METHODS.previewClose]: AuthPreviewOperateScope, // Stopping a server signals its processes, as closing a terminal does. [WS_METHODS.previewStopLocalServer]: AuthTerminalOperateScope, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 627b9529a6cc5..96b4537478ecc 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -174,6 +174,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ scheduledTaskThreadModes: Schema.optionalKey(Schema.Boolean), /** Server can transcribe 16 kHz mono PCM audio on the environment machine. */ voiceTranscription: Schema.optionalKey(Schema.Boolean), + cloudVoiceTranscription: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 8dd67640a0bc5..46dfcde197ac2 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -49,6 +49,12 @@ import { TrimmedNonEmptyString, } from "./baseSchemas.ts"; import { + CloudSpeechApiKey, + CloudSpeechError, + CloudSpeechKeys, + CloudSpeechModelId, + CloudSpeechModels, + CloudSpeechProvider, EnvironmentSpeechAccelerationRequest, EnvironmentSpeechModelRequest, EnvironmentSpeechModels, @@ -58,6 +64,7 @@ import { EnvironmentSpeechStatus, EnvironmentSpeechTranscriptionResult, } from "./speech.ts"; +import { SecretReference } from "./secretReference.ts"; import { OrchestrationV2ShellSnapshot, OrchestrationV2ThreadBoundedSnapshot, @@ -775,6 +782,50 @@ export class EnvironmentVoiceBodyLimit extends HttpApiMiddleware.Service string | null; + /** The OS's preferred languages, most preferred first; `navigator.languages` is pinned like the locale. */ + getPreferredSystemLanguages: () => readonly string[]; getLocalEnvironmentBootstraps: () => readonly DesktopEnvironmentBootstrap[]; getLocalEnvironmentEnabled: () => boolean; setLocalEnvironmentEnabled: (enabled: boolean) => Promise; + /** The environment ID recorded in this app's T3 home, such as its own machine's background service. */ + getHostEnvironmentId?: () => Promise; getLocalEnvironmentBearerToken: () => Promise; getClientSettings: () => Promise; setClientSettings: (settings: ClientSettings) => Promise; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index f4fe3445e9a9e..b20de548345c7 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -692,6 +692,7 @@ export const OrchestrationV2Run = Schema.Struct({ checkpointId: Schema.NullOr(CheckpointId), contextHandoffId: Schema.NullOr(ContextHandoffId), restartContinuationOfRunId: Schema.optional(RunId), + usageLimitContinuationOfRunId: Schema.optional(RunId), delegatedTaskId: Schema.optional(NodeId), workStartedAt: Schema.optional(Schema.DateTimeUtc), restartCancelledBackgroundWork: Schema.optional( diff --git a/packages/contracts/src/providerSetup.ts b/packages/contracts/src/providerSetup.ts index cd8b3ff3c710a..fe0d07681af0c 100644 --- a/packages/contracts/src/providerSetup.ts +++ b/packages/contracts/src/providerSetup.ts @@ -19,6 +19,7 @@ const SetupOperationId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); export const ProviderAuthMethod = Schema.Struct({ id: SetupOperationId, accountEmail: Schema.optional(TrimmedNonEmptyString), + canRemove: Schema.optionalKey(Schema.Boolean), name: TrimmedNonEmptyString, description: Schema.NullOr(Schema.String), type: Schema.Literals(["agent", "terminal", "credentials"]), @@ -91,6 +92,11 @@ export const ProviderAuthStartInput = Schema.Struct({ }); export type ProviderAuthStartInput = typeof ProviderAuthStartInput.Type; +export const ProviderAuthRemoveMethodInput = Schema.Struct({ + instanceId: ProviderInstanceId, + methodId: SetupOperationId, +}); + export const ProviderAuthRespondInput = Schema.Struct({ instanceId: ProviderInstanceId, flowId: SetupOperationId, diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 2988870be356c..532faa198364d 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -453,6 +453,7 @@ export const PullRequestLinkedThreadsResult = Schema.Struct({ projectId: ProjectId, title: Schema.String, archivedAt: Schema.NullOr(IsoDateTime), + settled: Schema.optionalKey(Schema.Boolean), }), ), }); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 41247781384e7..92ad228f2ec21 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -43,6 +43,7 @@ import { ProviderAuthCompleteInput, ProviderAuthState, ProviderAuthStartInput, + ProviderAuthRemoveMethodInput, ProviderAuthRespondInput, ProviderInstallCancelInput, ProviderInstallState, @@ -447,6 +448,7 @@ export const WS_METHODS = { chatGptHandoffSubscribe: "provider.chatgpt.handoff.subscribe", providerAuthRespond: "provider.auth.respond", providerAuthCancel: "provider.auth.cancel", + providerAuthRemoveMethod: "provider.auth.removeMethod", providerAuthLogout: "provider.auth.logout", providerAuthSubscribe: "provider.auth.subscribe", providerInstallStart: "provider.install.start", @@ -725,6 +727,12 @@ const WsProviderAuthCancelRpc = Rpc.make(WS_METHODS.providerAuthCancel, { error: ProviderSetupRpcError, }); +const WsProviderAuthRemoveMethodRpc = Rpc.make(WS_METHODS.providerAuthRemoveMethod, { + payload: ProviderAuthRemoveMethodInput, + success: ProviderAuthState, + error: ProviderSetupRpcError, +}); + const WsProviderAuthLogoutRpc = Rpc.make(WS_METHODS.providerAuthLogout, { payload: ProviderSetupInput, success: ProviderAuthState, @@ -2033,6 +2041,7 @@ export const WsRpcGroup = RpcGroup.make( WsChatGptHandoffSubscribeRpc, WsProviderAuthRespondRpc, WsProviderAuthCancelRpc, + WsProviderAuthRemoveMethodRpc, WsProviderAuthLogoutRpc, WsProviderAuthSubscribeRpc, WsProviderInstallStartRpc, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index c3eae38afc4ae..35cd269d6c7de 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -53,6 +53,7 @@ import { } from "./providerInstance.ts"; import { PullRequestMergeMethod } from "./pullRequest.ts"; import { + CloudSpeechModelId, SpeechAcceleration, SpeechCorrectionCue, SpeechCustomWords, @@ -93,7 +94,7 @@ export const SidebarAutoSettleAfterDays = Schema.Number.check( }), ); export type SidebarAutoSettleAfterDays = typeof SidebarAutoSettleAfterDays.Type; -const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 3; +const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 7; export const MAX_SNOOZE_PRESET_MINUTES = 30 * 24 * 60; export const MAX_SNOOZE_PRESETS = 8; @@ -365,11 +366,24 @@ export type ModelPreferences = { export const VoiceShortcutMode = Schema.Literals(["auto", "hold", "toggle"]); export type VoiceShortcutMode = typeof VoiceShortcutMode.Type; -/** Where this device sends microphone audio. A later backend adds its own kind here. */ -export const VoiceTranscriptionDestination = Schema.Struct({ - kind: Schema.Literal("environment"), - environmentId: EnvironmentId, -}); +/** + * Where this device sends microphone audio. A cloud grant names the forwarding environment and + * the provider; an OpenRouter grant also names the exact model, since OpenRouter has no default. + */ +export const VoiceTranscriptionDestination = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("environment"), environmentId: EnvironmentId }), + Schema.Struct({ + kind: Schema.Literal("cloud"), + environmentId: EnvironmentId, + provider: Schema.Literals(["openai", "xai"]), + }), + Schema.Struct({ + kind: Schema.Literal("cloud"), + environmentId: EnvironmentId, + provider: Schema.Literal("openrouter"), + model: CloudSpeechModelId, + }), +]); export type VoiceTranscriptionDestination = typeof VoiceTranscriptionDestination.Type; export const ClientSettingsSchema = Schema.Struct({ @@ -1236,6 +1250,15 @@ export const StorageCleanupSettings = Schema.Struct({ }); export type StorageCleanupSettings = typeof StorageCleanupSettings.Type; +const CloudSpeechCredential = SettingsSecretValue.pipe( + Schema.withDecodingDefault(Effect.succeed("")), +); +export const CloudSpeechCredentials = Schema.Struct({ + openai: CloudSpeechCredential, + xai: CloudSpeechCredential, + openrouter: CloudSpeechCredential, +}); + export const ServerSettings = Schema.Struct({ // Null means unmigrated; empty is authoritative and must not resurrect cleared preferences. favorites: Schema.NullOr(ModelFavorites).pipe(Schema.withDecodingDefault(Effect.succeed(null))), @@ -1261,6 +1284,9 @@ export const ServerSettings = Schema.Struct({ ), enableAgentBrowserAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + cloudSpeechCredentials: CloudSpeechCredentials.pipe( + Schema.withDecodingDefault(Effect.succeed({ openai: "", xai: "", openrouter: "" })), + ), speechModelId: Schema.String.pipe( Schema.withDecodingDefault(Effect.succeed("handy-computer/parakeet-unified-en-0.6b-gguf")), ), @@ -1435,9 +1461,6 @@ export const ServerSettings = Schema.Struct({ secretProviders: Schema.Record(SecretProviderId, SecretProviderConfig).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), - cursorKeychainUsageEnabled: Schema.Boolean.pipe( - Schema.withDecodingDefault(Effect.succeed(false)), - ), usagePriceOverrides: Schema.Record(TrimmedNonEmptyString, UsageModelPriceOverride).pipe( Schema.withDecodingDefault(Effect.succeed({})), @@ -1680,6 +1703,13 @@ export const ServerSettingsPatch = Schema.Struct({ enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), + cloudSpeechCredentials: Schema.optionalKey( + Schema.Struct({ + openai: Schema.optionalKey(SettingsSecretValue), + xai: Schema.optionalKey(SettingsSecretValue), + openrouter: Schema.optionalKey(SettingsSecretValue), + }), + ), speechModelId: Schema.optionalKey(Schema.String), speechAcceleration: Schema.optionalKey(SpeechAcceleration), speechModelUnloadTimeout: Schema.optionalKey(SpeechModelUnloadTimeout), @@ -1785,7 +1815,6 @@ export const ServerSettingsPatch = Schema.Struct({ secretProviders: Schema.optionalKey( Schema.Record(SecretProviderId, Schema.NullOr(SecretProviderConfigWrite)), ), - cursorKeychainUsageEnabled: Schema.optionalKey(Schema.Boolean), usagePriceOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, Schema.NullOr(UsageModelPriceOverride)), diff --git a/packages/contracts/src/speech.ts b/packages/contracts/src/speech.ts index 5983b6951e07c..e6e4d3ddbc51e 100644 --- a/packages/contracts/src/speech.ts +++ b/packages/contracts/src/speech.ts @@ -1,5 +1,80 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const CloudSpeechApiKey = TrimmedNonEmptyString.check( + Schema.isMaxLength(4096), + Schema.isPattern(/^(?![a-z][a-z\d+.-]*:\/\/)[!-~]+$/i), +); + +export const CloudSpeechProvider = Schema.Literals(["openai", "xai", "openrouter"]); +export type CloudSpeechProvider = typeof CloudSpeechProvider.Type; +export const CLOUD_SPEECH_LABELS = { + openai: "OpenAI", + xai: "Grok Voice Transcribe", + openrouter: "OpenRouter", +} as const; +export const CLOUD_SPEECH_PROVIDER_NAMES = { + openai: "OpenAI", + xai: "xAI", + openrouter: "OpenRouter", +} as const; +/** Servers that predate OpenRouter report only the first two providers. */ +export const CloudSpeechKeys = Schema.Struct({ + openai: Schema.Boolean, + xai: Schema.Boolean, + openrouter: Schema.optionalKey(Schema.Boolean), +}); +export type CloudSpeechKeys = typeof CloudSpeechKeys.Type; + +/** An exact `author/model` slug. Router shortcuts such as `:nitro` or `openrouter/auto` are not models. */ +export const CloudSpeechModelId = Schema.String.check( + Schema.isMinLength(3), + Schema.isMaxLength(160), + Schema.isPattern(/^(?!openrouter\/)[a-z\d][a-z\d.-]*\/[a-z\d][a-z\d._-]*$/i), +); +export type CloudSpeechModelId = typeof CloudSpeechModelId.Type; +export const CloudSpeechModel = Schema.Struct({ id: CloudSpeechModelId, name: Schema.String }); +export type CloudSpeechModel = typeof CloudSpeechModel.Type; +export const CloudSpeechModels = Schema.Struct({ models: Schema.Array(CloudSpeechModel) }); +export type CloudSpeechModels = typeof CloudSpeechModels.Type; + +export class CloudSpeechError extends Schema.TaggedError()( + "CloudSpeechError", + { + provider: CloudSpeechProvider, + reason: Schema.Literals([ + "missing-key", + "key-changed", + "unreadable-key", + "rejected-key", + "rate-limit", + "model-unavailable", + "unavailable", + ]), + }, + { httpApiStatus: 422 }, +) { + override get message() { + const name = CLOUD_SPEECH_PROVIDER_NAMES[this.provider]; + switch (this.reason) { + case "missing-key": + return `Add your ${name} API key in Settings โ†’ Voice.`; + case "key-changed": + return `The ${name} key changed before your recording was sent. Try again.`; + case "unreadable-key": + return `Couldnโ€™t read the ${name} API key. Unlock the vault on the selected environment or update the key in Settings โ†’ Voice. No audio was sent to ${name}.`; + case "rejected-key": + return `${name} rejected this API key. Check its permissions and billing in Settings โ†’ Voice.`; + case "rate-limit": + return `${name} reached a usage limit. Check the account's quota or try again later.`; + case "model-unavailable": + return `The chosen model isnโ€™t available on ${name}. Choose another model in Settings โ†’ Voice.`; + case "unavailable": + return `${name} couldnโ€™t transcribe this recording. Try again later.`; + } + } +} export const SpeechModelId = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(160)); export type SpeechModelId = typeof SpeechModelId.Type; diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 7f5a609eee20a..b473a7264b1a4 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -91,7 +91,6 @@ export const UsageSource = Schema.Struct({ /** Total this count; bucket counts double-count sessions spanning days or models. */ distinctSessions: NonNegativeInt, message: Schema.NullOr(TrimmedNonEmptyString), - action: Schema.optionalKey(Schema.Literal("enableCursorKeychain")), /** Cached response during refresh; repeat with awaitRefresh for fresh data. */ refreshing: Schema.optionalKey(Schema.Literal(true)), }); diff --git a/packages/effect-acp/scripts/generate.ts b/packages/effect-acp/scripts/generate.ts index 134b64aa0cf3c..8617d16dd4b15 100644 --- a/packages/effect-acp/scripts/generate.ts +++ b/packages/effect-acp/scripts/generate.ts @@ -166,6 +166,37 @@ function isJsonArray(value: Schema.Json | undefined): value is ReadonlyArray Schema.Json, +): Record { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => { + if (DATA_KEYWORDS.has(key)) return [key, child]; + if ( + key === "properties" && + child !== null && + typeof child === "object" && + !isJsonArray(child) + ) { + return [ + key, + Object.fromEntries( + Object.entries(child).map(([name, schema]) => [name, transform(schema)]), + ), + ]; + } + return [key, isJsonArray(child) ? child.map(transform) : transform(child)]; + }), + ); +} + function normalizeSchemaKeywords(value: Schema.Json): Schema.Json { if (isJsonArray(value)) { return value.map(normalizeSchemaKeywords); @@ -174,11 +205,7 @@ function normalizeSchemaKeywords(value: Schema.Json): Schema.Json { return value; } - const normalizedEntries = Object.entries(value).map(([key, child]) => [ - key, - normalizeSchemaKeywords(child), - ]); - const normalizedObject = Object.fromEntries(normalizedEntries) as Record; + const normalizedObject = mapSubschemas(value, normalizeSchemaKeywords); // ACP's forward-compatible variants exclude known discriminator values. // The Effect generator does not support `not`; express this exact exclusion // as a string pattern while preserving validation of the known variants. @@ -316,9 +343,7 @@ function flattenCompositions( ): Schema.Json { if (isJsonArray(value)) return value.map((child) => flattenCompositions(child, definitions)); if (value === null || typeof value !== "object") return value; - const normalized = Object.fromEntries( - Object.entries(value).map(([key, child]) => [key, flattenCompositions(child, definitions)]), - ); + const normalized = mapSubschemas(value, (child) => flattenCompositions(child, definitions)); const { allOf, anyOf, ...base } = normalized; const merge = (left: Schema.Json, right: Schema.Json): Schema.Json => { if ( diff --git a/packages/effect-acp/src/_generated/schema-v1.gen.ts b/packages/effect-acp/src/_generated/schema-v1.gen.ts index 268118dab204e..20a7c26d61c78 100644 --- a/packages/effect-acp/src/_generated/schema-v1.gen.ts +++ b/packages/effect-acp/src/_generated/schema-v1.gen.ts @@ -946,8 +946,22 @@ export const EnumOption = Schema.Struct({ identifier: "EnumOption", }); -export type TitledMultiSelectItems = never; -export const TitledMultiSelectItems = Schema.Never.annotate({ +export type TitledMultiSelectItems = { + readonly anyOf: ReadonlyArray; + readonly _meta?: { readonly [x: string]: Schema.Json } | null; +}; +export const TitledMultiSelectItems = Schema.Struct({ + anyOf: Schema.Array(EnumOption).annotate({ description: "Titled enum options." }), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Json.annotate({ expected: "JSON value" })).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), +}).annotate({ description: "Items definition for titled multi-select enum properties.", identifier: "TitledMultiSelectItems", }); diff --git a/packages/effect-acp/src/_generated/schema.gen.ts b/packages/effect-acp/src/_generated/schema.gen.ts index ad12b7e197e48..eb489ddf45f36 100644 --- a/packages/effect-acp/src/_generated/schema.gen.ts +++ b/packages/effect-acp/src/_generated/schema.gen.ts @@ -1128,8 +1128,24 @@ export const EnumOption = Schema.Struct({ identifier: "EnumOption", }); -export type TitledMultiSelectItems = never; -export const TitledMultiSelectItems = Schema.Never.annotate({ +export type TitledMultiSelectItems = { + readonly anyOf: ReadonlyArray; + readonly _meta?: { readonly [x: string]: Schema.Json } | null; +}; +export const TitledMultiSelectItems = Schema.Struct({ + anyOf: Schema.Array(EnumOption) + .annotate({ description: "Titled enum options. Must contain at least one option." }) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Json.annotate({ expected: "JSON value" })).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility)", + }), + Schema.Null, + ]), + ), +}).annotate({ description: "Items definition for titled multi-select enum properties.", identifier: "TitledMultiSelectItems", }); diff --git a/packages/effect-acp/src/agent.ts b/packages/effect-acp/src/agent.ts index 578509f9c85d9..ec6e2379342e6 100644 --- a/packages/effect-acp/src/agent.ts +++ b/packages/effect-acp/src/agent.ts @@ -35,6 +35,11 @@ export class AcpAgent extends Context.Service< AcpAgent, { readonly raw: { + /** + * Stream of inbound ACP notifications observed on the connection. Unread + * ones are kept best-effort: the newest 32 whose JSON lines fit in 16Mi + * characters, always including the latest. Ends when the connection does. + */ readonly notifications: Stream.Stream; readonly request: ( method: string, diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index 0736cb369c994..c5ab6d4b95364 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -49,6 +49,7 @@ const PermissionRequest = jsonRpcRequest( "session/request_permission", AcpSchema.RequestPermissionRequest, ); +const ElicitationResponse = jsonRpcResponse(AcpSchema.CreateElicitationResponse); const PermissionResponse = jsonRpcResponse(AcpSchema.RequestPermissionResponse); const ErrorResponse = Schema.Struct({ id: Schema.String, error: Schema.Unknown }); const DieResponse = Schema.Struct({ @@ -1295,6 +1296,59 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { }), ); + it.effect("hands titled multi-select elicitation fields to the handler", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const scope = yield* Scope.make(); + const acp = yield* AcpClient.make(stdio).pipe(Effect.provideService(Scope.Scope, scope)); + const received = yield* Deferred.make(); + yield* acp.handleElicitation((request) => + Deferred.succeed(received, request).pipe( + Effect.as({ action: "accept" as const, content: { areas: ["ui"] } }), + ), + ); + const requestedSchema = { + type: "object", + properties: { + areas: { + type: "array", + title: "Areas", + items: { + anyOf: [ + { const: "api", title: "API", description: "Server routes" }, + { const: "ui", title: "Interface" }, + ], + }, + }, + }, + required: ["areas"], + }; + + yield* Queue.offer( + input, + yield* encodeJsonl(jsonRpcRequest("elicitation/create", Schema.Unknown), { + jsonrpc: "2.0", + id: "elicitation", + method: "elicitation/create", + params: { + sessionId: "session-1", + mode: "form", + message: "Pick areas", + requestedSchema, + }, + headers: [], + }), + ); + const response = yield* Queue.take(output).pipe( + Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(ElicitationResponse))), + ); + assert.deepEqual(response.result, { action: "accept", content: { areas: ["ui"] } }); + const request = yield* Deferred.await(received); + assert.deepEqual("requestedSchema" in request && request.requestedSchema, requestedSchema); + yield* Scope.close(scope, Exit.void); + }), + ); + it.effect( "routes a standard prompt response after Grok extension notifications in the same batch", () => diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index ca1dba902ee8d..1d8f06c862def 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -60,6 +60,11 @@ export interface AcpClientOptions { } type AcpClientRaw = { + /** + * Inbound notifications. Unread ones are kept best-effort: the newest 32 whose + * JSON lines fit in 16Mi characters, always including the latest. Ends with the + * connection. + */ readonly notifications: Stream.Stream; readonly request: (method: string, payload: unknown) => Effect.Effect; readonly notify: (method: string, payload: unknown) => Effect.Effect; diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index 6eb9fe0ea3ca0..ad78437813bec 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -62,6 +62,16 @@ const JsonRpcErrorResponse = Schema.Struct({ }); const encodeUnknownJsonString = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const encoder = new TextEncoder(); +const MIB = 1024 * 1024; +const decodeIndex = Schema.decodeUnknownSync(Schema.Struct({ index: Schema.Number })); +const largeNotification = (index: number, textLength: number) => + encoder.encode( + `${encodeUnknownJsonString({ + jsonrpc: "2.0", + method: "x/large", + params: { index, text: "x".repeat(textLength) }, + })}\n`, + ); const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"), ); @@ -148,6 +158,173 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect("keeps only recent raw notifications after their callbacks run", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const handled = yield* Deferred.make(); + let handledCount = 0; + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + onNotification: () => + Effect.sync(() => ++handledCount).pipe( + Effect.flatMap((count) => + count === 64 ? Deferred.succeed(handled, undefined).pipe(Effect.asVoid) : Effect.void, + ), + ), + }); + + const messages = Array.from({ length: 64 }, (_, index) => + encodeUnknownJsonString({ + jsonrpc: "2.0", + method: "x/performance", + params: { index }, + }), + ); + yield* Queue.offer(input, encoder.encode(`${messages.join("\n")}\n`)); + yield* Deferred.await(handled); + + const retained = yield* transport.incoming.pipe(Stream.take(32), Stream.runCollect); + + assert.equal(handledCount, 64); + assert.equal(retained.length, 32); + assert.deepEqual(retained[0]?.params, { index: 32 }); + assert.deepEqual(retained[31]?.params, { index: 63 }); + }), + ); + + it.effect("keeps the newest unread raw notifications whose JSON fits in 16Mi characters", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const handled = yield* Queue.unbounded(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + onNotification: (notification) => + Queue.offer(handled, decodeIndex(notification.params).index).pipe(Effect.asVoid), + }); + + // Ten 3 MiB notifications: only the newest five fit in the budget. + for (let index = 0; index < 10; index++) { + yield* Queue.offer(input, largeNotification(index, 3 * MIB)); + assert.equal(yield* Queue.take(handled), index); + } + + const retained = yield* transport.incoming.pipe(Stream.take(5), Stream.runCollect); + assert.deepEqual( + retained.map((notification) => decodeIndex(notification.params).index), + [5, 6, 7, 8, 9], + ); + }), + ); + + it.effect("keeps a single unread raw notification that alone exceeds the budget", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const handled = yield* Queue.unbounded(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + onNotification: (notification) => + Queue.offer(handled, decodeIndex(notification.params).index).pipe(Effect.asVoid), + }); + + yield* Queue.offer(input, largeNotification(0, 1)); + yield* Queue.take(handled); + yield* Queue.offer(input, largeNotification(1, 17 * MIB)); + yield* Queue.take(handled); + + const [retained] = yield* transport.incoming.pipe(Stream.take(1), Stream.runCollect); + assert.equal(decodeIndex(retained?.params).index, 1); + }), + ); + + it.effect("delivers every raw notification to a reader that keeps up, then ends", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + }); + const received = yield* Queue.unbounded(); + const reader = yield* transport.incoming.pipe( + Stream.map((notification) => decodeIndex(notification.params).index), + Stream.tap((index) => Queue.offer(received, index)), + Stream.runCollect, + Effect.forkScoped, + ); + + // 30 MiB in total, well past the unread budget. + for (let index = 0; index < 10; index++) { + yield* Queue.offer(input, largeNotification(index, 3 * MIB)); + assert.equal(yield* Queue.take(received), index); + } + yield* Queue.end(input); + + assert.deepEqual(yield* Fiber.join(reader), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + }), + ); + + it.effect("ends the raw notification stream when the connection scope closes", () => + Effect.gen(function* () { + const { stdio } = yield* makeInMemoryStdio(); + const connection = yield* Scope.make(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + }).pipe(Scope.provide(connection)); + const reader = yield* transport.incoming.pipe(Stream.runCollect, Effect.forkScoped); + + yield* Scope.close(connection, Exit.void); + + assert.deepEqual(yield* Fiber.join(reader), []); + }), + ); + + it.effect("keeps the ended raw backlog intact when notifications arrive after termination", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const handled = yield* Queue.unbounded(); + const terminated = yield* Deferred.make(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio: Stdio.make({ + args: stdio.args, + stdin: stdio.stdin, + stdout: () => Sink.forEach(() => Effect.die("stdout failed")), + stderr: () => Sink.drain, + }), + serverRequestMethods: new Set(), + onNotification: (notification) => + Queue.offer(handled, decodeIndex(notification.params).index).pipe(Effect.asVoid), + onTermination: () => Deferred.succeed(terminated, undefined).pipe(Effect.asVoid), + }); + const deliver = (index: number) => + Queue.offer( + input, + encoder.encode( + `${encodeUnknownJsonString({ jsonrpc: "2.0", method: "x/late", params: { index } })}\n`, + ), + ).pipe(Effect.andThen(Queue.take(handled))); + + for (let index = 0; index < 32; index++) { + assert.equal(yield* deliver(index), index); + } + // A failed write terminates the connection while the reader keeps running. + yield* transport.notify("x/fail", {}).pipe(Effect.exit); + yield* Deferred.await(terminated); + assert.equal(yield* deliver(32), 32); + + const retained = yield* transport.incoming.pipe( + Stream.map((notification) => decodeIndex(notification.params).index), + Stream.runCollect, + ); + assert.deepEqual( + retained, + Array.from({ length: 32 }, (_, index) => index), + ); + }), + ); + // clanka once sent a usage_update without `used` and `size`. One bad update // must not end a session whose later updates are fine. it.effect("drops a session/update that fails to decode and keeps reading", () => diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index c29656cd12e7a..c61ee315e3536 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -128,6 +128,17 @@ interface AcpPendingRequest { readonly method: string; } +/** A decoded wire message and the length of the JSON line it arrived on. */ +interface AcpDecodedWireMessage { + readonly message: RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded; + readonly lineLength: number; +} + +interface AcpBufferedRawNotification { + readonly notification: AcpIncomingNotification; + readonly lineLength: number; +} + interface AcpOutgoingWrite { readonly acknowledgement?: Deferred.Deferred; readonly payload: string | Uint8Array; @@ -167,8 +178,16 @@ const makeStrictNdJsonRpcParser = () => { const codec = RpcSerialization.jsonRpc().makeUnsafe(); const decoder = new TextDecoder(); let buffer = ""; + const decodeLine = (line: string): ReadonlyArray => + codec.decode(line).map((message) => ({ + message: normalizeAcpJsonRpcError( + message as RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded, + ), + lineLength: line.length, + })); return { - decode: (bytes: Uint8Array | string): ReadonlyArray => { + // Every message in a batch line is charged the whole line. + decode: (bytes: Uint8Array | string): ReadonlyArray => { const chunk = typeof bytes === "string" ? bytes : decoder.decode(bytes, { stream: true }); const lastNewline = chunk.lastIndexOf("\n"); // Splitting the whole buffer per chunk is quadratic for multi-megabyte lines. @@ -178,14 +197,14 @@ const makeStrictNdJsonRpcParser = () => { } const lines = (buffer + chunk.slice(0, lastNewline)).split("\n"); buffer = chunk.slice(lastNewline + 1); - return lines.flatMap((line) => codec.decode(line)); + return lines.flatMap(decodeLine); }, // A truncated final line is left to the exit status, which explains a crash better. - end: (): ReadonlyArray => { + end: (): ReadonlyArray => { const line = buffer + decoder.decode(); buffer = ""; try { - return line.trim() === "" ? [] : codec.decode(line); + return line.trim() === "" ? [] : decodeLine(line); } catch { return []; } @@ -196,7 +215,11 @@ const makeStrictNdJsonRpcParser = () => { }, }; }; +// Unread raw notifications are best-effort. The newest is always kept; older +// ones are dropped once the backlog passes either limit. The length limit counts +// JSON line characters (UTF-16 code units), not bytes or heap size. const MAX_BUFFERED_RAW_NOTIFICATIONS = 32; +const MAX_BUFFERED_RAW_NOTIFICATION_JSON_LENGTH = 16 * 1024 * 1024; const decodeFailureDiagnostics = (error: AcpError.AcpProtocolParseError) => ({ operation: error.operation, @@ -258,9 +281,14 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi const parser = makeStrictNdJsonRpcParser(); const serverQueue = yield* Queue.unbounded(); const clientQueue = yield* Queue.unbounded(); - const notificationQueue = yield* Queue.sliding( - MAX_BUFFERED_RAW_NOTIFICATIONS, - ); + const rawNotifications = yield* Queue.unbounded>(); + // JSON length of the notifications offered since the queue was last empty. + // `incoming` takes the whole backlog on every pull, so an empty queue holds + // nothing, and a read can only make this an overestimate. + let rawNotificationsJsonLength = 0; + // Raw readers finish with the connection: termination ends the queue, and so + // does closing the scope, which interrupts the reader before it terminates. + yield* Effect.addFinalizer(() => Queue.end(rawNotifications)); const disconnects = yield* Queue.unbounded(); const outgoing = yield* Queue.unbounded>(); const outgoingWriterState = yield* Ref.make({ @@ -430,8 +458,27 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi ), ); - const dispatchNotification = (notification: AcpIncomingNotification) => - Queue.offer(notificationQueue, notification).pipe( + // Offer first so an ended queue rejects the notification without evicting the + // backlog readers are still draining. Offer and eviction share one sync block, + // so readers never see more than the limits allow. + const bufferRawNotification = (notification: AcpIncomingNotification, lineLength: number) => + Effect.sync(() => { + if (Queue.sizeUnsafe(rawNotifications) === 0) rawNotificationsJsonLength = 0; + if (!Queue.offerUnsafe(rawNotifications, { notification, lineLength })) return; + rawNotificationsJsonLength += lineLength; + while ( + Queue.sizeUnsafe(rawNotifications) > MAX_BUFFERED_RAW_NOTIFICATIONS || + (Queue.sizeUnsafe(rawNotifications) > 1 && + rawNotificationsJsonLength > MAX_BUFFERED_RAW_NOTIFICATION_JSON_LENGTH) + ) { + const dropped = Queue.takeUnsafe(rawNotifications); + if (dropped === undefined || Exit.isFailure(dropped)) break; + rawNotificationsJsonLength -= dropped.value.lineLength; + } + }); + + const dispatchNotification = (notification: AcpIncomingNotification, lineLength: number) => + bufferRawNotification(notification, lineLength).pipe( Effect.andThen( // A failing or dying handler must not stop the reader, or every later // message on the connection goes unanswered. @@ -461,6 +508,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi return [ Effect.gen(function* () { yield* Queue.offer(disconnects, 0); + yield* Queue.end(rawNotifications); const error = yield* classify(); yield* Deferred.fail(terminationFailure, error); yield* failAllExtPending(error); @@ -569,18 +617,21 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi ); }; - const handleRequestEncoded = (message: RpcMessage.RequestEncoded) => { + const handleRequestEncoded = (message: RpcMessage.RequestEncoded, lineLength: number) => { if (message.id === "") { if (message.tag === CLIENT_METHODS.session_update) { return decodeSessionUpdate(message.payload).pipe( Effect.matchEffect({ onFailure: dropUndecodableNotification(CLIENT_METHODS.session_update), onSuccess: (params) => - dispatchNotification({ - _tag: "SessionUpdate", - method: CLIENT_METHODS.session_update, - params: options.transformSessionUpdate?.(params) ?? params, - }), + dispatchNotification( + { + _tag: "SessionUpdate", + method: CLIENT_METHODS.session_update, + params: options.transformSessionUpdate?.(params) ?? params, + }, + lineLength, + ), }), ); } @@ -593,15 +644,14 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi Effect.matchEffect({ onFailure: dropUndecodableNotification(method), onSuccess: (params) => - dispatchNotification({ _tag: "ElicitationComplete", method, params }), + dispatchNotification({ _tag: "ElicitationComplete", method, params }, lineLength), }), ); } - return dispatchNotification({ - _tag: "ExtNotification", - method: message.tag, - params: message.payload, - }); + return dispatchNotification( + { _tag: "ExtNotification", method: message.tag, params: message.payload }, + lineLength, + ); } const observeIncoming = @@ -677,12 +727,13 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi }), ); - const routeDecodedMessage = ( - message: RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded, - ): Effect.Effect => { + const routeDecodedMessage = ({ + message, + lineLength, + }: AcpDecodedWireMessage): Effect.Effect => { switch (message._tag) { case "Request": - return handleRequestEncoded(message); + return handleRequestEncoded(message, lineLength); case "Exit": return handleExitEncoded(message); case "Chunk": @@ -724,12 +775,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi ).pipe( Effect.flatMap(() => Effect.try({ - try: () => - ( - parser.decode(data) as ReadonlyArray< - RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded - > - ).map(normalizeAcpJsonRpcError), + try: () => parser.decode(data), catch: (cause) => new AcpError.AcpProtocolParseError({ operation: "decode-wire-message", @@ -737,12 +783,14 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi }), }), ), - Effect.tap((messages) => - logProtocol({ - direction: "incoming", - stage: "decoded", - payload: messages, - }), + Effect.tap((decoded) => + options.logIncoming + ? logProtocol({ + direction: "incoming", + stage: "decoded", + payload: decoded.map(({ message }) => message), + }) + : Effect.void, ), Effect.tapErrorTag("AcpProtocolParseError", (error) => logProtocol({ @@ -751,25 +799,15 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi payload: decodeFailureDiagnostics(error), }), ), - Effect.flatMap((messages) => - Effect.forEach(messages, routeDecodedMessage, { + Effect.flatMap((decoded) => + Effect.forEach(decoded, routeDecodedMessage, { discard: true, }), ), ), ), Effect.andThen( - Effect.suspend(() => - Effect.forEach( - ( - parser.end() as ReadonlyArray< - RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded - > - ).map(normalizeAcpJsonRpcError), - routeDecodedMessage, - { discard: true }, - ), - ), + Effect.suspend(() => Effect.forEach(parser.end(), routeDecodedMessage, { discard: true })), ), // Anything that ends the reader, including a defect in a callback it runs, // terminates the connection so pending requests fail instead of hanging. @@ -1032,7 +1070,9 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi clientProtocol, serverProtocol, get incoming() { - return Stream.fromQueue(notificationQueue); + return Stream.fromQueue(rawNotifications).pipe( + Stream.map(({ notification }) => notification), + ); }, request: sendRequest, notify: sendNotification, diff --git a/packages/effect-acp/src/schema.test.ts b/packages/effect-acp/src/schema.test.ts index a066f2a5d1c94..ed8775271b52b 100644 --- a/packages/effect-acp/src/schema.test.ts +++ b/packages/effect-acp/src/schema.test.ts @@ -13,6 +13,27 @@ describe("pinned ACP wire schemas", () => { expect(accepts({ type: "future_content", payload: { value: 1 } })).toBe(true); }); + it("decodes titled multi-select form fields in v1 and v2", () => { + const titled = (anyOf: ReadonlyArray) => ({ + sessionId: "session-1", + mode: "form", + message: "Pick areas", + requestedSchema: { + type: "object", + properties: { areas: { type: "array", minItems: 1, items: { anyOf } } }, + required: ["areas"], + }, + }); + const form = titled([ + { const: "api", title: "API", description: "Server routes" }, + { const: "ui", title: "Interface" }, + ]); + for (const request of [V1.CreateElicitationRequest, V2.CreateElicitationRequest]) { + expect(Schema.decodeUnknownSync(request)(form)).toEqual(form); + expect(Schema.is(request)(titled([{ const: "api" }]))).toBe(false); + } + }); + it("keeps v1 command inputs compatible with native agents", () => { const command = { name: "plan", description: "Make a plan", input: { hint: "Task" } }; const isAvailableCommand = Schema.is(V1.AvailableCommand); diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index ff1b746f74a91..0fd91dae8a8e7 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -99,6 +99,7 @@ import { makeAcpClientPolicyGrants, unknownRecord, } from "./clientPolicy.ts"; +import { parseElicitationForm } from "./elicitationForm.ts"; import { makeAcpClientTerminals, resolveEmbeddedTerminalContent, @@ -1321,22 +1322,6 @@ function selectAutoApprovedPermissionOption( ); } -function elicitationContent( - answers: ProviderUserInputAnswers, - allowedKeys: ReadonlySet, -): Record { - const content: Record = {}; - for (const [key, value] of Object.entries(answers)) { - if (!allowedKeys.has(key)) continue; - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { - content[key] = value; - } else if (Array.isArray(value)) { - content[key] = value.filter((entry): entry is string => typeof entry === "string"); - } - } - return content; -} - interface ActiveTextSegment { readonly nativeItemId: string; readonly startedAt: DateTime.Utc; @@ -1591,6 +1576,8 @@ interface ActiveAcpSubagent { nextChildOrdinal: number; terminalStatusProjected: boolean; readonly toolState: Pick; + // ACP has no interrupted tool status; keep forced interruption across late updates. + readonly interruptedToolIds: Set; readonly toolSessionIds: Map; } @@ -2979,6 +2966,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const projectSubagentNotification = Effect.fnUntraced(function* ( subagent: ActiveAcpSubagent, notification: EffectAcpSchema.SessionNotification, + owner: ActiveAcpTurn, ) { const update = notification.update; if (update.sessionUpdate === "agent_message_chunk") { @@ -2986,6 +2974,8 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (text !== undefined) { yield* emitSubagentAssistant(subagent, text, "append", update.messageId); } + } else if (update.sessionUpdate === "tool_call_content_chunk") { + yield* projectSubagentTool(subagent, notification.sessionId, update, undefined, owner); } else if (update.sessionUpdate === "agent_message") { if (update.content === undefined) return; const text = (update.content ?? []) @@ -3001,8 +2991,11 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const projectSubagentTool = Effect.fnUntraced(function* ( subagent: ActiveAcpSubagent, sessionId: string, - toolCall: AcpToolCallState, + toolCall: + | AcpToolCallState + | Extract, projectedStatus?: ProjectedToolStatus, + owner?: ActiveAcpTurn, ) { const nativeTaskId = subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id); const key = @@ -3013,7 +3006,38 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const rootSessionId = yield* Ref.get(activeSessionId); subagent.toolSessionIds.set(key, sessionId); const previous = context.tools.get(key); + if ("sessionUpdate" in toolCall) { + if (toolCall.content.type !== "content") return; + const text = acpContentBlockDisplayText(toolCall.content.content); + if (text === undefined || text.length === 0) return; + const content: unknown[] = Array.isArray(previous?.data.content) + ? [...previous.data.content] + : []; + const last = unknownRecord(content.at(-1)); + const lastContent = unknownRecord(last?.content); + if ( + last?.type === "content" && + lastContent?.type === "text" && + typeof lastContent.text === "string" + ) { + content[content.length - 1] = { + ...last, + content: { ...lastContent, text: `${lastContent.text}${text}` }, + }; + } else { + content.push({ type: "content", content: { type: "text", text } }); + } + toolCall = { + toolCallId: toolCall.toolCallId, + data: { content }, + }; + } const merged = mergeToolCallState(previous, toolCall); + projectedStatus ??= owner === undefined ? undefined : stoppedToolStatus(owner, merged); + if (merged.status !== "failed") subagent.interruptedToolIds.delete(key); + if (projectedStatus === "interrupted") subagent.interruptedToolIds.add(key); + if (merged.status === "failed" && subagent.interruptedToolIds.has(key)) + projectedStatus = "interrupted"; context.tools.set(key, merged); if ( projectedStatus === undefined && @@ -3195,6 +3219,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( toolUpdatesSkipped: new Map(), toolStartedAt: new Map(), }, + interruptedToolIds: new Set(), toolSessionIds: new Map(), }; subagent.task = task; @@ -3283,7 +3308,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( context.pendingSubagentNotifications.delete(childSessionId); yield* Effect.forEach( buffered, - (notification) => projectSubagentNotification(subagent, notification), + (notification) => projectSubagentNotification(subagent, notification, context), { concurrency: 1, discard: true }, ); } @@ -3523,7 +3548,13 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ) continue; subagent.toolState.toolUpdatesSkipped.delete(key); - yield* projectSubagentTool(subagent, toolSessionId, tool, toolStatus(tool.status)); + yield* projectSubagentTool( + subagent, + toolSessionId, + tool, + toolStatus(tool.status), + context, + ); } } if ( @@ -3603,12 +3634,13 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( subagent, notification.sessionId, tool, - stoppedToolStatus(owner.owner, tool), + undefined, + owner.owner, ); } } } else { - yield* projectSubagentNotification(subagent, notification); + yield* projectSubagentNotification(subagent, notification, owner.owner); } }); @@ -5261,11 +5293,23 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( subagent, notification.sessionId, toolCall, - stoppedToolStatus(context, toolCall), + undefined, + context, ); } return; } + if (update.sessionUpdate === "tool_call_content_chunk") { + if (subagent !== undefined) + yield* projectSubagentTool( + subagent, + notification.sessionId, + update, + undefined, + context, + ); + return; + } const isDisplayableAssistantUpdate = (update.sessionUpdate === "agent_message_chunk" && acpContentBlockDisplayText(update.content) !== undefined) || @@ -5274,7 +5318,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( return; } if (subagent !== undefined) { - yield* projectSubagentNotification(subagent, notification); + yield* projectSubagentNotification(subagent, notification, context); return; } const buffered = context.pendingSubagentNotifications.get(notification.sessionId) ?? []; @@ -6695,36 +6739,30 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (params.mode !== "form" || !("requestedSchema" in params)) { return { action: "decline" } as const; } - const requestedSchema = unknownRecord(params.requestedSchema); - const properties = unknownRecord(requestedSchema?.properties) ?? {}; const nativeForm = flavor.extractElicitationForm?.(params); + const parsedForm = + nativeForm === undefined + ? parseElicitationForm(params.requestedSchema, params.message) + : undefined; + const form = + nativeForm ?? + (parsedForm === undefined + ? undefined + : { + questions: parsedForm.questions, + respond: ( + answers: ProviderUserInputAnswers, + ): EffectAcpSchema.CreateElicitationResponse => { + const content = parsedForm.content(answers); + return content === undefined + ? { action: "cancel" } + : { action: "accept", content }; + }, + }); + if (form === undefined) return { action: "decline" } as const; const elicitationScopeId = "sessionId" in params ? params.sessionId : `request:${params.requestId}`; - const questions = - nativeForm?.questions ?? - Object.entries(properties).map( - ([id, property], index): OrchestrationV2UserInputQuestion => { - const record = unknownRecord(property); - const enumValues = Array.isArray(record?.enum) - ? record.enum.filter((value): value is string => typeof value === "string") - : []; - const options = - enumValues.length > 0 - ? enumValues.map((value) => ({ label: value, description: value })) - : record?.type === "boolean" - ? [ - { label: "true", description: "Yes" }, - { label: "false", description: "No" }, - ] - : []; - return { - id, - header: nonEmptyText(record?.title, `Question ${index + 1}`), - question: nonEmptyText(record?.description, params.message), - options, - }; - }, - ); + const userInput = yield* requestUserInputWithAdmission( handlerGeneration, Effect.gen(function* () { @@ -6736,7 +6774,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( return { nativeItemId: nativeRequestId, nativeRequestId, - questions, + questions: form.questions, }; }), transportRequestId, @@ -6744,14 +6782,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const response = userInput.answers === null ? ({ action: "cancel" } as const) - : (nativeForm?.respond(userInput.answers) ?? - ({ - action: "accept", - content: elicitationContent( - userInput.answers, - new Set(Object.keys(properties)), - ), - } as const)); + : form.respond(userInput.answers); yield* userInput.acknowledgeNativeResponse; return response; }).pipe(holdingAgentRequest(handlerGeneration)), diff --git a/packages/provider-acp/src/server/clientTerminals.test.ts b/packages/provider-acp/src/server/clientTerminals.test.ts index 791bbc22a6a03..5955690dc6b70 100644 --- a/packages/provider-acp/src/server/clientTerminals.test.ts +++ b/packages/provider-acp/src/server/clientTerminals.test.ts @@ -51,6 +51,52 @@ describe("AcpClientTerminals", () => { ), ); + it.effect("preserves UTF-8 when stderr interleaves a split stdout character", () => + Effect.gen(function* () { + const stdoutStarted = yield* Deferred.make(); + const stderrWritten = yield* Deferred.make(); + const child = ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + stdout: Stream.make(new Uint8Array([0xe2])).pipe( + Stream.concat(Stream.fromEffectDrain(Deferred.succeed(stdoutStarted, undefined))), + Stream.concat(Stream.fromEffectDrain(Deferred.await(stderrWritten))), + Stream.concat(Stream.make(new Uint8Array([0x82, 0xac]))), + ), + stderr: Stream.fromEffectDrain(Deferred.await(stdoutStarted)).pipe( + Stream.concat(Stream.make(new Uint8Array([0x58]))), + Stream.concat(Stream.fromEffectDrain(Deferred.succeed(stderrWritten, undefined))), + ), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); + const terminals = yield* makeAcpClientTerminals({ defaultCwd: process.cwd() }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.succeed(child)), + ), + ); + yield* Effect.addFinalizer(() => terminals.disposeAll); + const terminal = yield* terminals.create({ sessionId: "session", command: "fixture" }); + const request = { sessionId: "session", ...terminal }; + yield* terminals.waitForExit(request); + + const output = yield* terminals.output(request); + expect(output.output).toContain("โ‚ฌ"); + expect(output.output).toContain("X"); + expect(output.output).not.toContain("\uFFFD"); + expect(output.truncated).toBe(false); + expect(terminals.readOutputSnapshot("session", terminal.terminalId)?.output).toBe( + output.output, + ); + }).pipe(Effect.scoped), + ); + it.live("resolves terminal environment from the requesting ACP session", () => Effect.gen(function* () { const terminals = yield* makeAcpClientTerminals({ @@ -96,14 +142,17 @@ describe("AcpClientTerminals", () => { }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); - it.live("truncates buffered output from the beginning at the byte limit", () => + it.live.each([ + { text: "a".repeat(64) + "TAIL", limit: 16 }, + { text: "โ‚ฌ".repeat(16) + "TAIL", limit: 6 }, + ])("truncates buffered output from the beginning at the $limit byte limit", ({ text, limit }) => withTerminals((terminals) => Effect.gen(function* () { const created = yield* terminals.create({ sessionId: "session", command: process.execPath, - args: ["-e", "process.stdout.write('a'.repeat(64) + 'TAIL');"], - outputByteLimit: 16, + args: ["-e", `process.stdout.write(${JSON.stringify(text)});`], + outputByteLimit: limit, }); yield* terminals.waitForExit({ sessionId: "session", terminalId: created.terminalId }); @@ -113,7 +162,8 @@ describe("AcpClientTerminals", () => { }); expect(output.truncated).toBe(true); expect(output.output.endsWith("TAIL")).toBe(true); - expect(output.output.length).toBeLessThanOrEqual(16); + expect(Buffer.byteLength(output.output)).toBeLessThanOrEqual(limit); + expect(output.output).not.toContain("\uFFFD"); }), ), ); diff --git a/packages/provider-acp/src/server/clientTerminals.ts b/packages/provider-acp/src/server/clientTerminals.ts index 2c3278d3c144d..a62698f496351 100644 --- a/packages/provider-acp/src/server/clientTerminals.ts +++ b/packages/provider-acp/src/server/clientTerminals.ts @@ -1,3 +1,4 @@ +import * as Channel from "effect/Channel"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -277,6 +278,9 @@ export const makeAcpClientTerminals = Effect.fn("makeAcpClientTerminals")(functi const pump = (stream: typeof child.stdout) => stream.pipe( + // Keep each pipe's UTF-8 decoder independent and preserve literal BOMs. + Stream.pipeThroughChannel(Channel.decodeText("utf-8", { ignoreBOM: true })), + Stream.encodeText, Stream.runForEach((chunk) => Effect.sync(() => { appendOutput(record.buffer, chunk); diff --git a/packages/provider-acp/src/server/elicitationForm.test.ts b/packages/provider-acp/src/server/elicitationForm.test.ts new file mode 100644 index 0000000000000..bbc82f036c8b8 --- /dev/null +++ b/packages/provider-acp/src/server/elicitationForm.test.ts @@ -0,0 +1,47 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { parseElicitationForm } from "./elicitationForm.ts"; + +describe("parseElicitationForm", () => { + for (const type of ["string", "array"] as const) { + it.each([ + { values: [" padded ", "", "plain"], labels: ["padded", '""', "plain"] }, + { values: [" a", "a", "b"], labels: ['" a"', '"a"', "b"] }, + { values: [" a", "a", '"a"'], labels: ['" a"', '"a"', '"\\"a\\""'] }, + ])( + `preserves ${type} enum values with distinct readable labels: $values`, + ({ values, labels }) => { + const form = parseElicitationForm( + { + type: "object", + properties: { + answer: + type === "array" + ? { type, items: { type: "string", enum: values } } + : { type, enum: values }, + }, + required: ["answer"], + }, + "Choose", + ); + assert(form !== undefined); + const question = form.questions[0]; + assert(question !== undefined); + assert.deepStrictEqual( + question.options.map((option) => option.label), + labels, + ); + assert.deepStrictEqual( + question.options.map((option) => option.value), + values, + ); + assert.strictEqual(question.allowCustomAnswer, false); + assert.strictEqual(question.multiSelect, type === "array" ? true : undefined); + for (const value of values) { + const answer = type === "array" ? [value] : value; + assert.deepStrictEqual(form.content({ answer }), { answer }); + } + }, + ); + } +}); diff --git a/packages/provider-acp/src/server/elicitationForm.ts b/packages/provider-acp/src/server/elicitationForm.ts new file mode 100644 index 0000000000000..df7c29cab2950 --- /dev/null +++ b/packages/provider-acp/src/server/elicitationForm.ts @@ -0,0 +1,223 @@ +import type { + OrchestrationV2UserInputQuestion, + ProviderUserInputAnswers, +} from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/compat"; + +import { unknownRecord } from "./clientPolicy.ts"; + +type Field = + | { readonly kind: "text" } + | { readonly kind: "number"; readonly integer: boolean } + | { readonly kind: "boolean" } + | { readonly kind: "choice"; readonly values: ReadonlySet } + | { + readonly kind: "multiChoice"; + readonly values: ReadonlySet; + readonly minItems: number; + readonly maxItems: number; + }; + +type Option = OrchestrationV2UserInputQuestion["options"][number]; + +export interface ElicitationForm { + readonly questions: ReadonlyArray; + /** + * Types answers as the schema declares them. Unknown keys and options are + * dropped, as is an answer that does not fit its field. Returns undefined + * when a required field is left without a valid answer. + */ + readonly content: ( + answers: ProviderUserInputAnswers, + ) => Record | undefined; +} + +function text(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +/** All of `entries` as options, or undefined when any entry cannot be one. */ +function choiceOptions( + entries: unknown, + toOption: (entry: unknown) => Option | undefined, +): Option[] | undefined { + if (!Array.isArray(entries) || entries.length === 0) return undefined; + const options = entries.map(toOption); + return options.every((option) => option !== undefined) ? options : undefined; +} + +function enumOptions(entries: unknown): Option[] | undefined { + if ( + !Array.isArray(entries) || + entries.length === 0 || + !entries.every((value): value is string => typeof value === "string") + ) + return undefined; + const counts = new Map(); + for (const value of entries) counts.set(value.trim(), (counts.get(value.trim()) ?? 0) + 1); + const options = entries.map((value) => { + const plain = value.trim(); + const label = plain !== "" && counts.get(plain) === 1 ? plain : JSON.stringify(value); + return { label, description: label, value }; + }); + // A quoted fallback can collide with a literal label; quote every value in that case. + return new Set(options.map((option) => option.label)).size === options.length + ? options + : options.map((option) => { + const label = JSON.stringify(option.value); + return { ...option, label, description: label }; + }); +} + +/** Titled entries (`oneOf` on strings, `anyOf` on array items) carry a title for their `const`. */ +function titledOption(entry: unknown): Option | undefined { + const option = unknownRecord(entry); + const label = text(option?.title) ?? text(option?.const); + return typeof option?.const === "string" && label !== undefined + ? { label, description: text(option.description) ?? label, value: option.const } + : undefined; +} + +function parseField( + property: Readonly>, +): { readonly field: Field; readonly options: Option[] } | undefined { + switch (property.type) { + case "string": { + // ACP treats a null `enum` or `oneOf` like an omitted one. + if ((property.enum ?? property.oneOf ?? undefined) === undefined) { + return { field: { kind: "text" }, options: [] }; + } + const options = Array.isArray(property.oneOf) + ? choiceOptions(property.oneOf, titledOption) + : enumOptions(property.enum); + return options === undefined + ? undefined + : { + field: { kind: "choice", values: new Set(options.map(optionValue)) }, + options, + }; + } + case "array": { + // String items list plain values in `enum`; titled items list `{ const, title }` in `anyOf`. + const items = unknownRecord(property.items); + const options = + items?.type === "string" + ? enumOptions(items.enum) + : items !== undefined && items.type === undefined + ? choiceOptions(items.anyOf, titledOption) + : undefined; + if (options === undefined) return undefined; + // The schema decode already limits counts to non-negative integers. + const values = new Set(options.map(optionValue)); + const minItems = typeof property.minItems === "number" ? property.minItems : 0; + const maxItems = typeof property.maxItems === "number" ? property.maxItems : Infinity; + // Clients require a selection, so empty-only questions cannot be answered. + if (maxItems === 0 || minItems > maxItems || minItems > values.size) return undefined; + return { field: { kind: "multiChoice", values, minItems, maxItems }, options }; + } + case "boolean": + return { + field: { kind: "boolean" }, + options: [ + { label: "true", description: "Yes", value: "true" }, + { label: "false", description: "No", value: "false" }, + ], + }; + case "number": + case "integer": + return { field: { kind: "number", integer: property.type === "integer" }, options: [] }; + default: + return undefined; + } +} + +function optionValue(option: Option): string { + return option.value ?? option.label; +} + +function typedAnswer( + field: Field, + answer: unknown, +): EffectAcpSchema.ElicitationContentValue | undefined { + switch (field.kind) { + case "text": + return typeof answer === "string" ? answer : undefined; + case "boolean": + if (typeof answer === "boolean") return answer; + return answer === "true" ? true : answer === "false" ? false : undefined; + case "number": { + const parsed = + typeof answer === "number" + ? answer + : typeof answer === "string" && answer.trim().length > 0 + ? Number(answer.trim()) + : Number.NaN; + if (!Number.isFinite(parsed)) return undefined; + return field.integer && !Number.isInteger(parsed) ? undefined : parsed; + } + case "choice": + return typeof answer === "string" && field.values.has(answer) ? answer : undefined; + case "multiChoice": { + if (!Array.isArray(answer)) return undefined; + const selected = [ + ...new Set( + answer.filter( + (entry): entry is string => typeof entry === "string" && field.values.has(entry), + ), + ), + ]; + // `[]` is an explicit empty selection, but a selection of only unknown + // options is not one. + if (selected.length === 0 && answer.length > 0) return undefined; + return selected.length >= field.minItems && selected.length <= field.maxItems + ? selected + : undefined; + } + } +} + +// Decline unaskable forms; validate types and selection counts, leaving other constraints to the agent. +export function parseElicitationForm( + requestedSchema: unknown, + message: string, +): ElicitationForm | undefined { + const schema = unknownRecord(requestedSchema); + const required = new Set( + Array.isArray(schema?.required) + ? schema.required.filter((key): key is string => typeof key === "string") + : [], + ); + const fields = new Map(); + const questions: OrchestrationV2UserInputQuestion[] = []; + for (const [id, value] of Object.entries(unknownRecord(schema?.properties) ?? {})) { + const property = unknownRecord(value); + const parsed = property === undefined ? undefined : parseField(property); + // Question ids must survive the contract's trim to round-trip answers. + if (property === undefined || parsed === undefined || text(id) !== id) return undefined; + fields.set(id, parsed.field); + questions.push({ + id, + header: text(property.title) ?? `Question ${questions.length + 1}`, + question: text(property.description) ?? message, + options: parsed.options, + ...(parsed.field.kind === "text" || parsed.field.kind === "number" + ? {} + : { allowCustomAnswer: false }), + ...(parsed.field.kind === "multiChoice" ? { multiSelect: true } : {}), + }); + } + return { + questions, + content: (answers) => { + const entries: Array<[string, EffectAcpSchema.ElicitationContentValue]> = []; + for (const [id, field] of fields) { + const typed = Object.hasOwn(answers, id) ? typedAnswer(field, answers[id]) : undefined; + if (typed !== undefined) entries.push([id, typed]); + else if (required.has(id)) return undefined; + } + // Own properties for every field, `__proto__` included; assigning to + // `{}` would hit its inherited setter instead. + return Object.fromEntries(entries); + }, + }; +} diff --git a/packages/provider-core/src/server/auth.ts b/packages/provider-core/src/server/auth.ts index 25f30b8fd0c43..314795b0b496f 100644 --- a/packages/provider-core/src/server/auth.ts +++ b/packages/provider-core/src/server/auth.ts @@ -25,6 +25,9 @@ export interface ProviderAuthController { readonly isChangingCredentials?: Effect.Effect; readonly invalidate?: Effect.Effect; readonly refreshMethods?: Effect.Effect; + readonly removeMethod?: ( + methodId: string, + ) => Effect.Effect; readonly withAccess?: ( task: Effect.Effect, ) => Effect.Effect; diff --git a/packages/provider-core/src/server/providerAuthFlow.test.ts b/packages/provider-core/src/server/providerAuthFlow.test.ts index cf4a4b699d7f0..d22dec7dcc521 100644 --- a/packages/provider-core/src/server/providerAuthFlow.test.ts +++ b/packages/provider-core/src/server/providerAuthFlow.test.ts @@ -439,6 +439,7 @@ it.effect("closing the controller scope interrupts and drains its adapter respon it.effect("a failed method refresh keeps the last discovered methods", () => Effect.gen(function* () { let fail = false; + let removed = false; const controller = yield* ProviderAuthFlow.make({ instanceId, credentialBinding: { owner: "provider", key: "shared-agent" }, @@ -447,10 +448,14 @@ it.effect("a failed method refresh keeps the last discovered methods", () => ? Effect.fail( new ProviderSetupError({ instanceId, operation: "status", detail: "interrupted" }), ) - : Effect.succeed([method]), + : Effect.succeed(removed ? [] : [method]), ), authenticate: () => Effect.void, logout: Effect.void, + removeMethod: () => + Effect.sync(() => { + removed = true; + }), }); yield* controller.refreshMethods!; fail = true; @@ -460,6 +465,15 @@ it.effect("a failed method refresh keeps the last discovered methods", () => .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); assert.deepEqual(state.methods, [method]); assert.strictEqual(state.message, "interrupted"); + const error = yield* Effect.flip(controller.removeMethod!(method.id)); + assert.isTrue(removed); + assert.strictEqual(error.detail, "interrupted"); + fail = false; + yield* controller.refreshMethods!; + const refreshed = yield* controller + .subscribe("owner") + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.deepEqual(refreshed.methods, []); }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); diff --git a/packages/provider-core/src/server/providerAuthFlow.ts b/packages/provider-core/src/server/providerAuthFlow.ts index 863748b753c26..cc135de2c2af5 100644 --- a/packages/provider-core/src/server/providerAuthFlow.ts +++ b/packages/provider-core/src/server/providerAuthFlow.ts @@ -69,6 +69,7 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { context: ProviderAuthFlowContext, ) => Effect.Effect; readonly logout: Effect.Effect; + readonly removeMethod?: (methodId: string) => Effect.Effect; readonly timeoutMs?: number; }) { const scope = yield* Scope.Scope; @@ -177,6 +178,27 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { return state; }, Effect.uninterruptible), refreshMethods, + ...(options.removeMethod + ? { + removeMethod: Effect.fn("providerAuthFlow.removeMethod")(function* (methodId: string) { + return yield* lock.withPermit( + Effect.gen(function* () { + if (operation !== "idle") + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "removeMethod", + detail: "Finish or cancel the existing sign-in first.", + }); + yield* options.removeMethod!(methodId); + const methods = yield* options.methods; + const state = { ...empty, methods }; + yield* SubscriptionRef.set(snapshot, { owner: null, state }); + return state; + }), + ); + }, Effect.uninterruptible), + } + : {}), invalidate: lock.withPermit( Effect.gen(function* () { if (operation !== "idle") return; diff --git a/packages/provider-core/src/server/usage.ts b/packages/provider-core/src/server/usage.ts index 509d890808987..3915ce7b2d9fa 100644 --- a/packages/provider-core/src/server/usage.ts +++ b/packages/provider-core/src/server/usage.ts @@ -89,7 +89,6 @@ export interface ProviderUsageScan { readonly hostId?: string; readonly status?: UsageSource["status"]; readonly message?: string; - readonly action?: UsageSource["action"]; readonly files: | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[] | null; diff --git a/packages/provider-cursor/package.json b/packages/provider-cursor/package.json index b8e38d3b778cd..f073e7c921e35 100644 --- a/packages/provider-cursor/package.json +++ b/packages/provider-cursor/package.json @@ -31,10 +31,6 @@ "types": "./src/server/CursorAccountReader.ts", "import": "./src/server/CursorAccountReader.ts" }, - "./server/CursorKeychain": { - "types": "./src/server/CursorKeychain.ts", - "import": "./src/server/CursorKeychain.ts" - }, "./server/CursorUsageAccounts": { "types": "./src/server/CursorUsageAccounts.ts", "import": "./src/server/CursorUsageAccounts.ts" @@ -50,7 +46,6 @@ }, "dependencies": { "@cursor/sdk": "1.2.0", - "@napi-rs/keyring": "^1.3.0", "@t3tools/contracts": "workspace:*", "@t3tools/provider-core": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/packages/provider-cursor/src/server/CursorAccountReader.ts b/packages/provider-cursor/src/server/CursorAccountReader.ts index 183819b05c8c8..f6e9b384a40d8 100644 --- a/packages/provider-cursor/src/server/CursorAccountReader.ts +++ b/packages/provider-cursor/src/server/CursorAccountReader.ts @@ -1,13 +1,11 @@ import * as Context from "effect/Context"; import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as HttpClient from "effect/http/HttpClient"; import * as Layer from "effect/Layer"; import type { CursorCredentialSource } from "./accountCache.ts"; import { readCursorAccountUsage, type CursorAccountUsageReadResult } from "./accountUsage.ts"; -import * as CursorKeychain from "./CursorKeychain.ts"; export class CursorAccountReader extends Context.Service< CursorAccountReader, @@ -23,18 +21,12 @@ export class CursorAccountReader extends Context.Service< export const layer = Layer.effect( CursorAccountReader, Effect.gen(function* () { - const keychain = yield* CursorKeychain.CursorKeychain; - const context = yield* Effect.context< - FileSystem.FileSystem | Digest.Digest | HttpClient.HttpClient - >(); + const context = yield* Effect.context(); return CursorAccountReader.of({ read: Effect.fn("CursorAccountReader.read")(function (credentialSource, sinceMs, untilMs) { - return readCursorAccountUsage( - credentialSource, - sinceMs, - untilMs, - keychain.accessToken, - ).pipe(Effect.provideContext(context)); + return readCursorAccountUsage(credentialSource, sinceMs, untilMs).pipe( + Effect.provideContext(context), + ); }), }); }), diff --git a/packages/provider-cursor/src/server/CursorKeychain.ts b/packages/provider-cursor/src/server/CursorKeychain.ts deleted file mode 100644 index 20947971814ac..0000000000000 --- a/packages/provider-cursor/src/server/CursorKeychain.ts +++ /dev/null @@ -1,102 +0,0 @@ -// Remote clients cannot answer macOS prompts; time out reads but retain the in-flight request for reuse. -import * as NodeModule from "node:module"; - -import * as Clock from "effect/Clock"; -import * as Context from "effect/Context"; -import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; - -const CACHE_MS = 5 * 60_000; -const DEFAULT_TIMEOUT = Duration.seconds(30); - -const requireForKeyring = NodeModule.createRequire(import.meta.url); - -export class CursorKeychainTimeoutError extends Schema.TaggedError()( - "CursorKeychainTimeoutError", - {}, -) { - override get message(): string { - return "Timed out waiting for Keychain access."; - } -} - -export const isCursorKeychainTimeoutError = Schema.is(CursorKeychainTimeoutError); - -export class CursorKeychainReadError extends Schema.TaggedError()( - "CursorKeychainReadError", - { cause: Schema.Defect() }, -) { - override get message(): string { - return "Cursor Keychain credentials could not be read."; - } -} - -export class CursorKeychain extends Context.Service< - CursorKeychain, - { - readonly accessToken: Effect.Effect< - string | null, - CursorKeychainTimeoutError | CursorKeychainReadError - >; - } ->()("@t3tools/provider-cursor/server/CursorKeychain") {} - -const readKeychainEntry = Effect.tryPromise({ - try: async () => { - const { AsyncEntry } = requireForKeyring( - "@napi-rs/keyring", - ) as typeof import("@napi-rs/keyring"); - return (await new AsyncEntry("cursor-access-token", "cursor-user").getPassword()) ?? null; - }, - catch: (cause) => new CursorKeychainReadError({ cause }), -}); - -const make = ( - read: Effect.Effect = readKeychainEntry, - timeout: Duration.Input = DEFAULT_TIMEOUT, -): CursorKeychain["Service"] => { - let cached: { readonly token: string; readonly untilMs: number } | null = null; - let pending: Deferred.Deferred | null = null; - - const startRead = Effect.uninterruptible( - Effect.gen(function* () { - if (pending !== null) return pending; - const done = Deferred.makeUnsafe(); - pending = done; - yield* read.pipe( - Effect.tap((token) => - Clock.currentTimeMillis.pipe( - Effect.map((nowMs) => { - cached = token ? { token, untilMs: nowMs + CACHE_MS } : null; - }), - ), - ), - Effect.onExit((exit) => - Effect.suspend(() => { - pending = null; - return Deferred.done(done, exit); - }), - ), - Effect.forkDetach, - ); - return done; - }), - ); - - const accessToken = Effect.gen(function* () { - const nowMs = yield* Clock.currentTimeMillis; - if (cached !== null && cached.untilMs > nowMs) return cached.token; - const done = yield* startRead; - const token = yield* Deferred.await(done).pipe(Effect.timeoutOption(timeout)); - if (Option.isNone(token)) return yield* new CursorKeychainTimeoutError(); - return token.value; - }); - - return CursorKeychain.of({ accessToken }); -}; - -export const layer = Layer.sync(CursorKeychain, () => make()); diff --git a/packages/provider-cursor/src/server/CursorUsageAccounts.ts b/packages/provider-cursor/src/server/CursorUsageAccounts.ts index 0bb9550ba9d71..21ab0e62e8948 100644 --- a/packages/provider-cursor/src/server/CursorUsageAccounts.ts +++ b/packages/provider-cursor/src/server/CursorUsageAccounts.ts @@ -2,7 +2,6 @@ import type { ProviderInstanceId } from "@t3tools/contracts"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import type { ProviderUsageScan } from "@t3tools/provider-core/server/usage"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Digest from "effect/crypto/Digest"; @@ -44,7 +43,6 @@ export class CursorUsageAccounts extends Context.Service< readonly instanceId: ProviderInstanceId; readonly environment: NodeJS.ProcessEnv; }>; - readonly keychainUsageEnabled: boolean; readonly windowStartMs: number; readonly retentionCutoffMs: number; readonly awaitRefresh: boolean; @@ -58,8 +56,6 @@ const make = Effect.gen(function* () { const path = yield* Path.Path; const host = yield* ProviderHost.ProviderHost; const accountReader = yield* CursorAccountReader.CursorAccountReader; - const hostEnvironment = yield* HostProcess.Environment; - const platform = yield* HostProcess.Platform; const digest = yield* Digest.Digest; const cachePath = path.join(host.paths.stateDir, CURSOR_ACCOUNT_CACHE_FILE_NAME); @@ -190,34 +186,12 @@ const make = Effect.gen(function* () { const accountSource = Effect.fn("CursorUsageAccounts.accountSource")(function* ( credential: CursorCredentialSource, - authPath: string, windowStartMs: number, retentionStartMs: number, awaitRefresh: boolean, ) { - // A Keychain login outlives an uninstalled Cursor, so it also needs Cursor's local directory. - const localLogin = - typeof credential === "string" - ? credential - : credential.kind === "keychain" - ? path.dirname(authPath) - : null; - if ( - localLogin !== null && - !(yield* fileSystem.exists(localLogin).pipe(Effect.orElseSucceed(() => true))) - ) { - return null; - } yield* ensureLoaded; - const credentialKey = - typeof credential === "string" - ? credential - : credential.kind === "apiKey" - ? // A fingerprint, so a replaced key never reads the previous account's cache. - `api-key:${Hex.encode( - yield* digest.digest(Digest.sha256, new TextEncoder().encode(credential.apiKey)), - )}` - : "keychain"; + const credentialKey = `api-key:${Hex.encode(yield* digest.digest(Digest.sha256, new TextEncoder().encode(credential.apiKey)))}`; const nowMs = yield* Clock.currentTimeMillis; const recentFailure = failures.get(credentialKey); let refreshing = false; @@ -236,8 +210,7 @@ const make = Effect.gen(function* () { if (failureMessage === null) return null; if (cache === undefined) { return { - dir: authPath, - // Never combine a local fallback with another server's account-wide history. + dir: "cursor-account", ...(refreshing ? { files: [], refreshing: true } : { files: null, message: failureMessage ?? CURSOR_ACCOUNT_READ_ERROR }), @@ -263,30 +236,7 @@ const make = Effect.gen(function* () { }); const scan: CursorUsageAccounts["Service"]["scan"] = Effect.fn("CursorUsageAccounts.scan")( - function* ({ - instances, - keychainUsageEnabled, - windowStartMs, - retentionCutoffMs, - awaitRefresh, - }) { - const home = yield* HostProcess.HomeDirectory; - const userHome = - (platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home; - const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim(); - const cursorHome = - platform === "darwin" - ? path.join(userHome, "Library", "Application Support") - : platform === "win32" - ? hostEnvironment["APPDATA"] || path.join(userHome, "AppData", "Roaming") - : configHome && path.isAbsolute(configHome) - ? configHome - : path.join(userHome, ".config"); - const authPath = - platform === "darwin" - ? path.join(userHome, ".cursor", "auth.json") - : path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json"); - // T3's Cursor sign-in and CURSOR_API_KEY name the account the agent uses, so they win. + function* ({ instances, windowStartMs, retentionCutoffMs, awaitRefresh }) { const apiKeys = yield* Effect.forEach(instances, ({ instanceId, environment }) => environment.CURSOR_API_KEY?.trim() ? Effect.succeed(environment.CURSOR_API_KEY.trim()) @@ -302,7 +252,6 @@ const make = Effect.gen(function* () { (apiKey) => accountSource( { kind: "apiKey", apiKey }, - authPath, windowStartMs, retentionCutoffMs, awaitRefresh, @@ -323,38 +272,7 @@ const make = Effect.gen(function* () { } return [...byAccount.values()]; } - const credentialStore = hostEnvironment["AGENT_CLI_CREDENTIAL_STORE"]; - const loginUnavailable = - Boolean(hostEnvironment["CURSOR_AUTH_TOKEN"]?.trim()) || credentialStore === "memory"; - const useKeychain = platform === "darwin" && credentialStore !== "file"; - if (useKeychain && !loginUnavailable && !keychainUsageEnabled) { - return [ - { - dir: authPath, - volumeId: "", - files: null, - message: "Cursor account usage is off on this environment.", - action: "enableCursorKeychain", - }, - ]; - } - if (loginUnavailable) { - return [ - { - dir: authPath, - files: null, - message: "Cursor account history needs a Cursor CLI login on this server.", - }, - ]; - } - const source = yield* accountSource( - useKeychain ? { kind: "keychain" } : authPath, - authPath, - windowStartMs, - retentionCutoffMs, - awaitRefresh, - ); - return source === null ? [] : [source]; + return []; }, ); diff --git a/packages/provider-cursor/src/server/accountCache.ts b/packages/provider-cursor/src/server/accountCache.ts index b2ff2fbd6b3a2..ffbfcc6400533 100644 --- a/packages/provider-cursor/src/server/accountCache.ts +++ b/packages/provider-cursor/src/server/accountCache.ts @@ -2,10 +2,7 @@ import { cursorRateModel } from "./accountUsage.ts"; import type { UsageRecord } from "@t3tools/provider-core/server/usage"; -export type CursorCredentialSource = - | string - | { readonly kind: "keychain" } - | { readonly kind: "apiKey"; readonly apiKey: string }; +export type CursorCredentialSource = { readonly kind: "apiKey"; readonly apiKey: string }; export const CURSOR_ACCOUNT_TTL_MS = 60 * 1000; diff --git a/packages/provider-cursor/src/server/accountUsage.test.ts b/packages/provider-cursor/src/server/accountUsage.test.ts index a2ccc42cc7502..d907c46f9ac3f 100644 --- a/packages/provider-cursor/src/server/accountUsage.test.ts +++ b/packages/provider-cursor/src/server/accountUsage.test.ts @@ -5,27 +5,14 @@ import { assert, describe, it } from "@effect/vitest"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; import * as FetchHttpClient from "effect/http/FetchHttpClient"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import * as Path from "effect/Path"; import * as TestClock from "effect/testing/TestClock"; -import * as CursorKeychain from "./CursorKeychain.ts"; import { readCursorAccountUsage } from "./accountUsage.ts"; -const writeAuth = (accessToken: string) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "usage-reader-test-" }); - const authPath = path.join(dir, "auth.json"); - yield* fileSystem.writeFileString(authPath, JSON.stringify({ accessToken })); - return { dir, authPath }; - }); - interface DashboardRequest { readonly url: string; readonly init: RequestInit; @@ -40,6 +27,15 @@ const dashboard = (answer: (request: DashboardRequest) => Response | Promise Response | Promise, - keychainToken?: Parameters[3], ) => - readCursorAccountUsage(credentialSource, sinceMs, endDate, keychainToken).pipe( + readCursorAccountUsage(credentialSource, sinceMs, endDate).pipe( Effect.provideService(HttpClient.HttpClient, dashboard(answer)), ); @@ -72,15 +67,18 @@ describe("Cursor account history", () => { () => Effect.gen(function* () { const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; - const { authPath } = yield* writeAuth(accessToken); + const credential = { kind: "apiKey", apiKey: accessToken } as const; const pages: number[] = []; const signals: AbortSignal[] = []; const request = ({ url, init, headers, page }: DashboardRequest) => { - assert.strictEqual(url, "https://cursor.com/api/dashboard/get-filtered-usage-events"); + assert.strictEqual( + url, + "https://api2.cursor.sh/aiserver.v1.DashboardService/GetFilteredUsageEvents", + ); assert.strictEqual(init.redirect, "error"); - assert.strictEqual(headers.get("origin"), "https://cursor.com"); + assert.strictEqual(headers.get("connect-protocol-version"), "1"); assert.strictEqual(headers.get("content-type"), "application/json"); - assert.include(headers.get("cookie") ?? "", "WorkosCursorSessionToken=demo%3A%3A"); + assert.strictEqual(headers.get("authorization"), `Bearer ${accessToken}`); const body = { page }; pages.push(body.page); if (init.signal) signals.push(init.signal); @@ -102,7 +100,7 @@ describe("Cursor account history", () => { })), }); }; - const result = yield* readAccountUsage(authPath, 0, 1781000000000, request); + const result = yield* readAccountUsage(credential, 0, 1781000000000, request); assert.isNull(result.error); assert.deepStrictEqual(pages, [1, 2]); assert.lengthOf(signals, 2); @@ -124,10 +122,10 @@ describe("Cursor account history", () => { it.effect("reads Cursor account history beyond 100 pages", () => Effect.gen(function* () { const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - const { authPath } = yield* writeAuth(accessToken); + const credential = { kind: "apiKey", apiKey: accessToken } as const; const fullPage = Array.from({ length: 1000 }, () => ({ tokenUsage: null })); let requests = 0; - const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => { + const result = yield* readAccountUsage(credential, 0, 1781000000000, async () => { requests += 1; return Response.json({ totalUsageEventsCount: 100_001, @@ -143,13 +141,13 @@ describe("Cursor account history", () => { it.effect("accepts confirmed empty Cursor usage but rejects error envelopes", () => Effect.gen(function* () { const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - const { authPath } = yield* writeAuth(accessToken); + const credential = { kind: "apiKey", apiKey: accessToken } as const; for (const body of [ {}, { totalUsageEventsCount: 0 }, { totalUsageEventsCount: 0, usageEventsDisplay: [] }, ]) { - const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => + const result = yield* readAccountUsage(credential, 0, 1781000000000, async () => Response.json(body), ); assert.isNull(result.error); @@ -165,7 +163,7 @@ describe("Cursor account history", () => { "invalid", 0, ]) { - const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => + const result = yield* readAccountUsage(credential, 0, 1781000000000, async () => Response.json(body), ); assert.isNotNull(result.error); @@ -177,9 +175,9 @@ describe("Cursor account history", () => { it.effect("requires a terminal Cursor page after a full page reaches the reported count", () => Effect.gen(function* () { const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - const { authPath } = yield* writeAuth(accessToken); + const credential = { kind: "apiKey", apiKey: accessToken } as const; let requests = 0; - const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => { + const result = yield* readAccountUsage(credential, 0, 1781000000000, async () => { requests++; return Response.json( requests === 1 @@ -205,7 +203,7 @@ describe("Cursor account history", () => { () => Effect.gen(function* () { const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - const { authPath } = yield* writeAuth(accessToken); + const credential = { kind: "apiKey", apiKey: accessToken } as const; const event = (index: number) => ({ timestamp: String(1780000000000 + index), model: "gpt-5", @@ -213,7 +211,7 @@ describe("Cursor account history", () => { }); for (const total of [2000, 2001]) { let requests = 0; - const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => { + const result = yield* readAccountUsage(credential, 0, 1781000000000, async () => { requests++; return Response.json({ totalUsageEventsCount: total, @@ -236,7 +234,7 @@ describe("Cursor account history", () => { assert.strictEqual(new Set(result.records.map((record) => record.dedupeKey)).size, total); } let requests = 0; - const inconsistent = yield* readAccountUsage(authPath, 0, 1781000000000, async () => { + const inconsistent = yield* readAccountUsage(credential, 0, 1781000000000, async () => { requests++; return Response.json({ totalUsageEventsCount: 1001, @@ -256,14 +254,14 @@ describe("Cursor account history", () => { () => Effect.gen(function* () { const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; - const { dir, authPath } = yield* writeAuth(accessToken); - const truncated = yield* readAccountUsage(authPath, 0, 1781000000000, async () => + const credential = { kind: "apiKey", apiKey: accessToken } as const; + const truncated = yield* readAccountUsage(credential, 0, 1781000000000, async () => Response.json({ totalUsageEventsCount: 101, usageEventsDisplay: [] }), ); assert.isNotNull(truncated.error); assert.deepStrictEqual(truncated.records, []); const denied = yield* readAccountUsage( - authPath, + credential, 0, 1781000000000, async () => new Response(accessToken, { status: 401 }), @@ -271,18 +269,6 @@ describe("Cursor account history", () => { assert.isNotNull(denied.error); assert.isFalse(denied.error?.includes(accessToken) ?? true); assert.deepStrictEqual(denied.records, []); - let requested = false; - const missing = yield* readAccountUsage( - (yield* Path.Path).join(dir, "missing.json"), - 0, - 1781000000000, - async () => { - requested = true; - return Response.json({}); - }, - ); - assert.isTrue(missing.missing); - assert.isFalse(requested); }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); }); @@ -336,33 +322,13 @@ describe("readCursorAccountUsage", () => { }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); - it.effect("asks for Keychain approval when the prompt goes unanswered", () => - Effect.gen(function* () { - const result = yield* readAccountUsage( - { kind: "keychain" }, - 0, - 1, - () => { - throw new Error("no network expected"); - }, - Effect.fail(new CursorKeychain.CursorKeychainTimeoutError()), - ); - assert.deepStrictEqual(result, { - accountKey: null, - records: [], - missing: false, - error: "Allow Keychain access on the Mac running T3 Code, then refresh.", - }); - }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), - ); - it.effect("reads the pages behind the first together and keeps them in page order", () => Effect.gen(function* () { const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; let inFlight = 0; let mostInFlight = 0; const result = yield* readAccountUsage( - { kind: "keychain" }, + { kind: "apiKey", apiKey: accessToken }, 0, 1781000000000, async ({ page }) => { @@ -379,7 +345,6 @@ describe("readCursorAccountUsage", () => { })), }); }, - Effect.succeed(accessToken), ); assert.isNull(result.error); assert.strictEqual(mostInFlight, 6); @@ -406,7 +371,7 @@ describe("readCursorAccountUsage", () => { ] as const) { let open = 0; const result = yield* readAccountUsage( - { kind: "keychain" }, + { kind: "apiKey", apiKey: accessToken }, 0, 1781000000000, ({ init, page }) => { @@ -421,7 +386,6 @@ describe("readCursorAccountUsage", () => { }); }); }, - Effect.succeed(accessToken), ); assert.strictEqual(result.error, error); assert.deepStrictEqual(result.records, []); diff --git a/packages/provider-cursor/src/server/accountUsage.ts b/packages/provider-cursor/src/server/accountUsage.ts index c26d691ad16ee..6d28410f7c831 100644 --- a/packages/provider-cursor/src/server/accountUsage.ts +++ b/packages/provider-cursor/src/server/accountUsage.ts @@ -5,17 +5,14 @@ import * as Effect from "effect/Effect"; import * as Base64Url from "effect/encoding/Base64Url"; import * as Hex from "effect/encoding/Hex"; import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; import * as FetchHttpClient from "effect/http/FetchHttpClient"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; -import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import type { CursorCredentialSource } from "./accountCache.ts"; import { DEFAULT_CURSOR_API_ENDPOINT, exchangeCursorApiKey } from "./apiKey.ts"; -import * as CursorKeychain from "./CursorKeychain.ts"; function object(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) @@ -87,84 +84,25 @@ function boundaryOverlap(previous: readonly string[], current: readonly string[] return lengths.at(-1) ?? 0; } -const DASHBOARD_USAGE_URL = "https://cursor.com/api/dashboard/get-filtered-usage-events"; - -/** The session cookie must never follow a redirect off cursor.com. */ +/** Never forward the access token through a redirect. */ const NO_REDIRECT: RequestInit = { redirect: "error" }; export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(function* ( credentialSource: CursorCredentialSource, sinceMs: number, endDate: number, - keychainToken: Effect.Effect< - string | null, - | CursorKeychain.CursorKeychainTimeoutError - | CursorKeychain.CursorKeychainReadError - | CursorAccountUsageInvalidError - > = invalid("No Keychain reader"), -): Effect.fn.Return< - CursorAccountUsageReadResult, - never, - FileSystem.FileSystem | Digest.Digest | HttpClient.HttpClient -> { - const fileSystem = yield* FileSystem.FileSystem; +): Effect.fn.Return { const httpClient = yield* HttpClient.HttpClient; - const credential = yield* Effect.result< - unknown, - | PlatformError.PlatformError - | Schema.SchemaError - | CursorKeychain.CursorKeychainTimeoutError - | CursorKeychain.CursorKeychainReadError - | CursorAccountUsageInvalidError, - never - >( - typeof credentialSource === "string" - ? fileSystem.readFileString(credentialSource).pipe( - Effect.flatMap(decodeJson), - Effect.map((credentials) => object(credentials).accessToken), - ) - : credentialSource.kind === "apiKey" - ? exchangeCursorApiKey(credentialSource.apiKey).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.mapError( - () => new CursorAccountUsageInvalidError({ reason: "Invalid API key" }), - ), - ) - : keychainToken, - ); + const credential = yield* Effect.result(exchangeCursorApiKey(credentialSource.apiKey)); if (Result.isFailure(credential)) { - const cause = credential.failure; - const missing = - typeof credentialSource === "string" && - PlatformError.isPlatformError(cause) && - cause.reason._tag === "NotFound"; return { accountKey: null, records: [], - missing, - error: missing - ? null - : typeof credentialSource === "string" - ? "Cursor credentials could not be read." - : credentialSource.kind === "apiKey" - ? "Cursor account usage could not be read." - : CursorKeychain.isCursorKeychainTimeoutError(cause) - ? "Allow Keychain access on the Mac running T3 Code, then refresh." - : "Cursor Keychain credentials could not be read.", + missing: false, + error: "Cursor account usage could not be read.", }; } const accessToken = credential.success; - if (typeof accessToken !== "string" || !accessToken) { - return { - accountKey: null, - records: [], - missing: true, - error: - typeof credentialSource === "string" - ? null - : "Cursor account history needs a macOS Keychain CLI login on this server.", - }; - } let accountKey: string | null = null; const read = Effect.gen(function* () { const payload = Base64Url.decodeString(accessToken.split(".")[1] ?? ""); @@ -175,26 +113,15 @@ export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(functi ), ).sub; if (typeof subject !== "string" || !subject) return yield* invalid("Invalid authentication"); - const userId = subject.split("|").at(-1); - if (!userId) return yield* invalid("Invalid authentication"); accountKey = yield* accountHash(subject); if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate) return yield* invalid("Invalid date window"); - // The web dashboard rejects API-key tokens; Cursor's RPC API accepts them. - const pageRequest = - typeof credentialSource !== "string" && credentialSource.kind === "apiKey" - ? HttpClientRequest.post( - `${DEFAULT_CURSOR_API_ENDPOINT}/aiserver.v1.DashboardService/GetFilteredUsageEvents`, - ).pipe( - HttpClientRequest.bearerToken(accessToken), - HttpClientRequest.setHeader("connect-protocol-version", "1"), - ) - : HttpClientRequest.post(DASHBOARD_USAGE_URL).pipe( - HttpClientRequest.setHeaders({ - Origin: "https://cursor.com", - Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${accessToken}`)}`, - }), - ); + const pageRequest = HttpClientRequest.post( + `${DEFAULT_CURSOR_API_ENDPOINT}/aiserver.v1.DashboardService/GetFilteredUsageEvents`, + ).pipe( + HttpClientRequest.bearerToken(accessToken), + HttpClientRequest.setHeader("connect-protocol-version", "1"), + ); const records: UsageRecord[] = []; const occurrences = new Map(); const pages: unknown[][] = []; diff --git a/packages/provider-cursor/src/server/driver.test.ts b/packages/provider-cursor/src/server/driver.test.ts index f0e99d20f7c82..5aada081a061d 100644 --- a/packages/provider-cursor/src/server/driver.test.ts +++ b/packages/provider-cursor/src/server/driver.test.ts @@ -34,7 +34,6 @@ import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import { CursorDriver } from "./driver.ts"; import * as CursorAgentSdk from "./CursorAgentSdk.ts"; import * as CursorSdk from "./CursorSdk.ts"; -import * as CursorKeychain from "./CursorKeychain.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; @@ -52,11 +51,6 @@ const layerTest = TestProviderHost.layer({ runBackgroundWork: false }).pipe( open: () => Effect.die("Maintenance resolution must not open a Cursor session"), }), ), - Layer.provideMerge( - Layer.succeed(CursorKeychain.CursorKeychain, { - accessToken: Effect.die("The driver test must not read the Keychain"), - }), - ), Layer.provideMerge( Layer.succeed( ProviderEventLoggers.ProviderEventLoggers, diff --git a/packages/provider-cursor/src/server/driver.ts b/packages/provider-cursor/src/server/driver.ts index 088c97c685c58..ef50abb0a93b4 100644 --- a/packages/provider-cursor/src/server/driver.ts +++ b/packages/provider-cursor/src/server/driver.ts @@ -37,7 +37,6 @@ import { makeCursorAuth } from "./auth.ts"; import * as CursorCredentialStore from "./credentialStore.ts"; import * as CursorAgentSdk from "./CursorAgentSdk.ts"; import * as CursorSdk from "./CursorSdk.ts"; -import * as CursorKeychain from "./CursorKeychain.ts"; import * as CursorUsageAccounts from "./CursorUsageAccounts.ts"; const decodeCursorSettings = Schema.decodeSync(CursorSettings); @@ -56,8 +55,7 @@ export type CursorDriverEnv = | Path.Path | HttpClient.HttpClient | CursorSdk.CursorSdk - | ProviderHost.ProviderHost - | CursorKeychain.CursorKeychain; + | ProviderHost.ProviderHost; export const CursorDriver: ProviderDriver< CursorSettings, @@ -74,12 +72,11 @@ export const CursorDriver: ProviderDriver< usage: { kind: "scan", provider: "cursor", - scan: ({ instances, settings, windowStartMs, retentionCutoffMs, awaitRefresh }) => + scan: ({ instances, windowStartMs, retentionCutoffMs, awaitRefresh }) => CursorUsageAccounts.CursorUsageAccounts.pipe( Effect.flatMap((accounts) => accounts.scan({ instances, - keychainUsageEnabled: settings.cursorKeychainUsageEnabled, windowStartMs, retentionCutoffMs, awaitRefresh, @@ -99,7 +96,6 @@ export const CursorDriver: ProviderDriver< const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; - const keychain = yield* CursorKeychain.CursorKeychain; const sdkRunner = yield* CursorAgentSdk.CursorAgentSdkRunner; const processEnv = yield* mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ @@ -233,22 +229,15 @@ export const CursorDriver: ProviderDriver< snapshot.auth.status === "authenticated" ), (snapshot) => - host.settings.get.pipe( - Effect.flatMap((settings) => - readCursorUsageLimits( - effectiveConfig, - { ...processEnv, CURSOR_API_KEY: apiKey }, - settings.cursorKeychainUsageEnabled, - ), - ), - Effect.map((usageLimits) => ({ ...snapshot, usageLimits })), - ), + readCursorUsageLimits(effectiveConfig, { + ...processEnv, + CURSOR_API_KEY: apiKey, + }).pipe(Effect.map((usageLimits) => ({ ...snapshot, usageLimits }))), ), ), ), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), - Effect.provideService(CursorKeychain.CursorKeychain, keychain), Effect.map(stampSnapshot), Effect.tap((provider) => Effect.sync(() => { diff --git a/packages/provider-cursor/src/server/usageLimits.test.ts b/packages/provider-cursor/src/server/usageLimits.test.ts index 2411156d9a98f..bdd96fbef41b4 100644 --- a/packages/provider-cursor/src/server/usageLimits.test.ts +++ b/packages/provider-cursor/src/server/usageLimits.test.ts @@ -1,25 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; -import { NodeServices } from "@effect/platform-node"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import * as CursorKeychain from "./CursorKeychain.ts"; import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./usageLimits.ts"; -const withNodeServices = ( - effect: Effect.Effect, - accessToken: CursorKeychain.CursorKeychain["Service"]["accessToken"] = Effect.die( - "must not read Keychain", - ), -) => - effect.pipe( - Effect.provideService(CursorKeychain.CursorKeychain, { accessToken }), - Effect.provide(NodeServices.layer), - ); - describe("Cursor usage limits", () => { const checkedAt = "2026-09-16T00:00:00.000Z"; @@ -70,245 +54,124 @@ describe("Cursor usage limits", () => { ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 100 }]); }); - it.effect("reads the instance's credentials and endpoint even when usage enabled is false", () => - withNodeServices( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fs.makeTempDirectoryScoped(); - yield* fs.makeDirectory(path.join(directory, "cursor")); - yield* fs.writeFileString( - path.join(directory, "cursor", "auth.json"), - '{"accessToken":"instance-token"}', - ); - const client = HttpClient.make((request) => { - expect(request.url).toBe( - "https://cursor.example/aiserver.v1.DashboardService/GetCurrentPeriodUsage", - ); - expect(request.method).toBe("POST"); - expect(request.headers.authorization).toBe("Bearer instance-token"); - expect(request.headers["connect-protocol-version"]).toBe("1"); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ enabled: false, planUsage: { totalPercentUsed: 42 } }), - ), - ); - }); - yield* fs.makeDirectory(path.join(directory, ".cursor")); - yield* fs.writeFileString( - path.join(directory, ".cursor", "auth.json"), - '{"accessToken":"instance-token"}', - ); - for (const platform of ["linux", "darwin"] as const) { - const limits = yield* readCursorUsageLimits( - { apiEndpoint: "https://cursor.example/" }, - { XDG_CONFIG_HOME: directory, HOME: directory, AGENT_CLI_CREDENTIAL_STORE: "file" }, - ).pipe( - Effect.provideService(HostProcess.Platform, platform), - Effect.provideService(HttpClient.HttpClient, client), - ); - expect(limits.windows[0]?.usedPercent).toBe(42); - } - }).pipe(Effect.scoped), - ), - ); - - it.effect( - "never reads stale files for keychain or memory logins, but accepts an explicit auth token", - () => - Effect.gen(function* () { - for (const platform of ["linux", "darwin"] as const) { - for (const token of [undefined, "explicit-token"]) { - const limits = yield* withNodeServices( - readCursorUsageLimits( - { apiEndpoint: "" }, - { - AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default", - ...(token ? { CURSOR_AUTH_TOKEN: token } : {}), - }, - false, - ).pipe( - Effect.provideService(HostProcess.Platform, platform), - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.die("must not read an unrelated credential file"), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - expect(token).toBe("explicit-token"); - expect(request.headers.authorization).toBe("Bearer explicit-token"); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ planUsage: { totalPercentUsed: 10 } }), - ), - ); - }), - ), - ), - ); - if (token) expect(limits.windows[0]?.usedPercent).toBe(10); - else expect(limits.unavailable?.reason).toBe("unsupported"); - } - } - }), - ); - - it.effect("reports failed requests without exposing credentials or response bodies", () => + it.effect("requires explicit credentials and accepts an auth token", () => Effect.gen(function* () { - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe( + for (const token of [undefined, "explicit-token"]) { + const limits = yield* readCursorUsageLimits( + { apiEndpoint: "" }, + token ? { CURSOR_AUTH_TOKEN: token } : {}, + ).pipe( Effect.provideService( HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed( + HttpClient.make((request) => { + expect(token).toBe("explicit-token"); + expect(request.headers.authorization).toBe("Bearer explicit-token"); + return Effect.succeed( HttpClientResponse.fromWeb( request, - new Response("private response", { status: 401 }), + Response.json({ planUsage: { totalPercentUsed: 10 } }), ), - ), - ), - ), - ), - ); - expect(limits.unavailable).toEqual({ - reason: "probeFailed", - message: "Cursor could not read usage limits.", - }); - }), - ); - - it.effect("reads an API key's own account instead of a stored login", () => - Effect.gen(function* () { - for (const platform of ["linux", "darwin"] as const) { - const requests: Array = []; - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_API_KEY: "api-key" }, true).pipe( - Effect.provideService(HostProcess.Platform, platform), - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.die("must not read a stored login"), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - requests.push([request.url, request.headers.authorization]); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json( - request.url.endsWith("/auth/exchange_user_api_key") - ? { accessToken: "api-key-access-token" } - : { planUsage: { totalPercentUsed: 37 } }, - ), - ), - ); - }), - ), + ); + }), ), ); - expect(requests).toEqual([ - ["https://api2.cursor.sh/auth/exchange_user_api_key", "Bearer api-key"], - [ - "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", - "Bearer api-key-access-token", - ], - ]); - expect(limits.windows[0]?.usedPercent).toBe(37); + if (token) expect(limits.windows[0]?.usedPercent).toBe(10); + else expect(limits.unavailable?.reason).toBe("unsupported"); } }), ); - it.effect("reports a rejected API key as a failed probe", () => + it.effect("reports failed requests without exposing credentials or response bodies", () => Effect.gen(function* () { - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_API_KEY: "revoked" }).pipe( - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb(request, new Response("denied", { status: 401 })), + const limits = yield* readCursorUsageLimits( + { apiEndpoint: "" }, + { CURSOR_AUTH_TOKEN: "private-token" }, + ).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("private response", { status: 401 }), ), ), ), ), ); - expect(limits.unavailable?.reason).toBe("probeFailed"); + expect(limits.unavailable).toEqual({ + reason: "probeFailed", + message: "Cursor could not read usage limits.", + }); }), ); - it.effect("reads the default macOS Cursor login from Keychain for limits", () => + it.effect("exchanges an API key to read its account limits", () => Effect.gen(function* () { - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint: "" }, {}, true).pipe( - Effect.provideService(HostProcess.Platform, "darwin"), - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.die("must not read a stale credential file"), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - expect(request.headers.authorization).toBe("Bearer keychain-token"); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ planUsage: { totalPercentUsed: 42 } }), + const requests: Array = []; + const limits = yield* readCursorUsageLimits( + { apiEndpoint: "" }, + { CURSOR_API_KEY: "api-key" }, + ).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push([request.url, request.headers.authorization]); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json( + request.url.endsWith("/auth/exchange_user_api_key") + ? { accessToken: "api-key-access-token" } + : { planUsage: { totalPercentUsed: 37 } }, ), - ); - }), - ), + ), + ); + }), ), - Effect.succeed("keychain-token"), ); - expect(limits.windows[0]?.usedPercent).toBe(42); + expect(requests).toEqual([ + ["https://api2.cursor.sh/auth/exchange_user_api_key", "Bearer api-key"], + [ + "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", + "Bearer api-key-access-token", + ], + ]); + expect(limits.windows[0]?.usedPercent).toBe(37); }), ); - it.effect("reports a Keychain initialization failure without failing the provider refresh", () => + it.effect("reports a rejected API key as a failed probe", () => Effect.gen(function* () { - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint: "" }, {}, true).pipe( - Effect.provideService(HostProcess.Platform, "darwin"), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("must not request limits without a login")), + const limits = yield* readCursorUsageLimits( + { apiEndpoint: "" }, + { CURSOR_API_KEY: "revoked" }, + ).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("denied", { status: 401 })), + ), ), ), - Effect.fail( - new CursorKeychain.CursorKeychainReadError({ - cause: new Error("Keychain initialization failed"), - }), - ), ); expect(limits.unavailable?.reason).toBe("probeFailed"); }), ); - it.effect("does not send a Keychain login or API key to a custom endpoint", () => + it.effect("does not send an API key to a custom endpoint", () => Effect.gen(function* () { for (const [apiEndpoint, environment] of [ - ["http://localhost:3000", {}], - ["", { CURSOR_API_ENDPOINT: "http://localhost:3000" }], - ["https://cursor-proxy.example", {}], - ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }], + ["http://localhost:3000", { CURSOR_API_KEY: "api-key" }], + ["", { CURSOR_API_ENDPOINT: "http://localhost:3000", CURSOR_API_KEY: "api-key" }], + ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example", CURSOR_API_KEY: "api-key" }], ["https://cursor-proxy.example", { CURSOR_API_KEY: "api-key" }], ] as const) { - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint }, environment, true).pipe( - Effect.provideService(HostProcess.Platform, "darwin"), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("must not send a Keychain credential to a proxy")), - ), + const limits = yield* readCursorUsageLimits({ apiEndpoint }, environment).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("must not send an API key to a proxy")), ), ); expect(limits.unavailable?.reason).toBe("unsupported"); diff --git a/packages/provider-cursor/src/server/usageLimits.ts b/packages/provider-cursor/src/server/usageLimits.ts index 87fcecef4e97f..a0746c3e013bf 100644 --- a/packages/provider-cursor/src/server/usageLimits.ts +++ b/packages/provider-cursor/src/server/usageLimits.ts @@ -1,12 +1,9 @@ import type { ServerProviderUsageWindow } from "@t3tools/contracts"; import type { CursorSettings } from "../settings.ts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; -import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; @@ -17,10 +14,7 @@ import { makeUsageLimits, } from "@t3tools/provider-core/server/usageLimits"; import { DEFAULT_CURSOR_API_ENDPOINT, exchangeCursorApiKey } from "./apiKey.ts"; -import * as CursorKeychain from "./CursorKeychain.ts"; -const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); -const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials)); const CursorUsageResponse = Schema.Struct({ billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])), planUsage: Schema.optional( @@ -64,13 +58,9 @@ export function cursorUsageResponseToLimits( export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* ( settings: Pick, environment: NodeJS.ProcessEnv = process.env, - allowKeychain = false, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); return yield* Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const platform = yield* HostProcess.Platform; const endpoint = ( settings.apiEndpoint?.trim() || environment.CURSOR_API_ENDPOINT?.trim() || @@ -78,8 +68,6 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function ).replace(/\/$/, ""); let token = environment.CURSOR_AUTH_TOKEN?.trim(); const apiKey = environment.CURSOR_API_KEY?.trim(); - const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE; - // An API key can name a different account from the stored login, so read the key's own. if (!token && apiKey) { if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) { return makeUnavailableUsageLimits({ @@ -89,47 +77,6 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function }); } token = yield* exchangeCursorApiKey(apiKey); - } else if (!token && credentialStore === "memory") { - return makeUnavailableUsageLimits({ - checkedAt, - reason: "unsupported", - message: "Cursor usage requires a CLI login or CURSOR_AUTH_TOKEN.", - }); - } else if (!token && platform === "darwin" && credentialStore !== "file") { - if (!allowKeychain) { - return makeUnavailableUsageLimits({ - checkedAt, - reason: "unsupported", - message: "Enable Cursor account usage in T3 Code to read its Keychain login.", - }); - } - if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) { - return makeUnavailableUsageLimits({ - checkedAt, - reason: "unsupported", - message: "Cursor account usage requires the default Cursor endpoint when using Keychain.", - }); - } - const keychain = yield* CursorKeychain.CursorKeychain; - token = (yield* keychain.accessToken)?.trim(); - } else if (!token) { - const home = - (platform === "win32" ? environment.USERPROFILE : environment.HOME) || - (yield* HostProcess.HomeDirectory); - const directory = - platform === "win32" - ? path.join(environment.APPDATA || path.join(home, "AppData", "Roaming"), "Cursor") - : platform === "darwin" - ? path.join(home, ".cursor") - : path.join(environment.XDG_CONFIG_HOME || path.join(home, ".config"), "cursor"); - const credentials = yield* fs.readFileString(path.join(directory, "auth.json")).pipe( - Effect.catchTags({ - PlatformError: (error) => - error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), - }), - Effect.flatMap(decodeCredentials), - ); - token = credentials.accessToken?.trim(); } if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); const client = yield* HttpClient.HttpClient; diff --git a/packages/provider-grok/src/server/xaiAcpExtension.test.ts b/packages/provider-grok/src/server/xaiAcpExtension.test.ts index 6b9cbf1ed30c6..a74078733d5df 100644 --- a/packages/provider-grok/src/server/xaiAcpExtension.test.ts +++ b/packages/provider-grok/src/server/xaiAcpExtension.test.ts @@ -458,6 +458,18 @@ describe("XAiAcpExtension", () => { const normalized = normalizeXAiAcpToolCallState(toolCall); expect(normalized.status).toBe("completed"); expect(normalized.title).toBe("for i in 1 2; do echo STREAM_$i; done; echo STREAM_DONE"); + expect( + normalizeXAiAcpToolCallState({ + ...toolCall, + data: { + ...toolCall.data, + rawOutput: { + ...toolCall.data.rawOutput, + command: `git commit -m "${"x".repeat(61)}๐ŸŽ‰ ship it"`, + }, + }, + }).title, + ).toBe(`git commit -m "${"x".repeat(61)}...`); expect( normalizeXAiAcpToolCallState({ ...toolCall, diff --git a/packages/provider-grok/src/server/xaiAcpExtension.ts b/packages/provider-grok/src/server/xaiAcpExtension.ts index fe356d0ec0483..022dd327b55bb 100644 --- a/packages/provider-grok/src/server/xaiAcpExtension.ts +++ b/packages/provider-grok/src/server/xaiAcpExtension.ts @@ -549,7 +549,10 @@ function resolveXAiAcpToolTitle(toolCall: AcpToolCallState): string | undefined nonEmptyString(rawInput?.command) ?? nonEmptyString(unknownRecord(toolCall.data.rawOutput)?.command); if (command !== undefined) { - return command.length > 80 ? `${command.slice(0, 77)}...` : command; + if (command.length <= 80) return command; + const splitsPair = + /[\uD800-\uDBFF]/.test(command.charAt(76)) && /[\uDC00-\uDFFF]/.test(command.charAt(77)); + return `${command.slice(0, splitsPair ? 76 : 77)}...`; } return nonEmptyString(toolCall.title); } diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 87e90e3258f4f..67e7cc6aca8eb 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -165,11 +165,14 @@ interface FakePi { readonly deferNextState: () => void; readonly resolveDeferredState: (data: unknown) => Effect.Effect; readonly failNextState: () => void; + readonly failNextThinking: () => void; readonly deferNextLifecycle: (type: "switch_session" | "new_session" | "fork" | "abort") => void; readonly deferNextModelSelection: () => void; readonly queueModels: (models: ReadonlyArray) => void; readonly vetoNextNewSession: () => void; readonly allRequests: () => ReadonlyArray; + readonly promptThinkingLevels: ReadonlyArray; + readonly promptModels: ReadonlyArray; readonly queueStats: (data: unknown) => void; /** Pi before 0.84.4 answers clear_queue as an unknown command. */ readonly setClearQueue: ( @@ -213,6 +216,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { let deferState = false; let deferredStateRequest: PiRpcRecord | undefined; let failState = false; + let failThinking = false; let vetoSwitch = false; let vetoNewSession = false; let deferredLifecycle: string | undefined; @@ -223,6 +227,10 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { let isCompacting = false; let clearQueue: Parameters[0] = { steering: [], followUp: [] }; let stdinBuffer = ""; + let thinkingLevel = "high"; + const promptThinkingLevels: Array = []; + const promptModels: Array = []; + let currentModel = "default"; const emit = (record: PiRpcRecord, chunkBytes?: number) => Effect.gen(function* () { @@ -259,6 +267,18 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { ...stateQueue.shift(), }, }; + case "set_model": + // Pi reapplies the target model's configured thinking default. + thinkingLevel = "low"; + currentModel = `${String(record["provider"])}/${String(record["modelId"])}`; + return base; + case "set_thinking_level": + if (failThinking) { + failThinking = false; + return { ...base, success: false, error: "thinking unavailable" }; + } + thinkingLevel = String(record["level"]); + return base; case "prompt": if (isCompacting) { return { ...base, success: false, error: PI_COMPACTING_PROMPT_ERROR }; @@ -312,6 +332,10 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { stdinBuffer = stdinBuffer.slice(newline + 1); if (line.length === 0) continue; const record = decodeJsonLine(line) as PiRpcRecord; + if (record["type"] === "prompt") { + promptThinkingLevels.push(thinkingLevel); + promptModels.push(currentModel); + } allRequests.push(record); yield* Queue.offer(requests, record); if (record["type"] === "get_state" && deferState) { @@ -366,6 +390,11 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { return { spawner, + failNextThinking: () => { + failThinking = true; + }, + promptThinkingLevels, + promptModels, emit, takeRequest, queueEntries: (data) => entriesQueue.push(data), @@ -2214,55 +2243,149 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); - it.effect("resets applied thinking when returning to Pi default", () => - Effect.gen(function* () { - const fake = yield* makeFakePi; - fake.queueState({ - model: { provider: "xai", id: "grok-4.6" }, - thinkingLevel: "medium", - sessionFile: FAKE_SESSION_FILE, - sessionId: "abc", - }); - const { runtime, takeEvent } = yield* openRuntime(fake); - const providerThread = yield* runtime.ensureThread({ - threadId: THREAD_ID, - modelSelection: modelSelection("default"), - runtimePolicy, - }); + it.effect.each(["anthropic/model-b", "default"])( + "preserves explicit thinking when switching models to %s in the same runtime", + (targetModel) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + fake.queueState({ + model: { provider: "anthropic", id: "model-b" }, + thinkingLevel: "high", + sessionFile: FAKE_SESSION_FILE, + }); + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + const selection = (model: string): ModelSelection => ({ + instanceId: PI_INSTANCE_ID, + model, + options: [{ id: "thinking", value: "high" }], + }); - yield* startTurn(runtime, providerThread, "default", [], "Hello pi", { - instanceId: PI_INSTANCE_ID, - model: "xai/grok-4.6", - options: [{ id: "thinking", value: "high" }], - }); - const modelRequest = yield* fake.takeRequest("set_model"); - assert.equal(modelRequest["provider"], "xai"); - assert.equal(modelRequest["modelId"], "grok-4.6"); - const levelRequest = yield* fake.takeRequest("set_thinking_level"); - assert.equal(levelRequest["level"], "high"); - yield* fake.emit({ type: "agent_start" }); - yield* fake.emit({ type: "agent_end", messages: [], willRetry: false }); - yield* fake.emit({ type: "agent_settled" }); - yield* takeEvent((event) => event.type === "turn.terminal"); + yield* startTurn( + runtime, + providerThread, + "default", + [], + "First", + selection("anthropic/model-a"), + ); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_end", messages: [], willRetry: false }); + yield* fake.emit({ type: "agent_settled" }); + yield* takeEvent((event) => event.type === "turn.terminal"); - yield* startTurn( - runtime, - providerThread, - "default", - [], - "Hello pi", - { + yield* startTurn( + runtime, + providerThread, + "default", + [], + "Second", + selection(targetModel), + 2, + ); + yield* fake.takeRequest("prompt"); + assert.deepEqual(fake.promptThinkingLevels, ["high", "high"]); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect.each([false, true, "previous"] as const)( + "resets applied thinking when returning to Pi default (failed first restore: %s)", + (failFirstRestore) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + fake.queueState({ + model: { provider: "xai", id: "grok-4.6" }, + thinkingLevel: "medium", + sessionFile: FAKE_SESSION_FILE, + sessionId: "abc", + }); + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + + yield* startTurn(runtime, providerThread, "default", [], "Hello pi", { instanceId: PI_INSTANCE_ID, - model: "default", - }, - 2, - ); - const replayModel = yield* fake.takeRequest("set_model"); - assert.equal(replayModel["provider"], "xai"); - assert.equal(replayModel["modelId"], "grok-4.6"); - const resetLevel = yield* fake.takeRequest("set_thinking_level"); - assert.equal(resetLevel["level"], "medium"); - }).pipe(Effect.scoped, Effect.provide(layerTest)), + model: "anthropic/model-a", + options: [{ id: "thinking", value: "high" }], + }); + const modelRequest = yield* fake.takeRequest("set_model"); + assert.equal(modelRequest["provider"], "anthropic"); + assert.equal(modelRequest["modelId"], "model-a"); + const levelRequest = yield* fake.takeRequest("set_thinking_level"); + assert.equal(levelRequest["level"], "high"); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_end", messages: [], willRetry: false }); + yield* fake.emit({ type: "agent_settled" }); + yield* takeEvent((event) => event.type === "turn.terminal"); + + if (failFirstRestore) { + fake.failNextThinking(); + const result = yield* startTurn( + runtime, + providerThread, + "default", + [], + "Hello pi", + { + instanceId: PI_INSTANCE_ID, + model: "default", + }, + 2, + ).pipe(Effect.exit); + assert.equal(result._tag, "Failure"); + yield* fake.takeRequest("set_model"); + yield* fake.takeRequest("set_thinking_level"); + } + + yield* startTurn( + runtime, + providerThread, + "default", + [], + "Hello pi", + { + instanceId: PI_INSTANCE_ID, + model: failFirstRestore === "previous" ? "anthropic/model-a" : "default", + ...(failFirstRestore === "previous" + ? { options: [{ id: "thinking", value: "high" }] } + : {}), + }, + 2, + ); + yield* fake.takeRequest("prompt"); + assert.deepEqual(fake.promptThinkingLevels, [ + "high", + failFirstRestore === "previous" ? "high" : "medium", + ]); + assert.deepEqual(fake.promptModels, [ + "anthropic/model-a", + failFirstRestore === "previous" ? "anthropic/model-a" : "xai/grok-4.6", + ]); + const replayModel = fake + .allRequests() + .findLast((request) => request["type"] === "set_model")!; + assert.equal( + replayModel["provider"], + failFirstRestore === "previous" ? "anthropic" : "xai", + ); + assert.equal( + replayModel["modelId"], + failFirstRestore === "previous" ? "model-a" : "grok-4.6", + ); + const resetLevel = fake + .allRequests() + .findLast((request) => request["type"] === "set_thinking_level")!; + assert.equal(resetLevel["level"], failFirstRestore === "previous" ? "high" : "medium"); + }).pipe(Effect.scoped, Effect.provide(layerTest)), ); it.effect.each([ diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index d47b693f0e123..8ed02b6917fc0 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -2484,6 +2484,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( } if (baselineThinking === null && appliedThinking === null) { baselineThinking = recordString(stateData, "thinkingLevel") ?? null; + appliedThinking = baselineThinking; } // switch_session accepts a file path, not Pi's display session UUID. const nativeId = recordString(stateData, "sessionFile"); @@ -2546,6 +2547,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( if (modelSelection.model === PI_INHERIT_MODEL_SLUG) { if (appliedModel !== null && baselineModel !== null) { const restoredModel = yield* request({ type: "set_model", ...baselineModel }); + appliedThinking = null; contextWindow = rememberModelContextWindow(restoredModel); appliedModel = null; const updatedAt = yield* DateTime.now; @@ -2558,12 +2560,11 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( } if ( thinking === undefined && - appliedThinking !== null && baselineThinking !== null && appliedThinking !== baselineThinking ) { yield* request({ type: "set_thinking_level", level: baselineThinking }); - appliedThinking = null; + appliedThinking = baselineThinking; } } else if (modelSelection.model !== appliedModel) { const parsed = parsePiModelSlug(modelSelection.model); @@ -2577,6 +2578,8 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( provider: parsed.provider, modelId: parsed.modelId, }); + // Pi resets thinking to the target model's configured default on set_model. + appliedThinking = null; contextWindow = rememberModelContextWindow(selectedModel); appliedModel = modelSelection.model; const updatedAt = yield* DateTime.now; diff --git a/packages/provider-pi/src/server/mcpInjection.test.ts b/packages/provider-pi/src/server/mcpInjection.test.ts index 4e776058a66a4..f1a8496bbfae7 100644 --- a/packages/provider-pi/src/server/mcpInjection.test.ts +++ b/packages/provider-pi/src/server/mcpInjection.test.ts @@ -1,4 +1,4 @@ -import { assert, describe, it } from "@effect/vitest"; +import { assert, describe, expect, it } from "@effect/vitest"; import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import { @@ -7,7 +7,18 @@ import { T3_PI_RUNTIME_MODE_ENV, T3_PI_MCP_EXTENSION_PATH_ENV, } from "./mcpExtensionSource.ts"; -import { buildPiRpcLaunch, resolvePiLaunchArgs } from "./mcpInjection.ts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as NodePath from "effect/Path"; +import * as NodeFS from "effect/FileSystem"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + buildPiRpcLaunch, + materializePiT3McpExtension, + resolvePiLaunchArgs, +} from "./mcpInjection.ts"; +import { PI_T3_MCP_EXTENSION_SOURCE } from "./mcpExtensionSource.ts"; const threadId = ThreadId.make("thread-pi-t3-mcp"); @@ -145,3 +156,53 @@ describe("pi T3 MCP injection", () => { assert.isTrue(resolvePiLaunchArgs("--model deepseek/v4").ok); }); }); + +describe("materializePiT3McpExtension", () => { + // A pi launch concurrent with the materialize reads the extension file from + // disk; a truncate-then-write exposes an empty or partial file mid-write and + // the launch loads a broken extension. + it.live("never exposes a torn extension file to a concurrent reader", () => + Effect.gen(function* () { + const fs = yield* NodeFS.FileSystem; + const path = yield* NodePath.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pi-atomic-" }); + const anomalies: Array = []; + const stopAfter = 120; + const writer = yield* Effect.forkChild( + Effect.gen(function* () { + for (let index = 0; index < stopAfter; index += 1) { + yield* fs + .remove(path.join(directory, "pi-t3-mcp-extension.ts"), { force: true }) + .pipe(Effect.ignore); + yield* Effect.all( + [materializePiT3McpExtension(directory), materializePiT3McpExtension(directory)], + { concurrency: "unbounded" }, + ); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + const reader = yield* Effect.forkChild( + Effect.forever( + Effect.gen(function* () { + const text = yield* fs + .readFileString(path.join(directory, "pi-t3-mcp-extension.ts")) + .pipe(Effect.option); + if (Option.isNone(text)) return; // Missing between writes: legitimate. + // A read that SUCCEEDS must return the full extension: an empty or + // partial read means the writer truncated the file mid-write. + if (text.value.length === 0) { + anomalies.push("empty: truncated mid-write"); + } else if (text.value.length !== PI_T3_MCP_EXTENSION_SOURCE.length) { + anomalies.push( + `partial: ${text.value.length} of ${PI_T3_MCP_EXTENSION_SOURCE.length} chars`, + ); + } + }), + ), + ); + yield* Fiber.join(writer); + yield* Fiber.interrupt(reader); + expect(anomalies).toEqual([]); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/packages/provider-pi/src/server/mcpInjection.ts b/packages/provider-pi/src/server/mcpInjection.ts index d88cdeb434c50..679b81ac33b78 100644 --- a/packages/provider-pi/src/server/mcpInjection.ts +++ b/packages/provider-pi/src/server/mcpInjection.ts @@ -232,7 +232,16 @@ export const materializePiT3McpExtension = Effect.fn("materializePiT3McpExtensio const dest = piT3McpExtensionDestPath(cacheDir); const existing = yield* fs.readFileString(dest).pipe(Effect.orElseSucceed(() => "")); if (existing !== PI_T3_MCP_EXTENSION_SOURCE) { - yield* fs.writeFileString(dest, PI_T3_MCP_EXTENSION_SOURCE); + // Concurrent pi launches read this file: stage it in a per-call directory + // and rename so readers and other writers never see a partial file. + yield* Effect.scoped( + Effect.gen(function* () { + const stagingDir = yield* fs.makeTempDirectoryScoped({ directory: cacheDir }); + const staging = `${stagingDir.replace(/\\/g, "/")}/${PI_T3_MCP_EXTENSION_FILENAME}`; + yield* fs.writeFileString(staging, PI_T3_MCP_EXTENSION_SOURCE); + yield* fs.rename(staging, dest).pipe(Effect.retry({ times: 3 })); + }), + ); } return dest; }); diff --git a/packages/shared/src/markdownLinks.test.ts b/packages/shared/src/markdownLinks.test.ts index 68aa6950135fd..1ca0b0ea0401b 100644 --- a/packages/shared/src/markdownLinks.test.ts +++ b/packages/shared/src/markdownLinks.test.ts @@ -24,6 +24,7 @@ describe("inlineCodeFilePathCandidate", () => { // regression: #13909 model ids in inline code do not become file chips it.each([ ["src\\main.ts", "src/main.ts"], + ["allocator.cc:217", "allocator.cc:217"], ["origin/main", null], ["z-ai/glm-5.3", null], ["share/man/ls.1", "share/man/ls.1"], diff --git a/packages/shared/src/markdownLinks.ts b/packages/shared/src/markdownLinks.ts index f8b0e8434e9b4..0d4e7556d072b 100644 --- a/packages/shared/src/markdownLinks.ts +++ b/packages/shared/src/markdownLinks.ts @@ -116,7 +116,6 @@ const GENERIC_HOSTNAME_TLDS = new Set([ "xyz", "me", "tv", - "cc", "gg", "chat", "cloud", @@ -159,6 +158,8 @@ const COUNTRY_HOSTNAME_TLDS = new Set([ "tr", "sg", "hk", + // C++ sources end in `.cc`, so `allocator.cc:217` names a file, not a host and port. + "cc", ]); function looksLikeHostname(segment: string, hasPosition: boolean): boolean { diff --git a/packages/shared/src/speech.test.ts b/packages/shared/src/speech.test.ts index 8ef4e7fd20ab7..6d1829fd67a91 100644 --- a/packages/shared/src/speech.test.ts +++ b/packages/shared/src/speech.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_SPEECH_TRANSCRIPTION_OPTIONS, SPEECH_MAX_OPTIONS_BYTES } from "@t3tools/contracts"; import { + chooseSetupSpeechModel, decodeSpeechPcmRequest, encodeSpeechPcmRequest, resolveSpeechVocabulary, @@ -71,3 +72,81 @@ describe("resolveSpeechVocabulary", () => { expect(resolved.at(-1)?.term).toBe("word97"); }); }); + +describe("chooseSetupSpeechModel", () => { + const model = ( + id: string, + languages: string[], + supportsLanguageDetection: boolean, + recommended: boolean, + ) => ({ id, languages, supportsLanguageDetection, recommended }); + const catalog = [ + model("unrecommended", ["en", "de", "es"], true, false), + model("english", ["en"], false, true), + model("fixed", ["en", "de"], false, true), + model("detecting", ["en", "de", "fr"], true, true), + ]; + + it.each([ + [ + "keeps the selected English model for an English speaker", + "english", + "auto", + ["en"], + { id: "english", language: "en" }, + ], + [ + "skips models that would transcribe English instead of German", + "english", + "auto", + ["de", "en"], + { id: "detecting", language: "de" }, + ], + [ + "accepts a model without detection for a chosen language", + "english", + "de", + ["en"], + { id: "fixed", language: "de" }, + ], + [ + "keeps a selected model that transcribes the language", + "unrecommended", + "auto", + ["de"], + { id: "unrecommended", language: "de" }, + ], + [ + "moves on to the next preferred language a model transcribes", + "english", + "auto", + ["ja", "fr"], + { id: "detecting", language: "fr" }, + ], + [ + "falls back to a model that is not recommended", + "english", + "auto", + ["es"], + { id: "unrecommended", language: "es" }, + ], + ["offers nothing when no model transcribes the language", "english", "auto", ["ja"], undefined], + [ + "offers the selected model without a language recommendation when Auto has no preferences", + "fixed", + "auto", + [], + { id: "fixed", language: null }, + ], + [ + "does not guess another model when Auto has no preferences or selection", + "gone", + "auto", + [], + undefined, + ], + ] as const)("%s", (_, selectedModelId, intent, preferred, expected) => { + const choice = chooseSetupSpeechModel(catalog, { selectedModelId, intent, preferred }); + expect(choice && { id: choice.model.id, language: choice.language }).toEqual(expected); + }); +}); diff --git a/packages/shared/src/speech.ts b/packages/shared/src/speech.ts index fcf74f10187b8..c5ed9ab14ea0a 100644 --- a/packages/shared/src/speech.ts +++ b/packages/shared/src/speech.ts @@ -1,4 +1,6 @@ import { + CLOUD_SPEECH_PROVIDER_NAMES, + type CloudSpeechProvider, SPEECH_MAX_OPTIONS_BYTES, type SpeechCustomWords, SpeechTranscriptionOptions, @@ -8,6 +10,21 @@ import * as Schema from "effect/Schema"; const decodeOptions = Schema.decodeUnknownSync(Schema.fromJsonString(SpeechTranscriptionOptions)); const encodeOptions = Schema.encodeSync(Schema.fromJsonString(SpeechTranscriptionOptions)); +export function cloudSpeechDisclosure(provider: CloudSpeechProvider, environment: string) { + return provider === "openrouter" + ? `After you stop, ${environment} sends the recording to OpenRouter. OpenRouter chooses who runs your model. T3 requests zero data retention; your OpenRouter account may allow safety retention.` + : `After you stop, ${environment} sends your recording and vocabulary hints, including project names, to ${CLOUD_SPEECH_PROVIDER_NAMES[provider]}.`; +} + +// Only offer explicit hints understood by all cloud APIs; other languages can still be detected. +export const cloudSpeechLanguages = { + supportsLanguageDetection: true, + languages: + "ar bg ca cs da de el en es fa fi fr hi hr hu id it ja ko mk ms nl pl pt ro ru sk sv th tr uk ur vi zh".split( + " ", + ), +}; + /** Resolve the language the model can produce from the requested language. */ export function effectiveSpeechLanguage( model: { readonly languages: readonly string[]; readonly supportsLanguageDetection: boolean }, @@ -19,6 +36,44 @@ export function effectiveSpeechLanguage( return model.languages.find((language) => language === "en") ?? model.languages[0] ?? "auto"; } +/** + * The model first-run setup offers: the selected one, else a recommended one, else any that the + * server would really transcribe the person's language with. Without a language hint, + * offer only the selected model, without a recommendation. + */ +export function chooseSetupSpeechModel< + Model extends { + readonly id: string; + readonly languages: readonly string[]; + readonly supportsLanguageDetection: boolean; + readonly recommended: boolean; + }, +>( + models: readonly Model[], + input: { + readonly selectedModelId: string; + readonly intent: string; + readonly preferred: readonly string[]; + }, +) { + if (input.intent === "auto" && input.preferred.length === 0) { + const model = models.find(({ id }) => id === input.selectedModelId); + return model && { model, language: null }; + } + for (const language of input.intent === "auto" ? input.preferred : [input.intent]) { + const capable = models.filter((model) => { + const effective = effectiveSpeechLanguage(model, input.intent); + return effective === language || (effective === "auto" && model.languages.includes(language)); + }); + const model = + capable.find(({ id }) => id === input.selectedModelId) ?? + capable.find(({ recommended }) => recommended) ?? + capable[0]; + if (model) return { model, language }; + } + return undefined; +} + const vocabularyKey = (value: string) => value.trim().normalize("NFC").toLocaleLowerCase(); /** diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index 6682a194ab278..c847f323a2065 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -116,12 +116,11 @@ describe("usage source messages", () => { ]); }); - it("omits empty missing sources, healthy sources, and diagnostics handled by an action", () => { + it("omits empty missing sources and healthy sources", () => { expect( usageSourceMessages([ malformedSource(0, { status: "missing" }), malformedSource(1, { status: "ok" }), - malformedSource(1, { action: "enableCursorKeychain" }), ]), ).toEqual([]); expect(usageSourceMessages([])).toEqual([]); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index 51398982e04c8..1c6ba7b8fd75a 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -26,11 +26,10 @@ export function usageSourceMessages(sources: readonly UsageSource[]): readonly s const malformedBySource = new Map(); for (const source of sources) { if ( - source.action || - (source.status !== "partial" && - source.status !== "failed" && - source.fingerprint.provider !== "cursor" && - !(source.status === "missing" && source.malformedRecords > 0)) + source.status !== "partial" && + source.status !== "failed" && + source.fingerprint.provider !== "cursor" && + !(source.status === "missing" && source.malformedRecords > 0) ) { continue; } diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 72a17323386c8..236a8baa13a26 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1412,6 +1412,17 @@ describe("/usage-limits", () => { expect( withUsageLimitsCommands([selected], [])[0]?.slashCommands.map((command) => command.name), ).toEqual(["usage-limits"]); + const unrelated = provider({ driver: ProviderDriverKind.make("claudeAgent") }); + const disabled = provider({ ...unrelated, enabled: false, usageLimits: limits }); + const mixed = withUsageLimitsCommands([selected, withWorkspace, unrelated, disabled], []); + expect(mixed.map((entry) => entry.slashCommands.map((command) => command.name))).toEqual([ + ["usage-limits"], + ["usage-limits"], + [], + [], + ]); + expect(mixed[2]).toBe(unrelated); + expect(mixed[3]).toBe(disabled); }); }); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 869f676acae69..1707deb735a49 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -729,8 +729,15 @@ export function withUsageLimitsCommands( providers: readonly ServerProvider[], sources: UsageLimitSourceSnapshots, ): ServerProvider[] { + const supportedDrivers = new Set([ + ...providersWithLimits(providers).map((provider) => provider.driver), + ...sources.flatMap((source) => source.accounts.map((account) => account.driver)), + ]); + const unreadableSource = sources.some( + (source) => source.error !== undefined && source.accounts.length === 0, + ); return providers.map((provider) => { - if (!hasProviderUsageLimits(provider.driver, providers, sources)) return provider; + if (!unreadableSource && !supportedDrivers.has(provider.driver)) return provider; const commands = (items: readonly ServerProviderSlashCommand[]) => [ ...items.filter((command) => command.name !== USAGE_LIMITS_COMMAND.name), USAGE_LIMITS_COMMAND, diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts index eb6151d336263..b730da1709b89 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts @@ -256,6 +256,7 @@ export class AzureDevOpsCli extends Context.Service< readonly getDefaultBranch: (input: { readonly cwd: string; + readonly repository: string; }) => Effect.Effect; readonly checkoutPullRequest: (input: { @@ -552,9 +553,18 @@ export const make = Effect.gen(function* () { }).pipe(Effect.asVoid); }), getDefaultBranch: Effect.fn("AzureDevOpsCli.getDefaultBranch")(function (input) { + const repository = parseRepositorySpecifier(input.repository); return executeJson({ cwd: input.cwd, - args: ["repos", "show", "--detect", "true"], + args: [ + "repos", + "show", + "--detect", + "true", + // Joined so a name starting with `-` stays the option's value. + `--repository=${repository.name}`, + ...(repository.project ? ["--project", repository.project] : []), + ], }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts index 5b4e8e91c436e..5660ba89427c9 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts @@ -1,5 +1,9 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { SourceControlProviderKind } from "@t3tools/contracts"; +import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; import { processOutput } from "@t3tools/source-control-testing/TestSourceControlHost"; -import { assert, it } from "@effect/vitest"; +import { assert, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -29,6 +33,64 @@ it("reads the signed-in Azure account and says when az is signed out", () => { assert.strictEqual(parseAuth(processOutput("\n")).status, "unknown"); }); +it.effect("names the remote's repository when reading the default branch", () => { + const run = vi.fn(() => + Effect.succeed( + processOutput( + JSON.stringify({ + name: "My Repo", + webUrl: "https://dev.azure.com/acme/project/_git/My%20Repo", + remoteUrl: "https://dev.azure.com/acme/project/_git/My%20Repo", + sshUrl: "git@ssh.dev.azure.com:v3/acme/project/My%20Repo", + defaultBranch: "refs/heads/master", + }), + ), + ), + ); + const support = Layer.mergeAll( + TestSourceControlHost.layer({ process: { run } }), + NodeServices.layer, + ); + return Effect.gen(function* () { + const provider = yield* AzureDevOpsSourceControlProvider.make; + for (const [remoteUrl, repository] of [ + ["https://acme@dev.azure.com/acme/project/_git/My%20Repo", "My Repo"], + ["https://dev.azure.com/acme/project/_git/My%20Repo?view=x#readme", "My Repo"], + ["https://acme.visualstudio.com/DefaultCollection/project/_git/My%20Repo/", "My Repo"], + ["git@ssh.dev.azure.com:v3/acme/project/My%20Repo", "My Repo"], + ["ssh://git@ssh.dev.azure.com/v3/acme/project/My%20Repo", "My Repo"], + ["https://dev.azure.com/acme/project/_git/service.git", "service.git"], + ["https://dev.azure.com/acme/project/_git/-repo", "-repo"], + ["https://dev.azure.com/acme/project/_git/%40.env", null], + ] as const) { + run.mockClear(); + const branch = yield* provider.getDefaultBranch({ + cwd: "/repo", + context: { + provider: { + kind: SourceControlProviderKind.make("azure-devops"), + name: "Azure DevOps", + baseUrl: "https://dev.azure.com", + }, + remoteName: "origin", + remoteUrl, + }, + }); + if (repository === null) { + assert.strictEqual(branch, null, remoteUrl); + assert.strictEqual(run.mock.calls.length, 0, remoteUrl); + continue; + } + assert.strictEqual(branch, "master", remoteUrl); + assert.include( + [...(run.mock.calls[0]?.[0].args ?? [])], + `--repository=${repository}`, + remoteUrl, + ); + } + }).pipe(Effect.provide(AzureDevOpsCli.layer.pipe(Layer.provide(support)))); +}); + it.effect( "looks up a branch's pull requests a full page at a time, past forks' same-named branches", () => diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts index c63fa639f2dee..05b39d532fa5a 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts @@ -53,6 +53,22 @@ export const discovery = { "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.", } satisfies SourceControlCliDiscoverySpec; +/** + * The repository name a remote ends in; `az` takes it decoded and finds org and project itself. + * Azure clone URLs carry no `.git` suffix, so a trailing `.git` is part of the name. + */ +function repositoryNameFromRemote(remoteUrl: string): string | null { + const path = URL.parse(remoteUrl.trim())?.pathname ?? remoteUrl.trim(); + const encoded = path.replace(/\/+$/u, "").split(/[/:]/u).at(-1); + if (!encoded) return null; + let name = encoded; + try { + name = decodeURIComponent(encoded); + } catch {} + // `az` replaces any argument value starting with `@` by the contents of that local file. + return name.startsWith("@") ? null : name; +} + function toChangeRequest(summary: NormalizedAzureDevOpsPullRequestRecord): ChangeRequest { return { provider: SourceControlProviderKind.make("azure-devops"), @@ -205,7 +221,10 @@ export const make = Effect.gen(function* () { ), getDefaultBranch: Effect.fn("AzureDevOpsSourceControlProvider.getDefaultBranch")( function (input) { - return azure.getDefaultBranch({ cwd: input.cwd }).pipe( + const repository = + input.context === undefined ? null : repositoryNameFromRemote(input.context.remoteUrl); + if (repository === null) return Effect.succeed(null); + return azure.getDefaultBranch({ cwd: input.cwd, repository }).pipe( Effect.mapError( (error) => new SourceControlProviderError({ diff --git a/packages/source-control-forgejo/src/shared/definition.test.ts b/packages/source-control-forgejo/src/shared/definition.test.ts index 19628b44d3cdf..139d21c37f5ca 100644 --- a/packages/source-control-forgejo/src/shared/definition.test.ts +++ b/packages/source-control-forgejo/src/shared/definition.test.ts @@ -120,6 +120,21 @@ describe("Forgejo remotes", () => { }); }); +describe("Forgejo CLI commands", () => { + it("recognizes shell merges and closes under every spelling tea accepts", () => { + for (const command of [ + "tea pulls merge 7", + "tea pull merge 7", + "tea pr m 7", + "tea pull close 7", + "fj pr close 7", + ]) { + expect(definition.changeRequestCloseCommand.test(command), command).toBe(true); + } + expect(definition.changeRequestCloseCommand.test("tea pull checkout 7")).toBe(false); + }); +}); + describe("Forgejo change request host", () => { it("keeps an HTTP remote's port, because a server's identity includes it", () => { const project = identity("http://forge.example:3000/git/team/repo.git"); diff --git a/packages/source-control-forgejo/src/shared/definition.ts b/packages/source-control-forgejo/src/shared/definition.ts index c75b071c2ab80..f78805fee1c60 100644 --- a/packages/source-control-forgejo/src/shared/definition.ts +++ b/packages/source-control-forgejo/src/shared/definition.ts @@ -93,7 +93,8 @@ export const definition = defineSourceControl({ repositoryUrl ? `git fetch '${repositoryUrl.replaceAll("'", "'\\''")}' ${changeRequestHeadRef(number)} && git checkout -B pulls/${number} FETCH_HEAD` : null, - changeRequestCloseCommand: /\b(?:tea\s+(?:pr|pulls)|fj\s+pr)\s+(?:merge|close)\b/u, + // tea also takes `pull` for `pulls` and `m` for `merge`. + changeRequestCloseCommand: /\b(?:tea\s+(?:pr|pulls?)|fj\s+pr)\s+(?:merge|m|close)\b/u, authorProfileUrl: () => null, hardLineBreaks: true, // Forgejo refuses a change request review without a summary, even with inline comments. diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts index f06c847471d0e..2ee073a2a330b 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts @@ -294,6 +294,20 @@ describe("pull request list decoding", () => { ]); }); + it("keeps review required when an approval falls short of the branch's required count", () => { + const batch = expectSuccess( + decodePullRequestListJson( + listJson([ + { + reviewDecision: "REVIEW_REQUIRED", + latestReviews: [{ author: user("octocat"), state: "APPROVED" }], + }, + ]), + ), + ); + expect(batch.items.map((entry) => entry.reviewDecision)).toEqual(["review-required"]); + }); + it("skips malformed entries but still counts them, so paging does not stop early", () => { const batch = expectSuccess( decodePullRequestListJson(listJson([{}, { number: "not-a-number" }])), diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.ts index a4c509e944488..f92c35f7afa21 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.ts @@ -509,7 +509,7 @@ export function toPullRequestActionSummary( }; } -/** GitHub's reviewDecision ignores reviews outside branch rules, including app approvals. */ +/** Prefer GitHub's reviewDecision; only use latest reviews when it is absent (e.g. bot approvals). */ function toReviewDecisionWithReviews( value: string | null | undefined, reviews: ReadonlyArray | undefined, @@ -518,7 +518,7 @@ function toReviewDecisionWithReviews( if (summarized === "approved" || summarized === "changes-requested") return summarized; 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"; + if (summarized === null && states.has("APPROVED")) return "approved"; return summarized; } diff --git a/packages/source-control-gitlab/src/shared/definition.test.ts b/packages/source-control-gitlab/src/shared/definition.test.ts index 9cd95fadfafcc..a67cfeda25cd8 100644 --- a/packages/source-control-gitlab/src/shared/definition.test.ts +++ b/packages/source-control-gitlab/src/shared/definition.test.ts @@ -100,6 +100,15 @@ describe("GitLab change request URLs", () => { }); }); +describe("GitLab CLI commands", () => { + it("recognizes shell merges and closes, including glab's `accept` alias", () => { + for (const command of ["glab mr merge 7 --yes", "glab mr accept 7 --yes", "glab mr close 7"]) { + expect(definition.changeRequestCloseCommand.test(command), command).toBe(true); + } + expect(definition.changeRequestCloseCommand.test("glab mr view 7")).toBe(false); + }); +}); + describe("GitLab remotes", () => { const recognize = (url: string) => recognizeRemoteWith(definition, url); diff --git a/packages/source-control-gitlab/src/shared/definition.ts b/packages/source-control-gitlab/src/shared/definition.ts index 2ec891cae34b8..16d5bb8cf0efa 100644 --- a/packages/source-control-gitlab/src/shared/definition.ts +++ b/packages/source-control-gitlab/src/shared/definition.ts @@ -62,7 +62,8 @@ export const definition = defineSourceControl({ "delete-branch", ] as const), checkoutCommand: ({ number }) => `glab mr checkout ${number}`, - changeRequestCloseCommand: /\bglab\s+mr\s+(?:merge|close)\b/u, + // `accept` is glab's alias for `merge`. + changeRequestCloseCommand: /\bglab\s+mr\s+(?:merge|accept|close)\b/u, changeRequestCreateCommand: /\bglab\s+mr\s+create\b/u, // Bot users, unlike GitHub's, have real profile pages. authorProfileUrl: (login, repositoryUrl) => diff --git a/patches/@react-native-menu__menu@2.0.0.patch b/patches/@react-native-menu__menu@2.0.0.patch index a96e172c09886..fc7c42bf4ed71 100644 --- a/patches/@react-native-menu__menu@2.0.0.patch +++ b/patches/@react-native-menu__menu@2.0.0.patch @@ -1,5 +1,5 @@ diff --git a/ios/MenuViewManager.mm b/ios/MenuViewManager.mm -index 3d149b3e7426b8f7c999603ddf1e15e12331c33a..e80fe2caae86e339fcb03408419a7d64b3e5b04f 100644 +index 3d149b3e7426b8f7c999603ddf1e15e12331c33a..73b9e8bbe30e6647b03cc1890806e8d48a1cbd8c 100644 --- a/ios/MenuViewManager.mm +++ b/ios/MenuViewManager.mm @@ -1,6 +1,6 @@ @@ -178,10 +178,19 @@ index 6a2a205f95c1001f3fdf6fb835815fa7c4c02c16..4f42b64349966fb1438f72439cec8256 + } diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift -index 5c4e0da4292b15d3a27b5ea1555f11452a470815..475a99b2a3b1800c9d3e3e5f77a64f552fa35828 100644 +index 5c4e0da4292b15d3a27b5ea1555f11452a470815..381aa8e2900aeb0d2453dba514360304a79e95f1 100644 --- a/ios/Shared/MenuViewImplementation.swift +++ b/ios/Shared/MenuViewImplementation.swift -@@ -60,17 +60,46 @@ public class MenuViewImplementation: UIButton { +@@ -17,7 +17,7 @@ public class MenuViewImplementation: UIButton { + } + _actions.removeAll() + actions.forEach { menuAction in +- _actions.append(RCTMenuAction(details: menuAction).createUIMenuElement({action in self.sendButtonAction(action)})) ++ _actions.append(RCTMenuAction(details: menuAction).createUIMenuElement({action in self.didSelectAction = true; self.sendButtonAction(action)})) + } + self.setup() + } +@@ -60,17 +60,98 @@ public class MenuViewImplementation: UIButton { } public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { @@ -195,6 +204,7 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..475a99b2a3b1800c9d3e3e5f77a64f55 + self.setup() + } + isMenuPresented = true ++ didSelectAction = false + sendMenuInteractionStart() return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in guard let self = self else { return nil } @@ -222,15 +232,66 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..475a99b2a3b1800c9d3e3e5f77a64f55 + pendingMenu = nil + self.setup() + } ++ } ++ ++ public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { ++ return contentPreview() ?? super.contextMenuInteraction(interaction, previewForHighlightingMenuWithConfiguration: configuration) ++ } ++ ++ public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { ++ guard let preview = contentPreview() else { ++ return super.contextMenuInteraction(interaction, previewForDismissingMenuWithConfiguration: configuration) ++ } ++ // A selection usually re-renders the trigger, so fade instead of landing on its old look. ++ return didSelectAction ? nil : preview } ++ // The button is a transparent overlay: morph from the React content so UIKit hides and restores it. ++ // iOS 26 only, and only for content with its own corner radius; otherwise keep UIButton's default. ++ private func contentPreview() -> UITargetedPreview? { ++ guard #available(iOS 26.0, *) else { ++ return nil ++ } ++ guard !shouldOpenOnLongPress, let siblings = self.superview?.subviews.filter({ $0 !== self }), siblings.count == 1, let content = siblings.first, content.window != nil, let (target, radius) = previewTarget(content) else { ++ return nil ++ } ++ let bounds = target.bounds ++ let parameters = UIPreviewParameters() ++ parameters.backgroundColor = .clear ++ parameters.visiblePath = UIBezierPath(roundedRect: bounds, cornerRadius: min(radius, bounds.height / 2, bounds.width / 2)) ++ return UITargetedPreview(view: target, parameters: parameters) ++ } ++ ++ // Walks the content's full-size single-child chain for its corner radius. A preview of a view ++ // above a glass effect view drops the glass until UIKit restores the source, so target the glass view. ++ private func previewTarget(_ content: UIView) -> (UIView, CGFloat)? { ++ var radius: CGFloat? ++ var view = content ++ for depth in 0...4 { ++ if radius == nil, view.layer.cornerRadius > 0 { ++ radius = view.layer.cornerRadius ++ } ++ if let radius, view is UIVisualEffectView { ++ return (view, radius) ++ } ++ guard depth < 4, view.subviews.count == 1, let child = view.subviews.first, ++ abs(child.frame.size.width - view.bounds.size.width) <= 0.5, ++ abs(child.frame.size.height - view.bounds.size.height) <= 0.5 else { ++ break ++ } ++ view = child ++ } ++ return radius.map { (content, $0) } ++ } ++ ++ private var didSelectAction = false + private var isMenuPresented = false + private var pendingMenu: UIMenu? + func setup () { let menu = UIMenu(title: _title, identifier: nil, -@@ -86,8 +115,102 @@ public class MenuViewImplementation: UIButton { +@@ -86,8 +167,102 @@ public class MenuViewImplementation: UIButton { } } @@ -333,7 +394,7 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..475a99b2a3b1800c9d3e3e5f77a64f55 } public override func reactSetFrame(_ frame: CGRect) { -@@ -124,4 +247,8 @@ public class MenuViewImplementation: UIButton { +@@ -124,4 +299,8 @@ public class MenuViewImplementation: UIButton { @objc func sendMenuOpen() { // NO-OP (should be overriden by parent) } diff --git a/patches/react-native-enriched-markdown@1.2.0-t3.16.patch b/patches/react-native-enriched-markdown@1.2.0-t3.16.patch new file mode 100644 index 0000000000000..2b5d8eecdd31b --- /dev/null +++ b/patches/react-native-enriched-markdown@1.2.0-t3.16.patch @@ -0,0 +1,92 @@ +diff --git a/android/src/main/java/com/swmansion/enriched/markdown/segments/TableContainerView.kt b/android/src/main/java/com/swmansion/enriched/markdown/segments/TableContainerView.kt +--- a/android/src/main/java/com/swmansion/enriched/markdown/segments/TableContainerView.kt ++++ b/android/src/main/java/com/swmansion/enriched/markdown/segments/TableContainerView.kt +@@ -131,7 +131,7 @@ class TableContainerView( + TableCellData( + attributedText = renderCellNode(cell, isHeader, align), + plainText = extractPlainText(cell), +- markdownText = MarkdownASTSerializer.serializeChildren(cell), ++ markdownText = MarkdownASTSerializer.serializeTableCell(cell), + isHeader = isHeader, + alignment = align, + ) +diff --git a/android/src/main/java/com/swmansion/enriched/markdown/utils/common/serialization/MarkdownASTSerializer.kt b/android/src/main/java/com/swmansion/enriched/markdown/utils/common/serialization/MarkdownASTSerializer.kt +--- a/android/src/main/java/com/swmansion/enriched/markdown/utils/common/serialization/MarkdownASTSerializer.kt ++++ b/android/src/main/java/com/swmansion/enriched/markdown/utils/common/serialization/MarkdownASTSerializer.kt +@@ -20,6 +20,12 @@ object MarkdownASTSerializer { + return buffer.toString() + } + ++ private val tableCellLineBreak = Regex("""(?` so the row stays on one line. */ ++ fun serializeTableCell(cell: MarkdownASTNode): String = ++ serializeChildren(cell).replace(tableCellLineBreak, "$1$1
") ++ + /** + * Serializes a Blockquote or Admonition node back to markdown, including + * the `> ` prefix on every line and `> [!TYPE]` header for admonitions. +@@ -179,7 +185,7 @@ object MarkdownASTSerializer { + for (section in node.children) { + for (row in section.children) { + if (row.type != NodeType.TableRow) continue +- val cells = row.children.map { serializeChildren(it) } ++ val cells = row.children.map { serializeTableCell(it) } + buffer.append("| ") + buffer.append(cells.joinToString(" | ")) + buffer.append(" |\n") +diff --git a/ios/segments/TableContainerView.m b/ios/segments/TableContainerView.m +--- a/ios/segments/TableContainerView.m ++++ b/ios/segments/TableContainerView.m +@@ -163,7 +163,7 @@ static NSArray *> *ENRMTableBuildRows(MarkdownASTNode * + cell.isHeader = isSectionHead || (cellNode.type == MarkdownNodeTypeTableHeaderCell); + cell.alignment = ENRMTableTextAlignmentFromString(cellNode.attributes[@"align"]); + cell.plainText = ENRMTablePlainTextFromNode(cellNode); +- cell.markdownText = markdownFromASTNodeChildren(cellNode); ++ cell.markdownText = markdownFromTableCellNode(cellNode); + + StyleConfig *cellConfig = cell.isHeader ? headerCellConfig : bodyCellConfig; + cell.attributedText = +diff --git a/ios/utils/MarkdownASTSerializer.h b/ios/utils/MarkdownASTSerializer.h +--- a/ios/utils/MarkdownASTSerializer.h ++++ b/ios/utils/MarkdownASTSerializer.h +@@ -17,6 +17,9 @@ NSString *markdownFromASTNode(MarkdownASTNode *node); + /// Useful for extracting the markdown content of a container node (e.g. a table cell). + NSString *markdownFromASTNodeChildren(MarkdownASTNode *node); + ++/// Serializes a table cell's content; a newline inside it becomes `
` so the row stays on one line. ++NSString *markdownFromTableCellNode(MarkdownASTNode *node); ++ + /// Serializes a Blockquote or Admonition node to full markdown, including `> ` line + /// prefixes and `> [!TYPE]` header for admonitions. + NSString *markdownFromBlockquoteNode(MarkdownASTNode *node); +diff --git a/ios/utils/MarkdownASTSerializer.m b/ios/utils/MarkdownASTSerializer.m +--- a/ios/utils/MarkdownASTSerializer.m ++++ b/ios/utils/MarkdownASTSerializer.m +@@ -148,7 +148,7 @@ static void appendTableMarkdown(MarkdownASTNode *node, NSMutableString *buffer) + continue; + NSMutableArray *cells = [NSMutableArray arrayWithCapacity:row.children.count]; + for (MarkdownASTNode *cell in row.children) { +- [cells addObject:markdownFromASTNodeChildren(cell)]; ++ [cells addObject:markdownFromTableCellNode(cell)]; + } + [buffer appendFormat:@"| %@ |\n", [cells componentsJoinedByString:@" | "]]; + if (!headerDone) { +@@ -333,6 +333,17 @@ NSString *markdownFromASTNodeChildren(MarkdownASTNode *node) + return [buffer copy]; + } + ++NSString *markdownFromTableCellNode(MarkdownASTNode *node) ++{ ++ NSString *markdown = markdownFromASTNodeChildren(node); ++ if (![markdown containsString:@"\n"]) ++ return markdown; ++ return [markdown stringByReplacingOccurrencesOfString:@"(?" ++ options:NSRegularExpressionSearch ++ range:NSMakeRange(0, markdown.length)]; ++} ++ + NSString *markdownFromBlockquoteNode(MarkdownASTNode *node) + { + if (!node) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e1dd4d26a461..697191c410823 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -365,7 +365,7 @@ patchedDependencies: '@muse-code/sdk@1.4.6': 7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101 '@pierre/diffs@1.6.1': 322ac5f5db8b5715189c010802047f8235c3a45d8ed5ca0ba60fac266e2eba34 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d - '@react-native-menu/menu@2.0.0': 910ec1bf22aa85a96fcf12299cd823bc30f702dd9b2d77b564d10b9ed709f7f6 + '@react-native-menu/menu@2.0.0': 6872ba71b9847c5a0210b1b896923dadc3860faedfe28d9f062bcedfe786ef1d '@react-navigation/native-stack@7.21.0': 990d575444c4af2ce8218b3b7a8280fe244c03abbdb32b822a2e862f791dbf42 alchemy@2.0.0-beta.85: 07cc8c7e14b3b9af143ddd40a059f117c27e55139fbf20bd5dde2eb30509cedd app-builder-lib@26.16.1: 0fbe9ae51783f5768ebe197d12d1fdb7d477a08c92e5db2f6356bcce62613c53 @@ -382,6 +382,7 @@ patchedDependencies: micromark-extension-llm-math@3.1.0: 4eeb9d9e27ebc75e98795e21c0719005483dad7d233055698257f7d10e91603b micromark@4.0.2: cb79edde7eca83bbcaaafb6ceb7b044b81b987314cde4910823b15257ecada67 node-pty@1.2.0-beta.15: f1e680afec85f650699ea756f91f469844ec4c976f4f168492a45c6b3f67d651 + react-native-enriched-markdown@1.2.0-t3.16: 542e6932066897941f54b902fa7823b2a1861c4a741b5369d5c2d49bd18485a2 react-native-gesture-handler@3.3.0: abe20e1d1c23549297cbbc818e14fe479b41455930d9663135d6b40fe359b626 react-native-image-viewing@0.2.2: 4aa038d541c7ddcc8f1437eb3e7b4f769f61de78e7f91b53f9805dfca608474d react-native-keyboard-controller@1.22.8: 4e9b5a52aa8c1f9de8082bad281ee979df7860fc77ec49820d4ed00c3c7cc5a2 @@ -565,7 +566,7 @@ importers: version: 3.1.1(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) '@react-native-menu/menu': specifier: 2.0.0 - version: 2.0.0(patch_hash=910ec1bf22aa85a96fcf12299cd823bc30f702dd9b2d77b564d10b9ed709f7f6)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + version: 2.0.0(patch_hash=6872ba71b9847c5a0210b1b896923dadc3860faedfe28d9f062bcedfe786ef1d)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) '@react-navigation/elements': specifier: 2.9.45 version: 2.9.45(d8845bac868f4d8a58c8a8f12ad0d4a5) @@ -736,7 +737,7 @@ importers: version: 0.25.1(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) react-native-enriched-markdown: specifier: file:deps/react-native-enriched-markdown-1.2.0-t3.16-29ddd424.tgz - version: file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-29ddd424.tgz(katex@0.16.47)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + version: file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-29ddd424.tgz(patch_hash=542e6932066897941f54b902fa7823b2a1861c4a741b5369d5c2d49bd18485a2)(katex@0.16.47)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) react-native-gesture-handler: specifier: 3.3.0 version: 3.3.0(patch_hash=abe20e1d1c23549297cbbc818e14fe479b41455930d9663135d6b40fe359b626)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) @@ -1708,9 +1709,6 @@ importers: '@cursor/sdk': specifier: 1.2.0 version: 1.2.0(zod@4.6.5) - '@napi-rs/keyring': - specifier: ^1.3.0 - version: 1.3.0 '@t3tools/contracts': specifier: workspace:* version: link:../contracts @@ -15714,7 +15712,7 @@ snapshots: react-native: 0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) optional: true - '@react-native-menu/menu@2.0.0(patch_hash=910ec1bf22aa85a96fcf12299cd823bc30f702dd9b2d77b564d10b9ed709f7f6)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)': + '@react-native-menu/menu@2.0.0(patch_hash=6872ba71b9847c5a0210b1b896923dadc3860faedfe28d9f062bcedfe786ef1d)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)': dependencies: react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) @@ -21248,7 +21246,7 @@ snapshots: react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) - react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-29ddd424.tgz(katex@0.16.47)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0): + react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-29ddd424.tgz(patch_hash=542e6932066897941f54b902fa7823b2a1861c4a741b5369d5c2d49bd18485a2)(katex@0.16.47)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 95d8b2d57e09f..d64fc1a679f0c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -262,6 +262,8 @@ patchedDependencies: # Observe the owned host exit independently of descendants retaining its stdio. "@muse-code/sdk@1.4.6": patches/@muse-code__sdk@1.4.6.patch app-builder-lib@26.16.1: patches/app-builder-lib@26.16.1.patch + # A newline in a copied table cell becomes
, so Copy as Markdown keeps the row whole. + react-native-enriched-markdown@1.2.0-t3.16: patches/react-native-enriched-markdown@1.2.0-t3.16.patch # A failed Artifacts.cached effect hangs every later caller: https://github.com/alchemy-run/alchemy/issues/2981 alchemy@2.0.0-beta.85: patches/alchemy@2.0.0-beta.85.patch diff --git a/scripts/apply-web-brand-assets.test.ts b/scripts/apply-web-brand-assets.test.ts index c3073879b207c..9ede298fb7df7 100644 --- a/scripts/apply-web-brand-assets.test.ts +++ b/scripts/apply-web-brand-assets.test.ts @@ -11,6 +11,8 @@ const WEB_ICON_FILENAMES = [ "favicon-16x16.png", "favicon-32x32.png", "favicon.ico", + "icon-192x192.png", + "icon-512x512.png", ]; it.layer(NodeServices.layer)("apply-web-brand-assets", (it) => { diff --git a/scripts/build-cli-archive.test.ts b/scripts/build-cli-archive.test.ts new file mode 100644 index 0000000000000..45ce12deb32c8 --- /dev/null +++ b/scripts/build-cli-archive.test.ts @@ -0,0 +1,216 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +import { awaitCliNotarization, signMacArchiveContents } from "./build-cli-archive.ts"; + +const signedEnv = { + T3CODE_CLI_MAC_SIGN_IDENTITY: "Developer ID Application: Test (TEAM)", + APPLE_API_KEY: "/tmp/AuthKey_TEST.p8", + APPLE_API_KEY_ID: "TEST", + APPLE_API_ISSUER: "issuer", +}; + +type NotaryReply = { readonly exitCode: number; readonly stdout?: string }; + +/** Stands in for `xcrun notarytool`, codesign and ditto; records each invocation. */ +function stubSpawner(notary: (subcommand: string) => NotaryReply) { + const calls: Array> = []; + const spawner = ChildProcessSpawner.make((command) => { + const argv = ChildProcess.isStandardCommand(command) + ? [command.command, ...command.args.map(String)] + : []; + calls.push(argv); + const reply = argv[1] === "notarytool" ? notary(argv[2] ?? "") : { exitCode: 0 }; + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(reply.exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: reply.stdout ? Stream.make(new TextEncoder().encode(reply.stdout)) : Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + return { calls, spawner }; +} + +const withStubs = ( + effect: Effect.Effect, + stub: ReturnType, + env: Record = signedEnv, +) => + effect.pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, stub.spawner), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))), + ); + +const makeContent = Effect.fn("test.makeContent")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-notary-" }); + const contentDir = path.join(root, "content"); + yield* fs.makeDirectory(contentDir); + const executablePath = path.join(contentDir, "t3"); + yield* fs.writeFileString(executablePath, "t3"); + return { root, contentDir, executablePath, submissionFile: path.join(root, "submission") }; +}); + +const notarySubcommands = (calls: ReadonlyArray>) => + calls.filter((argv) => argv[1] === "notarytool").map((argv) => argv.slice(2)); + +it.layer(NodeServices.layer)("build-cli-archive notarization", (it) => { + it.effect("records a deferred submission without waiting, then gates on Accepted", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + // Apple documents id and path for an unwaited submit; a pending status must not fail it. + for (const submitted of [ + '{"id":"new-id","message":"Successfully uploaded file","path":"t3.zip"}', + '{"id":"new-id","status":"In Progress"}', + ]) { + const content = yield* makeContent(); + yield* fs.writeFileString(content.submissionFile, "stale-id"); + const stub = stubSpawner((subcommand) => + subcommand === "submit" + ? { exitCode: 0, stdout: submitted } + : { exitCode: 0, stdout: '{"id":"new-id","status":"Accepted"}' }, + ); + + yield* withStubs( + signMacArchiveContents({ + repoRoot: content.root, + contentDir: content.contentDir, + executablePath: content.executablePath, + notarySubmissionFile: Option.some(content.submissionFile), + }), + stub, + ); + assert.equal(yield* fs.readFileString(content.submissionFile), "new-id"); + const [submit] = notarySubcommands(stub.calls); + assert.equal(submit?.[0], "submit"); + assert.isFalse(submit?.includes("--wait")); + + yield* withStubs(awaitCliNotarization(content.submissionFile), stub); + assert.deepEqual(notarySubcommands(stub.calls)[1]?.slice(0, 2), ["wait", "new-id"]); + } + }).pipe(Effect.scoped), + ); + + it.effect("fails the gate unless Apple accepted the submission", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cases: ReadonlyArray<{ + readonly name: string; + readonly wait: NotaryReply; + readonly submitted?: boolean; + readonly env?: Record; + readonly error: string | undefined; + readonly fetchesLog?: boolean; + }> = [ + { + name: "invalid", + wait: { exitCode: 0, stdout: '{"id":"sub","status":"Invalid"}' }, + error: "CliArchiveNotarizationRejectedError", + fetchesLog: true, + }, + { + name: "rejected with a non-zero exit", + wait: { exitCode: 1, stdout: '{"id":"sub","status":"Rejected"}' }, + error: "CliArchiveNotarizationRejectedError", + fetchesLog: true, + }, + { + name: "still in progress", + wait: { exitCode: 0, stdout: '{"id":"sub","status":"In Progress"}' }, + error: "CliArchiveNotarizationRejectedError", + }, + { + name: "still in progress with a non-zero exit", + wait: { exitCode: 1, stdout: '{"id":"sub","status":"In Progress"}' }, + error: "CliArchiveCommandFailedError", + }, + { + name: "notarytool failed", + wait: { exitCode: 69 }, + error: "CliArchiveCommandFailedError", + }, + { + name: "no status", + wait: { exitCode: 0, stdout: '{"id":"sub"}' }, + error: "CliArchiveNotarizationRejectedError", + }, + { + name: "accepted with a non-zero exit", + wait: { exitCode: 1, stdout: '{"id":"sub","status":"Accepted"}' }, + error: "CliArchiveCommandFailedError", + }, + { + name: "submission missing", + wait: { exitCode: 0, stdout: '{"id":"sub","status":"Accepted"}' }, + submitted: false, + error: "CliArchiveInputMissingError", + }, + { + name: "submission without credentials", + wait: { exitCode: 0, stdout: '{"id":"sub","status":"Accepted"}' }, + env: { T3CODE_CLI_MAC_SIGN_IDENTITY: signedEnv.T3CODE_CLI_MAC_SIGN_IDENTITY }, + error: "CliArchiveInputMissingError", + }, + { + name: "notarization not configured", + wait: { exitCode: 0 }, + submitted: false, + env: {}, + error: undefined, + }, + ]; + for (const testCase of cases) { + const content = yield* makeContent(); + if (testCase.submitted !== false) yield* fs.writeFileString(content.submissionFile, "sub"); + const stub = stubSpawner(() => testCase.wait); + const exit = yield* withStubs( + awaitCliNotarization(content.submissionFile), + stub, + testCase.env, + ).pipe(Effect.flip, Effect.option); + assert.equal(Option.getOrUndefined(exit)?._tag, testCase.error, testCase.name); + assert.equal( + notarySubcommands(stub.calls).some((argv) => argv[0] === "log"), + testCase.fetchesLog ?? false, + testCase.name, + ); + } + }).pipe(Effect.scoped), + ); + + it.effect("fails a standalone build whose waited submission is not Accepted", () => + Effect.gen(function* () { + const content = yield* makeContent(); + const stub = stubSpawner(() => ({ exitCode: 0, stdout: '{"id":"sub","status":"Invalid"}' })); + const error = yield* withStubs( + signMacArchiveContents({ + repoRoot: content.root, + contentDir: content.contentDir, + executablePath: content.executablePath, + notarySubmissionFile: Option.none(), + }), + stub, + ).pipe(Effect.flip); + assert.equal(error._tag, "CliArchiveNotarizationRejectedError"); + assert.isTrue(notarySubcommands(stub.calls)[0]?.includes("--wait")); + }).pipe(Effect.scoped), + ); +}); diff --git a/scripts/build-cli-archive.ts b/scripts/build-cli-archive.ts index 1244b9491fb73..d55d8799f6c95 100644 --- a/scripts/build-cli-archive.ts +++ b/scripts/build-cli-archive.ts @@ -11,6 +11,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import { Command, Flag } from "effect/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; @@ -330,16 +331,99 @@ const MacSigningConfig = Config.all({ appleApiIssuer: Config.String("APPLE_API_ISSUER").pipe(Config.option), }); +export class CliArchiveNotarizationRejectedError extends Schema.TaggedError()( + "CliArchiveNotarizationRejectedError", + { submissionId: Schema.String, status: Schema.String }, +) { + override get message(): string { + return `Apple notarization ${this.submissionId} is ${this.status}, not Accepted.`; + } +} + +const NotarySubmission = Schema.Struct({ + id: Schema.NonEmptyString, + status: Schema.optional(Schema.String), +}); +// Final failures from Apple's Notary API. "In Progress" is pending and has no log yet. +const rejectedNotaryStatuses: ReadonlySet = new Set(["Invalid", "Rejected"]); +const decodeNotarySubmission = Schema.decodeEffect(Schema.fromJsonString(NotarySubmission)); + +/** Notarization runs only for a Developer ID identity with all three App Store Connect key values. */ +const resolveNotaryCredentials = Effect.fn("resolveNotaryCredentials")(function* () { + const signing = yield* MacSigningConfig; + const identity = Option.getOrUndefined(signing.identity)?.trim() || "-"; + const key = Option.getOrUndefined(signing.appleApiKey); + const keyId = Option.getOrUndefined(signing.appleApiKeyId); + const issuer = Option.getOrUndefined(signing.appleApiIssuer); + const credentials = + identity !== "-" && key && keyId && issuer + ? Option.some(["--key", key, "--key-id", keyId, "--issuer", issuer]) + : Option.none>(); + return { identity, credentials }; +}); + +const notarytool = Effect.fn("notarytool")(function* ( + args: ReadonlyArray, + credentials: ReadonlyArray, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make("xcrun", ["notarytool", ...args, ...credentials, "--output-format", "json"], { + stdout: "pipe", + stderr: "inherit", + }), + ); + const [stdout, exitCode] = yield* Effect.all( + [Stream.mkString(Stream.decodeText(child.stdout)), child.exitCode], + { concurrency: "unbounded" }, + ); + const submission = yield* decodeNotarySubmission(stdout).pipe(Effect.option); + const status = Option.getOrUndefined(submission)?.status; + // A finished submission reports its status even when notarytool exits non-zero for it. + if (Option.isSome(submission) && status !== undefined && rejectedNotaryStatuses.has(status)) { + yield* runCommand( + ChildProcess.make("xcrun", ["notarytool", "log", submission.value.id, ...credentials]), + "notarytool log", + ).pipe(Effect.ignore); + return yield* new CliArchiveNotarizationRejectedError({ + submissionId: submission.value.id, + status, + }); + } + if (Number(exitCode) !== 0 || Option.isNone(submission)) { + return yield* new CliArchiveCommandFailedError({ + command: `notarytool ${args[0]}`, + exitCode: Number(exitCode), + }); + } + return submission.value; +}, Effect.scoped); + +const requireAccepted = Effect.fn("requireAccepted")(function* ( + submission: typeof NotarySubmission.Type, +) { + if (submission.status !== "Accepted") { + return yield* new CliArchiveNotarizationRejectedError({ + submissionId: submission.id, + status: submission.status ?? "without a status", + }); + } + yield* Effect.log(`[cli-archive] Notarized t3 (${submission.id}).`); +}); + /** Apple Silicon requires signed binaries; notarization's hardened runtime also requires every native addon signed. */ -const signMacArchiveContents = Effect.fn("signMacArchiveContents")(function* (input: { +export const signMacArchiveContents = Effect.fn("signMacArchiveContents")(function* (input: { readonly repoRoot: string; readonly contentDir: string; readonly executablePath: string; + readonly notarySubmissionFile: Option.Option; }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const signing = yield* MacSigningConfig; - const identity = Option.getOrUndefined(signing.identity)?.trim() || "-"; + const { identity, credentials } = yield* resolveNotaryCredentials(); + const deferred = Option.getOrUndefined(input.notarySubmissionFile); + // A stale id would let await-notarization approve bytes from an earlier build. + if (deferred !== undefined) yield* fs.remove(deferred, { force: true }); const entitlements = path.join(input.repoRoot, "apps/server/resources/cli-entitlements.plist"); const libraries = (yield* fs.readDirectory(input.contentDir, { recursive: true })) @@ -369,11 +453,7 @@ const signMacArchiveContents = Effect.fn("signMacArchiveContents")(function* (in yield* Effect.log("[cli-archive] Signed ad hoc (no T3CODE_CLI_MAC_SIGN_IDENTITY)."); return; } - - const apiKey = Option.getOrUndefined(signing.appleApiKey); - const apiKeyId = Option.getOrUndefined(signing.appleApiKeyId); - const apiIssuer = Option.getOrUndefined(signing.appleApiIssuer); - if (!apiKey || !apiKeyId || !apiIssuer) { + if (Option.isNone(credentials)) { yield* Effect.logWarning( "[cli-archive] Developer ID signed but not notarized (missing APPLE_API_KEY*).", ); @@ -385,22 +465,46 @@ const signMacArchiveContents = Effect.fn("signMacArchiveContents")(function* (in ChildProcess.make("ditto", ["-c", "-k", "--keepParent", input.executablePath, notarizeZip]), "ditto (notarization zip)", ); - yield* runCommand( - ChildProcess.make("xcrun", [ - "notarytool", - "submit", - notarizeZip, - "--key", - apiKey, - "--key-id", - apiKeyId, - "--issuer", - apiIssuer, - "--wait", - ]), - "notarytool submit", + const submission = yield* notarytool( + ["submit", notarizeZip, ...(deferred === undefined ? ["--wait"] : [])], + credentials.value, ).pipe(Effect.ensuring(fs.remove(notarizeZip, { force: true }).pipe(Effect.ignore))); - yield* Effect.log("[cli-archive] Notarized t3."); + if (deferred === undefined) { + yield* requireAccepted(submission); + return; + } + // The ticket lives with Apple, not in the archive, so the archive bytes are final before approval. + yield* fs.writeFileString(deferred, submission.id); + yield* Effect.log( + `[cli-archive] Submitted t3 for notarization (${submission.id}). Run await-notarization before publishing.`, + ); +}); + +/** Publication gate for a deferred submission: fails unless Apple accepted it. */ +export const awaitCliNotarization = Effect.fn("awaitCliNotarization")(function* ( + submissionFile: string, +) { + const fs = yield* FileSystem.FileSystem; + const { credentials } = yield* resolveNotaryCredentials(); + const submitted = yield* fs.exists(submissionFile); + if (Option.isNone(credentials)) { + if (submitted) { + return yield* new CliArchiveInputMissingError({ + inputPath: "T3CODE_CLI_MAC_SIGN_IDENTITY and APPLE_API_KEY*", + hint: `${submissionFile} names a submission, but its credentials are not set.`, + }); + } + yield* Effect.log("[cli-archive] No notarization to await (signing or APPLE_API_KEY* unset)."); + return; + } + if (!submitted) { + return yield* new CliArchiveInputMissingError({ + inputPath: submissionFile, + hint: "Notarization is configured, but build-cli-archive recorded no submission.", + }); + } + const id = (yield* fs.readFileString(submissionFile)).trim(); + yield* requireAccepted(yield* notarytool(["wait", id], credentials.value)); }); const WindowsSigningConfig = Config.all({ @@ -486,6 +590,7 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { readonly version: string; readonly outputDir: string; readonly resourceMonitorDir: Option.Option; + readonly notarySubmissionFile: Option.Option; }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -552,7 +657,12 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { }); } if (input.platform === "mac") { - yield* signMacArchiveContents({ repoRoot, contentDir, executablePath }); + yield* signMacArchiveContents({ + repoRoot, + contentDir, + executablePath, + notarySubmissionFile: input.notarySubmissionFile, + }); } else if (input.platform === "win") { yield* signWindowsExecutable(executablePath); } @@ -615,9 +725,28 @@ const command = Command.make( ), Flag.optional, ), + notarySubmissionFile: Flag.String("notary-submission-file").pipe( + Flag.withDescription( + "macOS: submit for notarization without waiting and write the submission id here. Publish only after await-notarization succeeds.", + ), + Flag.optional, + ), }, (input) => buildCliArchive(input).pipe(Effect.scoped), -).pipe(Command.withDescription("Package the t3 single-executable into a per-platform archive.")); +).pipe( + Command.withDescription("Package the t3 single-executable into a per-platform archive."), + Command.withSubcommands([ + Command.make( + "await-notarization", + { submissionFile: Flag.String("submission-file") }, + (input) => awaitCliNotarization(input.submissionFile), + ).pipe( + Command.withDescription( + "Wait for a --notary-submission-file submission; fail unless Accepted.", + ), + ), + ]), +); if (import.meta.main) { Command.run(command, { version: "0.0.0" }).pipe( diff --git a/scripts/export-brand-icons.ts b/scripts/export-brand-icons.ts index 36efc58ab3481..052665b7c2983 100644 --- a/scripts/export-brand-icons.ts +++ b/scripts/export-brand-icons.ts @@ -48,6 +48,8 @@ interface VariantOutputs { readonly macos: string; readonly universal: string; readonly appleTouch: string; + readonly webIcon192: string; + readonly webIcon512: string; readonly favicon16: string; readonly favicon32: string; readonly faviconIco: string; @@ -211,6 +213,8 @@ const ICON_VARIANTS = [ macos: BRAND_ASSET_PATHS.developmentDesktopIconPng, universal: BRAND_ASSET_PATHS.developmentUniversalIconPng, appleTouch: BRAND_ASSET_PATHS.developmentWebAppleTouchIconPng, + webIcon192: BRAND_ASSET_PATHS.developmentWebIcon192Png, + webIcon512: BRAND_ASSET_PATHS.developmentWebIcon512Png, favicon16: BRAND_ASSET_PATHS.developmentWebFavicon16Png, favicon32: BRAND_ASSET_PATHS.developmentWebFavicon32Png, faviconIco: BRAND_ASSET_PATHS.developmentWebFaviconIco, @@ -225,6 +229,8 @@ const ICON_VARIANTS = [ macos: BRAND_ASSET_PATHS.nightlyMacIconPng, universal: BRAND_ASSET_PATHS.nightlyLinuxIconPng, appleTouch: BRAND_ASSET_PATHS.nightlyWebAppleTouchIconPng, + webIcon192: BRAND_ASSET_PATHS.nightlyWebIcon192Png, + webIcon512: BRAND_ASSET_PATHS.nightlyWebIcon512Png, favicon16: BRAND_ASSET_PATHS.nightlyWebFavicon16Png, favicon32: BRAND_ASSET_PATHS.nightlyWebFavicon32Png, faviconIco: BRAND_ASSET_PATHS.nightlyWebFaviconIco, @@ -239,6 +245,8 @@ const ICON_VARIANTS = [ macos: BRAND_ASSET_PATHS.productionMacIconPng, universal: BRAND_ASSET_PATHS.productionLinuxIconPng, appleTouch: BRAND_ASSET_PATHS.productionWebAppleTouchIconPng, + webIcon192: BRAND_ASSET_PATHS.productionWebIcon192Png, + webIcon512: BRAND_ASSET_PATHS.productionWebIcon512Png, favicon16: BRAND_ASSET_PATHS.productionWebFavicon16Png, favicon32: BRAND_ASSET_PATHS.productionWebFavicon32Png, faviconIco: BRAND_ASSET_PATHS.productionWebFaviconIco, @@ -592,6 +600,8 @@ const renderVariant = Effect.fn("iconExport.renderVariant")(function* ( [variant.outputs.ios, ios], [variant.outputs.universal, ios], [variant.outputs.appleTouch, yield* render("iOS", 180)], + [variant.outputs.webIcon192, yield* render("iOS", 192)], + [variant.outputs.webIcon512, yield* render("iOS", 512)], [variant.outputs.favicon16, yield* render("iOS", 16)], [variant.outputs.favicon32, yield* render("iOS", 32)], [variant.outputs.faviconIco, ico], diff --git a/scripts/lib/brand-assets.ts b/scripts/lib/brand-assets.ts index 4f2d743a9c2e8..cde89a42b7d32 100644 --- a/scripts/lib/brand-assets.ts +++ b/scripts/lib/brand-assets.ts @@ -12,6 +12,8 @@ export const BRAND_ASSET_PATHS = { productionWebFavicon16Png: "assets/prod/t3-black-web-favicon-16x16.png", productionWebFavicon32Png: "assets/prod/t3-black-web-favicon-32x32.png", productionWebAppleTouchIconPng: "assets/prod/t3-black-web-apple-touch-180.png", + productionWebIcon192Png: "assets/prod/t3-black-web-icon-192x192.png", + productionWebIcon512Png: "assets/prod/t3-black-web-icon-512x512.png", nightlyIconComposerProject: "assets/nightly/app-icon.icon", nightlyIosIconPng: "assets/nightly/nightly-ios-1024.png", @@ -22,6 +24,8 @@ export const BRAND_ASSET_PATHS = { nightlyWebFavicon16Png: "assets/nightly/nightly-web-favicon-16x16.png", nightlyWebFavicon32Png: "assets/nightly/nightly-web-favicon-32x32.png", nightlyWebAppleTouchIconPng: "assets/nightly/nightly-web-apple-touch-180.png", + nightlyWebIcon192Png: "assets/nightly/nightly-web-icon-192x192.png", + nightlyWebIcon512Png: "assets/nightly/nightly-web-icon-512x512.png", developmentDesktopIconPng: "assets/dev/blueprint-macos-1024.png", developmentWindowsIconIco: "assets/dev/blueprint-windows.ico", @@ -29,6 +33,8 @@ export const BRAND_ASSET_PATHS = { developmentWebFavicon16Png: "assets/dev/blueprint-web-favicon-16x16.png", developmentWebFavicon32Png: "assets/dev/blueprint-web-favicon-32x32.png", developmentWebAppleTouchIconPng: "assets/dev/blueprint-web-apple-touch-180.png", + developmentWebIcon192Png: "assets/dev/blueprint-web-icon-192x192.png", + developmentWebIcon512Png: "assets/dev/blueprint-web-icon-512x512.png", } as const; export type WebAssetBrand = "development" | "nightly" | "production"; @@ -51,6 +57,8 @@ const WEB_ICON_TARGET_FILENAMES = { favicon16Png: "favicon-16x16.png", favicon32Png: "favicon-32x32.png", appleTouchIconPng: "apple-touch-icon.png", + icon192Png: "icon-192x192.png", + icon512Png: "icon-512x512.png", } as const; const WEB_ICON_SOURCE_PATHS_BY_BRAND = { @@ -59,18 +67,24 @@ const WEB_ICON_SOURCE_PATHS_BY_BRAND = { favicon16Png: BRAND_ASSET_PATHS.developmentWebFavicon16Png, favicon32Png: BRAND_ASSET_PATHS.developmentWebFavicon32Png, appleTouchIconPng: BRAND_ASSET_PATHS.developmentWebAppleTouchIconPng, + icon192Png: BRAND_ASSET_PATHS.developmentWebIcon192Png, + icon512Png: BRAND_ASSET_PATHS.developmentWebIcon512Png, }, nightly: { faviconIco: BRAND_ASSET_PATHS.nightlyWebFaviconIco, favicon16Png: BRAND_ASSET_PATHS.nightlyWebFavicon16Png, favicon32Png: BRAND_ASSET_PATHS.nightlyWebFavicon32Png, appleTouchIconPng: BRAND_ASSET_PATHS.nightlyWebAppleTouchIconPng, + icon192Png: BRAND_ASSET_PATHS.nightlyWebIcon192Png, + icon512Png: BRAND_ASSET_PATHS.nightlyWebIcon512Png, }, production: { faviconIco: BRAND_ASSET_PATHS.productionWebFaviconIco, favicon16Png: BRAND_ASSET_PATHS.productionWebFavicon16Png, favicon32Png: BRAND_ASSET_PATHS.productionWebFavicon32Png, appleTouchIconPng: BRAND_ASSET_PATHS.productionWebAppleTouchIconPng, + icon192Png: BRAND_ASSET_PATHS.productionWebIcon192Png, + icon512Png: BRAND_ASSET_PATHS.productionWebIcon512Png, }, } as const satisfies Record>; @@ -96,6 +110,14 @@ export function resolveWebIconOverrides( sourceRelativePath: sourcePaths.appleTouchIconPng, targetRelativePath: `${targetDirectory}/${WEB_ICON_TARGET_FILENAMES.appleTouchIconPng}`, }, + { + sourceRelativePath: sourcePaths.icon192Png, + targetRelativePath: `${targetDirectory}/${WEB_ICON_TARGET_FILENAMES.icon192Png}`, + }, + { + sourceRelativePath: sourcePaths.icon512Png, + targetRelativePath: `${targetDirectory}/${WEB_ICON_TARGET_FILENAMES.icon512Png}`, + }, ]; }