diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76422feb0bbc..668d1fcb59d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release Desktop +name: Release on: push: @@ -9,23 +9,28 @@ on: - cron: "0 */3 * * *" workflow_dispatch: inputs: + channel: + description: "Release channel" + required: false + default: stable + type: choice + options: + - stable + - nightly version: description: "Release version (for example 1.2.3 or v1.2.3)" - required: true + required: false type: string permissions: - contents: write + contents: read id-token: none -env: - T3CODE_RELEASE_REPOSITORY: aaditagrawal/t3code - T3CODE_DESKTOP_UPDATE_REPOSITORY: aaditagrawal/t3code - jobs: check_changes: name: Check for changes since last nightly - runs-on: ubuntu-24.04 + if: github.event_name == 'schedule' + runs-on: blacksmith-8vcpu-ubuntu-2404 outputs: has_changes: ${{ steps.check.outputs.has_changes }} steps: @@ -37,12 +42,6 @@ jobs: - id: check name: Compare HEAD to last nightly tag run: | - if [[ "${GITHUB_EVENT_NAME}" != "schedule" ]]; then - echo "Manual or tag release. Proceeding without nightly change detection." - echo "has_changes=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - last_nightly_tag=$(git tag --list 'v*-nightly.*' 'nightly-v*' --sort=-creatordate | head -n 1) if [[ -z "$last_nightly_tag" ]]; then echo "No previous nightly tag found. Proceeding with release." @@ -67,94 +66,191 @@ jobs: if: | !failure() && !cancelled() && (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') - runs-on: ubuntu-24.04 + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 outputs: + release_channel: ${{ steps.release_meta.outputs.release_channel }} version: ${{ steps.release_meta.outputs.version }} tag: ${{ steps.release_meta.outputs.tag }} - release_name: ${{ steps.release_meta.outputs.release_name }} - release_channel: ${{ steps.release_meta.outputs.release_channel }} - release_repository: ${{ steps.release_meta.outputs.release_repository }} + release_name: ${{ steps.release_meta.outputs.name }} + short_sha: ${{ steps.release_meta.outputs.short_sha }} + previous_tag: ${{ steps.previous_tag.outputs.previous_tag }} + cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }} is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }} make_latest: ${{ steps.release_meta.outputs.make_latest }} ref: ${{ github.sha }} steps: - name: Checkout uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Ensure Electron runtime is installed + run: vp run --filter @t3tools/desktop ensure:electron - id: release_meta name: Resolve release version shell: bash + env: + DISPATCH_CHANNEL: ${{ github.event.inputs.channel }} + DISPATCH_VERSION: ${{ github.event.inputs.version }} + NIGHTLY_DATE: ${{ github.run_started_at }} + NIGHTLY_SHA: ${{ github.sha }} + NIGHTLY_RUN_NUMBER: ${{ github.run_number }} run: | - release_repository="${T3CODE_RELEASE_REPOSITORY:?T3CODE_RELEASE_REPOSITORY is required}" - desktop_update_repository="${T3CODE_DESKTOP_UPDATE_REPOSITORY:?T3CODE_DESKTOP_UPDATE_REPOSITORY is required}" + if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then + nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" - for repository in "$release_repository" "$desktop_update_repository"; do - if [[ ! "$repository" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then - echo "Invalid release repository: $repository" >&2 + node scripts/resolve-nightly-release.ts \ + --date "$nightly_date" \ + --run-number "$NIGHTLY_RUN_NUMBER" \ + --sha "$NIGHTLY_SHA" \ + --github-output + + echo "release_channel=nightly" >> "$GITHUB_OUTPUT" + echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT" + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + echo "make_latest=false" >> "$GITHUB_OUTPUT" + else + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + raw="${DISPATCH_VERSION}" + if [[ -z "$raw" ]]; then + echo "workflow_dispatch stable releases require the version input." >&2 + exit 1 + fi + else + raw="${GITHUB_REF_NAME}" + fi + + version="${raw#v}" + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid release version: $raw" >&2 exit 1 fi - done - if [[ "$GITHUB_REPOSITORY" != "$release_repository" || "$GITHUB_REPOSITORY" != "$desktop_update_repository" ]]; then - echo "This fork release workflow is configured for release repository $release_repository and desktop updater repository $desktop_update_repository, but it is running in $GITHUB_REPOSITORY." >&2 - exit 1 + echo "release_channel=stable" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=v$version" >> "$GITHUB_OUTPUT" + echo "name=T3 Code v$version" >> "$GITHUB_OUTPUT" + echo "cli_dist_tag=latest" >> "$GITHUB_OUTPUT" + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + echo "make_latest=true" >> "$GITHUB_OUTPUT" + else + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + echo "make_latest=false" >> "$GITHUB_OUTPUT" + fi fi - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - raw="${{ github.event.inputs.version }}" - elif [[ "${GITHUB_EVENT_NAME}" == "schedule" ]]; then - # Nightly: derive a unique prerelease version from the current - # package.json base and today's date + run counter so every - # scheduled run produces a valid semver like - # `0.0.21-nightly.20260420.3`. - base_version=$(node -e "console.log(require('./apps/desktop/package.json').version)") - base_version="${base_version%%-*}" - date_stamp=$(date -u +%Y%m%d) - raw="${base_version}-nightly.${date_stamp}.${GITHUB_RUN_NUMBER}" - else - raw="${GITHUB_REF_NAME}" - fi + - name: Check + run: vp check - version="${raw#v}" - if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-nightly\.[0-9]{8}\.[0-9]+$ ]]; then - echo "Invalid release version: $raw" >&2 - exit 1 - fi + - name: Typecheck + run: vp run typecheck - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "tag=v$version" >> "$GITHUB_OUTPUT" - echo "release_repository=$release_repository" >> "$GITHUB_OUTPUT" - if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "release_name=T3 Code v$version" >> "$GITHUB_OUTPUT" - echo "release_channel=stable" >> "$GITHUB_OUTPUT" - echo "is_prerelease=false" >> "$GITHUB_OUTPUT" - echo "make_latest=true" >> "$GITHUB_OUTPUT" - elif [[ "$version" =~ -nightly\.[0-9]{8}\.[0-9]+$ ]]; then - echo "release_name=T3 Code Nightly $version (${GITHUB_SHA::12})" >> "$GITHUB_OUTPUT" - echo "release_channel=nightly" >> "$GITHUB_OUTPUT" - echo "is_prerelease=true" >> "$GITHUB_OUTPUT" - echo "make_latest=false" >> "$GITHUB_OUTPUT" - else - echo "Unsupported prerelease version: $version" >&2 - exit 1 - fi + - name: Test + run: vp run test + + - id: previous_tag + name: Resolve previous release tag + run: | + node scripts/resolve-previous-release-tag.ts \ + --channel "${{ steps.release_meta.outputs.release_channel }}" \ + --current-tag "${{ steps.release_meta.outputs.tag }}" \ + --github-output + + relay_public_config: + name: Resolve T3 Connect public config + needs: preflight + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 5 + environment: + name: production + outputs: + clerk_publishable_key: ${{ steps.public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ steps.public_config.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ steps.public_config.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ steps.public_config.outputs.relay_url }} + env: + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }} + RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }} + CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} + CLERK_JWT_TEMPLATE: ${{ vars.CLERK_JWT_TEMPLATE }} + CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.CLERK_CLI_OAUTH_CLIENT_ID }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=t3code-relay... - - name: Check - run: vp check + - id: relay_state + name: Read production relay tracing config + shell: bash + run: | + vp run --filter t3code-relay deploy \ + --stage prod \ + --read-state \ + --github-output \ + --github-env-file "$RUNNER_TEMP/relay-client-tracing.env" - - name: Typecheck - run: vp run typecheck + - name: Upload relay client tracing config + uses: actions/upload-artifact@v7 + with: + name: relay-client-tracing-config + path: ${{ runner.temp }}/relay-client-tracing.env + if-no-files-found: error + retention-days: 1 - - name: Test - run: vp run --filter './apps/*' --filter './packages/*' --filter '!@t3tools/desktop' test + - id: public_config + name: Resolve production relay public config + shell: bash + run: | + set -euo pipefail + + relay_domain="${RELAY_DOMAIN:-}" + if [[ -z "$relay_domain" && -n "${RELAY_API_ZONE_NAME:-}" ]]; then + relay_domain="relay.$RELAY_API_ZONE_NAME" + fi + required=( + relay_domain + CLERK_PUBLISHABLE_KEY + CLERK_JWT_TEMPLATE + CLERK_CLI_OAUTH_CLIENT_ID + ) + missing=() + for name in "${required[@]}"; do + if [[ -z "${!name:-}" ]]; then + missing+=("$name") + fi + done + if (( ${#missing[@]} > 0 )); then + printf 'Missing required relay deployment configuration: %s\n' "${missing[*]}" >&2 + exit 1 + fi + + echo "clerk_publishable_key=$CLERK_PUBLISHABLE_KEY" >> "$GITHUB_OUTPUT" + echo "clerk_jwt_template=$CLERK_JWT_TEMPLATE" >> "$GITHUB_OUTPUT" + echo "clerk_cli_oauth_client_id=$CLERK_CLI_OAUTH_CLIENT_ID" >> "$GITHUB_OUTPUT" + echo "relay_url=https://$relay_domain" >> "$GITHUB_OUTPUT" # node-pty publishes no Linux prebuilt and the WSL backend runs under the # distro's own (Linux) Node, which can't load the Windows/Electron binary. We @@ -166,11 +262,7 @@ jobs: name: Build WSL node-pty (linux-x64) needs: [preflight] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} - # Build against Debian bullseye glibc (≈ Ubuntu 20.04) so the bundled WSL - # native addon loads on common Ubuntu 20.04/22.04 WSL distros. N-API covers - # Node ABI, not libc ABI. Use a standard GitHub-hosted runner + Node bullseye - # container instead of Blacksmith Ubuntu 2004 labels, which can queue forever. - runs-on: ubuntu-24.04 + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 15 steps: - name: Checkout @@ -191,16 +283,11 @@ jobs: run: | set -euo pipefail # Resolve node-pty from apps/server (where it's a dependency) and build - # its native binary from source for Linux against an older glibc. node- - # addon-api resolves from node-pty's own dependency tree inside the - # mounted workspace, so node-gyp has everything it needs. + # its native binary from source for Linux. node-addon-api resolves from + # node-pty's own dependency tree, so node-gyp has everything it needs. pty_pkg="$(node -e "console.log(require.resolve('node-pty/package.json', { paths: ['$GITHUB_WORKSPACE/apps/server'] }))")" pty_dir="$(dirname "$pty_pkg")" - docker run --rm \ - -v "$GITHUB_WORKSPACE:$GITHUB_WORKSPACE" \ - -w "$pty_dir" \ - node:24.13.1-bullseye \ - bash -lc 'apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq build-essential python3 >/dev/null && npx --yes node-gyp rebuild' + ( cd "$pty_dir" && npx --yes node-gyp rebuild ) mkdir -p wsl-prebuild cp "$pty_dir/build/Release/pty.node" wsl-prebuild/pty.node file wsl-prebuild/pty.node @@ -212,189 +299,53 @@ jobs: path: wsl-prebuild/pty.node if-no-files-found: error - build_android_apk: - name: Build Android APK - needs: [preflight] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} - runs-on: ubuntu-24.04 - timeout-minutes: 45 - environment: production - permissions: - contents: read - env: - APP_VARIANT: production - EXPO_NO_GIT_STATUS: "1" - MOBILE_APP_VERSION: ${{ needs.preflight.outputs.version }} - MOBILE_ANDROID_VERSION_CODE: ${{ github.run_number }} - NODE_OPTIONS: --max-old-space-size=8192 - T3CODE_CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} - T3CODE_CLERK_JWT_TEMPLATE: ${{ vars.CLERK_JWT_TEMPLATE }} - T3CODE_RELAY_URL: ${{ vars.RELAY_DOMAIN }} - RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }} - EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID: ${{ vars.EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID }} - EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID: ${{ vars.EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.outputs.ref }} - fetch-depth: 0 - - - name: Setup Java - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: "17" - - - name: Setup Android SDK - uses: android-actions/setup-android@v4 - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: true - - - name: Prebuild and compile signed APK - shell: bash - env: - ANDROID_RELEASE_KEYSTORE_BASE64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_BASE64 }} - ANDROID_RELEASE_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }} - ANDROID_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }} - ANDROID_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }} - run: | - set -euo pipefail - - signing_values=( - "$ANDROID_RELEASE_KEYSTORE_BASE64" - "$ANDROID_RELEASE_KEYSTORE_PASSWORD" - "$ANDROID_RELEASE_KEY_ALIAS" - "$ANDROID_RELEASE_KEY_PASSWORD" - ) - configured=0 - for value in "${signing_values[@]}"; do - if [[ -n "$value" ]]; then - configured=$((configured + 1)) - fi - done - - keystore="$RUNNER_TEMP/t3code-android-release.p12" - if [[ "$configured" -eq 4 ]]; then - printf '%s' "$ANDROID_RELEASE_KEYSTORE_BASE64" | base64 --decode > "$keystore" - store_password="$ANDROID_RELEASE_KEYSTORE_PASSWORD" - key_alias="$ANDROID_RELEASE_KEY_ALIAS" - key_password="$ANDROID_RELEASE_KEY_PASSWORD" - echo "Using the configured persistent Android release key." - elif [[ "$configured" -eq 0 ]]; then - store_password="$(openssl rand -hex 32)" - key_password="$store_password" - key_alias="t3code-release" - keytool -genkeypair \ - -keystore "$keystore" \ - -storetype PKCS12 \ - -storepass "$store_password" \ - -alias "$key_alias" \ - -keypass "$key_password" \ - -keyalg RSA \ - -keysize 4096 \ - -validity 3650 \ - -dname "CN=T3 Code CI, OU=Ephemeral Android Build, O=T3 Code" - echo "::warning::Android signing secrets are absent. This APK is installable, but its ephemeral key cannot upgrade APKs from another release. Configure the documented signing secrets before distributing production updates." - else - echo "Android release signing is only partially configured. Set all four ANDROID_RELEASE_* secrets or remove all four to use an ephemeral installable build." >&2 - exit 1 - fi - - chmod 600 "$keystore" - export ORG_GRADLE_PROJECT_T3CODE_ANDROID_KEYSTORE_FILE="$keystore" - export ORG_GRADLE_PROJECT_T3CODE_ANDROID_KEYSTORE_PASSWORD="$store_password" - export ORG_GRADLE_PROJECT_T3CODE_ANDROID_KEY_ALIAS="$key_alias" - export ORG_GRADLE_PROJECT_T3CODE_ANDROID_KEY_PASSWORD="$key_password" - - if [[ -z "${T3CODE_RELAY_URL:-}" && -n "${RELAY_API_ZONE_NAME:-}" ]]; then - export T3CODE_RELAY_URL="https://relay.${RELAY_API_ZONE_NAME}" - elif [[ -n "${T3CODE_RELAY_URL:-}" && ! "$T3CODE_RELAY_URL" =~ ^https?:// ]]; then - export T3CODE_RELAY_URL="https://${T3CODE_RELAY_URL}" - fi - - vp exec --filter @t3tools/mobile -- expo prebuild --clean --platform android --no-install - apps/mobile/android/gradlew \ - --project-dir apps/mobile/android \ - :app:assembleRelease \ - --no-daemon \ - --stacktrace - - shopt -s nullglob - apks=(apps/mobile/android/app/build/outputs/apk/release/*.apk) - if [[ "${#apks[@]}" -ne 1 ]]; then - echo "Expected exactly one release APK, found ${#apks[@]}." >&2 - exit 1 - fi - - mkdir -p release-publish - cp "${apks[0]}" "release-publish/T3-Code-${MOBILE_APP_VERSION}-android.apk" - - - name: Verify APK signature and metadata - shell: bash - run: | - set -euo pipefail - apk="release-publish/T3-Code-${MOBILE_APP_VERSION}-android.apk" - build_tools="$(find "$ANDROID_HOME/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1)" - - "$build_tools/apksigner" verify --verbose --print-certs "$apk" - badging="$($build_tools/aapt dump badging "$apk" | head -n 1)" - echo "$badging" - grep -Fq "package: name='com.t3tools.t3code'" <<< "$badging" - grep -Fq "versionCode='${MOBILE_ANDROID_VERSION_CODE}'" <<< "$badging" - grep -Fq "versionName='${MOBILE_APP_VERSION}'" <<< "$badging" - - - name: Upload Android APK - uses: actions/upload-artifact@v7 - with: - name: android-apk - path: release-publish/*.apk - if-no-files-found: error - build: name: Build ${{ matrix.label }} # build_wsl_node_pty stays in `needs` so it runs first and its artifact is # available to download, but only the Windows matrix entry consumes it. We - # gate the job on preflight only. `!cancelled()` (not `!failure()`) lets - # macOS/Linux builds run even when build_wsl_node_pty failed; the - # Windows-only download step below then fails that single platform if the - # prebuild is missing. - needs: [preflight, build_wsl_node_pty] - if: ${{ !cancelled() && needs.preflight.result == 'success' }} + # therefore gate the job on preflight + relay (must succeed) WITHOUT requiring + # build_wsl_node_pty, so a failed Linux prebuild doesn't skip the macOS/Linux + # builds. `!cancelled()` (not `!failure()`) lets the job run even when + # build_wsl_node_pty failed; the Windows-only download step below then fails + # that single platform if the prebuild is missing. + needs: [preflight, relay_public_config, build_wsl_node_pty] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} runs-on: ${{ matrix.runner }} timeout-minutes: 30 + 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 }} + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} strategy: fail-fast: false matrix: include: - label: macOS arm64 - runner: macos-14 + runner: blacksmith-12vcpu-macos-26 platform: mac target: dmg arch: arm64 - label: macOS x64 - runner: macos-15-intel + runner: blacksmith-12vcpu-macos-26 platform: mac target: dmg arch: x64 - label: Linux x64 - runner: ubuntu-24.04 + runner: blacksmith-32vcpu-ubuntu-2404 platform: linux target: AppImage arch: x64 - label: Windows x64 - runner: windows-2022 + runner: blacksmith-32vcpu-windows-2025 platform: win target: nsis arch: x64 + # - label: Windows arm64 + # runner: windows-11-arm + # platform: win + # target: nsis + # arch: arm64 steps: - name: Checkout uses: actions/checkout@v6 @@ -409,6 +360,20 @@ jobs: cache: true run-install: true + - name: Download relay client tracing config + uses: actions/download-artifact@v8 + with: + name: relay-client-tracing-config + path: ${{ runner.temp }}/relay-client-tracing + + - name: Load relay client tracing config + shell: bash + run: | + config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" + tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" + echo "::add-mask::$tracing_token" + cat "$config_path" >> "$GITHUB_ENV" + - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" @@ -532,12 +497,6 @@ jobs: AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} run: | - # electron-builder walks large trees while packing; macOS runners often - # hit EMFILE without a higher soft limit. - if [[ "${{ matrix.platform }}" == "mac" ]]; then - ulimit -n 10240 || true - fi - args=( --platform "${{ matrix.platform }}" --target "${{ matrix.target }}" @@ -614,21 +573,31 @@ jobs: "release/*.AppImage" \ "release/*.exe" \ "release/*.blockmap" \ - "release/latest*.yml" \ - "release/nightly*.yml"; do + "release/*.yml"; do for file in $pattern; do cp "$file" release-publish/ done done if [[ "${{ matrix.platform }}" == "mac" && "${{ matrix.arch }}" != "arm64" ]]; then - for channel in latest nightly; do - if [[ -f "release-publish/${channel}-mac.yml" ]]; then - mv "release-publish/${channel}-mac.yml" "release-publish/${channel}-mac-${{ matrix.arch }}.yml" - fi + shopt -s nullglob + for manifest in release-publish/*-mac.yml; do + mv "$manifest" "${manifest%.yml}-${{ matrix.arch }}.yml" done fi + # Enable if Windows arm64 builds are enabled. + # Windows updater metadata is channel-specific (for example + # "latest.yml" or "nightly.yml"). Suffix each per-arch copy so the + # release job can merge matching arm64/x64 manifests back into one + # canonical manifest per channel. + # if [[ "${{ matrix.platform }}" == "win" ]]; then + # shopt -s nullglob + # for manifest in release-publish/*.yml; do + # mv "$manifest" "${manifest%.yml}-win-${{ matrix.arch }}.yml" + # done + # fi + - name: Upload build artifacts uses: actions/upload-artifact@v7 with: @@ -636,15 +605,78 @@ jobs: path: release-publish/* if-no-files-found: error + publish_cli: + name: Publish CLI to npm + needs: [preflight, relay_public_config, build] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build.result == 'success' }} + runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + permissions: + contents: read + id-token: write + 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 }} + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=t3... + - --filter=@t3tools/web... + - --filter=@t3tools/scripts... + + - name: Download relay client tracing config + uses: actions/download-artifact@v8 + with: + name: relay-client-tracing-config + path: ${{ runner.temp }}/relay-client-tracing + + - name: Load relay client tracing config + shell: bash + run: | + config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" + tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" + echo "::add-mask::$tracing_token" + cat "$config_path" >> "$GITHUB_ENV" + + - name: Align package versions to release version + run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + + - name: Build web package + run: vp run --filter @t3tools/web build + + - name: Build CLI package + run: vp run --filter t3 build + + - name: Publish CLI package + run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose + release: name: Publish GitHub Release - needs: [preflight, build, build_android_apk] - runs-on: ubuntu-24.04 + needs: [preflight, build, publish_cli] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && needs.publish_cli.result == 'success' }} + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 - permissions: - contents: write - id-token: none steps: + - id: app_token + name: Mint release app token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + - name: Checkout uses: actions/checkout@v6 with: @@ -655,7 +687,9 @@ jobs: with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=@t3tools/scripts... - name: Download all desktop artifacts uses: actions/download-artifact@v8 @@ -664,45 +698,55 @@ jobs: merge-multiple: true path: release-assets - - name: Download Android APK - uses: actions/download-artifact@v8 - with: - name: android-apk - path: release-assets - - name: Merge macOS updater manifests run: | - set -euo pipefail shopt -s nullglob - - found_mac_manifest=false for x64_manifest in release-assets/*-mac-x64.yml; do - arm64_manifest="${x64_manifest/-x64.yml/.yml}" - if [[ ! -f "$arm64_manifest" ]]; then - echo "Missing matching arm64 macOS manifest for $x64_manifest" >&2 - exit 1 + arm64_manifest="${x64_manifest%-x64.yml}.yml" + if [[ -f "$arm64_manifest" ]]; then + node scripts/merge-update-manifests.ts --platform mac "$arm64_manifest" "$x64_manifest" + rm -f "$x64_manifest" fi - - found_mac_manifest=true - node scripts/merge-update-manifests.ts --platform mac \ - "$arm64_manifest" \ - "$x64_manifest" - rm -f "$x64_manifest" done - if [[ "$found_mac_manifest" != true ]]; then - echo "No macOS updater manifests found to merge." >&2 - exit 1 - fi + # - name: Merge Windows updater manifests + # run: | + # shopt -s nullglob + # found_windows_manifest=false + # for x64_manifest in release-assets/*-win-x64.yml; do + # if [[ "$(basename "$x64_manifest")" == builder-debug-* ]]; then + # continue + # fi + + # arm64_manifest="${x64_manifest/-x64.yml/-arm64.yml}" + # output_manifest="${x64_manifest/-win-x64.yml/.yml}" + # if [[ ! -f "$arm64_manifest" ]]; then + # echo "Missing matching arm64 Windows manifest for $x64_manifest" >&2 + # exit 1 + # fi + + # found_windows_manifest=true + # node scripts/merge-update-manifests.ts --platform win \ + # "$arm64_manifest" \ + # "$x64_manifest" \ + # "$output_manifest" + # rm -f "$arm64_manifest" "$x64_manifest" + # done + + # if [[ "$found_windows_manifest" != true ]]; then + # echo "No Windows updater manifests found to merge." >&2 + # exit 1 + # fi - name: Publish release + if: needs.preflight.outputs.previous_tag != '' uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} name: ${{ needs.preflight.outputs.release_name }} - repository: ${{ needs.preflight.outputs.release_repository }} generate_release_notes: true + previous_tag: ${{ needs.preflight.outputs.previous_tag }} prerelease: ${{ needs.preflight.outputs.is_prerelease }} make_latest: ${{ needs.preflight.outputs.make_latest }} files: | @@ -712,17 +756,40 @@ jobs: release-assets/*.exe release-assets/*.blockmap release-assets/*.yml - release-assets/*.apk fail_on_unmatched_files: true + token: ${{ steps.app_token.outputs.token }} + + - name: Publish first release + if: needs.preflight.outputs.previous_tag == '' + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.preflight.outputs.tag }} + target_commitish: ${{ needs.preflight.outputs.ref }} + name: ${{ needs.preflight.outputs.release_name }} + generate_release_notes: true + prerelease: ${{ needs.preflight.outputs.is_prerelease }} + make_latest: ${{ needs.preflight.outputs.make_latest }} + files: | + release-assets/*.dmg + release-assets/*.zip + release-assets/*.AppImage + release-assets/*.exe + release-assets/*.blockmap + release-assets/*.yml + fail_on_unmatched_files: true + token: ${{ steps.app_token.outputs.token }} deploy_web: name: Deploy hosted web app - needs: [preflight, release] - # Fork: enable once this fork has its own hosted app deployment configured. - if: false - runs-on: ubuntu-24.04 + needs: [preflight, relay_public_config, release] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.release.result == 'success' }} + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 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 }} + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} @@ -741,7 +808,24 @@ jobs: with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=@t3tools/scripts... + - --filter=@t3tools/web... + + - name: Download relay client tracing config + uses: actions/download-artifact@v8 + with: + name: relay-client-tracing-config + path: ${{ runner.temp }}/relay-client-tracing + + - name: Load relay client tracing config + shell: bash + run: | + config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" + tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" + echo "::add-mask::$tracing_token" + cat "$config_path" >> "$GITHUB_ENV" - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" @@ -786,6 +870,13 @@ jobs: --token "$VERCEL_TOKEN" \ "${vercel_scope_args[@]}" \ --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \ + --build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \ + --build-env "T3CODE_CLERK_JWT_TEMPLATE=${T3CODE_CLERK_JWT_TEMPLATE:-}" \ + --build-env "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=${T3CODE_CLERK_CLI_OAUTH_CLIENT_ID:-}" \ + --build-env "T3CODE_RELAY_URL=${T3CODE_RELAY_URL:-}" \ + --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=${T3CODE_RELAY_CLIENT_OTLP_TRACES_URL:-}" \ + --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=${T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET:-}" \ + --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=${T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN:-}" \ --build-env "VITE_HOSTED_APP_URL=$router_url" \ --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name" )" @@ -804,11 +895,9 @@ jobs: finalize: name: Finalize release + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'stable' }} needs: [preflight, release] - # secrets context is not available in job-level `if`. Use an env/output - # from a prior job, or simply always run and let the token step fail-fast. - if: false # Fork: enable once RELEASE_APP_ID secret is configured - runs-on: ubuntu-24.04 + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: - id: app_token @@ -842,7 +931,10 @@ jobs: with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=@t3tools/scripts... + - --filter=@t3tools/oxlint-plugin-t3code... - id: update_versions name: Update version strings @@ -875,3 +967,58 @@ jobs: git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml git commit -m "chore(release): prepare $RELEASE_TAG" git push origin HEAD:main + + announce_discord: + name: Announce release on Discord + if: | + always() && !cancelled() && + needs.preflight.result == 'success' && + needs.relay_public_config.result == 'success' && + needs.release.result == 'success' && + needs.deploy_web.result == 'success' && + (needs.finalize.result == 'success' || needs.finalize.result == 'skipped') + needs: [preflight, relay_public_config, release, deploy_web, finalize] + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/scripts... + + - name: Announce prerelease on Discord + if: needs.preflight.outputs.is_prerelease == 'true' + continue-on-error: true + env: + DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_NIGHTLY_ROLE_ID }} + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} + run: | + node scripts/notify-discord-release.ts prerelease \ + --role-id "$DISCORD_MENTION_ROLE_ID" \ + --release-name "${{ needs.preflight.outputs.release_name }}" \ + --release-version "${{ needs.preflight.outputs.version }}" \ + --tag "${{ needs.preflight.outputs.tag }}" \ + --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}" + + - name: Announce latest release on Discord + if: needs.preflight.outputs.make_latest == 'true' + continue-on-error: true + env: + DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_LATEST_ROLE_ID }} + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} + run: | + node scripts/notify-discord-release.ts latest \ + --role-id "$DISCORD_MENTION_ROLE_ID" \ + --release-name "${{ needs.preflight.outputs.release_name }}" \ + --release-version "${{ needs.preflight.outputs.version }}" \ + --tag "${{ needs.preflight.outputs.tag }}" \ + --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}" diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index c5264332b661..f8e05915718e 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -73,8 +73,14 @@ function addScopedListener>( } const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")( - function* (): Effect.fn.Return { + function* (): Effect.fn.Return< + void, + never, + DesktopShutdown.DesktopShutdown | DesktopWindow.DesktopWindow + > { const shutdown = yield* DesktopShutdown.DesktopShutdown; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.flushMainWindowBounds; yield* shutdown.request; yield* shutdown.awaitComplete; }, diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 5e6a3f5164d3..fa0811d5df7b 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -76,6 +76,7 @@ function makePoolLayer( showConnectingSplash: Effect.void, handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, dispatchMenuAction: () => Effect.die("unexpected menu action"), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]), diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 5e6849e461e8..9bba1177395a 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -293,6 +293,7 @@ describe("DesktopServerExposure", () => { const settingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), setServerExposureMode: () => Effect.fail(settingsFailure), setTailscaleServe: () => Effect.fail(settingsFailure), setUpdateChannel: () => Effect.die("unexpected update channel change"), diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 70b267982668..3878b0e36ad0 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -11,6 +11,17 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "./DesktopAppSettings.ts"; const DesktopSettingsPatch = Schema.Struct({ + mainWindowBounds: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, + }), + ), + ), + mainWindowMaximized: Schema.optionalKey(Schema.Boolean), serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), @@ -91,6 +102,8 @@ describe("DesktopSettings", () => { assert.deepEqual( DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { + mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -116,6 +129,8 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -215,10 +230,13 @@ describe("DesktopSettings", () => { "serverExposureMode": "network-accessible", "tailscaleServeEnabled": true, "tailscaleServePort": 8443, + "mainWindowBounds": { "x": 120, "y": 80, "width": 1280, "height": 900 }, }\n`, ); assert.deepEqual(yield* settings.load, { + mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, + mainWindowMaximized: false, serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -232,6 +250,24 @@ describe("DesktopSettings", () => { ), ); + it.effect("rejects window bounds that do not satisfy the domain schema", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* writeSettingsPatch({ + mainWindowBounds: { x: 10.5, y: 20, width: 839, height: 620 }, + mainWindowMaximized: true, + serverExposureMode: "network-accessible", + }); + + const loaded = yield* settings.load; + assert.isNull(loaded.mainWindowBounds); + assert.isFalse(loaded.mainWindowMaximized); + assert.equal(loaded.serverExposureMode, "network-accessible"); + }), + ), + ); + it.effect("persists sparse desktop settings documents", () => withSettings( Effect.gen(function* () { @@ -239,12 +275,15 @@ describe("DesktopSettings", () => { const fileSystem = yield* FileSystem.FileSystem; const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }, true); yield* settings.setServerExposureMode("network-accessible"); const persisted = yield* decodeDesktopSettingsPatch( yield* fileSystem.readFileString(environment.desktopSettingsPath), ); assert.deepEqual(persisted, { + mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 }, + mainWindowMaximized: true, serverExposureMode: "network-accessible", } satisfies typeof DesktopSettingsPatch.Type); }), @@ -261,6 +300,8 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -286,6 +327,8 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -310,6 +353,8 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: true, tailscaleServePort: 443, diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 6a26bf5a6e26..466c9a9b5f8a 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -20,6 +20,8 @@ import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { + readonly mainWindowBounds: DesktopWindowBounds | null; + readonly mainWindowMaximized: boolean; readonly serverExposureMode: DesktopServerExposureMode; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; @@ -48,8 +50,25 @@ export interface DesktopSettingsChange { } export const DEFAULT_TAILSCALE_SERVE_PORT = 443; +const MIN_MAIN_WINDOW_SIZE = { + width: 840, + height: 620, +} as const; +export const DesktopWindowBoundsSchema = Schema.Struct({ + x: Schema.Int, + y: Schema.Int, + width: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.width)), + height: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.height)), +}); +export type DesktopWindowBounds = typeof DesktopWindowBoundsSchema.Type; +export const DEFAULT_MAIN_WINDOW_SIZE = { + width: 1100, + height: 780, +} as const; export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, @@ -60,7 +79,16 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { wslOnly: false, }; +const DesktopWindowBoundsDocument = Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, +}); + const DesktopSettingsDocument = Schema.Struct({ + mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), + mainWindowMaximized: Schema.optionalKey(Schema.Boolean), serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), @@ -81,6 +109,8 @@ type Mutable = { -readonly [K in keyof T]: T[K] }; const DesktopSettingsJson = fromLenientJson(DesktopSettingsDocument); const decodeDesktopSettingsJson = Schema.decodeEffect(DesktopSettingsJson); const encodeDesktopSettingsJson = Schema.encodeEffect(DesktopSettingsJson); +const decodeDesktopWindowBounds = Schema.decodeUnknownOption(DesktopWindowBoundsSchema); +const desktopWindowBoundsEquivalence = Schema.toEquivalence(DesktopWindowBoundsSchema); const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSettingsChange => ({ settings, @@ -114,6 +144,10 @@ export class DesktopAppSettings extends Context.Service< { readonly load: Effect.Effect; readonly get: Effect.Effect; + readonly setMainWindowBounds: ( + bounds: DesktopWindowBounds, + isMaximized: boolean, + ) => Effect.Effect; readonly setServerExposureMode: ( mode: DesktopServerExposureMode, ) => Effect.Effect; @@ -158,11 +192,16 @@ function normalizeWslDistro(value: unknown): string | null { return typeof value === "string" && isValidDistroName(value) ? value : null; } +export function normalizeMainWindowBounds(value: unknown): DesktopWindowBounds | null { + return Option.getOrNull(decodeDesktopWindowBounds(value)); +} + function normalizeDesktopSettingsDocument( parsed: DesktopSettingsDocument, appVersion: string, ): DesktopSettings { const defaultSettings = resolveDefaultDesktopSettings(appVersion); + const mainWindowBounds = normalizeMainWindowBounds(parsed.mainWindowBounds); const parsedUpdateChannel = Option.fromNullishOr(parsed.updateChannel); const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined; const updateChannelConfiguredByUser = @@ -177,6 +216,8 @@ function normalizeDesktopSettingsDocument( (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); return { + mainWindowBounds, + mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, serverExposureMode: parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, @@ -197,6 +238,12 @@ function toDesktopSettingsDocument( ): DesktopSettingsDocument { const document: Mutable = {}; + if (settings.mainWindowBounds !== null) { + document.mainWindowBounds = settings.mainWindowBounds; + } + if (settings.mainWindowMaximized) { + document.mainWindowMaximized = true; + } if (settings.serverExposureMode !== defaults.serverExposureMode) { document.serverExposureMode = settings.serverExposureMode; } @@ -237,6 +284,22 @@ function setServerExposureMode( }; } +function setMainWindowBounds( + settings: DesktopSettings, + bounds: DesktopWindowBounds, + isMaximized: boolean, +): DesktopSettings { + return settings.mainWindowBounds !== null && + desktopWindowBoundsEquivalence(settings.mainWindowBounds, bounds) && + settings.mainWindowMaximized === isMaximized + ? settings + : { + ...settings, + mainWindowBounds: bounds, + mainWindowMaximized: isMaximized, + }; +} + function setTailscaleServe( settings: DesktopSettings, input: { readonly enabled: boolean; readonly port: Option.Option }, @@ -431,6 +494,18 @@ export const make = Effect.gen(function* () { ); return yield* SynchronizedRef.setAndGet(settingsRef, settings); }).pipe(Effect.withSpan("desktop.settings.load")), + setMainWindowBounds: (bounds, isMaximized) => + persist((settings) => setMainWindowBounds(settings, bounds, isMaximized)).pipe( + Effect.withSpan("desktop.settings.setMainWindowBounds", { + attributes: { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + isMaximized, + }, + }), + ), setServerExposureMode: (mode) => persist((settings) => setServerExposureMode(settings, mode)).pipe( Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), @@ -488,6 +563,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET return DesktopAppSettings.of({ get: SynchronizedRef.get(settingsRef), load: SynchronizedRef.get(settingsRef), + setMainWindowBounds: (bounds, isMaximized) => + update((settings) => setMainWindowBounds(settings, bounds, isMaximized)), setServerExposureMode: (mode) => update((settings) => setServerExposureMode(settings, mode)), setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 5ae92bbee963..32224c7a5ca0 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -158,6 +158,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, { get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), setServerExposureMode: () => Effect.die("unexpected server exposure update"), setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), setUpdateChannel: () => Effect.fail(setUpdateChannelError), diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index ba77292fdc80..168846466ed7 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -76,6 +76,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => showConnectingSplash: Effect.void, handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 01b21936eff7..14d0ce01ebf2 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -1,12 +1,17 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as References from "effect/References"; +import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; -import type * as Electron from "electron"; +import * as Electron from "electron"; import { vi } from "vite-plus/test"; vi.mock("electron", async (importOriginal) => ({ @@ -18,13 +23,20 @@ vi.mock("electron", async (importOriginal) => ({ setUserAgent: vi.fn(), })), }, + screen: { + getAllDisplays: vi.fn(() => [ + { + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + }, + ]), + }, })); import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopState from "../app/DesktopState.ts"; -import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; @@ -67,15 +79,21 @@ function makeFakeBrowserWindow() { const window = { close: vi.fn(), focus: vi.fn(), + getBounds: vi.fn(() => ({ x: 0, y: 0, width: 1100, height: 780 })), + getNormalBounds: vi.fn(() => ({ x: 0, y: 0, width: 1100, height: 780 })), isDestroyed: vi.fn(() => false), isFullScreen: vi.fn(() => false), + isMaximized: vi.fn(() => false), isMinimized: vi.fn(() => false), isVisible: vi.fn(() => true), loadURL: vi.fn(() => Promise.resolve()), + maximize: vi.fn(), on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { windowListeners.set(eventName, listener); }), - once: vi.fn(), + once: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { + windowListeners.set(eventName, listener); + }), restore: vi.fn(), setBackgroundColor: vi.fn(), setAutoHideCursor: vi.fn(), @@ -87,7 +105,14 @@ function makeFakeBrowserWindow() { return { window: window as unknown as Electron.BrowserWindow, + getBounds: window.getBounds, + getNormalBounds: window.getNormalBounds, + isDestroyed: window.isDestroyed, + isFullScreen: window.isFullScreen, + isMaximized: window.isMaximized, + isMinimized: window.isMinimized, loadURL: window.loadURL, + maximize: window.maximize, openDevTools: webContents.openDevTools, reload: webContents.reload, send: webContents.send, @@ -121,11 +146,6 @@ const desktopServerExposureLayer = Layer.succeed(DesktopServerExposure.DesktopSe getAdvertisedEndpoints: Effect.die("unexpected getAdvertisedEndpoints"), } satisfies DesktopServerExposure.DesktopServerExposure["Service"]); -const electronProtocolLayer = Layer.succeed(ElectronProtocol.ElectronProtocol, { - registerDesktopProtocol: () => Effect.void, - updateDesktopProtocolTargetOrigin: () => Effect.void, -} satisfies ElectronProtocol.ElectronProtocol["Service"]); - const electronMenuLayer = Layer.succeed(ElectronMenu.ElectronMenu, { setApplicationMenu: () => Effect.void, popupTemplate: () => Effect.void, @@ -150,13 +170,57 @@ const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe( ), ); +const desktopWindowBoundsEquivalence = Schema.toEquivalence( + DesktopAppSettings.DesktopWindowBoundsSchema, +); + function makeTestLayer(input: { readonly window: Electron.BrowserWindow; readonly createCount: Ref.Ref; readonly mainWindow: Ref.Ref>; readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[]; + readonly desktopSettings?: DesktopAppSettings.DesktopSettings; + readonly mainWindowBoundsUpdates?: DesktopAppSettings.DesktopWindowBounds[]; + readonly mainWindowMaximizedUpdates?: boolean[]; + readonly beforeMainWindowBoundsUpdate?: ( + bounds: DesktopAppSettings.DesktopWindowBounds, + ) => Effect.Effect; readonly openedExternalUrls?: unknown[]; }) { + let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; + const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { + get: Effect.sync(() => desktopSettings), + load: Effect.sync(() => desktopSettings), + setMainWindowBounds: (bounds, isMaximized) => + Effect.gen(function* () { + if (input.beforeMainWindowBoundsUpdate) { + yield* input.beforeMainWindowBoundsUpdate(bounds); + } + const changed = + desktopSettings.mainWindowBounds === null || + !desktopWindowBoundsEquivalence(desktopSettings.mainWindowBounds, bounds) || + desktopSettings.mainWindowMaximized !== isMaximized; + if (changed) { + desktopSettings = { + ...desktopSettings, + mainWindowBounds: bounds, + mainWindowMaximized: isMaximized, + }; + input.mainWindowBoundsUpdates?.push(bounds); + input.mainWindowMaximizedUpdates?.push(isMaximized); + } + return { settings: desktopSettings, changed }; + }), + setServerExposureMode: () => Effect.die("unexpected server exposure update"), + setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), + setUpdateChannel: () => Effect.die("unexpected update channel change"), + setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), + setWslDistro: () => Effect.die("unexpected WSL distro change"), + setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), + applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), + } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); + const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { create: (options) => Effect.sync(() => { @@ -181,9 +245,9 @@ function makeTestLayer(input: { Layer.mergeAll( desktopAssetsLayer, desktopEnvironmentLayer, + desktopAppSettingsLayer, desktopServerExposureLayer, DesktopState.layer, - electronProtocolLayer, electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: (url) => @@ -280,8 +344,8 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n Layer.mergeAll( desktopAssetsLayer, desktopEnvironmentLayer, + DesktopAppSettings.layerTest(), desktopServerExposureLayer, - electronProtocolLayer, electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: () => Effect.succeed(true), @@ -304,6 +368,23 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n }); describe("DesktopWindow", () => { + it("restores bounds only when the window fits within a connected display", () => { + const persistedBounds = { x: 2040, y: 80, width: 1320, height: 880 }; + const displays = [ + { x: 0, y: 0, width: 1920, height: 1080 }, + { x: 1920, y: 0, width: 2560, height: 1440 }, + ]; + + assert.deepEqual( + DesktopWindow.resolveInitialMainWindowBounds(persistedBounds, displays), + persistedBounds, + ); + assert.deepEqual( + DesktopWindow.resolveInitialMainWindowBounds(persistedBounds, [displays[0]!]), + DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE, + ); + }); + it("recognizes only same-origin renderer navigations", () => { assert.isTrue( DesktopWindow.isSameOriginRendererNavigation({ @@ -345,6 +426,10 @@ describe("DesktopWindow", () => { yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); assert.equal(yield* Ref.get(createCount), 1); + assert.equal(createdWindowOptions[0]?.width, 1100); + assert.equal(createdWindowOptions[0]?.height, 780); + assert.isUndefined(createdWindowOptions[0]?.x); + assert.isUndefined(createdWindowOptions[0]?.y); assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); @@ -353,6 +438,427 @@ describe("DesktopWindow", () => { }), ); + it.effect("uses the persisted main window bounds when opening the window", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + createdWindowOptions, + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + mainWindowBounds: { x: 120, y: 80, width: 1320, height: 880 }, + }, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + assert.equal(createdWindowOptions[0]?.width, 1320); + assert.equal(createdWindowOptions[0]?.height, 880); + assert.equal(createdWindowOptions[0]?.x, 120); + assert.equal(createdWindowOptions[0]?.y, 80); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("restores the persisted maximized state", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + mainWindowBounds: { x: 120, y: 80, width: 1320, height: 880 }, + mainWindowMaximized: true, + }, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + assert.equal(fakeWindow.maximize.mock.calls.length, 0); + const readyToShow = fakeWindow.windowListeners.get("ready-to-show"); + if (!readyToShow) { + return yield* Effect.die("window ready-to-show listener was not registered"); + } + readyToShow(); + assert.equal(fakeWindow.maximize.mock.calls.length, 1); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("debounces move and resize bounds updates", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const move = fakeWindow.windowListeners.get("move"); + const resize = fakeWindow.windowListeners.get("resize"); + if (!move || !resize) { + return yield* Effect.die("window bounds listeners were not registered"); + } + + fakeWindow.getBounds.mockReturnValue({ x: 120, y: 80, width: 1280, height: 840 }); + move(); + yield* TestClock.adjust(250); + + fakeWindow.getBounds.mockReturnValue({ x: 160, y: 100, width: 1360, height: 900 }); + resize(); + yield* TestClock.adjust(499); + assert.deepEqual(mainWindowBoundsUpdates, []); + + yield* TestClock.adjust(1); + yield* Effect.promise(() => Promise.resolve()); + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 160, y: 100, width: 1360, height: 900 }]); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("persists normal bounds and state for a maximized window", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.isMaximized.mockReturnValue(true); + fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); + fakeWindow.getNormalBounds.mockReturnValue({ x: 220, y: 140, width: 1380, height: 920 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const mainWindowMaximizedUpdates: boolean[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + mainWindowMaximizedUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const close = fakeWindow.windowListeners.get("close"); + if (!close) { + return yield* Effect.die("window close listener was not registered"); + } + close(); + yield* Effect.promise(() => Promise.resolve()); + + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 220, y: 140, width: 1380, height: 920 }]); + assert.deepEqual(mainWindowMaximizedUpdates, [true]); + assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); + assert.equal(fakeWindow.getBounds.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("persists normal bounds and state from the native maximize event", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const mainWindowMaximizedUpdates: boolean[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + mainWindowMaximizedUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const maximize = fakeWindow.windowListeners.get("maximize"); + if (!maximize) { + return yield* Effect.die("window maximize listener was not registered"); + } + + fakeWindow.isMaximized.mockReturnValue(true); + fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); + fakeWindow.getNormalBounds.mockReturnValue({ x: 220, y: 140, width: 1380, height: 920 }); + maximize(); + yield* TestClock.adjust(500); + yield* Effect.promise(() => Promise.resolve()); + + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 220, y: 140, width: 1380, height: 920 }]); + assert.deepEqual(mainWindowMaximizedUpdates, [true]); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("does not persist bounds that fail the domain schema", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.getBounds.mockReturnValue({ x: 100.4, y: 80.2, width: 839.4, height: 619.4 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const resize = fakeWindow.windowListeners.get("resize"); + if (!resize) { + return yield* Effect.die("window resize listener was not registered"); + } + resize(); + yield* TestClock.adjust(500); + yield* Effect.promise(() => Promise.resolve()); + + assert.deepEqual(mainWindowBoundsUpdates, []); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("preserves unrestorable bounds until the user changes the window", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + mainWindowBounds: { x: 2040, y: 80, width: 1320, height: 880 }, + }, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const close = fakeWindow.windowListeners.get("close"); + const move = fakeWindow.windowListeners.get("move"); + if (!close || !move) { + return yield* Effect.die("window lifecycle listeners were not registered"); + } + + close(); + yield* Effect.promise(() => Promise.resolve()); + assert.deepEqual(mainWindowBoundsUpdates, []); + + fakeWindow.getBounds.mockReturnValue({ x: 80, y: 60, width: 1280, height: 840 }); + move(); + yield* TestClock.adjust(500); + yield* Effect.promise(() => Promise.resolve()); + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 80, y: 60, width: 1280, height: 840 }]); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("flushes normal bounds when fullscreen before the debounce completes", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); + fakeWindow.getNormalBounds.mockReturnValue({ x: 200, y: 130, width: 1400, height: 940 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const resize = fakeWindow.windowListeners.get("resize"); + if (!resize) { + return yield* Effect.die("window resize listener was not registered"); + } + resize(); + yield* TestClock.adjust(250); + fakeWindow.isFullScreen.mockReturnValue(true); + + yield* desktopWindow.flushMainWindowBounds; + + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 200, y: 130, width: 1400, height: 940 }]); + assert.equal(fakeWindow.getBounds.mock.calls.length, 0); + assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("flushes normal bounds when minimized before the debounce completes", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.getBounds.mockReturnValue({ x: -32_000, y: -32_000, width: 160, height: 28 }); + fakeWindow.getNormalBounds.mockReturnValue({ x: 180, y: 120, width: 1440, height: 960 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const resize = fakeWindow.windowListeners.get("resize"); + if (!resize) { + return yield* Effect.die("window resize listener was not registered"); + } + resize(); + yield* TestClock.adjust(250); + fakeWindow.isMinimized.mockReturnValue(true); + + yield* desktopWindow.flushMainWindowBounds; + + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 180, y: 120, width: 1440, height: 960 }]); + assert.equal(fakeWindow.getBounds.mock.calls.length, 0); + assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("logs display lookup failures before falling back to the default size", () => + Effect.gen(function* () { + const displayLookupFailure = new Error("screen API unavailable"); + vi.mocked(Electron.screen.getAllDisplays).mockImplementationOnce(() => { + throw displayLookupFailure; + }); + const logRecords: Array<{ + readonly message: unknown; + readonly annotations: Readonly>; + }> = []; + const logger = Logger.make(({ fiber, message }) => { + logRecords.push({ + message, + annotations: { ...fiber.getRef(References.CurrentLogAnnotations) }, + }); + }); + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + createdWindowOptions, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + }).pipe( + Effect.provide(Layer.mergeAll(layer, Logger.layer([logger], { mergeWithExisting: false }))), + ); + + const warning = logRecords.find( + (record) => + Array.isArray(record.message) && + record.message[0] === "failed to read connected displays; using defaults", + ); + assert.isDefined(warning); + assert.strictEqual(warning.annotations.cause, displayLookupFailure); + assert.equal(createdWindowOptions[0]?.width, 1100); + assert.equal(createdWindowOptions[0]?.height, 780); + assert.isUndefined(createdWindowOptions[0]?.x); + assert.isUndefined(createdWindowOptions[0]?.y); + }), + ); + + it.effect("persists the current main window bounds before the window closes", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.getBounds.mockReturnValue({ x: 240, y: 160, width: 1410, height: 930 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const writeStarted = yield* Deferred.make(); + const allowWrite = yield* Deferred.make(); + const flushCompleted = yield* Deferred.make(); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + beforeMainWindowBoundsUpdate: () => + Deferred.succeed(writeStarted, undefined).pipe( + Effect.andThen(Deferred.await(allowWrite)), + Effect.asVoid, + ), + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const close = fakeWindow.windowListeners.get("close"); + if (!close) { + return yield* Effect.die("window close listener was not registered"); + } + close(); + yield* Deferred.await(writeStarted); + fakeWindow.isDestroyed.mockReturnValue(true); + + const flushFiber = yield* desktopWindow.flushMainWindowBounds.pipe( + Effect.andThen(Deferred.succeed(flushCompleted, undefined)), + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.yieldNow; + assert.isFalse(yield* Deferred.isDone(flushCompleted)); + + yield* Deferred.succeed(allowWrite, undefined); + yield* Fiber.join(flushFiber); + assert.isTrue(yield* Deferred.isDone(flushCompleted)); + + assert.deepEqual(mainWindowBoundsUpdates, [ + { + x: 240, + y: 160, + width: 1410, + height: 930, + }, + ]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("publishes native macOS fullscreen changes to the renderer", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 0848703914fd..db4b698434d6 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -5,23 +5,25 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; -import type * as Electron from "electron"; +import * as Electron from "electron"; import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; -import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; import * as PreviewManager from "../preview/Manager.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; +const MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS = 500; const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([ -2, // ERR_FAILED @@ -41,7 +43,7 @@ type WindowTitleBarOptions = Pick< type DesktopWindowRuntimeServices = | DesktopEnvironment.DesktopEnvironment | DesktopAssets.DesktopAssets - | ElectronProtocol.ElectronProtocol + | DesktopAppSettings.DesktopAppSettings | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell | ElectronTheme.ElectronTheme @@ -76,6 +78,7 @@ export class DesktopWindow extends Context.Service< // window so a "macOS dock click" while the backend is down doesn't // produce a stranded window pointing at nothing. readonly handleBackendNotReady: Effect.Effect; + readonly flushMainWindowBounds: Effect.Effect; readonly dispatchMenuAction: (action: string) => Effect.Effect; readonly syncAppearance: Effect.Effect; } @@ -100,6 +103,45 @@ function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; } +type DisplayBounds = Pick; + +function windowFitsWithinDisplay( + windowBounds: DesktopAppSettings.DesktopWindowBounds, + displayBounds: DisplayBounds, +): boolean { + return ( + windowBounds.x >= displayBounds.x && + windowBounds.y >= displayBounds.y && + windowBounds.x + windowBounds.width <= displayBounds.x + displayBounds.width && + windowBounds.y + windowBounds.height <= displayBounds.y + displayBounds.height + ); +} + +function windowBoundsEqual( + left: DesktopAppSettings.DesktopWindowBounds, + right: DesktopAppSettings.DesktopWindowBounds, +): boolean { + return ( + left.x === right.x && + left.y === right.y && + left.width === right.width && + left.height === right.height + ); +} + +export function resolveInitialMainWindowBounds( + persistedBounds: DesktopAppSettings.DesktopWindowBounds | null, + displays: readonly DisplayBounds[], +): DesktopAppSettings.DesktopWindowBounds | typeof DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE { + if ( + persistedBounds !== null && + displays.some((display) => windowFitsWithinDisplay(persistedBounds, display)) + ) { + return persistedBounds; + } + return DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE; +} + // A self-contained "Connecting to WSL" splash, shown immediately in wsl-only // mode while the WSL backend (which serves the renderer) cold-boots. Inlined as // a data URL so it needs no bundled asset and no backend — pure CSS, no JS. @@ -198,12 +240,12 @@ function bindFirstRevealTrigger( export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const assets = yield* DesktopAssets.DesktopAssets; - const electronProtocol = yield* ElectronProtocol.ElectronProtocol; const electronMenu = yield* ElectronMenu.ElectronMenu; const electronShell = yield* ElectronShell.ElectronShell; const electronTheme = yield* ElectronTheme.ElectronTheme; const electronWindow = yield* ElectronWindow.ElectronWindow; const previewManager = yield* PreviewManager.PreviewManager; + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; // Window-side latch for the primary backend's readiness. Set by // handleBackendReady (driven by the pool's onReady callback), cleared // by handleBackendNotReady (driven by onShutdown). Only consumed by @@ -216,6 +258,7 @@ export const make = Effect.gen(function* () { const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); + let flushMainWindowBounds: Effect.Effect = Effect.void; const dismissConnectingSplash = Effect.gen(function* () { const splash = yield* Ref.getAndSet(splashWindowRef, Option.none()); @@ -248,13 +291,35 @@ export const make = Effect.gen(function* () { DesktopWindowError > { yield* previewManager.getBrowserSession(); - const applicationUrl = ElectronProtocol.getDesktopUrl(environment.isDevelopment); + const applicationUrl = getDesktopUrl(environment.isDevelopment); const iconPaths = yield* assets.iconPaths; const iconOption = getIconOption(iconPaths, environment.platform); const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + const persistedSettings = yield* desktopSettings.get; + const persistedBounds = persistedSettings.mainWindowBounds; + const displayBoundsResult = yield* Effect.sync(() => { + try { + return { + _tag: "Success" as const, + bounds: Electron.screen.getAllDisplays().map((display) => display.bounds), + }; + } catch (cause) { + return { _tag: "Failure" as const, cause }; + } + }); + const displayBounds = + displayBoundsResult._tag === "Success" + ? displayBoundsResult.bounds + : yield* logWindowWarning("failed to read connected displays; using defaults", { + cause: displayBoundsResult.cause, + }).pipe(Effect.as([])); + const initialBounds = resolveInitialMainWindowBounds(persistedBounds, displayBounds); + const restoredPersistedBounds = persistedBounds !== null && initialBounds === persistedBounds; + if (persistedBounds !== null && initialBounds === DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE) { + yield* logWindowWarning("saved main window bounds could not be restored; using defaults"); + } const window = yield* electronWindow.create({ - width: 1100, - height: 780, + ...initialBounds, minWidth: 840, minHeight: 620, show: false, @@ -276,6 +341,92 @@ export const make = Effect.gen(function* () { if (environment.platform === "darwin") { window.setAutoHideCursor(false); } + let boundsPersistFiber: Fiber.Fiber | undefined; + let pendingBoundsPersistFiber: Fiber.Fiber | undefined; + let boundsPersistenceEnabled = persistedBounds === null || restoredPersistedBounds; + const readPersistableBounds = (): DesktopAppSettings.DesktopWindowBounds | null => { + if (window.isDestroyed()) { + return null; + } + const bounds = + window.isFullScreen() || window.isMaximized() || window.isMinimized() + ? window.getNormalBounds() + : window.getBounds(); + return DesktopAppSettings.normalizeMainWindowBounds({ + x: Math.round(bounds.x), + y: Math.round(bounds.y), + width: Math.round(bounds.width), + height: Math.round(bounds.height), + }); + }; + const fallbackWindowBounds = boundsPersistenceEnabled ? null : readPersistableBounds(); + const fallbackWindowMaximized = persistedSettings.mainWindowMaximized; + const persistCurrentBounds = (): Fiber.Fiber | undefined => { + if (!boundsPersistenceEnabled) { + return pendingBoundsPersistFiber; + } + const bounds = readPersistableBounds(); + if (bounds === null) { + return pendingBoundsPersistFiber; + } + pendingBoundsPersistFiber = runFork( + desktopSettings.setMainWindowBounds(bounds, window.isMaximized()).pipe( + Effect.asVoid, + Effect.catch((error) => + logWindowWarning("failed to persist main window bounds", { + message: error.message, + }), + ), + ), + ); + return pendingBoundsPersistFiber; + }; + const scheduleBoundsPersist = () => { + if (!boundsPersistenceEnabled) { + const currentBounds = readPersistableBounds(); + if ( + currentBounds === null || + (fallbackWindowBounds !== null && + windowBoundsEqual(currentBounds, fallbackWindowBounds) && + window.isMaximized() === fallbackWindowMaximized) + ) { + return; + } + } + boundsPersistenceEnabled = true; + if (boundsPersistFiber !== undefined) { + const fiber = boundsPersistFiber; + boundsPersistFiber = undefined; + runFork(Fiber.interrupt(fiber)); + } + boundsPersistFiber = runFork( + Effect.sleep(MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS).pipe( + Effect.andThen( + Effect.sync(() => { + boundsPersistFiber = undefined; + void persistCurrentBounds(); + }), + ), + ), + ); + }; + const clearBoundsPersist = () => { + if (boundsPersistFiber === undefined) { + return; + } + const fiber = boundsPersistFiber; + boundsPersistFiber = undefined; + runFork(Fiber.interrupt(fiber)); + }; + const flushBoundsPersist = Effect.sync(() => { + clearBoundsPersist(); + return persistCurrentBounds(); + }).pipe( + Effect.flatMap((fiber) => + fiber === undefined ? Effect.void : Fiber.join(fiber).pipe(Effect.asVoid), + ), + ); + flushMainWindowBounds = flushBoundsPersist; yield* previewManager.setMainWindow(window); window.webContents.on("will-attach-webview", (event, webPreferences, params) => { @@ -366,6 +517,13 @@ export const make = Effect.gen(function* () { event.preventDefault(); window.setTitle(environment.displayName); }); + window.on("resize", scheduleBoundsPersist); + window.on("move", scheduleBoundsPersist); + window.on("maximize", scheduleBoundsPersist); + window.on("unmaximize", scheduleBoundsPersist); + window.on("close", () => { + runFork(flushBoundsPersist); + }); if (environment.platform === "darwin") { window.on("enter-full-screen", () => { @@ -474,6 +632,9 @@ export const make = Effect.gen(function* () { bindFirstRevealTrigger(revealSubscribers, () => { // Reveal the real window, then close the connecting splash (if any) so the // two don't overlap and there's no blank gap between them. + if (persistedSettings.mainWindowMaximized) { + window.maximize(); + } void runPromise(Effect.andThen(electronWindow.reveal(window), dismissConnectingSplash)); }); @@ -484,6 +645,7 @@ export const make = Effect.gen(function* () { window.on("closed", () => { clearDevelopmentLoadRetry(); + clearBoundsPersist(); void runPromise(electronWindow.clearMain(Option.some(window))); }); @@ -560,9 +722,7 @@ export const make = Effect.gen(function* () { }).pipe( // The splash is best-effort UX — never let it fail startup. Effect.catch((error) => - logWindowWarning("failed to show connecting splash", { - message: error.message, - }), + logWindowWarning("failed to show connecting splash", { message: error.message }), ), Effect.withSpan("desktop.window.showConnectingSplash"), ); @@ -596,18 +756,15 @@ export const make = Effect.gen(function* () { showConnectingSplash, handleBackendReady: Effect.fn("desktop.window.handleBackendReady")(function* (httpBaseUrl) { yield* Ref.set(backendReadyRef, true); - yield* logWindowInfo("backend ready", { - source: "http", - url: httpBaseUrl.href, - }); - if (!environment.isDevelopment) { - yield* electronProtocol.updateDesktopProtocolTargetOrigin(httpBaseUrl); - } + yield* logWindowInfo("backend ready", { source: "http", url: httpBaseUrl.href }); yield* createMainIfBackendReady; }), handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( Effect.withSpan("desktop.window.handleBackendNotReady"), ), + flushMainWindowBounds: Effect.suspend(() => flushMainWindowBounds).pipe( + Effect.withSpan("desktop.window.flushMainWindowBounds"), + ), dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) { yield* Effect.annotateCurrentSpan({ action }); const existingWindow = yield* focusedMainWindow; diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index ee2bde9d971f..74fe2f4852a8 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -58,6 +58,8 @@ import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; type WorkspaceMode = "local" | "worktree"; +const EMPTY_BRANCH_REFS: ReadonlyArray = []; + function pendingTaskDraftKey(messageId: string): string { return `pending-task:${messageId}`; } @@ -348,7 +350,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local"; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; - const startFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin ?? false; + // Keep the user's explicit choice separate from the resolved display value: + // only the explicit flag is ever written back to the draft, so the resolved + // value keeps tracking the server setting when the config loads late. + const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin; + const startFromOrigin = + draftStartFromOrigin ?? + selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ?? + true; const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; @@ -368,6 +377,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const selectedModel = selectedProjectDraft.modelSelection ?? selectedProject?.defaultModelSelection ?? + modelOptions.find((option) => option.isDefault)?.selection ?? modelOptions[0]?.selection ?? null; const selectedModelKey = selectedModel @@ -475,13 +485,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); const branchState = useBranches(branchTarget); const branchesLoading = branchState.isPending; + const allBranchRefs = branchState.data?.refs ?? EMPTY_BRANCH_REFS; const availableBranches = useMemo( () => pipe( - branchState.data?.refs ?? [], + allBranchRefs, Arr.filter((branch) => !branch.isRemote), ), - [branchState.data?.refs], + [allBranchRefs], ); const filteredBranches = useMemo(() => { @@ -543,11 +554,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { mode, branch: selectedBranchName, worktreePath: selectedWorktreePath, - startFromOrigin, + ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), }, }); }, - [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath, startFromOrigin], + [draftStartFromOrigin, selectedBranchName, selectedProjectDraftKey, selectedWorktreePath], ); const selectBranch = useCallback( @@ -560,11 +571,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { mode: workspaceMode, branch: branch.name, worktreePath: normalizeSelectedWorktreePath(selectedProject, branch), - startFromOrigin, + ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), }, }); }, - [selectedProject, selectedProjectDraftKey, startFromOrigin, workspaceMode], + [draftStartFromOrigin, selectedProject, selectedProjectDraftKey, workspaceMode], ); const setStartFromOrigin = useCallback( @@ -597,14 +608,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (workspaceMode !== "worktree" || selectedBranchName !== null) { return; } + // The default may only exist as origin/ (isRemote), which + // availableBranches filters out — search the unfiltered refs for it. const preferredBranch = + allBranchRefs.find((branch) => branch.isDefault) ?? availableBranches.find((branch) => branch.current) ?? - availableBranches.find((branch) => branch.isDefault) ?? null; if (preferredBranch) { selectBranch(preferredBranch); } - }, [availableBranches, selectBranch, selectedBranchName, workspaceMode]); + }, [allBranchRefs, availableBranches, selectBranch, selectedBranchName, workspaceMode]); const setRuntimeMode = useCallback( (value: RuntimeMode) => { @@ -696,7 +709,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { workspaceMode: mode, branch: workspaceSelection?.branch ?? null, worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null), - ...(workspaceSelection?.startFromOrigin ? { startFromOrigin: true } : {}), + // The draft only carries the flag when the user touched it; fall + // back to the resolved default (server settings) so queued tasks + // drain with the same origin mode the composer displayed. + ...((workspaceSelection?.startFromOrigin ?? startFromOrigin) + ? { startFromOrigin: true } + : {}), }, createdAt: metadata.createdAt, }; @@ -707,6 +725,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedModel, selectedProject, selectedProjectDraftKey, + startFromOrigin, ], ); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index e21682414d7d..ab859c73b463 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -15,6 +15,7 @@ export type ModelOption = { readonly providerKey: string; readonly providerLabel: string; readonly providerDriver: string; + readonly isDefault: boolean; readonly capabilities: ModelCapabilities | null; readonly selection: ModelSelection; }; @@ -78,6 +79,7 @@ export function buildModelOptions( providerKey: provider.instanceId, providerLabel, providerDriver: provider.driver, + isDefault: model.isDefault === true, capabilities: model.capabilities, selection: normalizeSelectionOptions( { @@ -107,6 +109,7 @@ export function buildModelOptions( providerKey: fallbackModelSelection.instanceId, providerLabel, providerDriver: fallbackModelSelection.instanceId, + isDefault: false, capabilities: null, selection: fallbackModelSelection, }); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index bee217968814..6f5e712d9224 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -218,6 +218,7 @@ describe("OrchestrationEngine", () => { const runtime = ManagedRuntime.make(layer); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); + expect(await runtime.runPromise(engine.latestSequence)).toBe(7); const result = await runtime.runPromise( engine.dispatch({ type: "thread.meta.update", @@ -228,6 +229,7 @@ describe("OrchestrationEngine", () => { ); expect(result.sequence).toBe(8); + expect(await runtime.runPromise(engine.latestSequence)).toBe(8); expect(fullSnapshotReadCount).toBe(0); await runtime.dispose(); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index e188ac7e11de..bf4abf17e3ca 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -337,6 +337,11 @@ const makeOrchestrationEngine = Effect.gen(function* () { get subscribeDomainEvents(): OrchestrationEngineShape["subscribeDomainEvents"] { return PubSub.subscribe(eventPubSub); }, + // The command read model's snapshotSequence tracks the latest committed + // event sequence (updated on the worker fiber). A plain property read is a + // consistent, committed value — reassignment of `commandReadModel` is + // atomic on the single-threaded event loop. + latestSequence: Effect.sync(() => commandReadModel.snapshotSequence), } satisfies OrchestrationEngineShape; }); diff --git a/apps/server/src/orchestration/Services/OrchestrationEngine.ts b/apps/server/src/orchestration/Services/OrchestrationEngine.ts index cdb3e33d1d76..0e27fc886b58 100644 --- a/apps/server/src/orchestration/Services/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Services/OrchestrationEngine.ts @@ -82,6 +82,13 @@ export interface OrchestrationEngineShape { never, Scope.Scope >; + + /** + * The latest sequence reflected in the engine's authoritative command read + * model (0 if none). Used to gauge how far behind a resuming client is before + * choosing between an incremental replay and a fresh projected snapshot. + */ + readonly latestSequence: Effect.Effect; } /** diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 22fd63bf875e..25b482e2cada 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1713,6 +1713,133 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("consumes undeclared and UX-internal system subtypes without warning rows", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEvents: Array = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + // Undeclared wire-only roster snapshot + every typed UX-internal + // subtype and top-level type consumed silently: none may surface as + // unknown-subtype warnings. + for (const message of [ + { + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "t1", task_type: "local_agent", description: "Say hi" }], + session_id: "session", + uuid: "roster", + }, + { + type: "system", + subtype: "task_updated", + task_id: "t1", + patch: { status: "running" }, + session_id: "session", + uuid: "tu", + }, + { type: "system", subtype: "commands_changed", session_id: "session", uuid: "cc" }, + { type: "system", subtype: "model_refusal_fallback", session_id: "session", uuid: "mrf" }, + { type: "system", subtype: "local_command_output", session_id: "session", uuid: "lco" }, + { type: "system", subtype: "plugin_install", session_id: "session", uuid: "pi" }, + { type: "system", subtype: "memory_recall", session_id: "session", uuid: "mr" }, + { type: "system", subtype: "elicitation_complete", session_id: "session", uuid: "ec" }, + { type: "prompt_suggestion", suggestion: "try this", session_id: "session", uuid: "ps" }, + { + type: "system", + subtype: "notification", + key: "context", + text: "low priority note", + priority: "low", + session_id: "session", + uuid: "notif", + }, + ]) { + harness.query.emit(message as unknown as SDKMessage); + } + // High-priority notifications DO surface as a warning row. + harness.query.emit({ + type: "system", + subtype: "notification", + key: "limit", + text: "context window nearly full", + priority: "high", + session_id: "session", + uuid: "notif-high", + } as unknown as SDKMessage); + // session_state_changed maps to the matching session states. + for (const [state, uuid] of [ + ["running", "ssc-run"], + ["requires_action", "ssc-req"], + ["idle", "ssc-idle"], + ]) { + harness.query.emit({ + type: "system", + subtype: "session_state_changed", + state, + session_id: "session", + uuid, + } as unknown as SDKMessage); + } + // api_retry maps to a session heartbeat, not a warning row. + harness.query.emit({ + type: "system", + subtype: "api_retry", + attempt: 3, + max_retries: 10, + retry_delay_ms: 1000, + error_status: 502, + error: { type: "api_error" }, + session_id: "session", + uuid: "retry", + } as unknown as SDKMessage); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + const warnings = runtimeEvents.filter((event) => event.type === "runtime.warning"); + // Exactly one warning: the high-priority notification. Nothing else. + assert.deepEqual( + warnings.map((event) => event.payload.message), + ["context window nearly full"], + ); + const sessionStates = runtimeEvents + .filter((event) => event.type === "session.state.changed") + .map((event) => + event.type === "session.state.changed" + ? `${event.payload.state}:${event.payload.reason ?? ""}` + : "", + ) + .filter( + (entry) => entry.startsWith("running:session_state") || entry.includes("session_state"), + ); + assert.deepEqual(sessionStates, [ + "running:session_state:running", + "waiting:session_state:requires_action", + "ready:session_state:idle", + ]); + const heartbeat = runtimeEvents.find( + (event) => + event.type === "session.state.changed" && + typeof event.payload.reason === "string" && + event.payload.reason.startsWith("api_retry:"), + ); + assert.equal(heartbeat?.type, "session.state.changed"); + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("emits thread token usage updates from Claude task progress", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -1767,7 +1894,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("seeds Claude Sonnet 5 sessions with the native 1M context window", () => { + it.effect("seeds Claude Sonnet 5 sessions with the default 200K context window", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -1795,6 +1922,62 @@ describe("ClaudeAdapterLive", () => { session_id: "sdk-session-sonnet-5-usage", uuid: "stream-sonnet-5-usage", parent_tool_use_id: null, + event: { + type: "message_delta", + delta: {}, + usage: { total_tokens: 250_000 }, + }, + } as unknown as SDKMessage); + + const usageEvent = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "thread.token-usage.updated", + ).pipe(Stream.runHead); + assert.equal(usageEvent._tag, "Some"); + if (usageEvent._tag === "Some") { + assert.deepEqual(usageEvent.value.payload, { + usage: { + usedTokens: 200_000, + lastUsedTokens: 200_000, + maxTokens: 200_000, + }, + }); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("seeds Claude Sonnet 5 sessions with 1M when that context window is selected", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-5", + [{ id: "contextWindow", value: "1m" }], + ), + runtimeMode: "full-access", + }); + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + yield* Stream.take(adapter.streamEvents, 1).pipe(Stream.runDrain); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-sonnet-5-1m-usage", + uuid: "stream-sonnet-5-1m-usage", + parent_tool_use_id: null, event: { type: "message_delta", delta: {}, @@ -1822,7 +2005,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("treats stale direct Claude Sonnet 5 200K selections as native 1M", () => { + it.effect("honors an explicit Claude Sonnet 5 200K context window selection", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -1854,7 +2037,7 @@ describe("ClaudeAdapterLive", () => { event: { type: "message_delta", delta: {}, - usage: { total_tokens: 1_250_000 }, + usage: { total_tokens: 250_000 }, }, } as unknown as SDKMessage); @@ -1866,9 +2049,9 @@ describe("ClaudeAdapterLive", () => { if (usageEvent._tag === "Some") { assert.deepEqual(usageEvent.value.payload, { usage: { - usedTokens: 1_000_000, - lastUsedTokens: 1_000_000, - maxTokens: 1_000_000, + usedTokens: 200_000, + lastUsedTokens: 200_000, + maxTokens: 200_000, }, }); } @@ -3218,7 +3401,7 @@ describe("ClaudeAdapterLive", () => { attachments: [], }); - assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6"]); + assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6[1m]"]); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -3316,10 +3499,11 @@ describe("ClaudeAdapterLive", () => { yield* adapter.sendTurn({ threadId: session.threadId, input: "hello again", - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-opus-4-6", - }, + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "contextWindow", value: "200k" }], + ), attachments: [], }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 4c5213ae43d3..18668b9a3172 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -74,10 +74,10 @@ import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { getClaudeModelCapabilities, - isClaudeSonnet5ConstrainedContextEnvironment, isClaudeUltracodeEffort, normalizeClaudeCliEffort, resolveClaudeApiModelId, + resolveClaudeContextWindow, resolveClaudeEffort, } from "./ClaudeProvider.ts"; import { @@ -338,37 +338,22 @@ function maxClaudeContextWindowFromModelUsage( function selectedClaudeContextWindow( modelSelection: ModelSelection | undefined, - environment: NodeJS.ProcessEnv, ): number | undefined { - const optionValue = getModelSelectionStringOptionValue(modelSelection, "contextWindow"); - if (modelSelection?.model === "claude-sonnet-5") { - if (optionValue === "1m") { - return 1_000_000; - } - return isClaudeSonnet5ConstrainedContextEnvironment(environment) ? 200_000 : 1_000_000; - } - - if (optionValue === "1m") { - return 1_000_000; - } - if (optionValue === "200k") { - return 200_000; - } - switch (modelSelection?.model) { case "claude-opus-4-8": case "claude-opus-4-7": + // Always 1M at the API; these models expose no contextWindow option. return 1_000_000; } - const caps = getClaudeModelCapabilities(modelSelection?.model); - const hasContextWindowOption = getProviderOptionDescriptors({ caps }).some( - (descriptor) => descriptor.type === "select" && descriptor.id === "contextWindow", - ); - if (hasContextWindowOption) { - return 200_000; + switch (resolveClaudeContextWindow(modelSelection)) { + case "1m": + return 1_000_000; + case "200k": + return 200_000; + default: + return undefined; } - return undefined; } function finiteNonNegativeInteger(value: unknown): number | undefined { @@ -2600,6 +2585,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, }; + // Undeclared-but-real subtypes (absent from the SDK's union, so they can't + // be switch cases): consumed intentionally without emitting, otherwise + // they fall through to the unknown-subtype warning and surface as spurious + // error rows in client work logs. `background_tasks_changed` is a roster + // snapshot ({tasks: [...]}) — the task_* lifecycle events carry the + // authoritative per-agent data and the typed background_tasks control + // request is the reconciliation source. + if ((message.subtype as string) === "background_tasks_changed") { + return; + } + switch (message.subtype) { case "init": yield* offerRuntimeEvent({ @@ -2712,6 +2708,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, }); return; + // Task state patch (status/backgrounded/end_time). No runtime mapping + // yet — the terminal task_notification reports the outcome — but it + // must not surface as an unknown-subtype warning row. + case "task_updated": + return; case "task_notification": yield* emitThreadTokenUsage( context, @@ -2756,6 +2757,52 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return; case "thinking_tokens": return; + case "api_retry": + // Transport-level retry heartbeat. Surfacing each attempt as a + // warning row spammed the work log (10 rows during a 502 storm); + // the terminal result/error path reports the actual failure. Keep + // the session visibly alive instead. + yield* offerRuntimeEvent({ + ...base, + type: "session.state.changed", + payload: { + state: "running", + reason: `api_retry:${message.attempt}/${message.max_retries}`, + }, + }); + return; + case "session_state_changed": + // Authoritative turn-over signal from the CLI. + yield* offerRuntimeEvent({ + ...base, + type: "session.state.changed", + payload: { + state: + message.state === "running" + ? "running" + : message.state === "requires_action" + ? "waiting" + : "ready", + reason: `session_state:${message.state}`, + }, + }); + return; + case "notification": + // User-facing CLI notification (e.g. context-limit warnings). Only + // high-priority ones warrant a work-log row. + if (message.priority === "high" || message.priority === "immediate") { + yield* emitRuntimeWarning(context, message.text, message); + } + return; + // Inner protocol/UX details with no T3 surface today — consumed + // deliberately so they don't masquerade as unknown-subtype warnings. + case "model_refusal_fallback": + case "local_command_output": + case "plugin_install": + case "commands_changed": + case "memory_recall": + case "elicitation_complete": + return; case "permission_denied": yield* offerRuntimeEvent({ ...base, @@ -2775,13 +2822,21 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( message, ); return; - default: + default: { + // Exhaustiveness guard: every subtype in the SDK's typed union is + // handled above, so `message` narrows to never here — a new SDK + // release adding a subtype fails this typecheck instead of silently + // warning at runtime. The runtime fallback still catches undeclared + // wire-only subtypes (like background_tasks_changed used to be). + message satisfies never; + const unknownMessage = message as never as { subtype: string }; yield* emitRuntimeWarning( context, - describeUnknownSdkMessage(`Claude system message '${message.subtype}'`, message), + describeUnknownSdkMessage(`Claude system message '${unknownMessage.subtype}'`, message), message, ); return; + } } }); @@ -2889,13 +2944,21 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( case "rate_limit_event": yield* handleSdkTelemetryMessage(context, message); return; - default: + // Composer prompt suggestions have no T3 surface; consumed deliberately. + case "prompt_suggestion": + return; + default: { + // Exhaustiveness guard (see handleSystemMessage): new SDK top-level + // message types fail typecheck here instead of warning at runtime. + message satisfies never; + const unknownMessage = message as never as { type: string }; yield* emitRuntimeWarning( context, - describeUnknownSdkMessage(`Claude SDK message '${message.type}'`, message), + describeUnknownSdkMessage(`Claude SDK message '${unknownMessage.type}'`, message), message, ); return; + } } }); @@ -3427,7 +3490,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const caps = getClaudeModelCapabilities(modelSelection?.model); const descriptors = getProviderOptionDescriptors({ caps }); const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined; - const initialContextWindow = selectedClaudeContextWindow(modelSelection, claudeEnvironment); + const initialContextWindow = selectedClaudeContextWindow(modelSelection); const rawEffort = getModelSelectionStringOptionValue(modelSelection, "effort"); const effort = resolveClaudeEffort(caps, rawEffort) ?? null; const fastModeSupported = descriptors.some( @@ -3680,7 +3743,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); context.currentApiModelId = apiModelId; } - const selectedContextWindow = selectedClaudeContextWindow(modelSelection, claudeEnvironment); + const selectedContextWindow = selectedClaudeContextWindow(modelSelection); if (selectedContextWindow !== undefined) { context.lastKnownContextWindow = selectedContextWindow; } diff --git a/apps/server/src/provider/Layers/ClaudeProvider.test.ts b/apps/server/src/provider/Layers/ClaudeProvider.test.ts index 81fd46f458e2..823ad575dde7 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.test.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.test.ts @@ -18,12 +18,12 @@ describe("ClaudeProvider", () => { ).toContain("claude-sonnet-5"); }); - it("exposes reasoning without a direct Anthropic context selector for Claude Sonnet 5", () => { + it("exposes reasoning and a context window selector for Claude Sonnet 5", () => { const descriptors = getProviderOptionDescriptors({ caps: getClaudeModelCapabilities("claude-sonnet-5"), }); - expect(descriptors.map((descriptor) => descriptor.id)).toEqual(["effort"]); + expect(descriptors.map((descriptor) => descriptor.id)).toEqual(["effort", "contextWindow"]); expect( descriptors.some( (descriptor) => @@ -31,5 +31,14 @@ describe("ClaudeProvider", () => { descriptor.options.some((option) => option.id === "ultracode"), ), ).toBe(true); + expect( + descriptors.find( + (descriptor) => descriptor.type === "select" && descriptor.id === "contextWindow", + ), + ).toMatchObject({ + type: "select", + id: "contextWindow", + currentValue: "200k", + }); }); }); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 25c5088c7b3c..c14927139997 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -139,7 +139,10 @@ const BUILT_IN_MODELS: ReadonlyArray = [ buildSelectOptionDescriptor({ id: "contextWindow", label: "Context Window", - options: CLAUDE_CONTEXT_WINDOW_OPTIONS, + options: [ + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, + ], }), ], }), @@ -201,7 +204,10 @@ const BUILT_IN_MODELS: ReadonlyArray = [ buildSelectOptionDescriptor({ id: "contextWindow", label: "Context Window", - options: CLAUDE_CONTEXT_WINDOW_OPTIONS, + options: [ + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, + ], }), ], }), @@ -228,6 +234,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ slug: "claude-sonnet-5", name: "Claude Sonnet 5", isCustom: false, + isDefault: true, capabilities: createModelCapabilities({ optionDescriptors: [ buildSelectOptionDescriptor({ @@ -236,6 +243,15 @@ const BUILT_IN_MODELS: ReadonlyArray = [ options: CLAUDE_EFFORT_OPTIONS.sonnet5, promptInjectedValues: ["ultrathink"], }), + buildSelectOptionDescriptor({ + id: "contextWindow", + label: "Context Window", + // Sonnet is 200k-default in Claude Code (1M is opt-in there too). + options: [ + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, + ], + }), ], }), }, @@ -254,7 +270,11 @@ const BUILT_IN_MODELS: ReadonlyArray = [ buildSelectOptionDescriptor({ id: "contextWindow", label: "Context Window", - options: CLAUDE_CONTEXT_WINDOW_OPTIONS, + // Sonnet is 200k-default in Claude Code (1M is opt-in there too). + options: [ + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, + ], }), ], }), @@ -425,8 +445,23 @@ export function isClaudeUltracodeEffort(effort: string | null | undefined): bool return effort === "ultracode"; } +export function resolveClaudeContextWindow( + modelSelection: ModelSelection | undefined, +): string | undefined { + const caps = getClaudeModelCapabilities(modelSelection?.model); + const raw = getModelSelectionStringOptionValue(modelSelection, "contextWindow"); + const descriptors = getProviderOptionDescriptors({ + caps, + ...(raw ? { selections: [{ id: "contextWindow", value: raw }] } : {}), + }); + const descriptor = descriptors.find((candidate) => candidate.id === "contextWindow"); + const value = getProviderOptionCurrentValue(descriptor); + return typeof value === "string" ? value : undefined; +} + export function resolveClaudeApiModelId(modelSelection: ModelSelection): string { - const contextWindow = getModelSelectionStringOptionValue(modelSelection, "contextWindow"); + const contextWindow = resolveClaudeContextWindow(modelSelection); + // Claude Code gateway expects the short Sonnet alias for 1M context. if (modelSelection.model === "claude-sonnet-5") { return contextWindow === "1m" ? "sonnet[1m]" : modelSelection.model; } diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 0e21b76306b9..2aeebdb2ccd8 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,6 +1,6 @@ import { assert, it } from "@effect/vitest"; -import { mapCodexModelCapabilities } from "./CodexProvider.ts"; +import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ @@ -102,3 +102,45 @@ it("uses standard routing when the catalog has no default service tier", () => { }, ]); }); + +it("marks the most preferred available model as default", () => { + const models = applyPreferredCodexDefaultModel([ + { slug: "gpt-5.6-terra", name: "GPT-5.6-Terra", isCustom: false, capabilities: null }, + { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, + ]); + + assert.deepStrictEqual( + models.map((model) => ({ slug: model.slug, isDefault: model.isDefault })), + [ + { slug: "gpt-5.6-terra", isDefault: true }, + { slug: "gpt-5.4", isDefault: undefined }, + ], + ); +}); + +it("prefers sol over terra when both are available", () => { + const models = applyPreferredCodexDefaultModel([ + { slug: "gpt-5.6-terra", name: "GPT-5.6-Terra", isCustom: false, capabilities: null }, + { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, + ]); + + assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.6-sol"); +}); + +it("keeps Codex's own default when no preferred model is available", () => { + const models = applyPreferredCodexDefaultModel([ + { slug: "gpt-5.5", name: "GPT-5.5", isCustom: false, capabilities: null }, + { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, + ]); + + assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); +}); + +it("ignores custom models that shadow a preferred slug", () => { + const models = applyPreferredCodexDefaultModel([ + { slug: "gpt-5.6-sol", name: "gpt-5.6-sol", isCustom: true, capabilities: null }, + { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, + ]); + + assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); +}); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 9306087a0bc2..1ed9c750c186 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -22,7 +22,7 @@ import type { ServerProviderModel, ServerProviderSkill, } from "@t3tools/contracts"; -import { ServerSettingsError } from "@t3tools/contracts"; +import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; @@ -189,10 +189,36 @@ function parseCodexModelListResponse( slug: model.model, name: toDisplayName(model), isCustom: false, + ...(model.isDefault ? { isDefault: true } : {}), capabilities: mapCodexModelCapabilities(model), })); } +/** + * Prefer our own default-model ranking when one of the preferred slugs is in + * the live catalog; otherwise keep whatever Codex itself flagged as default. + */ +export function applyPreferredCodexDefaultModel( + models: ReadonlyArray, +): ReadonlyArray { + const preferredSlug = PREFERRED_DEFAULT_CODEX_MODELS.find((slug) => + models.some((model) => model.slug === slug && !model.isCustom), + ); + if (!preferredSlug) { + return models; + } + return models.map((model) => { + if (model.slug === preferredSlug) { + return model.isDefault ? model : { ...model, isDefault: true }; + } + if (!model.isDefault) { + return model; + } + const { isDefault: _isDefault, ...rest } = model; + return rest; + }); +} + function appendCustomCodexModels( models: ReadonlyArray, customModels: ReadonlyArray, @@ -376,7 +402,9 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun return { account: accountResponse, version, - models: appendCustomCodexModels(models, input.customModels ?? []), + models: applyPreferredCodexDefaultModel( + appendCustomCodexModels(models, input.customModels ?? []), + ), skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), } satisfies CodexAppServerProviderSnapshot; }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 259eb794e7d1..e315ee037c3f 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1590,48 +1590,53 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); - it.effect( - "includes Claude Sonnet 5 with reasoning and no direct Anthropic context selector", - () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - const sonnet5 = status.models.find((model) => model.slug === "claude-sonnet-5"); - assert.strictEqual(sonnet5?.name, "Claude Sonnet 5"); - const effortDescriptor = sonnet5?.capabilities?.optionDescriptors?.find( - (descriptor) => descriptor.type === "select" && descriptor.id === "effort", - ); + it.effect("includes Claude Sonnet 5 with reasoning and a 200k-default context selector", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + const sonnet5 = status.models.find((model) => model.slug === "claude-sonnet-5"); + assert.strictEqual(sonnet5?.name, "Claude Sonnet 5"); + const effortDescriptor = sonnet5?.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.type === "select" && descriptor.id === "effort", + ); + assert.deepStrictEqual( + effortDescriptor?.type === "select" + ? effortDescriptor.options.find((option) => option.isDefault) + : undefined, + { id: "high", label: "High", isDefault: true }, + ); + assert.ok( + effortDescriptor?.type === "select" && + effortDescriptor.options.some((option) => option.id === "xhigh"), + ); + const contextDescriptor = sonnet5?.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.type === "select" && descriptor.id === "contextWindow", + ); + assert.ok(contextDescriptor?.type === "select"); + if (contextDescriptor?.type === "select") { assert.deepStrictEqual( - effortDescriptor?.type === "select" - ? effortDescriptor.options.find((option) => option.isDefault) - : undefined, - { id: "high", label: "High", isDefault: true }, - ); - assert.ok( - effortDescriptor?.type === "select" && - effortDescriptor.options.some((option) => option.id === "xhigh"), - ); - const contextDescriptor = sonnet5?.capabilities?.optionDescriptors?.find( - (descriptor) => descriptor.type === "select" && descriptor.id === "contextWindow", + contextDescriptor.options.find((option) => option.isDefault), + { id: "200k", label: "200k", isDefault: true }, ); - assert.strictEqual(contextDescriptor, undefined); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.197\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), + assert.ok(contextDescriptor.options.some((option) => option.id === "1m")); + } + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.197\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), ), + ), ); it.effect("exposes the Sonnet 5 context selector for Anthropic gateway environments", () => @@ -1673,14 +1678,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); - it("keeps xhigh as a supported Claude CLI effort for native-1M Sonnet 5", () => { + it("keeps xhigh as a supported Claude CLI effort for Sonnet 5", () => { assert.strictEqual(normalizeClaudeCliEffort("xhigh", "claude-sonnet-5"), "xhigh"); assert.strictEqual(normalizeClaudeCliEffort("xhigh", "claude-sonnet-4-6"), "max"); assert.strictEqual( getClaudeModelCapabilities("claude-sonnet-5").optionDescriptors?.some( (descriptor) => descriptor.id === "contextWindow", ), - false, + true, ); }); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 84b932af3b08..fa76e9e85c98 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -471,10 +471,10 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { readEvents: () => Stream.empty, dispatch: () => Effect.succeed({ sequence: 1 }), streamDomainEvents: Stream.fromQueue(events), - subscribeDomainEvents: Effect.flatMap( - PubSub.unbounded(), - PubSub.subscribe, + subscribeDomainEvents: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), ), + latestSequence: Effect.succeed(0), } satisfies OrchestrationEngineShape; const snapshotQuery = { @@ -666,8 +666,9 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { streamDomainEvents: Stream.fromQueue(events), subscribeDomainEvents: Effect.flatMap( PubSub.unbounded(), - PubSub.subscribe, + (pubsub) => PubSub.subscribe(pubsub), ), + latestSequence: Effect.succeed(0), } satisfies OrchestrationEngineShape), Layer.succeed(ProjectionSnapshotQuery, { getShellSnapshot: () => diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 02527a2ae203..131624ff9f7f 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -46,6 +46,7 @@ import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; @@ -680,6 +681,7 @@ const buildAppUnderTest = (options?: { subscribeDomainEvents: Effect.flatMap(PubSub.unbounded(), (pubsub) => PubSub.subscribe(pubsub), ), + latestSequence: Effect.succeed(0), ...options?.layers?.orchestrationEngine, }), ), @@ -4264,7 +4266,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.deepEqual(first.config.keybindings, []); assert.deepEqual(first.config.issues, []); assert.deepEqual(first.config.providers, providers); - assert.equal(first.config.observability.logsDirectoryPath.split(/[\\/]/).at(-1), "logs"); + assert.equal(first.config.observability.logsDirectoryPath.endsWith("/logs"), true); assert.equal(first.config.observability.localTracingEnabled, true); assert.equal(first.config.observability.otlpTracesUrl, "http://localhost:4318/v1/traces"); assert.equal(first.config.observability.otlpTracesEnabled, true); @@ -5692,7 +5694,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { projectionSnapshotQuery: { getShellSnapshot: () => Effect.gen(function* () { - yield* Effect.sleep("25 millis"); yield* PubSub.publish(liveEvents, deletedEvent); return { snapshotSequence: 1, @@ -5756,10 +5757,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { projectionSnapshotQuery: { getThreadDetailSnapshot: () => Effect.gen(function* () { - // Publish immediately during snapshot load. The subscribeThread - // path acquires the PubSub subscription synchronously before - // forking the live buffer, so this must still be delivered — - // no sleep to paper over the fork/subscribe race. + yield* Effect.sleep("25 millis"); yield* PubSub.publish(liveEvents, messageEvent); return Option.some({ snapshotSequence: 1, thread }); }), @@ -5779,6 +5777,474 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(items[0]?.kind, "snapshot"); assert.equal(items[1]?.kind, "event"); assert.equal(items[1]?.kind === "event" ? items[1].event.sequence : null, 2); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + + it.effect("subscribeShell sends a fresh snapshot instead of replaying a large gap", () => + Effect.gen(function* () { + let readEventsCalls = 0; + const snapshotThreadId = ThreadId.make("thread-from-snapshot"); + const now = "2026-01-01T00:00:00.000Z"; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + // Head is far ahead of the client's afterSequence (gap > 1000). + latestSequence: Effect.succeed(100_000), + readEvents: () => + Stream.sync(() => { + readEventsCalls += 1; + return { + sequence: 1, + eventId: EventId.make("event-should-not-be-read"), + aggregateKind: "thread", + aggregateId: snapshotThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.created", + payload: {} as never, + } satisfies OrchestrationEvent; + }), + }, + projectionSnapshotQuery: { + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 100_000, + projects: [], + threads: [makeDefaultOrchestrationThreadShell({ id: snapshotThreadId })], + updatedAt: now, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + afterSequence: 5, + requestCompletionMarker: true, + }).pipe(Stream.take(2), Stream.runCollect), + ), + ); + + const [first, second] = Array.from(items); + // Large gap => fresh snapshot, and the unbounded replay is never started. + assert.equal(first?.kind, "snapshot"); + if (first?.kind === "snapshot") { + assert.equal(first.snapshot.threads[0]?.id, snapshotThreadId); + } + assert.equal(second?.kind, "synchronized"); + assert.equal(readEventsCalls, 0); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("subscribeShell replaces a cursor ahead of the authoritative head", () => + Effect.gen(function* () { + let readEventsCalls = 0; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + latestSequence: Effect.succeed(5), + readEvents: () => + Stream.sync(() => { + readEventsCalls += 1; + return {} as OrchestrationEvent; + }), + }, + projectionSnapshotQuery: { + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 5, + projects: [], + threads: [], + updatedAt: "2026-01-01T00:00:00.000Z", + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const first = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ afterSequence: 10 }).pipe( + Stream.runHead, + ), + ), + ); + + assert.equal(Option.getOrThrow(first).kind, "snapshot"); + assert.equal(readEventsCalls, 0); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("subscribeShell coalesces a per-thread burst without stalling other threads", () => + Effect.gen(function* () { + const busyThreadId = ThreadId.make("thread-busy"); + const newThreadId = ThreadId.make("thread-new"); + const now = "2026-01-01T00:00:00.000Z"; + const shellFetches: Array = []; + let replayLimit: number | undefined; + + const messageEvent = (sequence: number): OrchestrationEvent => + ({ + sequence, + eventId: EventId.make(`event-${sequence}`), + aggregateKind: "thread", + aggregateId: busyThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: {} as never, + }) satisfies OrchestrationEvent; + + const createdEvent: OrchestrationEvent = { + sequence: 50, + eventId: EventId.make("event-created"), + aggregateKind: "thread", + aggregateId: newThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.created", + payload: {} as never, + }; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + latestSequence: Effect.succeed(50), + // A burst of message-sent deltas for the busy thread, plus one + // thread.created for a different thread, all within one batch. + readEvents: (_afterSequence, limit) => { + replayLimit = limit; + return Stream.fromIterable([ + ...Array.from({ length: 20 }, (_unused, index) => messageEvent(index + 1)), + createdEvent, + ]); + }, + }, + projectionSnapshotQuery: { + getThreadShellById: (threadId) => + Effect.sync(() => { + shellFetches.push(threadId); + return Option.some(makeDefaultOrchestrationThreadShell({ id: threadId })); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + afterSequence: 0, + requestCompletionMarker: true, + }).pipe(Stream.take(3), Stream.runCollect), + ), + ); + + const collected = Array.from(items); + const upsertedIds = collected.flatMap((item) => + item.kind === "thread-upserted" ? [item.thread.id] : [], + ); + // Both threads surface, and the busy thread's 20-event burst collapses to + // a single shell refetch (not 20). The new thread is not stuck behind it. + assert.include(upsertedIds, busyThreadId); + assert.include(upsertedIds, newThreadId); + assert.equal(collected[2]?.kind, "synchronized"); + assert.equal(shellFetches.filter((id) => id === busyThreadId).length, 1); + assert.equal(replayLimit, 50); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("subscribeShell coalesces live bursts after the synchronization marker", () => + Effect.gen(function* () { + const busyThreadId = ThreadId.make("thread-live-busy"); + const newThreadId = ThreadId.make("thread-live-new"); + const now = "2026-01-01T00:00:00.000Z"; + const liveEvents = yield* PubSub.unbounded(); + const synchronized = yield* Deferred.make(); + const shellFetches: Array = []; + const observedLiveThreadIds = new Set(); + + const messageEvent = (sequence: number): OrchestrationEvent => + ({ + sequence, + eventId: EventId.make(`event-live-${sequence}`), + aggregateKind: "thread", + aggregateId: busyThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: {} as never, + }) satisfies OrchestrationEvent; + + const createdEvent: OrchestrationEvent = { + sequence: 50, + eventId: EventId.make("event-live-created"), + aggregateKind: "thread", + aggregateId: newThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.created", + payload: {} as never, + }; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + streamDomainEvents: Stream.fromPubSub(liveEvents), + subscribeDomainEvents: PubSub.subscribe(liveEvents), + }, + projectionSnapshotQuery: { + getThreadShellById: (threadId) => + Effect.sync(() => { + shellFetches.push(threadId); + return Option.some(makeDefaultOrchestrationThreadShell({ id: threadId })); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + Effect.gen(function* () { + const itemsFiber = yield* withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + requestCompletionMarker: true, + }).pipe( + Stream.tap((item) => + item.kind === "synchronized" + ? Deferred.succeed(synchronized, undefined).pipe(Effect.ignore) + : Effect.void, + ), + Stream.takeUntil((item) => { + if (item.kind === "thread-upserted") { + observedLiveThreadIds.add(item.thread.id); + } + return ( + observedLiveThreadIds.has(busyThreadId) && observedLiveThreadIds.has(newThreadId) + ); + }), + Stream.runCollect, + ), + ).pipe(Effect.forkScoped); + + yield* Deferred.await(synchronized); + for (const event of [ + ...Array.from({ length: 20 }, (_unused, index) => messageEvent(index + 1)), + createdEvent, + ]) { + yield* PubSub.publish(liveEvents, event); + } + + return yield* Fiber.join(itemsFiber); + }), + ).pipe(Effect.timeout("2 seconds")); + + assert.equal(items[0]?.kind, "snapshot"); + assert.equal(items[1]?.kind, "synchronized"); + const liveUpsertedIds = Array.from(items) + .slice(2) + .flatMap((item) => (item.kind === "thread-upserted" ? [item.thread.id] : [])); + assert.include(liveUpsertedIds, busyThreadId); + assert.include(liveUpsertedIds, newThreadId); + assert.isBelow(shellFetches.filter((id) => id === busyThreadId).length, 20); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + + it.effect("subscribeShell coalescing still emits a removal for a deleted thread", () => + Effect.gen(function* () { + const goneThreadId = ThreadId.make("thread-gone"); + const now = "2026-01-01T00:00:00.000Z"; + + const makeThreadEvent = ( + sequence: number, + type: "thread.deleted" | "thread.message-sent", + ): OrchestrationEvent => + ({ + sequence, + eventId: EventId.make(`event-${sequence}`), + aggregateKind: "thread", + aggregateId: goneThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type, + payload: type === "thread.deleted" ? { threadId: goneThreadId, deletedAt: now } : {}, + }) as OrchestrationEvent; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + latestSequence: Effect.succeed(2), + // A thread.deleted followed, within the same coalescing window, by a + // later refetchable event for the same thread. The later event wins + // coalescing; its shell refetch returns none (the row is gone), which + // must still surface a removal rather than be swallowed. + readEvents: () => + Stream.fromIterable([ + makeThreadEvent(1, "thread.deleted"), + makeThreadEvent(2, "thread.message-sent"), + ]), + }, + projectionSnapshotQuery: { + getThreadShellById: () => Effect.succeed(Option.none()), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ afterSequence: 0 }).pipe( + Stream.take(1), + Stream.runCollect, + ), + ), + ); + + const [first] = Array.from(items); + assert.equal(first?.kind, "thread-removed"); + assert.equal(first?.kind === "thread-removed" ? first.threadId : null, goneThreadId); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("subscribeShell retries a transient shell projection refetch failure", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-transient-refetch"); + const now = "2026-01-01T00:00:00.000Z"; + let attempts = 0; + + const event: OrchestrationEvent = { + sequence: 1, + eventId: EventId.make("event-transient-refetch"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: {} as never, + }; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + latestSequence: Effect.succeed(1), + readEvents: () => Stream.make(event), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.suspend(() => { + attempts += 1; + return attempts === 1 + ? Effect.fail( + new PersistenceSqlError({ + operation: "test.shell-refetch", + detail: "transient failure", + }), + ) + : Effect.succeed( + Option.some(makeDefaultOrchestrationThreadShell({ id: threadId })), + ); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ afterSequence: 0 }).pipe( + Stream.take(1), + Stream.runCollect, + ), + ), + ); + + const [first] = Array.from(items); + assert.equal(first?.kind, "thread-upserted"); + assert.equal(first?.kind === "thread-upserted" ? first.thread.id : null, threadId); + assert.equal(attempts, 2); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("subscribeShell coalescing still removes a project after a trailing update", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project-gone"); + const now = "2026-01-01T00:00:00.000Z"; + + const makeProjectEvent = ( + sequence: number, + type: "project.deleted" | "project.meta-updated", + ): OrchestrationEvent => + ({ + sequence, + eventId: EventId.make(`event-project-${sequence}`), + aggregateKind: "project", + aggregateId: projectId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type, + payload: + type === "project.deleted" + ? { projectId, deletedAt: now } + : { projectId, title: "Still deleted", updatedAt: now }, + }) as OrchestrationEvent; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + latestSequence: Effect.succeed(2), + readEvents: () => + Stream.fromIterable([ + makeProjectEvent(1, "project.deleted"), + makeProjectEvent(2, "project.meta-updated"), + ]), + }, + projectionSnapshotQuery: { + getProjectShellById: () => Effect.succeed(Option.none()), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ afterSequence: 0 }).pipe( + Stream.take(1), + Stream.runCollect, + ), + ), + ); + + const [first] = Array.from(items); + assert.equal(first?.kind, "project-removed"); + assert.equal(first?.kind === "project-removed" ? first.projectId : null, projectId); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index a05d2147b9f4..78e6442c7a68 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -1,11 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { - DEFAULT_MODEL, - type OrchestrationEvent, - ProjectId, - ProviderInstanceId, - ThreadId, -} from "@t3tools/contracts"; +import { DEFAULT_MODEL, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import type { OrchestrationEvent } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; @@ -23,6 +18,11 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; +const unusedSubscribeDomainEvents = Effect.flatMap( + PubSub.unbounded(), + (pubsub) => PubSub.subscribe(pubsub), +); + it("uses the canonical Codex default for auto-bootstrapped model selection", () => { assert.deepStrictEqual(ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), { instanceId: ProviderInstanceId.make("codex"), @@ -175,10 +175,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa Effect.as({ sequence: 1 }), ), streamDomainEvents: Stream.empty, - subscribeDomainEvents: Effect.flatMap( - PubSub.unbounded(), - PubSub.subscribe, - ), + subscribeDomainEvents: unusedSubscribeDomainEvents, + latestSequence: Effect.succeed(0), } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), Effect.provide(NodeServices.layer), ); @@ -222,10 +220,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when Effect.as({ sequence: 1 }), ), streamDomainEvents: Stream.empty, - subscribeDomainEvents: Effect.flatMap( - PubSub.unbounded(), - PubSub.subscribe, - ), + subscribeDomainEvents: unusedSubscribeDomainEvents, + latestSequence: Effect.succeed(0), } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), Effect.provide(NodeServices.layer), ); @@ -275,10 +271,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa Effect.as({ sequence: 1 }), ), streamDomainEvents: Stream.empty, - subscribeDomainEvents: Effect.flatMap( - PubSub.unbounded(), - PubSub.subscribe, - ), + subscribeDomainEvents: unusedSubscribeDomainEvents, + latestSequence: Effect.succeed(0), } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), Effect.provideService(Crypto.Crypto, { ...crypto, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 8c627525b4cf..9ffd3ed696de 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -596,6 +596,26 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("marks the origin default ref as default when no local copy exists", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(cwd, ["remote", "set-head", "origin", initialBranch]); + yield* git(cwd, ["checkout", "-b", "feature/only-local"]); + yield* git(cwd, ["branch", "-D", initialBranch]); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const refs = yield* driver.listRefs({ cwd }); + const remoteDefault = refs.refs.find((ref) => ref.name === `origin/${initialBranch}`); + assert.equal(remoteDefault?.isRemote, true); + assert.equal(remoteDefault?.isDefault, true); + }), + ); + it.effect("creates, checks out, renames, and lists refs", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index ea221e060248..131087f31c48 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2219,7 +2219,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* name: refName.name, current: false, isRemote: true, - isDefault: false, + // origin/HEAD's target is the repo default even when no local + // copy of the default branch exists. + isDefault: + defaultBranch !== null && + parsedRemoteRef?.remoteName === "origin" && + parsedRemoteRef.branchName === defaultBranch, worktreePath: null, }; if (parsedRemoteRef) { @@ -2234,9 +2239,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }) : []; - const allBranches = input.includeMatchingRemoteRefs + const combinedBranches = input.includeMatchingRemoteRefs ? [...localBranches, ...remoteBranches] : dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]); + // Keep current/default refs on the first page even when the default + // only exists as origin/ (remote refs sort after all locals). + const allBranches = combinedBranches.toSorted((a, b) => { + const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; + const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; + return aPriority - bPriority; + }); const branchesForKind = input.refKind === "local" ? allBranches.filter((ref) => !ref.isRemote) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 852749b6d22d..7b6ba0df75db 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -36,6 +36,7 @@ import { OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, + type ProjectId, type ProjectEntriesFailure, type ProjectFileFailure, type ProjectFileOperation, @@ -276,6 +277,13 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< const PROVIDER_STATUS_DEBOUNCE_MS = 200; +// When a resuming client's cursor is more than this many events behind the +// current head, skip the per-event catch-up replay and send a fresh shell +// snapshot instead. Replaying each intervening event costs a shell refetch; +// past this gap a single O(active-threads) snapshot is cheaper and bounded. +// Matches the event store's default page size (DEFAULT_READ_FROM_SEQUENCE_LIMIT). +const SHELL_RESUME_MAX_GAP = 1_000; + const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope], [ORCHESTRATION_WS_METHODS.getTurnDiff, AuthOrchestrationReadScope], @@ -621,16 +629,7 @@ const makeWsRpcLayer = ( switch (event.type) { case "project.created": case "project.meta-updated": - return projectionSnapshotQuery.getProjectShellById(event.payload.projectId).pipe( - Effect.map((project) => - Option.map(project, (nextProject) => ({ - kind: "project-upserted" as const, - sequence: event.sequence, - project: nextProject, - })), - ), - Effect.orElseSucceed(() => Option.none()), - ); + return projectUpsertOrRemove(event.payload.projectId, event.sequence); case "project.deleted": return Effect.succeed( Option.some({ @@ -649,35 +648,192 @@ const makeWsRpcLayer = ( }), ); case "thread.unarchived": - return projectionSnapshotQuery.getThreadShellById(event.payload.threadId).pipe( - Effect.map((thread) => - Option.map(thread, (nextThread) => ({ - kind: "thread-upserted" as const, - sequence: event.sequence, - thread: nextThread, - })), - ), - Effect.orElseSucceed(() => Option.none()), - ); + return threadUpsertOrRemove(event.payload.threadId, event.sequence); default: if (event.aggregateKind !== "thread") { return Effect.succeed(Option.none()); } - return projectionSnapshotQuery - .getThreadShellById(ThreadId.make(event.aggregateId)) - .pipe( - Effect.map((thread) => - Option.map(thread, (nextThread) => ({ - kind: "thread-upserted" as const, - sequence: event.sequence, - thread: nextThread, - })), - ), - Effect.orElseSucceed(() => Option.none()), - ); + return threadUpsertOrRemove(ThreadId.make(event.aggregateId), event.sequence); } }; + // Coalescing makes each projection read represent every event for that + // aggregate in the current window. Retry a typed persistence failure once + // so a brief read failure cannot strand the shell at its previous state. + // If both attempts fail, log and drop the stream item; treating an error as + // a missing row would incorrectly remove a still-active aggregate. + const retryShellProjectionRead = ( + aggregateKind: "project" | "thread", + aggregateId: string, + read: Effect.Effect, + ): Effect.Effect, never, never> => + read.pipe( + Effect.retry({ times: 1 }), + Effect.map(Option.some), + Effect.tapError((error) => + Effect.logWarning("orchestration shell projection refetch failed", { + aggregateKind, + aggregateId, + error, + }), + ), + Effect.orElseSucceed(() => Option.none()), + ); + + const projectUpsertOrRemove = ( + projectId: ProjectId, + sequence: number, + ): Effect.Effect, never, never> => + retryShellProjectionRead( + "project", + projectId, + projectionSnapshotQuery.getProjectShellById(projectId), + ).pipe( + Effect.map( + Option.flatMap((project) => + Option.match(project, { + onNone: () => + Option.some({ + kind: "project-removed" as const, + sequence, + projectId, + }), + onSome: (nextProject) => + Option.some({ + kind: "project-upserted" as const, + sequence, + project: nextProject, + }), + }), + ), + ), + ); + + // Refetch a thread's shell and emit an upsert if it is still active, or a + // `thread-removed` if the projection has no active row for it. Emitting a + // removal on a `none` (rather than dropping the event) is what keeps + // coalescing correct: when a burst collapses a `thread.deleted`/`archived` + // into a later refetchable event for the same thread, the refetch returns + // `none` for the now-inactive row and this still tells the sidebar to drop + // it. A `thread-removed` the client does not have is a harmless no-op. The + // projection commits in the same transaction before the event publishes, + // so a `none` reliably means the thread is deleted or archived, not + // not-yet-persisted. + const threadUpsertOrRemove = ( + threadId: ThreadId, + sequence: number, + ): Effect.Effect, never, never> => + retryShellProjectionRead( + "thread", + threadId, + projectionSnapshotQuery.getThreadShellById(threadId), + ).pipe( + Effect.map( + Option.flatMap((thread) => + Option.match(thread, { + onNone: () => + Option.some({ + kind: "thread-removed" as const, + sequence, + threadId, + }), + onSome: (nextThread) => + Option.some({ + kind: "thread-upserted" as const, + sequence, + thread: nextThread, + }), + }), + ), + ), + ); + + // Turn a batch of domain events into shell stream items, coalescing by + // aggregate first. `toShellStreamEvent` re-reads the *current* projected + // shell for an aggregate, so within a batch only the latest event per + // aggregate matters: a burst of streaming `thread.message-sent` deltas for + // one thread collapses into a single shell refetch, and an unrelated + // `thread.created` in the same batch is never stuck behind those DB reads. + // + // Input events arrive in ascending sequence; we keep the last (highest + // sequence) event per aggregate, then re-sort ascending before emitting so + // the client — which applies shell items strictly by increasing sequence + // and drops any `sequence <= snapshotSequence` — never skips a coalesced + // item. The refetch runs with bounded concurrency (order-preserving). + const SHELL_REFETCH_CONCURRENCY = 8; + const coalesceShellEvents = ( + events: ReadonlyArray, + ): Effect.Effect, never, never> => + Effect.gen(function* () { + if (events.length === 0) { + return []; + } + const latestByAggregate = new Map(); + for (const event of events) { + latestByAggregate.set(`${event.aggregateKind}:${event.aggregateId}`, event); + } + const survivors = Array.from(latestByAggregate.values()).sort( + (left, right) => left.sequence - right.sequence, + ); + const shellEvents = yield* Effect.forEach(survivors, toShellStreamEvent, { + concurrency: SHELL_REFETCH_CONCURRENCY, + }); + return shellEvents.flatMap((option) => (Option.isSome(option) ? [option.value] : [])); + }); + + // Small time/size window over which to coalesce shell events. The window + // bounds the worst-case added latency for a brand-new thread to appear in + // the sidebar (imperceptible), while collapsing high-frequency streaming + // traffic so it can't serialize the shell stream behind per-event DB reads. + const SHELL_COALESCE_WINDOW = Duration.millis(50); + const SHELL_COALESCE_MAX_CHUNK = 512; + const coalesceShellStream = ( + stream: Stream.Stream, + ): Stream.Stream => + stream.pipe( + Stream.groupedWithin(SHELL_COALESCE_MAX_CHUNK, SHELL_COALESCE_WINDOW), + Stream.mapEffect(coalesceShellEvents), + Stream.flatMap((items) => Stream.fromIterable(items)), + ); + + type ShellLiveInput = + | { readonly kind: "event"; readonly event: OrchestrationEvent } + | { readonly kind: "synchronized" }; + + // A completion marker is queued alongside raw live events so it cannot + // overtake an event still waiting in the coalescing window. Split each + // batch at markers and coalesce only the event segments on either side. + const coalesceShellLiveInputs = ( + inputs: ReadonlyArray, + ): Effect.Effect, never, never> => + Effect.gen(function* () { + const output: Array = []; + let pendingEvents: Array = []; + + for (const input of inputs) { + if (input.kind === "event") { + pendingEvents.push(input.event); + continue; + } + + output.push(...(yield* coalesceShellEvents(pendingEvents))); + pendingEvents = []; + output.push({ kind: "synchronized" }); + } + + output.push(...(yield* coalesceShellEvents(pendingEvents))); + return output; + }); + + const coalesceShellLiveStream = ( + stream: Stream.Stream, + ): Stream.Stream => + stream.pipe( + Stream.groupedWithin(SHELL_COALESCE_MAX_CHUNK, SHELL_COALESCE_WINDOW), + Stream.mapEffect(coalesceShellLiveInputs), + Stream.flatMap((items) => Stream.fromIterable(items)), + ); + const dispatchBootstrapTurnStart = ( command: Extract, ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => @@ -1068,62 +1224,35 @@ const makeWsRpcLayer = ( observeRpcStreamEffect( ORCHESTRATION_WS_METHODS.subscribeShell, Effect.gen(function* () { + // Coalesce the live shell stream per aggregate over a small window + // so bursts of high-frequency events (streaming message deltas, + // activity appends) collapse into a single shell refetch and never + // serialize a brand-new thread's `thread.created` behind hundreds + // of per-event DB reads. See coalesceShellStream. + // Attach live delivery into a scope-bound buffer BEFORE loading any + // snapshot or draining catch-up, otherwise an event published while + // the snapshot query is in flight is lost (it is past the snapshot's + // sequence but the live subscription is not attached yet). Every + // path below emits from this same buffered live tail. Overlapping + // events are deduped by sequence on the client. + // // Acquire the PubSub subscription synchronously before forking. // `Stream.fromPubSub` defers subscribe until stream start, and // `forkScoped` only schedules the fibre — so an event published // between schedule and start would still drop without this. - const liveBuffer = yield* Queue.unbounded(); + const liveBuffer = yield* Queue.unbounded(); const liveSubscription = yield* orchestrationEngine.subscribeDomainEvents; yield* Effect.forkScoped( Stream.fromSubscription(liveSubscription).pipe( - Stream.mapEffect(toShellStreamEvent), - Stream.flatMap((event) => - Option.isSome(event) ? Stream.succeed(event.value) : Stream.empty, + Stream.runForEach((event) => + Queue.offer(liveBuffer, { kind: "event" as const, event }), ), - Stream.runForEach((item) => Queue.offer(liveBuffer, item)), ), + { startImmediately: true }, ); - const bufferedLiveStream = Stream.fromQueue(liveBuffer); - - // When the client already holds a shell snapshot (cached, or loaded - // over HTTP) it passes that snapshot's sequence, and we resume by - // replaying shell events after it instead of re-sending the whole - // projects/threads list over the socket. As in the thread path, the - // live subscription is attached (into a scope-bound buffer) before - // draining the catch-up replay so no event published during the - // replay window is lost; overlapping events are deduped by sequence - // on the client. The full range is read (not the store's default - // page limit) since the shell filter runs after reading. - if (input.afterSequence !== undefined) { - const afterSequence = input.afterSequence; - const catchUpStream = orchestrationEngine - .readEvents(afterSequence, Number.MAX_SAFE_INTEGER) - .pipe( - Stream.mapEffect(toShellStreamEvent), - Stream.flatMap((event) => - Option.isSome(event) ? Stream.succeed(event.value) : Stream.empty, - ), - Stream.mapError( - (cause) => - new OrchestrationGetSnapshotError({ - message: "Failed to replay orchestration shell events", - cause, - }), - ), - ); - const afterCatchUp = - input.requestCompletionMarker === true - ? Stream.concat( - Stream.fromEffect( - Queue.offer(liveBuffer, { kind: "synchronized" as const }), - ).pipe(Stream.drain), - bufferedLiveStream, - ) - : bufferedLiveStream; - return Stream.concat(catchUpStream, afterCatchUp); - } + const bufferedLiveStream = coalesceShellLiveStream(Stream.fromQueue(liveBuffer)); - const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe( + const loadSnapshot = projectionSnapshotQuery.getShellSnapshot().pipe( Effect.tapError((cause) => Effect.logError("orchestration shell snapshot load failed", { cause }), ), @@ -1136,21 +1265,70 @@ const makeWsRpcLayer = ( ), ); - const afterSnapshot = + // Offer the completion marker into the same queue as live events. + // Anything buffered while snapshot/replay work was in flight is + // therefore delivered before the client is told it is synchronized. + const synchronizedThenLive = input.requestCompletionMarker === true ? Stream.concat( Stream.fromEffect( - Queue.offer(liveBuffer, { kind: "synchronized" as const }), - ).pipe(Stream.drain), + Queue.offer(liveBuffer, { kind: "synchronized" as const }).pipe( + Effect.andThen(Queue.takeAll(liveBuffer)), + Effect.flatMap(coalesceShellLiveInputs), + ), + ).pipe(Stream.flatMap((items) => Stream.fromIterable(items))), bufferedLiveStream, ) : bufferedLiveStream; + + // When the client already holds a shell snapshot (cached, or loaded + // over HTTP) it passes that snapshot's sequence, and we resume by + // replaying shell events after it instead of re-sending the whole + // projects/threads list over the socket. If the client is too far + // behind, we fall back to a fresh snapshot instead of an unbounded + // replay (see below). + if (input.afterSequence !== undefined) { + const afterSequence = input.afterSequence; + const headSequence = yield* orchestrationEngine.latestSequence; + const replayGap = headSequence - afterSequence; + // Gap too large: replaying every intervening event (each a shell + // refetch) is far more expensive than a single O(active-threads) + // snapshot. A cursor ahead of this engine's authoritative state + // is also invalid, so reset it with a snapshot. Send the snapshot + // followed by the buffered live tail, exactly as the + // no-afterSequence path does. + if (replayGap < 0 || replayGap > SHELL_RESUME_MAX_GAP) { + const snapshot = yield* loadSnapshot; + return Stream.concat( + Stream.make({ kind: "snapshot" as const, snapshot }), + synchronizedThenLive, + ); + } + const catchUpStream = coalesceShellStream( + // Replay only through the head captured above. Newer events + // are already covered by the live subscription, so this bound + // cannot chase a moving event-store head or grow the live + // buffer indefinitely while waiting for an empty page. + orchestrationEngine.readEvents(afterSequence, replayGap), + ).pipe( + Stream.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to replay orchestration shell events", + cause, + }), + ), + ); + return Stream.concat(catchUpStream, synchronizedThenLive); + } + + const snapshot = yield* loadSnapshot; return Stream.concat( Stream.make({ kind: "snapshot" as const, snapshot, }), - afterSnapshot, + synchronizedThenLive, ); }), { "rpc.aggregate": "orchestration" }, diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 673b093e333e..701af3a79fc9 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -9,7 +9,15 @@ import { usePreparedConnection } from "~/state/session"; export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; -export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { +export type AssetUrlState = + | { readonly _tag: "Loading" } + | { readonly _tag: "Failure" } + | { readonly _tag: "Success"; readonly url: string }; + +export function useAssetUrlState( + environmentId: EnvironmentId, + resource: AssetResource, +): AssetUrlState { const preparedConnection = usePreparedConnection(environmentId); const result = useAtomValue( assetEnvironment.createUrl({ @@ -17,10 +25,22 @@ export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResourc input: { resource }, }), ); + if (result._tag === "Failure") { + return { _tag: "Failure" }; + } if (preparedConnection._tag === "None" || result._tag !== "Success") { + return { _tag: "Loading" }; + } + const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); + return url === null ? { _tag: "Failure" } : { _tag: "Success", url }; +} + +export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { + const result = useAssetUrlState(environmentId, resource); + if (result._tag !== "Success") { return null; } - return resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); + return result.url; } export function useAssetUrls( diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index ee56858bc588..6c692dc3de8b 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,13 +1,22 @@ import { useAtomValue } from "@effect/atom-react"; +import * as Schema from "effect/Schema"; import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; +import { getLocalStorageItem } from "../hooks/useLocalStorage"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; import ThreadSidebar from "./Sidebar"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; +import { + resolveInitialThreadSidebarWidth, + resolveThreadSidebarMaximumWidth, + THREAD_MAIN_CONTENT_MIN_WIDTH, + THREAD_SIDEBAR_MIN_WIDTH, + THREAD_SIDEBAR_WIDTH_STORAGE_KEY, +} from "./threadSidebarWidth"; import { Sidebar, SidebarProvider, @@ -18,11 +27,20 @@ import { } from "./ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; -const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; -const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; +function readInitialThreadSidebarWidth(): number { + try { + return resolveInitialThreadSidebarWidth( + getLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY, Schema.Finite), + window.innerWidth, + ); + } catch (error) { + console.error("Could not read persisted thread sidebar width.", error); + return resolveInitialThreadSidebarWidth(null, window.innerWidth); + } +} + function SidebarControl() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { toggleSidebar } = useSidebar(); @@ -82,16 +100,20 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); const pathname = useLocation({ select: (location) => location.pathname }); const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); + const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); + const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(window.innerWidth); const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; return isMacosDesktop && typeof getWindowFullscreenState === "function" ? getWindowFullscreenState() : false; }); - const macosWindowControlsStyle = - isMacosDesktop && !isWindowFullscreen - ? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties) - : undefined; + const sidebarProviderStyle = { + "--sidebar-width": `${sidebarWidth}px`, + ...(isMacosDesktop && !isWindowFullscreen + ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } + : {}), + } as CSSProperties; useEffect(() => { if (!isMacosDesktop) return; @@ -131,17 +153,19 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }, [navigate, pathname]); return ( - + nextWidth <= currentWidth || wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, + onResize: setSidebarWidth, }} > diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 94a3909a9614..8291e5e006e8 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -12,6 +12,7 @@ import { resolveBranchToolbarValue, resolveLockedWorkspaceLabel, shouldIncludeBranchPickerItem, + shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; const localEnvironmentId = EnvironmentId.make("environment-local"); @@ -119,6 +120,44 @@ describe("resolveEnvironmentOptionLabel", () => { }); }); +describe("shouldShowEnvironmentIndicator", () => { + it("shows the indicator whenever multiple environments are pickable", () => { + expect( + shouldShowEnvironmentIndicator({ + activeEnvironment: { isPrimary: true }, + canPickEnvironment: true, + }), + ).toBe(true); + }); + + it("shows a sole remote environment so the user knows where the project runs", () => { + expect( + shouldShowEnvironmentIndicator({ + activeEnvironment: { isPrimary: false }, + canPickEnvironment: false, + }), + ).toBe(true); + }); + + it("hides a sole primary (this-device) environment", () => { + expect( + shouldShowEnvironmentIndicator({ + activeEnvironment: { isPrimary: true }, + canPickEnvironment: false, + }), + ).toBe(false); + }); + + it("hides the indicator when the active environment is unknown", () => { + expect( + shouldShowEnvironmentIndicator({ + activeEnvironment: null, + canPickEnvironment: false, + }), + ).toBe(false); + }); +}); + describe("resolveEffectiveEnvMode", () => { it("treats draft threads already attached to a worktree as current-checkout mode", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 65388962c080..b16e1f590a93 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -42,6 +42,17 @@ export function resolveEnvironmentOptionLabel(input: { return runtimeLabel ?? savedLabel ?? input.environmentId; } +// A remote (non-primary) environment is always surfaced, even when it is the +// only environment available: with a single connected machine there is nothing +// to pick, but the user still needs to see where the project runs. +export function shouldShowEnvironmentIndicator(input: { + activeEnvironment: Pick | null; + canPickEnvironment: boolean; +}): boolean { + if (input.canPickEnvironment) return true; + return input.activeEnvironment !== null && !input.activeEnvironment.isPrimary; +} + export function resolveEnvModeLabel(mode: EnvMode): string { return mode === "worktree" ? "New worktree" : "Current checkout"; } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 958cb3743c70..0354c2e0cd78 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -20,6 +20,7 @@ import { resolveEnvModeLabel, resolveEffectiveEnvMode, resolveLockedWorkspaceLabel, + shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; @@ -60,6 +61,7 @@ interface MobileRunContextSelectorProps { environmentId: EnvironmentId; availableEnvironments: readonly EnvironmentOption[] | undefined; showEnvironmentPicker: boolean; + showEnvironmentIndicator: boolean; onEnvironmentChange: ((environmentId: EnvironmentId) => void) | undefined; effectiveEnvMode: EnvMode; activeWorktreePath: string | null; @@ -72,6 +74,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ environmentId, availableEnvironments, showEnvironmentPicker, + showEnvironmentIndicator, onEnvironmentChange, effectiveEnvMode, activeWorktreePath, @@ -94,7 +97,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ : resolveCurrentWorkspaceLabel(activeWorktreePath); const isLocked = envLocked || envModeLocked; const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon; - const icon = showEnvironmentPicker ? ( + const icon = showEnvironmentIndicator ? ( // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px. @@ -108,7 +111,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ <> {icon} - {showEnvironmentPicker ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} + {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} ); @@ -234,6 +237,12 @@ export const BranchToolbar = memo(function BranchToolbar({ const showEnvironmentPicker = Boolean( availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, ); + const activeEnvironmentOption = + availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null; + const showEnvironmentIndicator = shouldShowEnvironmentIndicator({ + activeEnvironment: activeEnvironmentOption, + canPickEnvironment: showEnvironmentPicker, + }); const isMobile = useIsMobile(); if (!hasActiveThread || !activeProject) return null; @@ -247,6 +256,7 @@ export const BranchToolbar = memo(function BranchToolbar({ environmentId={environmentId} availableEnvironments={availableEnvironments} showEnvironmentPicker={showEnvironmentPicker} + showEnvironmentIndicator={showEnvironmentIndicator} onEnvironmentChange={onEnvironmentChange} effectiveEnvMode={effectiveEnvMode} activeWorktreePath={activeWorktreePath} @@ -254,13 +264,13 @@ export const BranchToolbar = memo(function BranchToolbar({ /> ) : (
- {showEnvironmentPicker && availableEnvironments && onEnvironmentChange && ( + {showEnvironmentIndicator && availableEnvironments && ( <> diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index e2ee24c3608d..67ae3a8187d6 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -422,17 +422,32 @@ export function BranchToolbarBranchSelector({ }); }; + // Default the worktree base to the repo default branch (origin/HEAD), only + // falling back to the checked-out branch when no default is known. + const defaultBranchName = useMemo( + () => refs.find((refName) => refName.isDefault)?.name ?? null, + [refs], + ); + const worktreeBaseBranchCandidate = isInitialBranchesLoadPending + ? null + : (defaultBranchName ?? currentGitBranch); useEffect(() => { if ( effectiveEnvMode !== "worktree" || activeWorktreePath || activeThreadBranch || - !currentGitBranch + !worktreeBaseBranchCandidate ) { return; } - setThreadBranch(currentGitBranch, null); - }, [activeThreadBranch, activeWorktreePath, currentGitBranch, effectiveEnvMode, setThreadBranch]); + setThreadBranch(worktreeBaseBranchCandidate, null); + }, [ + activeThreadBranch, + activeWorktreePath, + effectiveEnvMode, + setThreadBranch, + worktreeBaseBranchCandidate, + ]); // --------------------------------------------------------------------------- // Combobox / list plumbing diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 35fc8b6a1904..dbc742bea5a7 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -17,7 +17,9 @@ interface BranchToolbarEnvironmentSelectorProps { envLocked: boolean; environmentId: EnvironmentId; availableEnvironments: readonly EnvironmentOption[]; - onEnvironmentChange: (environmentId: EnvironmentId) => void; + // Absent when there is only one environment to show: the indicator still + // renders (as a static label) so remote projects are always identifiable. + onEnvironmentChange?: (environmentId: EnvironmentId) => void; } export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ @@ -39,7 +41,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir [availableEnvironments], ); - if (envLocked) { + if (envLocked || onEnvironmentChange === undefined) { return ( {activeEnvironment?.isPrimary ? ( diff --git a/apps/web/src/components/chat/ChangedFilesTree.test.tsx b/apps/web/src/components/chat/ChangedFilesTree.test.tsx index c371acdb3620..120827b34b2a 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.test.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.test.tsx @@ -2,7 +2,32 @@ import { TurnId } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; -import { ChangedFilesTree } from "./ChangedFilesTree"; +import { ChangedFilesCard, ChangedFilesTree } from "./ChangedFilesTree"; + +describe("ChangedFilesCard", () => { + it("keeps its compact header sticky while preserving singular labels", () => { + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + />, + ); + + expect(markup).toContain('class="sticky top-0 z-10'); + expect(markup).not.toContain("self-start"); + expect(markup).toContain("whitespace-nowrap"); + expect(markup).toContain("!size-[22px]"); + expect(markup).toContain("size-3"); + expect(markup).toContain('aria-label="Collapse all"'); + expect(markup).toContain('aria-label="View diff"'); + expect(markup).toContain("1 changed file"); + expect(markup).not.toContain("1 changed files"); + }); +}); describe("ChangedFilesTree", () => { it.each([ diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index 3ea0e6315fdb..6fc1462c5e6a 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -6,11 +6,19 @@ import { summarizeTurnDiffStats, type TurnDiffTreeNode, } from "../../lib/turnDiffTree"; -import { ChevronRightIcon, FolderIcon, FolderClosedIcon } from "lucide-react"; +import { + ChevronsDownUpIcon, + ChevronsUpDownIcon, + ChevronRightIcon, + FileDiffIcon, + FolderIcon, + FolderClosedIcon, +} from "lucide-react"; import { cn } from "~/lib/utils"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; const EMPTY_DIRECTORY_OVERRIDES: Record = {}; @@ -33,10 +41,12 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); return ( -
-
-

- {files.length} changed files +

+
+

+ + {files.length} changed file{files.length === 1 ? "" : "s"} + {hasNonZeroStat(summaryStat) && (

- - + + + } + > + {allDirectoriesExpanded ? ( + + ) : ( + + )} + + + {allDirectoriesExpanded ? "Collapse all" : "Expand all"} + + + + onOpenTurnDiff(turnId, files[0]?.path)} + /> + } + > + + + View diff +
-
- -
+
); }); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index fc6755e32a7b..962a2c4f6378 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -44,6 +44,10 @@ import { shouldSubmitComposerOnEnter, } from "../../composer-logic"; import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; +import { + dataTransferHasComposerMention, + makeComposerMentionDragHandlers, +} from "./composerMentionDrag"; import { type ComposerImageAttachment, type DraftId, @@ -1136,6 +1140,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isSendBusy || isConnecting || projectSelectionRequired || + environmentUnavailable !== null || !composerSendState.hasSendableContent; const collapsedComposerPrimaryActionLabel = "Send message"; const showMobilePendingAnswerActions = @@ -1674,7 +1679,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const shouldBlurMobileComposerOnSubmit = useCallback(() => { if (!isMobileViewport) return false; - if (isSendBusy || isConnecting || phase === "running") return false; + if (isSendBusy || isConnecting || environmentUnavailable !== null || phase === "running") { + return false; + } if (activePendingProgress) { return activePendingProgress.isLastQuestion && Boolean(activePendingResolvedAnswers); } @@ -1683,6 +1690,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activePendingProgress, activePendingResolvedAnswers, composerSendState.hasSendableContent, + environmentUnavailable, isConnecting, isMobileViewport, isSendBusy, @@ -1859,6 +1867,66 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) addComposerImages(files); focusComposer(); }; + + const insertComposerTextAtEnd = ( + text: string, + options?: { ensureLeadingBoundary?: boolean }, + ): boolean => { + if ( + text.length === 0 || + isConnecting || + isComposerApprovalState || + pendingUserInputs.length > 0 || + projectSelectionRequired + ) { + return false; + } + const prompt = promptRef.current; + const needsLeadingSpace = + (options?.ensureLeadingBoundary ?? false) && prompt.length > 0 && !/\s$/.test(prompt); + return applyPromptReplacement( + prompt.length, + prompt.length, + needsLeadingSpace ? ` ${text}` : text, + ); + }; + + // File-tree drags land as mentions. Handled in the capture phase so the + // editor never sees the drop; the load-bearing rules (native stop, "move" + // effect, no eager focus) live in makeComposerMentionDragHandlers. + const composerMentionDragHandlers = makeComposerMentionDragHandlers({ + insertMentionAtEnd: (text) => insertComposerTextAtEnd(text, { ensureLeadingBoundary: true }), + setDragActive: setIsDragOverComposer, + onInsertRejected: () => { + toastManager.add({ + type: "error", + title: "Unable to add to chat", + description: "The composer is busy; try again once it is ready.", + }); + }, + }); + + const onComposerMentionDragLeaveCapture = (event: React.DragEvent) => { + if (!dataTransferHasComposerMention(event.dataTransfer.types)) return; + event.stopPropagation(); + const nextTarget = event.relatedTarget; + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; + setIsDragOverComposer(false); + }; + + // A cancelled drag (Escape) can end without a dragleave on the hovered + // target, which would leave the drop highlight stuck. dragend always fires + // on the in-page drag source and bubbles to window, so it is the reset of + // last resort while the highlight is up. + useEffect(() => { + if (!isDragOverComposer) return; + const onWindowDragEnd = () => { + dragDepthRef.current = 0; + setIsDragOverComposer(false); + }; + window.addEventListener("dragend", onWindowDragEnd); + return () => window.removeEventListener("dragend", onWindowDragEnd); + }, [isDragOverComposer]); const handleInterruptPrimaryAction = useCallback(() => { void onInterrupt(); }, [onInterrupt]); @@ -1922,26 +1990,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) focusAt: (cursor: number) => { composerEditorRef.current?.focusAt(cursor); }, - insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => { - if ( - text.length === 0 || - isConnecting || - isComposerApprovalState || - pendingUserInputs.length > 0 || - projectSelectionRequired || - (environmentUnavailable !== null && activePendingProgress === null) - ) { - return false; - } - const prompt = promptRef.current; - const needsLeadingSpace = - (options?.ensureLeadingBoundary ?? false) && prompt.length > 0 && !/\s$/.test(prompt); - return applyPromptReplacement( - prompt.length, - prompt.length, - needsLeadingSpace ? ` ${text}` : text, - ); - }, + insertTextAtEnd: insertComposerTextAtEnd, openModelPicker: () => { setIsComposerModelPickerOpen(true); }, @@ -2036,8 +2085,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerApprovalState, pendingUserInputs.length, projectSelectionRequired, - environmentUnavailable, - activePendingProgress, applyPromptReplacement, isComposerModelPickerOpen, readComposerSnapshot, @@ -2068,6 +2115,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onDragOver={onComposerDragOver} onDragLeave={onComposerDragLeave} onDrop={onComposerDrop} + onDragEnterCapture={composerMentionDragHandlers.onDragEnter} + onDragOverCapture={composerMentionDragHandlers.onDragOver} + onDragLeaveCapture={onComposerMentionDragLeaveCapture} + onDropCapture={composerMentionDragHandlers.onDrop} >
{ @@ -2433,12 +2484,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? "Ask for follow-up changes or attach images" : "Ask anything, @tag files/folders, $use skills, or / for commands" } - disabled={ - isConnecting || - isComposerApprovalState || - projectSelectionRequired || - (environmentUnavailable !== null && activePendingProgress === null) - } + disabled={isConnecting || isComposerApprovalState || projectSelectionRequired} /> {showMobilePendingAnswerActions ? (
{ + it("keeps assistant changed-files headers sticky below the thread header", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const assistantMessageId = MessageId.make("message-assistant-with-files"); + const turnId = TurnId.make("turn-with-files"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('class="sticky top-2 z-10'); + expect(markup).not.toContain("self-start"); + expect(markup).toContain("whitespace-nowrap"); + expect(markup).toContain("!size-[22px]"); + expect(markup).toContain("size-3"); + expect(markup).toContain('aria-label="Collapse all"'); + expect(markup).toContain('aria-label="View diff"'); + expect(markup).toContain("1 changed file"); + }); + it("uses LegendList isNearEnd when deciding whether the live edge is visible", async () => { const { resolveTimelineIsAtEnd, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 73fc86312e51..f759aa150be4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -44,8 +44,11 @@ import { CheckIcon, ChevronDownIcon, ChevronRightIcon, + ChevronsDownUpIcon, + ChevronsUpDownIcon, CircleAlertIcon, EyeIcon, + FileDiffIcon, GlobeIcon, HammerIcon, MessageCircleIcon, @@ -1274,38 +1277,67 @@ function AssistantChangedFilesSectionInner({ ); const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); const summaryStat = summarizeTurnDiffStats(checkpointFiles); - const changedFileCountLabel = String(checkpointFiles.length); return ( -
-
-

- Changed files ({changedFileCountLabel}) +

+
+

+ + {checkpointFiles.length} changed file{checkpointFiles.length === 1 ? "" : "s"} + {hasNonZeroStat(summaryStat) && ( - <> - - - + )}

- - + + + setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded) + } + /> + } + > + {allDirectoriesExpanded ? ( + + ) : ( + + )} + + + {allDirectoriesExpanded ? "Collapse all" : "Expand all"} + + + + onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)} + /> + } + > + + + View diff +
}) => { + const mention = options?.mention ?? "[index.md](docs/index.md)"; + const calls: Array = []; + const event = { + dataTransfer: { + types: options?.types ?? [COMPOSER_MENTION_DRAG_TYPE, "text/plain"], + getData: (format: string) => (format === COMPOSER_MENTION_DRAG_TYPE ? mention : ""), + dropEffect: "none", + }, + nativeEvent: { + stopPropagation: () => void calls.push("nativeStopPropagation"), + }, + preventDefault: () => void calls.push("preventDefault"), + stopPropagation: () => void calls.push("stopPropagation"), + }; + return { event, calls }; +}; + +const makeHost = (insertResult = true) => { + const log: Array = []; + const host: ComposerMentionDropHost = { + insertMentionAtEnd: (text) => { + log.push(`insert:${text}`); + return insertResult; + }, + setDragActive: (active) => void log.push(`active:${active}`), + onInsertRejected: () => void log.push("rejected"), + }; + return { host, log }; +}; + +describe("composerMentionFromTreePath", () => { + it("serializes a file path into a mention", () => { + expect(composerMentionFromTreePath("docs/index.md")).toBe("[index.md](docs/index.md)"); + }); + + it("strips the trailing slash directory rows carry", () => { + expect(composerMentionFromTreePath("docs/architecture/")).toBe( + "[architecture](docs/architecture)", + ); + }); + + it("rejects drags that carry no path", () => { + expect(composerMentionFromTreePath("")).toBeNull(); + expect(composerMentionFromTreePath("/")).toBeNull(); + }); +}); + +describe("dataTransferHasComposerMention", () => { + it("detects the mention payload among drag types", () => { + expect(dataTransferHasComposerMention([COMPOSER_MENTION_DRAG_TYPE, "text/plain"])).toBe(true); + expect(dataTransferHasComposerMention(["Files"])).toBe(false); + expect(dataTransferHasComposerMention([])).toBe(false); + }); +}); + +describe("makeComposerMentionDragHandlers", () => { + it("leaves drags without the mention payload alone", () => { + const { host, log } = makeHost(); + const handlers = makeComposerMentionDragHandlers(host); + const { event, calls } = makeDragEvent({ types: ["Files"] }); + handlers.onDragEnter(event); + handlers.onDragOver(event); + handlers.onDrop(event); + expect(calls).toEqual([]); + expect(log).toEqual([]); + }); + + it("stops the native event too, not just the synthetic one", () => { + // React's stopPropagation only halts synthetic dispatch; without the + // native stop, the editor's own DOM listeners process the drop and sync + // their stale state back over the inserted mention. + const { host } = makeHost(); + const handlers = makeComposerMentionDragHandlers(host); + const { event, calls } = makeDragEvent(); + handlers.onDrop(event); + expect(calls).toContain("preventDefault"); + expect(calls).toContain("stopPropagation"); + expect(calls).toContain("nativeStopPropagation"); + }); + + it('answers dragover with the "move" effect the tree allows', () => { + // Naming an effect outside the source's effectAllowed makes the browser + // cancel the drop without ever firing it. + const { host } = makeHost(); + const handlers = makeComposerMentionDragHandlers(host); + const { event } = makeDragEvent(); + handlers.onDragOver(event); + expect(event.dataTransfer.dropEffect).toBe("move"); + }); + + it("inserts the mention with its trailing space and clears the highlight", () => { + const { host, log } = makeHost(); + const handlers = makeComposerMentionDragHandlers(host); + handlers.onDragEnter(makeDragEvent().event); + handlers.onDrop(makeDragEvent().event); + expect(log).toEqual(["active:true", "active:false", "insert:[index.md](docs/index.md) "]); + }); + + it("reports a rejected insert instead of failing silently", () => { + const { host, log } = makeHost(false); + const handlers = makeComposerMentionDragHandlers(host); + handlers.onDrop(makeDragEvent().event); + expect(log).toContain("rejected"); + }); + + it("ignores a drop whose payload is empty", () => { + const { host, log } = makeHost(); + const handlers = makeComposerMentionDragHandlers(host); + handlers.onDrop(makeDragEvent({ mention: "" }).event); + expect(log).toEqual(["active:false"]); + }); +}); diff --git a/apps/web/src/components/chat/composerMentionDrag.ts b/apps/web/src/components/chat/composerMentionDrag.ts new file mode 100644 index 000000000000..43b1bfd800dc --- /dev/null +++ b/apps/web/src/components/chat/composerMentionDrag.ts @@ -0,0 +1,98 @@ +import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; + +/** + * Drag payload type carrying a serialized composer mention. Set on drags that + * start in the workspace file tree so the composer can tell them apart from + * OS file drags and plain text selections. + */ +export const COMPOSER_MENTION_DRAG_TYPE = "application/x-t3code-composer-mention"; + +export function composerMentionFromTreePath(treePath: string): string | null { + const relativePath = treePath.replace(/\/+$/, ""); + if (relativePath.length === 0) { + return null; + } + return serializeComposerFileLink(relativePath); +} + +export function dataTransferHasComposerMention(types: ReadonlyArray): boolean { + return types.includes(COMPOSER_MENTION_DRAG_TYPE); +} + +export interface ComposerMentionDragTransfer { + readonly types: ReadonlyArray; + getData(format: string): string; + dropEffect: string; +} + +export interface ComposerMentionDragEvent { + readonly dataTransfer: ComposerMentionDragTransfer; + readonly nativeEvent: { stopPropagation(): void }; + preventDefault(): void; + stopPropagation(): void; +} + +/** + * What a mention drop is allowed to do to the composer. Deliberately narrow: + * there is no way to focus the editor from here. Focusing it synchronously + * during the drop makes the not-yet-reconciled editor sync its stale empty + * state back over the inserted mention; the insert path already focuses on + * the next frame, after the editor has caught up. + */ +export interface ComposerMentionDropHost { + insertMentionAtEnd(text: string): boolean; + setDragActive(active: boolean): void; + onInsertRejected(): void; +} + +export interface ComposerMentionDragHandlers { + onDragEnter(event: ComposerMentionDragEvent): void; + onDragOver(event: ComposerMentionDragEvent): void; + onDrop(event: ComposerMentionDragEvent): void; +} + +export function makeComposerMentionDragHandlers( + host: ComposerMentionDropHost, +): ComposerMentionDragHandlers { + // Claim the event for the composer: React's stopPropagation only halts the + // synthetic dispatch, so the native event must be stopped too or the + // editor's own DOM listeners still process the drag. + const claim = (event: ComposerMentionDragEvent): boolean => { + if (!dataTransferHasComposerMention(event.dataTransfer.types)) { + return false; + } + event.preventDefault(); + event.stopPropagation(); + event.nativeEvent.stopPropagation(); + return true; + }; + return { + onDragEnter(event) { + if (claim(event)) { + host.setDragActive(true); + } + }, + onDragOver(event) { + if (!claim(event)) { + return; + } + // The tree constrains its drags to effectAllowed "move"; naming any + // other effect makes the browser cancel the drop without firing it. + event.dataTransfer.dropEffect = "move"; + host.setDragActive(true); + }, + onDrop(event) { + if (!claim(event)) { + return; + } + host.setDragActive(false); + const mention = event.dataTransfer.getData(COMPOSER_MENTION_DRAG_TYPE); + if (mention.length === 0) { + return; + } + if (!host.insertMentionAtEnd(`${mention} `)) { + host.onInsertRejected(); + } + }, + }; +} diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index f95022c3424c..3f53d65533dc 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -16,6 +16,7 @@ import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import { T3_PIERRE_ICONS } from "~/pierre-icons"; +import { createFileTreeDragMentionController } from "./fileTreeDragMention"; import { useProjectEntriesQuery } from "./projectFilesQueryState"; interface FileBrowserPanelProps { @@ -137,6 +138,14 @@ export default function FileBrowserPanel({ showEntryContextMenuRef.current = showEntryContextMenu; }); + const treeModelRef = useRef["model"] | null>(null); + const dragMention = useMemo( + () => + createFileTreeDragMentionController({ + deselect: (path) => treeModelRef.current?.getItem(path)?.deselect(), + }), + [], + ); const { model } = useFileTree({ composition: { contextMenu: { @@ -146,12 +155,21 @@ export default function FileBrowserPanel({ }, }, }, + // Rows only need to be draggable so entries can be dropped into the chat + // composer; rearranging files inside the tree stays off. + dragAndDrop: { canDrop: () => false }, density: "compact", fileTreeSearchMode: "hide-non-matches", flattenEmptyDirectories: true, initialExpansion: 1, icons: T3_PIERRE_ICONS, onSelectionChange: (selectedPaths) => { + dragMention.handleSelectionChange(selectedPaths); + // Starting a drag selects the dragged row; that selection is a side + // effect of the gesture, not a request to open the file. + if (dragMention.isDragInProgress()) { + return; + } const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, ""); if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") { onOpenFile(selectedPath); @@ -174,8 +192,34 @@ export default function FileBrowserPanel({ [entries], ); + // Tag tree drags with the composer mention payload. The row is read from + // the composed event path (the tree's shadow root is open), so this does + // not depend on running after the tree's own dragstart handler; the drag + // data store is writable for every dragstart listener in the dispatch. + // The capture phase runs before the tree's own dragstart handler selects + // the dragged row, so the drag flag is up before that selection emits. + const panelRef = useRef(null); + useEffect(() => { + treeModelRef.current = model; + }, [model]); + useEffect(() => { + const panel = panelRef.current; + if (panel === null) { + return; + } + const handleDragStart = (event: DragEvent) => dragMention.handleDragStart(event); + const handleDragEnd = () => dragMention.handleDragEnd(); + panel.addEventListener("dragstart", handleDragStart, true); + panel.addEventListener("dragend", handleDragEnd); + return () => { + panel.removeEventListener("dragstart", handleDragStart, true); + panel.removeEventListener("dragend", handleDragEnd); + }; + }, [dragMention]); + return (
diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 8c430d5d2ab8..6ddd38e9d253 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -4,6 +4,7 @@ import type { ResolvedKeybindingsConfig, ScopedThreadRef, } from "@t3tools/contracts"; +import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs"; import { Editor } from "@pierre/diffs/editor"; import { EditorProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react"; @@ -16,6 +17,7 @@ import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; +import { useAssetUrlState } from "~/assets/assetUrls"; import ChatMarkdown from "~/components/ChatMarkdown"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; import { useClientSettings } from "~/hooks/useSettings"; @@ -113,6 +115,43 @@ const FILE_LINK_REVEAL_UNSAFE_CSS = ` `; type FilePostRender = NonNullable["onPostRender"]>; +function WorkspaceImagePreview(props: { + readonly environmentId: EnvironmentId; + readonly threadRef: ScopedThreadRef; + readonly absolutePath: string; + readonly alt: string; +}) { + const assetUrl = useAssetUrlState(props.environmentId, { + _tag: "workspace-file", + threadId: props.threadRef.threadId, + path: props.absolutePath, + }); + const [failedUrl, setFailedUrl] = useState(null); + + if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { + return ( +
+ Unable to load workspace image. +
+ ); + } + + return assetUrl._tag === "Success" ? ( +
+ {props.alt} setFailedUrl(assetUrl.url)} + /> +
+ ) : ( +
+ +
+ ); +} + function clampFileLine(contents: string, requestedLine: number): number { let lineCount = 1; for (let index = 0; index < contents.length; index += 1) { @@ -630,7 +669,8 @@ export default function FilePreviewPanel({ const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); - const file = useProjectFileQuery(environmentId, cwd, relativePath); + const isImage = relativePath !== null && isWorkspaceImagePreviewPath(relativePath); + const file = useProjectFileQuery(environmentId, cwd, relativePath, !isImage); const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); const [markdownView, setMarkdownView] = useState<{ path: string | null; @@ -818,7 +858,15 @@ export default function FilePreviewPanel({ relativePath ? "flex" : "hidden", )} > - {relativePath && file.error && file.data === null ? ( + {relativePath && isImage && absolutePath ? ( + + ) : relativePath && file.error && file.data === null ? (
{file.error}
diff --git a/apps/web/src/components/files/fileTreeDragMention.test.ts b/apps/web/src/components/files/fileTreeDragMention.test.ts new file mode 100644 index 000000000000..812501ab5d14 --- /dev/null +++ b/apps/web/src/components/files/fileTreeDragMention.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { COMPOSER_MENTION_DRAG_TYPE } from "~/components/chat/composerMentionDrag"; +import { createFileTreeDragMentionController } from "./fileTreeDragMention.ts"; + +const makeTransfer = (plainText = "") => { + const data = new Map([["text/plain", plainText]]); + return { + setData: (format: string, value: string) => void data.set(format, value), + getData: (format: string) => data.get(format) ?? "", + data, + }; +}; + +const rowNode = (path: string) => ({ + getAttribute: (name: string) => (name === "data-item-path" ? path : null), +}); + +describe("createFileTreeDragMentionController", () => { + it("tags a row drag with the mention payload and flags the drag", () => { + const controller = createFileTreeDragMentionController({ deselect: () => {} }); + const transfer = makeTransfer(); + controller.handleDragStart({ + dataTransfer: transfer, + composedPath: () => [{}, rowNode("docs/index.md"), {}], + }); + expect(transfer.getData(COMPOSER_MENTION_DRAG_TYPE)).toBe("[index.md](docs/index.md)"); + expect(controller.isDragInProgress()).toBe(true); + }); + + it("strips the trailing slash from directory rows", () => { + const controller = createFileTreeDragMentionController({ deselect: () => {} }); + const transfer = makeTransfer(); + controller.handleDragStart({ + dataTransfer: transfer, + composedPath: () => [rowNode("docs/architecture/")], + }); + expect(transfer.getData(COMPOSER_MENTION_DRAG_TYPE)).toBe("[architecture](docs/architecture)"); + }); + + it("does not tag drags of selected text from the panel chrome", () => { + // Only a drag that originates on a tree row is a mention; dragging a text + // selection also carries text/plain, and tagging it would drop an invalid + // pill into the composer. + const controller = createFileTreeDragMentionController({ deselect: () => {} }); + const transfer = makeTransfer("selected text"); + controller.handleDragStart({ dataTransfer: transfer, composedPath: () => [{}] }); + expect(transfer.data.has(COMPOSER_MENTION_DRAG_TYPE)).toBe(false); + expect(controller.isDragInProgress()).toBe(false); + }); + + it("ignores drags that carry no row path", () => { + const controller = createFileTreeDragMentionController({ deselect: () => {} }); + const transfer = makeTransfer(); + controller.handleDragStart({ dataTransfer: transfer, composedPath: () => [{}] }); + expect(transfer.data.has(COMPOSER_MENTION_DRAG_TYPE)).toBe(false); + expect(controller.isDragInProgress()).toBe(false); + }); + + it("deselects the dragged row exactly once when the drag ends", () => { + const deselected: Array = []; + const controller = createFileTreeDragMentionController({ + deselect: (path) => deselected.push(path), + }); + controller.handleDragStart({ + dataTransfer: makeTransfer(), + composedPath: () => [rowNode("src/app.ts")], + }); + controller.handleDragEnd(); + controller.handleDragEnd(); + expect(deselected).toEqual(["src/app.ts"]); + expect(controller.isDragInProgress()).toBe(false); + }); + + it("drags the whole selection when the dragged row is part of it", () => { + const deselected: Array = []; + const controller = createFileTreeDragMentionController({ + deselect: (path) => deselected.push(path), + }); + controller.handleSelectionChange(["docs/index.md", "docs/api.md", "src/app.ts"]); + const transfer = makeTransfer(); + controller.handleDragStart({ + dataTransfer: transfer, + composedPath: () => [rowNode("docs/api.md")], + }); + expect(transfer.getData(COMPOSER_MENTION_DRAG_TYPE)).toBe( + "[index.md](docs/index.md) [api.md](docs/api.md) [app.ts](src/app.ts)", + ); + controller.handleDragEnd(); + expect(deselected).toEqual(["docs/index.md", "docs/api.md", "src/app.ts"]); + }); + + it("drags only the row under the cursor when it is outside the selection", () => { + const controller = createFileTreeDragMentionController({ deselect: () => {} }); + controller.handleSelectionChange(["docs/index.md"]); + const transfer = makeTransfer(); + controller.handleDragStart({ + dataTransfer: transfer, + composedPath: () => [rowNode("src/app.ts")], + }); + expect(transfer.getData(COMPOSER_MENTION_DRAG_TYPE)).toBe("[app.ts](src/app.ts)"); + }); + + it("does not deselect anything when no drag was started", () => { + const deselected: Array = []; + const controller = createFileTreeDragMentionController({ + deselect: (path) => deselected.push(path), + }); + controller.handleDragEnd(); + expect(deselected).toEqual([]); + }); +}); diff --git a/apps/web/src/components/files/fileTreeDragMention.ts b/apps/web/src/components/files/fileTreeDragMention.ts new file mode 100644 index 000000000000..7c17639a649d --- /dev/null +++ b/apps/web/src/components/files/fileTreeDragMention.ts @@ -0,0 +1,95 @@ +import { + COMPOSER_MENTION_DRAG_TYPE, + composerMentionFromTreePath, +} from "~/components/chat/composerMentionDrag"; + +interface FileTreeDragTransfer { + setData(format: string, data: string): void; +} + +export interface FileTreeDragStartEvent { + readonly dataTransfer: FileTreeDragTransfer | null; + composedPath(): ReadonlyArray; +} + +export interface FileTreeDragMentionHost { + /** Drop the tree's gesture-applied selection of the dragged row. */ + deselect(treePath: string): void; +} + +export interface FileTreeDragMentionController { + /** + * True from the moment a row drag starts until it ends. The tree selects + * the dragged row as part of the gesture; selection changes made while + * this is set are gesture side effects, not requests to open a file. + */ + isDragInProgress(): boolean; + /** Mirror of the tree's current selection, needed for multi-row drags. */ + handleSelectionChange(selectedPaths: ReadonlyArray): void; + handleDragStart(event: FileTreeDragStartEvent): void; + handleDragEnd(): void; +} + +const itemPathOf = (node: unknown): string | null => { + if (typeof node !== "object" || node === null) { + return null; + } + const element = node as { getAttribute?: (name: string) => string | null }; + return typeof element.getAttribute === "function" ? element.getAttribute("data-item-path") : null; +}; + +/** + * Tags file-tree drags with the composer mention payload and keeps the drag + * from acting like a click: while the drag runs, selection changes are + * suppressed, and when it ends the dragged rows are deselected so nothing is + * left highlighted and a later click on them still fires a selection change. + */ +export function createFileTreeDragMentionController( + host: FileTreeDragMentionHost, +): FileTreeDragMentionController { + let selection: ReadonlyArray = []; + let draggedPaths: ReadonlyArray = []; + return { + isDragInProgress: () => draggedPaths.length > 0, + handleSelectionChange(selectedPaths) { + selection = selectedPaths; + }, + handleDragStart(event) { + if (event.dataTransfer === null) { + return; + } + // Only drags that originate on a tree row are mentions; a text/plain + // fallback would also tag drags of selected text from the panel chrome. + let itemPath: string | null = null; + for (const node of event.composedPath()) { + itemPath = itemPathOf(node); + if (itemPath !== null) { + break; + } + } + if (itemPath === null) { + return; + } + // Same rule the tree applies to the drag itself: dragging a row that is + // part of the current selection drags the whole selection. + const dragged = selection.includes(itemPath) ? selection : [itemPath]; + const mentions = dragged + .map((path) => composerMentionFromTreePath(path)) + .filter((mention): mention is string => mention !== null); + if (mentions.length === 0) { + return; + } + draggedPaths = dragged; + event.dataTransfer.setData(COMPOSER_MENTION_DRAG_TYPE, mentions.join(" ")); + }, + handleDragEnd() { + if (draggedPaths.length === 0) { + return; + } + for (const path of draggedPaths) { + host.deselect(path); + } + draggedPaths = []; + }, + }; +} diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 191b97d6a965..0d3fb8dd9413 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -6,7 +6,7 @@ import type { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; -import { AsyncResult } from "effect/unstable/reactivity"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback } from "react"; import { appAtomRegistry } from "~/rpc/atomRegistry"; @@ -14,6 +14,9 @@ import { projectEnvironment } from "~/state/projects"; import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; const EMPTY_PROJECT_FILE_PATH = ""; +const EMPTY_PROJECT_FILE_QUERY_ATOM = Atom.make( + AsyncResult.initial(false), +).pipe(Atom.withLabel("project-file-query:empty")); function optimisticFileAtom(environmentId: EnvironmentId, cwd: string, relativePath: string) { return projectEnvironment.optimisticFile({ environmentId, cwd, relativePath }); } @@ -137,8 +140,11 @@ export function useProjectFileQuery( environmentId: EnvironmentId, cwd: string, relativePath: string | null, + enabled = true, ): ProjectQueryState { - const atom = getProjectFileQueryAtom(environmentId, cwd, relativePath); + const atom = enabled + ? getProjectFileQueryAtom(environmentId, cwd, relativePath) + : EMPTY_PROJECT_FILE_QUERY_ATOM; const result = useAtomValue(atom); const refreshAtom = useAtomRefresh(atom); const refresh = useCallback(() => refreshAtom(), [refreshAtom]); diff --git a/apps/web/src/components/threadSidebarWidth.test.ts b/apps/web/src/components/threadSidebarWidth.test.ts new file mode 100644 index 000000000000..12aef63bd5e9 --- /dev/null +++ b/apps/web/src/components/threadSidebarWidth.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + resolveInitialThreadSidebarWidth, + THREAD_MAIN_CONTENT_MIN_WIDTH, + THREAD_SIDEBAR_DEFAULT_WIDTH, + THREAD_SIDEBAR_MIN_WIDTH, +} from "./threadSidebarWidth"; + +describe("thread sidebar width", () => { + it("uses the default width when no preference is stored", () => { + expect(resolveInitialThreadSidebarWidth(null, 1200)).toBe(THREAD_SIDEBAR_DEFAULT_WIDTH); + }); + + it("uses a stored width in the initial render", () => { + expect(resolveInitialThreadSidebarWidth(360, 1200)).toBe(360); + }); + + it("clamps a stored width to the sidebar minimum", () => { + expect(resolveInitialThreadSidebarWidth(120, 1200)).toBe(THREAD_SIDEBAR_MIN_WIDTH); + }); + + it("leaves enough room for the main content on a smaller window", () => { + const viewportWidth = 1000; + + expect(resolveInitialThreadSidebarWidth(900, viewportWidth)).toBe( + viewportWidth - THREAD_MAIN_CONTENT_MIN_WIDTH, + ); + }); + + it("keeps the sidebar minimum when the whole layout is narrower than its minimums", () => { + expect(resolveInitialThreadSidebarWidth(900, 700)).toBe(THREAD_SIDEBAR_MIN_WIDTH); + }); +}); diff --git a/apps/web/src/components/threadSidebarWidth.ts b/apps/web/src/components/threadSidebarWidth.ts new file mode 100644 index 000000000000..93dd196e84da --- /dev/null +++ b/apps/web/src/components/threadSidebarWidth.ts @@ -0,0 +1,22 @@ +export const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; +export const THREAD_SIDEBAR_DEFAULT_WIDTH = 16 * 16; +export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; +export const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; + +export function resolveThreadSidebarMaximumWidth(viewportWidth: number): number { + return Math.max( + THREAD_SIDEBAR_MIN_WIDTH, + Math.floor(viewportWidth) - THREAD_MAIN_CONTENT_MIN_WIDTH, + ); +} + +export function resolveInitialThreadSidebarWidth( + storedWidth: number | null, + viewportWidth: number, +): number { + const preferredWidth = + storedWidth === null + ? THREAD_SIDEBAR_DEFAULT_WIDTH + : Math.max(THREAD_SIDEBAR_MIN_WIDTH, storedWidth); + return Math.min(preferredWidth, resolveThreadSidebarMaximumWidth(viewportWidth)); +} diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 6628937c99c1..4b9dd0f8f2ba 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -562,16 +562,24 @@ function SidebarRail({ [onClick, open, resolvedResizable, toggleSidebar], ); - React.useEffect(() => { + React.useLayoutEffect(() => { if (!resolvedResizable?.storageKey || typeof window === "undefined") return; const rail = railRef.current; if (!rail) return; const wrapper = rail.closest("[data-slot='sidebar-wrapper']"); if (!wrapper) return; - const storedWidth = getLocalStorageItem(resolvedResizable.storageKey, Schema.Finite); + let storedWidth: number | null; + try { + storedWidth = getLocalStorageItem(resolvedResizable.storageKey, Schema.Finite); + } catch (error) { + console.error("Could not restore persisted sidebar width.", error); + return; + } if (storedWidth === null) return; const clampedWidth = clampSidebarWidth(storedWidth, resolvedResizable); + // Hydrate the CSS variable before the browser paints so a restored sidebar + // never flashes at the default width first. wrapper.style.setProperty("--sidebar-width", `${clampedWidth}px`); resolvedResizable.onResize?.(clampedWidth); }, [resolvedResizable]); diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 3b05cc1f991e..9674e8c15a98 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -11,7 +11,7 @@ import { function provider(input: { provider?: ProviderDriverKind; instanceId: string; - models?: ReadonlyArray; + models?: ReadonlyArray; }): ServerProvider { const driver = input.provider ?? @@ -27,12 +27,16 @@ function provider(input: { status: "ready", auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", - models: (input.models ?? []).map((slug) => ({ - slug, - name: slug, - isCustom: false, - capabilities: {}, - })), + models: (input.models ?? []).map((model) => { + const slug = typeof model === "string" ? model : model.slug; + return { + slug, + name: slug, + isCustom: false, + capabilities: {}, + ...(typeof model === "object" && model.isDefault ? { isDefault: true as const } : {}), + }; + }), slashCommands: [], skills: [], }; @@ -283,7 +287,11 @@ describe("instance-scoped model selection", () => { provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: "claudeAgent", - models: ["claude-fable-5", "claude-sonnet-5", "claude-sonnet-4-6"], + models: [ + "claude-fable-5", + { slug: "claude-sonnet-5", isDefault: true }, + "claude-sonnet-4-6", + ], }), ]; @@ -341,12 +349,16 @@ describe("instance-scoped model selection", () => { }); }); - it("uses the Git text default when text generation falls back within an instance", () => { + it("uses the marked default model when text generation falls back within an instance", () => { const providers = [ provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: "claudeAgent", - models: ["claude-fable-5", "claude-sonnet-5", "claude-haiku-4-5"], + models: [ + "claude-fable-5", + "claude-sonnet-5", + { slug: "claude-haiku-4-5", isDefault: true }, + ], }), ]; const settings: UnifiedSettings = { diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index c1e8cc61fe3c..ec089d766cf0 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -1,7 +1,6 @@ import { DEFAULT_GIT_TEXT_GENERATION_MODEL, DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, - DEFAULT_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, type ModelSelection, ProviderDriverKind, @@ -76,6 +75,7 @@ export interface AppModelOption { shortName?: string; subProvider?: string; isCustom: boolean; + isDefault?: boolean; } function toAppModelOption(model: ServerProvider["models"][number]): AppModelOption { @@ -86,6 +86,7 @@ function toAppModelOption(model: ServerProvider["models"][number]): AppModelOpti }; if (model.shortName) option.shortName = model.shortName; if (model.subProvider) option.subProvider = model.subProvider; + if (model.isDefault) option.isDefault = true; return option; } @@ -240,33 +241,17 @@ export function resolveAppModelSelectionForInstance( settings: UnifiedSettings, providers: ReadonlyArray, selectedModel: string | null | undefined, -): string | null { - return resolveAppModelSelectionForInstanceWithDefault( - instanceId, - settings, - providers, - selectedModel, - undefined, - ); -} - -function resolveAppModelSelectionForInstanceWithDefault( - instanceId: ProviderInstanceId, - settings: UnifiedSettings, - providers: ReadonlyArray, - selectedModel: string | null | undefined, - preferredDefaultModel: string | null | undefined, ): string | null { const entry = deriveProviderInstanceEntries(providers).find( (candidate) => candidate.instanceId === instanceId, ); if (!entry) return null; const options = getAppModelOptionsForInstance(settings, entry); - const defaultModel = preferredDefaultModel ?? DEFAULT_MODEL_BY_PROVIDER[entry.driverKind]; return ( resolveSelectableModel(entry.driverKind, selectedModel, options) ?? - resolveSelectableModel(entry.driverKind, defaultModel, options) ?? + options.find((option) => option.isDefault)?.slug ?? options[0]?.slug ?? + entry.models.find((model) => model.isDefault)?.slug ?? entry.models[0]?.slug ?? null ); @@ -309,13 +294,7 @@ export function resolveAppModelSelectionState( // don't carry over the old instance's model — use the fallback instance's default. const selectedModel = selectedEntry ? selection.model : null; const model = - resolveAppModelSelectionForInstanceWithDefault( - entry.instanceId, - settings, - providers, - selectedModel, - DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind], - ) ?? + resolveAppModelSelectionForInstance(entry.instanceId, settings, providers, selectedModel) ?? entry.models[0]?.slug ?? DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind]; if (!model) { diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index aa9afcf31e12..9715344cba80 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -92,6 +92,7 @@ export function getDefaultServerModel( ): string { const models = getProviderModels(providers, provider); return ( + models.find((model) => model.isDefault && !model.isCustom)?.slug ?? models.find((model) => !model.isCustom)?.slug ?? models[0]?.slug ?? DEFAULT_MODEL_BY_PROVIDER[provider] ?? diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index bf4e2c893924..f3bc72603ac9 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { + DEFAULT_MODEL, EnvironmentId, ProjectId, CommandId, @@ -139,7 +140,7 @@ describe("add project shared logic", () => { createWorkspaceRootIfMissing: true, defaultModelSelection: { instanceId: "codex", - model: "gpt-5.4", + model: DEFAULT_MODEL, }, }); }); diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index ccec6a8898d7..c19c805d68f6 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -134,8 +134,18 @@ const DROID_DRIVER_KIND = ProviderDriverKind.make("droid"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); -export const DEFAULT_MODEL = "gpt-5.4"; -export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini"; +export const DEFAULT_MODEL = "gpt-5.6-sol"; + +/** + * Codex default-model preference, most preferred first. The provider snapshot + * marks the first of these present in the live `model/list` response as + * default; when none are available, Codex's own `isDefault` flag wins. + */ +export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray = [ + "gpt-5.6-sol", + "gpt-5.6-terra", +]; +export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.6-luna"; export const DEFAULT_MODEL_BY_PROVIDER: Partial> = { [CODEX_DRIVER_KIND]: DEFAULT_MODEL, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 316f09693ecb..3d99b8e95a69 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -64,6 +64,7 @@ export const ServerProviderModel = Schema.Struct({ shortName: Schema.optional(TrimmedNonEmptyString), subProvider: Schema.optional(TrimmedNonEmptyString), isCustom: Schema.Boolean, + isDefault: Schema.optional(Schema.Boolean), capabilities: Schema.NullOr(ModelCapabilities), }); export type ServerProviderModel = typeof ServerProviderModel.Type; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 0f618729c43e..a79042a2847d 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -88,14 +88,14 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { }); describe("ServerSettings worktree defaults", () => { - it("defaults start-from-origin off for legacy configs", () => { - expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(false); + it("defaults start-from-origin on for legacy configs", () => { + expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(true); }); it("accepts start-from-origin updates", () => { expect( - decodeServerSettingsPatch({ newWorktreesStartFromOrigin: true }).newWorktreesStartFromOrigin, - ).toBe(true); + decodeServerSettingsPatch({ newWorktreesStartFromOrigin: false }).newWorktreesStartFromOrigin, + ).toBe(false); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index bd3bbfb6410f..7f41bd4127fb 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -556,7 +556,7 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), ), newWorktreesStartFromOrigin: Schema.Boolean.pipe( - Schema.withDecodingDefault(Effect.succeed(false)), + Schema.withDecodingDefault(Effect.succeed(true)), ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( diff --git a/vite.config.ts b/vite.config.ts index b24986111981..b355db981403 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -40,6 +40,7 @@ export default defineConfig({ "apps/mobile/ios/**", "apps/web/public/mockServiceWorker.js", "apps/web/src/lib/vendor/qrcodegen.ts", + "packages/shared/src/qrCode.ts", "apps/mobile/uniwind-types.d.ts", "*.icon/**", ],