diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 19a28f205d86..000000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,346 +0,0 @@ -name: CI - -on: - pull_request: - push: - branches: - - main - -permissions: - contents: read - -concurrency: - group: ci-${{ github.event.pull_request.number || github.sha }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -jobs: - check: - name: Check - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Reject repository-owned PR assets - run: | - files="$(git ls-files .github/pr-assets)" - if test -n "$files"; then - printf 'PR evidence must be uploaded to GitHub, not committed:\n%s\n' "$files" >&2 - exit 1 - fi - - - 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 - - # Files/dependencies are repo-wide; export checks cover clean workspaces only. - - name: Check unused code - run: vp run knip:check - - - name: Check - run: vp check - - - name: Typecheck - run: vpr typecheck - - - uses: ./.github/actions/setup-apt-mirrors - - - name: Install browser secret helper build libraries - run: | - sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources - sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - - - name: Build desktop pipeline - run: vp run build:desktop - - - name: Verify preload bundle output - run: node apps/desktop/scripts/verify-preload-bundle.mjs - - # Everything except `t3` (apps/server). `--parallel` drops the package - # dependency ordering that `vp run` applies by default: these `test` tasks - # declare no `dependsOn` and resolve workspace deps from source, so ordering - # only bought us idle runners between dependency layers. The concurrency - # limit stays at the default 4 so peak load per runner is unchanged. - test: - name: Test - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: true - - - name: Ensure Electron runtime is installed - run: vp run --filter @t3tools/desktop ensure:electron - - - uses: ./.github/actions/setup-apt-mirrors - - - name: Install browser secret helper build libraries - run: | - sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources - sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - - - name: Test nightly release checks - run: node --test .github/scripts/check-nightly-release.test.cjs - - - name: Test - run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test - - # apps/server sets `fileParallelism: false`, so its 239 files run strictly - # one at a time. Sharding spreads them over separate runners instead of - # separate workers, so no two server test files ever share a machine and the - # isolation that flag buys is preserved exactly. - test_server: - name: Test Server ${{ matrix.shard }} - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3] - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: true - - # No Electron setup here: `t3` (apps/server) has no Electron dependency - # and none of its tests touch the runtime. Only the non-server `test` - # job, which covers @t3tools/desktop, needs the download. - - name: Test - env: - T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md - T3CODE_TRANSFER_BUDGET_RESULT_PATH: ${{ runner.temp }}/thread-transfer-result.json - run: vp run --filter t3 test --shard ${{ matrix.shard }}/${{ strategy.job-total }} - - # src/server.test.ts writes the budget report, so exactly one shard - # produces these files. Gating the upload on their presence keeps a - # single `thread-transfer-results` artifact per run, which is the name - # thread-transfer-report.yml resolves. - - name: Detect transfer budget report - id: transfer_budget - if: always() - run: | - if test -f "${{ runner.temp }}/thread-transfer-result.json"; then - echo "present=true" >> "$GITHUB_OUTPUT" - else - echo "present=false" >> "$GITHUB_OUTPUT" - fi - - - name: Publish transfer budget report - if: always() && steps.transfer_budget.outputs.present == 'true' - run: | - if test -f "${{ runner.temp }}/t3code-transfer-budget.md"; then - tee -a "$GITHUB_STEP_SUMMARY" < "${{ runner.temp }}/t3code-transfer-budget.md" - else - echo "Transfer budget report was not produced." >> "$GITHUB_STEP_SUMMARY" - fi - - - name: Upload thread transfer result - if: always() && steps.transfer_budget.outputs.present == 'true' - uses: actions/upload-artifact@v7 - with: - name: thread-transfer-results - path: ${{ runner.temp }}/thread-transfer-result.json - if-no-files-found: ignore - retention-days: 30 - - # Split out of Check and Test: both paid ~7-9s to install a Rust toolchain - # for checks that take under 3s, on the critical path of every PR. - rust: - name: Rust - runs-on: blacksmith-4vcpu-ubuntu-2404 - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt - - - name: Check Rust formatting - run: | - for crate in resource-monitor kde-snap-shot hyprland-snap-shot; do - cargo fmt --manifest-path "native/$crate/Cargo.toml" -- --check - done - - - name: Test Rust crates - run: | - for crate in resource-monitor kde-snap-shot hyprland-snap-shot; do - cargo test --locked --manifest-path "native/$crate/Cargo.toml" - done - - # The static analysis below needs a macOS runner, which bills ~6.7x a Linux - # minute, so gate it on the native sources it actually lints instead of paying - # for it on every push. Detection is API-only (no checkout) and fails open: if - # the diff cannot be resolved, the lint runs. - mobile_native_changes: - name: Mobile Native Changes - runs-on: blacksmith-2vcpu-ubuntu-2404 - timeout-minutes: 5 - permissions: - contents: read - pull-requests: read - outputs: - changed: ${{ steps.detect.outputs.changed }} - steps: - - name: Detect mobile native changes - id: detect - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - BEFORE_SHA: ${{ github.event.before }} - run: | - set -uo pipefail - - fail_open() { - echo "$* Running native static analysis." - echo "changed=true" >> "$GITHUB_OUTPUT" - exit 0 - } - - count_rows() { - printf '%s\n' "$1" | grep -c . || true - } - - # One row per changed file, holding the new path and, for a rename, - # the path it replaced: renaming a matched file out of the matched - # paths removes a lint input just like editing it. - row='[.filename, (.previous_filename // empty)] | @tsv' - - if [[ -n "${PR_NUMBER}" ]]; then - # The PR files endpoint stops at 3000 files and pagination cannot - # extend it, so cross-check against the count the PR itself reports. - expected=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.changed_files') \ - || fail_open "Could not read the pull request." - rows=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" --paginate --jq ".[] | ${row}") \ - || fail_open "Could not resolve changed files." - - listed=$(count_rows "$rows") - if [[ "$listed" -lt "$expected" ]]; then - fail_open "GitHub listed only ${listed} of ${expected} changed files." - fi - else - rows=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${BEFORE_SHA}...${GITHUB_SHA}" --jq ".files[]? | ${row}") \ - || fail_open "Could not resolve changed files." - - # The compare endpoint reports at most 300 files and pagination does - # not extend that list, so a full list may be hiding native changes. - listed=$(count_rows "$rows") - if [[ "$listed" -ge 300 ]]; then - fail_open "GitHub listed ${listed} changed files, the compare endpoint maximum." - fi - fi - - paths=$(tr '\t' '\n' <<< "$rows") - - # Sources scripts/mobile-native-static-check.ts lints, plus the tool - # and rule configuration that decides how it lints them, plus the - # root package.json that defines the lint:mobile command. - pattern='^apps/mobile/.*\.(swift|kt|kts)$|^apps/mobile/(\.swiftlint\.yml|detekt\.yml|\.editorconfig|Brewfile)$|^scripts/mobile-native-static-check\.ts$|^package\.json$|^\.github/workflows/ci\.yml$' - - if grep -qE "$pattern" <<< "$paths"; then - echo "Native sources or lint configuration changed:" - grep -E "$pattern" <<< "$paths" - echo "changed=true" >> "$GITHUB_OUTPUT" - else - echo "No mobile native sources or lint configuration changed." - echo "changed=false" >> "$GITHUB_OUTPUT" - fi - - mobile_native_static_analysis: - name: Mobile Native Static Analysis - needs: mobile_native_changes - # Skip only on an explicit "no": a gate job that failed or errored leaves the - # output empty, and that must run the lint rather than silently skip it. - if: ${{ !cancelled() && needs.mobile_native_changes.outputs.changed != 'false' }} - runs-on: blacksmith-6vcpu-macos-26 - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... - - - name: Install mobile native static analysis tools - run: brew bundle install --file apps/mobile/Brewfile - - - name: Lint mobile native sources - run: vp run lint:mobile - - release_smoke: - name: Release Smoke - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... - - - name: Exercise release-only workflow steps - run: node scripts/release-smoke.ts diff --git a/.github/workflows/cursor-hygiene-webhook.yml b/.github/workflows/cursor-hygiene-webhook.yml deleted file mode 100644 index ea0f579b4ac6..000000000000 --- a/.github/workflows/cursor-hygiene-webhook.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Forward to Cursor hygiene - -on: - push: - branches: [main] - pull_request: - types: [opened, reopened, ready_for_review] - issues: - types: [opened, closed, reopened] - discussion: - types: [created, closed, reopened] - -permissions: - contents: read - -jobs: - forward: - name: POST to Cursor - runs-on: ubuntu-24.04 - steps: - - name: POST to Cursor - env: - URL: ${{ secrets.CURSOR_T3CODE_WEBHOOK_URL }} - AUTH: ${{ secrets.CURSOR_T3CODE_WEBHOOK_AUTH }} - run: | - set -euo pipefail - if [ -z "${URL:-}" ] || [ -z "${AUTH:-}" ]; then - echo "Missing CURSOR_T3CODE_WEBHOOK_URL or CURSOR_T3CODE_WEBHOOK_AUTH — skipping." - exit 0 - fi - curl -fsS --max-time 60 -X POST "$URL" \ - -H "Authorization: $AUTH" \ - -H "Content-Type: application/json" \ - -H "X-GitHub-Event: ${{ github.event_name }}" \ - -H "X-GitHub-Delivery: ${{ github.run_id }}-${{ github.run_attempt }}" \ - --data-binary @"${{ github.event_path }}" diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml deleted file mode 100644 index 25e744a42968..000000000000 --- a/.github/workflows/deploy-relay.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: Deploy T3 Connect relay - -on: - push: - branches: - - main - -permissions: - contents: read - id-token: none - statuses: write - -concurrency: - group: relay-production - cancel-in-progress: false - -jobs: - deploy_relay: - name: Deploy production relay - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 15 - environment: - name: production - env: - CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} - PLANETSCALE_ORGANIZATION: ${{ vars.PLANETSCALE_ORGANIZATION }} - AXIOM_ORG_ID: ${{ vars.AXIOM_ORG_ID }} - RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }} - RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }} - RELAY_TUNNEL_ZONE_NAME: ${{ vars.RELAY_TUNNEL_ZONE_NAME }} - CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} - CLERK_JWT_AUDIENCE: ${{ vars.CLERK_JWT_AUDIENCE }} - APNS_ENVIRONMENT: ${{ vars.APNS_ENVIRONMENT }} - APNS_TEAM_ID: ${{ vars.APNS_TEAM_ID }} - APNS_KEY_ID: ${{ vars.APNS_KEY_ID }} - APNS_BUNDLE_ID: ${{ vars.APNS_BUNDLE_ID }} - ALCHEMY_TELEMETRY_DISABLED: "1" - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=t3code-relay... - - - name: Deploy production relay stage - id: deploy - run: vp run --filter t3code-relay deploy --stage prod --yes --github-output - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }} - PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }} - AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - APNS_PRIVATE_KEY: ${{ secrets.APNS_PRIVATE_KEY }} - FCM_SERVICE_ACCOUNT: ${{ secrets.FCM_SERVICE_ACCOUNT }} - - - name: Publish relay deploy commit status - uses: actions/github-script@v8 - with: - script: | - const result = "${{ steps.deploy.outputs.result }}"; - const changed = "${{ steps.deploy.outputs.changed }}" === "true"; - const description = changed - ? "Relay production deploy applied infrastructure changes." - : result === "noop" - ? "Relay production deploy was a no-op." - : `Relay production deploy completed with result: ${result}.`; - - await github.rest.repos.createCommitStatus({ - owner: context.repo.owner, - repo: context.repo.repo, - sha: context.sha, - state: "success", - context: "Relay deploy / production", - description, - target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, - }); diff --git a/.github/workflows/desktop-macos-preview.yml b/.github/workflows/desktop-macos-preview.yml deleted file mode 100644 index 7875aec6f36b..000000000000 --- a/.github/workflows/desktop-macos-preview.yml +++ /dev/null @@ -1,361 +0,0 @@ -name: Desktop macOS Preview - -on: - pull_request: - types: [labeled, unlabeled, synchronize, reopened, closed] - -permissions: - contents: read - -# Build events and cleanup events use separate groups: a push must cancel a -# stale in-flight build, but must never cancel a cleanup run mid-delete. The -# publish job re-checks PR state before uploading to cover the reverse race. -concurrency: - group: desktop-macos-preview-${{ github.event.pull_request.number }}-${{ contains(fromJSON('["closed", "unlabeled"]'), github.event.action) && 'cleanup' || 'build' }} - # Cleanup runs must complete (a close event right after an unlabel queues - # behind the running cleanup instead of canceling it mid-delete), and events - # that skip the build job, such as adding an unrelated label, must not - # cancel an in-flight build either. - cancel-in-progress: ${{ !contains(fromJSON('["closed", "unlabeled"]'), github.event.action) && (github.event.action != 'labeled' || github.event.label.name == 'preview:mac') }} - -jobs: - # Builds run PR code, so this job keeps a read-only token. Publishing to the - # release happens in the publish job below, which never checks out PR code. - build: - name: Build macOS Apple Silicon preview - if: >- - github.event.action != 'closed' && - github.event.action != 'unlabeled' && - github.event.pull_request.head.repo.full_name == github.repository && - contains(github.event.pull_request.labels.*.name, 'preview:mac') && - (github.event.action != 'labeled' || github.event.label.name == 'preview:mac') - runs-on: blacksmith-12vcpu-macos-26 - timeout-minutes: 30 - outputs: - dmg_name: ${{ steps.build.outputs.dmg_name }} - version: ${{ steps.version.outputs.version }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.sha }} - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: false - - - name: Install desktop dependencies - run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... - - - name: Cache resource monitor - id: resource_monitor_cache - uses: actions/cache@v6 - with: - path: native/resource-monitor/target/aarch64-apple-darwin/release/t3-resource-monitor - key: resource-monitor-aarch64-apple-darwin-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} - - - name: Setup Rust - if: steps.resource_monitor_cache.outputs.cache-hit != 'true' - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-apple-darwin - - - id: version - name: Set preview version and public configuration - shell: bash - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - - base_version="$(node -p "require('./apps/desktop/package.json').version")" - preview_version="${base_version}-pr.${PR_NUMBER}.${GITHUB_RUN_NUMBER}" - node scripts/update-release-package-versions.ts "$preview_version" - cp .env.example .env - - echo "version=$preview_version" >> "$GITHUB_OUTPUT" - - - id: build - name: Build unsigned macOS DMG - shell: bash - env: - T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} - PREVIEW_VERSION: ${{ steps.version.outputs.version }} - run: | - set -euo pipefail - - vp run dist:desktop:artifact \ - --platform mac \ - --target dmg \ - --arch arm64 \ - --build-version "$PREVIEW_VERSION" \ - --verbose - - shopt -s nullglob - dmg_files=(release/*.dmg) - if (( ${#dmg_files[@]} != 1 )); then - printf 'Expected one DMG, found %s.\n' "${#dmg_files[@]}" >&2 - exit 1 - fi - printf 'dmg_name=%s\n' "$(basename "${dmg_files[0]}")" >> "$GITHUB_OUTPUT" - - # archive: false uploads the file as its own artifact named after the - # file, so the publish job downloads by *.dmg pattern, not by name. - - name: Upload macOS DMG - uses: actions/upload-artifact@v7 - with: - path: release/*.dmg - if-no-files-found: error - archive: false - overwrite: true - retention-days: 7 - - # Release assets download without a GitHub account, unlike workflow - # artifacts. All preview DMGs live on one rolling prerelease tagged - # "desktop-preview" (release.yml only matches v*.*.* tags), so publishing a - # build never notifies release watchers. This job holds the write token and - # only handles the artifact the build job produced; it never runs PR code. - publish: - name: Publish anonymous download - needs: build - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - permissions: - contents: write - pull-requests: write - steps: - - name: Download macOS DMG - uses: actions/download-artifact@v8 - with: - pattern: "*.dmg" - merge-multiple: true - path: release - - - id: upload - name: Upload DMG to the rolling preview release - shell: bash - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: | - set -euo pipefail - - tag="desktop-preview" - - # True while the PR is open and still carries the preview label. - preview_eligible() { - [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ - --json state,labels \ - --jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)')" == "OPEN true" ]] - } - - # The build ran for many minutes. If the PR closed or lost the label - # meanwhile, cleanup already ran in its own concurrency group, so - # publishing now would resurrect a deleted download. - if ! preview_eligible; then - echo "PR closed or preview label removed while building. Skipping publish." - exit 0 - fi - - dmg_path="$(find release -type f -name '*.dmg' -print -quit)" - if [[ -z "$dmg_path" ]]; then - echo "No DMG found in the downloaded artifact." >&2 - exit 1 - fi - - # The filename comes out of the build, which runs PR code. Requiring - # this PR's marker keeps a build from clobbering or deleting another - # PR's asset, since those names carry a different -pr.N. marker. - if [[ "$(basename "$dmg_path")" != *"-pr.${PR_NUMBER}."* ]]; then - echo "DMG name '$(basename "$dmg_path")' does not carry this PR's -pr.${PR_NUMBER}. marker. Refusing to publish." >&2 - exit 1 - fi - - if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - # "|| true" tolerates a concurrent publish job creating the - # release between the check and the create. - gh release create "$tag" \ - --repo "$GITHUB_REPOSITORY" \ - --target "$DEFAULT_BRANCH" \ - --prerelease \ - --title "Desktop preview builds" \ - --notes "Rolling unsigned desktop builds from pull requests with a preview label. Each download is removed when its pull request closes or loses the label. Install stable builds from the latest release instead." \ - || true - fi - - # Keep one DMG per PR: drop this PR's older builds first. The - # trailing dot keeps -pr.12. from matching -pr.123. builds. - gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \ - | { grep -F -- "-pr.${PR_NUMBER}." || true; } \ - | while read -r asset; do - gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \ - || echo "Asset $asset was already removed by a concurrent run." - done - - gh release upload "$tag" "$dmg_path" --repo "$GITHUB_REPOSITORY" --clobber - - # Re-check after uploading. A cleanup run that started during the - # upload listed assets before ours existed, so it cannot delete it. - # Whichever writer acts last sees the final PR state; if the preview - # became ineligible, delete what we just uploaded. - if ! preview_eligible; then - gh release delete-asset "$tag" "$(basename "$dmg_path")" --repo "$GITHUB_REPOSITORY" --yes \ - || echo "Asset was already removed by a concurrent run." - echo "PR closed or preview label removed during upload. Removed the download." - exit 0 - fi - - echo "download_url=https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/$(basename "$dmg_path")" >> "$GITHUB_OUTPUT" - - - name: Comment download link - if: steps.upload.outputs.download_url != '' - uses: actions/github-script@v8 - env: - DOWNLOAD_URL: ${{ steps.upload.outputs.download_url }} - DMG_NAME: ${{ needs.build.outputs.dmg_name }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PREVIEW_VERSION: ${{ needs.build.outputs.version }} - with: - script: | - const { data: pullRequest } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.payload.pull_request.number, - }); - if ( - pullRequest.head.sha !== process.env.HEAD_SHA || - pullRequest.state !== "open" || - !pullRequest.labels.some((label) => label.name === "preview:mac") - ) { - core.info("Skipping the outdated macOS preview comment."); - return; - } - - const marker = ""; - const body = [ - marker, - "### macOS preview", - "", - `[Download Apple Silicon DMG](${process.env.DOWNLOAD_URL})`, - "", - `Version: ${process.env.PREVIEW_VERSION}`, - `Commit: ${process.env.HEAD_SHA.slice(0, 7)}`, - "", - "Unsigned build. Clear quarantine before opening:", - "```sh", - `xattr -d com.apple.quarantine ~/Downloads/${process.env.DMG_NAME}`, - "```", - "", - "No GitHub sign-in is needed. The download stays available until this PR closes or the preview label is removed.", - ].join("\n"); - - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - per_page: 100, - }); - const existing = comments.find((comment) => comment.body?.includes(marker)); - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - body, - }); - } - - # The way out: closing the PR or removing the label deletes its DMG from the - # rolling release and updates the PR comment to say so. - cleanup: - name: Remove preview download - if: >- - github.event.pull_request.head.repo.full_name == github.repository && - ((github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'preview:mac')) || - (github.event.action == 'unlabeled' && github.event.label.name == 'preview:mac')) - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - permissions: - contents: write - pull-requests: write - steps: - - id: delete - name: Delete this PR's preview assets - shell: bash - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - - tag="desktop-preview" - - # A stale cleanup must not delete a download that became valid - # again. If the PR is open and labeled once more, the next publish - # owns this PR's assets and replaces them itself. - if [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ - --json state,labels \ - --jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)')" == "OPEN true" ]]; then - echo "PR is open and labeled again. Skipping cleanup." - echo "removed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "removed=true" >> "$GITHUB_OUTPUT" - - if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "No preview release exists. Nothing to clean up." - exit 0 - fi - - gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \ - | { grep -F -- "-pr.${PR_NUMBER}." || true; } \ - | while read -r asset; do - gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \ - || echo "Asset $asset was already removed by a concurrent run." - done - - - name: Mark the preview comment as removed - if: steps.delete.outputs.removed == 'true' - uses: actions/github-script@v8 - with: - script: | - const marker = ""; - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - per_page: 100, - }); - const existing = comments.find((comment) => comment.body?.includes(marker)); - if (!existing) { - return; - } - - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body: [ - marker, - "### macOS preview", - "", - "The preview download was removed because this PR closed or the preview label was removed.", - ].join("\n"), - }); diff --git a/.github/workflows/issue-labels.yml b/.github/workflows/issue-labels.yml deleted file mode 100644 index d6571d65d453..000000000000 --- a/.github/workflows/issue-labels.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Issue Labels - -on: - push: - branches: - - main - paths: - - .github/ISSUE_TEMPLATE/** - - .github/workflows/issue-labels.yml - workflow_dispatch: - -permissions: - issues: write - -jobs: - sync: - name: Sync issue labels - runs-on: ubuntu-24.04 - steps: - - name: Ensure managed issue labels exist - uses: actions/github-script@v7 - with: - script: | - const managedLabels = [ - { - name: "bug", - color: "d73a4a", - description: "Something is broken or behaving incorrectly.", - }, - { - name: "enhancement", - color: "a2eeef", - description: "Requested improvement or new capability.", - }, - { - name: "needs-triage", - color: "fbca04", - description: "Issue needs maintainer review and initial categorization.", - }, - ]; - - for (const label of managedLabels) { - try { - const { data: existing } = await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - }); - - if ( - existing.color !== label.color || - (existing.description ?? "") !== label.description - ) { - await github.rest.issues.updateLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); - } - } catch (error) { - if (error.status !== 404) { - throw error; - } - - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); - } - } diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml deleted file mode 100644 index d53602f8f5e8..000000000000 --- a/.github/workflows/mobile-eas-preview.yml +++ /dev/null @@ -1,94 +0,0 @@ -name: Mobile EAS Preview - -on: - pull_request: - types: [opened, reopened, synchronize, labeled] - -jobs: - preview: - name: EAS Preview - if: | - contains(github.event.pull_request.labels.*.name, '🚀 Mobile Continuous Deployment') && - (github.event.action != 'labeled' || github.event.label.name == '🚀 Mobile Continuous Deployment') - runs-on: blacksmith-8vcpu-ubuntu-2404 - concurrency: - group: mobile-eas-preview-${{ github.event.pull_request.number }} - cancel-in-progress: true - permissions: - contents: read - pull-requests: write - env: - APP_VARIANT: preview - NODE_OPTIONS: --max-old-space-size=8192 - MOBILE_VERSION_POLICY: fingerprint - steps: - - id: expo-token - name: Check for EXPO_TOKEN - env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: | - if [ -n "$EXPO_TOKEN" ]; then - echo "present=true" >> "$GITHUB_OUTPUT" - else - echo "present=false" >> "$GITHUB_OUTPUT" - echo "EXPO_TOKEN is not available; skipping EAS preview." - fi - - - name: Checkout - if: steps.expo-token.outputs.present == 'true' - uses: actions/checkout@v6 - with: - fetch-depth: 0 - # No sparse-checkout here: it makes actions/checkout fetch with - # --filter=blob:none, and eas-cli archives the project via - # `git clone --depth 1 file://`, which fails (exit 128) - # when the partial clone can't serve the unfetched blobs. - - - name: Setup Vite+ - if: steps.expo-token.outputs.present == 'true' - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/mobile... - - - name: Expose pnpm - if: steps.expo-token.outputs.present == 'true' - run: | - pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" - vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" - echo "$vp_pnpm_bin" >> "$GITHUB_PATH" - "$vp_pnpm_bin/pnpm" --version - - - name: Setup EAS - if: steps.expo-token.outputs.present == 'true' - uses: expo/expo-github-action@v8 - with: - eas-version: latest - token: ${{ secrets.EXPO_TOKEN }} - # npm, not pnpm: this only installs eas-cli into the action's own - # tool dir, and pnpm 11 hard-fails that install on dtrace-provider's - # ignored build script (no allowBuilds config outside the repo). - packager: npm - - - name: Pull preview environment variables - if: steps.expo-token.outputs.present == 'true' - working-directory: apps/mobile - env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: eas env:pull preview --non-interactive - - - name: Deploy with fingerprint check - if: steps.expo-token.outputs.present == 'true' - uses: expo/expo-github-action/continuous-deploy-fingerprint@main - env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - with: - profile: preview:dev - branch: pr-${{ github.event.pull_request.number }} - platform: all - environment: preview - working-directory: apps/mobile - github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml deleted file mode 100644 index 4ad9f4f7672b..000000000000 --- a/.github/workflows/mobile-eas-production.yml +++ /dev/null @@ -1,290 +0,0 @@ -name: Mobile EAS Production - -# Production builds and OTA updates run from CI (Linux) — never from a laptop. -# Under the fingerprint runtime-version policy the fingerprint must be computed -# in the same OS/pnpm as the EAS build; a macOS `eas build` computes a different -# fingerprint (platform-specific deps + pnpm version) and errors. On this Linux -# runner, with corepack pinning pnpm 10.24 in eas.json, local == build. -# -# Every merge to main that touches the mobile app reconciles, per platform: -# 1. Store builds: if the latest production build's version differs from -# app.config.ts, cut a new build and submit it (TestFlight + Play internal -# track). Bumping `version` is therefore all it takes to -# start the next release train — the first build of a version enters -# external-TestFlight beta review immediately, and later builds of the -# same version auto-approve until that version is released. After App -# Store approval, Apple closes the release train and `version` must be -# bumped before another iOS build can be submitted. Releasing to the App -# Store stays a manual App Store Connect step. -# 2. OTA: publish a production-channel update for each platform where at -# least one finished production build matches the current native -# fingerprint. Old-version binaries with a matching fingerprint receive -# it too. When native drift means no binary could install the update, -# it is skipped and flagged in the job summary instead of published -# into the void. -# workflow_dispatch remains as a manual override for both modes (e.g. to -# retry an errored build or force an OTA). -on: - workflow_dispatch: - inputs: - mode: - description: "build (+ auto-submit to TestFlight) or update (OTA)" - required: true - type: choice - default: build - options: - - build - - update - platform: - description: "Target platform" - required: true - type: choice - default: ios - options: - - ios - - android - - all - version: - description: "Optional build version override (blank uses app.config.ts; an override is committed before building)" - required: false - type: string - message: - description: "OTA update message (mode=update only)" - required: false - type: string - push: - branches: [main] - paths: - - apps/mobile/** - - packages/client-runtime/** - - packages/contracts/** - - packages/shared/** - - assets/** - - scripts/** - - patches/** - - pnpm-lock.yaml - - pnpm-workspace.yaml - - .github/workflows/mobile-eas-production.yml - -# Serialize runs so OTAs publish in merge order. GitHub keeps at most one -# queued run per group, so a burst of merges collapses into one run of the -# newest commit — intermediate commits don't need their own OTA. -concurrency: - group: mobile-eas-production - cancel-in-progress: false - -jobs: - production: - name: EAS Production ${{ github.event_name == 'push' && 'auto' || inputs.mode }} - runs-on: blacksmith-8vcpu-ubuntu-2404 - permissions: - contents: read - env: - APP_VARIANT: production - NODE_OPTIONS: --max-old-space-size=8192 - steps: - - id: expo-token - name: Check for EXPO_TOKEN - env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: | - if [ -n "$EXPO_TOKEN" ]; then - echo "present=true" >> "$GITHUB_OUTPUT" - else - echo "present=false" >> "$GITHUB_OUTPUT" - echo "EXPO_TOKEN is not available; skipping EAS production job." - fi - - - id: version_app_token - name: Mint release app token for version override - if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.version != '' - 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 - if: steps.expo-token.outputs.present == 'true' - uses: actions/checkout@v6 - with: - fetch-depth: 0 - token: ${{ steps.version_app_token.outputs.token || github.token }} - # No sparse-checkout here: it makes actions/checkout fetch with - # --filter=blob:none, and eas-cli archives the project via - # `git clone --depth 1 file://`, which fails (exit 128) - # when the partial clone can't serve the unfetched blobs. - - - name: Setup Vite+ - if: steps.expo-token.outputs.present == 'true' - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/mobile... - - - name: Expose pnpm - if: steps.expo-token.outputs.present == 'true' - run: | - pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" - vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" - echo "$vp_pnpm_bin" >> "$GITHUB_PATH" - "$vp_pnpm_bin/pnpm" --version - - - name: Setup EAS - if: steps.expo-token.outputs.present == 'true' - uses: expo/expo-github-action@v8 - with: - eas-version: latest - token: ${{ secrets.EXPO_TOKEN }} - # npm, not pnpm: this only installs eas-cli into the action's own - # tool dir, and pnpm 11 hard-fails that install on dtrace-provider's - # ignored build script (no allowBuilds config outside the repo). - packager: npm - - - name: Pull production environment variables - if: steps.expo-token.outputs.present == 'true' - working-directory: apps/mobile - env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: eas env:pull production --non-interactive - - - name: Apply manual version override - if: steps.version_app_token.outcome == 'success' - env: - GH_TOKEN: ${{ steps.version_app_token.outputs.token }} - APP_SLUG: ${{ steps.version_app_token.outputs.app-slug }} - RELEASE_VERSION: ${{ inputs.version }} - run: | - if [ "$GITHUB_REF_TYPE" != "branch" ]; then - echo "Version overrides require dispatching this workflow from a branch; received $GITHUB_REF_TYPE '$GITHUB_REF_NAME'." >&2 - exit 1 - fi - if ! [[ "$RELEASE_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}$ ]]; then - echo "Version override must contain two or three dot-separated integers; received '$RELEASE_VERSION'." >&2 - exit 1 - fi - - node --input-type=module -e ' - import fs from "node:fs"; - const path = "apps/mobile/app.config.ts"; - const source = fs.readFileSync(path, "utf8"); - const next = source.replace( - /^( version: ")[^"]+(".*)$/m, - `$1${process.env.RELEASE_VERSION}$2`, - ); - if (next === source && !source.includes(` version: "${process.env.RELEASE_VERSION}"`)) { - throw new Error("Could not update app version"); - } - fs.writeFileSync(path, next); - ' - vp fmt apps/mobile/app.config.ts - - if git diff --quiet -- apps/mobile/app.config.ts; then - echo "app.config.ts is already at $RELEASE_VERSION; no version commit needed." - exit 0 - fi - - user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)" - git config user.name "${APP_SLUG}[bot]" - git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" - git add apps/mobile/app.config.ts - git commit \ - -m "chore(mobile): bump app version to $RELEASE_VERSION" \ - -m "Co-authored-by: codex " - git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" - - - name: Summarize manual build version - if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' - working-directory: apps/mobile - run: | - version="$(npx expo config --json --type public | jq -r '.version')" - { - echo "## Manual production build" - echo - echo "- App version: \`$version\`" - echo "- Platform: \`${{ inputs.platform }}\`" - echo - echo "> Apple closes an iOS release train after App Store approval. Before building iOS, confirm \`$version\` is newer than the approved App Store version." - } >> "$GITHUB_STEP_SUMMARY" - - - name: Build and submit (manual) - if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' - working-directory: apps/mobile - env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: eas build --platform ${{ inputs.platform }} --profile production --auto-submit --non-interactive --no-wait - - - name: Publish OTA update (manual) - if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'update' - working-directory: apps/mobile - env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: | - eas update \ - --channel production \ - --environment production \ - --platform ${{ inputs.platform }} \ - --message "${{ inputs.message || format('Production OTA ({0})', github.sha) }}" \ - --non-interactive - - # No --status filter on build:list: an in-queue/in-progress build must - # count as existing, or every merge during the build window would cut a - # duplicate. After an errored build, retry via workflow_dispatch - # mode=build — pushes won't re-trigger it until the app version changes. - - id: store_builds - name: Ensure store builds exist for the current app version - if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' - continue-on-error: true - working-directory: apps/mobile - env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: | - failed=0 - version="$(npx expo config --json --type public | jq -r '.version')" - for platform in ios android; do - latest="$(eas build:list --platform "$platform" --build-profile production --limit 1 --json --non-interactive | jq -r '.[0].appVersion // "none"')" - if [ "$latest" = "$version" ]; then - echo "$platform: production build for $version already exists (or is in progress)" - continue - fi - echo "$platform: latest production build is $latest, app.config.ts says $version — building" - if eas build --platform "$platform" --profile production --auto-submit --non-interactive --no-wait; then - echo ":building_construction: $platform: scheduled production build and submission for $version" >> "$GITHUB_STEP_SUMMARY" - else - failed=1 - echo ":x: $platform: production build or submission failed for $version" >> "$GITHUB_STEP_SUMMARY" - fi - done - exit "$failed" - - - name: Publish fingerprint-gated OTA - if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' - working-directory: apps/mobile - env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: | - message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))" - for platform in ios android; do - # eas-cli prints an environment-loaded notice to stdout before the - # JSON even with --json, so discard everything before the document. - hash="$(eas fingerprint:generate --platform "$platform" --environment production --json --non-interactive | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')" - matching="$(eas build:list --platform "$platform" --build-profile production --status finished --fingerprint-hash "$hash" --limit 1 --json --non-interactive | jq 'length')" - if [ "$matching" -gt 0 ]; then - eas update \ - --channel production \ - --environment production \ - --platform "$platform" \ - --message "$message" \ - --non-interactive - echo ":white_check_mark: $platform: OTA published to production (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY" - else - echo ":warning: $platform: no finished production build matches fingerprint \`$hash\` — OTA skipped; JS changes reach $platform only once a matching build ships" >> "$GITHUB_STEP_SUMMARY" - fi - done - - - name: Propagate store build failure - if: steps.store_builds.outcome == 'failure' - run: exit 1 diff --git a/.github/workflows/mobile-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml deleted file mode 100644 index fd98817cd105..000000000000 --- a/.github/workflows/mobile-fingerprint-check.yml +++ /dev/null @@ -1,205 +0,0 @@ -name: Mobile Fingerprint Check - -# Detects whether a PR changes the native fingerprint — i.e. whether merging -# it would leave main un-OTA-able until a new store build ships. Native-change -# PRs get the "📱 Native Change" label so they can be held and merged as a -# batch right before the next store submission, keeping main OTA-able for -# everything else in between. (Once one native PR merges, every later merge -# inherits the drifted fingerprint and loses OTA reach too — that is why the -# signal has to fire before merge, not after.) -# -# The check is advisory: it always passes, the label is the signal. Both -# fingerprints are computed in this one job (same OS, same corepack-pinned -# pnpm), so the comparison is self-consistent; no EXPO_TOKEN needed. -on: - pull_request: - paths: - - apps/mobile/** - - packages/client-runtime/** - - packages/contracts/** - - packages/shared/** - - assets/** - - scripts/** - - patches/** - - pnpm-lock.yaml - - pnpm-workspace.yaml - - .github/workflows/mobile-fingerprint-check.yml - -concurrency: - group: mobile-fingerprint-check-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - fingerprint: - name: Native fingerprint diff - runs-on: blacksmith-8vcpu-ubuntu-2404 - permissions: - contents: read - issues: write - pull-requests: write - env: - APP_VARIANT: production - NODE_OPTIONS: --max-old-space-size=8192 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - # Default pull_request checkout is the merge commit (PR applied on - # top of base), so the "head" fingerprint is the state main would - # actually be in after merging — stale branches compare cleanly. - fetch-depth: 0 - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/mobile... - - - name: Expose pnpm - run: | - pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" - vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" - echo "$vp_pnpm_bin" >> "$GITHUB_PATH" - "$vp_pnpm_bin/pnpm" --version - - - name: Fingerprint merge result - working-directory: apps/mobile - run: | - mkdir -p "$RUNNER_TEMP/fp/head" "$RUNNER_TEMP/fp/base" - for platform in ios android; do - npx expo-updates fingerprint:generate --platform "$platform" > "$RUNNER_TEMP/fp/head/$platform.json" - done - - - name: Fingerprint base - run: | - git checkout --quiet "${{ github.event.pull_request.base.sha }}" - # Re-sync node_modules to the base commit's lockfile before - # fingerprinting — a dep-changing PR must not fingerprint the base - # against head's installed packages. - pnpm install --filter=@t3tools/mobile... - cd apps/mobile - for platform in ios android; do - npx expo-updates fingerprint:generate --platform "$platform" > "$RUNNER_TEMP/fp/base/$platform.json" - done - - - id: compare - name: Compare fingerprints - run: | - changed="" - { - echo "## Native fingerprint diff" - echo - for platform in ios android; do - head_hash="$(jq -r .hash "$RUNNER_TEMP/fp/head/$platform.json")" - base_hash="$(jq -r .hash "$RUNNER_TEMP/fp/base/$platform.json")" - if [ "$head_hash" = "$base_hash" ]; then - echo "- ✅ **$platform**: unchanged (\`$head_hash\`) — OTA-compatible" - continue - fi - changed="$changed $platform" - echo "- 📱 **$platform**: \`$base_hash\` → \`$head_hash\` — merging requires a new native build before OTAs work again" - jq -r -n \ - --slurpfile h "$RUNNER_TEMP/fp/head/$platform.json" \ - --slurpfile b "$RUNNER_TEMP/fp/base/$platform.json" ' - ($b[0].sources | map({ (.filePath // .id): .hash }) | add // {}) as $bm - | $h[0].sources[] - | select($bm[(.filePath // .id)] != .hash) - | " - \(.type): `\(.filePath // .id)`"' - done - } >> "$GITHUB_STEP_SUMMARY" - echo "changed_platforms=${changed# }" >> "$GITHUB_OUTPUT" - - - name: Sync native change label - # Fork PRs get a read-only token under pull_request; the check stays - # advisory there (summary only). This workflow must not move to - # pull_request_target — it installs and runs PR code. - if: github.event.pull_request.head.repo.full_name == github.repository - uses: actions/github-script@v8 - env: - CHANGED_PLATFORMS: ${{ steps.compare.outputs.changed_platforms }} - with: - script: | - const managedLabel = { - name: "📱 Native Change", - color: "d93f0b", - description: - "Changes the native fingerprint; merging blocks production OTAs until a new store build ships.", - }; - const nativeChanged = (process.env.CHANGED_PLATFORMS ?? "").trim() !== ""; - const issueNumber = context.payload.pull_request.number; - - try { - const { data: existing } = await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: managedLabel.name, - }); - - if ( - existing.color !== managedLabel.color || - (existing.description ?? "") !== managedLabel.description - ) { - await github.rest.issues.updateLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: managedLabel.name, - color: managedLabel.color, - description: managedLabel.description, - }); - } - } catch (error) { - if (error.status !== 404) { - throw error; - } - - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: managedLabel.name, - color: managedLabel.color, - description: managedLabel.description, - }); - } catch (createError) { - if (createError.status !== 422) { - throw createError; - } - } - } - - const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - per_page: 100, - }); - const hasLabel = currentLabels.some((label) => label.name === managedLabel.name); - - if (nativeChanged && !hasLabel) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: [managedLabel.name], - }); - } else if (!nativeChanged && hasLabel) { - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - name: managedLabel.name, - }); - } catch (removeError) { - if (removeError.status !== 404) { - throw removeError; - } - } - } - - core.info( - `PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`, - ); diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml deleted file mode 100644 index c64bccacdca8..000000000000 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ /dev/null @@ -1,169 +0,0 @@ -name: Mobile Showcase Screenshots - -on: - workflow_dispatch: - inputs: - platform: - description: Device platforms to capture - required: true - default: all - type: choice - options: - - all - - ios - - android - appearance: - description: System appearances to capture - required: true - default: both - type: choice - options: - - both - - dark - - light - theme: - description: Palette to capture (all multiplies the run by six) - required: true - default: t3-code - type: choice - options: - - t3-code - - t3-chat - - grove - - ocean - - ember - - iris - - all - -permissions: - contents: read - -env: - NODE_OPTIONS: --max-old-space-size=8192 - -jobs: - ios: - name: iPhone 6.9, iPhone 6.5, and iPad 13 - if: inputs.platform == 'all' || inputs.platform == 'ios' - runs-on: blacksmith-12vcpu-macos-26 - # Capturing every palette multiplies the device matrix by six, and only the - # one native build is shared between them. - timeout-minutes: ${{ inputs.theme == 'all' && 300 || 60 }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/mobile... - - --filter=@t3tools/scripts... - - --filter=t3... - - - name: Expose pnpm - run: | - pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" - vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" - echo "$vp_pnpm_bin" >> "$GITHUB_PATH" - "$vp_pnpm_bin/pnpm" --version - - - name: Capture iOS showcase - run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" - - - name: Validate App Store Connect assets - run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" --validate-only - - - name: Upload iOS screenshots - if: always() - uses: actions/upload-artifact@v7 - with: - name: app-store-connect-screenshots - path: artifacts/app-store/screenshots/apple/ - if-no-files-found: warn - retention-days: 14 - - android: - name: Android phone, 7-inch tablet, and 10-inch tablet - if: inputs.platform == 'all' || inputs.platform == 'android' - runs-on: blacksmith-16vcpu-ubuntu-2404 - # Capturing every palette multiplies the device matrix by six, and only the - # one native build is shared between them. - timeout-minutes: ${{ inputs.theme == 'all' && 300 || 60 }} - env: - T3_SHOWCASE_ANDROID_ABI: x86_64 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/mobile... - - --filter=@t3tools/scripts... - - --filter=t3... - - - name: Expose pnpm - run: | - pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" - vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" - echo "$vp_pnpm_bin" >> "$GITHUB_PATH" - "$vp_pnpm_bin/pnpm" --version - - - name: Setup Java - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: 17 - - - name: Setup Gradle cache - uses: gradle/actions/setup-gradle@v5 - - - name: Enable KVM - run: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS="static_node=kvm"' \ - | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - - - name: Capture Android showcase - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: 36 - target: google_apis - arch: x86_64 - profile: pixel_7_pro - avd-name: Pixel_10_Pro - cores: 8 - ram-size: 4096M - disable-animations: false - script: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" - - - name: Validate Google Play assets - run: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" --validate-only - - - name: Upload Android screenshots - if: always() - uses: actions/upload-artifact@v7 - with: - name: google-play-screenshots - path: artifacts/app-store/screenshots/google-play/ - if-no-files-found: warn - retention-days: 14 diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml deleted file mode 100644 index af557dff62df..000000000000 --- a/.github/workflows/pr-size.yml +++ /dev/null @@ -1,295 +0,0 @@ -name: PR Size - -on: - pull_request_target: - types: [opened, reopened, synchronize, ready_for_review, converted_to_draft] - -permissions: - contents: read - -jobs: - prepare-config: - name: Prepare PR size config - runs-on: ubuntu-24.04 - outputs: - labels_json: ${{ steps.config.outputs.labels_json }} - steps: - - id: config - name: Build PR size label config - uses: actions/github-script@v8 - with: - result-encoding: string - script: | - const managedLabels = [ - { - name: "size:XS", - color: "0e8a16", - description: "0-9 effective changed lines (test files excluded in mixed PRs).", - }, - { - name: "size:S", - color: "5ebd3e", - description: "10-29 effective changed lines (test files excluded in mixed PRs).", - }, - { - name: "size:M", - color: "fbca04", - description: "30-99 effective changed lines (test files excluded in mixed PRs).", - }, - { - name: "size:L", - color: "fe7d37", - description: "100-499 effective changed lines (test files excluded in mixed PRs).", - }, - { - name: "size:XL", - color: "d93f0b", - description: "500-999 effective changed lines (test files excluded in mixed PRs).", - }, - { - name: "size:XXL", - color: "b60205", - description: "1,000+ effective changed lines (test files excluded in mixed PRs).", - }, - ]; - - core.setOutput("labels_json", JSON.stringify(managedLabels)); - sync-label-definitions: - name: Sync PR size label definitions - needs: prepare-config - if: github.event_name != 'pull_request_target' - runs-on: ubuntu-24.04 - permissions: - contents: read - issues: write - steps: - - name: Ensure PR size labels exist - uses: actions/github-script@v8 - env: - PR_SIZE_LABELS_JSON: ${{ needs.prepare-config.outputs.labels_json }} - with: - script: | - const managedLabels = JSON.parse(process.env.PR_SIZE_LABELS_JSON ?? "[]"); - - for (const label of managedLabels) { - try { - const { data: existing } = await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - }); - - if ( - existing.color !== label.color || - (existing.description ?? "") !== label.description - ) { - await github.rest.issues.updateLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); - } - } catch (error) { - if (error.status !== 404) { - throw error; - } - - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); - } catch (createError) { - if (createError.status !== 422) { - throw createError; - } - } - } - } - label: - name: Label PR size - needs: prepare-config - if: github.event_name == 'pull_request_target' - runs-on: ubuntu-24.04 - permissions: - contents: read - issues: read - pull-requests: write - concurrency: - group: pr-size-${{ github.event.pull_request.number }} - cancel-in-progress: true - steps: - # This pull_request_target job may fetch untrusted PR commits only as passive - # git data. Do not add dependency installs, build/test scripts, or cache - # actions here; use pull_request plus workflow_run for that pattern instead. - - name: Checkout base repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Sync PR size label - uses: actions/github-script@v8 - env: - PR_SIZE_LABELS_JSON: ${{ needs.prepare-config.outputs.labels_json }} - with: - script: | - const { execFileSync } = require("node:child_process"); - - const issueNumber = context.payload.pull_request.number; - const baseSha = context.payload.pull_request.base.sha; - const headSha = context.payload.pull_request.head.sha; - const headTrackingRef = `refs/remotes/pr-size/${issueNumber}`; - const managedLabels = JSON.parse(process.env.PR_SIZE_LABELS_JSON ?? "[]"); - const managedLabelNames = new Set(managedLabels.map((label) => label.name)); - // Keep this aligned with the repo's test entrypoints and test-only support files. - const testExcludePathspecs = [ - ":(glob,exclude)**/__tests__/**", - ":(glob,exclude)**/test/**", - ":(glob,exclude)**/tests/**", - ":(glob,exclude)apps/server/integration/**", - ":(glob,exclude)**/*.test.*", - ":(glob,exclude)**/*.spec.*", - ":(glob,exclude)**/*.browser.*", - ":(glob,exclude)**/*.integration.*", - ]; - - const sumNumstat = (text) => - text - .split("\n") - .filter(Boolean) - .reduce((total, line) => { - const [insertionsRaw = "0", deletionsRaw = "0"] = line.split("\t"); - const additions = - insertionsRaw === "-" ? 0 : Number.parseInt(insertionsRaw, 10) || 0; - const deletions = - deletionsRaw === "-" ? 0 : Number.parseInt(deletionsRaw, 10) || 0; - - return total + additions + deletions; - }, 0); - - const resolveSizeLabel = (totalChangedLines) => { - if (totalChangedLines < 10) { - return "size:XS"; - } - - if (totalChangedLines < 30) { - return "size:S"; - } - - if (totalChangedLines < 100) { - return "size:M"; - } - - if (totalChangedLines < 500) { - return "size:L"; - } - - if (totalChangedLines < 1000) { - return "size:XL"; - } - - return "size:XXL"; - }; - - execFileSync("git", ["fetch", "--no-tags", "origin", baseSha], { - stdio: "inherit", - }); - - execFileSync( - "git", - ["fetch", "--no-tags", "origin", `+refs/pull/${issueNumber}/head:${headTrackingRef}`], - { - stdio: "inherit", - }, - ); - - const resolvedHeadSha = execFileSync("git", ["rev-parse", headTrackingRef], { - encoding: "utf8", - }).trim(); - - if (resolvedHeadSha !== headSha) { - core.warning( - `Fetched head SHA ${resolvedHeadSha} does not match pull request head SHA ${headSha}; using fetched ref for sizing.`, - ); - } - - execFileSync("git", ["cat-file", "-e", `${baseSha}^{commit}`], { - stdio: "inherit", - }); - - const diffArgs = [ - "diff", - "--numstat", - "--ignore-all-space", - "--ignore-blank-lines", - `${baseSha}...${resolvedHeadSha}`, - ]; - - const totalChangedLines = sumNumstat( - execFileSync( - "git", - diffArgs, - { encoding: "utf8" }, - ), - ); - const nonTestChangedLines = sumNumstat( - execFileSync("git", [...diffArgs, "--", ".", ...testExcludePathspecs], { - encoding: "utf8", - }), - ); - const testChangedLines = Math.max(0, totalChangedLines - nonTestChangedLines); - - const changedLines = nonTestChangedLines === 0 ? testChangedLines : nonTestChangedLines; - const nextLabelName = resolveSizeLabel(changedLines); - - const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - per_page: 100, - }); - - for (const label of currentLabels) { - if (!managedLabelNames.has(label.name) || label.name === nextLabelName) { - continue; - } - - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - name: label.name, - }); - } catch (removeError) { - if (removeError.status !== 404) { - throw removeError; - } - } - } - - if (!currentLabels.some((label) => label.name === nextLabelName)) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: [nextLabelName], - }); - } - - const classification = - nonTestChangedLines === 0 - ? testChangedLines > 0 - ? "test-only PR" - : "no line changes" - : testChangedLines > 0 - ? "test lines excluded" - : "all non-test changes"; - - core.info( - `PR #${issueNumber}: ${nonTestChangedLines} non-test lines, ${testChangedLines} test lines, ${changedLines} effective lines -> ${nextLabelName} (${classification})`, - ); diff --git a/.github/workflows/pr-vouch.yml b/.github/workflows/pr-vouch.yml deleted file mode 100644 index c4abb08b727b..000000000000 --- a/.github/workflows/pr-vouch.yml +++ /dev/null @@ -1,199 +0,0 @@ -name: PR Vouch - -on: - pull_request_target: - types: [opened, reopened, synchronize, ready_for_review, converted_to_draft] - issue_comment: - types: [created] - push: - branches: - - main - paths: - - .github/VOUCHED.td - - .github/workflows/pr-vouch.yml - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - collect-targets: - name: Collect PR targets - runs-on: ubuntu-24.04 - outputs: - targets: ${{ steps.collect.outputs.targets }} - steps: - - id: collect - uses: actions/github-script@v8 - with: - script: | - if (context.eventName === "pull_request_target") { - const pr = context.payload.pull_request; - core.setOutput("targets", JSON.stringify([{ number: pr.number, user: pr.user.login }])); - return; - } - - if (context.eventName === "issue_comment") { - const issue = context.payload.issue; - const body = context.payload.comment?.body ?? ""; - if (!issue?.pull_request || !body.includes("/recheck-vouch")) { - core.setOutput("targets", "[]"); - return; - } - - core.setOutput( - "targets", - JSON.stringify([{ number: issue.number, user: issue.user.login }]), - ); - return; - } - - const pulls = await github.paginate(github.rest.pulls.list, { - owner: context.repo.owner, - repo: context.repo.repo, - state: "open", - per_page: 100, - }); - - const targets = pulls.map((pull) => ({ - number: pull.number, - user: pull.user.login, - })); - core.setOutput("targets", JSON.stringify(targets)); - - label: - name: Label PR ${{ matrix.target.number }} - needs: collect-targets - if: ${{ needs.collect-targets.outputs.targets != '[]' }} - runs-on: ubuntu-24.04 - concurrency: - group: pr-vouch-${{ matrix.target.number }} - cancel-in-progress: true - strategy: - fail-fast: false - matrix: - target: ${{ fromJson(needs.collect-targets.outputs.targets) }} - steps: - - id: vouch - name: Check PR author trust - uses: mitchellh/vouch/action/check-user@v1 - with: - user: ${{ matrix.target.user }} - allow-fail: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Sync PR labels - uses: actions/github-script@v8 - env: - PR_NUMBER: ${{ matrix.target.number }} - VOUCH_STATUS: ${{ steps.vouch.outputs.status }} - with: - script: | - const issueNumber = Number(process.env.PR_NUMBER); - const status = process.env.VOUCH_STATUS; - const managedLabels = [ - { - name: "vouch:trusted", - color: "1f883d", - description: "PR author is trusted by repo permissions or the VOUCHED list.", - }, - { - name: "vouch:unvouched", - color: "fbca04", - description: "PR author is not yet trusted in the VOUCHED list.", - }, - { - name: "vouch:denounced", - color: "d1242f", - description: "PR author is explicitly blocked by the VOUCHED list.", - }, - ]; - - const managedLabelNames = new Set(managedLabels.map((label) => label.name)); - - for (const label of managedLabels) { - try { - const { data: existing } = await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - }); - - if ( - existing.color !== label.color || - (existing.description ?? "") !== label.description - ) { - await github.rest.issues.updateLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); - } - } catch (error) { - if (error.status !== 404) { - throw error; - } - - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); - } catch (createError) { - if (createError.status !== 422) { - throw createError; - } - } - } - } - - const nextLabelName = - status === "denounced" - ? "vouch:denounced" - : ["bot", "collaborator", "vouched"].includes(status) - ? "vouch:trusted" - : "vouch:unvouched"; - - const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - per_page: 100, - }); - - for (const label of currentLabels) { - if (!managedLabelNames.has(label.name) || label.name === nextLabelName) { - continue; - } - - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - name: label.name, - }); - } catch (removeError) { - if (removeError.status !== 404) { - throw removeError; - } - } - } - - if (!currentLabels.some((label) => label.name === nextLabelName)) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: [nextLabelName], - }); - } - - core.info(`PR #${issueNumber}: ${status} -> ${nextLabelName}`); diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml deleted file mode 100644 index 62f8fd1f5470..000000000000 --- a/.github/workflows/publish-aur.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Publish AUR package - -# See packaging/aur/README.md. - -on: - workflow_call: - inputs: - release_tag: - required: true - type: string - pkgrel: - required: false - default: "1" - type: string - secrets: - AUR_SSH_PRIVATE_KEY: - required: true - workflow_dispatch: - inputs: - release_tag: - description: "Release tag to publish" - required: true - type: string - pkgrel: - description: "Arch package release override" - required: false - default: "1" - type: string - -permissions: - contents: read - -concurrency: - group: publish-aur - cancel-in-progress: false - -jobs: - publish: - name: Validate and publish - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 30 - container: - image: archlinux:base-devel - - steps: - - name: Install Arch packaging tools - run: pacman -Syu --noconfirm --needed git github-cli jq namcap openssh sudo - - - name: Checkout packaging sources - uses: actions/checkout@v6 - - - name: Create unprivileged build user - run: | - useradd --create-home builder - install -Dm0440 /dev/stdin /etc/sudoers.d/builder <<'EOF' - builder ALL=(root) NOPASSWD: /usr/bin/pacman - EOF - - - name: Validate and publish package sources - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ inputs.release_tag }} - PKGREL: ${{ inputs.pkgrel || '1' }} - AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} - run: packaging/aur/scripts/release.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 39e485d42a98..000000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,1261 +0,0 @@ -name: Release - -on: - push: - tags: - - "v*.*.*" - - "!v*-nightly.*" - schedule: - # Avoid minute zero, when GitHub scheduled jobs are busiest. - - cron: "8,38 * * * *" - workflow_dispatch: - inputs: - channel: - description: "Release channel" - required: false - default: stable - type: choice - options: - - stable - - nightly - version: - description: "Stable version override (for example 1.2.3). Defaults to the version the latest nightly previewed." - required: false - type: string - -# Serialize nightlies (scheduled and manual) so overlapping runs cannot build -# the same commit twice or publish out of order. Stable tag releases get their -# own group so a nightly never blocks them. Running publishers are never -# canceled, and queue: max keeps every pending run instead of the default -# newest-wins single slot, so a queued stable tag can never be silently -# dropped. Automatic nightlies recheck the release gap after leaving the queue. -concurrency: - group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly') && 'nightly' || 'stable' }} - cancel-in-progress: false - queue: max - -permissions: - contents: read - id-token: none - -jobs: - # Picks the commit every later job builds. Nightlies and tag pushes build the - # triggering commit. Manual stable releases build the commit of the latest - # published nightly, so stable only ever ships a build that nightly users - # have already run. Scheduled runs also decide here whether a nightly is due. - resolve_commit: - name: Resolve release commit - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 5 - outputs: - ref: ${{ steps.resolve.outputs.ref }} - nightly_version: ${{ steps.resolve.outputs.nightly_version }} - has_changes: ${{ steps.resolve.outputs.has_changes }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - sparse-checkout: .github/scripts - - - id: resolve - name: Resolve release commit - uses: actions/github-script@v8 - env: - DISPATCH_CHANNEL: ${{ inputs.channel }} - with: - script: | - const { - shouldReleaseNightly, - resolveLatestNightlyCommit, - } = require('./.github/scripts/check-nightly-release.cjs'); - - if (context.eventName === 'schedule') { - core.setOutput('has_changes', await shouldReleaseNightly({ github, context, core })); - core.setOutput('ref', context.sha); - } else if (context.eventName === 'workflow_dispatch' && process.env.DISPATCH_CHANNEL !== 'nightly') { - const { tag, sha, version } = await resolveLatestNightlyCommit({ github, context, core }); - core.notice(`Stable release builds ${sha}, the commit shipped by ${tag}.`); - core.setOutput('ref', sha); - core.setOutput('nightly_version', version); - } else { - core.setOutput('ref', context.sha); - } - - preflight: - name: Preflight - needs: [resolve_commit] - if: | - needs.resolve_commit.result == 'success' && - (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true') - 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.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: ${{ needs.resolve_commit.outputs.ref }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.resolve_commit.outputs.ref }} - fetch-depth: 0 - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: true - env: - pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - - - id: release_meta - name: Resolve release version - shell: bash - env: - DISPATCH_CHANNEL: ${{ github.event.inputs.channel }} - DISPATCH_VERSION: ${{ github.event.inputs.version }} - NIGHTLY_VERSION: ${{ needs.resolve_commit.outputs.nightly_version }} - NIGHTLY_DATE: ${{ github.run_started_at }} - NIGHTLY_SHA: ${{ needs.resolve_commit.outputs.ref }} - NIGHTLY_RUN_NUMBER: ${{ github.run_number }} - run: | - 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)" - - 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:-$NIGHTLY_VERSION}" - if [[ -z "$raw" ]]; then - echo "workflow_dispatch stable releases need a version input or a published nightly." >&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 - - 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 - - - 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 - - # Share only the verification results, not the large registry metadata cache. - - name: Upload dependency verification - continue-on-error: true - uses: actions/upload-artifact@v7 - with: - name: release-dependency-verification - path: ${{ runner.temp }}/pnpm-metadata/lockfile-verified.jsonl - - quality: - name: Release quality checks - needs: [preflight] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.outputs.ref }} - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - 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 - - - name: Check - run: vp check - - - name: Typecheck - run: vp run typecheck - - - uses: ./.github/actions/setup-apt-mirrors - - - name: Install browser secret helper build libraries - run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - - - name: Test - run: vp run test - - relay_public_config: - name: Resolve T3 Connect public config - # Consumes only the release commit, not preflight's resolved version, so it - # runs alongside preflight instead of after it. The condition mirrors preflight's. - needs: [resolve_commit] - if: | - needs.resolve_commit.result == 'success' && - (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true') - 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.resolve_commit.outputs.ref }} - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=t3code-relay... - - - 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: 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 - - - 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 - # build the Linux pty.node here, on Linux, and hand it to the Windows packaging - # job — the Windows artifact then ships a ready WSL backend binary with no - # cross-compiling and no first-launch compiler/node-gyp/network on the user's - # machine. node-pty is N-API, so one binary works across all WSL Node versions. - build_wsl_node_pty: - name: Build WSL node-pty (linux-x64) - # Same gating as relay_public_config: only the release commit is needed, so - # this runs alongside preflight. See the condition comment there. - needs: [resolve_commit] - if: | - needs.resolve_commit.result == 'success' && - (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true') - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.resolve_commit.outputs.ref }} - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=t3... - - - name: Build node-pty linux-x64 prebuild - shell: bash - run: | - set -euo pipefail - # Resolve node-pty from apps/server (where it's a dependency) and build - # 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")" - ( 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 - - - name: Upload node-pty linux-x64 prebuild - uses: actions/upload-artifact@v7 - with: - name: wsl-node-pty-x64 - path: wsl-prebuild/pty.node - 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 - # 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: blacksmith-12vcpu-macos-26 - platform: mac - target: dmg - arch: arm64 - rust_target: aarch64-apple-darwin - resource_key: darwin-arm64 - - label: macOS x64 - runner: blacksmith-12vcpu-macos-26 - platform: mac - target: dmg - arch: x64 - rust_target: x86_64-apple-darwin - resource_key: darwin-x64 - - label: Linux x64 - runner: blacksmith-32vcpu-ubuntu-2404 - platform: linux - target: AppImage - arch: x64 - rust_target: x86_64-unknown-linux-gnu - resource_key: linux-x64 - - label: Windows x64 - runner: blacksmith-32vcpu-windows-2025 - platform: win - target: nsis - arch: x64 - rust_target: x86_64-pc-windows-msvc - resource_key: win32-x64 - # - label: Windows arm64 - # runner: windows-11-arm - # platform: win - # target: nsis - # arch: arm64 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.outputs.ref }} - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: ${{ matrix.platform != 'win' }} - run-install: false - - - name: Resolve Windows package cache path - if: matrix.platform == 'win' - id: package_cache_path - shell: pwsh - run: '"path=$(vp pm cache dir)" >> $env:GITHUB_OUTPUT' - - - name: Cache Windows packages - if: matrix.platform == 'win' - uses: actions/cache@v6 - with: - path: ${{ steps.package_cache_path.outputs.path }} - key: windows-release-packages-v1-${{ matrix.arch }}-${{ hashFiles('pnpm-lock.yaml') }} - - # pnpm checks the lockfile and policy before reusing this result. A missing - # artifact leaves the cache empty, so installation runs the checks again. - - name: Download dependency verification - continue-on-error: true - uses: actions/download-artifact@v8 - with: - name: release-dependency-verification - path: ${{ runner.temp }}/pnpm-metadata - - - name: Install desktop dependencies - env: - pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... - - - name: Cache resource monitor - id: resource_monitor_cache - uses: actions/cache@v6 - with: - path: native/resource-monitor/target/${{ matrix.rust_target }}/release/t3-resource-monitor${{ matrix.platform == 'win' && '.exe' || '' }} - key: resource-monitor-${{ matrix.rust_target }}-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} - - - name: Cache Linux capture helpers - if: matrix.platform == 'linux' - id: capture_helper_cache - uses: actions/cache@v6 - with: - path: | - native/kde-snap-shot/target/${{ matrix.rust_target }}/release/t3-kde-snap-shot - native/hyprland-snap-shot/target/${{ matrix.rust_target }}/release/t3-hyprland-snap-shot - key: linux-capture-helpers-${{ matrix.rust_target }}-${{ hashFiles('native/kde-snap-shot/Cargo.lock', 'native/kde-snap-shot/Cargo.toml', 'native/kde-snap-shot/src/**', 'native/hyprland-snap-shot/Cargo.lock', 'native/hyprland-snap-shot/Cargo.toml', 'native/hyprland-snap-shot/src/**', 'native/hyprland-snap-shot/protocols/**') }} - - - name: Setup Rust - if: steps.resource_monitor_cache.outputs.cache-hit != 'true' || (matrix.platform == 'linux' && steps.capture_helper_cache.outputs.cache-hit != 'true') - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.rust_target }} - - - 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: Download WSL node-pty prebuild - if: matrix.platform == 'win' - uses: actions/download-artifact@v7 - with: - name: wsl-node-pty-x64 - path: wsl-prebuild - - - name: Install Spectre-mitigated MSVC libs - if: matrix.platform == 'win' - shell: pwsh - run: | - $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" - $installPath = & $vswhere -products * -latest -property installationPath - $setupExe = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\setup.exe" - $proc = Start-Process -FilePath $setupExe ` - -ArgumentList "modify", "--installPath", "`"$installPath`"", "--add", ` - "Microsoft.VisualStudio.Component.VC.Runtimes.x86.x64.Spectre", "--quiet", "--norestart" ` - -Wait -PassThru -NoNewWindow - if ($null -eq $proc -or $proc.ExitCode -ne 0) { - $code = if ($null -ne $proc) { $proc.ExitCode } else { 1 } - Write-Error "Visual Studio Installer failed with exit code $code" - exit $code - } - - - uses: ./.github/actions/setup-apt-mirrors - if: matrix.platform == 'linux' - - - name: Install Linux desktop build libraries - if: matrix.platform == 'linux' - shell: bash - run: | - sudo apt-get update - sudo apt-get install -y libsecret-1-dev pkg-config - if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then - sudo apt-get install -y imagemagick - fi - - if command -v magick >/dev/null 2>&1; then - magick -version - else - convert -version - fi - - - name: Prepare Azure Trusted Signing - if: matrix.platform == 'win' - shell: pwsh - env: - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - 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: | - $ErrorActionPreference = "Stop" - - $requiredSecrets = @( - $env:AZURE_TENANT_ID, - $env:AZURE_CLIENT_ID, - $env:AZURE_CLIENT_SECRET, - $env:AZURE_TRUSTED_SIGNING_ENDPOINT, - $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME, - $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME, - $env:AZURE_TRUSTED_SIGNING_PUBLISHER_NAME - ) - if ($requiredSecrets | Where-Object { [string]::IsNullOrWhiteSpace($_) }) { - Write-Host "Azure Trusted Signing disabled; skipping TrustedSigning module preparation." - exit 0 - } - - try { - Install-PackageProvider ` - -Name NuGet ` - -MinimumVersion 2.8.5.201 ` - -Force ` - -Scope CurrentUser ` - -ErrorAction Stop - } catch { - Write-Warning "Could not bootstrap NuGet package provider. Continuing because the runner may already have a usable provider. $($_.Exception.Message)" - } - - Install-Module ` - -Name TrustedSigning ` - -MinimumVersion 0.5.0 ` - -Force ` - -AllowClobber ` - -Repository PSGallery ` - -Scope CurrentUser ` - -ErrorAction Stop - - Import-Module TrustedSigning -MinimumVersion 0.5.0 -Force - Get-Command Invoke-TrustedSigning -ErrorAction Stop - - $moduleRoots = @( - [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "PowerShell", "Modules"), - [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "WindowsPowerShell", "Modules"), - [System.IO.Path]::Combine($env:ProgramFiles, "PowerShell", "Modules"), - [System.IO.Path]::Combine($env:ProgramFiles, "WindowsPowerShell", "Modules") - ) - $modulePathEntries = @($moduleRoots + ($env:PSModulePath -split ";")) | - Where-Object { $_ -and (Test-Path $_) } | - Select-Object -Unique - "PSModulePath=$($modulePathEntries -join ';')" >> $env:GITHUB_ENV - - - name: Build desktop artifact - shell: bash - env: - pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} - T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS: ${{ steps.capture_helper_cache.outputs.cache-hit == 'true' }} - CSC_LINK: ${{ secrets.CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} - MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} - T3CODE_CLERK_PASSKEY_RP_DOMAINS: ${{ vars.CLERK_PASSKEY_RP_DOMAINS }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - 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: | - args=( - --platform "${{ matrix.platform }}" - --target "${{ matrix.target }}" - --arch "${{ matrix.arch }}" - --build-version "${{ needs.preflight.outputs.version }}" - --verbose - ) - - has_all() { - for value in "$@"; do - if [[ -z "$value" ]]; then - return 1 - fi - done - return 0 - } - - if [[ "${{ matrix.platform }}" == "mac" ]]; then - if has_all "$CSC_LINK" "$CSC_KEY_PASSWORD" "$APPLE_API_KEY" "$APPLE_API_KEY_ID" "$APPLE_API_ISSUER"; then - if ! has_all "$APPLE_TEAM_ID" "$MACOS_PROVISIONING_PROFILE"; then - echo "macOS signing is configured, but APPLE_TEAM_ID or MACOS_PROVISIONING_PROFILE is missing." >&2 - exit 1 - fi - - key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" - printf '%s' "$APPLE_API_KEY" > "$key_path" - export APPLE_API_KEY="$key_path" - - profile_path="$RUNNER_TEMP/t3code.provisionprofile" - printf '%s' "$MACOS_PROVISIONING_PROFILE" | base64 -D > "$profile_path" - security cms -D -i "$profile_path" >/dev/null - export T3CODE_APPLE_TEAM_ID="$APPLE_TEAM_ID" - export T3CODE_MACOS_PROVISIONING_PROFILE="$profile_path" - - echo "macOS signing enabled." - args+=(--signed) - else - echo "macOS signing disabled (missing one or more Apple signing secrets)." - fi - elif [[ "${{ matrix.platform }}" == "win" ]]; then - # Bundle the Linux node-pty binary built by the build_wsl_node_pty job - # so the packaged WSL backend ships a ready binary (no first-launch - # compile). Required for a working WSL backend on Windows. - args+=(--wsl-prebuild "$GITHUB_WORKSPACE/wsl-prebuild/pty.node") - if has_all \ - "$AZURE_TENANT_ID" \ - "$AZURE_CLIENT_ID" \ - "$AZURE_CLIENT_SECRET" \ - "$AZURE_TRUSTED_SIGNING_ENDPOINT" \ - "$AZURE_TRUSTED_SIGNING_ACCOUNT_NAME" \ - "$AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME" \ - "$AZURE_TRUSTED_SIGNING_PUBLISHER_NAME"; then - echo "Windows signing enabled (Azure Trusted Signing)." - args+=(--signed) - else - echo "Windows signing disabled (missing one or more Azure Trusted Signing secrets)." - fi - else - echo "Signing disabled for ${{ matrix.platform }}." - fi - - vp run dist:desktop:artifact "${args[@]}" - - - name: Collect release assets - shell: bash - run: | - set -euo pipefail - mkdir -p release-publish - - shopt -s nullglob - for pattern in \ - "release/*.dmg" \ - "release/*.zip" \ - "release/*.AppImage" \ - "release/*.exe" \ - "release/*.blockmap" \ - "release/*.yml"; do - for file in $pattern; do - cp "$file" release-publish/ - done - done - - if [[ "${{ matrix.platform }}" == "mac" && "${{ matrix.arch }}" != "arm64" ]]; then - 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: Collect resource monitor - shell: bash - run: | - set -euo pipefail - binary_name="t3-resource-monitor" - if [[ "${{ matrix.platform }}" == "win" ]]; then - binary_name="${binary_name}.exe" - fi - source_path="native/resource-monitor/target/${{ matrix.rust_target }}/release/${binary_name}" - target_dir="resource-monitor-publish/${{ matrix.resource_key }}" - mkdir -p "$target_dir" - cp "$source_path" "$target_dir/$binary_name" - - - name: Upload build artifacts - uses: actions/upload-artifact@v7 - with: - name: desktop-${{ matrix.platform }}-${{ matrix.arch }} - path: release-publish/* - if-no-files-found: error - - - name: Upload resource monitor - uses: actions/upload-artifact@v7 - with: - name: resource-monitor-${{ matrix.resource_key }} - path: resource-monitor-publish/${{ matrix.resource_key }}/* - if-no-files-found: error - - publish_cli: - name: Publish CLI to npm - needs: [preflight, relay_public_config, quality, build] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.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 }} - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - 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 }}" - - # The t3 build task depends on @t3tools/web#build, so the web client is - # built (once) as part of this step. - - name: Build CLI package - run: vp run --filter t3 build - - - name: Download resource monitors - uses: actions/download-artifact@v8 - with: - pattern: resource-monitor-* - path: ${{ runner.temp }}/resource-monitors - - - name: Bundle resource monitors into CLI package - shell: bash - run: | - set -euo pipefail - for artifact_dir in "$RUNNER_TEMP"/resource-monitors/resource-monitor-*; do - resource_key="${artifact_dir##*/resource-monitor-}" - target_dir="apps/server/dist/resource-monitor/${resource_key}" - mkdir -p "$target_dir" - cp "$artifact_dir"/t3-resource-monitor* "$target_dir/" - chmod +x "$target_dir"/t3-resource-monitor 2>/dev/null || true - done - - - 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, 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: 30 - permissions: - contents: write - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.outputs.ref }} - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... - - - name: Download all desktop artifacts - uses: actions/download-artifact@v8 - with: - pattern: desktop-* - merge-multiple: true - path: release-assets - - - name: Merge macOS updater manifests - run: | - shopt -s nullglob - for x64_manifest in release-assets/*-mac-x64.yml; do - 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 - done - - - name: Publish release - if: needs.preflight.outputs.previous_tag != '' - uses: softprops/action-gh-release@v3 - with: - tag_name: ${{ needs.preflight.outputs.tag }} - target_commitish: ${{ needs.preflight.outputs.ref }} - name: ${{ needs.preflight.outputs.release_name }} - 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: | - release-assets/*.dmg - release-assets/*.zip - release-assets/*.AppImage - release-assets/*.exe - release-assets/*.blockmap - release-assets/*.yml - fail_on_unmatched_files: true - token: ${{ github.token }} - - - name: Publish first release - if: needs.preflight.outputs.previous_tag == '' - uses: softprops/action-gh-release@v3 - 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: ${{ github.token }} - - publish_aur: - name: Publish AUR package - needs: [preflight, release] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }} - uses: ./.github/workflows/publish-aur.yml - with: - release_tag: ${{ needs.preflight.outputs.tag }} - secrets: - AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} - - deploy_web: - name: Deploy hosted web app - 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 }} - T3CODE_WEB_ROUTER_URL: ${{ vars.T3CODE_WEB_ROUTER_URL }} - T3CODE_WEB_LATEST_DOMAIN: ${{ vars.T3CODE_WEB_LATEST_DOMAIN }} - T3CODE_WEB_NIGHTLY_DOMAIN: ${{ vars.T3CODE_WEB_NIGHTLY_DOMAIN }} - VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.outputs.ref }} - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: 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 }}" - - - name: Refresh release lockfile - run: vp install --lockfile-only --ignore-scripts - - - name: Deploy and alias channel - shell: bash - run: | - set -euo pipefail - - if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" || -z "${VERCEL_PROJECT_ID:-}" ]]; then - echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID." >&2 - exit 1 - fi - - router_url="${T3CODE_WEB_ROUTER_URL:-https://app.t3.codes}" - latest_domain="${T3CODE_WEB_LATEST_DOMAIN:-latest.app.t3.codes}" - nightly_domain="${T3CODE_WEB_NIGHTLY_DOMAIN:-nightly.app.t3.codes}" - router_domain="${router_url#http://}" - router_domain="${router_domain#https://}" - router_domain="${router_domain%%/*}" - - if [[ "${{ needs.preflight.outputs.release_channel }}" == "stable" ]]; then - channel_domain="$latest_domain" - channel_name="latest" - else - channel_domain="$nightly_domain" - channel_name="nightly" - fi - - vercel_scope="${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" - vercel_scope_args=(--scope "$vercel_scope") - - echo "Deploying hosted web app for $channel_name channel." - deployment_url="$( - vp dlx vercel@53.1.1 deploy \ - --archive=tgz \ - --prod \ - --skip-domain \ - --yes \ - --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" - )" - - echo "Aliasing $deployment_url to $channel_domain." - vp dlx vercel@53.1.1 alias set "$deployment_url" "$channel_domain" \ - --token "$VERCEL_TOKEN" \ - "${vercel_scope_args[@]}" - - if [[ "$channel_name" == "latest" && -n "$router_domain" && "$router_domain" != "$channel_domain" ]]; then - echo "Aliasing $deployment_url to router domain $router_domain." - vp dlx vercel@53.1.1 alias set "$deployment_url" "$router_domain" \ - --token "$VERCEL_TOKEN" \ - "${vercel_scope_args[@]}" - fi - - deploy_marketing: - name: Deploy marketing site - needs: [preflight, release] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'nightly' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - env: - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.outputs.ref }} - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/marketing... - - - name: Deploy marketing site to Vercel - shell: bash - run: | - set -euo pipefail - - if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" ]]; then - echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID." >&2 - exit 1 - fi - - VERCEL_PROJECT_ID="$( - curl --fail --silent --show-error \ - --header "Authorization: Bearer $VERCEL_TOKEN" \ - "https://api.vercel.com/v9/projects/t3code-marketing?teamId=$VERCEL_ORG_ID" \ - | jq --exit-status --raw-output '.id' - )" - export VERCEL_PROJECT_ID - - vp dlx vercel@53.1.1 deploy \ - --archive=tgz \ - --prod \ - --yes \ - --token "$VERCEL_TOKEN" \ - --scope "${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" - - finalize: - name: Finalize release - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'stable' }} - needs: [preflight, release] - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - 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: - ref: main - fetch-depth: 0 - token: ${{ steps.app_token.outputs.token }} - persist-credentials: true - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - id: app_bot - name: Resolve GitHub App bot identity - env: - GH_TOKEN: ${{ steps.app_token.outputs.token }} - APP_SLUG: ${{ steps.app_token.outputs.app-slug }} - run: | - user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)" - echo "name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT" - echo "email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT" - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... - - --filter=@t3tools/oxlint-plugin-t3code... - - - id: update_versions - name: Update version strings - env: - RELEASE_VERSION: ${{ needs.preflight.outputs.version }} - run: node scripts/update-release-package-versions.ts "$RELEASE_VERSION" --github-output - - - name: Format package.json files - if: steps.update_versions.outputs.changed == 'true' - run: vp fmt apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json - - - name: Refresh lockfile - if: steps.update_versions.outputs.changed == 'true' - run: vp install --lockfile-only --ignore-scripts - - - name: Commit and push version bump - if: steps.update_versions.outputs.changed == 'true' - shell: bash - env: - RELEASE_TAG: ${{ needs.preflight.outputs.tag }} - run: | - if git diff --quiet -- apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml; then - echo "No version changes to commit." - exit 0 - fi - - git config user.name "${{ steps.app_bot.outputs.name }}" - git config user.email "${{ steps.app_bot.outputs.email }}" - - 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 }} - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - 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/.github/workflows/sync-fork.yml b/.github/workflows/sync-fork.yml new file mode 100644 index 000000000000..f63efc2d2541 --- /dev/null +++ b/.github/workflows/sync-fork.yml @@ -0,0 +1,70 @@ +name: Sync fork + +on: + schedule: + - cron: "0 4,12 * * *" + workflow_dispatch: + +jobs: + sync: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Check out fork + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Rebase fork changes onto upstream + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + run: | + default_branch="$(gh api "repos/${REPOSITORY}" --jq '.default_branch')" + upstream_repository="$(gh api "repos/${REPOSITORY}" --jq '.parent.full_name')" + fork_head="$(git rev-parse "origin/${default_branch}")" + + git remote add upstream "https://github.com/${upstream_repository}.git" + git fetch --no-tags upstream "${default_branch}" + upstream_head="$(git rev-parse "upstream/${default_branch}")" + + if git merge-base --is-ancestor "${upstream_head}" "${fork_head}"; then + echo "Fork already contains the latest upstream commit." + exit 0 + fi + + fork_base="$(git merge-base "${fork_head}" "${upstream_head}")" + fork_paths_file="${RUNNER_TEMP}/fork-sync-paths" + git diff --name-only -z "${fork_base}" "${fork_head}" > "${fork_paths_file}" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config --local core.hooksPath /dev/null + export VITE_GIT_HOOKS=0 + + git checkout --detach "${upstream_head}" + + while IFS= read -r -d '' path; do + if git cat-file -e "${fork_head}:${path}" 2>/dev/null; then + git restore --source="${fork_head}" --staged --worktree -- "${path}" + else + git rm -f --ignore-unmatch -- "${path}" + fi + done < "${fork_paths_file}" + + git rm -r -f --ignore-unmatch -- .github/workflows + git restore \ + --source="${fork_head}" \ + --staged \ + --worktree \ + -- .github/workflows + + if ! git diff --cached --quiet; then + git commit -m "ci: remove all forked workflows, instead add sync fork worflow" + fi + + git push \ + --force-with-lease="refs/heads/${default_branch}:${fork_head}" \ + origin \ + "HEAD:${default_branch}" diff --git a/.github/workflows/thread-transfer-report.yml b/.github/workflows/thread-transfer-report.yml deleted file mode 100644 index 23eec72923bd..000000000000 --- a/.github/workflows/thread-transfer-report.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Thread Transfer Report - -on: - workflow_run: - workflows: [CI] - types: [completed] - -permissions: - actions: read - contents: read - pull-requests: write - -jobs: - publish: - name: Publish PR comment - if: github.event.workflow_run.event == 'pull_request' - runs-on: ubuntu-24.04 - concurrency: - group: thread-transfer-report-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.id }} - cancel-in-progress: true - steps: - # workflow_run has a write-capable token even for fork PRs. Only load the - # publisher from the trusted default branch and never execute PR code. - - name: Checkout trusted publisher - uses: actions/checkout@v6 - with: - ref: ${{ github.event.repository.default_branch }} - sparse-checkout: .github/scripts - - - name: Test trusted publisher - run: node --test .github/scripts/thread-transfer-report.test.cjs - - - id: resolve - name: Resolve PR and baseline artifacts - uses: actions/github-script@v8 - with: - script: | - const reporter = require("./.github/scripts/thread-transfer-report.cjs"); - await reporter.resolve({ github, context, core }); - - - name: Download PR result - if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.pr_artifact == 'true' - uses: actions/download-artifact@v8 - with: - name: thread-transfer-results - path: ${{ runner.temp }}/thread-transfer/pr - github-token: ${{ secrets.GITHUB_TOKEN }} - run-id: ${{ steps.resolve.outputs.pr_run_id }} - - - name: Download main baseline - if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.baseline_artifact == 'true' - uses: actions/download-artifact@v8 - with: - name: thread-transfer-results - path: ${{ runner.temp }}/thread-transfer/main - github-token: ${{ secrets.GITHUB_TOKEN }} - run-id: ${{ steps.resolve.outputs.baseline_run_id }} - - - name: Update thread transfer comment - if: steps.resolve.outputs.publish == 'true' - uses: actions/github-script@v8 - env: - PR_NUMBER: ${{ steps.resolve.outputs.pull_number }} - PR_SHA: ${{ steps.resolve.outputs.pr_sha }} - PR_CONCLUSION: ${{ steps.resolve.outputs.pr_conclusion }} - PR_RUN_ID: ${{ steps.resolve.outputs.pr_run_id }} - PR_RESULT_DIR: ${{ runner.temp }}/thread-transfer/pr - BASELINE_SHA: ${{ steps.resolve.outputs.baseline_sha }} - BASELINE_MATCHES_BASE: ${{ steps.resolve.outputs.baseline_matches_base }} - BASELINE_RUN_ID: ${{ steps.resolve.outputs.baseline_run_id }} - BASELINE_RESULT_DIR: ${{ runner.temp }}/thread-transfer/main - with: - script: | - const reporter = require("./.github/scripts/thread-transfer-report.cjs"); - await reporter.publish({ github, context, core }); diff --git a/.github/workflows/web-preview.yml b/.github/workflows/web-preview.yml deleted file mode 100644 index f9cc3b063fcd..000000000000 --- a/.github/workflows/web-preview.yml +++ /dev/null @@ -1,132 +0,0 @@ -name: Web Preview - -# Label a PR `preview:web` to get a hosted-web preview deployment on Vercel for -# that push and every subsequent push. The deployment is a plain (non-prod, -# non-aliased) deploy into the existing hosted-web Vercel project, so the -# latest/nightly channel aliases are never touched. -# -# The build intentionally omits the T3 Connect cloud config (Clerk keys, relay -# URL): previews boot as the hosted-static app with manual pairing only. Pair a -# server into a preview with `t3 pair --tailscale` (or any reachable HTTPS -# backend) and open the pairing URL against the preview origin. -# -# The preview must be opened at the exact deployment URL from the PR comment. -# Vite bakes that URL in as the hosted origin (via VERCEL_URL), and -# `isHostedStaticApp` matches on origin, so branch-alias URLs will not -# self-identify as the hosted app. - -on: - pull_request: - types: [labeled, synchronize, reopened] - -permissions: - contents: read - pull-requests: write - -concurrency: - group: web-preview-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - deploy: - name: Deploy web preview - # Same-repo PRs only: fork PRs do not receive the Vercel secrets, and this - # workflow should skip rather than fail for them. On `labeled` events, only - # the preview label itself triggers a deploy. - if: >- - github.event.pull_request.head.repo.full_name == github.repository && - contains(github.event.pull_request.labels.*.name, 'preview:web') && - (github.event.action != 'labeled' || github.event.label.name == 'preview:web') - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - env: - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} - VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.sha }} - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... - - --filter=@t3tools/web... - - - id: deploy - name: Deploy preview - shell: bash - run: | - set -euo pipefail - - if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" || -z "${VERCEL_PROJECT_ID:-}" ]]; then - echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID." >&2 - exit 1 - fi - - vercel_scope="${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" - - deployment_url="$( - vp dlx vercel@53.1.1 deploy \ - --archive=tgz \ - --yes \ - --token "$VERCEL_TOKEN" \ - --scope "$vercel_scope" - )" - - echo "Deployed $deployment_url" - echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT" - - - name: Comment deployment URL - uses: actions/github-script@v8 - env: - DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment_url }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - with: - script: | - const marker = ""; - const body = [ - marker, - "### Web preview", - "", - `${process.env.DEPLOYMENT_URL} (for ${process.env.HEAD_SHA.slice(0, 7)})`, - "", - "Open this exact URL — the hosted-app origin is baked in at build time.", - "Pair a server into it with `t3 pair --tailscale`, or paste a host + pairing", - "code under Settings → Connections.", - ].join("\n"); - - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - per_page: 100, - }); - const existing = comments.find((comment) => comment.body?.includes(marker)); - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - body, - }); - } diff --git a/.github/workflows/windows-tests.yml b/.github/workflows/windows-tests.yml deleted file mode 100644 index 3a70ad5a26a0..000000000000 --- a/.github/workflows/windows-tests.yml +++ /dev/null @@ -1,81 +0,0 @@ -# On-demand Windows test lane. Manual only: nothing in the suite passes on -# Windows yet, so this exists to give contributors (and agents) a cloud Windows -# box to iterate against. Once the suite is green here, fold it into ci.yml. -# -# gh workflow run windows-tests.yml --ref -f package=packages/shared -# gh workflow run windows-tests.yml --ref -f package=apps/server \ -# -f files="src/process/externalLauncher.test.ts src/cli/theme.test.ts" -# gh run watch && gh run view --log-failed -name: Windows Tests - -on: - workflow_dispatch: - inputs: - package: - description: "Workspace directory to test, e.g. apps/server or packages/shared. Empty runs every package except apps/server." - type: string - default: "" - files: - description: "Space-separated test files relative to the package directory. Empty runs the package's whole suite. Requires package." - type: string - default: "" - -permissions: - contents: read - -jobs: - test: - name: Test (${{ inputs.package || 'all non-server' }}) - runs-on: blacksmith-8vcpu-windows-2025 - timeout-minutes: 45 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - # setup-vp's own cache restores a Linux-shaped store on Windows, which is - # slower than no cache (see #7975). Cache pnpm's Windows store directly. - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: false - run-install: false - - - name: Resolve package cache path - id: package_cache_path - shell: pwsh - run: '"path=$(vp pm cache dir)" >> $env:GITHUB_OUTPUT' - - - name: Cache packages - uses: actions/cache@v6 - with: - path: ${{ steps.package_cache_path.outputs.path }} - key: windows-tests-packages-v1-${{ hashFiles('pnpm-lock.yaml') }} - - - name: Install - run: vp install - - - name: Ensure Electron runtime is installed - if: inputs.package == '' || inputs.package == 'apps/desktop' - run: vp run --filter "@t3tools/desktop" ensure:electron - - # `vp run ... test -- ` does not forward positional args to vitest, - # so file-scoped runs call `vp test run` inside the package instead. - - name: Test - shell: pwsh - run: | - $package = '${{ inputs.package }}' - $files = '${{ inputs.files }}' - if ($package -eq '') { - vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test - } elseif ($files -eq '') { - vp run --filter "./$package" test - } else { - Set-Location $package - vp test run $files.Split(' ') - } diff --git a/.gitignore b/.gitignore index 57262578a786..ec82bd95bb37 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules +/.pnpm-store .bun .turbo .DS_Store diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ab5c82bec329..c356faaf0542 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -40,5 +40,5 @@ "tailwindcss": "^4.0.0", "vite-plus": "catalog:" }, - "productName": "T3 Code (Alpha)" + "productName": "T3 Code" } diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 515be6726c94..1bc6ed7b364a 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -97,6 +97,7 @@ function makeDesktopWindowLayer( dispatchMenuAction: () => Effect.void, dispatchSnapShotEvent: () => Effect.void, zoomMain: () => Effect.void, + dispatchRendererEvent: () => Effect.void, syncAppearance: Effect.void, }); } diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index b9a96d72a77a..eeb6ed7c8aea 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -100,6 +100,7 @@ function makePoolLayer( dispatchMenuAction: () => Effect.die("unexpected menu action"), dispatchSnapShotEvent: () => Effect.void, zoomMain: () => Effect.die("unexpected zoom"), + dispatchRendererEvent: () => Effect.die("unexpected renderer event"), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]), ), diff --git a/apps/desktop/src/electron/ElectronNotification.ts b/apps/desktop/src/electron/ElectronNotification.ts new file mode 100644 index 000000000000..f404a8568a1a --- /dev/null +++ b/apps/desktop/src/electron/ElectronNotification.ts @@ -0,0 +1,83 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import * as Electron from "electron"; + +const MAX_ACTIVE_NOTIFICATIONS = 64; + +export interface ElectronNotificationInput { + readonly key: string; + readonly title: string; + readonly body: string; + readonly silent: boolean; + readonly onClick: () => void; + readonly onFailed: () => void; +} + +export class ElectronNotificationError extends Schema.TaggedError()( + "ElectronNotificationError", + { + operation: Schema.Literals(["check-support", "show"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop notification ${this.operation} failed.`; + } +} + +export class ElectronNotification extends Context.Service< + ElectronNotification, + { + readonly isSupported: Effect.Effect; + readonly show: ( + input: ElectronNotificationInput, + ) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronNotification") {} + +function pruneNotifications(notifications: Map): void { + while (notifications.size > MAX_ACTIVE_NOTIFICATIONS) { + const oldestKey = notifications.keys().next().value; + if (oldestKey === undefined) return; + notifications.delete(oldestKey); + } +} + +export const make = Effect.sync(() => { + const activeNotifications = new Map(); + return ElectronNotification.of({ + isSupported: Effect.try({ + try: () => Electron.Notification.isSupported(), + catch: (cause) => new ElectronNotificationError({ operation: "check-support", cause }), + }), + show: (input) => + Effect.try({ + try: () => { + const notification = new Electron.Notification({ + title: input.title, + body: input.body, + silent: input.silent, + }); + const cleanup = () => activeNotifications.delete(input.key); + notification.once("click", () => { + cleanup(); + input.onClick(); + }); + notification.once("close", cleanup); + notification.once("failed", () => { + cleanup(); + input.onFailed(); + }); + activeNotifications.set(input.key, notification); + pruneNotifications(activeNotifications); + notification.show(); + }, + catch: (cause) => new ElectronNotificationError({ operation: "show", cause }), + }), + }); +}); + +export const layer = Layer.effect(ElectronNotification, make); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index dc3769bb814f..34e8e19af8bb 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -2,6 +2,10 @@ import * as Effect from "effect/Effect"; import * as DesktopIpc from "./DesktopIpc.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; +import { + consumePendingDesktopNotificationTarget, + showDesktopNotification, +} from "./methods/desktopNotifications.ts"; import { clearConnectionCatalog, getConnectionCatalog, @@ -79,6 +83,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(getClientSettings); yield* ipc.handle(setClientSettings); + yield* ipc.handle(showDesktopNotification); + yield* ipc.handle(consumePendingDesktopNotificationTarget); yield* ipc.handle(getConnectionCatalog); yield* ipc.handle(getSnapShotState); yield* ipc.handle(setupSnapShot); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 43ecee06c0ca..e644dd4bc0d8 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -41,6 +41,10 @@ export const SET_SNAP_SHOT_ANIMATION_DESTINATION_CHANNEL = "desktop:set-snap-shot-animation-destination"; export const DISMISS_SNAP_SHOT_ANIMATION_CHANNEL = "desktop:dismiss-snap-shot-animation"; export const ACKNOWLEDGE_SNAP_SHOT_CHANNEL = "desktop:acknowledge-snap-shot"; +export const SHOW_DESKTOP_NOTIFICATION_CHANNEL = "desktop:show-notification"; +export const CONSUME_DESKTOP_NOTIFICATION_TARGET_CHANNEL = "desktop:consume-notification-target"; +export const DESKTOP_NOTIFICATION_TARGET_AVAILABLE_CHANNEL = + "desktop:notification-target-available"; export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog"; export const SET_CONNECTION_CATALOG_CHANNEL = "desktop:set-connection-catalog"; export const CLEAR_CONNECTION_CATALOG_CHANNEL = "desktop:clear-connection-catalog"; diff --git a/apps/desktop/src/ipc/methods/desktopNotifications.ts b/apps/desktop/src/ipc/methods/desktopNotifications.ts new file mode 100644 index 000000000000..0a7a460447d4 --- /dev/null +++ b/apps/desktop/src/ipc/methods/desktopNotifications.ts @@ -0,0 +1,32 @@ +import { + DesktopNotificationDeliveryStatusSchema, + DesktopNotificationEventSchema, + DesktopNotificationTargetSchema, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as DesktopNotifications from "../../notifications/DesktopNotifications.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +export const showDesktopNotification = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SHOW_DESKTOP_NOTIFICATION_CHANNEL, + payload: DesktopNotificationEventSchema, + result: DesktopNotificationDeliveryStatusSchema, + handler: Effect.fn("desktop.ipc.notifications.show")(function* (event) { + const notifications = yield* DesktopNotifications.DesktopNotifications; + return yield* notifications.show(event); + }), +}); + +export const consumePendingDesktopNotificationTarget = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.CONSUME_DESKTOP_NOTIFICATION_TARGET_CHANNEL, + payload: Schema.Void, + result: Schema.NullOr(DesktopNotificationTargetSchema), + handler: Effect.fn("desktop.ipc.notifications.consumeTarget")(function* () { + const notifications = yield* DesktopNotifications.DesktopNotifications; + return Option.getOrNull(yield* notifications.consumePendingTarget); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ed920abcdc8f..c9fe4b0c842f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -25,6 +25,7 @@ import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; import * as ElectronMenu from "./electron/ElectronMenu.ts"; import * as ElectronPowerMonitor from "./electron/ElectronPowerMonitor.ts"; +import * as ElectronNotification from "./electron/ElectronNotification.ts"; import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts"; import * as ElectronShell from "./electron/ElectronShell.ts"; @@ -42,6 +43,7 @@ import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfigurat import * as DesktopBackendPool from "./backend/DesktopBackendPool.ts"; import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentAuth.ts"; import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts"; +import * as DesktopNotifications from "./notifications/DesktopNotifications.ts"; import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; import * as DesktopLinuxUrlHandler from "./app/DesktopLinuxUrlHandler.ts"; @@ -123,6 +125,7 @@ const electronLayer = Layer.mergeAll( ElectronDialog.layer, ElectronMenu.layer, ElectronPowerMonitor.layer, + ElectronNotification.layer, ElectronProtocol.layer, ElectronSafeStorage.layer, ElectronShell.layer, @@ -202,6 +205,7 @@ const desktopApplicationLayer = Layer.mergeAll( desktopAppActivationLayer, DesktopApplicationMenu.layer, DesktopLinuxUrlHandler.layer, + DesktopNotifications.layer, DesktopShellEnvironment.layer, desktopSshLayer, ).pipe( diff --git a/apps/desktop/src/notifications/DesktopNotifications.test.ts b/apps/desktop/src/notifications/DesktopNotifications.test.ts new file mode 100644 index 000000000000..b4134645468e --- /dev/null +++ b/apps/desktop/src/notifications/DesktopNotifications.test.ts @@ -0,0 +1,141 @@ +import { assert, describe, it } from "@effect/vitest"; +import { + DEFAULT_CLIENT_SETTINGS, + EnvironmentId, + ThreadId, + type DesktopNotificationEvent, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as ElectronNotification from "../electron/ElectronNotification.ts"; +import { DESKTOP_NOTIFICATION_TARGET_AVAILABLE_CHANNEL } from "../ipc/channels.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopNotifications from "./DesktopNotifications.ts"; + +const event: DesktopNotificationEvent = { + eventId: "primary:thread-1:turn-completed:turn-1", + kind: "turn-completed", + environmentId: EnvironmentId.make("primary"), + threadId: ThreadId.make("thread-1"), +}; + +interface HarnessOptions { + readonly enabled?: boolean; + readonly supported?: boolean; + readonly showFails?: boolean; +} + +function makeHarness(options: HarnessOptions = {}) { + const shown: ElectronNotification.ElectronNotificationInput[] = []; + const dispatchedChannels: string[] = []; + const notificationLayer = Layer.succeed(ElectronNotification.ElectronNotification, { + isSupported: Effect.succeed(options.supported ?? true), + show: (input) => { + if (options.showFails) { + return Effect.fail( + new ElectronNotification.ElectronNotificationError({ + operation: "show", + cause: new Error("permission denied"), + }), + ); + } + return Effect.sync(() => shown.push(input)).pipe(Effect.asVoid); + }, + } satisfies ElectronNotification.ElectronNotification["Service"]); + const desktopWindowLayer = Layer.succeed(DesktopWindow.DesktopWindow, { + createMain: Effect.die("unexpected create"), + ensureMain: Effect.die("unexpected ensure"), + revealOrCreateMain: Effect.die("unexpected reveal"), + activate: Effect.void, + prepareCaptureReveal: Effect.void, + dispatchSnapShotEvent: () => Effect.void, + createMainIfBackendReady: Effect.void, + showConnectingSplash: Effect.void, + handleBackendReady: () => Effect.void, + handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, + dispatchMenuAction: () => Effect.void, + zoomMain: () => Effect.void, + dispatchRendererEvent: (channel) => + Effect.sync(() => { + dispatchedChannels.push(channel); + }), + syncAppearance: Effect.void, + } satisfies DesktopWindow.DesktopWindow["Service"]); + const settingsLayer = DesktopClientSettings.layerTest( + Option.some({ + ...DEFAULT_CLIENT_SETTINGS, + desktopNotificationsEnabled: options.enabled ?? true, + }), + ); + const layer = DesktopNotifications.layer.pipe( + Layer.provideMerge(notificationLayer), + Layer.provideMerge(desktopWindowLayer), + Layer.provideMerge(settingsLayer), + ); + return { layer, shown, dispatchedChannels }; +} + +describe("DesktopNotifications", () => { + it.effect("show_enabled_deliversGenericAudibleNotificationOnce", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const notifications = yield* DesktopNotifications.DesktopNotifications; + assert.equal(yield* notifications.show(event), "shown"); + assert.equal(yield* notifications.show(event), "duplicate"); + assert.lengthOf(harness.shown, 1); + assert.deepInclude(harness.shown[0], { + key: event.eventId, + title: "T3 Code needs your attention", + body: "A turn completed.", + silent: false, + }); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("show_disabledOrUnsupported_skipsDelivery", () => + Effect.gen(function* () { + for (const [options, status] of [ + [{ enabled: false }, "disabled"], + [{ supported: false }, "unsupported"], + ] as const) { + const harness = makeHarness(options); + assert.equal( + yield* Effect.gen(function* () { + const notifications = yield* DesktopNotifications.DesktopNotifications; + return yield* notifications.show(event); + }).pipe(Effect.provide(harness.layer)), + status, + ); + assert.lengthOf(harness.shown, 0); + } + }), + ); + + it.effect("show_permissionDenied_returnsFailedWithoutDefect", () => { + const harness = makeHarness({ showFails: true }); + return Effect.gen(function* () { + const notifications = yield* DesktopNotifications.DesktopNotifications; + assert.equal(yield* notifications.show(event), "failed"); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("click_notification_resolvesTargetExactlyOnce", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const notifications = yield* DesktopNotifications.DesktopNotifications; + yield* notifications.show(event); + harness.shown[0]?.onClick(); + yield* Effect.yieldNow; + assert.deepEqual( + yield* notifications.consumePendingTarget, + Option.some({ environmentId: event.environmentId, threadId: event.threadId }), + ); + assert.isTrue(Option.isNone(yield* notifications.consumePendingTarget)); + assert.deepEqual(harness.dispatchedChannels, [DESKTOP_NOTIFICATION_TARGET_AVAILABLE_CHANNEL]); + }).pipe(Effect.provide(harness.layer)); + }); +}); diff --git a/apps/desktop/src/notifications/DesktopNotifications.ts b/apps/desktop/src/notifications/DesktopNotifications.ts new file mode 100644 index 000000000000..479ca8bf39b7 --- /dev/null +++ b/apps/desktop/src/notifications/DesktopNotifications.ts @@ -0,0 +1,106 @@ +import type { + DesktopNotificationDeliveryStatus, + DesktopNotificationEvent, + DesktopNotificationKind, + DesktopNotificationTarget, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as ElectronNotification from "../electron/ElectronNotification.ts"; +import { DESKTOP_NOTIFICATION_TARGET_AVAILABLE_CHANNEL } from "../ipc/channels.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; + +const MAX_HANDLED_EVENTS = 512; +const NOTIFICATION_TITLE = "T3 Code needs your attention"; +const NOTIFICATION_BODY: Record = { + "turn-completed": "A turn completed.", + "turn-failed": "A turn failed.", + "approval-required": "An approval is required.", + "user-input-required": "Your response is required.", +}; + +const { logWarning } = DesktopObservability.makeComponentLogger("desktop-notifications"); + +export class DesktopNotifications extends Context.Service< + DesktopNotifications, + { + readonly show: ( + event: DesktopNotificationEvent, + ) => Effect.Effect; + readonly consumePendingTarget: Effect.Effect>; + } +>()("@t3tools/desktop/notifications/DesktopNotifications") {} + +function rememberEvent(eventIds: Set, eventId: string): void { + eventIds.add(eventId); + while (eventIds.size > MAX_HANDLED_EVENTS) { + const oldest = eventIds.values().next().value; + if (oldest === undefined) return; + eventIds.delete(oldest); + } +} + +export const make = Effect.gen(function* () { + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const electronNotification = yield* ElectronNotification.ElectronNotification; + const pendingTarget = yield* Ref.make>(Option.none()); + const handledEvents = new Set(); + const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); + + const handleClick = (event: DesktopNotificationEvent) => { + runFork( + Ref.set( + pendingTarget, + Option.some({ environmentId: event.environmentId, threadId: event.threadId }), + ).pipe( + Effect.andThen( + desktopWindow.dispatchRendererEvent(DESKTOP_NOTIFICATION_TARGET_AVAILABLE_CHANNEL), + ), + Effect.catch(() => logWarning("notification click handling failed")), + ), + ); + }; + + const show = Effect.fn("desktop.notifications.show")(function* ( + event: DesktopNotificationEvent, + ): Effect.fn.Return { + if (handledEvents.has(event.eventId)) return "duplicate"; + rememberEvent(handledEvents, event.eventId); + const settingsResult = yield* clientSettings.get.pipe(Effect.result); + if (settingsResult._tag === "Failure") return "failed"; + const settings = settingsResult.success; + if (!Option.exists(settings, (value) => value.desktopNotificationsEnabled)) return "disabled"; + const supported = yield* electronNotification.isSupported.pipe( + Effect.orElseSucceed(() => false), + ); + if (!supported) return "unsupported"; + return yield* electronNotification + .show({ + key: event.eventId, + title: NOTIFICATION_TITLE, + body: NOTIFICATION_BODY[event.kind], + silent: false, + onClick: () => handleClick(event), + onFailed: () => runFork(logWarning("native notification delivery failed")), + }) + .pipe( + Effect.as("shown"), + Effect.orElseSucceed(() => "failed" as const), + ); + }); + + return DesktopNotifications.of({ + show, + consumePendingTarget: Ref.getAndSet(pendingTarget, Option.none()), + }); +}); + +export const layer = Layer.effect(DesktopNotifications, make); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 7da32d7913ae..67d1cb9d1639 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -91,6 +91,20 @@ contextBridge.exposeInMainWorld("desktopBridge", { dismissSnapShotAnimation: (id) => ipcRenderer.invoke(IpcChannels.DISMISS_SNAP_SHOT_ANIMATION_CHANNEL, id), acknowledgeSnapShot: (id) => ipcRenderer.invoke(IpcChannels.ACKNOWLEDGE_SNAP_SHOT_CHANNEL, id), + showDesktopNotification: (event) => + ipcRenderer.invoke(IpcChannels.SHOW_DESKTOP_NOTIFICATION_CHANNEL, event), + consumePendingDesktopNotificationTarget: () => + ipcRenderer.invoke(IpcChannels.CONSUME_DESKTOP_NOTIFICATION_TARGET_CHANNEL), + onDesktopNotificationTargetAvailable: (listener) => { + const wrappedListener = () => listener(); + ipcRenderer.on(IpcChannels.DESKTOP_NOTIFICATION_TARGET_AVAILABLE_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener( + IpcChannels.DESKTOP_NOTIFICATION_TARGET_AVAILABLE_CHANNEL, + wrappedListener, + ); + }; + }, getConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.GET_CONNECTION_CATALOG_CHANNEL), setConnectionCatalog: (catalog) => ipcRenderer.invoke(IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, catalog), diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ea2a80010124..83e52474194b 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -33,6 +33,7 @@ const clientSettings: ClientSettings = { confirmThreadUnpin: false, contextWindowMeterEnabled: false, composerCollapseOnScroll: true, + desktopNotificationsEnabled: false, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffLayout: "stacked", @@ -50,6 +51,7 @@ const clientSettings: ClientSettings = { glassOpacity: 80, onboardingCompletedAt: null, panelAnimationDurationMs: 0, + hideNewProjectButton: false, planModeEnabled: false, proactivePanelsEnabled: true, showSkillsInSlashMenu: false, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index eeb0c86f031c..e20384ef3c13 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -89,6 +89,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => dispatchSnapShotEvent: () => Effect.void, zoomMain: (direction) => Deferred.succeed(selectedAction, `zoom-${direction}`).pipe(Effect.asVoid), + dispatchRendererEvent: () => Effect.die("unexpected renderer event"), 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 7bbb5c1da024..10c7155e5ddf 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import { DesktopSnapShotId } from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -95,6 +96,7 @@ function makeFakeBrowserWindow() { const window = { close: vi.fn(), + destroy: 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 })), @@ -105,8 +107,18 @@ function makeFakeBrowserWindow() { isVisible: vi.fn(() => true), loadURL: vi.fn(() => Promise.resolve()), maximize: vi.fn(), + hide: vi.fn(), on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { - windowListeners.set(eventName, listener); + const existing = windowListeners.get(eventName); + windowListeners.set( + eventName, + existing + ? (...args) => { + existing(...args); + listener(...args); + } + : listener, + ); }), once: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { windowListeners.set(eventName, listener); @@ -132,6 +144,8 @@ function makeFakeBrowserWindow() { isMinimized: window.isMinimized, loadURL: window.loadURL, maximize: window.maximize, + destroy: window.destroy, + hide: window.hide, openDevTools: webContents.openDevTools, reload: webContents.reload, send: webContents.send, @@ -222,6 +236,7 @@ function makeTestLayer(input: { readonly onPopupTemplate?: (input: ElectronMenu.ElectronMenuTemplateInput) => Effect.Effect; readonly previewZoomReapplies?: number[]; readonly onReveal?: (window: Electron.BrowserWindow) => void; + readonly notificationsEnabled?: boolean; }) { let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { @@ -283,9 +298,14 @@ function makeTestLayer(input: { desktopAssetsLayer, desktopEnvironmentLayer, desktopAppSettingsLayer, - desktopClientSettingsLayer, desktopServerExposureLayer, DesktopState.layer, + DesktopClientSettings.layerTest( + Option.some({ + ...DEFAULT_CLIENT_SETTINGS, + desktopNotificationsEnabled: input.notificationsEnabled ?? false, + }), + ), electronAppLayer, Layer.succeed(ElectronMenu.ElectronMenu, { setApplicationMenu: () => Effect.void, @@ -399,6 +419,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n DesktopAppSettings.layerTest(), desktopClientSettingsLayer, desktopServerExposureLayer, + DesktopState.layer, electronAppLayer, electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { @@ -575,6 +596,56 @@ describe("DesktopWindow", () => { ); }); + it("retainClose_darwinOptInNotQuitting_returnsTrue", () => { + assert.isTrue( + DesktopWindow.shouldRetainMainWindowOnClose({ + platform: "darwin", + notificationsEnabled: true, + isQuitting: false, + }), + ); + assert.isFalse( + DesktopWindow.shouldRetainMainWindowOnClose({ + platform: "darwin", + notificationsEnabled: true, + isQuitting: true, + }), + ); + assert.isFalse( + DesktopWindow.shouldRetainMainWindowOnClose({ + platform: "darwin", + notificationsEnabled: false, + isQuitting: false, + }), + ); + }); + + it.effect("close_optedInDarwin_hidesWindowAndKeepsRenderer", () => + 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, + notificationsEnabled: true, + }); + + 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("close listener not registered"); + const preventDefault = vi.fn(); + close({ preventDefault }); + for (let index = 0; index < 3; index += 1) yield* Effect.yieldNow; + assert.equal(preventDefault.mock.calls.length, 1); + assert.equal(fakeWindow.hide.mock.calls.length, 1); + assert.equal(fakeWindow.destroy.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); + }), + ); it("recognizes only same-origin renderer navigations", () => { assert.isTrue( DesktopWindow.isSameOriginRendererNavigation({ @@ -864,7 +935,7 @@ describe("DesktopWindow", () => { if (!close) { return yield* Effect.die("window close listener was not registered"); } - close(); + close({ preventDefault: vi.fn() }); yield* Effect.promise(() => Promise.resolve()); assert.deepEqual(mainWindowBoundsUpdates, [{ x: 220, y: 140, width: 1380, height: 920 }]); @@ -970,7 +1041,7 @@ describe("DesktopWindow", () => { return yield* Effect.die("window lifecycle listeners were not registered"); } - close(); + close({ preventDefault: vi.fn() }); yield* Effect.promise(() => Promise.resolve()); assert.deepEqual(mainWindowBoundsUpdates, []); @@ -1133,7 +1204,7 @@ describe("DesktopWindow", () => { if (!close) { return yield* Effect.die("window close listener was not registered"); } - close(); + close({ preventDefault: vi.fn() }); yield* Deferred.await(writeStarted); fakeWindow.isDestroyed.mockReturnValue(true); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 0a966ec36e4d..41a7269b873d 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -13,6 +13,7 @@ import { type DesktopSnapShotEvent, DEFAULT_CLIENT_SETTINGS } from "@t3tools/con import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; +import * as DesktopState from "../app/DesktopState.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; @@ -59,9 +60,10 @@ type WindowTitleBarOptions = Pick< type DesktopWindowRuntimeServices = | DesktopEnvironment.DesktopEnvironment + | DesktopClientSettings.DesktopClientSettings + | DesktopState.DesktopState | DesktopAssets.DesktopAssets | DesktopAppSettings.DesktopAppSettings - | DesktopClientSettings.DesktopClientSettings | ElectronApp.ElectronApp | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell @@ -118,6 +120,11 @@ export class DesktopWindow extends Context.Service< // guest page instead of the app UI. The menu routes here to always target // the main window. readonly zoomMain: (direction: MainWindowZoomDirection) => Effect.Effect; + readonly dispatchRendererEvent: ( + channel: string, + payload?: unknown, + options?: { readonly reveal?: boolean }, + ) => Effect.Effect; readonly syncAppearance: Effect.Effect; } >()("@t3tools/desktop/window/DesktopWindow") {} @@ -234,6 +241,14 @@ export function concealPendingQuitWindow( window.setOpacity(0); } +export function shouldRetainMainWindowOnClose(input: { + readonly platform: NodeJS.Platform; + readonly notificationsEnabled: boolean; + readonly isQuitting: boolean; +}): boolean { + return input.platform === "darwin" && input.notificationsEnabled && !input.isQuitting; +} + function getWindowTitleBarOptions( shouldUseDarkColors: boolean, platform: NodeJS.Platform, @@ -293,6 +308,8 @@ function bindFirstRevealTrigger( /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; + const desktopState = yield* DesktopState.DesktopState; const assets = yield* DesktopAssets.DesktopAssets; const electronMenu = yield* ElectronMenu.ElectronMenu; const electronShell = yield* ElectronShell.ElectronShell; @@ -300,7 +317,6 @@ export const make = Effect.gen(function* () { const electronWindow = yield* ElectronWindow.ElectronWindow; const previewManager = yield* PreviewManager.PreviewManager; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; - const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; const electronApp = yield* ElectronApp.ElectronApp; // Window-side latch for the primary backend's readiness. Set by // handleBackendReady (driven by the pool's onReady callback), cleared @@ -316,6 +332,22 @@ export const make = Effect.gen(function* () { const runPromise = Effect.runPromiseWith(context); let flushMainWindowBounds: Effect.Effect = Effect.void; + const closeMainWindow = Effect.fn("desktop.window.closeMainWindow")(function* ( + window: Electron.BrowserWindow, + ) { + const settings = yield* clientSettings.get; + const shouldRetain = shouldRetainMainWindowOnClose({ + platform: environment.platform, + notificationsEnabled: Option.exists(settings, (value) => value.desktopNotificationsEnabled), + isQuitting: yield* Ref.get(desktopState.quitting), + }); + if (shouldRetain) { + window.hide(); + return; + } + window.destroy(); + }); + const dismissConnectingSplash = Effect.gen(function* () { const splash = yield* Ref.getAndSet(splashWindowRef, Option.none()); if (Option.isSome(splash) && !splash.value.isDestroyed()) { @@ -402,6 +434,15 @@ export const make = Effect.gen(function* () { if (environment.platform === "darwin") { window.setAutoHideCursor(false); + let isClosePending = false; + window.on("close", (event) => { + event.preventDefault(); + if (isClosePending) return; + isClosePending = true; + void runPromise(closeMainWindow(window)).finally(() => { + isClosePending = false; + }); + }); } let boundsPersistFiber: Fiber.Fiber | undefined; let pendingBoundsPersistFiber: Fiber.Fiber | undefined; @@ -894,7 +935,7 @@ export const make = Effect.gen(function* () { const dispatchRendererEvent = Effect.fn("desktop.window.dispatchRendererEvent")(function* ( channel: string, - payload: unknown, + payload: unknown = undefined, { reveal = true }: { readonly reveal?: boolean } = {}, ) { const existingWindow = yield* reveal ? focusedMainWindow : electronWindow.main; @@ -989,6 +1030,7 @@ export const make = Effect.gen(function* () { // own zoom, so put each guest back where the preview left it. yield* previewManager.reapplyZoom(); }), + dispatchRendererEvent, syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; yield* electronWindow.syncAllAppearance((window) => diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index ff4bb76bf12a..43106465c6c4 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -13,10 +13,10 @@ import { resolveBranchToolbarPrBranch, resolveBranchToolbarValue, resolveLockedWorkspaceLabel, + resolveExistingWorktrees, resolveLocalCheckoutBranchMismatch, - resolvePreviousWorktreeLabel, - resolvePreviousWorktreeSeed, sanitizeNewRefName, + resolveWorktreeLabel, shouldIncludeBranchPickerItem, shouldShowComposerContextStrip, shouldShowEnvironmentIndicator, @@ -25,87 +25,43 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); const remoteEnvironmentId = EnvironmentId.make("environment-remote"); -describe("resolvePreviousWorktreeSeed", () => { - it("picks the most recently updated worktree thread", () => { - expect( - resolvePreviousWorktreeSeed({ - threads: [ - { - branch: "t3/older", - worktreePath: "/repo/.t3/worktrees/older", - updatedAt: "2026-07-20T00:00:00.000Z", - }, - { - branch: "t3/newer", - worktreePath: "/repo/.t3/worktrees/newer", - updatedAt: "2026-07-22T00:00:00.000Z", - }, - { branch: "main", worktreePath: null, updatedAt: "2026-07-23T00:00:00.000Z" }, - ], - currentWorktreePath: null, - }), - ).toEqual({ branch: "t3/newer", worktreePath: "/repo/.t3/worktrees/newer" }); - }); - - it("skips the worktree the composer already points at", () => { +describe("resolveExistingWorktrees", () => { + it("returns every live linked worktree and excludes the main checkout", () => { expect( - resolvePreviousWorktreeSeed({ - threads: [ - { - branch: "t3/current", - worktreePath: "/repo/.t3/worktrees/current", - updatedAt: "2026-07-22T00:00:00.000Z", - }, + resolveExistingWorktrees({ + refs: [ + { name: "main", worktreePath: "/repo" }, + { name: "feature-a", worktreePath: "/repo/.t3/worktrees/feature-a" }, + { name: "feature-b", worktreePath: "/repo/.t3/worktrees/feature-b" }, ], - currentWorktreePath: "/repo/.t3/worktrees/current", - }), - ).toBeNull(); - }); - - it("returns null when no thread has a worktree", () => { - expect( - resolvePreviousWorktreeSeed({ - threads: [{ branch: "main", worktreePath: null, updatedAt: "2026-07-22T00:00:00.000Z" }], + projectWorkspaceRoot: "/repo", currentWorktreePath: null, }), - ).toBeNull(); + ).toEqual([ + { branch: "feature-a", worktreePath: "/repo/.t3/worktrees/feature-a" }, + { branch: "feature-b", worktreePath: "/repo/.t3/worktrees/feature-b" }, + ]); }); - it("ignores archived threads and threads with unparseable timestamps", () => { - expect( - resolvePreviousWorktreeSeed({ - threads: [ - { - branch: "t3/archived", - worktreePath: "/repo/.t3/worktrees/archived", - updatedAt: "2026-07-23T00:00:00.000Z", - archivedAt: "2026-07-23T01:00:00.000Z", - }, - { - branch: "t3/garbage-timestamp", - worktreePath: "/repo/.t3/worktrees/garbage", - updatedAt: "not-a-date", - }, - { - branch: "t3/live", - worktreePath: "/repo/.t3/worktrees/live", - updatedAt: "2026-07-21T00:00:00.000Z", - archivedAt: null, - }, + it("excludes the active worktree and deduplicates paths", () => { + expect( + resolveExistingWorktrees({ + refs: [ + { name: "feature-a", worktreePath: "/repo/.t3/worktrees/feature-a" }, + { name: "feature-a-alias", worktreePath: "/repo/.t3/worktrees/feature-a" }, + { name: "feature-b", worktreePath: "/repo/.t3/worktrees/feature-b" }, ], - currentWorktreePath: null, + projectWorkspaceRoot: "/repo", + currentWorktreePath: "/repo/.t3/worktrees/feature-a", }), - ).toEqual({ branch: "t3/live", worktreePath: "/repo/.t3/worktrees/live" }); + ).toEqual([{ branch: "feature-b", worktreePath: "/repo/.t3/worktrees/feature-b" }]); }); }); -describe("resolvePreviousWorktreeLabel", () => { - it("includes the branch when known", () => { - expect(resolvePreviousWorktreeLabel({ branch: "t3/fix-thing", worktreePath: "/wt" })).toBe( - "Previous worktree (t3/fix-thing)", - ); - expect(resolvePreviousWorktreeLabel({ branch: null, worktreePath: "/wt" })).toBe( - "Previous worktree", +describe("resolveWorktreeLabel", () => { + it("identifies an existing worktree", () => { + expect(resolveWorktreeLabel({ branch: "feature-a", worktreePath: "/repo/feature-a" })).toBe( + "Existing Worktree (feature-a)", ); }); }); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 0577f5e8dd1f..9d0dcd8725e9 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,6 +1,5 @@ import type { EnvironmentId, EnvironmentMachineKind, VcsRef, ProjectId } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; -import { toSortableTimestamp } from "../lib/threadSort"; export { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, @@ -94,51 +93,38 @@ export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): return activeWorktreePath ? "Worktree" : "Local checkout"; } -export interface PreviousWorktreeSeed { - branch: string | null; +export interface ExistingWorktree { + branch: string; worktreePath: string; } -// The most recently touched worktree in the project that the composer isn't -// already pointing at. Backs the "Previous worktree" entry in the workspace -// selector so a follow-up thread can hop back into the worktree you just -// worked in without hunting for its branch. Archived threads don't compete — -// the rest of the UI hides them, so their worktrees shouldn't resurface here. -export function resolvePreviousWorktreeSeed(input: { - threads: ReadonlyArray<{ - branch: string | null; - worktreePath: string | null; - updatedAt: string; - archivedAt?: string | null; - }>; +export function resolveExistingWorktrees(input: { + refs: ReadonlyArray>; + projectWorkspaceRoot: string | null; currentWorktreePath: string | null; -}): PreviousWorktreeSeed | null { - let latest: { branch: string | null; worktreePath: string; updatedAt: number } | null = null; - for (const thread of input.threads) { +}): ReadonlyArray { + const seenPaths = new Set(); + const worktrees: ExistingWorktree[] = []; + + for (const ref of input.refs) { + const worktreePath = ref.worktreePath; if ( - !thread.worktreePath || - thread.worktreePath === input.currentWorktreePath || - (thread.archivedAt ?? null) !== null + worktreePath === null || + worktreePath === input.projectWorkspaceRoot || + worktreePath === input.currentWorktreePath || + seenPaths.has(worktreePath) ) { continue; } - const updatedAt = toSortableTimestamp(thread.updatedAt); - if (updatedAt === null) { - continue; - } - if (latest === null || updatedAt > latest.updatedAt) { - latest = { - branch: thread.branch, - worktreePath: thread.worktreePath, - updatedAt, - }; - } + seenPaths.add(worktreePath); + worktrees.push({ branch: ref.name, worktreePath }); } - return latest === null ? null : { branch: latest.branch, worktreePath: latest.worktreePath }; + + return worktrees; } -export function resolvePreviousWorktreeLabel(seed: PreviousWorktreeSeed): string { - return seed.branch ? `Previous worktree (${seed.branch})` : "Previous worktree"; +export function resolveWorktreeLabel(worktree: ExistingWorktree): string { + return `Existing Worktree (${worktree.branch})`; } export function resolveEffectiveEnvMode(input: { diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 3e3f834658ea..a536d33df664 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -11,9 +11,12 @@ import { import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; +import { useEnvironmentQuery } from "../state/query"; +import { vcsEnvironment } from "../state/vcs"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; -import { useProject, useThreadShell, useThreadShellsForProjectRefs } from "../state/entities"; +import { useProject, useThreadShell } from "../state/entities"; import { + type ExistingWorktree, type EnvMode, type EnvironmentOption, resolveContextStripLabelsCompact, @@ -21,13 +24,16 @@ import { resolveEnvModeLabel, resolveEffectiveEnvMode, resolveLockedWorkspaceLabel, - resolvePreviousWorktreeLabel, - resolvePreviousWorktreeSeed, + resolveExistingWorktrees, + resolveWorktreeLabel, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; -import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; +import { + BranchToolbarEnvModeSelector, + WORKTREE_SELECT_VALUE_PREFIX, +} from "./BranchToolbarEnvModeSelector"; import { Button } from "./ui/button"; import { Menu, @@ -81,8 +87,8 @@ interface MobileRunContextSelectorProps { effectiveEnvMode: EnvMode; activeWorktreePath: string | null; onEnvModeChange: (mode: EnvMode) => void; - previousWorktreeLabel: string | null; - onUsePreviousWorktree: () => void; + worktrees: ReadonlyArray; + onUseWorktree: (worktree: ExistingWorktree) => void; } const MobileRunContextSelector = memo(function MobileRunContextSelector({ @@ -98,8 +104,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ effectiveEnvMode, activeWorktreePath, onEnvModeChange, - previousWorktreeLabel, - onUsePreviousWorktree, + worktrees, + onUseWorktree, }: MobileRunContextSelectorProps) { const activeEnvironment = useMemo( () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, @@ -224,8 +230,12 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ { - if (value === "previous-worktree") { - onUsePreviousWorktree(); + if (value.startsWith(WORKTREE_SELECT_VALUE_PREFIX)) { + const worktree = worktrees.find( + (candidate) => + `${WORKTREE_SELECT_VALUE_PREFIX}${candidate.worktreePath}` === value, + ); + if (worktree) onUseWorktree(worktree); return; } onEnvModeChange(value as EnvMode); @@ -249,14 +259,18 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ {resolveEnvModeLabel("worktree")} - {previousWorktreeLabel ? ( - + {worktrees.map((worktree) => ( + - {previousWorktreeLabel} + {resolveWorktreeLabel(worktree)} - ) : null} + ))} @@ -484,39 +498,45 @@ export const BranchToolbar = memo(function BranchToolbar({ }); const envModeLocked = envLocked || (serverThread !== null && activeWorktreePath !== null); - // "Previous worktree" hops a draft into the most recently active worktree - // of this project — the "keep going where I just was" follow-up flow. Only - // drafts can hop; started server threads have their workspace pinned. - const canUsePreviousWorktree = draftThread !== null && serverThread === null && !envModeLocked; - const projectRefsForWorktreeLookup = useMemo( - () => (canUsePreviousWorktree && activeProjectRef ? [activeProjectRef] : []), - [canUsePreviousWorktree, activeProjectRef], + // Existing worktrees are read from Git rather than inferred from recent + // threads, so new-chat can offer every live worktree, including ones whose + // most recent thread is stale or missing. + const canUseExistingWorktrees = draftThread !== null && serverThread === null && !envModeLocked; + const worktreeRefsQuery = useEnvironmentQuery( + canUseExistingWorktrees && activeProject + ? vcsEnvironment.listRefs({ + environmentId, + input: { + cwd: activeProject.workspaceRoot, + refKind: "local", + limit: 200, + }, + }) + : null, ); - const projectThreads = useThreadShellsForProjectRefs(projectRefsForWorktreeLookup); - const previousWorktreeSeed = useMemo( + const worktrees = useMemo( () => - canUsePreviousWorktree - ? resolvePreviousWorktreeSeed({ - threads: projectThreads, - currentWorktreePath: activeWorktreePath, - }) - : null, - [activeWorktreePath, canUsePreviousWorktree, projectThreads], + resolveExistingWorktrees({ + refs: worktreeRefsQuery.data?.refs ?? [], + projectWorkspaceRoot: activeProject?.workspaceRoot ?? null, + currentWorktreePath: activeWorktreePath, + }), + [activeProject?.workspaceRoot, activeWorktreePath, worktreeRefsQuery.data?.refs], + ); + const onUseWorktree = useCallback( + (worktree: ExistingWorktree) => { + if (!activeProjectRef) return; + // Same shape the branch selector writes when picking a branch that + // already lives in a worktree: point the draft at the existing tree. + setDraftThreadContext(draftId ?? threadRef, { + branch: worktree.branch, + worktreePath: worktree.worktreePath, + envMode: "worktree", + projectRef: activeProjectRef, + }); + }, + [activeProjectRef, draftId, setDraftThreadContext, threadRef], ); - const previousWorktreeLabel = previousWorktreeSeed - ? resolvePreviousWorktreeLabel(previousWorktreeSeed) - : null; - const onUsePreviousWorktree = useCallback(() => { - if (!previousWorktreeSeed || !activeProjectRef) return; - // Same shape the branch selector writes when picking a branch that - // already lives in a worktree: point the draft at the existing tree. - setDraftThreadContext(draftId ?? threadRef, { - branch: previousWorktreeSeed.branch, - worktreePath: previousWorktreeSeed.worktreePath, - envMode: "worktree", - projectRef: activeProjectRef, - }); - }, [activeProjectRef, draftId, previousWorktreeSeed, setDraftThreadContext, threadRef]); const showEnvironmentPicker = Boolean( availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, @@ -559,8 +579,8 @@ export const BranchToolbar = memo(function BranchToolbar({ effectiveEnvMode={effectiveEnvMode} activeWorktreePath={activeWorktreePath} onEnvModeChange={onEnvModeChange} - previousWorktreeLabel={previousWorktreeLabel} - onUsePreviousWorktree={onUsePreviousWorktree} + worktrees={worktrees} + onUseWorktree={onUseWorktree} /> ) : null} @@ -597,8 +617,8 @@ export const BranchToolbar = memo(function BranchToolbar({ effectiveEnvMode={effectiveEnvMode} activeWorktreePath={activeWorktreePath} onEnvModeChange={onEnvModeChange} - previousWorktreeLabel={previousWorktreeLabel} - onUsePreviousWorktree={onUsePreviousWorktree} + worktrees={worktrees} + onUseWorktree={onUseWorktree} /> ) : null} diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 4685dbf1ecd1..bd3135556f95 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -5,6 +5,8 @@ import { resolveCurrentWorkspaceLabel, resolveEnvModeLabel, resolveLockedWorkspaceLabel, + resolveWorktreeLabel, + type ExistingWorktree, type EnvMode, } from "./BranchToolbar.logic"; import { composerFloatingLayerProps } from "./chat/composerEventScope"; @@ -18,15 +20,15 @@ import { SelectValue, } from "./ui/select"; -const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree"; +export const WORKTREE_SELECT_VALUE_PREFIX = "worktree:"; interface BranchToolbarEnvModeSelectorProps { envLocked: boolean; effectiveEnvMode: EnvMode; activeWorktreePath: string | null; onEnvModeChange: (mode: EnvMode) => void; - previousWorktreeLabel?: string | null; - onUsePreviousWorktree?: () => void; + worktrees: ReadonlyArray; + onUseWorktree: (worktree: ExistingWorktree) => void; } export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({ @@ -34,19 +36,19 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe effectiveEnvMode, activeWorktreePath, onEnvModeChange, - previousWorktreeLabel, - onUsePreviousWorktree, + worktrees, + onUseWorktree, }: BranchToolbarEnvModeSelectorProps) { - const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree); const envModeItems = useMemo( () => [ { value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath) }, { value: "worktree", label: resolveEnvModeLabel("worktree") }, - ...(showPreviousWorktree && previousWorktreeLabel - ? [{ value: PREVIOUS_WORKTREE_SELECT_VALUE, label: previousWorktreeLabel }] - : []), + ...worktrees.map((worktree) => ({ + value: `${WORKTREE_SELECT_VALUE_PREFIX}${worktree.worktreePath}`, + label: resolveWorktreeLabel(worktree), + })), ], - [activeWorktreePath, previousWorktreeLabel, showPreviousWorktree], + [activeWorktreePath, worktrees], ); if (envLocked) { @@ -80,8 +82,11 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe modal={false} value={effectiveEnvMode} onValueChange={(value: string | null) => { - if (value === PREVIOUS_WORKTREE_SELECT_VALUE) { - onUsePreviousWorktree?.(); + if (value?.startsWith(WORKTREE_SELECT_VALUE_PREFIX)) { + const worktree = worktrees.find( + (candidate) => `${WORKTREE_SELECT_VALUE_PREFIX}${candidate.worktreePath}` === value, + ); + if (worktree) onUseWorktree(worktree); return; } onEnvModeChange(value as EnvMode); @@ -133,14 +138,17 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe {resolveEnvModeLabel("worktree")} - {showPreviousWorktree && previousWorktreeLabel ? ( - + {worktrees.map((worktree) => ( + - {previousWorktreeLabel} + {resolveWorktreeLabel(worktree)} - ) : null} + ))} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0fbef88c81e1..99df2ef306f3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -27,6 +27,7 @@ import { type ProjectId, type ProviderApprovalDecision, type PreviewAnnotationPayload, + type ProviderOptionSelection, ProviderInstanceId, type ServerProvider, type ResolvedKeybindingsConfig, @@ -7739,7 +7740,11 @@ export default function ChatView(props: ChatViewProps) { ); const onProviderModelSelect = useCallback( - (instanceId: ProviderInstanceId, model: string) => { + ( + instanceId: ProviderInstanceId, + model: string, + options?: ReadonlyArray, + ) => { if (!activeThread) return; // Look up the configured instance so model normalization and custom // model lookup stay scoped to that exact instance. Unknown instance ids @@ -7780,6 +7785,7 @@ export default function ChatView(props: ChatViewProps) { const nextModelSelection: ModelSelection = { instanceId, model: resolvedModel, + ...(options ? { options } : {}), }; const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({ providers: providerStatuses, diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index c0e16c7cce71..56952d215482 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -2914,6 +2914,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( attachProjectListAutoAnimateRef, projectsLength, } = props; + const hideNewProjectButton = useClientSettings((s) => s.hideNewProjectButton); const handleProjectSortOrderChange = useCallback( (sortOrder: SidebarProjectSortOrder) => { @@ -3000,23 +3001,25 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( onThreadSortOrderChange={handleThreadSortOrderChange} onThreadPreviewCountChange={handleThreadPreviewCountChange} /> - - - } - > - - - Add project - + {hideNewProjectButton ? null : ( + + + } + > + + + Add project + + )} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ebc1078e672b..40acdaaa4553 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2090,6 +2090,7 @@ export default function Sidebar() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); + const hideNewProjectButton = useClientSettings((s) => s.hideNewProjectButton); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const timestampFormat = useClientSettings((s) => s.timestampFormat); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -4502,26 +4503,28 @@ export default function Sidebar() { - - + + } + > + + - New project - + + New project + + )} ) : null} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 7b2e65f8bf3e..966be4b79cb8 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -5,6 +5,7 @@ import { changeQuestionAttachmentPreparation, } from "../../questionAttachments"; import type { + ProviderOptionSelection, ApprovalRequestId, AssistantCitation, ChatFileAttachment, @@ -28,7 +29,11 @@ import { } from "@t3tools/contracts"; import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; -import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; +import { + createModelSelection, + isClaudeUltrathinkPrompt, + normalizeModelSlug, +} from "@t3tools/shared/model"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { Fragment, @@ -174,6 +179,8 @@ import { import { measureRestingComposerControls } from "./restingComposerControlsMeasurement"; import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations"; import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; +import { ModelReasoningPicker } from "./ModelReasoningPicker"; +import { buildModelReasoningGridSpec } from "./ModelReasoningGrid"; import { ProviderModelPicker } from "./ProviderModelPicker"; import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; @@ -241,6 +248,15 @@ import { } from "./composerScrollGesture"; import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; +const MODEL_REASONING_LEVEL_IDS = new Set(["low", "medium", "high", "xhigh"]); + +function shouldShowModelReasoningMoreOption(descriptorId: string, optionId: string): boolean { + return !( + (descriptorId === "effort" || descriptorId === "reasoningEffort") && + MODEL_REASONING_LEVEL_IDS.has(optionId) + ); +} + function ComposerVideoThumbnail({ file }: { file: File }) { const setVideo = useCallback( (video: HTMLVideoElement | null) => { @@ -1373,7 +1389,11 @@ export interface ChatComposerProps { cursorAdjacentToMention: boolean, ) => void; - onProviderModelSelect: (instanceId: ProviderInstanceId, model: string) => void; + onProviderModelSelect: ( + instanceId: ProviderInstanceId, + model: string, + options?: ReadonlyArray, + ) => void; onOpenProviderSetup: (instanceId: ProviderInstanceId) => void; getModelDisabledReason: (instanceId: ProviderInstanceId, model: string) => string | null; toggleInteractionMode: () => void; @@ -1568,6 +1588,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) environmentId, }) : null); + const setComposerDraftProviderModelOptions = useComposerDraftStore( + (store) => store.setProviderModelOptions, + ); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); @@ -2246,6 +2269,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt], ); + const modelReasoningSpec = buildModelReasoningGridSpec({ + provider: selectedProvider, + models: selectedProviderModels, + model: selectedModel, + modelOptions: composerModelOptions?.[selectedInstanceId], + }); const providerTraitsMenuContent = renderProviderTraitsMenuContent({ provider: selectedProvider, instanceId: selectedInstanceId, @@ -2258,6 +2287,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPromptChange: setPromptFromTraits, planModeEnabled: settings.planModeEnabled, }); + const providerTraitsMoreMenuContent = modelReasoningSpec + ? renderProviderTraitsMenuContent({ + provider: selectedProvider, + instanceId: selectedInstanceId, + ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), + ...(routeKind === "draft" && draftId ? { draftId } : {}), + model: selectedModel, + models: selectedProviderModels, + modelOptions: composerModelOptions?.[selectedInstanceId], + prompt, + onPromptChange: setPromptFromTraits, + planModeEnabled: settings.planModeEnabled, + hiddenDescriptorIds: + selectedProvider === "claudeAgent" ? ["contextWindow"] : ["serviceTier", "fastMode"], + optionFilter: shouldShowModelReasoningMoreOption, + }) + : null; const providerTraitsPickerInput = { provider: selectedProvider, instanceId: selectedInstanceId, @@ -2271,7 +2317,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) planModeEnabled: settings.planModeEnabled, isComposerOwned: true, } satisfies Parameters[0]; - const providerTraitsPicker = renderProviderTraitsPicker(providerTraitsPickerInput); const { controlsRef: restingComposerControlsRef, hiddenBlockCount: restingControlsHiddenBlockCount, @@ -4081,11 +4126,46 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const restingHiddenBlockCount = composerControlsInStrip ? restingControlsHiddenBlockCount : 0; const composerControlsCompact = !composerControlsInStrip && isComposerFooterCompact; - const restingProviderTraitsPicker = renderProviderTraitsPicker({ - ...providerTraitsPickerInput, - size: "xs", - hidden: composerControlsHidden || restingHiddenBlockCount > 1, - }); + const providerModelReasoningPicker = modelReasoningSpec ? ( + + selectedProvider === "claudeAgent" && + isClaudeUltrathinkPrompt(prompt.replace(/^Ultrathink:\s*/i, "")) + ? "Reasoning is controlled by ultrathink in the prompt" + : getModelDisabledReason(selectedInstanceId, nextModel) + } + onModelOptionsChange={(nextOptions) => { + setComposerDraftProviderModelOptions(composerDraftTarget, selectedProvider, nextOptions, { + instanceId: selectedInstanceId, + model: selectedModel, + persistSticky: true, + }); + }} + onModelChange={(nextModel, nextOptions) => { + if (selectedProvider === "claudeAgent" && /^Ultrathink:/i.test(prompt)) { + setPromptFromTraits(prompt.replace(/^Ultrathink:\s*/i, "")); + } + onProviderModelSelect(selectedInstanceId, nextModel, nextOptions); + }} + /> + ) : null; + const providerTraitsPicker = + providerModelReasoningPicker ?? renderProviderTraitsPicker(providerTraitsPickerInput); + const restingProviderTraitsPicker = + providerModelReasoningPicker ?? + renderProviderTraitsPicker({ + ...providerTraitsPickerInput, + size: "xs", + hidden: composerControlsHidden || restingHiddenBlockCount > 1, + }); const restingBlockDefs = [ ...(providerTraitsPicker ? [ @@ -4145,6 +4225,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) /> ) : null} + {composerControlsCompact && providerModelReasoningPicker} {composerControlsCompact ? ( diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 63dea7844c46..7a6039426397 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -46,6 +46,7 @@ import { type ProviderInstanceEntry, } from "../../providerInstances"; import { providerModelKey, sortProviderModelItems } from "../../modelOrdering"; +import { collapseModelVariants } from "./modelPickerVariants"; type ModelPickerItem = { slug: string; @@ -147,6 +148,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { */ modelOptionsByInstance: ReadonlyMap>; terminalOpen: boolean; + collapseModelFamilies?: boolean; onRequestClose?: () => void; onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void; getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; @@ -345,8 +347,17 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { }); } } - return out; - }, [modelOptionsByInstance, entryByInstanceId, props.activeInstanceId, activeModelSlug]); + return props.collapseModelFamilies + ? collapseModelVariants(out, props.activeInstanceId, props.model) + : out; + }, [ + modelOptionsByInstance, + entryByInstanceId, + props.activeInstanceId, + props.collapseModelFamilies, + props.model, + activeModelSlug, + ]); const isLocked = props.lockedProvider !== null; const isSearching = searchQuery.trim().length > 0; @@ -729,7 +740,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return (
{/* Sidebar */} @@ -896,7 +907,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { showProvider preferShortName={!isLocked} useTriggerLabel={false} - showNewBadge={model.badge === "new"} + showNewBadge={false} unavailable={model.isUnavailable === true} jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null} disabledReason={disabledReason} diff --git a/apps/web/src/components/chat/ModelReasoningGrid.test.ts b/apps/web/src/components/chat/ModelReasoningGrid.test.ts new file mode 100644 index 000000000000..b7bb409d9155 --- /dev/null +++ b/apps/web/src/components/chat/ModelReasoningGrid.test.ts @@ -0,0 +1,161 @@ +import { + ProviderDriverKind, + type ProviderOptionDescriptor, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildModelReasoningGridSpec, findNearestModelReasoningPoint } from "./ModelReasoningGrid"; + +function model( + slug: string, + reasoningOptions: ReadonlyArray<{ id: string; label: string }>, + isCustom = false, + descriptorId = "reasoningEffort", +): ServerProviderModel { + const reasoning: ProviderOptionDescriptor = { + id: descriptorId, + label: "Reasoning", + type: "select", + options: [...reasoningOptions], + }; + return { + slug, + name: slug, + isCustom, + capabilities: createModelCapabilities({ optionDescriptors: [reasoning] }), + }; +} + +describe("buildModelReasoningGridSpec", () => { + it("builds ordered model rows and reasoning columns for a Codex variant family", () => { + const spec = buildModelReasoningGridSpec({ + provider: ProviderDriverKind.make("codex"), + model: "gpt-5.6-terra", + models: [ + model("gpt-5.6-luna", [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + ]), + model("gpt-5.6-terra", [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ]), + model("gpt-5.6-sol", [ + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra High" }, + ]), + ], + modelOptions: [{ id: "reasoningEffort", value: "high" }], + }); + + expect(spec?.rows.map(({ model: slug, label }) => ({ slug, label }))).toEqual([ + { slug: "gpt-5.6-sol", label: "Sol" }, + { slug: "gpt-5.6-terra", label: "Terra" }, + { slug: "gpt-5.6-luna", label: "Luna" }, + ]); + expect(spec?.columns).toEqual([ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + { id: "xhigh", label: "XHigh" }, + ]); + }); + + it("excludes custom shadows and models outside the active family", () => { + const spec = buildModelReasoningGridSpec({ + provider: ProviderDriverKind.make("codex"), + model: "gpt-5.6-sol", + models: [ + model("gpt-5.6-sol", [{ id: "high", label: "High" }]), + model("gpt-5.6-terra", [{ id: "high", label: "High" }], true), + model("gpt-5.7-luna", [{ id: "high", label: "High" }]), + ], + modelOptions: undefined, + }); + + expect(spec?.rows.map((row) => row.model)).toEqual(["gpt-5.6-sol"]); + }); + + it("falls back to the existing traits menu outside supported Codex variants", () => { + const models = [model("gpt-5.6-sol", [{ id: "high", label: "High" }])]; + expect( + buildModelReasoningGridSpec({ + provider: ProviderDriverKind.make("claudeAgent"), + model: "gpt-5.6-sol", + models, + modelOptions: undefined, + }), + ).toBeNull(); + expect( + buildModelReasoningGridSpec({ + provider: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + models, + modelOptions: undefined, + }), + ).toBeNull(); + }); +}); + +describe("findNearestModelReasoningPoint", () => { + const points = [ + { model: "sol", reasoning: "low", x: 20, y: 20 }, + { model: "sol", reasoning: "high", x: 100, y: 20 }, + { model: "luna", reasoning: "low", x: 20, y: 100 }, + { model: "luna", reasoning: "high", x: 100, y: 100 }, + ]; + + it("snaps horizontally and vertically to the nearest available point", () => { + expect(findNearestModelReasoningPoint(points, 91, 28)).toEqual({ + model: "sol", + reasoning: "high", + }); + expect(findNearestModelReasoningPoint(points, 28, 91)).toEqual({ + model: "luna", + reasoning: "low", + }); + }); + + it("returns null when there are no available points", () => { + expect(findNearestModelReasoningPoint([], 50, 50)).toBeNull(); + }); +}); + +describe("expanded model families", () => { + const options = [ + { id: "high", label: "High" }, + { id: "max", label: "Max" }, + ]; + + it("places Astra first when either Astra or a GPT-5.6 variant is active", () => { + const slugs = ["gpt-5.6-luna", "gpt-5.6-sol", "gpt-6-astra", "gpt-5.6-terra"]; + for (const active of slugs) { + const spec = buildModelReasoningGridSpec({ + provider: ProviderDriverKind.make("codex"), + model: active, + models: slugs.map((slug) => model(slug, options)), + modelOptions: undefined, + }); + expect(spec?.rows.map((row) => row.label)).toEqual(["Astra", "Sol", "Terra", "Luna"]); + } + }); + + it("uses Claude effort capabilities and excludes legacy Fable", () => { + const slugs = ["claude-fable-5-1", "claude-sonnet-5", "claude-opus-5", "claude-fable-5"]; + for (const active of slugs.slice(0, 3)) { + const spec = buildModelReasoningGridSpec({ + provider: ProviderDriverKind.make("claudeAgent"), + model: active, + models: slugs.map((slug) => model(slug, options, false, "effort")), + modelOptions: [{ id: "effort", value: "max" }], + }); + expect(spec?.rows.map((row) => row.label)).toEqual(["Fable", "Opus", "Sonnet"]); + expect( + spec?.rows.every((row) => row.reasoningOptions.every((option) => option.id !== "max")), + ).toBe(true); + expect(spec?.columns.at(-1)?.id).toBe("xhigh"); + } + }); +}); diff --git a/apps/web/src/components/chat/ModelReasoningGrid.tsx b/apps/web/src/components/chat/ModelReasoningGrid.tsx new file mode 100644 index 000000000000..2548066290a9 --- /dev/null +++ b/apps/web/src/components/chat/ModelReasoningGrid.tsx @@ -0,0 +1,460 @@ +import type { + ProviderDriverKind, + ProviderOptionSelection, + ServerProviderModel, +} from "@t3tools/contracts"; +import { getProviderOptionDescriptors } from "@t3tools/shared/model"; +import { ZapIcon } from "lucide-react"; +import { memo, type PointerEvent as ReactPointerEvent, useRef, useState } from "react"; + +import { getModelPickerVariant, type ModelVariant } from "./modelPickerVariants"; +import { cn } from "~/lib/utils"; +import { getProviderModelCapabilities } from "../../providerModels"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +type ProviderOptions = ReadonlyArray; + +type ReasoningOption = { + id: string; + label: string; +}; + +type ModelReasoningPoint = { + model: string; + reasoning: string; + x: number; + y: number; +}; + +type ModelReasoningSelection = Pick; + +export function findNearestModelReasoningPoint( + points: ReadonlyArray, + x: number, + y: number, +): ModelReasoningSelection | null { + let nearest: ModelReasoningPoint | null = null; + let nearestDistance = Number.POSITIVE_INFINITY; + for (const point of points) { + const distance = (point.x - x) ** 2 + (point.y - y) ** 2; + if (distance < nearestDistance) { + nearest = point; + nearestDistance = distance; + } + } + return nearest ? { model: nearest.model, reasoning: nearest.reasoning } : null; +} + +export type ModelReasoningRow = { + model: string; + label: string; + variant: ModelVariant; + reasoningOptions: ReadonlyArray; +}; + +export type ModelReasoningGridSpec = { + columns: ReadonlyArray; + rows: ReadonlyArray; +}; + +const VISIBLE_REASONING_OPTIONS: ReadonlyArray = [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + { id: "xhigh", label: "XHigh" }, +]; + +const MODEL_VARIANT_STYLES: Record = { + astra: { + row: "bg-[#46376B]/60", + fill: "bg-[#765EB8]", + }, + opus: { + row: "bg-[#63431F]/60", + fill: "bg-[#B78039]", + }, + sonnet: { + row: "bg-[#5A2525]/60", + fill: "bg-[#A84242]", + }, + fable: { + row: "bg-[#66551A]/60", + fill: "bg-[#B69B2E]", + }, + sol: { + row: "bg-[#1B4939]/60", + fill: "bg-[#2D8565]", + }, + terra: { + row: "bg-[#63431F]/60", + fill: "bg-[#B78039]", + }, + luna: { + row: "bg-[#204A63]/60", + fill: "bg-[#3987B2]", + }, +}; + +/** Build the model/reasoning matrix from the selected provider catalog. */ +export function buildModelReasoningGridSpec(input: { + provider: ProviderDriverKind; + models: ReadonlyArray; + model: string; + modelOptions: ProviderOptions | null | undefined; +}): ModelReasoningGridSpec | null { + const activeVariant = getModelPickerVariant(input.model, input.provider); + if (!activeVariant) { + return null; + } + + const visibleOptions = VISIBLE_REASONING_OPTIONS; + + const rows = input.models + .filter((candidate) => !candidate.isCustom) + .flatMap((candidate) => { + const candidateVariant = getModelPickerVariant( + candidate.slug, + input.provider, + candidate.isCustom, + ); + if (!candidateVariant || candidateVariant.family !== activeVariant.family) { + return []; + } + const descriptors = getProviderOptionDescriptors({ + caps: getProviderModelCapabilities(input.models, candidate.slug, input.provider), + selections: input.modelOptions, + }); + const reasoningDescriptor = descriptors.find( + (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> => + descriptor.type === "select" && + descriptor.id === (input.provider === "claudeAgent" ? "effort" : "reasoningEffort"), + ); + if (!reasoningDescriptor) { + return []; + } + return [ + { + model: candidate.slug, + label: candidateVariant.label, + priority: candidateVariant.priority, + variant: candidateVariant.variant, + reasoningOptions: reasoningDescriptor.options + .filter((option) => visibleOptions.some((visible) => visible.id === option.id)) + .map(({ id, label }) => ({ id, label })), + }, + ]; + }) + .sort((left, right) => left.priority - right.priority); + + if (rows.length === 0) { + return null; + } + + const availableOptionById = new Map(); + for (const row of rows) { + for (const option of row.reasoningOptions) { + availableOptionById.set(option.id, option); + } + } + + return { + columns: visibleOptions.map((option) => + option.id === "xhigh" ? option : (availableOptionById.get(option.id) ?? option), + ), + rows, + }; +} + +export const ModelReasoningGrid = memo(function ModelReasoningGrid(props: { + spec: ModelReasoningGridSpec; + selectedModel: string; + selectedReasoning: string | null; + fastModeEnabled: boolean; + showFastMode: boolean; + contextWindowEnabled: boolean; + showContextWindow: boolean; + getModelDisabledReason?: (model: string) => string | null; + onFastModeToggle: () => void; + onContextWindowToggle: () => void; + onSelectionChange: (model: string, reasoning: string) => void; +}) { + const gridRef = useRef(null); + const dragPointsRef = useRef>([]); + const dragPointerIdRef = useRef(null); + const dragSelectionRef = useRef(null); + const [dragSelection, setDragSelection] = useState(null); + const selectedModel = dragSelection?.model ?? props.selectedModel; + const selectedReasoning = dragSelection?.reasoning ?? props.selectedReasoning; + const reasoningColumnTemplate = props.spec.columns + .map((column) => (column.id === "medium" ? "50px" : "2.5rem")) + .join(" "); + + const updateDragSelection = (nextSelection: ModelReasoningSelection | null) => { + dragSelectionRef.current = nextSelection; + setDragSelection((current) => + current?.model === nextSelection?.model && current?.reasoning === nextSelection?.reasoning + ? current + : nextSelection, + ); + }; + + const readDragPoints = (): ReadonlyArray => + [ + ...(gridRef.current?.querySelectorAll("[data-model][data-reasoning]") ?? + []), + ] + .filter((cell) => !cell.disabled) + .map((cell) => { + const bounds = cell.getBoundingClientRect(); + return { + model: cell.dataset.model ?? "", + reasoning: cell.dataset.reasoning ?? "", + x: bounds.left + bounds.width / 2, + y: bounds.top + bounds.height / 2, + }; + }) + .filter((point) => point.model.length > 0 && point.reasoning.length > 0); + + const handlePointerDown = ( + event: ReactPointerEvent, + fallbackSelection: ModelReasoningSelection, + ) => { + if (event.pointerType === "mouse" && event.button !== 0) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + dragPointerIdRef.current = event.pointerId; + dragPointsRef.current = readDragPoints(); + updateDragSelection( + findNearestModelReasoningPoint(dragPointsRef.current, event.clientX, event.clientY) ?? + fallbackSelection, + ); + }; + + const handlePointerMove = (event: ReactPointerEvent) => { + if (dragPointerIdRef.current !== event.pointerId) return; + event.preventDefault(); + const nearest = findNearestModelReasoningPoint( + dragPointsRef.current, + event.clientX, + event.clientY, + ); + if (nearest) { + updateDragSelection(nearest); + } + }; + + const clearDrag = () => { + dragPointerIdRef.current = null; + dragPointsRef.current = []; + updateDragSelection(null); + }; + + const handlePointerUp = (event: ReactPointerEvent) => { + if (dragPointerIdRef.current !== event.pointerId) return; + event.preventDefault(); + const nearest = + findNearestModelReasoningPoint(dragPointsRef.current, event.clientX, event.clientY) ?? + dragSelectionRef.current; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + clearDrag(); + if (nearest) { + props.onSelectionChange(nearest.model, nearest.reasoning); + } + }; + + const handlePointerCancel = (event: ReactPointerEvent) => { + if (dragPointerIdRef.current !== event.pointerId) return; + clearDrag(); + }; + + return ( +
+
+
+ {props.showFastMode ? ( + + + } + > + + + + Fast mode {props.fastModeEnabled ? "on" : "off"} + + + ) : null} + {props.showContextWindow ? ( + + + } + > + 1M + + + 1M context {props.contextWindowEnabled ? "on" : "off"} + + + ) : null} +
+ + {props.spec.columns.map((column) => ( +
+ {column.label} +
+ ))} + + {props.spec.rows.map((row, rowIndex) => { + const isSelectedModel = row.model === selectedModel; + const disabledReason = props.getModelDisabledReason?.(row.model) ?? null; + const supportedReasoning = new Set(row.reasoningOptions.map((option) => option.id)); + const variantStyles = MODEL_VARIANT_STYLES[row.variant]; + const selectedReasoningIndex = isSelectedModel + ? props.spec.columns.findIndex((column) => column.id === selectedReasoning) + : -1; + const selectedTrackPercent = + selectedReasoningIndex < 0 + ? 0 + : ((selectedReasoningIndex + 0.5) / props.spec.columns.length) * 100; + + return ( +
+
+ {row.label} +
+
+ {selectedTrackPercent > 0 ? ( +
+
+ ); + })} +
+
+ ); +}); diff --git a/apps/web/src/components/chat/ModelReasoningModal.test.ts b/apps/web/src/components/chat/ModelReasoningModal.test.ts new file mode 100644 index 000000000000..f6ef1eb705bd --- /dev/null +++ b/apps/web/src/components/chat/ModelReasoningModal.test.ts @@ -0,0 +1,74 @@ +import type { ProviderOptionDescriptor } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { getContextWindowControl, getFastModeControl } from "./ModelReasoningModal"; + +function serviceTierDescriptor( + currentValue: string, +): Extract { + return { + id: "serviceTier", + label: "Service Tier", + type: "select", + options: [ + { id: "default", label: "Standard" }, + { id: "priority", label: "Fast" }, + ], + currentValue, + }; +} + +describe("getFastModeControl", () => { + it("maps the Codex service tier to a two-way fast mode toggle", () => { + expect(getFastModeControl([serviceTierDescriptor("default")])).toMatchObject({ + enabled: false, + nextValue: "priority", + }); + expect(getFastModeControl([serviceTierDescriptor("priority")])).toMatchObject({ + enabled: true, + nextValue: "default", + }); + }); + + it("continues to support boolean fast-mode descriptors", () => { + expect( + getFastModeControl([ + { + id: "fastMode", + label: "Fast Mode", + type: "boolean", + currentValue: false, + }, + ]), + ).toMatchObject({ + enabled: false, + nextValue: true, + }); + }); +}); + +describe("getContextWindowControl", () => { + const descriptor = ( + currentValue: string, + ): Extract => ({ + id: "contextWindow", + label: "Context Window", + type: "select", + options: [ + { id: "200k", label: "200k" }, + { id: "1m", label: "1M" }, + ], + currentValue, + }); + + it("toggles between 1M and 200k", () => { + expect(getContextWindowControl([descriptor("200k")])).toMatchObject({ + enabled: false, + nextValue: "1m", + }); + expect(getContextWindowControl([descriptor("1m")])).toMatchObject({ + enabled: true, + nextValue: "200k", + }); + }); +}); diff --git a/apps/web/src/components/chat/ModelReasoningModal.tsx b/apps/web/src/components/chat/ModelReasoningModal.tsx new file mode 100644 index 000000000000..f8a59bde4365 --- /dev/null +++ b/apps/web/src/components/chat/ModelReasoningModal.tsx @@ -0,0 +1,199 @@ +import type { + ProviderDriverKind, + ProviderOptionDescriptor, + ProviderOptionSelection, + ServerProviderModel, +} from "@t3tools/contracts"; +import { + buildProviderOptionSelectionsFromDescriptors, + getProviderOptionCurrentValue, + getProviderOptionDescriptors, + isClaudeUltrathinkPrompt, +} from "@t3tools/shared/model"; +import { memo } from "react"; + +import { getProviderModelCapabilities } from "../../providerModels"; +import { ModelReasoningGrid, type ModelReasoningGridSpec } from "./ModelReasoningGrid"; +import { replaceDescriptorCurrentValue } from "./TraitsPicker"; + +type ProviderOptions = ReadonlyArray; + +type FastModeControl = + | { + descriptor: Extract; + enabled: boolean; + nextValue: boolean; + } + | { + descriptor: Extract; + enabled: boolean; + nextValue: string; + }; + +export function getFastModeControl( + descriptors: ReadonlyArray, +): FastModeControl | null { + const booleanDescriptor = descriptors.find( + (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode", + ); + if (booleanDescriptor?.type === "boolean") { + const enabled = booleanDescriptor.currentValue === true; + return { descriptor: booleanDescriptor, enabled, nextValue: !enabled }; + } + + const serviceTierDescriptor = descriptors.find( + (descriptor) => descriptor.type === "select" && descriptor.id === "serviceTier", + ); + if (serviceTierDescriptor?.type !== "select") { + return null; + } + const fastOption = serviceTierDescriptor.options.find( + (option) => + option.id === "priority" || + option.id === "fast" || + option.label.trim().toLowerCase() === "fast", + ); + const standardOption = + serviceTierDescriptor.options.find((option) => option.id === "default") ?? + serviceTierDescriptor.options.find( + (option) => option.label.trim().toLowerCase() === "standard", + ); + if (!fastOption || !standardOption) { + return null; + } + const currentValue = getProviderOptionCurrentValue(serviceTierDescriptor); + const enabled = currentValue === fastOption.id; + return { + descriptor: serviceTierDescriptor, + enabled, + nextValue: enabled ? standardOption.id : fastOption.id, + }; +} + +type ContextWindowControl = { + descriptor: Extract; + enabled: boolean; + nextValue: string; +}; + +export function getContextWindowControl( + descriptors: ReadonlyArray, +): ContextWindowControl | null { + const descriptor = descriptors.find( + (candidate) => candidate.type === "select" && candidate.id === "contextWindow", + ); + if (descriptor?.type !== "select") { + return null; + } + const oneMillionOption = descriptor.options.find((option) => option.id === "1m"); + const twoHundredThousandOption = descriptor.options.find((option) => option.id === "200k"); + if (!oneMillionOption || !twoHundredThousandOption) { + return null; + } + const enabled = getProviderOptionCurrentValue(descriptor) === oneMillionOption.id; + return { + descriptor, + enabled, + nextValue: enabled ? twoHundredThousandOption.id : oneMillionOption.id, + }; +} + +export type ModelReasoningModalProps = { + spec: ModelReasoningGridSpec; + prompt?: string; + provider: ProviderDriverKind; + models: ReadonlyArray; + model: string; + modelOptions: ProviderOptions | null | undefined; + getModelDisabledReason?: (model: string) => string | null; + onModelOptionsChange: (options: ProviderOptions | undefined) => void; + onModelChange: (model: string, options: ProviderOptions | undefined) => void; + onSelectionComplete?: () => void; +}; + +export const ModelReasoningModal = memo(function ModelReasoningModal( + props: ModelReasoningModalProps, +) { + const descriptors = getProviderOptionDescriptors({ + caps: getProviderModelCapabilities(props.models, props.model, props.provider), + selections: props.modelOptions, + }); + const reasoningDescriptor = descriptors.find( + (descriptor) => + descriptor.type === "select" && + (descriptor.id === "reasoningEffort" || descriptor.id === "effort"), + ); + if (reasoningDescriptor?.type !== "select") { + return null; + } + const selectedReasoning = + props.provider === "claudeAgent" && isClaudeUltrathinkPrompt(props.prompt) + ? "ultrathink" + : getProviderOptionCurrentValue(reasoningDescriptor); + const fastModeControl = props.provider === "codex" ? getFastModeControl(descriptors) : null; + const contextWindowControl = + props.provider === "claudeAgent" ? getContextWindowControl(descriptors) : null; + + return ( + { + if (!fastModeControl) return; + props.onModelOptionsChange( + buildProviderOptionSelectionsFromDescriptors( + replaceDescriptorCurrentValue( + descriptors, + fastModeControl.descriptor.id, + fastModeControl.nextValue, + ), + ), + ); + }} + onContextWindowToggle={() => { + if (!contextWindowControl) return; + props.onModelOptionsChange( + buildProviderOptionSelectionsFromDescriptors( + replaceDescriptorCurrentValue( + descriptors, + contextWindowControl.descriptor.id, + contextWindowControl.nextValue, + ), + ), + ); + }} + onSelectionChange={(nextModel, reasoning) => { + const nextDescriptors = getProviderOptionDescriptors({ + caps: getProviderModelCapabilities(props.models, nextModel, props.provider), + selections: props.modelOptions, + }); + const nextReasoningDescriptor = nextDescriptors.find( + (descriptor) => + descriptor.type === "select" && + (descriptor.id === "reasoningEffort" || descriptor.id === "effort"), + ); + if ( + nextReasoningDescriptor?.type !== "select" || + !nextReasoningDescriptor.options.some((option) => option.id === reasoning) + ) { + return; + } + props.onModelChange( + nextModel, + buildProviderOptionSelectionsFromDescriptors( + replaceDescriptorCurrentValue(nextDescriptors, nextReasoningDescriptor.id, reasoning), + ), + ); + props.onSelectionComplete?.(); + }} + /> + ); +}); diff --git a/apps/web/src/components/chat/ModelReasoningPicker.test.ts b/apps/web/src/components/chat/ModelReasoningPicker.test.ts new file mode 100644 index 000000000000..d5461e4518de --- /dev/null +++ b/apps/web/src/components/chat/ModelReasoningPicker.test.ts @@ -0,0 +1,96 @@ +import type { ProviderOptionDescriptor } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import type { ModelReasoningGridSpec } from "./ModelReasoningGrid"; +import { buildModelReasoningTriggerDisplay } from "./ModelReasoningPicker"; + +const spec: ModelReasoningGridSpec = { + columns: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + rows: [ + { + model: "gpt-5.6-sol", + label: "Sol", + variant: "sol", + reasoningOptions: [], + }, + ], +}; + +function descriptors(serviceTier: string): ReadonlyArray { + return [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + currentValue: "medium", + }, + { + id: "serviceTier", + label: "Service Tier", + type: "select", + options: [ + { id: "default", label: "Standard" }, + { id: "priority", label: "Fast" }, + ], + currentValue: serviceTier, + }, + ]; +} + +describe("buildModelReasoningTriggerDisplay", () => { + it("shows the selected model variant and reasoning level", () => { + expect( + buildModelReasoningTriggerDisplay({ + spec, + model: "gpt-5.6-sol", + descriptors: descriptors("default"), + }), + ).toEqual({ + modelLabel: "Sol", + reasoningLabel: "Medium", + showFastModeIcon: false, + }); + }); + + it("shows the fast icon only while fast mode is active", () => { + expect( + buildModelReasoningTriggerDisplay({ + spec, + model: "gpt-5.6-sol", + descriptors: descriptors("priority"), + }).showFastModeIcon, + ).toBe(true); + }); +}); + +it("shows the Claude effort selection in the family trigger", () => { + expect( + buildModelReasoningTriggerDisplay({ + spec: { + columns: [], + rows: [ + { model: "claude-fable-5-1", label: "Fable", variant: "fable", reasoningOptions: [] }, + ], + }, + model: "claude-fable-5-1", + descriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [{ id: "max", label: "Max" }], + currentValue: "max", + }, + ], + }), + ).toEqual({ modelLabel: "Fable", reasoningLabel: "Max", showFastModeIcon: false }); +}); diff --git a/apps/web/src/components/chat/ModelReasoningPicker.tsx b/apps/web/src/components/chat/ModelReasoningPicker.tsx new file mode 100644 index 000000000000..290856b79623 --- /dev/null +++ b/apps/web/src/components/chat/ModelReasoningPicker.tsx @@ -0,0 +1,161 @@ +import type { + ProviderDriverKind, + ProviderOptionDescriptor, + ProviderOptionSelection, + ServerProviderModel, +} from "@t3tools/contracts"; +import { + getProviderOptionCurrentLabel, + getProviderOptionDescriptors, + isClaudeUltrathinkPrompt, +} from "@t3tools/shared/model"; +import { ZapIcon } from "lucide-react"; +import { memo, useState, type ReactNode } from "react"; + +import { getProviderModelCapabilities } from "../../providerModels"; +import { Menu, MenuPopup, MenuTrigger } from "../ui/menu"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { + ComposerControl, + ComposerControlChevron, + type ComposerControlSize, +} from "./ComposerControl"; +import { composerFloatingLayerProps } from "./composerEventScope"; +import type { ModelReasoningGridSpec } from "./ModelReasoningGrid"; +import { ModelReasoningModal, getFastModeControl } from "./ModelReasoningModal"; + +type ProviderOptions = ReadonlyArray; + +export function buildModelReasoningTriggerDisplay(input: { + spec: ModelReasoningGridSpec; + model: string; + descriptors: ReadonlyArray; +}) { + const reasoningDescriptor = input.descriptors.find( + (descriptor) => + descriptor.type === "select" && + (descriptor.id === "reasoningEffort" || descriptor.id === "effort"), + ); + return { + modelLabel: input.spec.rows.find((row) => row.model === input.model)?.label ?? input.model, + reasoningLabel: + reasoningDescriptor?.type === "select" + ? (getProviderOptionCurrentLabel(reasoningDescriptor) ?? reasoningDescriptor.label) + : "Reasoning", + showFastModeIcon: getFastModeControl(input.descriptors)?.enabled ?? false, + }; +} + +export const ModelReasoningPicker = memo(function ModelReasoningPicker(props: { + size?: ComposerControlSize; + traitsMenuContent?: ReactNode; + prompt?: string; + spec: ModelReasoningGridSpec; + provider: ProviderDriverKind; + models: ReadonlyArray; + model: string; + modelOptions: ProviderOptions | null | undefined; + getModelDisabledReason?: (model: string) => string | null; + onModelOptionsChange: (options: ProviderOptions | undefined) => void; + onModelChange: (model: string, options: ProviderOptions | undefined) => void; +}) { + const [open, setOpen] = useState(false); + const [showMoreOptions, setShowMoreOptions] = useState(false); + const descriptors = getProviderOptionDescriptors({ + caps: getProviderModelCapabilities(props.models, props.model, props.provider), + selections: props.modelOptions, + }); + const { + modelLabel, + reasoningLabel: optionReasoningLabel, + showFastModeIcon, + } = buildModelReasoningTriggerDisplay({ + spec: props.spec, + model: props.model, + descriptors, + }); + + const reasoningLabel = + props.provider === "claudeAgent" && isClaudeUltrathinkPrompt(props.prompt) + ? "Ultrathink" + : optionReasoningLabel; + + return ( + { + setOpen(nextOpen); + if (!nextOpen) { + setShowMoreOptions(false); + } + }} + > + setShowMoreOptions(event.ctrlKey || event.metaKey)} + /> + } + > + + {showFastModeIcon ? ( + <> + + + + setOpen(false)} + /> + {showMoreOptions && props.traitsMenuContent ? ( +
+ + } + > + More options + + + + {props.traitsMenuContent} + + +
+ ) : null} +
+
+ ); +}); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 932754db1eca..5caf6d706236 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -25,6 +25,7 @@ import { type ComposerControlSize, } from "./ComposerControl"; import { composerFloatingLayerProps } from "./composerEventScope"; +import { getModelPickerFamilyDisplayModel } from "./modelPickerVariants"; export const ProviderModelPicker = memo(function ProviderModelPicker(props: { /** @@ -39,6 +40,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { instanceEntries: ReadonlyArray; keybindings?: ResolvedKeybindingsConfig; modelOptionsByInstance: ReadonlyMap>; + collapseModelFamilies?: boolean; activeProviderIconClassName?: string; instanceIndicatorBackground?: string; size?: ComposerControlSize; @@ -80,13 +82,17 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { (activeEntry?.driverKind === "opencode" || activeEntry?.driverKind === "antigravity" ? undefined : selectedInstanceOptions[0]); - const triggerTitle = selectedModel - ? getTriggerDisplayModelName(selectedModel) + const triggerDisplayModel = + selectedModel && props.collapseModelFamilies && activeEntry + ? getModelPickerFamilyDisplayModel(selectedModel, activeEntry.driverKind) + : selectedModel; + const triggerTitle = triggerDisplayModel + ? getTriggerDisplayModelName(triggerDisplayModel) : props.model === ANTIGRAVITY_DEFAULT_MODEL ? "Choose model" : props.model || "Choose model"; - const triggerLabel = selectedModel - ? `${getTriggerDisplayModelLabel(selectedModel)}${selectedModel.isUnavailable ? " (Unavailable)" : ""}` + const triggerLabel = triggerDisplayModel + ? `${getTriggerDisplayModelLabel(triggerDisplayModel)}${triggerDisplayModel.isUnavailable ? " (Unavailable)" : ""}` : triggerTitle; const showInstanceBadge = activeEntry !== null && shouldShowInstanceBadge(activeEntry, props.instanceEntries); @@ -234,6 +240,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { instanceEntries={props.instanceEntries} {...(props.keybindings ? { keybindings: props.keybindings } : {})} modelOptionsByInstance={props.modelOptionsByInstance} + {...(props.collapseModelFamilies ? { collapseModelFamilies: true } : {})} terminalOpen={props.terminalOpen ?? false} onRequestClose={() => setIsMenuOpen(false)} {...(props.onOpenProviderSetup ? { onOpenProviderSetup: props.onOpenProviderSetup } : {})} diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 47ce59efde52..fc8a0c5011f0 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -103,7 +103,7 @@ function DefaultBadge() { ); } -function replaceDescriptorCurrentValue( +export function replaceDescriptorCurrentValue( descriptors: ReadonlyArray, descriptorId: string, currentValue: string | boolean | undefined, @@ -282,6 +282,8 @@ export interface TraitsMenuContentProps { triggerVariant?: VariantProps["variant"]; triggerClassName?: string; isComposerOwned?: boolean; + hiddenDescriptorIds?: ReadonlyArray; + optionFilter?: (descriptorId: string, optionId: string) => boolean; } export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ @@ -294,6 +296,8 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ modelOptions, allowPromptInjectedEffort = true, planModeEnabled, + hiddenDescriptorIds, + optionFilter, ...persistence }: TraitsMenuContentProps & TraitsPersistence) { const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); @@ -317,8 +321,6 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ ); const { descriptors, - selectDescriptors, - booleanDescriptors, primarySelectDescriptor, ultrathinkPromptControlled, ultrathinkInBodyText, @@ -333,6 +335,30 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ allowPromptInjectedEffort, planModeEnabled, }); + const hiddenDescriptorIdSet = new Set(hiddenDescriptorIds ?? []); + const visibleDescriptors = descriptors + .filter((descriptor) => !hiddenDescriptorIdSet.has(descriptor.id)) + .map((descriptor) => { + if (descriptor.type !== "select" || !optionFilter) { + return descriptor; + } + return { + ...descriptor, + options: descriptor.options.filter((option) => optionFilter(descriptor.id, option.id)), + }; + }); + const visibleSelectDescriptors = visibleDescriptors.filter( + (descriptor): descriptor is Extract => + descriptor.type === "select", + ); + const visibleBooleanDescriptors = visibleDescriptors.filter( + (descriptor): descriptor is Extract => + descriptor.type === "boolean", + ); + const hasVisibleControls = modelIsUnavailable + ? visibleDescriptors.length > 0 + : visibleSelectDescriptors.some((descriptor) => descriptor.options.length > 0) || + visibleBooleanDescriptors.length > 0; const updateDescriptors = (nextDescriptors: ReadonlyArray) => { updateModelOptions(buildProviderOptionSelectionsFromDescriptors(nextDescriptors)); }; @@ -358,14 +384,14 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ updateDescriptors(replaceDescriptorCurrentValue(descriptors, descriptor.id, value)); }; - if (!hasAnyControls) { + if (!hasAnyControls || !hasVisibleControls) { return null; } if (modelIsUnavailable) { return ( <> - {descriptors.map((descriptor, index) => { + {visibleDescriptors.map((descriptor, index) => { const value = getProviderOptionCurrentLabel(descriptor); if (!value) return null; return ( @@ -386,7 +412,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ return ( <> - {selectDescriptors.map((descriptor, index) => { + {visibleSelectDescriptors.map((descriptor, index) => { const selectedValue = ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id ? "ultrathink" @@ -444,12 +470,12 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
); })} - {booleanDescriptors.map((descriptor, index) => { + {visibleBooleanDescriptors.map((descriptor, index) => { const selectedValue = descriptor.currentValue === true ? "on" : "off"; return (
- {index > 0 || selectDescriptors.length > 0 ? : null} + {index > 0 || visibleSelectDescriptors.length > 0 ? : null}
{descriptor.label} diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index c6837700cdfa..ffcbe7b171e8 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -58,6 +58,8 @@ type TraitsRenderInput = { triggerVariant?: VariantProps["variant"]; triggerClassName?: string; isComposerOwned?: boolean; + hiddenDescriptorIds?: ReadonlyArray; + optionFilter?: (descriptorId: string, optionId: string) => boolean; }; export function getComposerPromptInjectionState(prompt: string): ComposerPromptInjectionState { @@ -181,6 +183,8 @@ function renderTraitsControl( triggerVariant, triggerClassName, isComposerOwned, + hiddenDescriptorIds, + optionFilter, } = input; const hasTarget = threadRef !== undefined || draftId !== undefined; const { selections: resolvedModelOptions } = resolveComposerOptionSelections( @@ -220,6 +224,8 @@ function renderTraitsControl( {...(triggerVariant !== undefined ? { triggerVariant } : {})} {...(triggerClassName !== undefined ? { triggerClassName } : {})} {...(isComposerOwned ? { isComposerOwned } : {})} + {...(hiddenDescriptorIds ? { hiddenDescriptorIds } : {})} + {...(optionFilter ? { optionFilter } : {})} /> ); } diff --git a/apps/web/src/components/chat/modelPickerVariants.test.ts b/apps/web/src/components/chat/modelPickerVariants.test.ts new file mode 100644 index 000000000000..e391cd61a33f --- /dev/null +++ b/apps/web/src/components/chat/modelPickerVariants.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { collapseModelVariants, getModelPickerFamilyDisplayModel } from "./modelPickerVariants"; + +const codexModels = [ + { + slug: "gpt-5.6-sol", + name: "GPT-5.6-Sol", + driverKind: "codex", + instanceId: "codex", + }, + { + slug: "gpt-5.6-terra", + name: "GPT-5.6-Terra", + driverKind: "codex", + instanceId: "codex", + }, + { + slug: "gpt-5.6-luna", + name: "GPT-5.6-Luna", + driverKind: "codex", + instanceId: "codex", + }, + { + slug: "gpt-5.5", + name: "GPT-5.5", + driverKind: "codex", + instanceId: "codex", + }, +]; + +describe("collapseModelVariants", () => { + it("exposes one GPT-5.6 entry that defaults to Sol", () => { + expect(collapseModelVariants(codexModels, "codex", "gpt-5.5")).toEqual([ + { + slug: "gpt-5.6-sol", + name: "GPT 5/6", + shortName: "GPT 5/6", + driverKind: "codex", + instanceId: "codex", + }, + codexModels[3], + ]); + }); + + it("preserves the active GPT-5.6 variant as the selection target", () => { + expect(collapseModelVariants(codexModels, "codex", "gpt-5.6-terra")[0]).toMatchObject({ + slug: "gpt-5.6-terra", + name: "GPT 5/6", + shortName: "GPT 5/6", + }); + }); + + it("does not collapse similarly named models from other providers", () => { + const claudeModel = { + slug: "gpt-5.6-sol", + name: "GPT-5.6-Sol", + driverKind: "claudeAgent", + instanceId: "claudeAgent", + }; + + expect(collapseModelVariants([claudeModel], "claudeAgent", claudeModel.slug)).toEqual([ + claudeModel, + ]); + }); +}); + +describe("getModelPickerFamilyDisplayModel", () => { + it("removes the variant suffix from the GPT-5.6 model trigger", () => { + expect(getModelPickerFamilyDisplayModel(codexModels[1]!, "codex")).toMatchObject({ + slug: "gpt-5.6-terra", + name: "GPT 5/6", + shortName: "GPT 5/6", + }); + }); +}); + +const astra = { + slug: "gpt-6-astra", + name: "GPT-6 Astra", + driverKind: "codex", + instanceId: "codex", +}; +const claudeModels = ["claude-opus-5", "claude-sonnet-5", "claude-fable-5-1"].map((slug) => ({ + slug, + name: slug, + driverKind: "claudeAgent", + instanceId: "claude", +})); + +describe("model family grouping", () => { + it("defaults the GPT group to Astra when available and preserves an active older variant", () => { + const models = [...codexModels, astra]; + expect(collapseModelVariants(models, "codex", "gpt-5.5")[0]?.slug).toBe("gpt-6-astra"); + expect(collapseModelVariants(models, "codex", "gpt-5.6-luna")[0]?.slug).toBe("gpt-5.6-luna"); + expect(collapseModelVariants(models, "codex", "gpt-6-astra")).toHaveLength(2); + }); + + it("groups Claude 5 while preserving each selected variant", () => { + for (const model of claudeModels) { + expect(collapseModelVariants(claudeModels, "claude", model.slug)).toEqual([ + { ...model, name: "Claude 5", shortName: "Claude 5" }, + ]); + expect(getModelPickerFamilyDisplayModel(model, "claudeAgent").name).toBe("Claude 5"); + } + }); + + it("keeps instance selections independent and leaves custom and legacy models alone", () => { + const secondInstance = claudeModels.map((model) => ({ ...model, instanceId: "claude-two" })); + const custom = { ...claudeModels[0]!, isCustom: true }; + const legacy = { ...claudeModels[0]!, slug: "claude-fable-5" }; + const result = collapseModelVariants( + [...codexModels, astra, ...claudeModels, ...secondInstance, custom, legacy], + "claude-two", + "claude-fable-5-1", + ); + expect(result.map((model) => [model.instanceId, model.slug])).toEqual([ + ["codex", "gpt-6-astra"], + ["codex", "gpt-5.5"], + ["claude", "claude-fable-5-1"], + ["claude-two", "claude-fable-5-1"], + ["claude", custom.slug], + ["claude", legacy.slug], + ]); + expect(result.slice(-2)).toEqual([custom, legacy]); + }); +}); diff --git a/apps/web/src/components/chat/modelPickerVariants.ts b/apps/web/src/components/chat/modelPickerVariants.ts new file mode 100644 index 000000000000..418b427b2bea --- /dev/null +++ b/apps/web/src/components/chat/modelPickerVariants.ts @@ -0,0 +1,72 @@ +type ModelPickerModel = { + slug: string; + name: string; + shortName?: string; + driverKind: string; + instanceId: string; + isCustom?: boolean; +}; + +const MODEL_VARIANTS = { + "gpt-6-astra": { family: "GPT 5/6", variant: "astra", label: "Astra", priority: 0 }, + "gpt-5.6-sol": { family: "GPT 5/6", variant: "sol", label: "Sol", priority: 1 }, + "gpt-5.6-terra": { family: "GPT 5/6", variant: "terra", label: "Terra", priority: 2 }, + "gpt-5.6-luna": { family: "GPT 5/6", variant: "luna", label: "Luna", priority: 3 }, + "claude-fable-5-1": { family: "Claude 5", variant: "fable", label: "Fable", priority: 0 }, + "claude-opus-5": { family: "Claude 5", variant: "opus", label: "Opus", priority: 1 }, + "claude-sonnet-5": { family: "Claude 5", variant: "sonnet", label: "Sonnet", priority: 2 }, +} as const; + +export type ModelVariant = (typeof MODEL_VARIANTS)[keyof typeof MODEL_VARIANTS]["variant"]; + +export function getModelPickerVariant(slug: string, driverKind: string, isCustom = false) { + if (isCustom) return null; + const key = slug.toLowerCase(); + if (!Object.hasOwn(MODEL_VARIANTS, key)) return null; + const variant = MODEL_VARIANTS[key as keyof typeof MODEL_VARIANTS]; + const expectedDriver = variant.family === "Claude 5" ? "claudeAgent" : "codex"; + return driverKind === expectedDriver ? variant : null; +} + +export function getModelPickerFamilyDisplayModel< + T extends { slug: string; name: string; isCustom?: boolean }, +>(model: T, driverKind: string): T { + const variant = getModelPickerVariant(model.slug, driverKind, model.isCustom); + return variant ? { ...model, name: variant.family, shortName: variant.family } : model; +} + +/** Keep each instance's active variant when presenting a family as one entry. */ +export function collapseModelVariants( + models: ReadonlyArray, + activeInstanceId: string, + activeModel: string, +): T[] { + const representativeByInstance = new Map(); + const familyKey = (model: T, family: string) => JSON.stringify([model.instanceId, family]); + for (const model of models) { + const variant = getModelPickerVariant(model.slug, model.driverKind, model.isCustom); + if (!variant) continue; + const isActive = + model.instanceId === activeInstanceId && + model.slug.toLowerCase() === activeModel.toLowerCase(); + const priority = isActive ? -1 : variant.priority; + const key = familyKey(model, variant.family); + const current = representativeByInstance.get(key); + if (!current || priority < current.priority) { + representativeByInstance.set(key, { model, priority }); + } + } + + const collapsedFamilies = new Set(); + return models.flatMap((model) => { + const variant = getModelPickerVariant(model.slug, model.driverKind, model.isCustom); + if (!variant) return [model]; + const key = familyKey(model, variant.family); + if (collapsedFamilies.has(key)) return []; + collapsedFamilies.add(key); + const representative = representativeByInstance.get(key)?.model; + return representative + ? [getModelPickerFamilyDisplayModel(representative, representative.driverKind)] + : []; + }); +} diff --git a/apps/web/src/components/desktop/DesktopNotificationBootstrap.tsx b/apps/web/src/components/desktop/DesktopNotificationBootstrap.tsx new file mode 100644 index 000000000000..28b205d70854 --- /dev/null +++ b/apps/web/src/components/desktop/DesktopNotificationBootstrap.tsx @@ -0,0 +1,85 @@ +import { useAtomValue } from "@effect/atom-react"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useEffectEvent } from "react"; + +import { subscribeDesktopNotificationEnvironment } from "../../desktopNotifications.subscription"; +import { isElectron } from "../../env"; +import { useClientSettings, useClientSettingsHydrated } from "../../hooks/useSettings"; +import { appAtomRegistry } from "../../rpc/atomRegistry"; +import { environmentCatalog } from "../../connection/catalog"; +import { useEnvironments } from "../../state/environments"; +import { environmentShell } from "../../state/shell"; + +function DesktopNotificationEnvironmentObserver({ + environmentId, +}: { + readonly environmentId: EnvironmentId; +}) { + const isHydrated = useClientSettingsHydrated(); + const isEnabled = useClientSettings((settings) => settings.desktopNotificationsEnabled); + const connection = Option.getOrNull( + AsyncResult.value(useAtomValue(environmentCatalog.stateAtom(environmentId))), + ); + const generation = connection?.generation ?? 0; + const isConnected = connection?.phase === "connected"; + + useEffect(() => { + const bridge = window.desktopBridge; + if (!bridge?.showDesktopNotification || !isHydrated || !isEnabled || !isConnected) return; + return subscribeDesktopNotificationEnvironment({ + registry: appAtomRegistry, + shellAtom: environmentShell.stateValueAtom(environmentId), + environmentId, + generation, + deliver: (event) => { + void bridge.showDesktopNotification(event).catch(() => undefined); + }, + }); + }, [environmentId, generation, isConnected, isEnabled, isHydrated]); + + return null; +} + +function DesktopNotificationNavigation() { + const navigate = useNavigate(); + const consumeTarget = useEffectEvent(() => { + const bridge = window.desktopBridge; + if (!bridge?.consumePendingDesktopNotificationTarget) return; + void bridge + .consumePendingDesktopNotificationTarget() + .then((target) => { + if (target === null) return; + return navigate({ + to: "/$environmentId/$threadId", + params: target, + }); + }) + .catch(() => undefined); + }); + + useEffect(() => { + const bridge = window.desktopBridge; + if (!bridge?.onDesktopNotificationTargetAvailable) return; + const unsubscribe = bridge.onDesktopNotificationTargetAvailable(consumeTarget); + consumeTarget(); + return unsubscribe; + }, []); + + return null; +} + +export function DesktopNotificationBootstrap() { + const { environments } = useEnvironments(); + if (!isElectron) return null; + return ( + <> + + {environments.map(({ environmentId }) => ( + + ))} + + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index d93db8d970b1..88630b7f7d7c 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -10,6 +10,7 @@ import * as Duration from "effect/Duration"; import { describe, expect, it } from "vite-plus/test"; import { backgroundActivitySharedPolicySettings, + buildGeneralSettingsRestorePatch, buildProviderInstanceUpdatePatch, formatDiagnosticsDescription, getChangedBrowserSettingLabels, @@ -151,6 +152,12 @@ describe("project grouping toggle", () => { }); }); +describe("buildGeneralSettingsRestorePatch", () => { + it("restore_desktopNotifications_returnsDisabled", () => { + expect(buildGeneralSettingsRestorePatch().desktopNotificationsEnabled).toBe(false); + }); +}); + describe("formatDiagnosticsDescription", () => { it("collapses trace and metric URLs that share the same OTEL base path", () => { expect( diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 5cbcb190a97b..5306f25935ad 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -202,6 +202,24 @@ export function backgroundActivitySharedPolicySettings( }; } +export function buildGeneralSettingsRestorePatch(): Partial { + return { + timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, + wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, + diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, + sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, + desktopNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.desktopNotificationsEnabled, + enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, + automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, + defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, + newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, + addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, + confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, + confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, + textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, + }; +} + function collapseOtelSignalsUrl(input: { readonly tracesUrl: string; readonly metricsUrl: string; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 4c108b01d0c8..1956928dbaba 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -523,6 +523,9 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode ? ["Project Grouping"] : []), + ...(settings.hideNewProjectButton !== DEFAULT_UNIFIED_SETTINGS.hideNewProjectButton + ? ["Hide new project button"] + : []), ...(settings.sidebarAutoSettleAfterDays !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? ["Auto-settle inactive threads"] @@ -552,6 +555,10 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming ? ["Stream token by token"] : []), + ...(settings.desktopNotificationsEnabled !== + DEFAULT_UNIFIED_SETTINGS.desktopNotificationsEnabled + ? ["Desktop notifications"] + : []), ...(settings.enableProviderUpdateChecks !== DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks ? ["Provider update checks"] @@ -622,12 +629,15 @@ export function useSettingsRestore(onRestored?: () => void) { settings.glassOpacity, settings.panelAnimationDurationMs, settings.enableLegacyTokenStreaming, + settings.desktopNotificationsEnabled, + settings.automaticGitFetchInterval, settings.enableProviderUpdateChecks, settings.continueThreadsAfterServerUpdate, settings.sidebarAutoSettleAfterDays, settings.sidebarAutoSettleOnMerge, settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, + settings.hideNewProjectButton, settings.showSkillsInSlashMenu, settings.timestampFormat, settings.wordWrap, @@ -709,11 +719,13 @@ export function useSettingsRestore(onRestored?: () => void) { showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll, contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, + desktopNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.desktopNotificationsEnabled, environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, + hideNewProjectButton: DEFAULT_UNIFIED_SETTINGS.hideNewProjectButton, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, @@ -2112,6 +2124,32 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + hideNewProjectButton: DEFAULT_UNIFIED_SETTINGS.hideNewProjectButton, + }) + } + /> + ) : null + } + control={ + + updateSettings({ hideNewProjectButton: Boolean(checked) }) + } + aria-label="Hide new project button" + /> + } + /> + {supportsAutoSettlement ? ( <> + {isElectron ? ( + updateSettings({ desktopNotificationsEnabled: false })} + /> + ) : null + } + control={ + + updateSettings({ desktopNotificationsEnabled: Boolean(checked) }) + } + aria-label="Enable desktop notifications" + /> + } + /> + ) : null} + { targetId: "browser-profiles", }); }); + + it("routes the new project button preference to General", () => { + expect(searchSettings("hide new project button")[0]).toMatchObject({ + id: "hide-new-project-button", + to: "/settings/general", + }); + }); }); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index e32baf6f3987..fc9398cd2e2f 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -162,6 +162,11 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", searchTerms: ["combine matching repositories environments sidebar"], }, + { + id: "hide-new-project-button", + title: "Hide new project button", + to: "/settings/general", + }, { id: "auto-settle-inactive-threads", title: "Auto-settle inactive threads", diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 4f115a751422..572af80c47ce 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -14,7 +14,6 @@ import { useEnvironments } from "../../state/environments"; import { T3Wordmark } from "../T3Wordmark"; import { resolveEnvironmentIdentificationPillLabel, - resolveSidebarStageBackdropVariant, resolveSidebarStageFocusRingOffsetClass, SidebarStageBackdrop, useEnvironmentStageLabel, @@ -31,6 +30,7 @@ import { } from "../ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences"; +import { SidebarUsageItem } from "./SidebarUsageItem"; import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill"; @@ -41,10 +41,7 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ }) { const stageLabel = useEnvironmentStageLabel(); const environmentIdentificationMode = useEnvironmentIdentificationMode(); - const backdropVariant = resolveSidebarStageBackdropVariant( - stageLabel, - environmentIdentificationMode === "artwork", - ); + const backdropVariant = "nightly"; const pillLabel = environmentIdentificationMode === "pill" ? resolveEnvironmentIdentificationPillLabel(stageLabel) @@ -170,12 +167,15 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { void navigate({ to: "/settings" }); }, [closeMobileSidebar, navigate]); - const handleUsageClick = useCallback(() => { - if (isMobile) { - setOpenMobile(false); - } - void navigate({ to: "/usage" }); - }, [isMobile, navigate, setOpenMobile]); + // Each entry point pins its view rather than reopening wherever the page was left. + const handleCostsClick = useCallback(() => { + closeMobileSidebar(); + void navigate({ to: "/usage", search: { metric: "cost" } }); + }, [closeMobileSidebar, navigate]); + const handleLimitsClick = useCallback(() => { + closeMobileSidebar(); + void navigate({ to: "/usage", search: { metric: "limits" } }); + }, [closeMobileSidebar, navigate]); const handleBackClick = useCallback(() => { closeMobileSidebar(); @@ -187,37 +187,41 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { }, [canGoBack, closeMobileSidebar, navigate]); return ( - - {currentFooterPage ? ( - - - - Back - - - ) : ( - <> - } - label="Settings" - onClick={handleSettingsClick} - /> - {pullRequestsSupported ? ( + <> + {/* Quota is worth knowing wherever you are, so this outlives the Back row. */} + + + {currentFooterPage ? ( + + + + Back + + + ) : ( + <> } - label="Pull Requests" - onClick={handlePullRequestsClick} + icon={} + label="Settings" + onClick={handleSettingsClick} /> - ) : null} - } - label="Usage" - onClick={handleUsageClick} - /> - - )} - - + {pullRequestsSupported ? ( + } + label="Pull Requests" + onClick={handlePullRequestsClick} + /> + ) : null} + } + label="Usage" + onClick={handleCostsClick} + /> + + )} + + + ); }); diff --git a/apps/web/src/components/sidebar/SidebarUsageItem.tsx b/apps/web/src/components/sidebar/SidebarUsageItem.tsx new file mode 100644 index 000000000000..7f7b0b91c505 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarUsageItem.tsx @@ -0,0 +1,86 @@ +import { useAtomValue } from "@effect/atom-react"; +import { ChartNoAxesColumnIcon } from "lucide-react"; +import { useMemo } from "react"; + +import { environmentPresentations } from "../../state/presentation"; +import { getDriverOption } from "../settings/providerDriverMeta"; +import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; +import { type SidebarUsageEntry, resolveSidebarUsage } from "./sidebarUsage"; + +function entrySummary(entry: SidebarUsageEntry): string { + const label = getDriverOption(entry.driver)?.label ?? String(entry.driver); + const windows = entry.windows.map( + (window) => + `${window.label} ${window.remainingPercent}% left${window.reset === null ? "" : `, resets ${window.reset}`}`, + ); + return `${label}: ${windows.join(". ")}`; +} + +/** + * One provider's line, as `23% 3h 10m` and `Week: 20% 27 Sept`. Hidden from + * assistive tech, which reads the whole item's summary instead. + */ +function UsageLine({ entry }: { readonly entry: SidebarUsageEntry }) { + const Icon = getDriverOption(entry.driver)?.icon ?? ChartNoAxesColumnIcon; + return ( + + {/* Wrapped so the button's `[&>svg]` rule cannot repaint it in the + contrast-boosted icon colour. */} + + + + + {entry.windows.map((window) => ( + + {window.prefix === null ? null : ( + {window.prefix}: + )} + {window.remainingPercent}% + {window.reset === null ? null : {window.reset}} + + ))} + + + ); +} + +/** Named apart from the Usage control beside it, which means costs. */ +const HEADING = "Usage limits"; + +/** + * Subscription quota for every provider that reports it, as one control: the + * lines are a single reading, not a menu, and all of them lead to the same + * place. Usage → Limits is where the accounts behind a pooled figure live. + */ +export function SidebarUsageItem({ onSelect }: { readonly onSelect: () => void }) { + const presentations = useAtomValue(environmentPresentations.presentationsAtom); + // Recomputed only when the snapshots change; the countdowns read against the + // provider's own checkedAt, so they need no clock of their own here. + const entries = useMemo(() => resolveSidebarUsage(presentations), [presentations]); + if (entries.length === 0) { + return null; + } + const summary = `${HEADING}. ${entries.map(entrySummary).join(". ")}`; + return ( + + + + {summary} + + {HEADING} + + {entries.map((entry) => ( + + ))} + + + + ); +} diff --git a/apps/web/src/components/sidebar/sidebarUsage.test.ts b/apps/web/src/components/sidebar/sidebarUsage.test.ts new file mode 100644 index 000000000000..1842dae35056 --- /dev/null +++ b/apps/web/src/components/sidebar/sidebarUsage.test.ts @@ -0,0 +1,221 @@ +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + type ServerProviderUsageWindow, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveSidebarUsage } from "./sidebarUsage"; + +/** Every fixture reports at noon, which is the clock the figures read against. */ +const CHECKED_AT = "2026-09-09T12:00:00.000Z"; + +const session = { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: 77, + windowDurationMins: 300, + resetsAt: "2026-09-09T15:20:00.000Z", +} as const satisfies ServerProviderUsageWindow; + +const weekly = { + id: "seven_day", + kind: "weekly", + label: "Weekly", + usedPercent: 80, + windowDurationMins: 7 * 24 * 60, + resetsAt: "2026-09-27T09:00:00.000Z", +} as const satisfies ServerProviderUsageWindow; + +const monthly = { + id: "primary", + kind: "monthly", + label: "Monthly", + usedPercent: 20, + windowDurationMins: 30 * 24 * 60, + resetsAt: "2026-10-20T09:00:00.000Z", +} as const satisfies ServerProviderUsageWindow; + +function provider( + driver: string, + windows: readonly ServerProviderUsageWindow[], + email: string, + checkedAt: string = CHECKED_AT, +): ServerProvider { + return { + instanceId: ProviderInstanceId.make(driver), + driver: ProviderDriverKind.make(driver), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated", email }, + checkedAt, + models: [], + slashCommands: [], + skills: [], + usageLimits: { checkedAt, windows }, + }; +} + +function presentations(providers: readonly ServerProvider[]) { + return new Map([ + [ + EnvironmentId.make("env-a"), + { entry: { target: { label: "Laptop" } }, serverConfig: { providers } }, + ], + ]) as never; +} + +describe("resolveSidebarUsage", () => { + it("labels the longer windows and dates each by its own reset", () => { + expect( + resolveSidebarUsage( + presentations([provider("codex", [session, weekly, monthly], "a@b.com")]), + ), + ).toEqual([ + { + driver: "codex", + windows: [ + { + id: "session:five_hour", + label: "Session", + prefix: null, + remainingPercent: 23, + reset: "3h 20m", + }, + { + id: "weekly:seven_day", + label: "Weekly", + prefix: "Week", + remainingPercent: 20, + reset: "27 Sept", + }, + { + id: "monthly:primary", + label: "Monthly", + prefix: "Month", + remainingPercent: 80, + reset: "20 Oct", + }, + ], + }, + ]); + }); + + it("orders windows session first however the provider reported them", () => { + const entries = resolveSidebarUsage( + presentations([provider("codex", [monthly, weekly, session], "a@b.com")]), + ); + expect(entries[0]?.windows.map((window) => window.prefix)).toEqual([null, "Week", "Month"]); + }); + + it("counts down from when the provider reported, not from render time", () => { + // The same window read an hour earlier has an hour more of session left. + expect( + resolveSidebarUsage( + presentations([provider("codex", [session], "a@b.com", "2026-09-09T11:00:00.000Z")]), + ), + ).toMatchObject([{ windows: [{ reset: "4h 20m" }] }]); + }); + + it("reads against the freshest account when they were probed apart", () => { + const stale = provider("codex", [session], "one@example.com", "2026-09-09T09:00:00.000Z"); + const fresh = { + ...provider("codex", [session], "two@example.com"), + instanceId: ProviderInstanceId.make("work"), + }; + expect(resolveSidebarUsage(presentations([stale, fresh]))).toMatchObject([ + { windows: [{ reset: "3h 20m" }] }, + ]); + }); + + it("counts minutes under the hour and calls a lapsed reset now", () => { + const soon = { ...session, resetsAt: "2026-09-09T12:40:00.000Z" } as const; + const lapsed = { ...session, resetsAt: "2026-09-09T11:40:00.000Z" } as const; + expect( + resolveSidebarUsage(presentations([provider("codex", [soon], "a@b.com")])), + ).toMatchObject([{ windows: [{ reset: "40m" }] }]); + expect( + resolveSidebarUsage(presentations([provider("codex", [lapsed], "a@b.com")])), + ).toMatchObject([{ windows: [{ reset: "now" }] }]); + }); + + it("shows a bare percentage for a window kind we have no word for", () => { + const other = { ...weekly, id: "credits", kind: "other", label: "Credits" } as const; + expect( + resolveSidebarUsage(presentations([provider("codex", [other], "a@b.com")])), + ).toMatchObject([ + { windows: [{ label: "Credits", prefix: null, remainingPercent: 20, reset: "27 Sept" }] }, + ]); + }); + + it("keeps only the first window of a kind, since a row fits two figures", () => { + const opus = { + ...weekly, + id: "seven_day_opus", + label: "Weekly (Opus)", + usedPercent: 95, + } as const; + const entries = resolveSidebarUsage( + presentations([provider("claudeAgent", [session, weekly, opus], "a@b.com")]), + ); + expect(entries[0]?.windows.map((window) => window.id)).toEqual([ + "session:five_hour", + "weekly:seven_day", + ]); + }); + + it("pools one account reported by two environments instead of counting it twice", () => { + const laptop = provider("codex", [weekly], "dev@example.com"); + expect( + resolveSidebarUsage( + new Map([ + [ + EnvironmentId.make("env-a"), + { entry: { target: { label: "Laptop" } }, serverConfig: { providers: [laptop] } }, + ], + [ + EnvironmentId.make("env-b"), + { entry: { target: { label: "Desktop" } }, serverConfig: { providers: [laptop] } }, + ], + ]) as never, + ), + ).toMatchObject([{ driver: "codex", windows: [{ remainingPercent: 20 }] }]); + }); + + it("averages the pool across distinct accounts on the same provider", () => { + const entries = resolveSidebarUsage( + presentations([ + provider("codex", [weekly], "one@example.com"), + { + ...provider("codex", [{ ...weekly, usedPercent: 40 }], "two@example.com"), + instanceId: ProviderInstanceId.make("work"), + }, + ]), + ); + // (80 + 40) / 2 spent, so 40 points of the pool are left. + expect(entries).toMatchObject([{ windows: [{ remainingPercent: 40 }] }]); + }); + + it("has nothing to show when a probe failed", () => { + const failed = provider("codex", [], "dev@example.com"); + expect( + resolveSidebarUsage( + presentations([ + { + ...failed, + usageLimits: { + checkedAt: CHECKED_AT, + windows: [], + unavailable: { reason: "probeFailed" }, + }, + }, + ]), + ), + ).toEqual([]); + }); +}); diff --git a/apps/web/src/components/sidebar/sidebarUsage.ts b/apps/web/src/components/sidebar/sidebarUsage.ts new file mode 100644 index 000000000000..5a9e147882ca --- /dev/null +++ b/apps/web/src/components/sidebar/sidebarUsage.ts @@ -0,0 +1,102 @@ +import type { ServerProvider, ServerProviderUsageWindow } from "@t3tools/contracts"; +import { + collectLimitAccounts, + collectLimitPools, + formatDuration, + type LimitAccount, + type LimitPoolWindow, +} from "@t3tools/shared/usageLimits"; + +const DAY = 24 * 60 * 60 * 1_000; + +const resetDateFormatter = new Intl.DateTimeFormat("en-GB", { + day: "numeric", + month: "short", +}); + +/** The session leads the line and needs no naming; the longer windows do. */ +const KIND_PREFIX: Record = { + session: null, + weekly: "Week", + monthly: "Month", + other: null, +}; + +export interface SidebarUsageWindow { + readonly id: string; + /** The provider's own name for the window, for the spoken summary. */ + readonly label: string; + readonly prefix: string | null; + readonly remainingPercent: number; + /** `3h 10m` while the reset is less than a day out, else `27 Sept`. */ + readonly reset: string | null; +} + +export interface SidebarUsageEntry { + readonly driver: ServerProvider["driver"]; + /** Session first, then weekly, monthly, other: one window per kind. */ + readonly windows: readonly SidebarUsageWindow[]; +} + +/** + * One row per provider that reports subscription limits, pooled from the same + * accounts Usage → Limits draws so the two views can never disagree. + * + * A provider can report several windows of one kind (Claude prices its + * model-scoped allowances as extra weeklies), and a sidebar row fits two + * figures. Only the first window of each kind makes it in; the page is where + * the rest live. + */ +export function resolveSidebarUsage( + presentations: Parameters[0], +): readonly SidebarUsageEntry[] { + const accounts = collectLimitAccounts(presentations); + const now = freshestCheckedAt(accounts); + return collectLimitPools(accounts, now).flatMap((pool) => { + const seen = new Set(); + const windows = pool.windows.flatMap((window) => { + if (seen.has(window.kind)) { + return []; + } + seen.add(window.kind); + return [ + { + id: `${window.kind}:${window.id}`, + label: window.label, + prefix: KIND_PREFIX[window.kind], + remainingPercent: window.remainingPercent, + reset: formatReset(window, now), + }, + ]; + }); + return windows.length === 0 ? [] : [{ driver: pool.driver, windows }]; + }); +} + +/** + * The clock the figures are read against: when the provider last reported + * them, not when this render happened. The sidebar outlives any page, so a + * mount-time clock would drift and overstate every countdown. + */ +function freshestCheckedAt(accounts: readonly LimitAccount[]): number { + let freshest = 0; + for (const account of accounts) { + const at = Date.parse(account.limits.checkedAt); + if (Number.isFinite(at) && at > freshest) { + freshest = at; + } + } + return freshest; +} + +function formatReset(window: LimitPoolWindow, now: number): string | null { + const at = window.resets[0]?.at; + if (at === undefined) { + return null; + } + const remaining = at - now; + if (remaining <= 0) { + return "now"; + } + return remaining >= DAY ? resetDateFormatter.format(at) : formatDuration(remaining); +} diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index e41843e6d9cc..df5cbc7b1480 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -38,6 +38,12 @@ vi.mock("react", async (importOriginal) => { }; }); +// Rendered without a router, so the page's own metric preference decides the +// view: `useSearch` stands in for a URL that pins nothing. +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => vi.fn(), + useSearch: () => undefined, +})); vi.mock("../../env", () => ({ isElectron: false })); vi.mock("../../state/usage", () => ({ useUsage: testState.useUsage })); vi.mock("../ui/button", () => ({ Button: "button" })); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index deb05f266b98..53e4b4f2b01e 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -11,6 +11,7 @@ import { CircleDashedIcon, SlidersHorizontalIcon, } from "lucide-react"; +import { useNavigate, useSearch } from "@tanstack/react-router"; import { useMemo, useRef, useState } from "react"; import { @@ -68,14 +69,15 @@ import { type UsagePagePreferences, } from "./usagePagePreferences"; -type UsageMetric = UsageChartMetric | "limits"; +export type UsageMetric = UsageChartMetric | "limits"; const METRIC_OPTIONS = [ { value: "cost", label: "Cost" }, { value: "tokens", label: "Tokens" }, { value: "limits", label: "Limits" }, ] as const satisfies readonly { value: UsageMetric; label: string }[]; -function isUsageMetric(value: string | null | undefined): value is UsageMetric { +/** Narrows a routed or stored value onto the metrics the page can show. */ +export function isUsageMetric(value: unknown): value is UsageMetric { return METRIC_OPTIONS.some((option) => option.value === value); } @@ -91,6 +93,7 @@ function isUsageWindowDays(value: number): value is UsagePagePreferences["window } export function UsagePage() { + const navigate = useNavigate(); const [preferences, setPreferences] = useState(readUsagePagePreferences); const [windowSelection, setWindowSelection] = useState(() => ({ days: preferences.windowDays, @@ -100,7 +103,9 @@ export function UsagePage() { preferences.windowDays === 1 ? "hour" : "day", ), })); - const metric = preferences.metric; + // A routed metric wins over the stored one, so a link that promises limits lands on limits. + const routedMetric = useSearch({ from: "/usage", select: (search) => search.metric }); + const metric = routedMetric ?? preferences.metric; const showingLimits = metric === "limits"; const [isRefreshing, setIsRefreshing] = useState(false); const refreshingRef = useRef(false); @@ -161,6 +166,10 @@ export function UsagePage() { const nextPreferences = { metric: nextMetric, windowDays }; setPreferences(nextPreferences); saveUsagePagePreferences(nextPreferences); + // Only when the URL pinned one, so a plain visit keeps its address bar clean. + if (routedMetric !== undefined && routedMetric !== nextMetric) { + void navigate({ to: "/usage", search: { metric: nextMetric }, replace: true }); + } }; const refreshWindow = () => { if (refreshingRef.current) return; diff --git a/apps/web/src/desktopNotifications.logic.test.ts b/apps/web/src/desktopNotifications.logic.test.ts new file mode 100644 index 000000000000..e6edaaed9d8c --- /dev/null +++ b/apps/web/src/desktopNotifications.logic.test.ts @@ -0,0 +1,154 @@ +import { + EnvironmentId, + ThreadId, + TurnId, + type OrchestrationSessionStatus, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + EMPTY_DESKTOP_NOTIFICATION_TRACKER_STATE, + reduceDesktopNotificationObservation, + type DesktopNotificationThread, + type DesktopNotificationTrackerState, +} from "./desktopNotifications.logic"; + +const ENVIRONMENT_ID = EnvironmentId.make("primary"); +const THREAD_ID = ThreadId.make("thread-1"); +const TURN_ID = TurnId.make("turn-1"); + +function makeThread( + input: { + readonly turnState?: "running" | "interrupted" | "completed" | "error" | null; + readonly sessionStatus?: OrchestrationSessionStatus; + readonly approval?: boolean; + readonly userInput?: boolean; + readonly archived?: boolean; + readonly updatedAt?: string; + } = {}, +): DesktopNotificationThread { + const turnState = input.turnState ?? null; + return { + id: THREAD_ID, + updatedAt: input.updatedAt ?? "2026-07-14T10:00:00.000Z", + archivedAt: input.archived ? "2026-07-14T10:00:00.000Z" : null, + latestTurn: + turnState === null + ? null + : { + turnId: TURN_ID, + state: turnState, + requestedAt: "2026-07-14T09:59:00.000Z", + startedAt: "2026-07-14T09:59:01.000Z", + completedAt: turnState === "running" ? null : "2026-07-14T10:00:00.000Z", + assistantMessageId: null, + }, + session: + input.sessionStatus === undefined + ? null + : { + threadId: THREAD_ID, + status: input.sessionStatus, + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: input.sessionStatus === "running" ? TURN_ID : null, + lastError: null, + updatedAt: "2026-07-14T10:00:00.000Z", + }, + hasPendingApprovals: input.approval ?? false, + hasPendingUserInput: input.userInput ?? false, + }; +} + +function observe( + state: DesktopNotificationTrackerState, + threads: ReadonlyArray, + syncKey = "1:1", +) { + return reduceDesktopNotificationObservation(state, { + active: true, + syncKey, + environmentId: ENVIRONMENT_ID, + threads, + }); +} + +function baseline(thread: DesktopNotificationThread) { + return observe(EMPTY_DESKTOP_NOTIFICATION_TRACKER_STATE, [thread]).state; +} + +describe("desktop notification transition reduction", () => { + it("reduce_bootstrapWithAttention_emitsNothing", () => { + const result = observe(EMPTY_DESKTOP_NOTIFICATION_TRACKER_STATE, [ + makeThread({ turnState: "completed", approval: true, userInput: true }), + ]); + expect(result.events).toEqual([]); + }); + + it("reduce_runningToCompleted_emitsOnce", () => { + const running = makeThread({ sessionStatus: "running" }); + const settling = makeThread({ sessionStatus: "ready" }); + const completed = makeThread({ turnState: "completed", sessionStatus: "ready" }); + const intermediate = observe(baseline(running), [settling]); + const result = observe(intermediate.state, [completed]); + expect(intermediate.events).toEqual([]); + expect(result.events.map((event) => event.kind)).toEqual(["turn-completed"]); + expect(observe(result.state, [completed]).events).toEqual([]); + }); + + it("reduce_runningToError_emitsOnce", () => { + const running = makeThread({ sessionStatus: "running" }); + const failed = makeThread({ turnState: "error", sessionStatus: "error" }); + expect(observe(baseline(running), [failed]).events.map((event) => event.kind)).toEqual([ + "turn-failed", + ]); + }); + + it("reduce_newApprovalAndInput_emitsEachOnce", () => { + const idle = makeThread(); + const pending = makeThread({ + approval: true, + userInput: true, + updatedAt: "2026-07-14T10:01:00.000Z", + }); + const result = observe(baseline(idle), [pending]); + expect(result.events.map((event) => event.kind)).toEqual([ + "approval-required", + "user-input-required", + ]); + expect(observe(result.state, [pending]).events).toEqual([]); + }); + + it("reduce_reconnectOrReseed_rebaselinesWithoutEvents", () => { + const running = makeThread({ turnState: "running", sessionStatus: "running" }); + const completed = makeThread({ turnState: "completed", sessionStatus: "ready" }); + const state = baseline(running); + expect(observe(state, [completed], "2:1").events).toEqual([]); + expect(observe(state, [completed], "1:2").events).toEqual([]); + }); + + it("reduce_archivedOrRemoved_suppressesEvents", () => { + const running = makeThread({ turnState: "running", sessionStatus: "running" }); + const state = baseline(running); + expect(observe(state, [makeThread({ turnState: "completed", archived: true })]).events).toEqual( + [], + ); + expect(observe(state, []).events).toEqual([]); + }); + + it("reduce_partialSettledThread_emitsNothing", () => { + const result = observe(baseline(makeThread()), [makeThread({ turnState: "completed" })]); + expect(result.events).toEqual([]); + }); + + it("reduce_inactive_resetsTracker", () => { + const state = baseline(makeThread({ turnState: "running", sessionStatus: "running" })); + const result = reduceDesktopNotificationObservation(state, { + active: false, + syncKey: "1:1", + environmentId: ENVIRONMENT_ID, + threads: [], + }); + expect(result).toEqual({ state: EMPTY_DESKTOP_NOTIFICATION_TRACKER_STATE, events: [] }); + }); +}); diff --git a/apps/web/src/desktopNotifications.logic.ts b/apps/web/src/desktopNotifications.logic.ts new file mode 100644 index 000000000000..d19742a9f561 --- /dev/null +++ b/apps/web/src/desktopNotifications.logic.ts @@ -0,0 +1,174 @@ +import type { + DesktopNotificationEvent, + EnvironmentId, + OrchestrationThreadShell, + ThreadId, +} from "@t3tools/contracts"; + +export type DesktopNotificationThread = Pick< + OrchestrationThreadShell, + | "id" + | "updatedAt" + | "archivedAt" + | "latestTurn" + | "session" + | "hasPendingApprovals" + | "hasPendingUserInput" +>; + +interface ThreadAttentionState { + readonly threadId: ThreadId; + readonly updatedAt: string; + readonly turnId: string | null; + readonly turnState: "running" | "interrupted" | "completed" | "error" | null; + readonly runningTurnId: string | null; + readonly isTurnActive: boolean; + readonly hasPendingApprovals: boolean; + readonly hasPendingUserInput: boolean; +} + +export interface DesktopNotificationTrackerState { + readonly syncKey: string | null; + readonly threads: ReadonlyMap; +} + +export interface DesktopNotificationObservation { + readonly active: boolean; + readonly syncKey: string; + readonly environmentId: EnvironmentId; + readonly threads: ReadonlyArray; +} + +export interface DesktopNotificationReduction { + readonly state: DesktopNotificationTrackerState; + readonly events: ReadonlyArray; +} + +export const EMPTY_DESKTOP_NOTIFICATION_TRACKER_STATE: DesktopNotificationTrackerState = { + syncKey: null, + threads: new Map(), +}; + +function toAttentionState(thread: DesktopNotificationThread): ThreadAttentionState | null { + if (thread.archivedAt !== null) return null; + return { + threadId: thread.id, + updatedAt: thread.updatedAt, + turnId: thread.latestTurn?.turnId ?? null, + turnState: thread.latestTurn?.state ?? null, + runningTurnId: + thread.session?.activeTurnId ?? + (thread.latestTurn?.state === "running" ? thread.latestTurn.turnId : null), + isTurnActive: thread.session?.status === "starting" || thread.session?.status === "running", + hasPendingApprovals: thread.hasPendingApprovals, + hasPendingUserInput: thread.hasPendingUserInput, + }; +} + +function collectAttentionStates(threads: ReadonlyArray) { + const states = new Map(); + for (const thread of threads) { + const state = toAttentionState(thread); + if (state !== null) states.set(thread.id, state); + } + return states; +} + +function preserveSettlingTurn( + previous: ThreadAttentionState, + next: ThreadAttentionState, +): ThreadAttentionState { + if (next.runningTurnId !== null || previous.runningTurnId === null) return next; + const isSettled = next.turnId === previous.runningTurnId && next.turnState !== "running"; + return isSettled ? next : { ...next, runningTurnId: previous.runningTurnId }; +} + +function reconcileAttentionStates( + previous: ReadonlyMap, + next: ReadonlyMap, +) { + return new Map( + [...next].map(([threadId, state]) => { + const prior = previous.get(threadId); + return [threadId, prior === undefined ? state : preserveSettlingTurn(prior, state)] as const; + }), + ); +} + +function makeEvent( + environmentId: EnvironmentId, + state: ThreadAttentionState, + kind: DesktopNotificationEvent["kind"], + identity: string, +): DesktopNotificationEvent { + return { + eventId: `${environmentId}:${state.threadId}:${kind}:${identity}`, + kind, + environmentId, + threadId: state.threadId, + }; +} + +function deriveTurnEvent( + environmentId: EnvironmentId, + previous: ThreadAttentionState, + next: ThreadAttentionState, +): DesktopNotificationEvent | null { + const sameRunningTurn = previous.runningTurnId !== null && previous.runningTurnId === next.turnId; + if (!sameRunningTurn || next.isTurnActive) return null; + if (next.turnState === "completed") { + return makeEvent(environmentId, next, "turn-completed", next.turnId); + } + if (next.turnState === "error") { + return makeEvent(environmentId, next, "turn-failed", next.turnId); + } + return null; +} + +function derivePendingEvents( + environmentId: EnvironmentId, + previous: ThreadAttentionState, + next: ThreadAttentionState, +): DesktopNotificationEvent[] { + const events: DesktopNotificationEvent[] = []; + if (!previous.hasPendingApprovals && next.hasPendingApprovals) { + events.push(makeEvent(environmentId, next, "approval-required", next.updatedAt)); + } + if (!previous.hasPendingUserInput && next.hasPendingUserInput) { + events.push(makeEvent(environmentId, next, "user-input-required", next.updatedAt)); + } + return events; +} + +function deriveThreadEvents( + environmentId: EnvironmentId, + previous: ThreadAttentionState, + next: ThreadAttentionState, +): DesktopNotificationEvent[] { + const turnEvent = deriveTurnEvent(environmentId, previous, next); + return [ + ...(turnEvent === null ? [] : [turnEvent]), + ...derivePendingEvents(environmentId, previous, next), + ]; +} + +export function reduceDesktopNotificationObservation( + state: DesktopNotificationTrackerState, + observation: DesktopNotificationObservation, +): DesktopNotificationReduction { + if (!observation.active) { + return { state: EMPTY_DESKTOP_NOTIFICATION_TRACKER_STATE, events: [] }; + } + const observedThreads = collectAttentionStates(observation.threads); + if (state.syncKey !== observation.syncKey) { + return { state: { syncKey: observation.syncKey, threads: observedThreads }, events: [] }; + } + const threads = reconcileAttentionStates(state.threads, observedThreads); + const events = [...threads].flatMap(([threadId, next]) => { + const previous = state.threads.get(threadId); + return previous === undefined + ? [] + : deriveThreadEvents(observation.environmentId, previous, next); + }); + return { state: { syncKey: observation.syncKey, threads }, events }; +} diff --git a/apps/web/src/desktopNotifications.subscription.test.ts b/apps/web/src/desktopNotifications.subscription.test.ts new file mode 100644 index 000000000000..2ec15bd6117b --- /dev/null +++ b/apps/web/src/desktopNotifications.subscription.test.ts @@ -0,0 +1,103 @@ +import type { EnvironmentShellState } from "@t3tools/client-runtime/state/shell"; +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { subscribeDesktopNotificationEnvironment } from "./desktopNotifications.subscription"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const THREAD_ID = ThreadId.make("thread-1"); +const TURN_ID = TurnId.make("turn-1"); + +const BASE_THREAD = { + id: THREAD_ID, + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-07-14T10:00:00.000Z", + updatedAt: "2026-07-14T10:00:02.000Z", + archivedAt: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + settledOverride: null, + settledAt: null, +} as const; + +function makeTurn(state: "running" | "completed") { + return { + turnId: TURN_ID, + state, + requestedAt: "2026-07-14T10:00:00.000Z", + startedAt: "2026-07-14T10:00:01.000Z", + completedAt: state === "running" ? null : "2026-07-14T10:00:02.000Z", + assistantMessageId: null, + }; +} + +function makeSession(state: "running" | "completed") { + return { + threadId: THREAD_ID, + status: state === "running" ? ("running" as const) : ("ready" as const), + providerName: "Codex", + runtimeMode: "full-access" as const, + activeTurnId: state === "running" ? TURN_ID : null, + lastError: null, + updatedAt: "2026-07-14T10:00:02.000Z", + }; +} + +function makeThread(state: "idle" | "running" | "completed"): OrchestrationThreadShell { + if (state === "idle") return { ...BASE_THREAD, latestTurn: null, session: null }; + return { ...BASE_THREAD, latestTurn: makeTurn(state), session: makeSession(state) }; +} + +function makeShell(thread: OrchestrationThreadShell): EnvironmentShellState { + return { + snapshot: Option.some({ + snapshotSequence: 1, + updatedAt: thread.updatedAt, + projects: [], + threads: [thread], + }), + status: "live", + error: Option.none(), + baselineRevision: 1, + }; +} + +describe("desktop notification subscription", () => { + it("subscribe_backToBackTurnUpdates_observesRunningTransition", () => { + const shellAtom = Atom.make(makeShell(makeThread("idle"))); + const registry = AtomRegistry.make(); + const deliver = vi.fn(); + const unsubscribe = subscribeDesktopNotificationEnvironment({ + registry, + shellAtom, + environmentId: ENVIRONMENT_ID, + generation: 1, + deliver, + }); + + registry.set(shellAtom, makeShell(makeThread("running"))); + registry.set(shellAtom, makeShell(makeThread("completed"))); + + expect(deliver).toHaveBeenCalledOnce(); + expect(deliver).toHaveBeenCalledWith(expect.objectContaining({ kind: "turn-completed" })); + unsubscribe(); + registry.dispose(); + }); +}); diff --git a/apps/web/src/desktopNotifications.subscription.ts b/apps/web/src/desktopNotifications.subscription.ts new file mode 100644 index 000000000000..5b96431c4ec6 --- /dev/null +++ b/apps/web/src/desktopNotifications.subscription.ts @@ -0,0 +1,44 @@ +import type { EnvironmentShellState } from "@t3tools/client-runtime/state/shell"; +import type { DesktopNotificationEvent, EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import type { Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { + EMPTY_DESKTOP_NOTIFICATION_TRACKER_STATE, + reduceDesktopNotificationObservation, +} from "./desktopNotifications.logic"; + +interface DesktopNotificationSubscriptionOptions { + readonly registry: AtomRegistry.AtomRegistry; + readonly shellAtom: Atom.Atom; + readonly environmentId: EnvironmentId; + readonly generation: number; + readonly deliver: (event: DesktopNotificationEvent) => void; +} + +function readThreads(shell: EnvironmentShellState) { + return Option.match(shell.snapshot, { + onNone: () => [], + onSome: (snapshot) => snapshot.threads, + }); +} + +export function subscribeDesktopNotificationEnvironment( + options: DesktopNotificationSubscriptionOptions, +): () => void { + let tracker = EMPTY_DESKTOP_NOTIFICATION_TRACKER_STATE; + return options.registry.subscribe( + options.shellAtom, + (shell) => { + const reduction = reduceDesktopNotificationObservation(tracker, { + active: shell.status === "live", + syncKey: `${options.generation}:${shell.baselineRevision}`, + environmentId: options.environmentId, + threads: readThreads(shell), + }); + tracker = reduction.state; + for (const event of reduction.events) options.deliver(event); + }, + { immediate: true }, + ); +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 23b1e4c58265..9f68c87c3335 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -23,6 +23,7 @@ import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstall import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator"; import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator"; +import { DesktopNotificationBootstrap } from "../components/desktop/DesktopNotificationBootstrap"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; @@ -198,6 +199,7 @@ function RootRouteView() { + {primaryEnvironmentAuthenticated ? ( diff --git a/apps/web/src/routes/usage.tsx b/apps/web/src/routes/usage.tsx index c617e434b2e1..324320d43483 100644 --- a/apps/web/src/routes/usage.tsx +++ b/apps/web/src/routes/usage.tsx @@ -1,7 +1,14 @@ import { createFileRoute } from "@tanstack/react-router"; -import { UsagePage } from "../components/usage/UsagePage"; +import { UsagePage, isUsageMetric, type UsageMetric } from "../components/usage/UsagePage"; + +/** Absent, the page opens on the stored preference. */ +export interface UsageSearch { + readonly metric?: UsageMetric; +} export const Route = createFileRoute("/usage")({ + validateSearch: (raw: Record): UsageSearch => + isUsageMetric(raw.metric) ? { metric: raw.metric } : {}, component: UsagePage, }); diff --git a/apps/web/src/state/shell.test.ts b/apps/web/src/state/shell.test.ts index 745e674400d3..5b18105fce96 100644 --- a/apps/web/src/state/shell.test.ts +++ b/apps/web/src/state/shell.test.ts @@ -17,6 +17,7 @@ const REMOTE = EnvironmentId.make("remote"); function shellState(status: EnvironmentShellState["status"]): EnvironmentShellState { return { status, + baselineRevision: 0, snapshot: status === "empty" ? Option.none() diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index b8d2aef40697..68b597511791 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -147,6 +147,7 @@ function shellState(snapshot: OrchestrationShellSnapshot): EnvironmentShellState snapshot: Option.some(snapshot), status: "live", error: Option.none(), + baselineRevision: 1, }; } diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 0d933c39f8ba..a9222391d917 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -148,7 +148,18 @@ describe("environment shell synchronization", () => { const state = yield* SubscriptionRef.get(shellState); expect(state.status).toBe("live"); + expect(state.baselineRevision).toBe(1); expect(Option.getOrThrow(state.snapshot)).toEqual(LIVE_SHELL_SNAPSHOT); + + yield* Queue.offer(events, { + kind: "snapshot", + snapshot: { ...LIVE_SHELL_SNAPSHOT, snapshotSequence: 2 }, + }); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((next) => next.baselineRevision === 2), + Stream.runHead, + ); + expect((yield* SubscriptionRef.get(shellState)).baselineRevision).toBe(2); }), ); diff --git a/packages/client-runtime/src/state/shell.test.ts b/packages/client-runtime/src/state/shell.test.ts index f1326e0a5cbe..0aafe1286460 100644 --- a/packages/client-runtime/src/state/shell.test.ts +++ b/packages/client-runtime/src/state/shell.test.ts @@ -41,6 +41,7 @@ function shellState(input: { }), status: input.status, error: input.error === undefined ? Option.none() : Option.some(input.error), + baselineRevision: 0, }; } diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index 95d90f9b36f2..bad674ee098a 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -33,12 +33,14 @@ export interface EnvironmentShellState { readonly snapshot: Option.Option; readonly status: EnvironmentShellStatus; readonly error: Option.Option; + readonly baselineRevision: number; } const EMPTY_SHELL_STATE: EnvironmentShellState = { snapshot: Option.none(), status: "empty", error: Option.none(), + baselineRevision: 0, }; function shellStatusForSnapshot( @@ -70,6 +72,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") snapshot: cachedSnapshot, status: shellStatusForSnapshot(cachedSnapshot), error: Option.none(), + baselineRevision: 0, }); const awaitingCompletion = yield* Ref.make(false); const lastAuthoritativeSession = yield* Ref.make(null); @@ -167,6 +170,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") receivedSnapshot ||= item.kind === "snapshot"; next = { snapshot: Option.some(nextSnapshot), + baselineRevision: item.kind === "snapshot" ? next.baselineRevision + 1 : next.baselineRevision, status: waiting ? "synchronizing" : "live", error: Option.none(), }; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index cd906aecdfce..2a0693444996 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -88,7 +88,7 @@ import type { OrchestrationThreadStreamItem, } from "./orchestration.ts"; import { SnapShotSource } from "./orchestration.ts"; -import { EnvironmentId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { BrowserProfileId } from "./browserProfile.ts"; import type { BrowserImportResult, @@ -1210,6 +1210,36 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ */ export const SystemSettingsPaneSchema = Schema.Literals(["full-disk-access"]); export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; +export const DesktopNotificationKindSchema = Schema.Literals([ + "turn-completed", + "turn-failed", + "approval-required", + "user-input-required", +]); +export type DesktopNotificationKind = typeof DesktopNotificationKindSchema.Type; + +export const DesktopNotificationEventSchema = Schema.Struct({ + eventId: Schema.String.check(Schema.isTrimmed(), Schema.isNonEmpty()), + kind: DesktopNotificationKindSchema, + environmentId: EnvironmentId, + threadId: ThreadId, +}); +export type DesktopNotificationEvent = typeof DesktopNotificationEventSchema.Type; + +export const DesktopNotificationTargetSchema = Schema.Struct({ + environmentId: EnvironmentId, + threadId: ThreadId, +}); +export type DesktopNotificationTarget = typeof DesktopNotificationTargetSchema.Type; + +export const DesktopNotificationDeliveryStatusSchema = Schema.Literals([ + "shown", + "disabled", + "unsupported", + "duplicate", + "failed", +]); +export type DesktopNotificationDeliveryStatus = typeof DesktopNotificationDeliveryStatusSchema.Type; export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; @@ -1229,6 +1259,11 @@ export interface DesktopBridge { getLocalEnvironmentBearerToken: () => Promise; getClientSettings: () => Promise; setClientSettings: (settings: ClientSettings) => Promise; + showDesktopNotification: ( + event: DesktopNotificationEvent, + ) => Promise; + consumePendingDesktopNotificationTarget: () => Promise; + onDesktopNotificationTargetAvailable: (listener: () => void) => () => void; getConnectionCatalog?: () => Promise; setConnectionCatalog?: (catalog: string) => Promise; clearConnectionCatalog?: () => Promise; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 7d3cd2ceafe7..bc8de4615c3e 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -6,12 +6,20 @@ import { ClientSettingsSchema, ClientSettingsPatch, ClaudeSettings, + DEFAULT_CLIENT_SETTINGS, DEFAULT_SERVER_SETTINGS, resolveProviderInstanceEnabled, ServerSettings, ServerSettingsPatch, } from "./settings.ts"; +describe("ClientSettings desktop notifications", () => { + it("decode_legacySettings_defaultsDisabled", () => { + expect(decodeClientSettings({}).desktopNotificationsEnabled).toBe(false); + expect(DEFAULT_CLIENT_SETTINGS.desktopNotificationsEnabled).toBe(false); + }); +}); + const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema); const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch); const encodeClientSettings = Schema.encodeSync(ClientSettingsSchema); @@ -474,6 +482,15 @@ describe("ClientSettings pull request merge methods", () => { }); }); +describe("ClientSettings new project button", () => { + it("shows the button by default and accepts the hide preference", () => { + expect(decodeClientSettings({}).hideNewProjectButton).toBe(false); + expect(decodeClientSettingsPatch({ hideNewProjectButton: true }).hideNewProjectButton).toBe( + true, + ); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults text generation to Luna at low reasoning effort", () => { expect(DEFAULT_SERVER_SETTINGS.textGenerationModelSelection).toEqual({ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3491103da94f..4e8c875fb667 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -330,6 +330,9 @@ export const ClientSettingsSchema = Schema.Struct({ confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadUnpin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + desktopNotificationsEnabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), @@ -429,6 +432,7 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), ), + hideNewProjectButton: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), @@ -1311,6 +1315,7 @@ export const ClientSettingsPatch = Schema.Struct({ confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), confirmThreadUnpin: Schema.optionalKey(Schema.Boolean), + desktopNotificationsEnabled: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), diffLayout: Schema.optionalKey(DiffLayout), environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), @@ -1362,6 +1367,7 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), + hideNewProjectButton: Schema.optionalKey(Schema.Boolean), timestampFormat: Schema.optionalKey(TimestampFormat), snapShotEnabled: Schema.optionalKey(Schema.Boolean), snapShotIncludeAccessibility: Schema.optionalKey(Schema.Boolean), diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index b601d3997fd7..de8f14d9319d 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -264,7 +264,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }); it("switches desktop packaging product names to nightly for nightly builds", () => { - assert.equal(resolveDesktopProductName("0.0.17"), "T3 Code (Alpha)"); + assert.equal(resolveDesktopProductName("0.0.17"), "T3 Code"); assert.equal(resolveDesktopProductName("0.0.17-nightly.20260413.42"), "T3 Code (Nightly)"); });